internal/mcp/mcp.go
1
// Package mcp serves koment annotations to agents over stdio or HTTP.
2
package mcp
4
import (
5
"context"
6
"errors"
7
"fmt"
8
"strings"
9
"time"
11
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
13
"github.com/janpuc/koment/internal/agentpolicy"
14
"github.com/janpuc/koment/internal/anchor"
15
"github.com/janpuc/koment/internal/application"
16
"github.com/janpuc/koment/internal/metrics"
17
"github.com/janpuc/koment/internal/repository"
18
)
20
const (
21
serverName = "koment"
22
serverVersion = "0.1.0"
24
getDescription = "Annotations recorded against a source file: why it is written this way, " +
25
"what bit someone here before, and which invariants must hold. Read this before editing " +
26
"an unfamiliar file. Every annotation carries a resolution status; heed the warning field. " +
27
"Pass repository when more than one is served - call koment_repositories to see them. " +
28
"Omitting it resolves only if exactly one repository has that path."
30
searchDescription = "Full-text search across annotation bodies. Use it to find recorded rationale " +
31
"by topic when you do not already know which file holds it. Omitting repository searches " +
32
"every repository; each match names the one it came from."
34
repositoriesDescription = "The repositories this koment serves, with their annotation counts. " +
35
"Call this first when you do not know which repository a file belongs to."
36
)
38
func newServer(repositories *repository.Set, recorder metrics.Recorder, writes bool) *sdk.Server {
39
instructions := agentpolicy.Contract()
40
if !writes {
41
instructions += "\n\nThis server is read-only. Restart it with `koment mcp --write` over stdio when mutations are required."
42
}
43
server := sdk.NewServer(&sdk.Implementation{Name: serverName, Version: serverVersion}, &sdk.ServerOptions{Instructions: instructions})
44
sdk.AddTool(server, &sdk.Tool{Name: "koment_get", Description: getDescription}, get(repositories, recorder))
45
sdk.AddTool(server, &sdk.Tool{Name: "koment_search", Description: searchDescription}, search(repositories, recorder))
46
sdk.AddTool(server, &sdk.Tool{Name: "koment_repositories", Description: repositoriesDescription}, list(repositories))
47
if writes {
48
addWriteTools(server, repositories)
49
}
50
return server
51
}
53
func repositoryForGet(repositories *repository.Set, named, file string) (repository.Repository, error) {
54
if named != "" {
55
chosen, found := repositories.Resolve(named)
56
if !found {
57
return repository.Repository{}, fmt.Errorf("no repository %q; served: %s",
58
named, strings.Join(repositories.IDs(), ", "))
59
}
60
return chosen, nil
61
}
62
if only, single := repositories.Only(); single {
63
return only, nil
64
}
66
var candidates []repository.Repository
67
for _, candidate := range repositories.All() {
68
annotations := candidate.Store()
69
candidateFile, err := annotations.FromRoot(file)
70
if err != nil {
71
continue
72
}
73
found, err := annotations.ForFile(candidateFile)
74
if err != nil {
75
return repository.Repository{}, err
76
}
77
if len(found) > 0 {
78
candidates = append(candidates, candidate)
79
}
80
}
82
switch len(candidates) {
83
case 1:
84
return candidates[0], nil
85
case 0:
86
return repository.Repository{}, fmt.Errorf("no repository has annotations for %s; served: %s",
87
file, strings.Join(repositories.IDs(), ", "))
88
default:
89
names := make([]string, 0, len(candidates))
90
for _, candidate := range candidates {
91
names = append(names, candidate.ID)
92
}
93
return repository.Repository{}, fmt.Errorf(
94
"%s is annotated in more than one repository (%s); pass repository to choose",
95
file, strings.Join(names, ", "))
96
}
97
}
99
func list(repositories *repository.Set) sdk.ToolHandlerFor[RepositoriesInput, RepositoriesOutput] {
100
return func(_ context.Context, _ *sdk.CallToolRequest, _ RepositoriesInput) (*sdk.CallToolResult, RepositoriesOutput, error) {
101
summaries := make([]RepositorySummary, 0, repositories.Len())
102
for _, entry := range repositories.All() {
103
snapshot, err := application.BuildSnapshot(entry)
104
if err != nil {
105
return nil, RepositoriesOutput{}, err
106
}
107
counts := map[string]int{}
108
for status, count := range snapshot.Counts() {
109
counts[string(status)] = count
110
}
111
summaries = append(summaries, RepositorySummary{
112
ID: entry.ID, Name: entry.Display(),
113
DefaultBranch: entry.DefaultBranch, CloneURL: entry.CloneURL,
114
Files: len(snapshot.Files), Annotations: counts,
115
})
116
}
117
return nil, RepositoriesOutput{Repositories: summaries}, nil
118
}
119
}
121
func recordMCPCall(recorder metrics.Recorder, tool string, started time.Time, served []Annotation, err error) {
122
outcome := "ok"
123
if err != nil {
124
outcome = "error"
125
}
126
recorder.ObserveMCPCall(tool, outcome, time.Since(started))
127
for _, annotation := range served {
128
recorder.ObserveServed(anchor.Status(annotation.Status))
129
}
130
}
132
type GetInput struct {
133
File string `json:"file" jsonschema:"path of the source file, relative to the repository root"`
134
Repository string `json:"repository,omitempty" jsonschema:"which repository; needed only when several serve this path"`
135
}
137
type RepositoriesInput struct{}
139
type RepositoriesOutput struct {
140
Repositories []RepositorySummary `json:"repositories"`
141
}
143
type RepositorySummary struct {
144
ID string `json:"id"`
145
Name string `json:"name"`
146
DefaultBranch string `json:"default_branch,omitempty"`
147
CloneURL string `json:"clone_url,omitempty"`
148
Commit string `json:"commit,omitempty"`
149
Files int `json:"files"`
150
Annotations map[string]int `json:"annotations"`
151
}
153
type GetOutput struct {
154
Repository string `json:"repository"`
155
Commit string `json:"commit,omitempty"`
156
File string `json:"file"`
157
Annotations []Annotation `json:"annotations"`
158
}
160
type SearchInput struct {
161
Query string `json:"query" jsonschema:"text to look for in annotation bodies, matched case-insensitively"`
162
Repository string `json:"repository,omitempty" jsonschema:"limit to one repository; omit to search all of them"`
163
}
165
type SearchOutput struct {
166
Query string `json:"query"`
167
Matches []Annotation `json:"matches"`
168
}
170
type Annotation struct {
171
Repository string `json:"repository"`
172
Commit string `json:"commit,omitempty"`
173
File string `json:"file"`
174
ID string `json:"id"`
175
Kind string `json:"kind"`
176
Body string `json:"body"`
177
Scope string `json:"scope"`
178
Excerpt string `json:"excerpt,omitempty"`
179
Line int `json:"line,omitempty"`
180
Occurrences int `json:"occurrences"`
181
Created string `json:"created"`
182
Status string `json:"status"`
183
Warning string `json:"warning,omitempty"`
184
Author AnnotationAuthor `json:"author"`
185
Git *AnnotationGit `json:"git,omitempty"`
186
Policy *AnnotationPolicy `json:"policy,omitempty"`
187
}
189
type AnnotationAuthor struct {
190
Name string `json:"name"`
191
Email string `json:"email,omitempty"`
192
Kind string `json:"kind"`
193
Source string `json:"source"`
194
Account string `json:"account,omitempty"`
195
Verified string `json:"verified,omitempty"`
196
}
198
type AnnotationGit struct {
199
Commit string `json:"commit"`
200
Path string `json:"path"`
201
Line int `json:"line,omitempty"`
202
EndLine int `json:"end_line,omitempty"`
203
}
205
type AnnotationPolicy struct {
206
Exception string `json:"exception"`
207
Acknowledged bool `json:"acknowledged"`
208
}
210
func get(repositories *repository.Set, recorder metrics.Recorder) sdk.ToolHandlerFor[GetInput, GetOutput] {
211
return func(_ context.Context, _ *sdk.CallToolRequest, input GetInput) (result *sdk.CallToolResult, out GetOutput, err error) {
212
started := time.Now()
213
defer func() { recordMCPCall(recorder, "koment_get", started, out.Annotations, err) }()
215
chosen, err := repositoryForGet(repositories, input.Repository, input.File)
216
if err != nil {
217
return nil, GetOutput{}, err
218
}
219
annotations := chosen.Store()
220
file, err := annotations.FromRoot(input.File)
221
if err != nil {
222
return nil, GetOutput{}, err
223
}
224
snapshot, err := application.BuildSnapshot(chosen)
225
if err != nil {
226
return nil, GetOutput{}, err
227
}
228
fileSnapshot, found := snapshot.File(file)
229
views := []application.AnnotationView{}
230
if found {
231
views = fileSnapshot.Annotations
232
}
233
return nil, GetOutput{
234
File: file, Repository: chosen.ID,
235
Annotations: describeAll(chosen.ID, views),
236
}, nil
237
}
238
}
240
func search(repositories *repository.Set, recorder metrics.Recorder) sdk.ToolHandlerFor[SearchInput, SearchOutput] {
241
return func(_ context.Context, _ *sdk.CallToolRequest, input SearchInput) (result *sdk.CallToolResult, out SearchOutput, err error) {
242
started := time.Now()
243
defer func() { recordMCPCall(recorder, "koment_search", started, out.Matches, err) }()
245
query := strings.TrimSpace(input.Query)
246
if query == "" {
247
return nil, SearchOutput{}, errors.New("query must not be empty")
248
}
250
searching := repositories.All()
251
if input.Repository != "" {
252
chosen, found := repositories.Resolve(input.Repository)
253
if !found {
254
return nil, SearchOutput{}, fmt.Errorf("no repository %q; served: %s",
255
input.Repository, strings.Join(repositories.IDs(), ", "))
256
}
257
searching = []repository.Repository{chosen}
258
}
260
matches := []Annotation{}
261
for _, entry := range searching {
262
snapshot, err := application.BuildSnapshot(entry)
263
if err != nil {
264
return nil, SearchOutput{}, err
265
}
266
for _, view := range snapshot.Search(query) {
267
matches = append(matches, describe(entry.ID, view))
268
}
269
}
270
return nil, SearchOutput{Query: query, Matches: matches}, nil
271
}
272
}
274
func describeAll(repositoryID string, views []application.AnnotationView) []Annotation {
275
described := make([]Annotation, len(views))
276
for index, view := range views {
277
described[index] = describe(repositoryID, view)
278
}
279
return described
280
}
282
func describe(repositoryID string, view application.AnnotationView) Annotation {
283
record := view.Record
284
described := Annotation{
285
Repository: repositoryID,
286
File: record.File,
287
ID: record.ID,
288
Kind: string(record.Kind),
289
Body: record.Body,
290
Scope: string(record.Anchor.Scope),
291
Excerpt: record.Anchor.Excerpt,
292
Line: view.Line,
293
Occurrences: view.Occurrences,
294
Created: record.Created.Format("2006-01-02"),
295
Status: string(view.Status),
296
Warning: view.Warning,
297
Author: AnnotationAuthor{
298
Name: record.Author.Name, Email: record.Author.Email, Kind: string(record.Author.Kind),
299
Source: string(record.Author.Source), Account: record.Author.Account, Verified: record.Author.Verified,
300
},
301
}
302
if record.Git != nil {
303
described.Git = &AnnotationGit{
304
Commit: record.Git.Commit, Path: record.Git.Path, Line: record.Git.Line, EndLine: record.Git.EndLine,
305
}
306
}
307
if record.Policy != nil {
308
described.Policy = &AnnotationPolicy{Exception: record.Policy.Exception, Acknowledged: record.Policy.Acknowledged}
309
}
310
return described
311
}