Skip to content

Add consent gating + process lifecycle to mcp-hub - #206

Closed
C-K-Loan wants to merge 4 commits into
Blaizzy:mcp-hubfrom
C-K-Loan:mcp-hub-consent-lifecycle
Closed

Add consent gating + process lifecycle to mcp-hub#206
C-K-Loan wants to merge 4 commits into
Blaizzy:mcp-hubfrom
C-K-Loan:mcp-hub-consent-lifecycle

Conversation

@C-K-Loan

@C-K-Loan C-K-Loan commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Adds the two things flagged on #200: a consent gate for MCP tool calls (fail-closed, including when a server can't be identified) and wiring MCPHostManager.shutdown() into app termination, since it existed but nothing called it — both covered with tests against real subprocesses. Also fixes the test target, which wasn't compiling (missing NativSkill.swift), so the existing suite was being silently skipped. Nothing else touched.

The unit-test target lists NativSettings.swift explicitly, and that file
now holds a `skills: [NativSkill]` property, but NativSkill.swift itself
was never added alongside it — so the test target fails to compile and the
whole suite is skipped rather than reported as failing.

Adding the one missing source file brings the 216 existing tests back.
MCP tool calls went straight from the model to the server with no approval
step, while switch_model — the only other tool that reaches outside the
chat — has always asked first. A server configured for filesystem or shell
access could therefore write or delete without the user seeing anything
until it was done.

The existing consent branch was hardcoded to switch_model. It now asks
ChatToolConsentRequirement what a call needs, so both tools share one gate
and the approve/decline/cancel handling is written once. switch_model's
behaviour is unchanged; an approved MCP call falls through to the same
dispatch path it already used.

The gate matches MCP tools on their name prefix rather than on whether the
host can currently route them. Connections reload asynchronously and
debounced, so a name that looks unroutable at gate time can be routable by
the time it is dispatched — matching on the name alone closes that window
and keeps an unrecognized or misconfigured server gated instead of letting
it through.

The prompt now describes what it is actually approving: the tool and the
server it belongs to, rather than switch_model's "the server restarts
briefly" wording, which was shown for every gated call.
MCPHostManager.shutdown() had no callers, so every MCP server a session
started outlived the app — quitting Nativ left npx and uvx children running
until they were killed by hand.

Wiring it up needed two fixes. The manager was a @StateObject owned by
ControlPanelView, so nothing outside the view hierarchy could reach it at
termination; it now sits alongside the other app-lifetime objects on
AppDelegate and is passed down the same way extensionManager already is,
which also stops a rebuilt view from silently spawning a second set of
servers.

The second is that shutdown() handed its disconnects to a detached task.
Even called from applicationWillTerminate that loses the race — the app
exits before the task runs — so the children survived anyway. MCPClient
now keeps its process behind a lock where a nonisolated caller can reach
it, and shutdown() terminates synchronously.
The shutdown work had no tests. Mocking the transport would not have helped
— the bug it fixes is that a real child process outlives the app, which a
mock cannot reproduce.

A fixture server completes the MCP handshake, writes its pid, and then just
blocks on stdin. The tests read that pid and watch the actual OS process, so
they assert the child died rather than trusting the client's own bookkeeping
about what it thinks it terminated.

Both levels are covered: MCPClient.terminateImmediately() directly, and
MCPHostManager.shutdown() with two connected servers, which is what
applicationWillTerminate calls. Restoring either to its previous form fails
these — the manager test reports both servers surviving.

The manager test's poll is synchronous on purpose, and that is the part
worth not simplifying later: the old shutdown() handed its work to a Task
created in a main-actor context, so blocking the main actor is what makes
this a stand-in for termination. Awaiting instead would let that task run
and the test would pass against the bug it exists to catch.

The fixture echoes back whichever protocol version the client asks for
rather than pinning one, so an SDK bump does not silently break it.
@C-K-Loan C-K-Loan mentioned this pull request Aug 4, 2026
@Lazarus-931

Copy link
Copy Markdown
Collaborator

sweet, could u make this a stacked pr, easier to handle conflicts

@C-K-Loan

C-K-Loan commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Split into a stack per your suggestion: #207 (test fix) → #208 (consent gate) → #209 (lifecycle). Closing this one.

@C-K-Loan C-K-Loan closed this Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants