Imported from irahardianto/pi-mcp-extension (
AGENTS.md). Install upstream withnpx skills add irahardianto/pi-mcp-extension. Copyright stays with the author.
AGENTS.md — pi-mcp-extension
Instructions for AI coding agents (Pi, Claude Code, OpenCode, Codex, Cursor, Antigravity, Gemini CLI) and human contributors. Keep this file accurate: if you add or rename a src/ module, change a verification command, or change the release flow, update this file in the same change.
What this is: a TypeScript extension for the Pi coding agent that bridges MCP servers (stdio, sse, streamable-http) into Pi tools and /mcp* commands, with OAuth 2.1 (PKCE + DCR) for remote servers. Pi loads src/index.ts directly as TypeScript. There is no build step, and the npm tarball ships src/ as-is (relative imports use .js specifiers per Node16 resolution).
Commands
npm run lint # eslint . (type-aware flat config)
npm run typecheck # tsc --noEmit (strict, exactOptionalPropertyTypes, noUncheckedIndexedAccess, unused/return/override/fallthrough)
npm test # all suites: node --import tsx/esm --test ./tests/*.test.ts
npm run test:coverage # all suites with coverage floor (95% lines / 85% branches / 83% functions)
node --import tsx/esm --test tests/config.test.ts # one suite
node --import tsx/esm --test --test-name-pattern="collision" tests/tool-bridge.test.ts # matching tests
npm pack --dry-run # tarball must contain only src/, README.md, LICENSE, package.json
semgrep --test --metrics=off .semgrep/ # custom Semgrep rules against their fixtures
semgrep scan --metrics=off --config p/typescript --config p/javascript --config p/github-actions --config .semgrep/ --error # the CI scan
gitleaks git --redact -v # secret scan of the full git history (uses .gitleaks.toml)
Node 22+ (CI runs 22 and 24 on Linux, and 22 on macOS and Windows). A change is done only when lint, typecheck, test, and pack all pass.
Tests must pass on macOS and Windows too. Skip a test only when the behavior is POSIX-only by design (e.g. file modes), and only on that platform: { skip: process.platform === "win32" && "WHY: <reason>" }.
Hard rules
- Never write to
stdout(console.log,process.stdout). It belongs to Pi's TUI/RPC stream. User-facing output goes throughctx.ui.notify(...)/ctx.ui.setStatus("pi-mcp", ...); useconsole.error("[pi-mcp] ...")only when no UI context exists. - Pushing to
mainis a manual override only. Always work on a branch and open a PR. Never push tomain, publish, or tag unless the user explicitly instructs it for that change; every push tomaincan publish to npm (see Commits & release). Never runnpm publish/npm version, editversioninpackage.json, or hand-write a versionedCHANGELOG.mdheading. - No silent error swallowing. No empty
catch {}or.catch(() => {}). Handle expected codes (ENOENT,ESRCH,EEXIST) explicitly; an intentional best-effort teardown ignore needs a// WHY:comment. - Never log or surface secrets: access/refresh tokens,
client_secret, authorization codes, PKCE verifiers,Authorizationheaders. Never commit real credentials, even in fixtures. Ifgitleaksflags a real secret, revoke and rotate it first, then report it privately as described inSECURITY.md, never in a public issue or PR. - No new runtime dependencies without asking. Runtime deps are only
@modelcontextprotocol/sdkandzod. Pi packages (@earendil-works/pi-coding-agent,@earendil-works/pi-ai) andtypeboxare peer deps; never move them intodependencies, and never import the deprecated@mariozechner/*names. - No
any. Useunknown+ narrowing or Zod. Preserve existing// WHY:comments; add one whenever you work around an MCP SDK, TypeBox, or Pi quirk.
Semgrep and ESLint enforce several of these rules. Suppress a finding only with a justified inline comment (// nosemgrep: <rule-id> -- WHY: ...).
Repository map
src/ is intentionally flat. Don't create folder hierarchies or layer files (*_impl.ts, business.ts).
| File | Owns |
|---|---|
src/index.ts |
Extension entry (default export); session_start / session_shutdown; connection coordination (connectRequested, connectAttempts, authQueue, startup auth prompts); interactive authorize(); /mcp, /mcp:start, /mcp:stop, /mcp:auth; mcp_status / mcp_connect tools |
src/server-manager.ts |
Per-server lifecycle (stopped → starting → ready → failed), transport creation, retry/backoff, health checks, onclose recovery, roots/list, stdio child cleanup, sanitizeStderrLine |
src/tool-bridge.ts |
tools/list discovery, JSON Schema → TypeBox conversion, tool naming/hashing/collisions, Pi tool registration and callTool |
src/config.ts |
Zod schemas, JSONC parsing, global <agentDir>/mcp.json + project .pi/mcp.json merge (project overrides only what it states; no ${VAR} interpolation), Pi 1.x config aliases |
src/oauth-provider.ts |
MCP SDK OAuthClientProvider: silent vs. interactive modes, token/DCR/discovery persistence, refresh rotation |
src/auth-lock.ts |
Cross-process <hash>.json.lock with heartbeat and stale-lock reclaim; injectable AuthLockDeps |
src/callback-server.ts, src/callback-url.ts |
Loopback OAuth callback listener; pure redirect/callback URL validation and headless paste-back parsing |
src/auth-challenge.ts |
WWW-Authenticate discovery (RFC 9728 resource_metadata, scope) |
src/errors.ts |
McpError kinds config | connection | auth | timeout | protocol; AuthRequiredError, AuthLockError, AuthCancelledError |
Tests live in tests/*.test.ts (node:test + node:assert/strict, run through tsx). Fixtures: tests/mock-server.ts, tests/helpers/. Keep tests out of src/.
User-facing docs to keep in sync with behavior changes: README.md (config, commands, tools), OAUTH.md (auth flows), examples/mcp.json.example (config schema), CHANGELOG.md, and SECURITY.md (security scope and reporting). .github/CODEOWNERS lists the security-sensitive paths.
[!NOTE]
index.ts,server-manager.ts,tool-bridge.ts, andoauth-provider.tsexceed the ~400-line target. Do not split them opportunistically inside a fix or feature change; extraction is a planned, separaterefactor:change. Put substantial new pure logic in a focused module instead of growing these files.
Architecture
- Pure core, thin I/O. Keep policy and transforms as pure functions: state transitions, retry/timeout math, lock staleness, schema conversion, tool naming, URL/argument parsing, config merging. Keep SDK transports,
node:fs,node:child_process,node:http, browser launching, and timers in thin adapters. - Inject boundaries. A module that touches the filesystem, clock, PIDs, network, or browser takes an optional deps object with production defaults (pattern:
AuthLockDepsinsrc/auth-lock.ts), so tests use fakes instead of real sleeps, sockets, or processes. - Validate every external input with Zod:
mcp.json, stored OAuth state, lock records, DCR/discovery metadata. NeverJSON.parse(x) as T. Config errors areMcpError(..., "config")and include the file path. - Classify errors. New user-facing failures use an
McpErrorkind (config | connection | auth | timeout | protocol) or an auth subclass (AuthRequiredError,AuthLockError,AuthCancelledError), not a bareError, so callers andmcp_statuscan tell them apart. - Timeouts and cancellation. The effective timeout is
server.config.requestTimeoutMs ?? settings.requestTimeoutMs. Pass{ timeout, signal }to SDK calls (connect,ping,listTools,callTool) and to OAuth discovery fetches, and checksignal.abortedbetween async steps.
Invariants: don't regress
Each of these was a real bug, and the listed suites guard it. If you touch the area, run those suites first.
Lifecycle & processes (server-manager.test.ts, connection-coordination.test.ts, management-tools.test.ts)
server.attemptandconnectAttemptsentries are cleared infinally/_shutdown()on every path: success, failure, abort, reload.- stdio: pass the session
cwdtoStdioClientTransport, track the child PID as soon as it spawns, and have_shutdown()do SIGTERM → bounded wait → SIGKILL, including before retrying a failed handshake. - Health checks never overlap, each ping is time-bounded, and transport
onclosetriggers recovery for all three transports. - Decode child
stderrwithStringDecoderand pass every line throughsanitizeStderrLinebefore storing or displaying it. session_start/session_shutdown,/mcp*commands, and management tools are registered even if bootstrap config loading fails or finds no servers, because a latersession_startin another directory may load a valid.pi/mcp.json. On reload, abort in-flight attempts, stop the callback server, callToolBridge.updateSettings, and refresh the rootscwd. Root URIs usepathToFileURL(cwd).href.
Security & OAuth (oauth-fixes.test.ts, auth-lock.test.ts, auth-commands.test.ts, pi-1x-compat.test.ts)
- Project trust: never read
<cwd>/.pi/mcp.jsonduring bootstrap or whenctx.isProjectTrusted()is false (loadConfigwithincludeProjectConfig: false). - OAuth state is origin-bound. The state file records
serverUrland is rejected on load if it doesn't match the configured URL (urlsMatch), so a project server that reuses a global server's name can't receive its tokens. Preserve DCRtoken_endpoint_auth_methodandredirect_uris. - OAuth state is also bound to the authorization server: persist and return the SDK's
issueron stored tokens and client information unchanged (including in silent mode and peer reloads), never re-bind a stamped client to another issuer, and never write a config-suppliedclient_secretto disk (GHSA-6qxp-vccf-f47h). - Sensitive files (
<hash>.json,<hash>.json.lock) are written0o600inside0o700directories via temp file +rename. Corrupt state for one server must not breakmcp_statusfor the others. - Refresh tokens are single-use. Before invalidating tokens, reload them from disk once in case a peer process already rotated them, and coordinate interactive writes through the auth lock.
- Trigger OAuth only on a genuine HTTP 401 (during
initializeandtools/list) or a 403 withinsufficient_scope. Never substring-match"401"in error messages. The only exception is the anchored message fallback insideisOAuthUnauthorizedError, after its structured checks; Semgreppi-mcp.no-401-substringenforces this everywhere else. - Redirect URLs are loopback
http:/https:with an explicit port. Validate callbackstateon success and on?error=responses. Onlyhttp:/https:authorization URLs reach the OS browser opener, and unvalidated strings never reach a shell.
Schema conversion (tool-bridge.test.ts): Pi runs Value.Convert(schema, args) before validating.
- In unions,
Type.Null()must come beforeString/Number/Integer/Boolean, ornullgets coerced to""/0/false. - Keep object
propertieswhenanyOf/oneOf/allOfare present, and emit{ type: "object", properties: {} }(notpatternProperties) for empty objects, because Gemini and OpenAI reject the latter. - Track
$refs on the active resolution path (seenRefs) to stop recursive blow-up. Preserveinteger,enum,const, tupleitems, and numeric/string constraints.
Config validation (config.test.ts): reject bad config at load time, not at connect time.
toolPrefixmust match^[a-zA-Z][a-zA-Z0-9_]*$.authis rejected onstdioservers, andauth.redirectUrl(including thecallbackUrl/callbackPortaliases) is validated when the config loads.
Bug-fix workflow
- Reproduce: write a deterministic failing test in
tests/(fake clocks, transports, or deps; no wall-clock sleeps) before editingsrc/. - Trace: follow the whole state machine, including abort, reload, and multi-process interleavings, not just the symptom.
- Fix at the root, keeping pure policy separate from I/O.
- Verify: run the new test, the full suite, lint, typecheck, and pack.
Self-review before finishing:
- Are promises, timers, child processes, and abort listeners cleaned up on error, abort, and reload?
- Does anything write to stdout or swallow errors?
- Is external input Zod-validated and are URLs scheme-checked?
- Are credentials origin-bound and written
0o600?
CI
Workflows live in .github/workflows/. The checks below run on every PR, whatever its base branch (so each PR in a stack is checked too), and on every push to main.
| Workflow | Job | What it enforces |
|---|---|---|
ci.yml |
check (Node 22, 24) |
npm audit signatures, npm audit --omit=dev --audit-level=high (Node 22), lint, typecheck, tests (coverage floor on Node 22), npm pack --dry-run |
ci.yml |
test (macos-latest), test (windows-latest) |
npm test on Node 22. macOS is blocking; Windows is non-blocking (continue-on-error) until it's proven green (#54) |
workflow-lint.yml |
workflow-lint |
actionlint (syntax, expressions, shellcheck of run: scripts) and zizmor (Actions security: injection, pinning, permissions, credentials), failing on any finding of low severity or above |
secret-scan.yml |
gitleaks |
No secrets in the PR's commits (pushes to main: the pushed range; weekly and on dispatch: full history). Output is redacted |
semgrep.yml |
semgrep |
Semgrep registry packs (p/typescript, p/javascript, p/github-actions) plus the custom rules in .semgrep/rules/ that encode hard rules 1, 4, 5 and the security invariants above |
dependency-review.yml |
dependency-review |
Dependencies added or changed by the PR: no high/critical advisories and no copyleft licenses in runtime scope |
pr-title.yml |
pr-title |
PR title is a Conventional Commit; feat / fix / perf / ! titles must change CHANGELOG.md; a PR-description line starting with BREAKING CHANGE: requires ! in the title |
scorecard.yml (OpenSSF Scorecard) runs on pushes to main and weekly, not on PRs. Results go to code scanning and the README badge.
When you edit a workflow:
- Pin every
uses:to a full 40-character commit SHA with a# vX.Y.Zcomment. Never use a tag or branch. - Grant least privilege:
permissions: contents: read(or{}) at the workflow level, widened per job only where a step needs it. - Check out with
persist-credentials: false, and give every job atimeout-minutes. - Never interpolate
${{ github.event.* }},${{ inputs.* }}, or${{ steps.*.outputs.* }}intorun:. Pass them throughenv:and quote"$VAR". - Run
actionlintanduvx zizmor@1.31.1 .github/(orpipx run zizmor==1.31.1 .github/) before pushing; both must be clean.
Commits & release
Work on a branch and open a PR. Use Conventional Commits; scopes are fine (e.g. fix(oauth): ...).
- Regular PRs are squash-merged, so the PR title becomes the commit that decides the release, and the PR description becomes the commit body. The
pr-titlecheck enforces both (see CI). - Stacked PRs are the exception: their commits can land on
mainindividually, so every commit subject in a stack must be a correct Conventional Commit.
.github/workflows/publish.yml runs on every push to main. Its verify job repeats the CI checks on Node 22 and 24 with a read-only token; only then does the release job (the only job with contents: write / id-token: write, in the npm-publish environment) scan all commit subjects since the last tag and publish automatically:
| Prefix | Release |
|---|---|
type!:, or BREAKING CHANGE: in a body |
major |
feat: |
minor |
fix:, perf: |
patch |
anything else (docs:, chore:, refactor:, test:, ci:, …) |
none |
- For
feat/fix/perf/ breaking changes, add a user-facing entry under## [Unreleased]inCHANGELOG.md. The workflow stamps the version and date. - Because the scan covers everything since the last tag, a
docs:push still publishes if unreleasedfix:/feat:commits are already onmain. - The bot creates the
chore: release vX.Y.Zcommit and the tag. Don't create them by hand. - Dependabot PRs are titled
chore(deps): ...,chore(deps-dev): ..., orci(deps): ..., so they don't release. When a runtime bump must raise the minimum version for users (e.g. a security floor), retitle itfix(deps): ...and add aCHANGELOG.mdentry. - Releases never overlap (
concurrency: publish) and are never cancelled mid-flight. The release job doesn't runnpm ci, so no install script runs with the write token. - Maintainers can rehearse a release with
gh workflow run publish.yml -f dry_run=true: it runsnpm publish --dry-runand never commits, tags, or pushes (with no release-worthy commits it rehearses a patch). A real manual release (dry_run=false) is only allowed frommain.
