feat(mcp): emit structured tool-error codes - #79
Merged
Conversation
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
marked this pull request as ready for review
July 9, 2026 08:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Before
A failed MCP tool call returned only human-readable prose in the result
contentwithisError: 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.gonow attaches astructuredContenterror envelope alongside the unchanged prosecontent:{ "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 joinErrInvalidso they classify asinvalidwhile keeping their exactinvalid arguments: ...prose.Catalog
not_foundinvalidalready_existsconflictinternalHow
errorcode.go(packagecairnline) exports the fiveErrorCode*constants andClassifyErrorCode(err error) string(nil ->"", unclassified ->internal). Depends only oninternal/core; importable by external Go hosts (e.g. Hecate) asgithub.com/hecatehq/cairnline.internal/mcp/protocol.goadds typedToolErrorPayload/ToolErrorDetailwire structs.internal/mcp/server.gocentral error path buildsStructuredContentfrom the classifier;ContentandIsErrorare unchanged, so prose-only clients are unaffected.internal/app/tools.goargument-decode sites wrap through a newinvalidArgumentshelper that reads naturally (invalid arguments: ...) and unwraps tocore.ErrInvalid.docs/agent-host-integration.md(host contract + catalog + JSON shape) and a client-facing "Tool error shape" note indocs/mcp-quickstart.md.Test evidence
go build ./...— cleango vet ./...— cleango test ./...— all packages passgo test -race ./internal/mcp/... ./internal/app/... .— passNew tests:
TestServer_CallToolErrorCodesandTestServer_CallToolSuccessHasNoErrorEnvelope(end-to-end throughServefor each sentinel + internal + success),TestMCPTools_ErrorCodesAcrossCentralPath(real handlers: arg-decode -> invalid, missing entity -> not_found, validation -> invalid), andTestClassifyErrorCode(classifier incl. nil -> "").Generated by Claude Code