Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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")