Skip to content

feat(mcp): add MCP server settings with project-trust gating - #893

Closed
YaoShenyaoge wants to merge 1 commit into
agegr:mainfrom
YaoShenyaoge:feat/mcp-settings
Closed

YaoShenyaoge wants to merge 1 commit into
agegr:mainfrom
YaoShenyaoge:feat/mcp-settings

Conversation

@YaoShenyaoge

Copy link
Copy Markdown

What this adds

A settings section for MCP servers, editing the two files the pi CLI and the
mcp-bridge extension already read:

  • <agentDir>/mcp.json (global)
  • <cwd>/.pi/mcp.json (project), overriding global entries by name

Pi has no built-in MCP support; the runtime comes from an extension. So this PR
deliberately only edits config, and the running session applies changes on
reload (reload re-emits session_start, which is where the extension re-reads
the file). One runtime then serves the terminal, Pi Web, and any other host,
instead of a second implementation that would only work inside this app.

The panel also shows which mcp_* tools the session actually has registered, so
"did my server start?" is answerable from the UI.

Security: why project servers are gated

While reading the plugin and trust code I noticed a gap worth calling out.

projectTrustReloadOptions correctly keeps a project's .pi/extensions from
executing (issue #236). But that gate covers extension code loading, not
config file reads. An MCP server definition is a command that gets spawned
as a local process, so a globally installed MCP extension reading
<cwd>/.pi/mcp.json bypasses the gate: clone a hostile repo, open it in Pi Web,
and the repository's command runs.

This PR closes it on both sides:

  • app/api/mcp/route.ts refuses project-scope writes while the project is
    untrusted (403), so the UI cannot be used to plant a payload either.
  • The extension itself skips project-origin servers when isProjectTrusted() is
    false, so an untrusted repository stays dormant even with a global install.

Global config is never gated. Because override is resolved per entry, a project
entry that overrides a global server becomes project-origin and is therefore
gated too — fail closed.

Reasoning is recorded in docs/adr/0004-mcp-server-config-surface.md.

Files

File
lib/mcp-config-store.ts read/write both files; atomic writes; unknown top-level and server-level keys preserved; per-entry validation (exactly one transport, http/https only, args/env types); unknown config keys are not persisted
app/api/mcp/route.ts GET ?cwd= and POST {action,cwd,scope,name,config}; standard guards (isApiRequestAllowed 403, hasJsonContentType 415, allowed-root 403); project-scope writes require project trust
components/McpConfig.tsx the settings section
lib/settings-navigation.ts adds the mcp section and exports PROJECT_SECTIONS — SettingsPanel had its own copy of that list, now shared
components/SettingsPanel.tsx section registration, icon, mount
lib/i18n/messages/{en,zh-CN,zh-TW}.ts 26 keys each
docs/adr/0004-...md the decision above

Verification

  • 33 new tests: lib/mcp-config-store.test.mjs (16), app/api/mcp/route.test.mjs (17)
  • npm test: 1102 tests / 1083 pass / 14 fail. The 14 failures are pre-existing
    and identical to a clean HEAD worktree (compared test-by-test); they are
    Windows-environment issues (symlink EPERM, unix sockets, PTY, ripgrep)
  • npm run lint: 0 problems
  • tsc --noEmit: clean

Deliberate non-goals

No "test connection" endpoint. That would let an HTTP request spawn an arbitrary
command, widening the surface this PR is trying to keep narrow; live tool names
from the session cover the same need read-only.

Open question

Two behaviours I did not touch, because they look like your call rather than
mine:

  1. withExtensionTools force-adds every non-builtin tool, so all MCP tools are
    always active — there is no way to disable an individual one, and a server
    with 30 tools adds 30 tool definitions to every request.
  2. validateSessionToolSelection rejects non-builtin names, so an MCP tool
    selection cannot be persisted across reloads.

Both sit in ADR-0002 territory. Happy to follow up if you want either changed;
I would rather ask than refactor your tool-selection policy in an unrelated PR.

Pi has no built-in MCP support: the runtime comes from an extension that reads
mcp.json. This adds a settings section that edits the same two files the CLI and
that extension read, so a single runtime serves the terminal, Pi Web, and any
other host instead of a second, app-local implementation.

- lib/mcp-config-store.ts: read/write global and project mcp.json with atomic
  writes, unknown keys preserved, and per-entry validation
- app/api/mcp/route.ts: GET/POST with the standard request guards; project-scope
  writes require project trust, mirroring the plugin route
- components/McpConfig.tsx: settings section listing configured servers plus the
  live mcp_* tools the session reports
- ADR 0004 records why the surface is config-only and why project servers are
  gated: a project mcp.json spawns a local process, which the extension-level
  project-trust gate does not cover

Also exports PROJECT_SECTIONS from lib/settings-navigation so the settings panel
no longer keeps its own copy of the project-section list.
@agegr

agegr commented Sep 30, 2026

Copy link
Copy Markdown
Owner

Thanks @YaoShenyaoge, and especially for the project-trust analysis.

I'm closing this because pi 0.99 now supports MCP natively. It reads the same ~/.pi/agent/mcp.json / .pi/mcp.json files, only reads the project file once the project is trusted, and adds /mcp, pi mcp …, OAuth and exposure modes.

For Pi Web, the remaining work is upgrading to 0.99 and wiring createMcpExtension() into our resource loader, since SDK sessions don't load pi's built-in extensions. Any settings UI should build on pi's own MCP config and status.

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