Skip to content

Commit 20a9762

Browse files
authored
Merge pull request #241 from morluto/agent/gitcontribute-tool-surface
feat(mcp)!: adopt stable SDK and typed recovery surface
2 parents bdb6e23 + 20e4add commit 20a9762

42 files changed

Lines changed: 858 additions & 272 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/mcp-scalable-workflows.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ github.search_repositories -> corpus.get_repositories
1414
github.sync_repository_context -> jobs.get -> corpus.get_repositories
1515
research.query_deepwiki
1616
github.sync_threads -> jobs.get -> corpus.rank_contribution_candidates
17-
github.sync_thread_facets -> jobs.get -> corpus.get_threads
17+
github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets
1818
corpus.find_precedents -> workflow.find_related_work
1919
workflow.prepare_issue_set
2020
```
@@ -180,15 +180,21 @@ Batch outputs preserve input order. Each item has one of these statuses:
180180

181181
- `complete`: use the value;
182182
- `retryable`: retry that item after `retry_after_ms` when present;
183-
- `unavailable`: follow `next_action` or acquire the missing facet explicitly;
183+
- `unavailable`: follow the typed `recovery` plan or acquire the missing facet explicitly;
184184
- `failed`: fix the input or local failure before retrying.
185185

186186
A durable job can succeed while its result is `partial`: job success means the
187187
bounded operation completed and recorded every item outcome. Poll concurrent
188188
jobs together with vectorized `jobs.get`, then retry only retryable items. Never
189189
interpret absent coverage as a zero, a passing check, or a lack of competing
190190
work. New job references carry a semantic `job:<id>` reference,
191-
`poll_after_ms`, and a machine-readable suggested `jobs.get` call.
191+
`poll_after_ms`, and a typed `jobs.get` follow-up with its job ID.
192+
193+
Facet synchronization completes on the same offline read plane: use
194+
`corpus.get_thread_facets` for bounded coverage metadata and follow each
195+
returned `resource_uri` through MCP `resources/read` for the persisted facet
196+
observations. A missing repository, thread, or facet returns a versioned
197+
`recovery` plan whose `then` calls are ordered and carry typed arguments.
192198

193199
Repository and dossier absence have different recovery paths:
194200

docs/mcp-tool-redesign.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# MCP tool redesign
22

3-
GitContribute targets `github.com/modelcontextprotocol/go-sdk`
4-
`v1.7.0-pre.3` and negotiates MCP `2026-07-28`. The server continues to
3+
GitContribute targets `github.com/modelcontextprotocol/go-sdk` `v1.7.0` and
4+
negotiates MCP `2026-07-28`. The server continues to
55
register generic SDK tools so the SDK owns input decoding and output-schema
66
validation at the protocol boundary.
77

docs/migrations/v2-tool-surface.md

Lines changed: 0 additions & 27 deletions
This file was deleted.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ require (
1111
github.com/google/jsonschema-go v0.4.3
1212
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
1313
github.com/google/uuid v1.6.0
14-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3
14+
github.com/modelcontextprotocol/go-sdk v1.7.0
1515
github.com/pelletier/go-toml/v2 v2.4.3
1616
github.com/pressly/goose/v3 v3.27.3
1717
github.com/sethvargo/go-retry v0.4.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
9898
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
9999
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
100100
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
101-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 h1:SEAY9IduDif4iApnZgpFkjFIdo3askSGZVbZIYyTy6I=
102-
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
101+
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
102+
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
103103
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
104104
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
105105
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=

internal/app/app_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,12 +338,12 @@ func TestMCPReaderLocalReads(t *testing.T) {
338338

339339
_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"})
340340
var dossierErr *mcpcontract.ToolError
341-
if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || len(dossierErr.SuggestedActions) != 1 || dossierErr.SuggestedActions[0].Tool != mcpcontract.ToolGetRepositories {
341+
if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || dossierErr.Recovery == nil || len(dossierErr.Recovery.Then) != 1 || dossierErr.Recovery.Then[0].Tool != mcpcontract.ToolGetRepositories {
342342
t.Fatalf("MCP dossier before build error = %+v", err)
343343
}
344344
_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "missing"})
345345
var repositoryErr *mcpcontract.ToolError
346-
if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || len(repositoryErr.SuggestedActions) != 1 || repositoryErr.SuggestedActions[0].Tool != mcpcontract.ToolSyncRepositoryContext {
346+
if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || repositoryErr.Recovery == nil || len(repositoryErr.Recovery.Then) != 1 || repositoryErr.Recovery.Then[0].Tool != mcpcontract.ToolSyncRepositoryContext {
347347
t.Fatalf("MCP dossier for missing repository error = %+v", err)
348348
}
349349
if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil {

internal/app/hydration.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ type HydratedFacet struct {
5555

5656
// HydrateOptions controls selective thread hydration.
5757
type HydrateOptions struct {
58+
// Kind selects the exact issue or pull request when a number is ambiguous.
59+
Kind string
5860
// Facets lists the facets to retrieve. An empty list hydrates all facets
5961
// applicable to the thread kind.
6062
Facets []string
@@ -110,7 +112,16 @@ func (s *Service) HydrateThread(ctx context.Context, repo contracts.RepoRef, num
110112
return nil, hydrateErr
111113
}
112114

113-
thread, err := c.GetThreadByNumber(ctx, repoProjection.ID, number)
115+
var thread *corpus.Thread
116+
if opts.Kind == "" {
117+
thread, err = c.GetThreadByNumber(ctx, repoProjection.ID, number)
118+
} else {
119+
if opts.Kind != corpus.ThreadKindIssue && opts.Kind != corpus.ThreadKindPullRequest {
120+
hydrateErr = fmt.Errorf("thread kind must be issue or pull_request")
121+
return nil, hydrateErr
122+
}
123+
thread, err = c.GetThread(ctx, repoProjection.ID, opts.Kind, number)
124+
}
114125
if err != nil {
115126
hydrateErr = fmt.Errorf("get thread: %w", err)
116127
return nil, hydrateErr

internal/app/hydration_refresh.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,20 @@ import (
1212
// refreshHydrationThreadHeader fetches the current exact thread header before
1313
// child facets. It reuses the sync projection path so hydration cannot derive
1414
// coverage freshness from a stale or missing local header.
15-
func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contracts.RepoRef, number int) error {
15+
func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contracts.RepoRef, kind string, number int) error {
1616
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
1717
if err := ref.Validate(); err != nil {
1818
return err
1919
}
2020
if number <= 0 {
2121
return errors.New("thread number must be positive")
2222
}
23+
if kind != "" && kind != "issue" && kind != "pull_request" {
24+
return errors.New("thread kind must be issue or pull_request")
25+
}
26+
if kind == "" {
27+
kind = "both"
28+
}
2329

2430
c, err := s.openCorpus(ctx)
2531
if err != nil {
@@ -43,7 +49,7 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac
4349
ctx: ctx,
4450
corpus: c,
4551
repositoryID: repository.ID,
46-
kind: "both",
52+
kind: kind,
4753
}
4854
_, err = syncExactThreadHeaders(ctx, reader, ref, []int{number}, newSyncRequestBudget(1), writer)
4955
return err

internal/app/hydration_repo.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func (s *Service) HydrateRepository(ctx context.Context, repo contracts.RepoRef,
112112
}
113113

114114
hr, err := s.HydrateThread(ctx, repo, t.Number, HydrateOptions{
115+
Kind: t.Kind,
115116
Facets: facets,
116117
MaxPages: maxPages,
117118
})

internal/app/mcp.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -239,10 +239,9 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc
239239
return mcpcontract.DossierOutput{}, mcpcontract.Unavailable(
240240
"repository_not_indexed",
241241
fmt.Sprintf("Repository %s is not present in the local corpus.", ref),
242-
mcpcontract.SuggestedAction{
243-
Tool: mcpcontract.ToolSyncRepositoryContext,
244-
Reason: "Persist repository metadata before requesting local derived artifacts.",
245-
Arguments: &mcpcontract.SuggestedActionArguments{
242+
mcpcontract.ToolCall{
243+
Tool: mcpcontract.ToolSyncRepositoryContext,
244+
Arguments: &mcpcontract.ToolCallArguments{
246245
Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}},
247246
},
248247
},
@@ -256,10 +255,9 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc
256255
return mcpcontract.DossierOutput{}, mcpcontract.Unavailable(
257256
"dossier_not_persisted",
258257
fmt.Sprintf("No persisted dossier exists for %s.", ref),
259-
mcpcontract.SuggestedAction{
260-
Tool: mcpcontract.ToolGetRepositories,
261-
Reason: "Read available repository metadata without creating local state.",
262-
Arguments: &mcpcontract.SuggestedActionArguments{
258+
mcpcontract.ToolCall{
259+
Tool: mcpcontract.ToolGetRepositories,
260+
Arguments: &mcpcontract.ToolCallArguments{
263261
Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}},
264262
},
265263
},
@@ -622,11 +620,14 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI
622620
out.Status = "partial"
623621
} else if reason != "" {
624622
item.Status, item.Reason = "unavailable", reason
625-
if reason == "not_indexed" {
623+
item.Message = "owner/repo and optional kind/number must identify a repository or exact thread"
624+
switch reason {
625+
case "repository_not_indexed":
626+
item.Message = "target is not present in the local corpus"
627+
item.Recovery = recoveryPlan(reason, item.Message, syncRepositoryContextCall(target.Owner, target.Repo))
628+
case "thread_not_indexed":
626629
item.Message = "target is not present in the local corpus"
627-
item.NextAction = "Synchronize the repository or thread explicitly, then retry this item."
628-
} else {
629-
item.Message = "owner/repo and optional kind/number must identify a repository or exact thread"
630+
item.Recovery = recoveryPlan(reason, item.Message, syncThreadCall(mcpcontract.ThreadRef(target)))
630631
}
631632
out.Status = "partial"
632633
} else {
@@ -661,7 +662,7 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac
661662
return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get repository: %w", err)
662663
}
663664
if repo == nil {
664-
return mcpcontract.CoverageOutput{}, "not_indexed", nil
665+
return mcpcontract.CoverageOutput{}, "repository_not_indexed", nil
665666
}
666667
var threadID *int64
667668
asOf := repo.SourceUpdatedAt
@@ -671,7 +672,7 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac
671672
return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get thread: %w", err)
672673
}
673674
if thread == nil {
674-
return mcpcontract.CoverageOutput{}, "not_indexed", nil
675+
return mcpcontract.CoverageOutput{}, "thread_not_indexed", nil
675676
}
676677
threadID = &thread.ID
677678
asOf = thread.SourceUpdatedAt

0 commit comments

Comments
 (0)