stack: MCP submit-Tool mit Upload Review Gate und Quarantäne-Schreibpfad (schliesst #32)
CI / verify (push) Successful in 53s
Release / release (push) Successful in 36s

Files changed:
- .gitignore
- AGENTS.md
- CHANGES.md
- INSTALL-MCP.md
- README.md
- VERSION
- docs/why-gates-are-code.md
- instructions/gates.md
- instructions/ingest-queue.md
- instructions/mcp-read-server.md
- instructions/wiki-ingest/SKILL.md
- raw/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/upload_cmd.py
- tools/chemenu/config.py
- tools/chemenu/mcp/server.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_mcp_server.py
- tools/chemenu/tests/test_upload.py
- tools/chemenu/tests/test_upload_cmd.py
- tools/chemenu/upload.py
This commit is contained in:
torben committed 2026-09-11 09:51:37 +02:00
1 parent 4781140375
commit 828521861d
24 files changed
+1866 -56

No files matched your search

+8
View File
@@ -120,6 +120,14 @@ REQUIRED_IGNORE_CANARIES = (
# backstop a few lines above. Flat since Gitea #67 - incoming/ no longer
# has type subdirectories, so the probe sits directly in it.
"incoming/probe.pdf",
# The MCP `submit` tool's quarantine (Gitea #32) - stronger than
# `incoming/` above: read by no command in the ordinary pipeline, not
# only uncommitted. No type subdirectory either, for the same reason.
"mcp-upload/probe.pdf",
# The `submit` tool's opt-in, same shape as `.wikitool-remotes.json`/
# `.wikitool-telemetry.json` a few lines below - per-checkout, never
# committed.
".wikitool-upload.json",
)
REQUIRED_TRACKED_PATHS = (
"reports/CONTRACT.md",
+40 -2
View File
@@ -376,6 +376,43 @@ def check_telemetry() -> Check:
)
def check_upload_intake() -> Check:
"""Whether the MCP `submit` tool is armed for this checkout, and how full
its quarantine is.
Absent is the *safe* default here, unlike `check_publish_remotes`'s "any
push target passes" absence: no `.wikitool-upload.json` means the write
path does not exist at all, not that it is unrestricted - so this never
`FAIL`s on a missing file. It does `FAIL` on one that parses to something
invalid, because a broken opt-in must not silently disable the very
limits it exists to enforce.
"""
from chemenu import upload as upload_module
from chemenu.errors import ValidationError
try:
cfg = upload_module.read_config(config.ROOT)
except ValidationError as exc:
return Check(
"upload-intake", "FAIL", str(exc),
f"Fix or delete {config.UPLOAD_CONFIG_FILENAME} - a broken one is not treated as "
"'no limits'",
)
if cfg is None:
return Check(
"upload-intake", "OK",
f"submit tool not registered - no {config.UPLOAD_CONFIG_FILENAME}",
)
pending = upload_module.list_submissions(config.ROOT)
return Check(
"upload-intake", "OK",
f"submit tool armed (identity header {cfg.identity_header!r}, up to "
f"{cfg.max_bytes:,} byte(s), {cfg.submissions_per_day}/day and "
f"{cfg.bytes_per_day:,} byte(s)/day per submitter); "
f"{len(pending)} submission(s) waiting in {rel_path(config.UPLOAD_DIR)}",
)
def check_session_id() -> Check:
import os
@@ -478,6 +515,7 @@ def run_doctor() -> list[Check]:
check_conventions(),
check_environment(),
check_publish_remotes(),
check_upload_intake(),
check_generated_files(),
check_session_id(),
check_telemetry(),
@@ -490,8 +528,8 @@ def doctor_command(
):
"""Check that this instance is correctly configured: dependencies, author,
git identity/remote, published skills, structure, personalization, KB
conventions, generated files, session scoping, and telemetry state.
Read-only. Exits 1 only
conventions, generated files, session scoping, telemetry state, and
whether the MCP `submit` tool is armed. Read-only. Exits 1 only
if a check FAILs."""
checks = run_doctor()
+122
View File
@@ -0,0 +1,122 @@
"""`wikitool upload list|show|accept|reject` - the human review side of the
MCP submission quarantine (Gitea #32).
Everything that decides *whether* a submission was accepted or written at all
lives in `chemenu.upload` - importable from the MCP server, stdlib only. What
lives here instead is CLI-only by construction: the **Upload Review Gate**
(`accept` refuses with Exit 42 until a human has seen the submission and
re-runs with the printed `--confirm` token), and the two read commands a
reviewer uses to look before clearing it. None of the four is importable
from `chemenu.mcp.server` - they sit under `chemenu.commands`, the same
boundary every other write command is already kept out by (AGENTS.md
invariant 6, `instructions/gates.md`).
"""
from __future__ import annotations
import json as json_module
from typing import Optional
import typer
from chemenu import config, upload
from chemenu.commands._util import fail, needs_clearance, rel_path, success
from chemenu.errors import ValidationError
app = typer.Typer(help="Review, promote or reject MCP submissions waiting in mcp-upload/.")
def _call(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except ValidationError as exc:
fail(str(exc))
@app.command("list")
def upload_list_command(
json_out: bool = typer.Option(False, "--json", help="Print every waiting submission as JSON"),
):
"""List every submission currently waiting in mcp-upload/, oldest first."""
manifests = _call(upload.list_submissions, config.ROOT)
if json_out:
typer.echo(json_module.dumps(manifests, indent=2))
return
if not manifests:
typer.echo("Nothing is waiting in mcp-upload/.")
return
for manifest in manifests:
typer.echo(
f"{manifest['id']} {manifest['filename']} {manifest['size']}B "
f"from {manifest['submitter']}"
)
@app.command("show")
def upload_show_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
json_out: bool = typer.Option(False, "--json"),
):
"""Print one submission's manifest in full - what a reviewer checks
before `accept`."""
manifest = _call(upload.read_manifest, config.ROOT, submission_id)
if json_out:
typer.echo(json_module.dumps(manifest, indent=2))
return
for key in ("id", "filename", "size", "sha256", "submitter", "submitter_source", "submitted_at"):
typer.echo(f"{key}: {manifest.get(key)}")
def _clearance_message(manifest: dict, token: str, stale: Optional[str]) -> str:
lines = [
f"Upload Review Gate: submission '{manifest['id']}' needs a human to look at it "
"before it is promoted into incoming/.",
"",
f" filename: {manifest['filename']}",
f" size: {manifest['size']} bytes",
f" sha256: {manifest['sha256']}",
f" submitter: {manifest['submitter']}",
f" submitter_source: {manifest['submitter_source']}",
f" submitted_at: {manifest['submitted_at']}",
"",
]
if stale:
lines.append(
f"The token you passed ({stale}) does not match this submission - its manifest "
"changed, or the token was invented."
)
lines.append("")
lines.append(
"Nothing was promoted. Check this against raw/CONTRACT.md \"What does not belong "
"here\" and instructions/ingest-queue.md, then re-run with the token below:"
)
lines.append("")
lines.append(f" tools/wikitool upload accept {manifest['id']} --confirm {token}")
return "\n".join(lines)
@app.command("accept")
def upload_accept_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
confirm: Optional[str] = typer.Option(
None, "--confirm", help="The token from a prior refusal, once a human has reviewed it"
),
):
"""Promote a submission into incoming/ - refuses with Exit 42 until a
human has seen the manifest and cleared it with `--confirm <token>`."""
manifest = _call(upload.read_manifest, config.ROOT, submission_id)
token = upload.confirm_token(manifest)
if confirm != token:
needs_clearance(_clearance_message(manifest, token, confirm))
return
dest = _call(upload.promote, config.ROOT, submission_id)
success(f"Promoted '{submission_id}' to {rel_path(dest)}.")
@app.command("reject")
def upload_reject_command(
submission_id: str = typer.Argument(..., help="A submission id from `upload list`"),
reason: str = typer.Option(..., "--reason", help="Why this submission was declined"),
):
"""Delete a submission's material, keeping its ledger trail."""
_call(upload.reject, config.ROOT, submission_id, reason)
success(f"Rejected '{submission_id}': {reason}")