| 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 | |