Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/mcp-scalable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ github.search_repositories -> corpus.get_repositories
github.sync_repository_context -> jobs.get -> corpus.get_repositories
research.query_deepwiki
github.sync_threads -> jobs.get -> corpus.rank_contribution_candidates
github.sync_thread_facets -> jobs.get -> corpus.get_threads
github.sync_thread_facets -> jobs.get -> corpus.get_thread_facets
corpus.find_precedents -> workflow.find_related_work
workflow.prepare_issue_set
```
Expand Down Expand Up @@ -180,15 +180,21 @@ Batch outputs preserve input order. Each item has one of these statuses:

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

A durable job can succeed while its result is `partial`: job success means the
bounded operation completed and recorded every item outcome. Poll concurrent
jobs together with vectorized `jobs.get`, then retry only retryable items. Never
interpret absent coverage as a zero, a passing check, or a lack of competing
work. New job references carry a semantic `job:<id>` reference,
`poll_after_ms`, and a machine-readable suggested `jobs.get` call.
`poll_after_ms`, and a typed `jobs.get` follow-up with its job ID.

Facet synchronization completes on the same offline read plane: use
`corpus.get_thread_facets` for bounded coverage metadata and follow each
returned `resource_uri` through MCP `resources/read` for the persisted facet
observations. A missing repository, thread, or facet returns a versioned
`recovery` plan whose `then` calls are ordered and carry typed arguments.

Repository and dossier absence have different recovery paths:

Expand Down
4 changes: 2 additions & 2 deletions docs/mcp-tool-redesign.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MCP tool redesign

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

Expand Down
27 changes: 0 additions & 27 deletions docs/migrations/v2-tool-surface.md

This file was deleted.

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/google/jsonschema-go v0.4.3
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pressly/goose/v3 v3.27.3
github.com/sethvargo/go-retry v0.4.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 h1:SEAY9IduDif4iApnZgpFkjFIdo3askSGZVbZIYyTy6I=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
Expand Down
4 changes: 2 additions & 2 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,12 @@ func TestMCPReaderLocalReads(t *testing.T) {

_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "rocket"})
var dossierErr *mcpcontract.ToolError
if !errors.As(err, &dossierErr) || dossierErr.Code != "dossier_not_persisted" || len(dossierErr.SuggestedActions) != 1 || dossierErr.SuggestedActions[0].Tool != mcpcontract.ToolGetRepositories {
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 {
t.Fatalf("MCP dossier before build error = %+v", err)
}
_, err = reader.Dossier(ctx, mcpcontract.RepoInput{Owner: "acme", Repo: "missing"})
var repositoryErr *mcpcontract.ToolError
if !errors.As(err, &repositoryErr) || repositoryErr.Code != "repository_not_indexed" || len(repositoryErr.SuggestedActions) != 1 || repositoryErr.SuggestedActions[0].Tool != mcpcontract.ToolSyncRepositoryContext {
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 {
t.Fatalf("MCP dossier for missing repository error = %+v", err)
}
if _, err := svc.BuildRepositoryDossier(ctx, contracts.RepoRef{Owner: "acme", Repo: "rocket"}); err != nil {
Expand Down
13 changes: 12 additions & 1 deletion internal/app/hydration.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ type HydratedFacet struct {

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

thread, err := c.GetThreadByNumber(ctx, repoProjection.ID, number)
var thread *corpus.Thread
if opts.Kind == "" {
thread, err = c.GetThreadByNumber(ctx, repoProjection.ID, number)
} else {
if opts.Kind != corpus.ThreadKindIssue && opts.Kind != corpus.ThreadKindPullRequest {
hydrateErr = fmt.Errorf("thread kind must be issue or pull_request")
return nil, hydrateErr
}
thread, err = c.GetThread(ctx, repoProjection.ID, opts.Kind, number)
}
if err != nil {
hydrateErr = fmt.Errorf("get thread: %w", err)
return nil, hydrateErr
Expand Down
10 changes: 8 additions & 2 deletions internal/app/hydration_refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,20 @@ import (
// refreshHydrationThreadHeader fetches the current exact thread header before
// child facets. It reuses the sync projection path so hydration cannot derive
// coverage freshness from a stale or missing local header.
func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contracts.RepoRef, number int) error {
func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contracts.RepoRef, kind string, number int) error {
ref := domain.RepoRef{Owner: repo.Owner, Repo: repo.Repo}
if err := ref.Validate(); err != nil {
return err
}
if number <= 0 {
return errors.New("thread number must be positive")
}
if kind != "" && kind != "issue" && kind != "pull_request" {
return errors.New("thread kind must be issue or pull_request")
}
if kind == "" {
kind = "both"
}

c, err := s.openCorpus(ctx)
if err != nil {
Expand All @@ -43,7 +49,7 @@ func (s *Service) refreshHydrationThreadHeader(ctx context.Context, repo contrac
ctx: ctx,
corpus: c,
repositoryID: repository.ID,
kind: "both",
kind: kind,
}
_, err = syncExactThreadHeaders(ctx, reader, ref, []int{number}, newSyncRequestBudget(1), writer)
return err
Expand Down
1 change: 1 addition & 0 deletions internal/app/hydration_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func (s *Service) HydrateRepository(ctx context.Context, repo contracts.RepoRef,
}

hr, err := s.HydrateThread(ctx, repo, t.Number, HydrateOptions{
Kind: t.Kind,
Facets: facets,
MaxPages: maxPages,
})
Expand Down
29 changes: 15 additions & 14 deletions internal/app/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,9 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc
return mcpcontract.DossierOutput{}, mcpcontract.Unavailable(
"repository_not_indexed",
fmt.Sprintf("Repository %s is not present in the local corpus.", ref),
mcpcontract.SuggestedAction{
Tool: mcpcontract.ToolSyncRepositoryContext,
Reason: "Persist repository metadata before requesting local derived artifacts.",
Arguments: &mcpcontract.SuggestedActionArguments{
mcpcontract.ToolCall{
Tool: mcpcontract.ToolSyncRepositoryContext,
Arguments: &mcpcontract.ToolCallArguments{
Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}},
},
},
Expand All @@ -256,10 +255,9 @@ func (r *MCPReader) Dossier(ctx context.Context, in mcpcontract.RepoInput) (mcpc
return mcpcontract.DossierOutput{}, mcpcontract.Unavailable(
"dossier_not_persisted",
fmt.Sprintf("No persisted dossier exists for %s.", ref),
mcpcontract.SuggestedAction{
Tool: mcpcontract.ToolGetRepositories,
Reason: "Read available repository metadata without creating local state.",
Arguments: &mcpcontract.SuggestedActionArguments{
mcpcontract.ToolCall{
Tool: mcpcontract.ToolGetRepositories,
Arguments: &mcpcontract.ToolCallArguments{
Repositories: []mcpcontract.RepositoryRef{{Owner: ref.Owner, Repo: ref.Repo}},
},
},
Expand Down Expand Up @@ -622,11 +620,14 @@ func (r *MCPReader) GetCoverage(ctx context.Context, in mcpcontract.GetCoverageI
out.Status = "partial"
} else if reason != "" {
item.Status, item.Reason = "unavailable", reason
if reason == "not_indexed" {
item.Message = "owner/repo and optional kind/number must identify a repository or exact thread"
switch reason {
case "repository_not_indexed":
item.Message = "target is not present in the local corpus"
item.Recovery = recoveryPlan(reason, item.Message, syncRepositoryContextCall(target.Owner, target.Repo))
case "thread_not_indexed":
item.Message = "target is not present in the local corpus"
item.NextAction = "Synchronize the repository or thread explicitly, then retry this item."
} else {
item.Message = "owner/repo and optional kind/number must identify a repository or exact thread"
item.Recovery = recoveryPlan(reason, item.Message, syncThreadCall(mcpcontract.ThreadRef(target)))
}
out.Status = "partial"
} else {
Expand Down Expand Up @@ -661,7 +662,7 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac
return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get repository: %w", err)
}
if repo == nil {
return mcpcontract.CoverageOutput{}, "not_indexed", nil
return mcpcontract.CoverageOutput{}, "repository_not_indexed", nil
}
var threadID *int64
asOf := repo.SourceUpdatedAt
Expand All @@ -671,7 +672,7 @@ func readCoverageTarget(ctx context.Context, c *corpus.Corpus, target mcpcontrac
return mcpcontract.CoverageOutput{}, "", fmt.Errorf("get thread: %w", err)
}
if thread == nil {
return mcpcontract.CoverageOutput{}, "not_indexed", nil
return mcpcontract.CoverageOutput{}, "thread_not_indexed", nil
}
threadID = &thread.ID
asOf = thread.SourceUpdatedAt
Expand Down
8 changes: 4 additions & 4 deletions internal/app/mcp_advanced_reads.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,11 @@ func (r *MCPReader) FindClusters(ctx context.Context, in mcpcontract.FindCluster
item.Value = &value
case errors.Is(err, errRepositoryNotFound):
item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", err.Error()
item.NextAction = "Call github.sync_repository_context for this repository."
item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(target.Owner, target.Repo))
out.Status = "partial"
case errors.Is(err, errThreadNotFound):
item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", err.Error()
item.NextAction = "Call github.sync_threads for this exact thread."
item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(mcpcontract.ThreadRef(target)))
out.Status = "partial"
default:
item.Status, item.Reason, item.Message = "failed", "read_failed", err.Error()
Expand Down Expand Up @@ -199,11 +199,11 @@ func (r *MCPReader) FindNeighbors(ctx context.Context, in mcpcontract.FindNeighb
item.Value = &value
case errors.Is(err, errRepositoryNotFound):
item.Status, item.Reason, item.Message = "unavailable", "repository_not_indexed", err.Error()
item.NextAction = "Call github.sync_repository_context for this repository."
item.Recovery = recoveryPlan("repository_not_indexed", err.Error(), syncRepositoryContextCall(thread.Owner, thread.Repo))
out.Status = "partial"
case errors.Is(err, errThreadNotFound):
item.Status, item.Reason, item.Message = "unavailable", "thread_not_indexed", err.Error()
item.NextAction = "Call github.sync_threads for this exact thread."
item.Recovery = recoveryPlan("thread_not_indexed", err.Error(), syncThreadCall(mcpcontract.ThreadRef{Owner: thread.Owner, Repo: thread.Repo, Kind: thread.Kind, Number: thread.Number}))
out.Status = "partial"
default:
item.Status, item.Reason, item.Message = "failed", "read_failed", err.Error()
Expand Down
Loading
Loading