a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
196 lines
7.5 KiB
Go
196 lines
7.5 KiB
Go
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")
|