feat(mcp): gate Connected-but-unattached MCP namespaces (Closes #708) #967

Merged
sysadmin merged 6 commits from feat/issue-708-mcp-namespace-attachment into master 2026-07-28 18:31:14 -05:00
6 Commits
Author SHA1 Message Date
jcwalker3andClaude Opus 4.8 58bd852188 docs(remote-mcp): restamp the #956 threat model onto the commit it resolves at (review 637 B1)
Commit 126d76a set docs/remote-mcp/threat-model-anchors.json
generated_against_commit to e3fa3b26 and left docs/remote-mcp/threat-model.md
citing a143cd06, so the document and the fixture named different source
revisions. tests/test_issue_956_threat_model.py asserts the fixture value
appears in the document, and that assertion failed from 126d76a onward.

The mismatch was not cosmetic. Resolving all 58 anchors against each candidate
revision shows 0 unresolved at e3fa3b26 and 21 unresolved at a143cd06, so the
document's own claim about where its file:line citations resolve was false.
The fixture held the accurate value and the document held the stale one.

The B2 fix in ca5f078d inserted a net 16 lines into gitea_mcp_server.py at a
single point, shifting 21 anchors below it. All 21 are re-derived here by that
one uniform offset and each was verified against its recorded expect substring
rather than assumed, so no anchor is guessed and none became ambiguous. Both
artifacts now name ca5f078d, the commit those anchors were taken at, and the
document's inline citations are shifted to match. The boundary table's claim
about what the code enforces is restamped onto the same revision, because it
described that same source tree.

Verified: 58/58 anchors resolve at the declared generation commit, 58/58 at
this head, no two anchors share a file:line location, fixture and document
metadata agree, and tests/test_issue_956_threat_model.py passes 17/17.

Refs #708

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 17:25:24 -05:00
jcwalker3andClaude Opus 4.8 ca5f078d8a fix(mcp): separate not-connected from Connected-but-unattached (review 637 B2)
A required namespace absent from the connected-service inventory was never
counted as missing, because missing_namespaces was populated only while
walking connected_servers. The session therefore reported attachment_healthy
true, discovery_status namespaces_attached and error_type None while holding
no attachment proof for that namespace, and the gate text told the operator
"the host reports Connected" about a service nothing had reported Connected.

assess_connected_namespace_attachment now classifies the two conditions
apart. missing_namespaces keeps its meaning: required, Connected at the host,
absent from the session tool surface -- the actual #708 defect, still typed
mcp_connected_namespaces_missing. Required namespaces absent from the
connected inventory land in the new not_connected_namespaces list, typed
mcp_required_namespaces_not_connected, so a caller is never pointed at an
attachment recovery for a service that never connected. attachment_healthy
is false whenever any required namespace lacks attachment proof under either
condition, error_types reports every condition present so neither hides the
other, and namespace_conditions carries the per-namespace connected/attached
verdict. Evidence that disagrees with itself -- attached in the session yet
absent from the connected inventory -- is reported as contradictory and fails
closed rather than being read as proof.

attachment_gate_from_session states only what the recorded evidence supports:
the Connected wording appears solely when connected is true, the not-connected
wording when it is false, and a neutral refusal when connected status was
never recorded. Review and merge stay fail-closed in all three cases.

_record_live_namespace_attachment consumes the per-namespace verdicts instead
of pinning the session-wide error_type onto every unattached namespace and
instead of deriving health from a missing list that tracked only one of the
two conditions.

The two existing cases in test_issue_708_mcp_namespace_attachment.py declared
no required_namespaces, so they inherited a default set containing a namespace
their connected list omitted. Under the corrected rule that is a false-healthy
assertion, so each now declares the required set it actually means.

Closes #708

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 17:08:44 -05:00
sysadminandClaude Opus 4.8 126d76ad28 docs(remote-mcp): restamp the commit the #956 anchors resolve at
The anchors were re-derived against the #708 wiring commit; record that SHA so
the fixture states the tree its line numbers were taken from.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 16:46:37 -04:00
sysadminandClaude Opus 4.8 e3fa3b263d feat(mcp): gate Connected-but-unattached MCP namespaces (Closes #708)
The prior #708 slice added assess_connected_namespace_attachment() as a pure
decision function with no call site: nothing invoked it, so a session whose
role namespaces were Connected at the host but absent from the active session
tool surface still passed every mutation gate. Detection existed on paper only.

This makes it load-bearing.

Decision layer (mcp_namespace_health.py)
- assess_connected_namespace_attachment() gains secret-free telemetry
  (connected/attached/required/missing counts, discovery cache hit and age,
  reconnect_required, auto_attach_attempted, auto_recovered, error_type) and
  reports reconnect_required, auto_recovered and startup_ordering_race.
- Startup ordering: a namespace whose connect completed after the session tool
  snapshot cannot be in that snapshot, so parallel multi-role startup is
  identified as its own race with the affected namespaces listed.
- attachment_gate_from_session() is a fail-closed gate keyed by
  ATTACHMENT_GATED_TASKS. An unassessed namespace does not gate, matching #543
  semantics, so this never fabricates a block.
- SANCTIONED_ATTACH_RECOVERY_TOOL names gitea_request_mcp_reconnect (#678) as
  the only recovery.

Server wiring (gitea_mcp_server.py)
- New tool gitea_assess_mcp_namespace_attachment classifies the condition and
  records a per-namespace verdict in _LIVE_NAMESPACE_ATTACHMENT.
- gitea_submit_pr_review and gitea_merge_pr now consult
  _namespace_attachment_gate() alongside the existing #543 health gate, so both
  fail closed while a required namespace is unattached.
- Watchdog check-in emits status only, never namespace contents.

The typed condition mcp_connected_namespaces_missing stays distinct from config
drift (#672), transport-closed (#584) and resolver EOF (#685). Recovery never
routes through direct imports, CLI or raw API mutation, profile hopping,
session-state overrides, or process kills.

Docs
- docs/mcp-namespace-health.md documents the tool arguments, startup ordering,
  the fail-closed gate, and the telemetry contract.
- skills/llm-project-workflow/SKILL.md states Connected is not attached, and
  that preflight proof is live tool visibility plus gitea_whoami on the role
  namespace rather than host status alone.
- docs/mcp-tool-inventory.md lists the new tool.
- docs/remote-mcp/threat-model-anchors.json and threat-model.md: 21 #956 anchors
  restamped for the line shift these additions caused in gitea_mcp_server.py.
  Every anchor was re-derived from its recorded expect substring; none guessed.

Tests
- tests/test_issue_708_attachment_wiring.py (19 cases): typed detection, proof
  mapping, reconnect-only next action, auto-attach success and failure,
  reconnect rediscovery, multi-role startup ordering, telemetry including a
  no-secret-leak assertion, fail-closed gate per role, unassessed and unmapped
  tasks not gating, partial attachment gating only the affected role, and no
  healthy verdict without attachment proof.

Verification
- tests/test_issue_708_attachment_wiring.py + test_issue_708_mcp_namespace_attachment.py: 24 passed
- namespace/session/runtime/review sweep: 427 passed, 12 subtests
- full suite head: 31 failed, 5885 passed, 6 skipped, 1047 subtests
- full suite base 17ba1ff035: 30 failed, 5862 passed, 6 skipped, 1047 subtests
- failing identifier sets match, plus tests/test_mirror_refs.py DryRunBanner,
  which fails on the unmodified base in isolation and passes here: flaky, not a
  regression from this branch.
- Gate proven by execution, not inspection: registering the assessment blocks
  merge_pr and review_pr, and attaching the namespaces clears the block.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 16:46:09 -04:00
sysadmin 4533e86bcd Merge branch 'master' into feat/issue-708-mcp-namespace-attachment 2026-07-28 16:20:13 -04:00
sysadmin 08d9cf4cbd feat(mcp): detect Connected-but-namespaces-missing attachment failures (Closes #708) 2026-07-25 19:33:18 -04:00