Skip to content

Commit 8167077

Browse files
authored
Merge pull request #14 from morluto/codex/harden-mcp-tool-contracts
Define canonical MCP tool contracts
2 parents 326d80f + 9035b42 commit 8167077

12 files changed

Lines changed: 1193 additions & 407 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ gitcontribute mcp serve --transport=stdio
237237

238238
MCP capabilities are deliberately separate:
239239

240+
Tool names use the `gitcontribute.<capability>.<action>` namespace. The server
241+
advertises one canonical name per operation; unnamespaced compatibility aliases
242+
are not registered.
243+
240244
| Capability | Examples |
241245
| --- | --- |
242246
| **Offline reads** | Search, inspect repositories and threads, build research briefs, read dossiers, explain matches, inspect evidence, opportunities, readiness checks, and workflow resources. |
@@ -246,7 +250,7 @@ MCP capabilities are deliberately separate:
246250

247251
Contribution workflow resources and prompts are available for agents:
248252

249-
- `get_readiness` and `gitcontribute://readiness/<opportunity-id>` expose the
253+
- `gitcontribute.corpus.get_readiness` and `gitcontribute://readiness/<opportunity-id>` expose the
250254
same offline readiness report as the CLI.
251255
- `gitcontribute://workflow/contribution/<opportunity-id>` links the local
252256
opportunity, evidence, readiness report, and safe workflow prompts.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/gofrs/flock v0.13.0
1111
github.com/google/go-cmp v0.7.0
1212
github.com/google/go-github/v89 v89.0.0
13+
github.com/google/jsonschema-go v0.4.3
1314
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
1415
github.com/google/uuid v1.6.0
1516
github.com/modelcontextprotocol/go-sdk v1.6.1
@@ -36,7 +37,6 @@ require (
3637
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3738
github.com/godbus/dbus/v5 v5.2.2 // indirect
3839
github.com/google/go-querystring v1.2.0 // indirect
39-
github.com/google/jsonschema-go v0.4.3 // indirect
4040
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4141
github.com/mattn/go-isatty v0.0.20 // indirect
4242
github.com/mattn/go-localereader v0.0.1 // indirect

internal/app/mcp_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,35 @@ func TestMCPReaderSearchCodeIntegration(t *testing.T) {
4747
}
4848
}
4949

50+
func TestDecodeJobJSONPreservesStructuredValues(t *testing.T) {
51+
object, err := decodeJobJSON("request", `{"owner":"acme","limit":20}`)
52+
if err != nil {
53+
t.Fatalf("decode object: %v", err)
54+
}
55+
fields, ok := object.(map[string]any)
56+
if !ok || fields["owner"] != "acme" || fields["limit"] != float64(20) {
57+
t.Fatalf("decoded object = %#v", object)
58+
}
59+
60+
array, err := decodeJobJSON("result", `["one","two"]`)
61+
if err != nil {
62+
t.Fatalf("decode array: %v", err)
63+
}
64+
items, ok := array.([]any)
65+
if !ok || len(items) != 2 {
66+
t.Fatalf("decoded array = %#v", array)
67+
}
68+
69+
if _, err := decodeJobJSON("result", `{broken`); err == nil {
70+
t.Fatal("invalid persisted job JSON was accepted")
71+
}
72+
73+
empty, err := decodeJobJSON("result", "")
74+
if err != nil || empty != nil {
75+
t.Fatalf("decoded empty result = %#v, %v", empty, err)
76+
}
77+
}
78+
5079
func TestMCPReaderRepositorySearchDoesNotFallBackFromMissingExactRepository(t *testing.T) {
5180
ctx := context.Background()
5281
svc := newSearchTestService(t)

internal/app/mcp_v1.go

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
78
"strings"
@@ -321,7 +322,7 @@ func (r *MCPReader) GetJob(ctx context.Context, in mcpserver.GetJobInput) (mcpse
321322
if err != nil {
322323
return mcpserver.GetJobOutput{}, err
323324
}
324-
return jobResultToMCP(job), nil
325+
return jobResultToMCP(job)
325326
}
326327

327328
// CancelJob cancels a durable job and returns its updated state.
@@ -330,16 +331,24 @@ func (r *MCPReader) CancelJob(ctx context.Context, in mcpserver.CancelJobInput)
330331
if err != nil {
331332
return mcpserver.GetJobOutput{}, err
332333
}
333-
return jobResultToMCP(job), nil
334+
return jobResultToMCP(job)
334335
}
335336

336-
func jobResultToMCP(job *cli.JobResult) mcpserver.GetJobOutput {
337+
func jobResultToMCP(job *cli.JobResult) (mcpserver.GetJobOutput, error) {
338+
request, err := decodeJobJSON("request", job.Request)
339+
if err != nil {
340+
return mcpserver.GetJobOutput{}, err
341+
}
342+
result, err := decodeJobJSON("result", job.Result)
343+
if err != nil {
344+
return mcpserver.GetJobOutput{}, err
345+
}
337346
return mcpserver.GetJobOutput{
338347
ID: job.ID,
339348
Kind: job.Kind,
340349
Status: job.Status,
341-
Request: job.Request,
342-
Result: job.Result,
350+
Request: request,
351+
Result: result,
343352
Error: job.Error,
344353
Progress: job.Progress,
345354
Statistics: job.Statistics,
@@ -348,7 +357,17 @@ func jobResultToMCP(job *cli.JobResult) mcpserver.GetJobOutput {
348357
CompletedAt: job.CompletedAt,
349358
CancelledAt: job.CancelledAt,
350359
CancellationRequested: job.Cancellation,
360+
}, nil
361+
}
362+
363+
func decodeJobJSON(field, value string) (any, error) {
364+
var decoded any
365+
if strings.TrimSpace(value) != "" {
366+
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
367+
return nil, fmt.Errorf("decode job %s: %w", field, err)
368+
}
351369
}
370+
return decoded, nil
352371
}
353372

354373
// StartCrawl submits a durable crawl job.

internal/mcpserver/catalog.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package mcpserver
2+
3+
import (
4+
"github.com/google/jsonschema-go/jsonschema"
5+
"github.com/modelcontextprotocol/go-sdk/mcp"
6+
)
7+
8+
// Canonical MCP tool names group operations by capability and side-effect boundary.
9+
const (
10+
ToolSearchRepositories = "gitcontribute.corpus.search_repositories"
11+
ToolSearchThreads = "gitcontribute.corpus.search_threads"
12+
ToolSearchCode = "gitcontribute.corpus.search_code"
13+
ToolGetRepository = "gitcontribute.corpus.get_repository"
14+
ToolGetThread = "gitcontribute.corpus.get_thread"
15+
ToolGetRepositoryDossier = "gitcontribute.corpus.get_repository_dossier"
16+
ToolExplainMatch = "gitcontribute.corpus.explain_match"
17+
ToolGetInvestigation = "gitcontribute.corpus.get_investigation"
18+
ToolListOpportunities = "gitcontribute.corpus.list_opportunities"
19+
ToolGetOpportunity = "gitcontribute.corpus.get_opportunity"
20+
ToolGetEvidence = "gitcontribute.corpus.get_evidence"
21+
ToolGetReadiness = "gitcontribute.corpus.get_readiness"
22+
ToolFindClusters = "gitcontribute.corpus.find_clusters"
23+
ToolFindNeighbors = "gitcontribute.corpus.find_neighbors"
24+
ToolGetCoverage = "gitcontribute.corpus.get_coverage"
25+
ToolGetLens = "gitcontribute.corpus.get_lens"
26+
ToolBuildRepositoryDossier = "gitcontribute.corpus.build_repository_dossier"
27+
ToolGetJob = "gitcontribute.jobs.get"
28+
ToolCancelJob = "gitcontribute.jobs.cancel"
29+
ToolStartCrawl = "gitcontribute.github.start_crawl"
30+
ToolSyncRepository = "gitcontribute.github.sync_repository"
31+
ToolHydrateThread = "gitcontribute.github.hydrate_thread"
32+
ToolHydrateRepository = "gitcontribute.github.hydrate_repository"
33+
ToolCreateWorkspace = "gitcontribute.workspace.create"
34+
ToolDefineValidation = "gitcontribute.validation.define"
35+
ToolRunValidation = "gitcontribute.validation.run"
36+
ToolStartInvestigation = "gitcontribute.workflow.start_investigation"
37+
ToolRecordHypothesis = "gitcontribute.workflow.record_hypothesis"
38+
ToolCheckDuplicates = "gitcontribute.workflow.check_duplicates"
39+
ToolCheckCollisions = "gitcontribute.workflow.check_collisions"
40+
ToolPromoteOpportunity = "gitcontribute.workflow.promote_opportunity"
41+
ToolPrepareContribution = "gitcontribute.workflow.prepare_contribution"
42+
)
43+
44+
var canonicalToolNames = []string{
45+
ToolSearchRepositories,
46+
ToolSearchThreads,
47+
ToolSearchCode,
48+
ToolGetRepository,
49+
ToolGetThread,
50+
ToolGetRepositoryDossier,
51+
ToolExplainMatch,
52+
ToolGetInvestigation,
53+
ToolListOpportunities,
54+
ToolGetOpportunity,
55+
ToolGetEvidence,
56+
ToolGetReadiness,
57+
ToolFindClusters,
58+
ToolFindNeighbors,
59+
ToolGetCoverage,
60+
ToolGetLens,
61+
ToolBuildRepositoryDossier,
62+
ToolGetJob,
63+
ToolCancelJob,
64+
ToolStartCrawl,
65+
ToolSyncRepository,
66+
ToolHydrateThread,
67+
ToolHydrateRepository,
68+
ToolCreateWorkspace,
69+
ToolDefineValidation,
70+
ToolRunValidation,
71+
ToolStartInvestigation,
72+
ToolRecordHypothesis,
73+
ToolCheckDuplicates,
74+
ToolCheckCollisions,
75+
ToolPromoteOpportunity,
76+
ToolPrepareContribution,
77+
}
78+
79+
type catalogTool[In, Out any] struct {
80+
name, title, description string
81+
annotations *mcp.ToolAnnotations
82+
input *jsonschema.Schema
83+
output *jsonschema.Schema
84+
handler mcp.ToolHandlerFor[In, Out]
85+
}
86+
87+
func addCatalogTool[In, Out any](server *mcp.Server, tool catalogTool[In, Out]) {
88+
mcp.AddTool(server, &mcp.Tool{
89+
Name: tool.name,
90+
Title: tool.title,
91+
Description: tool.description,
92+
Annotations: tool.annotations,
93+
InputSchema: tool.input,
94+
OutputSchema: tool.output,
95+
}, tool.handler)
96+
}
97+
98+
func readOnlyAnnotations() *mcp.ToolAnnotations {
99+
return &mcp.ToolAnnotations{
100+
ReadOnlyHint: true,
101+
IdempotentHint: true,
102+
OpenWorldHint: boolPtr(false),
103+
DestructiveHint: boolPtr(false),
104+
}
105+
}
106+
107+
func localWriteAnnotations(idempotent bool) *mcp.ToolAnnotations {
108+
return &mcp.ToolAnnotations{
109+
ReadOnlyHint: false,
110+
IdempotentHint: idempotent,
111+
OpenWorldHint: boolPtr(false),
112+
DestructiveHint: boolPtr(false),
113+
}
114+
}
115+
116+
func networkReadAnnotations() *mcp.ToolAnnotations {
117+
return &mcp.ToolAnnotations{
118+
ReadOnlyHint: false,
119+
IdempotentHint: false,
120+
OpenWorldHint: boolPtr(true),
121+
DestructiveHint: boolPtr(false),
122+
}
123+
}
124+
125+
func executionAnnotations() *mcp.ToolAnnotations {
126+
return &mcp.ToolAnnotations{
127+
ReadOnlyHint: false,
128+
IdempotentHint: false,
129+
OpenWorldHint: boolPtr(false),
130+
DestructiveHint: boolPtr(true),
131+
}
132+
}
133+
134+
func cancellationAnnotations() *mcp.ToolAnnotations {
135+
return &mcp.ToolAnnotations{
136+
ReadOnlyHint: false,
137+
IdempotentHint: true,
138+
OpenWorldHint: boolPtr(false),
139+
DestructiveHint: boolPtr(true),
140+
}
141+
}
142+
143+
func noSchemaCustomization(*jsonschema.Schema) {}

0 commit comments

Comments
 (0)