Skip to content

Commit ce43639

Browse files
committed
propose valid inputs where errors know them
Where the valid set behind a failed input is knowable, the error names it instead of leaving the agent to guess: fuzzy did_you_mean proposals for file paths (edit distance with a similarity threshold plus a path-suffix fast path), configured_labels enumerated on a vote 400, and a hint naming the refreshing tool for stale reply anchors. Two misleading successes join the error contract: get_file_diff on a path absent from the revision (Gerrit answers 200 with an empty diff) errors with proposals, and post_comments refuses new comments on files outside the current revision, which Gerrit accepts silently and no reviewer ever sees; replies stay exempt since their thread may anchor to an older patch set's file. search_changes validates start and empty queries before the round trip (empty stays legal under project scoping) and scoped results carry a scope attribute. Enrichment runs on the failure path only, except the post_comments pre-flight: one file-list fetch when new file comments are present. ADR 1.3 amended: recovery affordances join the published contract.
1 parent a4c61ae commit ce43639

12 files changed

Lines changed: 599 additions & 18 deletions

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,11 @@ server middleware rather than per tool, so failure output never forces a parsing
2323
untouched (llmxml is the model-facing layer, not the transport signal), and protocol-level failures such as an unknown
2424
tool name remain plain JSON-RPC errors. The error shape is part of the same published contract as success renders and
2525
is pinned by golden files.
26+
27+
**Amended 2026-07-11 — errors carry a recovery path where one is knowable.** When the valid set behind a failed input
28+
is known, the error names it: fuzzy `did_you_mean` proposals for file paths (large legible sets), full enumeration for
29+
small sets (configured labels, patch-set ranges), and a `hint` field naming the tool that refreshes stale context
30+
(reply anchors). Proposals beyond a similarity threshold are dropped — a far-fetched proposal invites a confident
31+
wrong retry. The same stance covers misleading successes: an empty diff for a path absent from the revision becomes an
32+
error, and scoped query results name the active `scope` so an empty page reads as "possibly out of scope", not "does
33+
not exist". Enrichment fetches run on the failure path only.
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package tools_test
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
"testing"
7+
8+
"github.com/modelcontextprotocol/go-sdk/mcp"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"dev.gaijin.team/go/go-gerrit-mcp/internal/config"
13+
)
14+
15+
// Errors and empty results must carry what the agent needs to recover:
16+
// proposals when the valid set is known, the active scope when it silently
17+
// narrows a query, and the real patch-set range when a revision guess misses.
18+
19+
func Test_SearchChanges_Boundaries(t *testing.T) {
20+
t.Parallel()
21+
22+
t.Run("negative start refused before any request", func(t *testing.T) {
23+
t.Parallel()
24+
25+
requested := false
26+
27+
cs := fakeGerritFlag(t, ")]}'\n[]", &requested)
28+
29+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
30+
Name: "search_changes",
31+
Arguments: map[string]any{"query": "status:open", "start": -2},
32+
})
33+
require.NoError(t, err)
34+
require.True(t, res.IsError)
35+
assert.Contains(t, errorText(t, res), "start must be zero or positive")
36+
assert.False(t, requested, "no query may leave the process")
37+
})
38+
39+
t.Run("empty query without scope refused with a starting point", func(t *testing.T) {
40+
t.Parallel()
41+
42+
requested := false
43+
44+
cs := fakeGerritFlag(t, ")]}'\n[]", &requested)
45+
46+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
47+
Name: "search_changes",
48+
Arguments: map[string]any{"query": " "},
49+
})
50+
require.NoError(t, err)
51+
require.True(t, res.IsError)
52+
assert.Contains(t, errorText(t, res), "status:open")
53+
assert.False(t, requested, "no query may leave the process")
54+
})
55+
56+
t.Run("empty query with scope browses the scope", func(t *testing.T) {
57+
t.Parallel()
58+
59+
cs, lastURL := scopedGerrit(t, ")]}'\n[]")
60+
61+
out := callTool(t, cs, "search_changes", map[string]any{"query": ""})
62+
63+
assert.Equal(t, `<changes query="" scope="core" start="0" count="0" more="false"/>`, out)
64+
assert.Contains(t, *lastURL, "project:core", "scope clause must reach the wire")
65+
})
66+
67+
t.Run("scoped result names the scope", func(t *testing.T) {
68+
t.Parallel()
69+
70+
cs, _ := scopedGerrit(t, ")]}'\n[]")
71+
72+
out := callTool(t, cs, "search_changes", map[string]any{"query": "project:elsewhere"})
73+
74+
assert.Equal(t,
75+
`<changes query="project:elsewhere" scope="core" start="0" count="0" more="false"/>`,
76+
out,
77+
"the scope attribute is what tells the agent an empty page may mean out-of-scope",
78+
)
79+
})
80+
}
81+
82+
func Test_GetFileDiff_UnknownFile(t *testing.T) {
83+
t.Parallel()
84+
85+
const emptyDiffJSON = ")]}'\n" + `{"change_type":"MODIFIED","content":[]}`
86+
87+
cs := session(t, func(w http.ResponseWriter, r *http.Request) {
88+
switch {
89+
case r.URL.Path == "/a/accounts/self":
90+
_, _ = w.Write([]byte(selfJSON))
91+
case strings.HasSuffix(r.URL.Path, "/diff"):
92+
_, _ = w.Write([]byte(emptyDiffJSON))
93+
case strings.HasSuffix(r.URL.Path, "/files/"):
94+
_, _ = w.Write([]byte(revisionFilesJSON))
95+
default:
96+
_, _ = w.Write([]byte(ownChangeJSON))
97+
}
98+
})
99+
100+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
101+
Name: "get_file_diff",
102+
Arguments: map[string]any{"change": "123", "file": "core/scaner.go"},
103+
})
104+
require.NoError(t, err)
105+
require.True(t, res.IsError, "Gerrit's empty diff for an absent file must not render as success")
106+
107+
golden(t, "error-diff-unknown-file", errorText(t, res))
108+
}
109+
110+
func Test_ListChangeFiles_RevisionNotFound(t *testing.T) {
111+
t.Parallel()
112+
113+
const allRevisionsJSON = ")]}'\n" + `{"_number":123,"project":"core",` +
114+
`"current_revision":"sha3",` +
115+
`"revisions":{"sha1":{"_number":1},"sha2":{"_number":2},"sha3":{"_number":3}}}`
116+
117+
cs := session(t, func(w http.ResponseWriter, r *http.Request) {
118+
switch {
119+
case r.URL.Path == "/a/accounts/self":
120+
_, _ = w.Write([]byte(selfJSON))
121+
case strings.Contains(r.URL.Path, "/revisions/99/"):
122+
w.WriteHeader(http.StatusNotFound)
123+
124+
_, _ = w.Write([]byte("Not found: 99"))
125+
126+
default:
127+
_, _ = w.Write([]byte(allRevisionsJSON))
128+
}
129+
})
130+
131+
res, err := cs.CallTool(t.Context(), &mcp.CallToolParams{
132+
Name: "list_change_files",
133+
Arguments: map[string]any{"change": "123", "revision": "99"},
134+
})
135+
require.NoError(t, err)
136+
require.True(t, res.IsError)
137+
138+
text := errorText(t, res)
139+
assert.Contains(t, text, "Not found: 99")
140+
assert.Contains(t, text, "valid_patch_sets=1-3")
141+
assert.Contains(t, text, "current_patch_set=3")
142+
}
143+
144+
// fakeGerritFlag is fakeGerrit with a flag reporting whether any API request
145+
// beyond the startup self-check reached the fake.
146+
func fakeGerritFlag(t *testing.T, fixture string, requested *bool) *mcp.ClientSession {
147+
t.Helper()
148+
149+
return session(t, func(w http.ResponseWriter, r *http.Request) {
150+
if r.URL.Path == "/a/accounts/self" {
151+
_, _ = w.Write([]byte(selfJSON))
152+
153+
return
154+
}
155+
156+
*requested = true
157+
158+
_, _ = w.Write([]byte(fixture))
159+
})
160+
}
161+
162+
// scopedGerrit is fakeGerrit with the project allowlist set to "core".
163+
func scopedGerrit(t *testing.T, fixture string) (*mcp.ClientSession, *string) {
164+
t.Helper()
165+
166+
lastURL := new(string)
167+
168+
cs := session(t, func(w http.ResponseWriter, r *http.Request) {
169+
if r.URL.Path == "/a/accounts/self" {
170+
_, _ = w.Write([]byte(selfJSON))
171+
172+
return
173+
}
174+
175+
*lastURL = r.URL.String()
176+
177+
_, _ = w.Write([]byte(fixture))
178+
}, func(cfg *config.Config) { cfg.Projects = []string{"core"} })
179+
180+
return cs, lastURL
181+
}

internal/tools/get-change_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,13 @@ const changeJSON = ")]}'\n" + `{
4646

4747
// session spins a fake Gerrit plus an in-memory MCP server/client pair with
4848
// the read-group tool registered, and returns the connected client session.
49-
func session(t *testing.T, gerritHandler http.HandlerFunc) *mcp.ClientSession {
49+
func session(t *testing.T, gerritHandler http.HandlerFunc, mutate ...func(*config.Config)) *mcp.ClientSession {
5050
t.Helper()
5151

5252
srv := httptest.NewServer(gerritHandler)
5353
t.Cleanup(srv.Close)
5454

55-
client, err := gerritclient.New(t.Context(), &config.Config{
55+
cfg := &config.Config{
5656
GerritURL: srv.URL,
5757
Username: "bot",
5858
Token: "s3cret",
@@ -61,7 +61,12 @@ func session(t *testing.T, gerritHandler http.HandlerFunc) *mcp.ClientSession {
6161
ExcludeTools: nil,
6262
Projects: nil,
6363
AllowForeignChanges: false,
64-
})
64+
}
65+
for _, m := range mutate {
66+
m(cfg)
67+
}
68+
69+
client, err := gerritclient.New(t.Context(), cfg)
6570
require.NoError(t, err)
6671

6772
mcpServer := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil)

internal/tools/get-file-diff.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import (
55
"fmt"
66
"strings"
77

8+
"dev.gaijin.team/go/golib/e"
9+
"dev.gaijin.team/go/golib/fields"
810
gerrit "github.com/andygrunwald/go-gerrit"
911
"github.com/modelcontextprotocol/go-sdk/mcp"
1012

1113
"dev.gaijin.team/go/go-gerrit-mcp/internal/gerritclient"
1214
"dev.gaijin.team/go/go-gerrit-mcp/internal/llmxml"
1315
)
1416

17+
var errFileNotInRevision = e.New("file is not part of the revision")
18+
1519
type getFileDiffInput struct {
1620
Change string `json:"change" jsonschema:"Change identifier: numeric ID, project~number, or Change-Id"`
1721
File string `json:"file" jsonschema:"File path within the change; /COMMIT_MSG addresses the commit message"`
@@ -32,12 +36,52 @@ func getFileDiff(c *gerritclient.Client) Tool {
3236
return nil, nil, err
3337
}
3438

39+
// Gerrit answers 200 with an empty diff for paths absent from
40+
// the revision; distinguish "no such file" from a legitimately
41+
// empty diff before rendering a misleading success.
42+
if !diff.Binary && len(diff.Content) == 0 {
43+
if err := checkDiffFile(ctx, c, in); err != nil {
44+
return nil, nil, err
45+
}
46+
}
47+
3548
return textResult(renderDiff(in, diff)), nil, nil
3649
})
3750
},
3851
}
3952
}
4053

54+
// checkDiffFile verifies the requested file exists in the revision, turning
55+
// Gerrit's ambiguous empty diff into an error that carries the closest
56+
// actual paths. Best-effort: an unverifiable file list keeps the empty diff.
57+
func checkDiffFile(ctx context.Context, c *gerritclient.Client, in getFileDiffInput) error {
58+
files, err := c.ListFiles(ctx, in.Change, in.Revision)
59+
if err != nil {
60+
return nil //nolint:nilerr // verification is best-effort, the empty diff render stands
61+
}
62+
63+
if _, ok := files[in.File]; ok {
64+
return nil
65+
}
66+
67+
res := errFileNotInRevision.WithFields(
68+
fields.F("change", in.Change),
69+
fields.F("revision", revisionLabel(in.Revision)),
70+
fields.F("file", in.File),
71+
)
72+
73+
paths := make([]string, 0, len(files))
74+
for path := range files {
75+
paths = append(paths, path)
76+
}
77+
78+
if near := proposals(in.File, paths); len(near) > 0 {
79+
return res.WithField("did_you_mean", strings.Join(near, ", "))
80+
}
81+
82+
return res.WithField("hint", "list_change_files names the files of the revision")
83+
}
84+
4185
func renderDiff(in getFileDiffInput, diff *gerrit.DiffInfo) string {
4286
root := llmxml.NewElement("diff",
4387
llmxml.Attr("change", in.Change),

internal/tools/post-comments.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ var (
2222
errUnknownReplyTo = e.New("reply targets do not exist on the change")
2323
errCommentNoText = e.New("comment message must not be empty")
2424
errCommentNoFile = e.New("comment file must not be empty")
25+
// errCommentFileUnknown guards against Gerrit's silent acceptance of
26+
// comments on paths outside the change, which no reviewer ever sees.
27+
errCommentFileUnknown = e.New("comment file is not part of the current revision")
2528
)
2629

2730
type inlineComment struct {
@@ -48,10 +51,11 @@ func postComments(c *gerritclient.Client) Tool {
4851
mcp.AddTool(s, &mcp.Tool{
4952
Name: NamePostComments,
5053
Description: "Post a review to a Gerrit change in one call: optional top-level message " +
51-
"plus inline, range, file-level, and reply comments. Replies anchor to comment ids " +
52-
"from get_change_comments; setting resolved on a reply toggles the thread state. " +
53-
"Refused on changes not owned by the authenticated account unless the operator " +
54-
"disabled the own-changes restriction.",
54+
"plus inline, range, file-level, and reply comments. New comments must name a file " +
55+
"of the current revision (or /COMMIT_MSG, /PATCHSET_LEVEL); replies anchor to " +
56+
"comment ids from get_change_comments, and setting resolved on a reply toggles the " +
57+
"thread state. Refused on changes not owned by the authenticated account unless " +
58+
"the operator disabled the own-changes restriction.",
5559
}, func(ctx context.Context, _ *mcp.CallToolRequest, in postCommentsInput,
5660
) (*mcp.CallToolResult, any, error) {
5761
input, err := buildReviewInput(ctx, c, in)
@@ -84,6 +88,10 @@ func buildReviewInput(ctx context.Context, c *gerritclient.Client, in postCommen
8488
return nil, err
8589
}
8690

91+
if err := validateCommentFiles(ctx, c, in); err != nil {
92+
return nil, err
93+
}
94+
8795
if err := validateReplyTargets(ctx, c, in); err != nil {
8896
return nil, err
8997
}
@@ -201,7 +209,66 @@ func validateReplyTargets(ctx context.Context, c *gerritclient.Client, in postCo
201209
return errUnknownReplyTo.WithFields(
202210
fields.F("change", in.Change),
203211
fields.F("ids", strings.Join(missing, ",")),
212+
fields.F("hint", "comment ids may be stale; get_change_comments returns current anchors"),
213+
)
214+
}
215+
216+
return nil
217+
}
218+
219+
// magicPath reports whether the path is one of Gerrit's virtual files,
220+
// commentable on every change.
221+
func magicPath(path string) bool {
222+
switch path {
223+
case "/COMMIT_MSG", "/PATCHSET_LEVEL", "/MERGE_LIST":
224+
return true
225+
default:
226+
return false
227+
}
228+
}
229+
230+
// validateCommentFiles refuses new comments on files absent from the current
231+
// revision — Gerrit silently accepts them and the comment is never seen next
232+
// to code. Replies are exempt: their thread may anchor to a file of an older
233+
// patch set. Costs one file-list fetch, only when new file comments exist.
234+
func validateCommentFiles(ctx context.Context, c *gerritclient.Client, in postCommentsInput) error {
235+
var unchecked []string
236+
237+
for _, comment := range in.Comments {
238+
if comment.ReplyTo == "" && !magicPath(comment.File) {
239+
unchecked = append(unchecked, comment.File)
240+
}
241+
}
242+
243+
if len(unchecked) == 0 {
244+
return nil
245+
}
246+
247+
files, err := c.ListFiles(ctx, in.Change, "")
248+
if err != nil {
249+
return err
250+
}
251+
252+
for _, file := range unchecked {
253+
if _, ok := files[file]; ok {
254+
continue
255+
}
256+
257+
res := errCommentFileUnknown.WithFields(
258+
fields.F("change", in.Change),
259+
fields.F("file", file),
204260
)
261+
262+
paths := make([]string, 0, len(files))
263+
for path := range files {
264+
paths = append(paths, path)
265+
}
266+
267+
if near := proposals(file, paths); len(near) > 0 {
268+
return res.WithField("did_you_mean", strings.Join(near, ", "))
269+
}
270+
271+
return res.WithField("hint", "list_change_files names the files of the current revision")
205272
}
206273

207274
return nil

0 commit comments

Comments
 (0)