Files
Gitea-Tools/docs/remote-mcp/coupling-inventory.md
T
sysadminandClaude Opus 5 97bc190fc2 docs(remote-mcp): inventory stdio- and localhost-coupled assumptions (#930)
Add docs/remote-mcp/coupling-inventory.md, the blocking first child of epic
#929. It enumerates every place gitea_mcp_server.py and its supporting modules
depend on being a local, client-spawned, stdio-attached process on the
operator's machine.

62 entries across the seven required categories: transport bind, launch
provenance, role binding, credentials, runtime freshness, local filesystem,
and durable state. Each entry carries a file and line anchor resolving at
7bf4f12584, states what the code assumes today
and what it would observe on a remote host, is classified as portable as
written / needs a seam / needs a replacement / cannot be remote, and is
assigned to exactly one epic child. Every child from #931 through #939 is
named by at least one entry. Summary tables count entries per category, per
classification, per category-by-classification, and per child.

Documentation only. No server behavior changes.

Closes #930

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HvJz7bUz5CkZgUxq8twHMz
2026-07-26 02:09:09 -04:00

25 KiB
Raw Blame History

Remote-MCP coupling inventory

Every place the Gitea MCP server depends on being a local, client-spawned, stdio-attached process on the operator's machine.

  • Issue: #930 (Remote-MCP 01), child 1 of epic #929.
  • Generated against commit: 7bf4f1258451823a55b36d2157e74f8457165088 (master).
  • Anchors: every file:line below resolves at the commit above and at the commit that adds this document. This change adds one new file and edits no existing file, so no existing line number shifts between the two.
  • Scope: documentation only. No server behavior changes in this child.

How to read an entry

Field Meaning
Anchor file:line at the commit under review.
Assumes today What the code takes for granted while running as a local stdio process.
Observes remotely What the same code would actually see on a shared remote host.
Class One of: portable as written, needs a seam, needs a replacement, cannot be remote.
Owner Exactly one epic child (#931#939) responsible for the fix.

Classification meanings:

  • portable as written — the code is already transport-, host-, and principal-neutral; it moves unchanged once its inputs are supplied by a remote-aware caller.
  • needs a seam — the logic is correct but is wired to a hard-coded local source. It needs an injection point, not new semantics.
  • needs a replacement — the semantics themselves are local-only. A remote deployment needs a differently-defined mechanism, not the same mechanism relocated.
  • cannot be remote — the operation is inherently about the operator's own machine (its process table, its keychain, its checkout). It must either stay local behind an explicit boundary or be deleted from the remote surface.

1. Transport bind

The transport is bound literally, once, at process start, and the bound value is the root of the mutation-authorization chain.

ID Anchor Assumes today Observes remotely Class Owner
T1 gitea_mcp_server.py:23750 The single production bind call passes the literal transport="stdio" immediately before the server loop. The literal is wrong for any non-stdio deployment; there is no parameter to change it. needs a seam #931
T2 mcp_daemon_guard.py:45 _PRODUCTION_TRANSPORTS = frozenset({"stdio"}) is the closed allowlist of production transports. A remote transport name is rejected by the allowlist before any other check runs. needs a seam #931
T3 mcp_daemon_guard.py:174 bind_native_mcp_transport raises UnsanctionedRuntimeError for any transport outside _PRODUCTION_TRANSPORTS (raise at mcp_daemon_guard.py:187). The remote server fails to start rather than degrading; the failure is correct, but the allowlist is the only thing that must change. needs a seam #931
T4 mcp_daemon_guard.py:328 is_native_mcp_transport() asserts a process-local runtime record whose pid matches os.getpid() and whose phase is transport_bound. The predicate itself names no transport. Unchanged semantics: one server process that bound one transport. It stays true on a remote host. portable as written #931
T5 mcp_daemon_guard.py:349 is_production_native_mcp_transport() adds only a mode == production check on top of T4. Unchanged. portable as written #931
T6 irrecoverable_provenance.py:497 assess_transport_for_auth_mint() requires production native transport before minting non-forgeable recovery authorization (#709 F1). The gate is transport-agnostic in form, but its guarantee — "an ordinary Python process cannot reach this" — is currently underwritten by the stdio bind. Under a remote transport the guarantee must be re-derived from the authenticated session, not from the bind. needs a seam #931
T7 gitea_mcp_server.py:8375 Consumer: refuses to proceed unless assess_transport_for_auth_mint() allows. Unchanged given a corrected T6. portable as written #931
T8 gitea_mcp_server.py:8624 Second consumer of the same gate on the confirmation path. Unchanged given a corrected T6. portable as written #931
T9 mcp_server.py:4 Module docstring asserts "Runs over stdio." as a property of the server. The stated contract becomes false on the remote deployment and is load-bearing documentation for operators. needs a replacement #931

2. Launch provenance

Mutations fail closed unless the process can prove a client launched it with real stdio pipes and GITEA_CLIENT_MANAGED provenance. Every proof in this section is a statement about the local operating system.

ID Anchor Assumes today Observes remotely Class Owner
P1 gitea_mcp_server.py:14588 _is_client_managed_process() derives provenance from GITEA_CLIENT_MANAGED / GITEA_MCP_CLIENT_MANAGED / GITEA_SERVER_PROVENANCE / GITEA_FORCE_CLIENT_MANAGED on this process's own environment. A long-lived remote process has one environment for all callers, so a per-process env var can no longer say anything about the caller that issued a request. needs a replacement #934
P2 gitea_mcp_server.py:14606 Falls back to sys.stdin.isatty(): an active TTY on stdin means a human launched it from a terminal, so refuse. A remote server has no meaningful stdin. The signal is absent, not merely different. cannot be remote #934
P3 gitea_mcp_server.py:14618 _provenance_mutation_block() emits blocker_kind: "unsupported_manual_launch" and a "reconnect the IDE/client-managed MCP namespace" remediation. The block shape is reusable; its predicate and its remediation text are both stdio-specific. needs a seam #934
P4 gitea_mcp_server.py:20599 _check_mcp_runtimes_diagnostics() shells ps -o pid,lstart,command -ax and greps for mcp_server.py to find peer role servers. On a shared host the process table lists unrelated tenants' processes, or none at all under a container. Peer discovery by ps has no remote meaning. cannot be remote #934
P5 gitea_mcp_server.py:20702 More than one process per GITEA_MCP_PROFILE in the local process table is reported as a duplicate-launch fault. A remote endpoint is expected to serve many concurrent sessions per role. "Two processes for one role" becomes the normal case, so the check inverts from a safety net into a false wall. cannot be remote #934
P6 gitea_mcp_server.py:20715 Processes lacking client-managed provenance are ignored for runtime freshness and reported as manual launches. Same defect as P5: correctness depends on enumerating local peers. cannot be remote #934
P7 gitea_config.py:1172 RECOGNIZED_GITEA_ENV_KEYS is the allowlist of GITEA_* env vars a legitimately launched server may carry; anything else is contamination. Configuration on a remote host arrives from deployment tooling, not from a client-authored env block. The allowlist keeps working mechanically but stops proving anything about provenance. needs a replacement #934
P8 gitea_mcp_server.py:20683 The unsupported-env scan applies RECOGNIZED_GITEA_ENV_KEYS to other processes' environments harvested via ps eww <pid>. Reading another process's environment is unavailable or prohibited across tenants, and is not exposed in this form outside macOS/BSD ps. cannot be remote #934
P9 mcp_daemon_guard.py:126 mark_sanctioned_daemon() requires the claiming stack frame's resolved absolute path to be the canonical mcp_server.py / gitea_mcp_server.py next to the guard module; basename spoofing is rejected. Entrypoint-path identity still exists on a remote host, but it authenticates the deployment, not the caller. It must be kept and demoted from "authorizes mutations" to "authorizes the process". needs a seam #934
P10 gitea_config.py:1233 The client-config generator emits "GITEA_CLIENT_MANAGED": "1" into each generated MCP client entry, alongside GITEA_MCP_CONFIG / GITEA_MCP_PROFILE. A remote endpoint is addressed by URL and credential, not by a spawn command with an env block. This generator produces the wrong artifact entirely. needs a replacement #938
P11 mcp_namespace_health.py:232 Namespace health classifies a namespace as client_managed or manual_launch from the reported env summary. During dual-run, local and remote namespaces coexist and must both be classifiable; a two-valued local/manual axis cannot express "remote endpoint, authenticated session". needs a replacement #939
P12 gitea_mcp_server.py:18161 The diagnostics payload reports server_provenance as exactly "client_managed" or "manual_launch". This is the field a cutover operator reads to confirm which deployment served a call. It must gain a remote value before dual-run parity can be validated. needs a replacement #939

3. Role binding

Role separation is currently enforced by which process a call reaches. The process is pinned to one role for its lifetime by an environment variable.

ID Anchor Assumes today Observes remotely Class Owner
R1 gitea_config.py:54 ENV_PROFILE = "GITEA_MCP_PROFILE" is the single source of the active profile, read from the process environment. One shared process serves several principals; a process-wide profile cannot answer "who is calling now". This is the root of the coupling. needs a replacement #932
R2 review_workflow_load.py:95 Reads GITEA_MCP_PROFILE directly to decide the reviewer workflow binding. Reads the deployment's profile, not the caller's, silently granting or denying the wrong role. needs a replacement #932
R3 mcp_discoverability.py:152 Reads GITEA_MCP_PROFILE to describe the namespace to the client. Correct logic, wrong input source; it needs the request principal injected. needs a seam #932
R4 webui/deployment_boundary.py:115 Reads GITEA_MCP_PROFILE to classify the deployment boundary for the console. Same as R3. needs a seam #932
R5 gitea_mcp_server.py:21106 Remediation text instructs the operator to "Relaunch the server with GITEA_MCP_PROFILE set to a profile that has the required permission". Relaunching a shared remote endpoint to change one caller's role is not a valid instruction; it would re-role every other session. needs a replacement #932
R6 native_mcp_preference.py:93 Detects shell commands that override GITEA_MCP_PROFILE away from the session (native_mcp_preference.py:223) and flags them as CLI auth divergence. The divergence check is genuinely useful and survives, but its notion of "the session's profile" must come from the request principal. needs a seam #932
R7 gitea_mcp_server.py:20671 Recovers a peer server's role by regexing GITEA_MCP_PROFILE= out of that process's environment. Depends on P4/P8 process-table access; role discovery by peer-env scraping has no remote analogue. cannot be remote #932

4. Credentials

Every token resolves, directly or indirectly, from one human's macOS keychain.

ID Anchor Assumes today Observes remotely Class Owner
C1 gitea_config.py:956 _keychain_token() shells security find-generic-password -s <item> -w. security(1) is a macOS binary reading the calling user's login keychain. It does not exist on a Linux host and would be the wrong identity even on a shared Mac. cannot be remote #933
C2 gitea_config.py:974 resolve_token(profile, keychain_lookup=_keychain_token) dispatches on auth.type of env or keychain, defaulting the lookup to C1. The injectable keychain_lookup parameter is the existing seam; a remote credential provider plugs in here without changing the dispatch. needs a seam #933
C3 gitea_config.py:1015 keychain_auth(item_id) constructs the {"type": "keychain", "id": ...} reference stored in profiles. The reference type itself encodes "macOS keychain" into persisted config. A remote provider needs a new auth reference type, not a new value of this one. needs a replacement #933
C4 mcp_daemon_guard.py:440 assert_keychain_access_allowed() fails closed for git-credential keychain fill outside a sanctioned daemon, with an operator opt-out env var. The gate protects a mechanism that will not exist remotely. Its replacement must gate the credential provider call, not the keychain call, or the protection silently lapses. needs a replacement #933
C5 sentry_incident_bridge.py:190 resolve_token(env) resolves the Sentry token from an injected env mapping with no keychain path. Already host-neutral; it is the shape the Gitea credential path should converge on. portable as written #933
C6 gitea_mcp_server.py:18469 The profile-audit tool calls gitea_config.resolve_token(p) for every configured profile to report "credentials present" without networking. On a remote host this would materialize every principal's credential inside one process — an audit surface that becomes a credential-aggregation risk. needs a seam #933

5. Runtime freshness

The mutation gate is defined as "the commit this process started at matches the checkout on this disk, and both match live master". Two of those three terms are local-disk facts.

ID Anchor Assumes today Observes remotely Class Owner
F1 master_parity_gate.py:168 capture_startup_parity(root) reads git HEAD from the server's own root once at startup and returns it as the baseline. A remote host carries a deployed artifact, not the operator's checkout. Its HEAD says nothing about the operator's working tree, which is the thing the gate exists to protect. cannot be remote #935
F2 master_parity_gate.py:255 mutation_safe = determinable and in_parity and live_known and not live_stale — a conjunction of two local-HEAD comparisons and one live-remote comparison. Two of the three conjuncts lose meaning, so the whole verdict does. A remote deployment needs a redefined, testable freshness predicate rather than this one relocated. needs a replacement #935
F3 master_parity_gate.py:164 The live-remote head is probed and cached per (root, remote, branch), keyed on the local root. The live-remote probe is the one conjunct that survives; it needs a key that is not the operator's filesystem path. needs a seam #935
F4 gitea_mcp_server.py:18262 gitea_assess_master_parity publishes startup_head / local_head / live_remote_head / mutation_safe as the authoritative mutation-safety verdict. The tool's contract is consumed by every mutation caller and by the operator; it must keep its shape while its semantics are redefined, or every consumer breaks at once. needs a replacement #935
F5 gitea_mcp_server.py:23054 Falls back to _process_boot_head_sha — the commit this process booted at — when the parity payload has no startup_head. Same defect as F1, in a fallback path that is easy to miss when F1 is fixed. needs a seam #935
F6 gitea_mcp_server.py:20615 Staleness is also inferred from os.path.getmtime() of gitea_mcp_server.py under PROJECT_ROOT (gitea_mcp_server.py:20611), compared against peer process start times. File mtime on a deployed artifact tracks the deploy, not the operator's edits, and the peer start times it is compared against come from the unavailable process table (P4). cannot be remote #935

6. Local filesystem

Author and reviewer tools act directly on the operator's checkout.

ID Anchor Assumes today Observes remotely Class Owner
L1 gitea_mcp_server.py:10122 gitea_bootstrap_author_issue_worktree creates and binds a git worktree on the server's own disk. The remote host has no operator checkout to add a worktree to. Executing this remotely would act on the wrong disk while reporting success. cannot be remote #936
L2 gitea_mcp_server.py:190 ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" and AUTHOR_WORKTREE_ENV (gitea_mcp_server.py:191) carry the active workspace as process-wide environment. Process-wide workspace state cannot represent per-session workspaces on a shared endpoint. needs a replacement #936
L3 gitea_mcp_server.py:9801 Binding a worktree writes os.environ["GITEA_AUTHOR_WORKTREE"] and os.environ["GITEA_ACTIVE_WORKTREE"] (gitea_mcp_server.py:9802), mutating global process state. One session's bind would silently retarget every other concurrent session in the same process. This is a correctness bug the moment concurrency is real. needs a replacement #936
L4 reviewer_inventory_worktree.py:48 _BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I) requires review worktree paths to sit under branches/. A path convention on the operator's machine, asserted as a validation rule. It needs to become a property of a declared workspace, not a substring test. needs a seam #936
L5 stable_control_runtime.py:54 DEV_WORKTREE_SEGMENT = "branches" classifies a process root as a development worktree by path segment. Same class of assumption as L4, on the runtime-classification side. needs a seam #936
L6 mcp_server.py:42 check_conflict_markers() runs at import and os.walks the install directory for unresolved conflict markers, sys.exit(1) on a hit. On a remote host it scans a deployed artifact, which by construction never has conflict markers — so the guard passes trivially and stops protecting the thing it was written to protect. needs a replacement #936
L7 role_session_router.py:487 check_mid_merge() reports infra-stop from .git/MERGE_HEAD, rebase-merge, rebase-apply and a source conflict scan under the server's project root. Same inversion as L6: it would report the deployment's git state, not the operator's. needs a replacement #936
L8 author_issue_bootstrap.py:996 Enumerates worktrees with git -C <root> worktree list --porcelain. Requires a real local clone with real worktrees; there is nothing equivalent to enumerate remotely. cannot be remote #936
L9 mcp_server.py:10 Redirects sys.stderr to the fixed path /tmp/mcp_server_stderr.log outside pytest. A single fixed /tmp path is shared by every concurrent server on a host and is not a deployment's logging surface. needs a replacement #938
L10 gitea_mcp_server.py:2314 ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" — the legacy single global lock slot. One global /tmp slot per host cannot represent concurrent remote sessions and is world-visible on a shared machine. needs a replacement #937
L11 issue_lock_provenance.py:14 ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json") keeps the same /tmp default in the provenance path. Same as L10; the env override is a local escape hatch, not a remote design. needs a replacement #937

7. Durable state

Locks, leases, session state, and the control-plane database live in the operator's home directory and are keyed on local PIDs.

ID Anchor Assumes today Observes remotely Class Owner
S1 issue_lock_store.py:26 DEFAULT_LOCK_DIR = ~/.cache/gitea-tools/issue-locks — per-issue lock files under one user's home. A shared endpoint has no single operator home; per-user paths make locks invisible across sessions and hosts. needs a replacement #937
S2 issue_lock_store.py:83 session_pointer_path() names the session pointer file session-<os.getpid()>.json. Many sessions share one PID on a remote server, so the pointer collapses to a single slot and sessions overwrite each other. cannot be remote #937
S3 issue_lock_store.py:98 is_process_alive(pid) decides lock liveness by probing the local process table. A PID recorded by one host is meaningless on another, and may coincidentally match a live unrelated process. cannot be remote #937
S4 issue_lock_store.py:213 Lock records stamp session_pid and pid from os.getpid(). The recorded identity no longer distinguishes sessions; ownership checks silently pass for the wrong caller. needs a replacement #937
S5 mcp_session_state.py:27 DEFAULT_STATE_DIR = ~/.cache/gitea-tools/session-state, mode 0o700. Same home-directory coupling as S1, for review decision locks and workflow proofs. needs a replacement #937
S6 mcp_session_state.py:559 Session bodies stamp session_pid and writer_pid from os.getpid() (mcp_session_state.py:560). Writer attribution collapses across concurrent sessions in one process. needs a replacement #937
S7 control_plane_db.py:47 DEFAULT_DB_PATH = ~/.cache/gitea-tools/control-plane/control_plane.sqlite3. A per-user SQLite file is not reachable by, or safe for, multiple remote sessions or multiple hosts. needs a replacement #937
S8 control_plane_db.py:386 sqlite3.connect(self.db_path, timeout=30) — single-writer file locking tuned for one local process. SQLite's write lock does not extend across hosts and degrades sharply under real concurrency; the store needs a concurrency-safe backend. needs a replacement #937
S9 control_plane_db.py:1145 Lease rows record owner_pid defaulting to os.getpid() (also control_plane_db.py:2039). PID-keyed lease ownership is unusable across hosts and ambiguous within one shared process. cannot be remote #937
S10 mcp_daemon_guard.py:53 _DEFAULT_SESSION_STATE_DIR is pinned once at transport bind so a later GITEA_MCP_SESSION_STATE_DIR change cannot manufacture a second authority domain (#695 AC2). The single-authority-domain invariant is exactly right and must be preserved; only its backing location needs to move. needs a seam #937
S11 gitea_mcp_server.py:11875 Reviewer-lease reclaim reads owner_pid_alive from the lease freshness record to decide whether an owner is dead. Consumes S3/S9; a false "owner alive" or "owner dead" here reclaims or refuses a live lease. This is the highest-consequence consumer of PID liveness. cannot be remote #937

Summary

Entries per category

Category Entries
1. Transport bind 9
2. Launch provenance 12
3. Role binding 7
4. Credentials 6
5. Runtime freshness 6
6. Local filesystem 11
7. Durable state 11
Total 62

No category is empty, so no "this category has no coupling" justification is required.

Entries per classification

Classification Entries
portable as written 5
needs a seam 16
needs a replacement 26
cannot be remote 15
Total 62

Category × classification

Category portable seam replacement cannot Total
1. Transport bind 4 4 1 0 9
2. Launch provenance 0 2 5 5 12
3. Role binding 0 3 3 1 7
4. Credentials 1 2 2 1 6
5. Runtime freshness 0 2 2 2 6
6. Local filesystem 0 2 7 2 11
7. Durable state 0 1 6 4 11
Total 5 16 26 15 62

Entries per epic child

Every child from 2 through 10 is named by at least one entry, and every entry names exactly one child.

Child Issue Title Entries IDs
2 #931 Transport-neutral bind seam 9 T1T9
3 #932 Per-request principal resolution 7 R1R7
4 #933 Server-side credential provider 6 C1C6
5 #934 Remote-session provenance 9 P1P9
6 #935 Redefined master-parity gate 6 F1F6
7 #936 Local-filesystem vs remotable tool split 8 L1L8
8 #937 Concurrency-safe session, lock, and lease state 13 L10, L11, S1S11
9 #938 Authenticated remote MCP endpoint 2 P10, L9
10 #939 Dual-run cutover and rollback 2 P11, P12
Total 62

Notes for downstream children

  • The three highest-risk entries are P5, F2, and S11. Each is a guard that does not merely stop working remotely — it inverts. P5 turns concurrency into a reported fault, F2 returns a verdict computed from terms that no longer mean anything, and S11 reclaims or refuses leases on a PID-liveness answer that is wrong rather than unknown. A gate that fails open while still reporting green is worse than one that fails to start.
  • T4, T5, T7, T8, and C5 are the portable core. They show the target shape: predicates over injected inputs, with no reference to the host, the process table, or the operator's disk.
  • The keychain seam already exists at C2 (resolve_token's injectable keychain_lookup). #933 should widen that seam rather than introduce a parallel path, and must remember C4 — the guard protecting the old mechanism has to be re-pointed, or the protection lapses silently when the mechanism is replaced.
  • branches/ appears as a validation rule in at least two independent places (L4, L5). Path-substring conventions tend to have more copies than expected; #936 should re-grep rather than trust this list to be exhaustive for that specific pattern.