package mcp import ( "encoding/json" "errors" "fmt" "github.com/mark3labs/mcp-go/mcp" ) // Structured MCP error responses. // // MCP tool handlers historically returned errors as text content with // no machine-readable code, leaving agents to regex-parse English // strings to decide whether to retry, reauth, or surface to the user. // Iteration 1 introduces a small set of stable error codes for the // failure modes the workspace boundary checks introduce, plus the // multi-server routing that can fail in distinct ways the agent // should react to differently. // // The codes live here (one place to grep) so cross-package code that // surfaces MCP errors can refer to a single canonical list. The // payload is JSON-encoded into a TextContent block so existing // transports keep working — the `is_error` flag on CallToolResult // signals the structured shape, and a leading `{"error_code": ...}` // is what a smart client decodes. // ErrorCode is the stable identifier surfaced under // `error_code` in tool error payloads. Adding new codes is OK; renaming // or removing an existing one is a wire-contract break. type ErrorCode string const ( // ErrCodeWorkspaceUnknown — the requested workspace doesn't // exist on this server. Routing guidance: the daemon should fall // back to the "default" server, surface a hint to the user, or // fail. ErrCodeWorkspaceUnknown ErrorCode = "workspace_unknown" // ErrCodeProjectUnknown — workspace exists but the requested // project slug doesn't (e.g. monorepo missing the named project). ErrCodeProjectUnknown ErrorCode = "project_unknown" // ErrCodeCrossWorkspaceDenied — the source workspace's // `cross_workspace_deps` doesn't declare the target workspace // (or the import path doesn't match a declared module). The // query is refused at the matcher / resolver boundary. ErrCodeCrossWorkspaceDenied ErrorCode = "cross_workspace_denied" // ErrCodeRepoNotTracked — the cwd wasn't found in any tracked // repo's root tree. Used by the daemon's MCP front-door. ErrCodeRepoNotTracked ErrorCode = "repo_not_tracked" // ErrCodeSymbolNotFound — the requested symbol id is not in the // index. Recoverable: the agent can search for it instead. ErrCodeSymbolNotFound ErrorCode = "symbol_not_found" // ErrCodeFileNotIndexed — no symbols are indexed for the requested // file (new / ignored / unsupported language). Recoverable. ErrCodeFileNotIndexed ErrorCode = "file_not_indexed" // ErrCodeRouteUnresolved — the multi-server router couldn't // pick a server for the request (no servers.toml entry covers // the workspace, no roster claims it, no default). Retriable // after the user adjusts servers.toml. ErrCodeRouteUnresolved ErrorCode = "route_unresolved" // ErrCodeProxyUpstream — the router proxied to a remote server // and the upstream returned a non-2xx. error.data carries // `upstream_status` and `upstream_body` for debugging. ErrCodeProxyUpstream ErrorCode = "proxy_upstream" // ErrCodeOverlayDrift — an overlay push carried a BaseSHA that // disagrees with the file's current on-disk SHA. The client // should re-read the file and resubmit a fresh overlay. ErrCodeOverlayDrift ErrorCode = "overlay_drift" // ErrCodeOverlaySessionUnknown — the overlay session ID isn't // registered (expired, dropped, or never existed). ErrCodeOverlaySessionUnknown ErrorCode = "overlay_session_unknown" // ErrCodeInvalidArgument — the tool args failed validation. // Generic catch-all so per-tool handlers don't have to invent // their own code for "you passed nonsense". ErrCodeInvalidArgument ErrorCode = "invalid_argument" // ErrCodeToolBlockedByMode — the call was refused because the // session's runtime mode forbids it (an editing tool while the // session is in planning mode). Switch with set_planning_mode. ErrCodeToolBlockedByMode ErrorCode = "tool_blocked_by_mode" // ErrCodeToolOutOfPhase — an active workflow does not allow this // tool in the current phase. The error data carries the current // phase and the allowed-tool list. ErrCodeToolOutOfPhase ErrorCode = "tool_out_of_phase" ) // StructuredError is the JSON shape encoded into the TextContent // block of an MCP error. Smart clients parse the leading `{` to read // it; older clients see the human-readable Message field as a // fallback. type StructuredError struct { ErrorCode ErrorCode `json:"error_code"` Message string `json:"message"` Retriable bool `json:"retriable,omitempty"` Data map[string]any `json:"data,omitempty"` } // NewStructuredErrorResult returns a CallToolResult whose IsError is // true and whose single TextContent block is the JSON form of err. // Use this from any MCP handler that wants to surface a typed // failure instead of a free-form string. func NewStructuredErrorResult(err StructuredError) *mcp.CallToolResult { if err.ErrorCode == "" { err.ErrorCode = ErrCodeInvalidArgument } if err.Message == "" { err.Message = string(err.ErrorCode) } body, mErr := json.Marshal(err) if mErr != nil { // Fallback to plain text — never let a marshal failure mask // the actual error. return mcp.NewToolResultError(fmt.Sprintf("%s: %s", err.ErrorCode, err.Message)) } res := mcp.NewToolResultError(string(body)) res.IsError = true return res } // Common constructors for the codes above so handlers can call // `mcp.WorkspaceUnknownError(slug)` instead of building structs by // hand. func WorkspaceUnknownError(workspace string) *mcp.CallToolResult { return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeWorkspaceUnknown, Message: fmt.Sprintf("workspace %q is not known to this server", workspace), Retriable: false, Data: map[string]any{"workspace": workspace}, }) } func ProjectUnknownError(workspace, project string) *mcp.CallToolResult { return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeProjectUnknown, Message: fmt.Sprintf("project %q does not exist in workspace %q", project, workspace), Retriable: false, Data: map[string]any{"workspace": workspace, "project": project}, }) } func CrossWorkspaceDeniedError(source, target, importPath string) *mcp.CallToolResult { msg := fmt.Sprintf("cross-workspace access from %q to %q is not declared in cross_workspace_deps", source, target) if importPath != "" { msg = fmt.Sprintf("%s (import path %q)", msg, importPath) } return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeCrossWorkspaceDenied, Message: msg, Retriable: false, Data: map[string]any{ "source_workspace": source, "target_workspace": target, "import_path": importPath, }, }) } // AsStructuredError unwraps a typed error from the package's known // sentinel set, returning a CallToolResult on hit. Returns (nil, // false) when err isn't one of the recognised sentinels — caller // falls back to its own error handling. func AsStructuredError(err error) (*mcp.CallToolResult, bool) { if err == nil { return nil, false } // Future: extend with errors.Is checks for daemon.Err* sentinels // once the daemon's errors are imported here. For now we only // match generic shapes used by handlers. switch { case errors.Is(err, errInvalidArgument): return NewStructuredErrorResult(StructuredError{ ErrorCode: ErrCodeInvalidArgument, Message: err.Error(), }), true } return nil, false } // errInvalidArgument is the canonical sentinel a tool can return when // its args fail validation; AsStructuredError converts it to the // structured form. Wrapping (`fmt.Errorf("%w: ...", errInvalidArgument)`) // is supported via errors.Is. var errInvalidArgument = errors.New("invalid argument")