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
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 immediately1) Server config model and validation
src/mcp/types.ts defines the authoring shape used by MCP config writers and runtime:
stdio(default whentypemissing): requirescommand, optionalargs,env,cwdhttp: requiresurl, optionalheaderssse: requiresurl, optionalheaders(kept for compatibility)- shared fields:
enabled,timeout,auth
validateServerConfig() (src/mcp/config.ts) enforces transport basics:
- rejects configs that set both
commandandurl - requires
commandfor stdio - requires
urlfor 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
typeomitted means stdio. If you intended HTTP/SSE but omittedtype,commandbecomes mandatory.sseis 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:
- loads providers in priority order
- dedupes by
server.name(first win = highest priority) - validates deduped items
Result: duplicate server names across sources are not merged. One definition wins; lower-priority duplicates are shadowed.
.mcp.json and related files
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.jsonwhen 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:
auth: { type: "oauth", credentialId: "..." }and credential exists in auth storage:
http/sse: injectsAuthorization: Bearer <access_token>headerstdio: injectsOAUTH_ACCESS_TOKENenv 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:
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
isErrortoError: ...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/rmenable/disabletestreauth/unauthreload
Config writes are atomic (writeMCPConfigFile: temp file + rename).
After changes, controller calls #reloadMCP():
mcpManager.disconnectAll()mcpManager.discoverAndConnect()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:
/mcpgives 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/mcpcommand 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 discoveredOAuth 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:
- Keep server names globally unique across all MCP-capable config sources.
- Prefer alphanumeric/underscore names to avoid sanitized-name collisions in generated
mcp_*tool names. - Use explicit
typeto avoid accidental stdio defaults. - Treat
enabled: falseas hard-off: server is omitted from runtime connect set. - For OAuth configs, store a valid
credentialId; otherwise auth injection is skipped. - If using command-based secret resolution (
!cmd), verify command output is stable and non-empty.