Skip to content

MCP server and tool authoring

This document explains how MCP server definitions become callable mcp_* tools in coding-agent, and what operators should expect when configs are invalid, duplicated, disabled, or auth-gated.

Architecture at a glance

text
Config sources (.pisces/.claude/.cursor/.vscode/mcp.json, mcp.json, etc.)
  -> discovery providers normalize to canonical MCPServer
  -> capability loader dedupes by server name (higher provider priority wins)
  -> loadAllMCPConfigs converts to MCPServerConfig + skips enabled:false
  -> MCPManager connects/listTools (with auth/header/env resolution)
  -> MCPTool/DeferredMCPTool bridge exposes tools as mcp_<server>_<tool>
  -> AgentSession.refreshMCPTools replaces live MCP tools immediately

1) Server config model and validation

src/mcp/types.ts defines the authoring shape used by MCP config writers and runtime:

  • stdio (default when type missing): requires command, optional args, env, cwd
  • http: requires url, optional headers
  • sse: requires url, optional headers (kept for compatibility)
  • shared fields: enabled, timeout, auth

validateServerConfig() (src/mcp/config.ts) enforces transport basics:

  • rejects configs that set both command and url
  • requires command for stdio
  • requires url for http/sse
  • rejects unknown type

config-writer.ts applies this validation for add/update operations and also validates server names:

  • non-empty
  • max 100 chars
  • only [a-zA-Z0-9_.-]

Transport pitfalls

  • type omitted means stdio. If you intended HTTP/SSE but omitted type, command becomes mandatory.
  • sse is still accepted but treated as HTTP transport internally (createHttpTransport).
  • Validation is structural, not reachability: a syntactically valid URL can still fail at connect time.

2) Discovery, normalization, and precedence

Capability-based discovery

loadAllMCPConfigs() (src/mcp/config.ts) loads canonical MCPServer items via loadCapability(mcpCapability.id).

The capability layer (src/capability/index.ts) then:

  1. loads providers in priority order
  2. dedupes by server.name (first win = highest priority)
  3. validates deduped items

Result: duplicate server names across sources are not merged. One definition wins; lower-priority duplicates are shadowed.

The dedicated fallback provider in src/discovery/mcp-json.ts reads project-root mcp.json and .mcp.json (low priority).

In practice MCP servers also come from higher-priority providers (for example native .pisces/... and tool-specific config dirs). Authoring guidance:

  • Prefer .pisces/mcp.json (project) or ~/.pisces/mcp.json (user) for explicit control.
  • Use root mcp.json / .mcp.json when you need fallback compatibility.
  • Reusing the same server name in multiple sources causes precedence shadowing, not merge.

Normalization behavior

convertToLegacyConfig() (src/mcp/config.ts) maps canonical MCPServer to runtime MCPServerConfig.

Key behavior:

  • transport inferred as server.transport ?? (command ? "stdio" : url ? "http" : "stdio")
  • disabled servers (enabled === false) are dropped before connection
  • optional fields are preserved when present

Environment expansion during discovery

mcp-json.ts expands env placeholders in string fields with expandEnvVarsDeep():

  • supports ${VAR} and ${VAR:-default}
  • unresolved values remain literal ${VAR} strings

mcp-json.ts also performs runtime type checks for user JSON and logs warnings for invalid enabled/timeout values instead of hard-failing the whole file.

3) Auth and runtime value resolution

MCPManager.prepareConfig()/#resolveAuthConfig() (src/mcp/manager.ts) is the final pre-connect pass.

OAuth credential injection

If config has:

ts
auth: { type: "oauth", credentialId: "..." }

and credential exists in auth storage:

  • http/sse: injects Authorization: Bearer <access_token> header
  • stdio: injects OAUTH_ACCESS_TOKEN env var

If credential lookup fails, manager logs a warning and continues with unresolved auth.

Header/env value resolution

Before connect, manager resolves each header/env value via resolveConfigValue() (src/config/resolve-config-value.ts):

  • value starting with ! => execute shell command, use trimmed stdout (cached)
  • otherwise, treat value as environment variable name first (process.env[name]), fallback to literal value
  • unresolved command/env values are omitted from final headers/env map

Operational caveat: this means a mistyped secret command/env key can silently remove that header/env entry, producing downstream 401/403 or server startup failures.

4) Tool bridge: MCP -> agent-callable tools

src/mcp/tool-bridge.ts converts MCP tool definitions into CustomTools.

Naming and collision domain

Tool names are generated as:

text
mcp_<sanitized_server_name>_<sanitized_tool_name>

Rules:

  • lowercases
  • non-[a-z_] chars become _
  • repeated underscores collapse
  • redundant <server>_ prefix in tool name is stripped once

This avoids many collisions, but not all. Different raw names can still sanitize to the same identifier (for example my-server and my.server both sanitize similarly), and registry insertion is last-write-wins.

Schema mapping

convertSchema() keeps MCP JSON Schema mostly as-is but patches object schemas missing properties with {} for provider compatibility.

Execution mapping

MCPTool.execute() / DeferredMCPTool.execute():

  • calls MCP tools/call
  • flattens MCP content into displayable text
  • returns structured details (serverName, mcpToolName, provider metadata)
  • maps server-reported isError to Error: ... text result
  • maps thrown transport/runtime failures to MCP error: ...
  • preserves abort semantics by translating AbortError into ToolAbortError

5) Operator lifecycle: add/edit/remove and live updates

Interactive mode exposes /mcp in src/modes/controllers/mcp-command-controller.ts.

Supported operations:

  • add (wizard or quick-add)
  • remove / rm
  • enable / disable
  • test
  • reauth / unauth
  • reload

Config writes are atomic (writeMCPConfigFile: temp file + rename).

After changes, controller calls #reloadMCP():

  1. mcpManager.disconnectAll()
  2. mcpManager.discoverAndConnect()
  3. session.refreshMCPTools(mcpManager.getTools())

refreshMCPTools() replaces all mcp_ registry entries and immediately re-activates the latest MCP tool set, so changes take effect without restarting the session.

Mode differences

  • Interactive/TUI mode: /mcp gives in-app UX (wizard, OAuth flow, connection status text, immediate runtime rebinding).
  • SDK/headless integration: discoverAndLoadMCPTools() (src/mcp/loader.ts) returns loaded tools + per-server errors; no /mcp command UX.

6) User-visible error surfaces

Common error strings users/operators see:

  • add/update validation failures:
    • Invalid server config: ...
    • Server "<name>" already exists in <path>
  • quick-add argument issues:
    • Use either --url or -- <command...>, not both.
    • --token requires --url (HTTP/SSE transport).
  • connect/test failures:
    • Failed to connect to "<name>": <message>
    • timeout help text suggests increasing timeout
    • auth help text for 401/403
  • auth/OAuth flows:
    • Authentication required ... OAuth endpoints could not be discovered
    • OAuth flow timed out. Please try again.
    • OAuth authentication failed: ...
  • disabled server usage:
    • Server "<name>" is disabled. Run /mcp enable <name> first.

Bad source JSON in discovery is generally handled as warnings/logs; config-writer paths throw explicit errors.

7) Practical authoring guidance

For robust MCP authoring in this codebase:

  1. Keep server names globally unique across all MCP-capable config sources.
  2. Prefer alphanumeric/underscore names to avoid sanitized-name collisions in generated mcp_* tool names.
  3. Use explicit type to avoid accidental stdio defaults.
  4. Treat enabled: false as hard-off: server is omitted from runtime connect set.
  5. For OAuth configs, store a valid credentialId; otherwise auth injection is skipped.
  6. If using command-based secret resolution (!cmd), verify command output is stable and non-empty.

Implementation files