Skip to content

feat(mcp): emit structured tool-error codes - #79

Merged
chicoxyzzy merged 2 commits into
mainfrom
feat/mcp-tool-error-codes
Jul 9, 2026
Merged

feat(mcp): emit structured tool-error codes#79
chicoxyzzy merged 2 commits into
mainfrom
feat/mcp-tool-error-codes

Conversation

@chicoxyzzy

Copy link
Copy Markdown
Member

Before

A failed MCP tool call returned only human-readable prose in the result content with isError: true. A host had to string-parse the message to distinguish a missing entity from bad input from a claim race — there was no machine-readable failure class.

After

The single central tool-error path in internal/mcp/server.go now attaches a structuredContent error envelope alongside the unchanged prose content:

{
  "content": [{"type": "text", "text": "root \"x\" not found"}],
  "structuredContent": {"error": {"code": "not_found", "message": "root \"x\" not found"}},
  "isError": true
}

The code is derived from the typed store sentinels via errors.Is, so it covers every tool family (all 73 handlers flow through this one path) without per-handler changes. Argument-decode failures now join ErrInvalid so they classify as invalid while keeping their exact invalid arguments: ... prose.

Catalog

Code Sentinel Meaning Suggested host HTTP
not_found ErrNotFound entity does not exist 404
invalid ErrInvalid bad/missing input, incl. arg-decode + validation 400
already_exists ErrDuplicate id/uniqueness collision 409
conflict ErrConflict invalid state transition / claim race 409
internal (default) unexpected server-side error 500

How

  • New public package file errorcode.go (package cairnline) exports the five ErrorCode* constants and ClassifyErrorCode(err error) string (nil -> "", unclassified -> internal). Depends only on internal/core; importable by external Go hosts (e.g. Hecate) as github.com/hecatehq/cairnline.
  • internal/mcp/protocol.go adds typed ToolErrorPayload / ToolErrorDetail wire structs.
  • internal/mcp/server.go central error path builds StructuredContent from the classifier; Content and IsError are unchanged, so prose-only clients are unaffected.
  • internal/app/tools.go argument-decode sites wrap through a new invalidArguments helper that reads naturally (invalid arguments: ...) and unwraps to core.ErrInvalid.
  • Docs: normative "Tool Error Codes" section in docs/agent-host-integration.md (host contract + catalog + JSON shape) and a client-facing "Tool error shape" note in docs/mcp-quickstart.md.

Test evidence

  • go build ./... — clean
  • go vet ./... — clean
  • go test ./... — all packages pass
  • go test -race ./internal/mcp/... ./internal/app/... . — pass

New tests: TestServer_CallToolErrorCodes and TestServer_CallToolSuccessHasNoErrorEnvelope (end-to-end through Serve for each sentinel + internal + success), TestMCPTools_ErrorCodesAcrossCentralPath (real handlers: arg-decode -> invalid, missing entity -> not_found, validation -> invalid), and TestClassifyErrorCode (classifier incl. nil -> "").


Generated by Claude Code

Before: a failed MCP tool call returned only human-readable prose in the
result Content with isError: true. Hosts had to parse the message to tell a
missing entity from bad input from a claim race.

After: the single central tool-error path attaches a machine-readable
structuredContent envelope alongside the unchanged prose:
{ "error": { "code": "not_found", "message": "..." } }. The code is derived
from the typed store sentinels via errors.Is, so it covers every tool family
without per-handler changes. Argument-decode failures now join ErrInvalid so
they classify as invalid. Exported constants and ClassifyErrorCode live in the
public github.com/hecatehq/cairnline package for host reuse.

Catalog:
- not_found      (ErrNotFound)  entity does not exist            -> 404
- invalid        (ErrInvalid)   bad/missing input or validation  -> 400
- already_exists (ErrDuplicate) id/uniqueness collision          -> 409
- conflict       (ErrConflict)  invalid transition / claim race  -> 409
- internal       (default)      unexpected server-side error     -> 500
@chicoxyzzy
chicoxyzzy merged commit dde0c49 into main Jul 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant