Skip to content

Commit 7699023

Browse files
committed
render tool errors as llmxml
Success output speaks llmxml (ADR 1.3); errors were the one surface breaking the dialect, reaching the model as bare prose. Receiving middleware rewraps in-band error text as <error tool="NAME">message</error> at one choke point covering SDK argument validation, client-side refusals, and Gerrit API passthrough alike; per-handler wrapping would miss validation errors, which never reach handler code. Protocol-level failures (unknown tool) stay JSON-RPC errors; the MCP isError flag is untouched. Error shape is pinned by golden files alongside success renders.
1 parent 90504d2 commit 7699023

7 files changed

Lines changed: 120 additions & 0 deletions

File tree

cmd/go-gerrit-mcp/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func run(lgr *slog.Logger) error {
5353
&mcp.Implementation{Name: serverName, Version: version},
5454
&mcp.ServerOptions{Instructions: instructions},
5555
)
56+
srv.AddReceivingMiddleware(tools.WrapErrors)
5657

5758
enabled, err := registry.Resolve(cfg)
5859
if err != nil {

design-docs/adr/1.3-llmxml-output.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,10 @@ per-file comment threads and diff hunks). XML-style tags delimit nested structur
1616
review data needs and what models parse reliably. The format is a published
1717
behavior contract — user prompts and workflows will come to depend on its shape — so changing it later is a breaking
1818
change, hence the record.
19+
20+
**Amended 2026-07-11 — errors speak the dialect too.** Every in-band tool-call error — argument validation, client-side
21+
refusals, Gerrit API passthrough — reaches the model as `<error tool="NAME">message</error>`, applied uniformly by
22+
server middleware rather than per tool, so failure output never forces a parsing-mode switch. The MCP `isError` flag is
23+
untouched (llmxml is the model-facing layer, not the transport signal), and protocol-level failures such as an unknown
24+
tool name remain plain JSON-RPC errors. The error shape is part of the same published contract as success renders and
25+
is pinned by golden files.

internal/tools/error-middleware.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
6+
"github.com/modelcontextprotocol/go-sdk/mcp"
7+
8+
"dev.gaijin.team/go/go-gerrit-mcp/internal/llmxml"
9+
)
10+
11+
// WrapErrors is receiving middleware that re-renders in-band tool-call errors
12+
// as llmxml, so error output speaks the same dialect as success output
13+
// (ADR 1.3). Placed on the server it covers every error class uniformly:
14+
// SDK argument validation, client-side refusals, and Gerrit API passthrough.
15+
// Protocol-level failures (e.g. unknown tool) are JSON-RPC errors and pass
16+
// through untouched.
17+
func WrapErrors(next mcp.MethodHandler) mcp.MethodHandler {
18+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
19+
res, err := next(ctx, method, req)
20+
if err != nil {
21+
return res, err
22+
}
23+
24+
call, ok := req.(*mcp.CallToolRequest)
25+
if !ok {
26+
return res, nil
27+
}
28+
29+
out, ok := res.(*mcp.CallToolResult)
30+
if !ok || !out.IsError || len(out.Content) != 1 {
31+
return res, nil
32+
}
33+
34+
text, ok := out.Content[0].(*mcp.TextContent)
35+
if !ok {
36+
return res, nil
37+
}
38+
39+
wrapped := llmxml.NewElement("error", llmxml.Attr("tool", call.Params.Name)).
40+
WrapText(text.Text).
41+
String()
42+
43+
out.Content = []mcp.Content{&mcp.TextContent{Text: wrapped}}
44+
45+
return out, nil
46+
}
47+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package tools_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/modelcontextprotocol/go-sdk/mcp"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
// errorText extracts the sole text content of an error result.
11+
func errorText(t *testing.T, res *mcp.CallToolResult) string {
12+
t.Helper()
13+
14+
require.True(t, res.IsError)
15+
require.Len(t, res.Content, 1)
16+
17+
text, ok := res.Content[0].(*mcp.TextContent)
18+
require.True(t, ok)
19+
20+
return text.Text
21+
}
22+
23+
// Tool errors are a product contract like success renders (ADR 1.3): every
24+
// in-band error class reaches the model wrapped as llmxml. Validation errors
25+
// originate in the SDK before handler code runs; refusals originate in
26+
// handlers — the middleware must cover both.
27+
func Test_WrapErrors(t *testing.T) {
28+
t.Parallel()
29+
30+
t.Run("argument validation error is llmxml", func(t *testing.T) {
31+
t.Parallel()
32+
33+
cs, _ := fakeGerrit(t, "")
34+
35+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
36+
Name: "search_changes",
37+
Arguments: map[string]any{},
38+
})
39+
require.NoError(t, err)
40+
41+
golden(t, "error-validation", errorText(t, res))
42+
})
43+
44+
t.Run("handler refusal is llmxml", func(t *testing.T) {
45+
t.Parallel()
46+
47+
cs, _ := transitionSession(t, ")]}'\n{}")
48+
49+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
50+
Name: "transition_change",
51+
Arguments: map[string]any{"change": "123", "action": "merge"},
52+
})
53+
require.NoError(t, err)
54+
55+
golden(t, "error-refusal", errorText(t, res))
56+
})
57+
}

internal/tools/get-change_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ func session(t *testing.T, gerritHandler http.HandlerFunc) *mcp.ClientSession {
6565
require.NoError(t, err)
6666

6767
mcpServer := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)
68+
mcpServer.AddReceivingMiddleware(tools.WrapErrors)
69+
6870
for _, tool := range tools.All(client) {
6971
tool.Register(mcpServer)
7072
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<error tool="transition_change">
2+
unknown action, expected submit, abandon, restore, wip, or ready (action=merge)
3+
</error>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<error tool="search_changes">
2+
validating "arguments": validating root: required: missing properties: ["query"]
3+
</error>

0 commit comments

Comments
 (0)