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