mcpserver.go

v1.5.0
Doc Versions Source
1
// Package mcpserver exposes Curator's module management operations over the
2
// Model Context Protocol (MCP), so agents can add/delete modules, patterns and
3
// credentials without going through the REST API or admin UI by hand.
4
//
5
// The tools operate directly on the *store.Store — the same writer the running
6
// server uses — and reuse the store's repository verification and validation
7
// logic, mirroring the REST handlers in internal/admin/api.go.
8
package mcpserver
9
10
import (
11
	"context"
12
	"database/sql"
13
	"errors"
14
	"fmt"
15
	"net/http"
16
	"strings"
17
18
	"github.com/go-git/go-git/v6/plumbing/transport"
19
	"github.com/modelcontextprotocol/go-sdk/mcp"
20
21
	"go.bigb.es/curator/internal/git"
22
	"go.bigb.es/curator/internal/store"
23
)
24
25
// New builds an MCP server exposing module, pattern and credential management
26
// tools backed by the given store.
27
func New(st *store.Store, version string) *mcp.Server {
28
	s := mcp.NewServer(&mcp.Implementation{
29
		Name:    "curator",
30
		Version: version,
31
		Title:   "Curator module management",
32
	}, nil)
33
	registerTools(s, st)
34
	return s
35
}
36
37
// HTTPHandler returns an http.Handler serving the MCP server over the
38
// streamable-HTTP transport. Mount it behind the admin auth middleware — it
39
// performs no authentication of its own.
40
func HTTPHandler(st *store.Store, version string) http.Handler {
41
	srv := New(st, version)
42
	return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv }, nil)
43
}
44
45
// tools registers every management tool on the server. Kept as a method-less
46
// function so New and tests share one registration path.
47
func registerTools(s *mcp.Server, st *store.Store) {
48
	h := &handlers{store: st}
49
50
	// Modules.
51
	mcp.AddTool(s, &mcp.Tool{
52
		Name:        "module_list",
53
		Description: "List all exact-match modules configured in Curator.",
54
	}, h.moduleList)
55
	mcp.AddTool(s, &mcp.Tool{
56
		Name:        "module_add",
57
		Description: "Add an exact-match module. The git repository is verified (git ls-remote) before it is stored; verification uses the referenced credential if one is given.",
58
	}, h.moduleAdd)
59
	mcp.AddTool(s, &mcp.Tool{
60
		Name:        "module_update",
61
		Description: "Update an existing module's fields (repo, vcs, web, private, credential). The repository is re-verified before saving.",
62
	}, h.moduleUpdate)
63
	mcp.AddTool(s, &mcp.Tool{
64
		Name:        "module_rename",
65
		Description: "Rename a module (change its import path / primary key) while keeping all other fields.",
66
	}, h.moduleRename)
67
	mcp.AddTool(s, &mcp.Tool{
68
		Name:        "module_delete",
69
		Description: "Delete a module by name.",
70
	}, h.moduleDelete)
71
72
	// Patterns.
73
	mcp.AddTool(s, &mcp.Tool{
74
		Name:        "pattern_list",
75
		Description: "List all module patterns (regex-based fallbacks) ordered by priority.",
76
	}, h.patternList)
77
	mcp.AddTool(s, &mcp.Tool{
78
		Name:        "pattern_add",
79
		Description: "Add a module pattern. The regex is validated before it is stored. Repo/web/vcs support {name} and capture-group ({1}, {2}, …) templates.",
80
	}, h.patternAdd)
81
	mcp.AddTool(s, &mcp.Tool{
82
		Name:        "pattern_update",
83
		Description: "Update an existing module pattern by ID. The regex is re-validated before saving.",
84
	}, h.patternUpdate)
85
	mcp.AddTool(s, &mcp.Tool{
86
		Name:        "pattern_delete",
87
		Description: "Delete a module pattern by ID.",
88
	}, h.patternDelete)
89
90
	// Credentials. Secret data is never returned by list/read tools.
91
	mcp.AddTool(s, &mcp.Tool{
92
		Name:        "credential_list",
93
		Description: "List stored credentials by name and type. Secret data is never returned.",
94
	}, h.credentialList)
95
	mcp.AddTool(s, &mcp.Tool{
96
		Name:        "credential_add",
97
		Description: "Add a credential used to authenticate to private git repositories. Type must be one of: basic, token, ssh.",
98
	}, h.credentialAdd)
99
	mcp.AddTool(s, &mcp.Tool{
100
		Name:        "credential_update",
101
		Description: "Update an existing credential's type and secret data.",
102
	}, h.credentialUpdate)
103
	mcp.AddTool(s, &mcp.Tool{
104
		Name:        "credential_delete",
105
		Description: "Delete a credential by name. Fails if it is still referenced by any module or pattern.",
106
	}, h.credentialDelete)
107
}
108
109
type handlers struct {
110
	store *store.Store
111
}
112
113
// --- Modules ---
114
115
type moduleListOut struct {
116
	Modules []store.ModuleRow `json:"modules"`
117
	Count   int               `json:"count"`
118
}
119
120
func (h *handlers) moduleList(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, moduleListOut, error) {
121
	modules, err := h.store.ListModules()
122
	if err != nil {
123
		return fail[moduleListOut]("list modules: %v", err)
124
	}
125
	if modules == nil {
126
		modules = []store.ModuleRow{}
127
	}
128
	return ok(moduleListOut{Modules: modules, Count: len(modules)},
129
		fmt.Sprintf("%d module(s) configured", len(modules)))
130
}
131
132
type moduleAddIn struct {
133
	Name           string `json:"name" jsonschema:"module import path relative to the server host, e.g. \"mypkg\" or \"team/mypkg\""`
134
	Repo           string `json:"repo" jsonschema:"git repository URL to fetch the module from"`
135
	VCS            string `json:"vcs,omitempty" jsonschema:"version control system; defaults to \"git\""`
136
	Web            string `json:"web,omitempty" jsonschema:"optional web/browse URL for documentation links"`
137
	Private        bool   `json:"private,omitempty" jsonschema:"if true, unauthenticated users get 404 for this module"`
138
	CredentialName string `json:"credential_name,omitempty" jsonschema:"name of a stored credential to authenticate to a private repo"`
139
}
140
141
func (h *handlers) moduleAdd(_ context.Context, _ *mcp.CallToolRequest, in moduleAddIn) (*mcp.CallToolResult, store.ModuleRow, error) {
142
	if in.Name == "" || in.Repo == "" {
143
		return fail[store.ModuleRow]("name and repo are required")
144
	}
145
	m := store.ModuleRow{
146
		Name:           in.Name,
147
		VCS:            defaultVCS(in.VCS),
148
		Repo:           in.Repo,
149
		Web:            in.Web,
150
		Private:        in.Private,
151
		CredentialName: in.CredentialName,
152
	}
153
154
	if err := store.VerifyModule(m.Repo, h.authFor(m.CredentialName)); err != nil {
155
		return fail[store.ModuleRow]("repository verification failed: %v", err)
156
	}
157
	if err := h.store.AddModule(m); err != nil {
158
		return fail[store.ModuleRow]("%s", conflictMessage(err, "module", m.Name))
159
	}
160
	return ok(m, fmt.Sprintf("added module %q -> %s", m.Name, m.Repo))
161
}
162
163
func (h *handlers) moduleUpdate(_ context.Context, _ *mcp.CallToolRequest, in moduleAddIn) (*mcp.CallToolResult, store.ModuleRow, error) {
164
	if in.Name == "" || in.Repo == "" {
165
		return fail[store.ModuleRow]("name and repo are required")
166
	}
167
	m := store.ModuleRow{
168
		Name:           in.Name,
169
		VCS:            defaultVCS(in.VCS),
170
		Repo:           in.Repo,
171
		Web:            in.Web,
172
		Private:        in.Private,
173
		CredentialName: in.CredentialName,
174
	}
175
	if err := store.VerifyModule(m.Repo, h.authFor(m.CredentialName)); err != nil {
176
		return fail[store.ModuleRow]("repository verification failed: %v", err)
177
	}
178
	if err := h.store.UpdateModule(m); err != nil {
179
		if errors.Is(err, sql.ErrNoRows) {
180
			return fail[store.ModuleRow]("module %q not found", m.Name)
181
		}
182
		return fail[store.ModuleRow]("update module: %v", err)
183
	}
184
	return ok(m, fmt.Sprintf("updated module %q", m.Name))
185
}
186
187
type moduleRenameIn struct {
188
	OldName string `json:"old_name" jsonschema:"current module name"`
189
	NewName string `json:"new_name" jsonschema:"new module name"`
190
}
191
192
func (h *handlers) moduleRename(_ context.Context, _ *mcp.CallToolRequest, in moduleRenameIn) (*mcp.CallToolResult, statusOut, error) {
193
	if in.OldName == "" || in.NewName == "" {
194
		return fail[statusOut]("old_name and new_name are required")
195
	}
196
	if err := h.store.RenameModule(in.OldName, in.NewName); err != nil {
197
		if errors.Is(err, sql.ErrNoRows) {
198
			return fail[statusOut]("module %q not found", in.OldName)
199
		}
200
		return fail[statusOut]("%s", conflictMessage(err, "module", in.NewName))
201
	}
202
	return ok(statusOut{OK: true}, fmt.Sprintf("renamed module %q -> %q", in.OldName, in.NewName))
203
}
204
205
type nameIn struct {
206
	Name string `json:"name" jsonschema:"module name"`
207
}
208
209
func (h *handlers) moduleDelete(_ context.Context, _ *mcp.CallToolRequest, in nameIn) (*mcp.CallToolResult, statusOut, error) {
210
	if in.Name == "" {
211
		return fail[statusOut]("name is required")
212
	}
213
	if err := h.store.DeleteModule(in.Name); err != nil {
214
		if errors.Is(err, sql.ErrNoRows) {
215
			return fail[statusOut]("module %q not found", in.Name)
216
		}
217
		return fail[statusOut]("delete module: %v", err)
218
	}
219
	return ok(statusOut{OK: true}, fmt.Sprintf("deleted module %q", in.Name))
220
}
221
222
// --- Patterns ---
223
224
type patternListOut struct {
225
	Patterns []store.PatternRow `json:"patterns"`
226
	Count    int                `json:"count"`
227
}
228
229
func (h *handlers) patternList(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, patternListOut, error) {
230
	patterns, err := h.store.ListPatterns()
231
	if err != nil {
232
		return fail[patternListOut]("list patterns: %v", err)
233
	}
234
	if patterns == nil {
235
		patterns = []store.PatternRow{}
236
	}
237
	return ok(patternListOut{Patterns: patterns, Count: len(patterns)},
238
		fmt.Sprintf("%d pattern(s) configured", len(patterns)))
239
}
240
241
type patternAddIn struct {
242
	Pattern        string `json:"pattern" jsonschema:"regex matched against the requested module path (anchored automatically)"`
243
	Repo           string `json:"repo" jsonschema:"git repository URL template; supports {name} and capture groups {1}, {2}, …"`
244
	VCS            string `json:"vcs,omitempty" jsonschema:"version control system; defaults to \"git\""`
245
	Web            string `json:"web,omitempty" jsonschema:"optional web/browse URL template"`
246
	Private        bool   `json:"private,omitempty" jsonschema:"if true, matched modules are private"`
247
	Priority       int    `json:"priority,omitempty" jsonschema:"lower priority values are matched first"`
248
	CredentialName string `json:"credential_name,omitempty" jsonschema:"name of a stored credential for private repos"`
249
}
250
251
func (h *handlers) patternAdd(_ context.Context, _ *mcp.CallToolRequest, in patternAddIn) (*mcp.CallToolResult, store.PatternRow, error) {
252
	if in.Pattern == "" || in.Repo == "" {
253
		return fail[store.PatternRow]("pattern and repo are required")
254
	}
255
	if err := store.VerifyPattern(in.Pattern); err != nil {
256
		return fail[store.PatternRow]("%v", err)
257
	}
258
	p := store.PatternRow{
259
		Pattern:        in.Pattern,
260
		VCS:            defaultVCS(in.VCS),
261
		Repo:           in.Repo,
262
		Web:            in.Web,
263
		Private:        in.Private,
264
		Priority:       in.Priority,
265
		CredentialName: in.CredentialName,
266
	}
267
	if err := h.store.AddPattern(p); err != nil {
268
		return fail[store.PatternRow]("%s", conflictMessage(err, "pattern", p.Pattern))
269
	}
270
	return ok(p, fmt.Sprintf("added pattern %q -> %s", p.Pattern, p.Repo))
271
}
272
273
type patternUpdateIn struct {
274
	ID int64 `json:"id" jsonschema:"ID of the pattern to update (from pattern_list)"`
275
	patternAddIn
276
}
277
278
func (h *handlers) patternUpdate(_ context.Context, _ *mcp.CallToolRequest, in patternUpdateIn) (*mcp.CallToolResult, store.PatternRow, error) {
279
	if in.ID == 0 {
280
		return fail[store.PatternRow]("id is required")
281
	}
282
	if in.Pattern == "" || in.Repo == "" {
283
		return fail[store.PatternRow]("pattern and repo are required")
284
	}
285
	if err := store.VerifyPattern(in.Pattern); err != nil {
286
		return fail[store.PatternRow]("%v", err)
287
	}
288
	p := store.PatternRow{
289
		ID:             in.ID,
290
		Pattern:        in.Pattern,
291
		VCS:            defaultVCS(in.VCS),
292
		Repo:           in.Repo,
293
		Web:            in.Web,
294
		Private:        in.Private,
295
		Priority:       in.Priority,
296
		CredentialName: in.CredentialName,
297
	}
298
	if err := h.store.UpdatePattern(p); err != nil {
299
		if errors.Is(err, sql.ErrNoRows) {
300
			return fail[store.PatternRow]("pattern %d not found", in.ID)
301
		}
302
		return fail[store.PatternRow]("update pattern: %v", err)
303
	}
304
	return ok(p, fmt.Sprintf("updated pattern %d", p.ID))
305
}
306
307
type idIn struct {
308
	ID int64 `json:"id" jsonschema:"pattern ID"`
309
}
310
311
func (h *handlers) patternDelete(_ context.Context, _ *mcp.CallToolRequest, in idIn) (*mcp.CallToolResult, statusOut, error) {
312
	if in.ID == 0 {
313
		return fail[statusOut]("id is required")
314
	}
315
	if err := h.store.DeletePattern(in.ID); err != nil {
316
		if errors.Is(err, sql.ErrNoRows) {
317
			return fail[statusOut]("pattern %d not found", in.ID)
318
		}
319
		return fail[statusOut]("delete pattern: %v", err)
320
	}
321
	return ok(statusOut{OK: true}, fmt.Sprintf("deleted pattern %d", in.ID))
322
}
323
324
// --- Credentials ---
325
326
type credentialListOut struct {
327
	Credentials []store.CredentialRow `json:"credentials"`
328
	Count       int                   `json:"count"`
329
}
330
331
func (h *handlers) credentialList(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, credentialListOut, error) {
332
	creds, err := h.store.ListCredentials()
333
	if err != nil {
334
		return fail[credentialListOut]("list credentials: %v", err)
335
	}
336
	if creds == nil {
337
		creds = []store.CredentialRow{}
338
	}
339
	// Never leak secret data.
340
	for i := range creds {
341
		creds[i].Data = ""
342
	}
343
	return ok(credentialListOut{Credentials: creds, Count: len(creds)},
344
		fmt.Sprintf("%d credential(s) stored", len(creds)))
345
}
346
347
type credentialAddIn struct {
348
	Name string `json:"name" jsonschema:"unique credential name referenced by modules/patterns"`
349
	Type string `json:"type" jsonschema:"credential type: basic, token, or ssh"`
350
	Data string `json:"data" jsonschema:"secret data: \"user:password\" for basic, a token string for token, or a PEM private key for ssh"`
351
}
352
353
func (h *handlers) credentialAdd(_ context.Context, _ *mcp.CallToolRequest, in credentialAddIn) (*mcp.CallToolResult, statusOut, error) {
354
	if in.Name == "" || in.Type == "" || in.Data == "" {
355
		return fail[statusOut]("name, type, and data are required")
356
	}
357
	if !validCredentialType(in.Type) {
358
		return fail[statusOut]("type must be basic, token, or ssh")
359
	}
360
	if err := h.store.AddCredential(store.CredentialRow{Name: in.Name, Type: in.Type, Data: in.Data}); err != nil {
361
		return fail[statusOut]("%s", conflictMessage(err, "credential", in.Name))
362
	}
363
	return ok(statusOut{OK: true}, fmt.Sprintf("added credential %q (%s)", in.Name, in.Type))
364
}
365
366
func (h *handlers) credentialUpdate(_ context.Context, _ *mcp.CallToolRequest, in credentialAddIn) (*mcp.CallToolResult, statusOut, error) {
367
	if in.Name == "" || in.Type == "" || in.Data == "" {
368
		return fail[statusOut]("name, type, and data are required")
369
	}
370
	if !validCredentialType(in.Type) {
371
		return fail[statusOut]("type must be basic, token, or ssh")
372
	}
373
	if err := h.store.UpdateCredential(store.CredentialRow{Name: in.Name, Type: in.Type, Data: in.Data}); err != nil {
374
		if errors.Is(err, sql.ErrNoRows) {
375
			return fail[statusOut]("credential %q not found", in.Name)
376
		}
377
		return fail[statusOut]("update credential: %v", err)
378
	}
379
	return ok(statusOut{OK: true}, fmt.Sprintf("updated credential %q", in.Name))
380
}
381
382
func (h *handlers) credentialDelete(_ context.Context, _ *mcp.CallToolRequest, in nameIn) (*mcp.CallToolResult, statusOut, error) {
383
	if in.Name == "" {
384
		return fail[statusOut]("name is required")
385
	}
386
	if err := h.store.DeleteCredential(in.Name); err != nil {
387
		if errors.Is(err, sql.ErrNoRows) {
388
			return fail[statusOut]("credential %q not found", in.Name)
389
		}
390
		// Reference-in-use errors carry a useful message; surface them verbatim.
391
		return fail[statusOut]("%v", err)
392
	}
393
	return ok(statusOut{OK: true}, fmt.Sprintf("deleted credential %q", in.Name))
394
}
395
396
// --- Shared helpers ---
397
398
// statusOut is the structured result of an action tool that has no richer payload.
399
type statusOut struct {
400
	OK bool `json:"ok"`
401
}
402
403
// authFor resolves a stored credential into a git AuthMethod, or nil when the
404
// name is empty or unresolvable (mirrors admin.APIHandler.authForCredential).
405
func (h *handlers) authFor(name string) transport.AuthMethod {
406
	if name == "" {
407
		return nil
408
	}
409
	cred, err := h.store.GetCredential(name)
410
	if err != nil {
411
		return nil
412
	}
413
	auth, err := git.AuthMethodFromCredential(cred.Type, cred.Data)
414
	if err != nil {
415
		return nil
416
	}
417
	return auth
418
}
419
420
func defaultVCS(vcs string) string {
421
	if vcs == "" {
422
		return "git"
423
	}
424
	return vcs
425
}
426
427
func validCredentialType(t string) bool {
428
	switch t {
429
	case "basic", "token", "ssh":
430
		return true
431
	default:
432
		return false
433
	}
434
}
435
436
// conflictMessage produces a friendly message for UNIQUE-constraint violations
437
// and passes other errors through.
438
func conflictMessage(err error, kind, name string) string {
439
	if strings.Contains(err.Error(), "UNIQUE constraint") {
440
		return fmt.Sprintf("%s %q already exists", kind, name)
441
	}
442
	return err.Error()
443
}
444
445
// fail returns a tool result flagged as an error, visible to the model, with a
446
// zero-valued structured output. It never returns a protocol-level error.
447
func fail[Out any](format string, a ...any) (*mcp.CallToolResult, Out, error) {
448
	var zero Out
449
	return &mcp.CallToolResult{
450
		IsError: true,
451
		Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, a...)}},
452
	}, zero, nil
453
}
454
455
// ok returns a successful tool result with a human-readable summary; the SDK
456
// populates StructuredContent from out automatically.
457
func ok[Out any](out Out, summary string) (*mcp.CallToolResult, Out, error) {
458
	return &mcp.CallToolResult{
459
		Content: []mcp.Content{&mcp.TextContent{Text: summary}},
460
	}, out, nil
461
}
462

Source Files