snapshot of c000b74a5b09c433f96a69fa102c5c8f6583dff0 Annotations about the code that implements koment.

internal/mcp/mcp.go

1 // Package mcp serves koment annotations to agents over stdio or HTTP.
2 package mcp
3
4 import (
5 "context"
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10
11 sdk "github.com/modelcontextprotocol/go-sdk/mcp"
12
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 )
19
20 const (
21 serverName = "koment"
22
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."
28
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."
32
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 )
36
37 var serverVersion = "unknown"
38
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 }
54
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 }
67
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 }
83
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 }
100
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 }
122
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 }
133
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 }
138
139 type RepositoriesInput struct{}
140
141 type RepositoriesOutput struct {
142 Repositories []RepositorySummary `json:"repositories"`
143 }
144
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 }
154
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 }
161
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 }
166
167 type SearchOutput struct {
168 Query string `json:"query"`
169 Matches []Annotation `json:"matches"`
170 }
171
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 }
190
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 }
199
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 }
206
207 type AnnotationPolicy struct {
208 Exception string `json:"exception"`
209 Acknowledged bool `json:"acknowledged"`
210 }
211
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) }()
216
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 }
241
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) }()
246
247 query := strings.TrimSpace(input.Query)
248 if query == "" {
249 return nil, SearchOutput{}, errors.New("query must not be empty")
250 }
251
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 }
261
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 }
275
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 }
283
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close