snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 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/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 )
19
20 const (
21 serverName = "koment"
22 serverVersion = "0.1.0"
23
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."
29
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."
33
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 )
37
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 }
52
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 }
65
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 }
81
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 }
98
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 }
120
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 }
131
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 }
136
137 type RepositoriesInput struct{}
138
139 type RepositoriesOutput struct {
140 Repositories []RepositorySummary `json:"repositories"`
141 }
142
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 }
152
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 }
159
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 }
164
165 type SearchOutput struct {
166 Query string `json:"query"`
167 Matches []Annotation `json:"matches"`
168 }
169
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 }
188
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 }
197
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 }
204
205 type AnnotationPolicy struct {
206 Exception string `json:"exception"`
207 Acknowledged bool `json:"acknowledged"`
208 }
209
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) }()
214
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 }
239
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) }()
244
245 query := strings.TrimSpace(input.Query)
246 if query == "" {
247 return nil, SearchOutput{}, errors.New("query must not be empty")
248 }
249
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 }
259
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 }
273
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 }
281
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 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close