From 1a93505a0e9b19d155752d567dd32a051aec85fa Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Thu, 21 May 2026 10:25:33 +0200 Subject: [PATCH 1/4] feat: embedded MCP server exposing the admin API as tools Add an opt-in MCP server (server.mcp.enabled, default port 8082) that exposes the entire admin API as ~61 MCP tools over Streamable HTTP. Authentication reuses server.adminPassword as a bearer token via a new middleware.BearerAuth that mirrors AdminAuth's constant-time compare and per-IP rate limiter. Tools call *store.Store directly. There is no HTTP roundtrip back to the admin port, following the spirit of decision #6 ("Admin UI never accesses the User API"). Skills upload/download and backup/restore remain on admin REST because binary archives do not map cleanly to MCP tool inputs. Implementation lives in server/api/mcp/, one tools_.go per admin resource group. Existing admin validators (ValidateFlowStep, ValidateClientConfig, SecretToResponse) are exported and reused so both surfaces enforce the same rules. Secret values are never returned by GET tools (magec_get_secret, magec_list_secrets), matching the admin REST redaction policy. Adds 'omitempty' to id and client.token tags in store.types so the SDK's JSON schema reflection does not mark server-assigned fields as required on the inputs of create_* tools. Docs: website/content/docs/admin-mcp-server.md plus sidebar entry. Decision record: .agents/DECISIONS.md #30. --- .agents/AGENTS.md | 13 +- .agents/DECISIONS.md | 41 ++++ .agents/MULTI_AGENT_ADMIN_API.md | 4 + .agents/TODO.md | 6 +- config.example.yaml | 4 + server/api/admin/clients.go | 7 + server/api/admin/flows.go | 6 + server/api/admin/handler.go | 13 ++ server/api/admin/secrets.go | 8 + server/api/mcp/doc.go | 20 ++ server/api/mcp/errors.go | 22 +++ server/api/mcp/handler.go | 64 +++++++ server/api/mcp/schemas.go | 24 +++ server/api/mcp/server.go | 19 ++ server/api/mcp/server_http_test.go | 78 ++++++++ server/api/mcp/server_test.go | 65 +++++++ server/api/mcp/tools_agents.go | 138 ++++++++++++++ server/api/mcp/tools_agents_test.go | 56 ++++++ server/api/mcp/tools_backends.go | 106 +++++++++++ server/api/mcp/tools_backends_test.go | 96 ++++++++++ server/api/mcp/tools_clients.go | 152 +++++++++++++++ server/api/mcp/tools_commands.go | 97 ++++++++++ server/api/mcp/tools_conversations.go | 228 +++++++++++++++++++++++ server/api/mcp/tools_flows.go | 118 ++++++++++++ server/api/mcp/tools_flows_test.go | 48 +++++ server/api/mcp/tools_mcps.go | 94 ++++++++++ server/api/mcp/tools_memory.go | 167 +++++++++++++++++ server/api/mcp/tools_secrets.go | 122 ++++++++++++ server/api/mcp/tools_secrets_test.go | 74 ++++++++ server/api/mcp/tools_settings.go | 40 ++++ server/api/mcp/tools_skills.go | 55 ++++++ server/api/mcp/tools_voice.go | 46 +++++ server/config/config.go | 24 ++- server/go.sum | 89 +++++++++ server/main.go | 69 ++++++- server/middleware/middleware.go | 49 +++++ server/store/types.go | 20 +- website/content/docs/admin-mcp-server.md | 189 +++++++++++++++++++ website/hugo.toml | 13 +- 39 files changed, 2461 insertions(+), 23 deletions(-) create mode 100644 server/api/mcp/doc.go create mode 100644 server/api/mcp/errors.go create mode 100644 server/api/mcp/handler.go create mode 100644 server/api/mcp/schemas.go create mode 100644 server/api/mcp/server.go create mode 100644 server/api/mcp/server_http_test.go create mode 100644 server/api/mcp/server_test.go create mode 100644 server/api/mcp/tools_agents.go create mode 100644 server/api/mcp/tools_agents_test.go create mode 100644 server/api/mcp/tools_backends.go create mode 100644 server/api/mcp/tools_backends_test.go create mode 100644 server/api/mcp/tools_clients.go create mode 100644 server/api/mcp/tools_commands.go create mode 100644 server/api/mcp/tools_conversations.go create mode 100644 server/api/mcp/tools_flows.go create mode 100644 server/api/mcp/tools_flows_test.go create mode 100644 server/api/mcp/tools_mcps.go create mode 100644 server/api/mcp/tools_memory.go create mode 100644 server/api/mcp/tools_secrets.go create mode 100644 server/api/mcp/tools_secrets_test.go create mode 100644 server/api/mcp/tools_settings.go create mode 100644 server/api/mcp/tools_skills.go create mode 100644 server/api/mcp/tools_voice.go create mode 100644 website/content/docs/admin-mcp-server.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index ccb848e..3746c7b 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -32,7 +32,7 @@ Self-hosted multi-agent AI platform with voice, visual workflows, and tool integ ``` magec/ ├── server/ # Go backend -│ ├── main.go # HTTP server (:8080 user + :8081 admin), routing, middleware +│ ├── main.go # HTTP servers (:8080 user + :8081 admin + :8082 MCP optional), routing, middleware │ ├── agent/ │ │ ├── agent.go # Multi-agent ADK setup, MCP transport, memory tools, ContextGuard wiring, BuildAgentInstance entry point │ │ ├── flow.go # Flow→ADK workflow agent builder (sequential/parallel/loop), per-appearance instance builder, exit_loop wiring, exitWhen evaluator wiring @@ -58,6 +58,10 @@ magec/ │ │ │ ├── backup.go # Backup/restore (tar.gz of data/ directory) │ │ │ ├── voice.go # Voice provider types endpoint │ │ │ └── docs/ # Generated swagger +│ │ ├── mcp/ # Embedded MCP server (decision #30) +│ │ │ ├── handler.go # Handler container + StreamableHTTP handler + tool registration +│ │ │ ├── server.go # registerAll() per-resource group +│ │ │ └── tools_*.go # One file per resource: ~54 magec_* tools │ │ └── user/ # User-facing REST API │ │ ├── handlers.go # Health, ClientInfo, Voice, Webhook, EphemeralArtifact swagger types │ │ ├── doc.go # Swagger metadata @@ -197,6 +201,10 @@ magec/ | | **Backup** | GET `/settings/backup`, POST `/settings/restore` (tar.gz of data/) | | | **Voice** | GET `/voice/types` (registered voice providers with JSON Schemas) | +### MCP Server (port 8082) — Embedded MCP Server + +Opt-in via `server.mcp.enabled: true` in `config.yaml`. Speaks Streamable HTTP at `/` and exposes the entire admin API as ~54 MCP tools (`magec_list_backends`, `magec_create_agent`, etc.). Reuses `server.adminPassword` as bearer token, validated with the same constant-time compare and per-IP rate limiter as `AdminAuth`. Tools call the data store directly — no HTTP roundtrip to the admin port. See `website/content/docs/admin-mcp-server.md` and decision #30. + ## Configuration **Split model**: @@ -212,6 +220,9 @@ server: # adminPassword: "" # Admin API auth (Bearer token) # encryptionKey: "" # Encrypt secrets at rest (AES-256-GCM, independent from adminPassword) # publicURL: "" # Public URL for A2A agent cards (defaults to http://localhost:{port}) + # mcp: # Embedded MCP server (decision #30) + # enabled: false + # port: 8082 # Streamable HTTP; reuses adminPassword for bearer auth voice: ui: diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index 78e0c73..0b7f386 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -883,3 +883,44 @@ no automatic conversion, no "try harder" repair pass. - `frontend/admin-ui/src/views/skills/SkillsList.vue` — opens viewer on click, upload from header button. - `frontend/admin-ui/src/lib/api/skills.js` — upload/get/list/delete/download. - `frontend/admin-ui/src/lib/markdown.js` + style.css `.magec-markdown` block — Magec-flavoured markdown renderer for skill instructions. + +--- + +## 30. Embedded MCP server exposes the admin API as MCP tools + +**Date**: 2026-05-21 +**Status**: Implemented + +Magec ships an embedded MCP server on its own port (default `8082`, configurable via `server.mcp.port`) that exposes every admin API operation as an MCP tool. It uses Streamable HTTP transport (`github.com/modelcontextprotocol/go-sdk/mcp.NewStreamableHTTPHandler`, v1.4.1) and reuses `server.adminPassword` as the bearer token. The handler calls the same `*store.Store` methods admin REST handlers call — there is no HTTP roundtrip back to the admin port. + +**Reason**: An MCP client like Claude Code or mcp-cli should be able to manage a Magec instance the same way the admin UI does. Building this as a separate process would have required a second store reference or a duplicate REST client; reusing decision #6 ("Admin UI never accesses the User API") and going store-direct keeps the access pattern uniform and the dependency tree narrow. + +**Decisions inside this decision**: + +- **Separate port** instead of mounting under `/api/v1/mcp/` on the admin server. The SSE streams MCP uses need a longer write timeout (set to zero) than admin write paths (30s), and CLI clients prefer the root path. The two surfaces also have different rate-limit needs in the future. +- **Typed `mcp.AddTool[In, Out]`** for every tool, not the raw `Server.AddTool`, so the SDK infers schemas from Go structs and validates inputs before the handler runs. The only exception is **flow tools**, which set explicit `*jsonschema.Schema{Type:"object"}` on InputSchema/OutputSchema because `store.FlowStep` is self-referential and the schema generator does not support cycles. +- **Skills upload/download and backup/restore are not exposed**. Both stream binary archives (`.zip`/`.tar.gz`) and do not map cleanly to MCP tool inputs and outputs. Admin REST stays the source of truth for those operations. +- **Secret values are redacted in MCP responses**, matching the admin REST policy via the shared `admin.SecretToResponse` helper. +- **Validators are shared, not duplicated**. `admin.ValidateFlowStep` and `admin.ValidateClientConfig` are exported and reused inside MCP tool handlers so the same rules apply on both surfaces. +- **New `middleware.BearerAuth`** (no `/api/` carve-out) wraps the MCP mux. It reuses the same constant-time compare and per-IP rate limiter as `AdminAuth`. Empty password keeps the server open with a startup warning, same as admin. + +**Do not**: + +- Wire the MCP server through the admin HTTP router. The data store is the only shared dependency; introducing a second HTTP hop would defeat decision #6. +- Introduce a separate password (`server.mcp.token`). One credential keeps the admin and MCP surfaces in lockstep and is consistent with decision #12 (standard `Authorization: Bearer`). +- Add MCP resources or prompts unless a concrete operator need appears. Tools-only keeps every spec-compliant CLI client compatible. +- Expose binary endpoints (skill upload/download, backup/restore) as MCP tools. They stay on admin REST. + +**Files**: + +- `server/main.go` — `startMCPServer`, extended `startGracefulShutdown`. +- `server/config/config.go` — `Server.MCP{Enabled bool, Port int}` plus default `8082`. +- `server/middleware/middleware.go` — new `BearerAuth` middleware. +- `server/api/admin/handler.go` — exported `SessionService()` and `Store()` accessors. +- `server/api/admin/flows.go` — exported `ValidateFlowStep`. +- `server/api/admin/clients.go` — exported `ValidateClientConfig`. +- `server/api/admin/secrets.go` — exported `SecretToResponse`. +- `server/api/mcp/*` — Handler, registration aggregator, one `tools_.go` per admin resource group, smoke + per-resource tests. +- `website/content/docs/admin-mcp-server.md` — operator docs. +- `website/hugo.toml` — sidebar entry under Core Concepts (weight 6). +- `.agents/AGENTS.md`, `.agents/MULTI_AGENT_ADMIN_API.md` — short pointers to the new module. diff --git a/.agents/MULTI_AGENT_ADMIN_API.md b/.agents/MULTI_AGENT_ADMIN_API.md index b978c47..36036a4 100644 --- a/.agents/MULTI_AGENT_ADMIN_API.md +++ b/.agents/MULTI_AGENT_ADMIN_API.md @@ -189,6 +189,10 @@ Same registry pattern as `/clients/types` and `/memory/types`. Decision #21. The backup archive contains `store.json`, `conversations.json`, and `skills/{id}/` files. On restore, the archive must contain a valid `store.json` at the root level. The current data directory is atomically swapped (rename) and both stores are reloaded in memory. +## Embedded MCP Server + +The same admin surface is also exposed as MCP tools at `http://localhost:8082/` when `server.mcp.enabled: true`. Tools live in `server/api/mcp/`; they call `*store.Store` directly without going through the admin HTTP router (decision #30). Authentication reuses `server.adminPassword` as a bearer token. Skill upload/download and backup/restore are intentionally not exposed because they stream binary archives that do not map cleanly to MCP tool inputs. See `website/content/docs/admin-mcp-server.md` for the full tool catalogue and client setup. + ## Persistence - Store persists to `data/store.json` on each write diff --git a/.agents/TODO.md b/.agents/TODO.md index 8d3d476..feaed3b 100644 --- a/.agents/TODO.md +++ b/.agents/TODO.md @@ -2,7 +2,11 @@ ## Recently Completed -### This branch (`feature/lazy-load-skills`, 2026-05-09) +### This branch (`feature/admin-mcp-server`, 2026-05-21) + +- **Embedded admin MCP server** — new opt-in HTTP server on port 8082 (`server.mcp.enabled: true`) exposes the full admin API as ~54 MCP tools (`magec_list_backends`, `magec_create_agent`, etc.) over Streamable HTTP. Auth reuses `server.adminPassword` as a bearer token via the new `middleware.BearerAuth`. Tools call `*store.Store` directly — no HTTP roundtrip to the admin port. Skills upload/download and backup/restore stay on admin REST (binary streams don't map cleanly to tools). Decision #30. Files: `server/api/mcp/`, `server/main.go`, `server/config/config.go`, `server/middleware/middleware.go`, `website/content/docs/admin-mcp-server.md`, `website/hugo.toml`. + +### Earlier branch (`feature/lazy-load-skills`, 2026-05-09) - **Skills as on-disk packages** — skills now live at `data/skills/{slug}/SKILL.md` with optional `references/`, `assets/`, `scripts/` sub-trees. Store keeps only `{id, slug}`; everything else (frontmatter, instructions, resources) is read live from disk. Inline injection into system prompts is gone. Decision #29. - **Per-agent `skilltoolset`** — adopted `google.golang.org/adk/tool/skilltoolset` (v1.2.0). Each agent gets its own `skilltoolset` rooted at `data/skills/` but filtered through `agent/tools/skills.AgentFS`, an `fs.FS` wrapper that whitelists only the slugs linked to the agent. Agents with no linked skills get no toolset at all. diff --git a/config.example.yaml b/config.example.yaml index b235ae2..c7171d5 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -14,6 +14,10 @@ server: # encryptionKey: ${MAGEC_ENCRYPTION_KEY} # Encrypt secrets (API keys) at rest # publicURL: https://magec.example.com # External URL for A2A agent cards (defaults to http://localhost:{port}) + # mcp: # Embedded MCP server (opt-in) + # enabled: false # When true, exposes the admin API as MCP tools + # port: 8082 # Streamable HTTP port; auth reuses adminPassword + voice: ui: enabled: true # Enable/disable Voice UI (default: true) diff --git a/server/api/admin/clients.go b/server/api/admin/clients.go index 8d0482f..2cb9cb8 100644 --- a/server/api/admin/clients.go +++ b/server/api/admin/clients.go @@ -184,6 +184,13 @@ func (h *Handler) listClientTypes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, types) } +// ValidateClientConfig validates a client's type-specific config block against +// the JSON schema registered by the client provider. Exported so the embedded +// MCP server can reuse the same rules. +func ValidateClientConfig(c store.ClientDefinition) error { + return validateClientConfig(c) +} + func validateClientConfig(c store.ClientDefinition) error { raw, err := json.Marshal(c.Config) if err != nil { diff --git a/server/api/admin/flows.go b/server/api/admin/flows.go index b204931..0c745c3 100644 --- a/server/api/admin/flows.go +++ b/server/api/admin/flows.go @@ -128,6 +128,12 @@ func (h *Handler) deleteFlow(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ValidateFlowStep recursively validates a flow step tree. Exported so other +// admission paths (e.g. the embedded MCP server) can reuse the same rules. +func ValidateFlowStep(step *store.FlowStep) error { + return validateFlowStep(step) +} + func validateFlowStep(step *store.FlowStep) error { // Loop-only fields must not appear elsewhere. Catch this before the // type switch so the per-type branches stay focused. diff --git a/server/api/admin/handler.go b/server/api/admin/handler.go index 3d8020a..a7e9e46 100644 --- a/server/api/admin/handler.go +++ b/server/api/admin/handler.go @@ -46,6 +46,19 @@ func (h *Handler) SetSessionService(svc session.Service) { h.sessionService = svc } +// SessionService returns the configured ADK session service (may be nil if +// the launcher has not been initialised). Used by external components that +// need to operate on sessions directly, e.g. the embedded MCP server. +func (h *Handler) SessionService() session.Service { + return h.sessionService +} + +// Store returns the data store. Used by external components that need direct +// access to the same store the admin handlers see. +func (h *Handler) Store() *store.Store { + return h.store +} + // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) diff --git a/server/api/admin/secrets.go b/server/api/admin/secrets.go index 3223a05..352218b 100644 --- a/server/api/admin/secrets.go +++ b/server/api/admin/secrets.go @@ -42,6 +42,14 @@ func secretResponse(s store.Secret) SecretResponse { } } +// SecretToResponse converts a stored secret to its public representation +// (value is never exposed). Exported so external surfaces (e.g. the embedded +// MCP server) can mirror admin's redaction policy without duplicating the +// struct shape. +func SecretToResponse(s store.Secret) SecretResponse { + return secretResponse(s) +} + // listSecrets returns all secrets (values are never exposed). // @Summary List secrets // @Description Returns all configured secrets without their values diff --git a/server/api/mcp/doc.go b/server/api/mcp/doc.go new file mode 100644 index 0000000..8f1605a --- /dev/null +++ b/server/api/mcp/doc.go @@ -0,0 +1,20 @@ +// Package mcp exposes the Magec admin API as MCP tools over Streamable HTTP. +// +// The server is embedded inside magec-server and runs on its own port (default +// 8082) when server.mcp.enabled is true. Authentication reuses +// server.adminPassword as a bearer token, validated with constant-time +// comparison and a per-IP rate limiter (5 failures per minute), the same as +// the admin REST API. +// +// Tools call the data store directly. There is no HTTP roundtrip back to the +// admin port. The package mirrors the layout of server/api/admin: +// +// handler.go Handler container and registration aggregator +// server.go *mcp.Server construction and HTTP wiring +// schemas.go shared input/output structs +// errors.go small error helpers +// tools_.go one file per admin resource group +// +// See decision #30 in .agents/DECISIONS.md and the public docs at +// website/content/docs/admin-mcp-server.md. +package mcp diff --git a/server/api/mcp/errors.go b/server/api/mcp/errors.go new file mode 100644 index 0000000..eb13074 --- /dev/null +++ b/server/api/mcp/errors.go @@ -0,0 +1,22 @@ +package mcp + +import "errors" + +// errValidation is returned when a required field is missing or invalid. +// Wrapping it via fmt.Errorf("...: %w", errValidation(...)) lets the caller +// (mcp tool handler) surface a clean message back to the client; the SDK +// turns the returned error into a tool error response. +type validationError struct{ msg string } + +func (e validationError) Error() string { return e.msg } + +func errValidation(msg string) error { + return validationError{msg: msg} +} + +// IsValidation reports whether err originates from errValidation. Reserved +// for tests; the runtime treats every tool error the same way. +func IsValidation(err error) bool { + var v validationError + return errors.As(err, &v) +} diff --git a/server/api/mcp/handler.go b/server/api/mcp/handler.go new file mode 100644 index 0000000..21a3904 --- /dev/null +++ b/server/api/mcp/handler.go @@ -0,0 +1,64 @@ +package mcp + +import ( + "net/http" + "time" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/api/admin" + "github.com/achetronic/magec/server/store" +) + +// Handler is the embedded MCP server's dependency container. It mirrors +// admin.Handler so MCP tools have access to the same store, conversation +// store, and ADK session service. +type Handler struct { + store *store.Store + conversations *store.ConversationStore + adminHandler *admin.Handler + + server *sdk.Server + streamable *sdk.StreamableHTTPHandler + toolCount int +} + +// NewHandler builds a fresh MCP handler. The admin handler is borrowed only +// for its ADK session service accessor (used by the reset-session tool); it +// can be nil in tests that do not exercise that path. +func NewHandler(s *store.Store, cs *store.ConversationStore, ah *admin.Handler) *Handler { + h := &Handler{store: s, conversations: cs, adminHandler: ah} + h.server = sdk.NewServer(&sdk.Implementation{ + Name: "magec-admin", + Version: "1.0.0", + }, nil) + h.registerAll() + h.streamable = sdk.NewStreamableHTTPHandler( + func(*http.Request) *sdk.Server { return h.server }, + &sdk.StreamableHTTPOptions{ + SessionTimeout: 10 * time.Minute, + }, + ) + return h +} + +// HTTPHandler returns the http.Handler that speaks the MCP Streamable HTTP +// transport. Callers wrap it with their own auth/CORS middleware. +func (h *Handler) HTTPHandler() http.Handler { return h.streamable } + +// Server returns the underlying *mcp.Server. Used by tests that drive the +// server through the in-memory transport. +func (h *Handler) Server() *sdk.Server { return h.server } + +// ToolCount returns the number of tools registered on the server. Used by +// the startup log line and the smoke test. +func (h *Handler) ToolCount() int { return h.toolCount } + +// sessionService returns the borrowed ADK session service, or nil when the +// admin handler is not wired in. +func (h *Handler) sessionService() interface{} { + if h.adminHandler == nil { + return nil + } + return h.adminHandler.SessionService() +} diff --git a/server/api/mcp/schemas.go b/server/api/mcp/schemas.go new file mode 100644 index 0000000..181eddd --- /dev/null +++ b/server/api/mcp/schemas.go @@ -0,0 +1,24 @@ +package mcp + +// idInput is the canonical shape for tools that target a resource by ID. +type idInput struct { + ID string `json:"id" jsonschema:"resource id"` +} + +// agentMCPLinkInput targets the agent/{id}/mcps/{mcpId} link operations. +type agentMCPLinkInput struct { + AgentID string `json:"agentId" jsonschema:"agent id"` + MCPID string `json:"mcpId" jsonschema:"mcp server id"` +} + +// idsOutput is reused by tools that return a list of IDs. +type idsOutput struct { + IDs []string `json:"ids"` +} + +// emptyOutput is returned by tools that have no payload (delete, link, etc.). +type emptyOutput struct { + OK bool `json:"ok"` +} + +var okOutput = emptyOutput{OK: true} diff --git a/server/api/mcp/server.go b/server/api/mcp/server.go new file mode 100644 index 0000000..4bac0b9 --- /dev/null +++ b/server/api/mcp/server.go @@ -0,0 +1,19 @@ +package mcp + +// registerAll wires every tool group on the MCP server. Each helper +// increments h.toolCount so the startup log can report the catalogue size +// without re-introspecting the SDK. +func (h *Handler) registerAll() { + h.registerBackendTools() + h.registerMemoryTools() + h.registerMCPServerTools() + h.registerAgentTools() + h.registerClientTools() + h.registerCommandTools() + h.registerFlowTools() + h.registerSkillTools() + h.registerSettingsTools() + h.registerSecretTools() + h.registerConversationTools() + h.registerVoiceTools() +} diff --git a/server/api/mcp/server_http_test.go b/server/api/mcp/server_http_test.go new file mode 100644 index 0000000..438cc33 --- /dev/null +++ b/server/api/mcp/server_http_test.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/achetronic/magec/server/middleware" +) + +// initFrame is a minimal MCP initialize request used to coerce the SDK into +// a response. We only check that auth is enforced; whether the SDK accepts +// the payload is incidental. +const initFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}` + +func TestHTTP_BearerRequired(t *testing.T) { + h := newTestHandler(t) + srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "secret")) + defer srv.Close() + + // No bearer -> 401. + resp, err := http.Post(srv.URL, "application/json", strings.NewReader(initFrame)) + if err != nil { + t.Fatalf("POST: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no auth: got %d, want 401", resp.StatusCode) + } + + // Wrong bearer -> 401. + req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) + req.Header.Set("Authorization", "Bearer wrong") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("wrong auth: got %d, want 401", resp.StatusCode) + } + + // Correct bearer -> anything but 401. + req, _ = http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + t.Fatalf("authenticated request rejected: status %d", resp.StatusCode) + } +} + +func TestHTTP_OpenModeBypass(t *testing.T) { + h := newTestHandler(t) + // Empty password -> middleware short-circuits, requests pass through. + srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "")) + defer srv.Close() + + req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + t.Fatal("open mode unexpectedly returned 401") + } +} diff --git a/server/api/mcp/server_test.go b/server/api/mcp/server_test.go new file mode 100644 index 0000000..7bd1abc --- /dev/null +++ b/server/api/mcp/server_test.go @@ -0,0 +1,65 @@ +package mcp + +import ( + "context" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestSmoke_ToolsRegistered drives the embedded MCP server through the +// in-memory transport and verifies the full tool catalogue is exposed. +func TestSmoke_ToolsRegistered(t *testing.T) { + h := newTestHandler(t) + ctx := context.Background() + + serverT, clientT := sdk.NewInMemoryTransports() + if _, err := h.server.Connect(ctx, serverT, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + + client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "0"}, nil) + sess, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer sess.Close() + + res, err := sess.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + if len(res.Tools) < 50 { + t.Fatalf("expected at least 50 tools registered, got %d", len(res.Tools)) + } + + // Check that the SDK-reported count matches the handler's internal count. + if got, want := len(res.Tools), h.ToolCount(); got != want { + t.Fatalf("tool count mismatch: ListTools=%d, ToolCount=%d", got, want) + } + + // Spot-check that a representative tool from each major group is present. + required := []string{ + "magec_list_backends", + "magec_create_agent", + "magec_list_flows", + "magec_list_clients", + "magec_list_commands", + "magec_list_skills", + "magec_get_settings", + "magec_list_secrets", + "magec_list_conversations", + "magec_list_voice_types", + "magec_list_mcp_servers", + "magec_list_memory_providers", + } + have := make(map[string]bool, len(res.Tools)) + for _, tool := range res.Tools { + have[tool.Name] = true + } + for _, name := range required { + if !have[name] { + t.Errorf("required tool missing from server: %s", name) + } + } +} diff --git a/server/api/mcp/tools_agents.go b/server/api/mcp/tools_agents.go new file mode 100644 index 0000000..1b27349 --- /dev/null +++ b/server/api/mcp/tools_agents.go @@ -0,0 +1,138 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +type listAgentsOutput struct { + Agents []store.AgentDefinition `json:"agents"` +} + +func (h *Handler) listAgents(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listAgentsOutput, error) { + return nil, listAgentsOutput{Agents: h.store.ListRawAgents()}, nil +} + +func (h *Handler) getAgent(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.AgentDefinition, error) { + a, ok := h.store.GetRawAgent(in.ID) + if !ok { + return nil, store.AgentDefinition{}, fmt.Errorf("get agent: %w", errValidation("not found: "+in.ID)) + } + return nil, a, nil +} + +type createAgentInput struct { + Agent store.AgentDefinition `json:"agent" jsonschema:"agent definition"` +} + +func (h *Handler) createAgent(_ context.Context, _ *sdk.CallToolRequest, in createAgentInput) (*sdk.CallToolResult, store.AgentDefinition, error) { + if in.Agent.Name == "" { + return nil, store.AgentDefinition{}, fmt.Errorf("create agent: %w", errValidation("name is required")) + } + created, err := h.store.CreateAgent(in.Agent) + if err != nil { + return nil, store.AgentDefinition{}, fmt.Errorf("create agent: %w", err) + } + return nil, created, nil +} + +type updateAgentInput struct { + ID string `json:"id" jsonschema:"agent id"` + Agent store.AgentDefinition `json:"agent" jsonschema:"new agent definition"` +} + +func (h *Handler) updateAgent(_ context.Context, _ *sdk.CallToolRequest, in updateAgentInput) (*sdk.CallToolResult, store.AgentDefinition, error) { + if err := h.store.UpdateAgent(in.ID, in.Agent); err != nil { + return nil, store.AgentDefinition{}, fmt.Errorf("update agent: %w", err) + } + updated, _ := h.store.GetRawAgent(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteAgent(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteAgent(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete agent: %w", err) + } + return nil, okOutput, nil +} + +type listAgentMCPsOutput struct { + MCPServers []store.MCPServer `json:"mcpServers"` +} + +func (h *Handler) listAgentMCPs(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, listAgentMCPsOutput, error) { + mcps, err := h.store.ResolveRawAgentMCPs(in.ID) + if err != nil { + return nil, listAgentMCPsOutput{}, fmt.Errorf("list agent mcps: %w", err) + } + return nil, listAgentMCPsOutput{MCPServers: mcps}, nil +} + +func (h *Handler) linkAgentMCP(_ context.Context, _ *sdk.CallToolRequest, in agentMCPLinkInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.LinkAgentMCP(in.AgentID, in.MCPID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("link agent mcp: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) unlinkAgentMCP(_ context.Context, _ *sdk.CallToolRequest, in agentMCPLinkInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.UnlinkAgentMCP(in.AgentID, in.MCPID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("unlink agent mcp: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerAgentTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_agents", Title: "List agents", + Description: "List every agent definition.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listAgents) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_agent", Title: "Get agent", + Description: "Return one agent by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getAgent) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_agent", Title: "Create agent", + Description: "Create a new agent.", + }, h.createAgent) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_agent", Title: "Update agent", + Description: "Replace the agent identified by id.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateAgent) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_agent", Title: "Delete agent", + Description: "Delete an agent by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteAgent) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_agent_mcps", Title: "List agent MCP servers", + Description: "Resolve the MCP servers linked to an agent.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listAgentMCPs) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_link_agent_mcp", Title: "Link MCP to agent", + Description: "Attach an MCP server to an agent.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.linkAgentMCP) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_unlink_agent_mcp", Title: "Unlink MCP from agent", + Description: "Detach an MCP server from an agent.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.unlinkAgentMCP) + h.toolCount++ +} diff --git a/server/api/mcp/tools_agents_test.go b/server/api/mcp/tools_agents_test.go new file mode 100644 index 0000000..cf3aa53 --- /dev/null +++ b/server/api/mcp/tools_agents_test.go @@ -0,0 +1,56 @@ +package mcp + +import ( + "context" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +func TestAgentCRUD_LifecycleAndMCPLinks(t *testing.T) { + h := newTestHandler(t) + ctx := context.Background() + + _, _, err := h.createAgent(ctx, &sdk.CallToolRequest{}, createAgentInput{Agent: store.AgentDefinition{Name: ""}}) + if err == nil || !IsValidation(err) { + t.Fatalf("expected validation error for empty name, got %v", err) + } + + _, agent, err := h.createAgent(ctx, &sdk.CallToolRequest{}, createAgentInput{Agent: store.AgentDefinition{Name: "weather"}}) + if err != nil { + t.Fatalf("create agent: %v", err) + } + if agent.ID == "" { + t.Fatal("agent id is empty") + } + + _, mcp, err := h.createMCPServer(ctx, &sdk.CallToolRequest{}, createMCPInput{Server: store.MCPServer{Name: "weather-mcp", Type: "http", Endpoint: "http://example.com"}}) + if err != nil { + t.Fatalf("create mcp server: %v", err) + } + + if _, _, err := h.linkAgentMCP(ctx, &sdk.CallToolRequest{}, agentMCPLinkInput{AgentID: agent.ID, MCPID: mcp.ID}); err != nil { + t.Fatalf("link agent mcp: %v", err) + } + + _, list, err := h.listAgentMCPs(ctx, &sdk.CallToolRequest{}, idInput{ID: agent.ID}) + if err != nil { + t.Fatalf("list agent mcps: %v", err) + } + if len(list.MCPServers) != 1 || list.MCPServers[0].ID != mcp.ID { + t.Fatalf("unexpected agent mcps: %+v", list) + } + + if _, _, err := h.unlinkAgentMCP(ctx, &sdk.CallToolRequest{}, agentMCPLinkInput{AgentID: agent.ID, MCPID: mcp.ID}); err != nil { + t.Fatalf("unlink agent mcp: %v", err) + } + + if _, _, err := h.deleteAgent(ctx, &sdk.CallToolRequest{}, idInput{ID: agent.ID}); err != nil { + t.Fatalf("delete agent: %v", err) + } + if got := len(h.store.ListRawAgents()); got != 0 { + t.Fatalf("expected 0 agents after delete, got %d", got) + } +} diff --git a/server/api/mcp/tools_backends.go b/server/api/mcp/tools_backends.go new file mode 100644 index 0000000..35f2814 --- /dev/null +++ b/server/api/mcp/tools_backends.go @@ -0,0 +1,106 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +type listBackendsOutput struct { + Backends []store.BackendDefinition `json:"backends"` +} + +func (h *Handler) listBackends(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listBackendsOutput, error) { + return nil, listBackendsOutput{Backends: h.store.ListRawBackends()}, nil +} + +func (h *Handler) getBackend(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.BackendDefinition, error) { + b, ok := h.store.GetRawBackend(in.ID) + if !ok { + return nil, store.BackendDefinition{}, fmt.Errorf("get backend: %w", errValidation("backend not found: "+in.ID)) + } + return nil, b, nil +} + +type createBackendInput struct { + Definition store.BackendDefinition `json:"definition" jsonschema:"backend definition (id is assigned by the server)"` +} + +func (h *Handler) createBackend(_ context.Context, _ *sdk.CallToolRequest, in createBackendInput) (*sdk.CallToolResult, store.BackendDefinition, error) { + if in.Definition.Name == "" { + return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", errValidation("name is required")) + } + if in.Definition.Type == "" { + return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", errValidation("type is required")) + } + created, err := h.store.CreateBackend(in.Definition) + if err != nil { + return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", err) + } + return nil, created, nil +} + +type updateBackendInput struct { + ID string `json:"id" jsonschema:"backend id"` + Definition store.BackendDefinition `json:"definition" jsonschema:"new backend definition (id is taken from the path)"` +} + +func (h *Handler) updateBackend(_ context.Context, _ *sdk.CallToolRequest, in updateBackendInput) (*sdk.CallToolResult, store.BackendDefinition, error) { + if err := h.store.UpdateBackend(in.ID, in.Definition); err != nil { + return nil, store.BackendDefinition{}, fmt.Errorf("update backend: %w", err) + } + updated, _ := h.store.GetRawBackend(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteBackend(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteBackend(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete backend: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerBackendTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_backends", + Title: "List backends", + Description: "List every configured LLM/TTS/transcription backend.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listBackends) + h.toolCount++ + + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_backend", + Title: "Get backend", + Description: "Return one backend by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getBackend) + h.toolCount++ + + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_backend", + Title: "Create backend", + Description: "Create a new backend. The server assigns the id.", + }, h.createBackend) + h.toolCount++ + + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_backend", + Title: "Update backend", + Description: "Replace the backend identified by id with the provided definition.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateBackend) + h.toolCount++ + + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_backend", + Title: "Delete backend", + Description: "Delete a backend by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteBackend) + h.toolCount++ +} diff --git a/server/api/mcp/tools_backends_test.go b/server/api/mcp/tools_backends_test.go new file mode 100644 index 0000000..f8d8dae --- /dev/null +++ b/server/api/mcp/tools_backends_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +func newTestHandler(t *testing.T) *Handler { + t.Helper() + s, err := store.New(filepath.Join(t.TempDir(), "store.json"), "") + if err != nil { + t.Fatalf("store.New: %v", err) + } + return NewHandler(s, nil, nil) +} + +func TestListBackends_Empty(t *testing.T) { + h := newTestHandler(t) + _, out, err := h.listBackends(context.Background(), &sdk.CallToolRequest{}, struct{}{}) + if err != nil { + t.Fatalf("listBackends: %v", err) + } + if len(out.Backends) != 0 { + t.Fatalf("got %d backends, want 0", len(out.Backends)) + } +} + +func TestCreateBackend(t *testing.T) { + cases := []struct { + name string + in store.BackendDefinition + wantErr bool + wantStore int + }{ + {"valid", store.BackendDefinition{Name: "openai-1", Type: "openai"}, false, 1}, + {"missing name", store.BackendDefinition{Type: "openai"}, true, 0}, + {"missing type", store.BackendDefinition{Name: "x"}, true, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newTestHandler(t) + _, out, err := h.createBackend(context.Background(), &sdk.CallToolRequest{}, createBackendInput{Definition: tc.in}) + if (err != nil) != tc.wantErr { + t.Fatalf("err=%v wantErr=%v", err, tc.wantErr) + } + if !tc.wantErr { + if out.ID == "" { + t.Fatal("expected non-empty id") + } + if err != nil && !IsValidation(err) { + t.Fatalf("expected validation error, got %v", err) + } + } + if got := len(h.store.ListRawBackends()); got != tc.wantStore { + t.Fatalf("store size: got %d want %d", got, tc.wantStore) + } + }) + } +} + +func TestUpdateAndDeleteBackend(t *testing.T) { + h := newTestHandler(t) + _, created, err := h.createBackend(context.Background(), &sdk.CallToolRequest{}, createBackendInput{ + Definition: store.BackendDefinition{Name: "openai-1", Type: "openai"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, updated, err := h.updateBackend(context.Background(), &sdk.CallToolRequest{}, updateBackendInput{ + ID: created.ID, + Definition: store.BackendDefinition{Name: "openai-renamed", Type: "openai"}, + }) + if err != nil { + t.Fatalf("update: %v", err) + } + if updated.Name != "openai-renamed" { + t.Fatalf("update did not persist; got name=%q", updated.Name) + } + + if _, _, err := h.deleteBackend(context.Background(), &sdk.CallToolRequest{}, idInput{ID: created.ID}); err != nil { + t.Fatalf("delete: %v", err) + } + if got := len(h.store.ListRawBackends()); got != 0 { + t.Fatalf("after delete: %d backends remain", got) + } + + if _, _, err := h.getBackend(context.Background(), &sdk.CallToolRequest{}, idInput{ID: created.ID}); err == nil { + t.Fatal("expected error fetching deleted backend") + } +} diff --git a/server/api/mcp/tools_clients.go b/server/api/mcp/tools_clients.go new file mode 100644 index 0000000..60c8e10 --- /dev/null +++ b/server/api/mcp/tools_clients.go @@ -0,0 +1,152 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/api/admin" + "github.com/achetronic/magec/server/clients" + "github.com/achetronic/magec/server/store" +) + +type listClientsOutput struct { + Clients []store.ClientDefinition `json:"clients"` +} + +func (h *Handler) listClients(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listClientsOutput, error) { + return nil, listClientsOutput{Clients: h.store.ListRawClients()}, nil +} + +func (h *Handler) getClient(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.ClientDefinition, error) { + c, ok := h.store.GetRawClient(in.ID) + if !ok { + return nil, store.ClientDefinition{}, fmt.Errorf("get client: %w", errValidation("not found: "+in.ID)) + } + return nil, c, nil +} + +type createClientInput struct { + Client store.ClientDefinition `json:"client" jsonschema:"client definition (token is auto-generated)"` +} + +func (h *Handler) createClient(_ context.Context, _ *sdk.CallToolRequest, in createClientInput) (*sdk.CallToolResult, store.ClientDefinition, error) { + c := in.Client + if c.Name == "" { + return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("name is required")) + } + if c.Type == "" { + return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("type is required")) + } + if !clients.ValidType(c.Type) { + return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("unsupported client type: "+c.Type)) + } + if err := admin.ValidateClientConfig(c); err != nil { + return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", err) + } + created, err := h.store.CreateClient(c) + if err != nil { + return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", err) + } + return nil, created, nil +} + +type updateClientInput struct { + ID string `json:"id" jsonschema:"client id"` + Client store.ClientDefinition `json:"client" jsonschema:"new client definition"` +} + +func (h *Handler) updateClient(_ context.Context, _ *sdk.CallToolRequest, in updateClientInput) (*sdk.CallToolResult, store.ClientDefinition, error) { + if in.Client.Type != "" { + if err := admin.ValidateClientConfig(in.Client); err != nil { + return nil, store.ClientDefinition{}, fmt.Errorf("update client: %w", err) + } + } + if err := h.store.UpdateClient(in.ID, in.Client); err != nil { + return nil, store.ClientDefinition{}, fmt.Errorf("update client: %w", err) + } + updated, _ := h.store.GetRawClient(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteClient(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteClient(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete client: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) regenerateClientToken(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.ClientDefinition, error) { + cl, err := h.store.RegenerateClientToken(in.ID) + if err != nil { + return nil, store.ClientDefinition{}, fmt.Errorf("regenerate client token: %w", err) + } + return nil, cl, nil +} + +type clientTypeInfo struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` + ConfigSchema clients.Schema `json:"configSchema"` +} + +type listClientTypesOutput struct { + Types []clientTypeInfo `json:"types"` +} + +func (h *Handler) listClientTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listClientTypesOutput, error) { + var types []clientTypeInfo + for _, p := range clients.All() { + types = append(types, clientTypeInfo{ + Type: p.Type(), + DisplayName: p.DisplayName(), + ConfigSchema: p.ConfigSchema(), + }) + } + return nil, listClientTypesOutput{Types: types}, nil +} + +func (h *Handler) registerClientTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_clients", Title: "List clients", + Description: "List every configured client (telegram, slack, discord, cron, webhook, direct).", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listClients) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_client", Title: "Get client", + Description: "Return one client by id (including the auth token).", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getClient) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_client", Title: "Create client", + Description: "Create a new client. Type must be registered (magec_list_client_types) and config must validate against the type's schema.", + }, h.createClient) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_client", Title: "Update client", + Description: "Replace the client identified by id. Token and id are preserved.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateClient) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_client", Title: "Delete client", + Description: "Delete a client by id, revoking its token.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteClient) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_regenerate_client_token", Title: "Regenerate client token", + Description: "Generate a fresh auth token for a client, invalidating the previous one.", + }, h.regenerateClientToken) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_client_types", Title: "List client types", + Description: "List registered client types with their JSON schemas.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listClientTypes) + h.toolCount++ +} diff --git a/server/api/mcp/tools_commands.go b/server/api/mcp/tools_commands.go new file mode 100644 index 0000000..97c0be6 --- /dev/null +++ b/server/api/mcp/tools_commands.go @@ -0,0 +1,97 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +type listCommandsOutput struct { + Commands []store.Command `json:"commands"` +} + +func (h *Handler) listCommands(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listCommandsOutput, error) { + return nil, listCommandsOutput{Commands: h.store.ListRawCommands()}, nil +} + +func (h *Handler) getCommand(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.Command, error) { + c, ok := h.store.GetRawCommand(in.ID) + if !ok { + return nil, store.Command{}, fmt.Errorf("get command: %w", errValidation("not found: "+in.ID)) + } + return nil, c, nil +} + +type createCommandInput struct { + Command store.Command `json:"command" jsonschema:"command definition"` +} + +func (h *Handler) createCommand(_ context.Context, _ *sdk.CallToolRequest, in createCommandInput) (*sdk.CallToolResult, store.Command, error) { + if in.Command.Name == "" { + return nil, store.Command{}, fmt.Errorf("create command: %w", errValidation("name is required")) + } + if in.Command.Prompt == "" { + return nil, store.Command{}, fmt.Errorf("create command: %w", errValidation("prompt is required")) + } + created, err := h.store.CreateCommand(in.Command) + if err != nil { + return nil, store.Command{}, fmt.Errorf("create command: %w", err) + } + return nil, created, nil +} + +type updateCommandInput struct { + ID string `json:"id" jsonschema:"command id"` + Command store.Command `json:"command" jsonschema:"new command definition"` +} + +func (h *Handler) updateCommand(_ context.Context, _ *sdk.CallToolRequest, in updateCommandInput) (*sdk.CallToolResult, store.Command, error) { + if err := h.store.UpdateCommand(in.ID, in.Command); err != nil { + return nil, store.Command{}, fmt.Errorf("update command: %w", err) + } + updated, _ := h.store.GetRawCommand(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteCommand(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteCommand(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete command: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerCommandTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_commands", Title: "List commands", + Description: "List reusable prompts that can be invoked via cron or webhook clients.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listCommands) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_command", Title: "Get command", + Description: "Return one command by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getCommand) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_command", Title: "Create command", + Description: "Create a new reusable command.", + }, h.createCommand) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_command", Title: "Update command", + Description: "Replace the command identified by id.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateCommand) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_command", Title: "Delete command", + Description: "Delete a command by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteCommand) + h.toolCount++ +} diff --git a/server/api/mcp/tools_conversations.go b/server/api/mcp/tools_conversations.go new file mode 100644 index 0000000..76a8838 --- /dev/null +++ b/server/api/mcp/tools_conversations.go @@ -0,0 +1,228 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + "google.golang.org/adk/session" + + "github.com/achetronic/magec/server/store" +) + +type listConversationsInput struct { + AgentID string `json:"agentId,omitempty" jsonschema:"filter by agent or flow id"` + Source string `json:"source,omitempty" jsonschema:"filter by source (voice-ui, telegram, executor, direct, cron, webhook)"` + ClientID string `json:"clientId,omitempty"` + Perspective string `json:"perspective,omitempty" jsonschema:"filter by perspective (admin or user)"` + Limit int `json:"limit,omitempty" jsonschema:"max items to return (default 30, 0 for all)"` + Offset int `json:"offset,omitempty"` +} + +func (h *Handler) listConversations(_ context.Context, _ *sdk.CallToolRequest, in listConversationsInput) (*sdk.CallToolResult, store.PaginatedResult[store.Conversation], error) { + if h.conversations == nil { + return nil, store.PaginatedResult[store.Conversation]{Items: []store.Conversation{}}, nil + } + limit := in.Limit + if limit == 0 { + limit = 30 + } + return nil, h.conversations.List(in.AgentID, in.Source, in.ClientID, in.Perspective, limit, in.Offset), nil +} + +type getConversationInput struct { + ID string `json:"id" jsonschema:"conversation id"` + MsgLimit int `json:"msgLimit,omitempty" jsonschema:"max messages to return (default 50, 0 for all)"` + MsgOffset int `json:"msgOffset,omitempty"` +} + +type getConversationOutput struct { + Conversation store.Conversation `json:"conversation"` + TotalMessages int `json:"totalMessages"` +} + +func (h *Handler) getConversation(_ context.Context, _ *sdk.CallToolRequest, in getConversationInput) (*sdk.CallToolResult, getConversationOutput, error) { + if h.conversations == nil { + return nil, getConversationOutput{}, fmt.Errorf("get conversation: %w", errValidation("conversation store not initialized")) + } + limit := in.MsgLimit + if limit == 0 { + limit = 50 + } + convo, total, ok := h.conversations.Get(in.ID, limit, in.MsgOffset) + if !ok { + return nil, getConversationOutput{}, fmt.Errorf("get conversation: %w", errValidation("not found: "+in.ID)) + } + return nil, getConversationOutput{Conversation: convo, TotalMessages: total}, nil +} + +func (h *Handler) deleteConversation(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if h.conversations == nil { + return nil, emptyOutput{}, fmt.Errorf("delete conversation: %w", errValidation("conversation store not initialized")) + } + if err := h.conversations.Delete(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete conversation: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) clearConversations(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, emptyOutput, error) { + if h.conversations == nil { + return nil, emptyOutput{}, fmt.Errorf("clear conversations: %w", errValidation("conversation store not initialized")) + } + if err := h.conversations.Clear(); err != nil { + return nil, emptyOutput{}, fmt.Errorf("clear conversations: %w", err) + } + return nil, okOutput, nil +} + +type conversationStatsOutput struct { + Total int `json:"total"` + BySources map[string]int `json:"bySources"` + ByAgents map[string]int `json:"byAgents"` +} + +func (h *Handler) conversationStats(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, conversationStatsOutput, error) { + if h.conversations == nil { + return nil, conversationStatsOutput{BySources: map[string]int{}, ByAgents: map[string]int{}}, nil + } + all := h.conversations.List("", "", "", "", 0, 0) + source := map[string]int{} + agent := map[string]int{} + for _, c := range all.Items { + source[c.Source]++ + if c.AgentName != "" { + agent[c.AgentName]++ + } else { + agent[c.AgentID]++ + } + } + return nil, conversationStatsOutput{Total: all.Total, BySources: source, ByAgents: agent}, nil +} + +type updateConversationSummaryInput struct { + ID string `json:"id" jsonschema:"conversation id"` + Summary string `json:"summary" jsonschema:"new summary text"` +} + +func (h *Handler) updateConversationSummary(_ context.Context, _ *sdk.CallToolRequest, in updateConversationSummaryInput) (*sdk.CallToolResult, store.Conversation, error) { + if h.conversations == nil { + return nil, store.Conversation{}, fmt.Errorf("update conversation summary: %w", errValidation("conversation store not initialized")) + } + if err := h.conversations.SetSummary(in.ID, in.Summary); err != nil { + return nil, store.Conversation{}, fmt.Errorf("update conversation summary: %w", err) + } + convo, _, _ := h.conversations.Get(in.ID, 0, 0) + return nil, convo, nil +} + +type findPairOutput struct { + PairID string `json:"pairId,omitempty"` +} + +func (h *Handler) findConversationPair(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, findPairOutput, error) { + if h.conversations == nil { + return nil, findPairOutput{}, fmt.Errorf("find conversation pair: %w", errValidation("conversation store not initialized")) + } + convo, _, ok := h.conversations.Get(in.ID, 0, 0) + if !ok { + return nil, findPairOutput{}, fmt.Errorf("find conversation pair: %w", errValidation("not found: "+in.ID)) + } + pair, found := h.conversations.FindExactPair(convo.ID, convo.SessionID, convo.AgentID, convo.Perspective) + if !found { + return nil, findPairOutput{}, nil + } + return nil, findPairOutput{PairID: pair.ID}, nil +} + +type resetSessionOutput struct { + Message string `json:"message"` + AgentID string `json:"agentId"` + SessionID string `json:"sessionId"` +} + +func (h *Handler) resetConversationSession(ctx context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, resetSessionOutput, error) { + if h.conversations == nil { + return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation store not initialized")) + } + svc, _ := h.sessionService().(session.Service) + if svc == nil { + return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("session service not available")) + } + convo, _, ok := h.conversations.Get(in.ID, 0, 0) + if !ok { + return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation not found: "+in.ID)) + } + if convo.AgentID == "" || convo.SessionID == "" { + return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation has no agent or session id")) + } + userID := convo.UserID + if userID == "" { + userID = "user" + } + if err := svc.Delete(ctx, &session.DeleteRequest{ + AppName: convo.AgentID, + UserID: userID, + SessionID: convo.SessionID, + }); err != nil { + return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", err) + } + _ = h.conversations.CloseBySession(convo.SessionID, convo.AgentID, "admin") + _ = h.conversations.CloseBySession(convo.SessionID, convo.AgentID, "user") + return nil, resetSessionOutput{ + Message: "session reset successfully", + AgentID: convo.AgentID, + SessionID: convo.SessionID, + }, nil +} + +func (h *Handler) registerConversationTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_conversations", Title: "List conversations", + Description: "Paginated list of conversation audit logs (newest first). Filters by agent, source, client, perspective.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listConversations) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_conversation", Title: "Get conversation", + Description: "Return one conversation by id with paginated messages.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getConversation) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_conversation", Title: "Delete conversation", + Description: "Delete a conversation by id (also deletes the paired perspective).", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteConversation) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_clear_conversations", Title: "Clear all conversations", + Description: "Delete every conversation audit log. Does not affect ADK sessions.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive}, + }, h.clearConversations) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_conversation_stats", Title: "Conversation stats", + Description: "Return totals broken down by source and agent.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.conversationStats) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_conversation_summary", Title: "Update conversation summary", + Description: "Set the summary text used by context window compaction.", + }, h.updateConversationSummary) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_find_conversation_pair", Title: "Find conversation pair", + Description: "Return the id of the conversation that records the opposite perspective (admin/user) of a given conversation.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.findConversationPair) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_reset_conversation_session", Title: "Reset ADK session for a conversation", + Description: "Delete the ADK session associated with a conversation so the next message starts a fresh one. Requires the ADK session service to be wired in.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive}, + }, h.resetConversationSession) + h.toolCount++ +} diff --git a/server/api/mcp/tools_flows.go b/server/api/mcp/tools_flows.go new file mode 100644 index 0000000..fbec7ed --- /dev/null +++ b/server/api/mcp/tools_flows.go @@ -0,0 +1,118 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/google/jsonschema-go/jsonschema" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/api/admin" + "github.com/achetronic/magec/server/store" +) + +// openObjectSchema is a permissive JSON Schema that accepts any object. We +// pin it on flow tools because store.FlowStep is self-referential (each step +// has Steps []FlowStep), and the SDK's reflection-based schema generator does +// not support cycles. Tool argument validation falls back to "any object" for +// these tools; the tool handler still receives a typed store.FlowDefinition +// via the SDK's JSON unmarshalling, so runtime behaviour is unchanged. +func openObjectSchema() *jsonschema.Schema { + return &jsonschema.Schema{Type: "object"} +} + +type listFlowsOutput struct { + Flows []store.FlowDefinition `json:"flows"` +} + +func (h *Handler) listFlows(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listFlowsOutput, error) { + return nil, listFlowsOutput{Flows: h.store.ListRawFlows()}, nil +} + +func (h *Handler) getFlow(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.FlowDefinition, error) { + f, ok := h.store.GetRawFlow(in.ID) + if !ok { + return nil, store.FlowDefinition{}, fmt.Errorf("get flow: %w", errValidation("not found: "+in.ID)) + } + return nil, f, nil +} + +type createFlowInput struct { + Flow store.FlowDefinition `json:"flow" jsonschema:"flow definition"` +} + +func (h *Handler) createFlow(_ context.Context, _ *sdk.CallToolRequest, in createFlowInput) (*sdk.CallToolResult, store.FlowDefinition, error) { + if in.Flow.Name == "" { + return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", errValidation("name is required")) + } + if err := admin.ValidateFlowStep(&in.Flow.Root); err != nil { + return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", err) + } + created, err := h.store.CreateFlow(in.Flow) + if err != nil { + return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", err) + } + return nil, created, nil +} + +type updateFlowInput struct { + ID string `json:"id" jsonschema:"flow id"` + Flow store.FlowDefinition `json:"flow" jsonschema:"new flow definition"` +} + +func (h *Handler) updateFlow(_ context.Context, _ *sdk.CallToolRequest, in updateFlowInput) (*sdk.CallToolResult, store.FlowDefinition, error) { + if err := admin.ValidateFlowStep(&in.Flow.Root); err != nil { + return nil, store.FlowDefinition{}, fmt.Errorf("update flow: %w", err) + } + if err := h.store.UpdateFlow(in.ID, in.Flow); err != nil { + return nil, store.FlowDefinition{}, fmt.Errorf("update flow: %w", err) + } + updated, _ := h.store.GetRawFlow(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteFlow(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteFlow(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete flow: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerFlowTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_flows", Title: "List flows", + Description: "List every multi-agent flow.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + OutputSchema: openObjectSchema(), + }, h.listFlows) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_flow", Title: "Get flow", + Description: "Return one flow by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + OutputSchema: openObjectSchema(), + }, h.getFlow) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_flow", Title: "Create flow", + Description: "Create a new flow. The Root step tree is validated recursively. Loop steps must set at most one of exitLoop or exitWhen.", + InputSchema: openObjectSchema(), + OutputSchema: openObjectSchema(), + }, h.createFlow) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_flow", Title: "Update flow", + Description: "Replace the flow identified by id. The Root step tree is validated recursively.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + InputSchema: openObjectSchema(), + OutputSchema: openObjectSchema(), + }, h.updateFlow) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_flow", Title: "Delete flow", + Description: "Delete a flow by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteFlow) + h.toolCount++ +} diff --git a/server/api/mcp/tools_flows_test.go b/server/api/mcp/tools_flows_test.go new file mode 100644 index 0000000..d1f0f30 --- /dev/null +++ b/server/api/mcp/tools_flows_test.go @@ -0,0 +1,48 @@ +package mcp + +import ( + "context" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +func TestCreateFlow_ValidatesRootStep(t *testing.T) { + h := newTestHandler(t) + ctx := context.Background() + + // Loop step with both exitLoop and exitWhen → admin validator rejects it. + bad := store.FlowDefinition{ + Name: "bad", + Root: store.FlowStep{ + Type: store.FlowStepLoop, + ExitLoop: true, + ExitWhen: "state.done == true", + Steps: []store.FlowStep{ + {Type: store.FlowStepAgent, AgentID: "agent-1"}, + }, + }, + } + if _, _, err := h.createFlow(ctx, &sdk.CallToolRequest{}, createFlowInput{Flow: bad}); err == nil { + t.Fatal("expected validation error for mutually exclusive exitLoop/exitWhen") + } + + good := store.FlowDefinition{ + Name: "good", + Root: store.FlowStep{ + Type: store.FlowStepSequential, + Steps: []store.FlowStep{ + {Type: store.FlowStepAgent, AgentID: "agent-1"}, + }, + }, + } + _, created, err := h.createFlow(ctx, &sdk.CallToolRequest{}, createFlowInput{Flow: good}) + if err != nil { + t.Fatalf("create good flow: %v", err) + } + if created.ID == "" { + t.Fatal("expected non-empty flow id") + } +} diff --git a/server/api/mcp/tools_mcps.go b/server/api/mcp/tools_mcps.go new file mode 100644 index 0000000..1a5e10c --- /dev/null +++ b/server/api/mcp/tools_mcps.go @@ -0,0 +1,94 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +type listMCPServersOutput struct { + Servers []store.MCPServer `json:"servers"` +} + +func (h *Handler) listMCPServers(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMCPServersOutput, error) { + return nil, listMCPServersOutput{Servers: h.store.ListRawMCPServers()}, nil +} + +func (h *Handler) getMCPServer(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.MCPServer, error) { + m, ok := h.store.GetRawMCPServer(in.ID) + if !ok { + return nil, store.MCPServer{}, fmt.Errorf("get mcp server: %w", errValidation("not found: "+in.ID)) + } + return nil, m, nil +} + +type createMCPInput struct { + Server store.MCPServer `json:"server" jsonschema:"mcp server definition"` +} + +func (h *Handler) createMCPServer(_ context.Context, _ *sdk.CallToolRequest, in createMCPInput) (*sdk.CallToolResult, store.MCPServer, error) { + if in.Server.Name == "" { + return nil, store.MCPServer{}, fmt.Errorf("create mcp server: %w", errValidation("name is required")) + } + created, err := h.store.CreateMCPServer(in.Server) + if err != nil { + return nil, store.MCPServer{}, fmt.Errorf("create mcp server: %w", err) + } + return nil, created, nil +} + +type updateMCPInput struct { + ID string `json:"id" jsonschema:"mcp server id"` + Server store.MCPServer `json:"server" jsonschema:"new mcp server definition"` +} + +func (h *Handler) updateMCPServer(_ context.Context, _ *sdk.CallToolRequest, in updateMCPInput) (*sdk.CallToolResult, store.MCPServer, error) { + if err := h.store.UpdateMCPServer(in.ID, in.Server); err != nil { + return nil, store.MCPServer{}, fmt.Errorf("update mcp server: %w", err) + } + updated, _ := h.store.GetRawMCPServer(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteMCPServer(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteMCPServer(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete mcp server: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerMCPServerTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_mcp_servers", Title: "List MCP servers", + Description: "List every MCP server registered as a tool source. Not to be confused with the embedded MCP server you are currently talking to.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listMCPServers) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_mcp_server", Title: "Get MCP server", + Description: "Return one registered MCP server by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getMCPServer) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_mcp_server", Title: "Create MCP server", + Description: "Register an external MCP server. Use type=http with endpoint+headers, or type=stdio with command+args+env.", + }, h.createMCPServer) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_mcp_server", Title: "Update MCP server", + Description: "Replace the MCP server identified by id.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateMCPServer) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_mcp_server", Title: "Delete MCP server", + Description: "Delete a registered MCP server by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteMCPServer) + h.toolCount++ +} diff --git a/server/api/mcp/tools_memory.go b/server/api/mcp/tools_memory.go new file mode 100644 index 0000000..8d552d0 --- /dev/null +++ b/server/api/mcp/tools_memory.go @@ -0,0 +1,167 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/memory" + "github.com/achetronic/magec/server/store" +) + +type listMemoryOutput struct { + Providers []store.MemoryProvider `json:"providers"` +} + +func (h *Handler) listMemoryProviders(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMemoryOutput, error) { + return nil, listMemoryOutput{Providers: h.store.ListRawMemoryProviders()}, nil +} + +func (h *Handler) getMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.MemoryProvider, error) { + m, ok := h.store.GetRawMemoryProvider(in.ID) + if !ok { + return nil, store.MemoryProvider{}, fmt.Errorf("get memory provider: %w", errValidation("not found: "+in.ID)) + } + return nil, m, nil +} + +type createMemoryInput struct { + Provider store.MemoryProvider `json:"provider" jsonschema:"memory provider definition"` +} + +func (h *Handler) createMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in createMemoryInput) (*sdk.CallToolResult, store.MemoryProvider, error) { + m := in.Provider + if m.Name == "" { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("name is required")) + } + if m.Type == "" { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("type is required")) + } + if m.Category == "" { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("category is required")) + } + if !memory.ValidType(m.Type) { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("unsupported provider type: "+m.Type)) + } + if !memory.ValidTypeForCategory(m.Type, memory.Category(m.Category)) { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation(fmt.Sprintf("provider type %q does not support category %q", m.Type, m.Category))) + } + created, err := h.store.CreateMemoryProvider(m) + if err != nil { + return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", err) + } + return nil, created, nil +} + +type updateMemoryInput struct { + ID string `json:"id" jsonschema:"memory provider id"` + Provider store.MemoryProvider `json:"provider" jsonschema:"new memory provider definition"` +} + +func (h *Handler) updateMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in updateMemoryInput) (*sdk.CallToolResult, store.MemoryProvider, error) { + if err := h.store.UpdateMemoryProvider(in.ID, in.Provider); err != nil { + return nil, store.MemoryProvider{}, fmt.Errorf("update memory provider: %w", err) + } + updated, _ := h.store.GetRawMemoryProvider(in.ID) + return nil, updated, nil +} + +func (h *Handler) deleteMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteMemoryProvider(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete memory provider: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) checkMemoryHealth(ctx context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, memory.HealthResult, error) { + m, ok := h.store.GetMemoryProvider(in.ID) + if !ok { + return nil, memory.HealthResult{}, fmt.Errorf("check memory health: %w", errValidation("not found: "+in.ID)) + } + provider := memory.Get(m.Type) + if provider == nil { + return nil, memory.HealthResult{Healthy: false, Detail: "unsupported provider type: " + m.Type}, nil + } + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + cfg := m.Config + if cfg == nil { + cfg = map[string]interface{}{} + } + return nil, provider.Ping(checkCtx, cfg), nil +} + +type memoryTypeInfo struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` + Categories []string `json:"categories"` + ConfigSchema memory.Schema `json:"configSchema"` +} + +type listMemoryTypesOutput struct { + Types []memoryTypeInfo `json:"types"` +} + +func (h *Handler) listMemoryTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMemoryTypesOutput, error) { + var types []memoryTypeInfo + for _, p := range memory.All() { + cats := make([]string, len(p.SupportedCategories())) + for i, c := range p.SupportedCategories() { + cats[i] = string(c) + } + types = append(types, memoryTypeInfo{ + Type: p.Type(), + DisplayName: p.DisplayName(), + Categories: cats, + ConfigSchema: p.ConfigSchema(), + }) + } + return nil, listMemoryTypesOutput{Types: types}, nil +} + +func (h *Handler) registerMemoryTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_memory_providers", Title: "List memory providers", + Description: "List every configured memory provider (Redis session, Postgres long-term, etc.).", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listMemoryProviders) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_memory_provider", Title: "Get memory provider", + Description: "Return one memory provider by id.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getMemoryProvider) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_memory_provider", Title: "Create memory provider", + Description: "Create a new memory provider. Type must be registered (see magec_list_memory_types) and compatible with the category.", + }, h.createMemoryProvider) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_memory_provider", Title: "Update memory provider", + Description: "Replace the memory provider identified by id.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateMemoryProvider) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_memory_provider", Title: "Delete memory provider", + Description: "Delete a memory provider by id.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteMemoryProvider) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_check_memory_health", Title: "Check memory provider health", + Description: "Ping the memory provider's backing service (5s timeout). Returns connectivity status.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.checkMemoryHealth) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_memory_types", Title: "List memory provider types", + Description: "List registered memory provider types with their JSON schemas.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listMemoryTypes) + h.toolCount++ +} diff --git a/server/api/mcp/tools_secrets.go b/server/api/mcp/tools_secrets.go new file mode 100644 index 0000000..b49bbd6 --- /dev/null +++ b/server/api/mcp/tools_secrets.go @@ -0,0 +1,122 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/api/admin" + "github.com/achetronic/magec/server/store" +) + +type listSecretsOutput struct { + Secrets []admin.SecretResponse `json:"secrets"` +} + +func (h *Handler) listSecrets(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listSecretsOutput, error) { + stored := h.store.ListSecrets() + out := make([]admin.SecretResponse, len(stored)) + for i, s := range stored { + out[i] = admin.SecretToResponse(s) + } + return nil, listSecretsOutput{Secrets: out}, nil +} + +func (h *Handler) getSecret(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, admin.SecretResponse, error) { + s, ok := h.store.GetSecret(in.ID) + if !ok { + return nil, admin.SecretResponse{}, fmt.Errorf("get secret: %w", errValidation("not found: "+in.ID)) + } + return nil, admin.SecretToResponse(s), nil +} + +type createSecretInput struct { + Name string `json:"name" jsonschema:"human-readable secret name"` + Key string `json:"key" jsonschema:"environment variable name (e.g. OPENAI_API_KEY)"` + Value string `json:"value" jsonschema:"secret value (never returned in subsequent reads)"` + Description string `json:"description,omitempty"` +} + +func (h *Handler) createSecret(_ context.Context, _ *sdk.CallToolRequest, in createSecretInput) (*sdk.CallToolResult, admin.SecretResponse, error) { + if in.Name == "" { + return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("name is required")) + } + if in.Key == "" { + return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("key is required")) + } + if in.Value == "" { + return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("value is required")) + } + s, err := h.store.CreateSecret(store.Secret{ + Name: in.Name, + Key: in.Key, + Value: in.Value, + Description: in.Description, + }) + if err != nil { + return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", err) + } + return nil, admin.SecretToResponse(s), nil +} + +type updateSecretInput struct { + ID string `json:"id" jsonschema:"secret id"` + Name string `json:"name"` + Key string `json:"key"` + Value string `json:"value,omitempty" jsonschema:"new value (omit to keep the existing one)"` + Description string `json:"description,omitempty"` +} + +func (h *Handler) updateSecret(_ context.Context, _ *sdk.CallToolRequest, in updateSecretInput) (*sdk.CallToolResult, admin.SecretResponse, error) { + if err := h.store.UpdateSecret(in.ID, store.Secret{ + Name: in.Name, + Key: in.Key, + Value: in.Value, + Description: in.Description, + }); err != nil { + return nil, admin.SecretResponse{}, fmt.Errorf("update secret: %w", err) + } + updated, _ := h.store.GetSecret(in.ID) + return nil, admin.SecretToResponse(updated), nil +} + +func (h *Handler) deleteSecret(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteSecret(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete secret: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerSecretTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_secrets", Title: "List secrets", + Description: "List every secret (id, name, key, description). Values are never returned.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listSecrets) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_secret", Title: "Get secret", + Description: "Return one secret by id (without the value).", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getSecret) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_create_secret", Title: "Create secret", + Description: "Create a new secret. Value is stored encrypted at rest if server.encryptionKey is configured.", + }, h.createSecret) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_secret", Title: "Update secret", + Description: "Replace a secret by id. Leave value empty to keep the existing one (other fields are still rewritten).", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateSecret) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_secret", Title: "Delete secret", + Description: "Delete a secret by id. Unsets the corresponding env var.", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteSecret) + h.toolCount++ +} diff --git a/server/api/mcp/tools_secrets_test.go b/server/api/mcp/tools_secrets_test.go new file mode 100644 index 0000000..13babc7 --- /dev/null +++ b/server/api/mcp/tools_secrets_test.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestSecretTools_ValueRedacted(t *testing.T) { + h := newTestHandler(t) + ctx := context.Background() + + _, created, err := h.createSecret(ctx, &sdk.CallToolRequest{}, createSecretInput{ + Name: "OpenAI Key", + Key: "OPENAI_API_KEY", + Value: "sk-secret-do-not-leak", + }) + if err != nil { + t.Fatalf("create secret: %v", err) + } + if created.ID == "" { + t.Fatal("expected non-empty id") + } + // SecretResponse intentionally has no Value field; JSON encoding must + // never include it. + raw, _ := json.Marshal(created) + if got := string(raw); contains(got, "sk-secret") { + t.Fatalf("secret value leaked in create response: %s", got) + } + + _, got, err := h.getSecret(ctx, &sdk.CallToolRequest{}, idInput{ID: created.ID}) + if err != nil { + t.Fatalf("get secret: %v", err) + } + raw, _ = json.Marshal(got) + if s := string(raw); contains(s, "sk-secret") { + t.Fatalf("secret value leaked in get response: %s", s) + } + + _, list, err := h.listSecrets(ctx, &sdk.CallToolRequest{}, struct{}{}) + if err != nil { + t.Fatalf("list secrets: %v", err) + } + raw, _ = json.Marshal(list) + if s := string(raw); contains(s, "sk-secret") { + t.Fatalf("secret value leaked in list response: %s", s) + } +} + +func TestSecret_ValidationErrors(t *testing.T) { + h := newTestHandler(t) + ctx := context.Background() + cases := []createSecretInput{ + {Name: "", Key: "K", Value: "v"}, + {Name: "n", Key: "", Value: "v"}, + {Name: "n", Key: "K", Value: ""}, + } + for i, in := range cases { + if _, _, err := h.createSecret(ctx, &sdk.CallToolRequest{}, in); err == nil || !IsValidation(err) { + t.Fatalf("case %d: expected validation error, got %v", i, err) + } + } +} + +func contains(haystack, needle string) bool { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} diff --git a/server/api/mcp/tools_settings.go b/server/api/mcp/tools_settings.go new file mode 100644 index 0000000..06d2dda --- /dev/null +++ b/server/api/mcp/tools_settings.go @@ -0,0 +1,40 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +func (h *Handler) getSettings(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, store.Settings, error) { + return nil, h.store.GetSettings(), nil +} + +type updateSettingsInput struct { + Settings store.Settings `json:"settings" jsonschema:"new global settings"` +} + +func (h *Handler) updateSettings(_ context.Context, _ *sdk.CallToolRequest, in updateSettingsInput) (*sdk.CallToolResult, store.Settings, error) { + if err := h.store.UpdateSettings(in.Settings); err != nil { + return nil, store.Settings{}, fmt.Errorf("update settings: %w", err) + } + return nil, h.store.GetSettings(), nil +} + +func (h *Handler) registerSettingsTools() { + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_settings", Title: "Get settings", + Description: "Return the global runtime settings (session/long-term memory provider selection, temporary dir).", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getSettings) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_update_settings", Title: "Update settings", + Description: "Replace the global runtime settings.", + Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, + }, h.updateSettings) + h.toolCount++ +} diff --git a/server/api/mcp/tools_skills.go b/server/api/mcp/tools_skills.go new file mode 100644 index 0000000..a958b52 --- /dev/null +++ b/server/api/mcp/tools_skills.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "context" + "fmt" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/store" +) + +type listSkillsOutput struct { + Skills []store.Skill `json:"skills"` +} + +func (h *Handler) listSkills(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listSkillsOutput, error) { + return nil, listSkillsOutput{Skills: h.store.ListRawSkills()}, nil +} + +func (h *Handler) getSkill(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.Skill, error) { + sk, ok := h.store.GetRawSkill(in.ID) + if !ok { + return nil, store.Skill{}, fmt.Errorf("get skill: %w", errValidation("not found: "+in.ID)) + } + return nil, sk, nil +} + +func (h *Handler) deleteSkill(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { + if err := h.store.DeleteSkill(in.ID); err != nil { + return nil, emptyOutput{}, fmt.Errorf("delete skill: %w", err) + } + return nil, okOutput, nil +} + +func (h *Handler) registerSkillTools() { + destructive := true + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_skills", Title: "List skills", + Description: "List every registered skill package (id and slug only). Use the admin UI to read SKILL.md content.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listSkills) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_get_skill", Title: "Get skill", + Description: "Return one skill stub by id. Content lives on disk at data/skills/{slug}/.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.getSkill) + h.toolCount++ + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_delete_skill", Title: "Delete skill", + Description: "Delete a skill by id (store record and the on-disk package).", + Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, + }, h.deleteSkill) + h.toolCount++ +} diff --git a/server/api/mcp/tools_voice.go b/server/api/mcp/tools_voice.go new file mode 100644 index 0000000..4bec740 --- /dev/null +++ b/server/api/mcp/tools_voice.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "context" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/voice" +) + +type voiceProviderInfo struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` + SupportsTTS bool `json:"supportsTts"` + SupportsSTT bool `json:"supportsStt"` + TTSConfigSchema voice.Schema `json:"ttsConfigSchema"` + STTConfigSchema voice.Schema `json:"sttConfigSchema"` +} + +type listVoiceTypesOutput struct { + Types []voiceProviderInfo `json:"types"` +} + +func (h *Handler) listVoiceTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listVoiceTypesOutput, error) { + var types []voiceProviderInfo + for _, p := range voice.All() { + types = append(types, voiceProviderInfo{ + Type: p.Type(), + DisplayName: p.DisplayName(), + SupportsTTS: p.SupportsTTS(), + SupportsSTT: p.SupportsSTT(), + TTSConfigSchema: p.TTSConfigSchema(), + STTConfigSchema: p.STTConfigSchema(), + }) + } + return nil, listVoiceTypesOutput{Types: types}, nil +} + +func (h *Handler) registerVoiceTools() { + sdk.AddTool(h.server, &sdk.Tool{ + Name: "magec_list_voice_types", Title: "List voice provider types", + Description: "List registered voice providers (TTS/STT) with their JSON schemas.", + Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, + }, h.listVoiceTypes) + h.toolCount++ +} diff --git a/server/config/config.go b/server/config/config.go index 01ff4d5..7da8bf5 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -41,12 +41,21 @@ type Config struct { // Server holds network and runtime settings for the HTTP servers. type Server struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - AdminPort int `yaml:"adminPort"` - AdminPassword string `yaml:"adminPassword"` - EncryptionKey string `yaml:"encryptionKey"` - PublicURL string `yaml:"publicURL"` + Host string `yaml:"host"` + Port int `yaml:"port"` + AdminPort int `yaml:"adminPort"` + AdminPassword string `yaml:"adminPassword"` + EncryptionKey string `yaml:"encryptionKey"` + PublicURL string `yaml:"publicURL"` + MCP MCPServer `yaml:"mcp"` +} + +// MCPServer configures the embedded MCP server that exposes the admin API as +// MCP tools. Disabled by default. Auth reuses [Server.AdminPassword] via +// bearer token. +type MCPServer struct { + Enabled bool `yaml:"enabled"` + Port int `yaml:"port"` } // Voice holds voice-related configuration (UI, ONNX runtime, etc.). @@ -121,6 +130,9 @@ func (c *Config) applyDefaults() { if c.Server.AdminPort == 0 { c.Server.AdminPort = 8081 } + if c.Server.MCP.Port == 0 { + c.Server.MCP.Port = 8082 + } if c.Log.Level == "" { c.Log.Level = "info" } diff --git a/server/go.sum b/server/go.sum index 3dbff9f..0d78d18 100644 --- a/server/go.sum +++ b/server/go.sum @@ -4,13 +4,30 @@ charm.land/catwalk v0.25.0 h1:bRkP8NPm3Tc+R89yVmaAQVk1jtyWxENJRu6BXwkCo8I= charm.land/catwalk v0.25.0/go.mod h1:rFC/V96rIHX7VES215c/qzI1EW/Moo1ggs1Q6seTy5s= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/aiplatform v1.121.0/go.mod h1:juMdDWeNphHV40KhWdN+563zNCOKNmLJjk5D2TA43ls= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.56.1/go.mod h1:C9xuCZgFl3buo2HZU/1FncgvvOgTAs/rnh4gF4lMg0s= +cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/a2aproject/a2a-go v0.3.13 h1:WpIcSHgCySIxD7OQEdV7U7WJc/HL/G2QQj0RJ0YhPi0= github.com/a2aproject/a2a-go v0.3.13/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/achetronic/adk-utils-go v0.16.0 h1:teR4mF6feoHKIhVVGlFCYFbiRNqIHotZa8TfvXQeR3E= @@ -23,6 +40,21 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/awalterschulze/gographviz v2.0.3+incompatible h1:9sVEXJBJLwGX7EQVhLm2elIKCm7P2YHFC8v6096G09E= github.com/awalterschulze/gographviz v2.0.3+incompatible/go.mod h1:GEV5wmg4YquNw7v1kkyoX9etIk8yVmXj+AkDHuuETHs= +github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM= +github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII= +github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw= +github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ= +github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -35,12 +67,15 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/x/etag v0.2.0 h1:Euj1VkheoHfTYA9y+TCwkeXF/hN8Fb9l4LqZl79pt04= github.com/charmbracelet/x/etag v0.2.0/go.mod h1:C1B7/bsgvzzxpfu0Rabbd+rTHJa5TmC/qgTseCf6DF0= +github.com/charmbracelet/x/exp/strings v0.1.0/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -50,8 +85,17 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E= +github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -67,18 +111,24 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8= @@ -96,8 +146,12 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= @@ -111,26 +165,36 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8= github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/openai/openai-go/v3 v3.16.0 h1:VdqS+GFZgAvEOBcWNyvLVwPlYEIboW5xwiUCcLrVf8c= github.com/openai/openai-go/v3 v3.16.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -143,6 +207,9 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5I github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -187,12 +254,20 @@ github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aK github.com/yalue/onnxruntime_go v1.25.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= @@ -205,10 +280,12 @@ go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3Tri go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= @@ -230,10 +307,13 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= @@ -241,8 +321,11 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/adk v1.2.0 h1:MfQD1/GqPfIsFNBcozNykkjdqNIdCrPH/SNqKPZF/yM= google.golang.org/adk v1.2.0/go.mod h1:6QY5jQI7awU4WYtJqvyIkJQheCvqsGWweU6BX63USEc= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genai v1.40.0 h1:kYxyQSH+vsib8dvsgyLJzsVEIv5k3ZmHJyVqdvGncmc= google.golang.org/genai v1.40.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:0oz9d7g9QLSdv9/lgbIjowW1JoxMbxmBVNe8i6tORJI= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1:CogIeEXn4qWYzzQU0QqvYBM8yDF9cFYzDq9ojSpv0Js= google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= @@ -264,9 +347,15 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.21.1/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI= rsc.io/omap v1.2.0 h1:c1M8jchnHbzmJALzGLclfH3xDWXrPxSUHXzH5C+8Kdw= rsc.io/omap v1.2.0/go.mod h1:C8pkI0AWexHopQtZX+qiUeJGzvc8HkdgnsWK4/mAa00= rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak= rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/server/main.go b/server/main.go index 15dbf58..e93d927 100644 --- a/server/main.go +++ b/server/main.go @@ -36,6 +36,7 @@ import ( "github.com/achetronic/magec/server/agent" toolsartifacts "github.com/achetronic/magec/server/agent/tools/artifacts" "github.com/achetronic/magec/server/api/admin" + magecmcp "github.com/achetronic/magec/server/api/mcp" user "github.com/achetronic/magec/server/api/user" "github.com/achetronic/magec/server/clients" "github.com/achetronic/magec/server/clients/cron" @@ -99,6 +100,10 @@ func main() { adminServer, adminCtx, adminCancel := startAdminServer(cfg, adminHandler) + // Embedded MCP server (opt-in via server.mcp.enabled). Exposes the same + // admin surface as MCP tools over Streamable HTTP. See decision #30. + mcpServer, mcpCtx, mcpCancel := startMCPServer(cfg, dataStore, convoStore, adminHandler) + // cwRegistry provides LLM context window sizes cwRegistry := contextguard.NewCrushRegistry() @@ -124,7 +129,7 @@ func main() { cronScheduler, clientManager := startClients(ctx, cfg, dataStore, agentRouter, executor) // Graceful shutdown - startGracefulShutdown(adminServer, adminCtx, adminCancel, userServer, userCtx, userCancel, cronScheduler, clientManager, voiceDetector) + startGracefulShutdown(adminServer, adminCtx, adminCancel, userServer, userCtx, userCancel, mcpServer, mcpCtx, mcpCancel, cronScheduler, clientManager, voiceDetector) } // initStores initializes the primary JSON file stores for application data @@ -233,6 +238,56 @@ func startAdminServer(cfg *config.Config, adminHandler *admin.Handler) (*http.Se return adminServer, adminCtx, adminCancel } +// startMCPServer starts the embedded MCP server when server.mcp.enabled is +// true. The server speaks Streamable HTTP at the root path and authenticates +// requests with the same bearer token used by the admin REST API +// (server.adminPassword). Returns nil sentinel values when disabled so the +// shutdown path stays linear. +func startMCPServer(cfg *config.Config, dataStore *store.Store, convoStore *store.ConversationStore, adminHandler *admin.Handler) (*http.Server, context.Context, context.CancelFunc) { + if !cfg.Server.MCP.Enabled { + return nil, nil, nil + } + + if cfg.Server.AdminPassword == "" { + slog.Warn("MCP server enabled without server.adminPassword — admin tools are exposed without authentication") + } + + mcpHandler := magecmcp.NewHandler(dataStore, convoStore, adminHandler) + + mcpMux := http.NewServeMux() + mcpMux.Handle("/", mcpHandler.HTTPHandler()) + + mcpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.MCP.Port) + mcpServer := &http.Server{ + Addr: mcpAddr, + // CORS is permissive (same as the admin port) so browsers can drive + // the MCP server. BearerAuth reuses the same rate-limited + // constant-time compare as AdminAuth, without the /api/ carve-out + // because MCP serves the root path. + Handler: middleware.AccessLog( + middleware.CORS( + middleware.BearerAuth(mcpMux, cfg.Server.AdminPassword), + ), + ), + ReadTimeout: 30 * time.Second, + // SSE streams may stay open longer than a typical admin request, so + // write timeout is left at zero. Per-request context cancellation + // keeps abandoned connections from leaking. + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } + + mcpCtx, mcpCancel := context.WithCancel(context.Background()) + go func() { + slog.Info("MCP server started", "addr", mcpAddr, "url", fmt.Sprintf("http://%s", mcpAddr), "tools", mcpHandler.ToolCount()) + if err := mcpServer.ListenAndServe(); err != http.ErrServerClosed { + slog.Error("MCP server error", "error", err) + } + }() + + return mcpServer, mcpCtx, mcpCancel +} + // startUserServer configures and starts the HTTP server for the user-facing // Agent API, Voice API, and A2A endpoints. It wires up all necessary middlewares, // such as session assurance, SSE timeouts, and conversation recording. @@ -411,6 +466,7 @@ func startClients(ctx context.Context, cfg *config.Config, dataStore *store.Stor func startGracefulShutdown( adminServer *http.Server, adminCtx context.Context, adminCancel context.CancelFunc, userServer *http.Server, userCtx context.Context, userCancel context.CancelFunc, + mcpServer *http.Server, mcpCtx context.Context, mcpCancel context.CancelFunc, cronScheduler *cron.Scheduler, cm *clientManager, voiceDetector *voice.Detector, ) { @@ -430,9 +486,20 @@ func startGracefulShutdown( adminServer.Shutdown(shutdownCtx) userServer.Shutdown(shutdownCtx) + if mcpServer != nil { + mcpServer.Shutdown(shutdownCtx) + } adminCancel() userCancel() + if mcpCancel != nil { + mcpCancel() + } + // adminCtx, userCtx, mcpCtx are kept in the signature so callers can wire + // observability hooks against them; nothing to do here. + _ = adminCtx + _ = userCtx + _ = mcpCtx } // newVoiceHandler creates a router for /api/v1/voice/{agentId}/{action} routes. diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go index a16c987..a668949 100644 --- a/server/middleware/middleware.go +++ b/server/middleware/middleware.go @@ -150,6 +150,55 @@ func AdminAuth(next http.Handler, password string) http.Handler { }) } +// BearerAuth protects an HTTP handler with `Authorization: Bearer ` +// authentication. Used by surfaces that serve at the root path (no `/api/` +// prefix), such as the embedded MCP server. Reuses the same constant-time +// comparison and per-IP rate limiter as [AdminAuth]. +// +// If password is empty, all requests pass through (open mode) and a warning +// is the caller's responsibility. +func BearerAuth(next http.Handler, password string) http.Handler { + if password == "" { + return next + } + + rl := newRateLimiter(5, time.Minute) + go rl.cleanup(30 * time.Second) + + passwordBytes := []byte(password) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + ip := extractIP(r) + if !rl.allow(ip) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", "60") + http.Error(w, `{"error":"too many failed attempts, try again later"}`, http.StatusTooManyRequests) + return + } + + token := extractBearerToken(r) + if token == "" { + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + if subtle.ConstantTimeCompare([]byte(token), passwordBytes) != 1 { + rl.record(ip) + w.Header().Set("Content-Type", "application/json") + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + func extractBearerToken(r *http.Request) string { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { diff --git a/server/store/types.go b/server/store/types.go index 0d7f3e0..000aae4 100644 --- a/server/store/types.go +++ b/server/store/types.go @@ -13,7 +13,7 @@ func generateID() string { // AgentDefinition represents a single agent's full configuration in the store. type AgentDefinition struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"` @@ -36,7 +36,7 @@ type A2AConfig struct { // BackendDefinition represents a reusable AI backend. type BackendDefinition struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` URL string `json:"url,omitempty" yaml:"url,omitempty"` @@ -105,7 +105,7 @@ type ContextGuardConfig struct { // MemoryProvider represents a reusable memory backend (Redis, Postgres, etc.). type MemoryProvider struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` Category string `json:"category" yaml:"category"` @@ -115,7 +115,7 @@ type MemoryProvider struct { // MCPServer represents an MCP server configuration. type MCPServer struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type,omitempty" yaml:"type,omitempty"` Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` @@ -131,10 +131,10 @@ type MCPServer struct { // ClientDefinition represents an access point (voice-ui, Telegram, Discord, webhook, etc.). // Type determines what platform-specific config is expected inside Config. type ClientDefinition struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` - Token string `json:"token" yaml:"token"` + Token string `json:"token,omitempty" yaml:"token"` AllowedAgents []string `json:"allowedAgents" yaml:"allowedAgents"` Enabled bool `json:"enabled" yaml:"enabled"` Config ClientConfig `json:"config" yaml:"config"` @@ -209,14 +209,14 @@ type WebhookClientConfig struct { // upload time; renames go through the upload endpoint, which rewrites the // frontmatter and renames the directory atomically. type Skill struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Slug string `json:"slug" yaml:"slug"` } // Command represents a reusable prompt that can be invoked against an agent // via cron or webhook clients. type Command struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Prompt string `json:"prompt" yaml:"prompt"` @@ -355,7 +355,7 @@ func collectAgentIDs(step *FlowStep, seen map[string]bool, ids *[]string) { // FlowDefinition represents a multi-agent workflow stored as a recursive tree // of steps that maps directly to ADK workflow agents. type FlowDefinition struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Root FlowStep `json:"root" yaml:"root"` @@ -379,7 +379,7 @@ type Settings struct { // The Key field is the environment variable name (e.g. OPENAI_API_KEY). // The Value is stored encrypted at rest when an admin password is configured. type Secret struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id,omitempty" yaml:"id"` Name string `json:"name" yaml:"name"` Key string `json:"key" yaml:"key"` Value string `json:"value" yaml:"value"` diff --git a/website/content/docs/admin-mcp-server.md b/website/content/docs/admin-mcp-server.md new file mode 100644 index 0000000..facd906 --- /dev/null +++ b/website/content/docs/admin-mcp-server.md @@ -0,0 +1,189 @@ +--- +title: "Admin MCP Server" +--- + +Magec ships an embedded MCP server that exposes the entire admin API as MCP tools. Connect any MCP client (Claude Code, Cursor, mcp-cli) and you can list backends, create agents, register MCP servers, wire flows and more, all from your editor or shell. + +This page covers the embedded server. For consuming external MCP servers (Home Assistant, GitHub, filesystem, etc.) see [MCP Tools](/docs/mcp/). + +## What it does + +The server runs alongside the user and admin HTTP servers, on its own port. It speaks Streamable HTTP, so every MCP client that follows the spec works without extra adapters. + +It exposes one tool per admin operation. Listing, getting, creating, updating and deleting works for every resource the admin REST API understands: backends, memory providers, MCP servers, agents, clients, commands, flows, secrets, settings, conversations, plus the type catalogues for clients, memory and voice providers. + +## Enabling it + +Set `server.mcp.enabled: true` in `config.yaml` and restart `magec-server`: + +```yaml +server: + host: 0.0.0.0 + port: 8080 + adminPort: 8081 + adminPassword: "your-admin-password" + mcp: + enabled: true + port: 8082 +``` + +The startup log shows a line like: + +``` +INFO MCP server started addr=0.0.0.0:8082 url=http://0.0.0.0:8082 tools=54 +``` + +## Authentication + +The MCP server reuses `server.adminPassword`. Every request must carry the `Authorization: Bearer ` header. The check is constant-time and rate-limited per IP (5 failed attempts per minute), the same as the admin REST API. + +If `adminPassword` is empty, the MCP server still starts but logs a warning and accepts every request. Do not expose port 8082 outside your trust boundary in that mode. + +## Connecting from Claude Code + +Add an entry to `~/.claude/mcp.json`: + +```json +{ + "mcpServers": { + "magec": { + "type": "streamable-http", + "url": "http://localhost:8082/", + "headers": { + "Authorization": "Bearer your-admin-password" + } + } + } +} +``` + +Then `/mcp` inside Claude Code lists the `magec` tools. You can ask the agent things like *"list the Magec backends"* or *"create an OpenAI backend called bench"* and it picks the right tool. + +## Connecting from mcp-cli + +```bash +npx @wong2/mcp-cli streamable-http http://localhost:8082/ \ + --header "Authorization: Bearer your-admin-password" \ + tools/list +``` + +## Tool catalogue + +Tool names use the `magec_` prefix and snake_case. + +### Backends + +- `magec_list_backends` +- `magec_get_backend` +- `magec_create_backend` +- `magec_update_backend` +- `magec_delete_backend` + +### Memory providers + +- `magec_list_memory_providers` +- `magec_get_memory_provider` +- `magec_create_memory_provider` +- `magec_update_memory_provider` +- `magec_delete_memory_provider` +- `magec_check_memory_health` +- `magec_list_memory_types` + +### MCP servers (the ones agents consume) + +- `magec_list_mcp_servers` +- `magec_get_mcp_server` +- `magec_create_mcp_server` +- `magec_update_mcp_server` +- `magec_delete_mcp_server` + +### Agents + +- `magec_list_agents` +- `magec_get_agent` +- `magec_create_agent` +- `magec_update_agent` +- `magec_delete_agent` +- `magec_list_agent_mcps` +- `magec_link_agent_mcp` +- `magec_unlink_agent_mcp` + +### Clients + +- `magec_list_clients` +- `magec_get_client` +- `magec_create_client` +- `magec_update_client` +- `magec_delete_client` +- `magec_regenerate_client_token` +- `magec_list_client_types` + +### Commands + +- `magec_list_commands` +- `magec_get_command` +- `magec_create_command` +- `magec_update_command` +- `magec_delete_command` + +### Flows + +- `magec_list_flows` +- `magec_get_flow` +- `magec_create_flow` +- `magec_update_flow` +- `magec_delete_flow` + +### Skills + +- `magec_list_skills` +- `magec_get_skill` +- `magec_delete_skill` + +### Settings + +- `magec_get_settings` +- `magec_update_settings` + +### Secrets + +- `magec_list_secrets` +- `magec_get_secret` +- `magec_create_secret` +- `magec_update_secret` +- `magec_delete_secret` + +### Conversations + +- `magec_list_conversations` +- `magec_get_conversation` +- `magec_delete_conversation` +- `magec_clear_conversations` +- `magec_conversation_stats` +- `magec_update_conversation_summary` +- `magec_find_conversation_pair` +- `magec_reset_conversation_session` + +### Voice + +- `magec_list_voice_types` + +## What is not exposed + +Skill upload and download (one SKILL.md plus optional `references/`, `assets/`, `scripts/`) and the backup and restore endpoints stream binary archives. They do not map cleanly to MCP tool inputs and outputs, so they stay on the admin REST API. Use the admin UI or call `POST /api/v1/admin/skills/upload` and `GET /api/v1/admin/settings/backup` directly when you need them. + +## Security notes + +- Secret values are never returned by `magec_get_secret` or `magec_list_secrets`. The MCP server mirrors the admin REST behaviour: GET responses contain the metadata only. Use the create/update tools to write new values. +- The MCP server has the same authority as the admin API. Treat the bearer token with the same care. +- Running a Magec agent against the admin MCP creates a recursive surface: the agent can call destructive tools (`magec_delete_*`, `magec_clear_conversations`) against the very instance running it. Use sparingly. +- Streamable HTTP allows server-sent events, so the MCP server's `WriteTimeout` is set to zero. The HTTP client manages cancellation through request context, which is what every spec-compliant MCP client does. + +## Troubleshooting + +| Symptom | Likely cause | +|--------|-------------| +| `tools=0` at startup | Build is stale, restart the server | +| 401 on every request | Wrong or missing bearer; verify `Authorization: Bearer ...` | +| 429 with `Retry-After: 60` | Hit the 5-failed-attempts-per-minute rate limit; wait one minute | +| 403 from a localhost client | DNS rebinding protection kicked in; make sure the client sends a `Host` header matching `localhost` or `127.0.0.1` | diff --git a/website/hugo.toml b/website/hugo.toml index acc5aeb..c95bf39 100644 --- a/website/hugo.toml +++ b/website/hugo.toml @@ -107,26 +107,31 @@ theme = 'magec' parent = 'core' url = '/docs/mcp/' weight = 5 + [[menu.docs]] + name = 'Admin MCP Server' + parent = 'core' + url = '/docs/admin-mcp-server/' + weight = 6 [[menu.docs]] name = 'Skills' parent = 'core' url = '/docs/skills/' - weight = 6 + weight = 7 [[menu.docs]] name = 'A2A Protocol' parent = 'core' url = '/docs/a2a/' - weight = 7 + weight = 8 [[menu.docs]] name = 'Secrets' parent = 'core' url = '/docs/secrets/' - weight = 8 + weight = 9 [[menu.docs]] name = 'Commands' parent = 'core' url = '/docs/commands/' - weight = 9 + weight = 10 [[menu.docs]] identifier = 'clients' From 86d4056c7238f723a99bd5081371299994a8e6ae Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 22 May 2026 12:08:26 +0200 Subject: [PATCH 2/4] docs: correct registered tool count in admin-mcp-server example log --- website/content/docs/admin-mcp-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/content/docs/admin-mcp-server.md b/website/content/docs/admin-mcp-server.md index facd906..376121b 100644 --- a/website/content/docs/admin-mcp-server.md +++ b/website/content/docs/admin-mcp-server.md @@ -30,7 +30,7 @@ server: The startup log shows a line like: ``` -INFO MCP server started addr=0.0.0.0:8082 url=http://0.0.0.0:8082 tools=54 +INFO MCP server started addr=0.0.0.0:8082 url=http://0.0.0.0:8082 tools=61 ``` ## Authentication From 5911ca6e83aea2240c9c25cc2c24db0e94997f01 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Fri, 22 May 2026 17:56:39 +0200 Subject: [PATCH 3/4] refactor(mcp): discover tools from the admin OpenAPI spec Replace the hand-written tools_*.go files with runtime discovery via github.com/achetronic/openapi2tools. The library parses the admin swagger spec (converted from 2.0 to 3.0 with kin-openapi), builds JSON Schema inputs from path, query, header and body parameters, and produces one MCP tool per filtered operation. A thin adapter wires the resulting descriptors onto the go-sdk v1.4.1 server (the library ships its own adapter for an older sdk version, hence the local one). Tool calls reach the admin REST API over the loopback interface via mcptools.HTTPExecutor with the bearer token forwarded as a default header. Validation, secret redaction and conversation logging stay in the admin handlers and apply uniformly whether the caller is the admin UI, a REST client, or an MCP tool. Adding a new admin endpoint propagates to the MCP catalogue on the next build with no changes to this package. Effects: - Removes the 12 tools_*.go files and their per-resource tests (~1.7k LOC). - Removes the admin handler exports that were only used by the old MCP package: ValidateFlowStep, ValidateClientConfig, SecretToResponse, SessionService accessor, Store accessor. - Reverts the omitempty workaround on id/token tags in store types. The new path takes its schema from swagger, not from struct reflection, so server-assigned fields are no longer marked as required on create_* inputs. - Adds dependencies: github.com/achetronic/openapi2tools, github.com/getkin/kin-openapi. Tests update: handler_test.go drives the server through the in-memory transport, exercises the dispatcher against a stub admin (recording bearer, method, path and body), and re-verifies the bearer middleware on both 401 and authenticated paths. --- .agents/DECISIONS.md | 30 +-- server/api/admin/clients.go | 7 - server/api/admin/flows.go | 6 - server/api/admin/handler.go | 13 -- server/api/admin/secrets.go | 8 - server/api/mcp/adapter.go | 125 +++++++++++++ server/api/mcp/doc.go | 22 +-- server/api/mcp/errors.go | 22 --- server/api/mcp/handler.go | 154 ++++++++++++--- server/api/mcp/handler_test.go | 206 ++++++++++++++++++++ server/api/mcp/schemas.go | 24 --- server/api/mcp/server.go | 19 -- server/api/mcp/server_http_test.go | 78 -------- server/api/mcp/server_test.go | 65 ------- server/api/mcp/tools_agents.go | 138 -------------- server/api/mcp/tools_agents_test.go | 56 ------ server/api/mcp/tools_backends.go | 106 ----------- server/api/mcp/tools_backends_test.go | 96 ---------- server/api/mcp/tools_clients.go | 152 --------------- server/api/mcp/tools_commands.go | 97 ---------- server/api/mcp/tools_conversations.go | 228 ----------------------- server/api/mcp/tools_flows.go | 118 ------------ server/api/mcp/tools_flows_test.go | 48 ----- server/api/mcp/tools_mcps.go | 94 ---------- server/api/mcp/tools_memory.go | 167 ----------------- server/api/mcp/tools_secrets.go | 122 ------------ server/api/mcp/tools_secrets_test.go | 74 -------- server/api/mcp/tools_settings.go | 40 ---- server/api/mcp/tools_skills.go | 55 ------ server/api/mcp/tools_voice.go | 46 ----- server/go.mod | 14 +- server/go.sum | 22 +++ server/main.go | 20 +- server/store/types.go | 20 +- website/content/docs/admin-mcp-server.md | 114 +----------- 35 files changed, 550 insertions(+), 2056 deletions(-) create mode 100644 server/api/mcp/adapter.go delete mode 100644 server/api/mcp/errors.go create mode 100644 server/api/mcp/handler_test.go delete mode 100644 server/api/mcp/schemas.go delete mode 100644 server/api/mcp/server.go delete mode 100644 server/api/mcp/server_http_test.go delete mode 100644 server/api/mcp/server_test.go delete mode 100644 server/api/mcp/tools_agents.go delete mode 100644 server/api/mcp/tools_agents_test.go delete mode 100644 server/api/mcp/tools_backends.go delete mode 100644 server/api/mcp/tools_backends_test.go delete mode 100644 server/api/mcp/tools_clients.go delete mode 100644 server/api/mcp/tools_commands.go delete mode 100644 server/api/mcp/tools_conversations.go delete mode 100644 server/api/mcp/tools_flows.go delete mode 100644 server/api/mcp/tools_flows_test.go delete mode 100644 server/api/mcp/tools_mcps.go delete mode 100644 server/api/mcp/tools_memory.go delete mode 100644 server/api/mcp/tools_secrets.go delete mode 100644 server/api/mcp/tools_secrets_test.go delete mode 100644 server/api/mcp/tools_settings.go delete mode 100644 server/api/mcp/tools_skills.go delete mode 100644 server/api/mcp/tools_voice.go diff --git a/.agents/DECISIONS.md b/.agents/DECISIONS.md index 0b7f386..c7b0990 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -891,22 +891,28 @@ no automatic conversion, no "try harder" repair pass. **Date**: 2026-05-21 **Status**: Implemented -Magec ships an embedded MCP server on its own port (default `8082`, configurable via `server.mcp.port`) that exposes every admin API operation as an MCP tool. It uses Streamable HTTP transport (`github.com/modelcontextprotocol/go-sdk/mcp.NewStreamableHTTPHandler`, v1.4.1) and reuses `server.adminPassword` as the bearer token. The handler calls the same `*store.Store` methods admin REST handlers call — there is no HTTP roundtrip back to the admin port. +Magec ships an embedded MCP server on its own port (default `8082`, configurable via `server.mcp.port`) that exposes every admin API operation as an MCP tool. It uses Streamable HTTP transport (`github.com/modelcontextprotocol/go-sdk/mcp.NewStreamableHTTPHandler`, v1.4.1) and reuses `server.adminPassword` as the bearer token. -**Reason**: An MCP client like Claude Code or mcp-cli should be able to manage a Magec instance the same way the admin UI does. Building this as a separate process would have required a second store reference or a duplicate REST client; reusing decision #6 ("Admin UI never accesses the User API") and going store-direct keeps the access pattern uniform and the dependency tree narrow. +The tool catalogue is **discovered at startup from the admin OpenAPI spec**: the swagger embedded by swaggo is converted to OpenAPI 3.0 and fed to `github.com/achetronic/openapi2tools`, which produces one MCP tool per operation. Adding or removing an admin endpoint propagates to the MCP surface on the next build without touching the MCP package. + +Tool calls are dispatched through `mcptools.HTTPExecutor` to the admin port on the loopback interface (`http://127.0.0.1:/api/v1/admin`). The bearer token is forwarded as a default header so the admin's existing `AdminAuth`, validation, secret redaction and conversation logging apply uniformly. + +**Reason**: An MCP client like Claude Code or mcp-cli should be able to manage a Magec instance the same way the admin UI does. Doing the discovery from the swagger spec keeps the two surfaces in lockstep by construction — there is no per-tool Go code to drift out of sync with the REST API. **Decisions inside this decision**: -- **Separate port** instead of mounting under `/api/v1/mcp/` on the admin server. The SSE streams MCP uses need a longer write timeout (set to zero) than admin write paths (30s), and CLI clients prefer the root path. The two surfaces also have different rate-limit needs in the future. -- **Typed `mcp.AddTool[In, Out]`** for every tool, not the raw `Server.AddTool`, so the SDK infers schemas from Go structs and validates inputs before the handler runs. The only exception is **flow tools**, which set explicit `*jsonschema.Schema{Type:"object"}` on InputSchema/OutputSchema because `store.FlowStep` is self-referential and the schema generator does not support cycles. -- **Skills upload/download and backup/restore are not exposed**. Both stream binary archives (`.zip`/`.tar.gz`) and do not map cleanly to MCP tool inputs and outputs. Admin REST stays the source of truth for those operations. -- **Secret values are redacted in MCP responses**, matching the admin REST policy via the shared `admin.SecretToResponse` helper. -- **Validators are shared, not duplicated**. `admin.ValidateFlowStep` and `admin.ValidateClientConfig` are exported and reused inside MCP tool handlers so the same rules apply on both surfaces. +- **Separate port** instead of mounting under `/api/v1/mcp/` on the admin server. SSE streams need a longer write timeout (set to zero) than admin write paths (30s), and CLI clients prefer the root path. The two surfaces can also have different rate-limit needs in the future. +- **`openapi2tools` carries the heavy lifting**: spec parsing, `$ref` resolution, schema flexibility for LLM inputs, route filtering, JSON Schema generation and the HTTP executor. The MCP package only adds a thin adapter to the go-sdk v1.4.1 server (because the library's bundled adapter targets an older sdk version) and a list of regex filters. +- **Swagger 2.0 → OpenAPI 3.0 conversion** happens in-process via `github.com/getkin/kin-openapi/openapi2conv`. Avoids forcing a swaggo replacement just to feed the discovery layer. +- **Loopback HTTP, not store-direct calls**. Calling the admin handler via HTTP keeps validation, secret redaction and conversation logging in a single layer (the admin REST handlers). The loopback hop is on `127.0.0.1` and adds millisecond-scale latency. +- **Route filters keep the MCP surface clean**: skills upload/download, backup/restore, `/auth/check` and `/overview` are excluded — binary streams do not map cleanly to MCP tool inputs and the helper endpoints are not operator actions. +- **Tool annotations are derived from the HTTP verb**: GET maps to `ReadOnlyHint`, DELETE to `DestructiveHint`, PUT to `IdempotentHint`. No per-route override list. - **New `middleware.BearerAuth`** (no `/api/` carve-out) wraps the MCP mux. It reuses the same constant-time compare and per-IP rate limiter as `AdminAuth`. Empty password keeps the server open with a startup warning, same as admin. **Do not**: -- Wire the MCP server through the admin HTTP router. The data store is the only shared dependency; introducing a second HTTP hop would defeat decision #6. +- Re-add hand-written `tools_*.go` files for individual resources. The catalogue is discovered; per-resource code defeats the point. +- Bypass the admin handler by calling the store directly from MCP tools. Validation and redaction live in the admin layer; skipping it would force duplication and risk leaking secrets. - Introduce a separate password (`server.mcp.token`). One credential keeps the admin and MCP surfaces in lockstep and is consistent with decision #12 (standard `Authorization: Bearer`). - Add MCP resources or prompts unless a concrete operator need appears. Tools-only keeps every spec-compliant CLI client compatible. - Expose binary endpoints (skill upload/download, backup/restore) as MCP tools. They stay on admin REST. @@ -916,11 +922,9 @@ Magec ships an embedded MCP server on its own port (default `8082`, configurable - `server/main.go` — `startMCPServer`, extended `startGracefulShutdown`. - `server/config/config.go` — `Server.MCP{Enabled bool, Port int}` plus default `8082`. - `server/middleware/middleware.go` — new `BearerAuth` middleware. -- `server/api/admin/handler.go` — exported `SessionService()` and `Store()` accessors. -- `server/api/admin/flows.go` — exported `ValidateFlowStep`. -- `server/api/admin/clients.go` — exported `ValidateClientConfig`. -- `server/api/admin/secrets.go` — exported `SecretToResponse`. -- `server/api/mcp/*` — Handler, registration aggregator, one `tools_.go` per admin resource group, smoke + per-resource tests. +- `server/api/mcp/handler.go` — spec loading (swag → openapi2 → openapi3 → openapi2tools), filter, executor wiring, server construction. +- `server/api/mcp/adapter.go` — thin go-sdk v1.4.1 adapter for openapi2tools `ToolDescriptor`s. +- `server/api/mcp/handler_test.go` — smoke + dispatcher + bearer auth tests against a stub admin. - `website/content/docs/admin-mcp-server.md` — operator docs. - `website/hugo.toml` — sidebar entry under Core Concepts (weight 6). - `.agents/AGENTS.md`, `.agents/MULTI_AGENT_ADMIN_API.md` — short pointers to the new module. diff --git a/server/api/admin/clients.go b/server/api/admin/clients.go index 2cb9cb8..8d0482f 100644 --- a/server/api/admin/clients.go +++ b/server/api/admin/clients.go @@ -184,13 +184,6 @@ func (h *Handler) listClientTypes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, types) } -// ValidateClientConfig validates a client's type-specific config block against -// the JSON schema registered by the client provider. Exported so the embedded -// MCP server can reuse the same rules. -func ValidateClientConfig(c store.ClientDefinition) error { - return validateClientConfig(c) -} - func validateClientConfig(c store.ClientDefinition) error { raw, err := json.Marshal(c.Config) if err != nil { diff --git a/server/api/admin/flows.go b/server/api/admin/flows.go index 0c745c3..b204931 100644 --- a/server/api/admin/flows.go +++ b/server/api/admin/flows.go @@ -128,12 +128,6 @@ func (h *Handler) deleteFlow(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// ValidateFlowStep recursively validates a flow step tree. Exported so other -// admission paths (e.g. the embedded MCP server) can reuse the same rules. -func ValidateFlowStep(step *store.FlowStep) error { - return validateFlowStep(step) -} - func validateFlowStep(step *store.FlowStep) error { // Loop-only fields must not appear elsewhere. Catch this before the // type switch so the per-type branches stay focused. diff --git a/server/api/admin/handler.go b/server/api/admin/handler.go index a7e9e46..3d8020a 100644 --- a/server/api/admin/handler.go +++ b/server/api/admin/handler.go @@ -46,19 +46,6 @@ func (h *Handler) SetSessionService(svc session.Service) { h.sessionService = svc } -// SessionService returns the configured ADK session service (may be nil if -// the launcher has not been initialised). Used by external components that -// need to operate on sessions directly, e.g. the embedded MCP server. -func (h *Handler) SessionService() session.Service { - return h.sessionService -} - -// Store returns the data store. Used by external components that need direct -// access to the same store the admin handlers see. -func (h *Handler) Store() *store.Store { - return h.store -} - // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) diff --git a/server/api/admin/secrets.go b/server/api/admin/secrets.go index 352218b..3223a05 100644 --- a/server/api/admin/secrets.go +++ b/server/api/admin/secrets.go @@ -42,14 +42,6 @@ func secretResponse(s store.Secret) SecretResponse { } } -// SecretToResponse converts a stored secret to its public representation -// (value is never exposed). Exported so external surfaces (e.g. the embedded -// MCP server) can mirror admin's redaction policy without duplicating the -// struct shape. -func SecretToResponse(s store.Secret) SecretResponse { - return secretResponse(s) -} - // listSecrets returns all secrets (values are never exposed). // @Summary List secrets // @Description Returns all configured secrets without their values diff --git a/server/api/mcp/adapter.go b/server/api/mcp/adapter.go new file mode 100644 index 0000000..cb6bea5 --- /dev/null +++ b/server/api/mcp/adapter.go @@ -0,0 +1,125 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/achetronic/openapi2tools/mcptools" + "github.com/google/jsonschema-go/jsonschema" + sdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// registerDescriptors installs every openapi2tools tool descriptor on the +// given go-sdk server. The library ships an adapter for an older go-sdk +// version; we keep our own thin one here so we can stay on the currently +// vendored v1.4.1 without dragging an incompatible transitive dep. +func registerDescriptors(s *sdk.Server, descriptors []mcptools.ToolDescriptor) (int, error) { + count := 0 + for i := range descriptors { + if err := registerDescriptor(s, descriptors[i]); err != nil { + return count, fmt.Errorf("register tool %q: %w", descriptors[i].Name, err) + } + count++ + } + return count, nil +} + +func registerDescriptor(s *sdk.Server, td mcptools.ToolDescriptor) error { + schema, err := schemaFromMap(td.InputSchema) + if err != nil { + return fmt.Errorf("build input schema: %w", err) + } + + tool := &sdk.Tool{ + Name: td.Name, + Description: td.Description, + InputSchema: schema, + Annotations: annotationsForMethod(td.Route.Method), + } + + handler := td.Handler + s.AddTool(tool, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + args, err := decodeArguments(req) + if err != nil { + return errorResult(fmt.Sprintf("invalid arguments: %s", err)), nil + } + if handler == nil { + return errorResult("no handler configured for this tool"), nil + } + + out, err := handler(ctx, mcptools.ToolCall{ + Name: td.Name, + Arguments: args, + }) + if err != nil { + return nil, err + } + if out == nil { + out = &mcptools.ToolResult{} + } + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: out.Text}}, + IsError: out.IsError, + }, nil + }) + return nil +} + +// schemaFromMap roundtrips a JSON-schema-compatible map through JSON into the +// go-sdk's typed jsonschema.Schema. AddTool requires a non-nil schema; an +// empty object accepts anything which is fine for tools without parameters. +func schemaFromMap(m map[string]any) (*jsonschema.Schema, error) { + if m == nil { + return &jsonschema.Schema{Type: "object"}, nil + } + raw, err := json.Marshal(m) + if err != nil { + return nil, err + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, err + } + return &schema, nil +} + +// decodeArguments unmarshals the JSON-RPC arguments envelope into a +// map[string]any. The go-sdk represents Arguments as json.RawMessage so we +// can decode lazily and avoid a per-tool input type. +func decodeArguments(req *sdk.CallToolRequest) (map[string]any, error) { + if req == nil || len(req.Params.Arguments) == 0 { + return map[string]any{}, nil + } + var args map[string]any + if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { + return nil, err + } + if args == nil { + args = map[string]any{} + } + return args, nil +} + +// annotationsForMethod derives the MCP tool hints from the HTTP verb. The +// admin REST API is conventional enough (GET reads, DELETE removes, PUT +// updates) that this mapping is accurate without per-route overrides. +func annotationsForMethod(method string) *sdk.ToolAnnotations { + destructive := true + switch method { + case "GET": + return &sdk.ToolAnnotations{ReadOnlyHint: true} + case "DELETE": + return &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true} + case "PUT": + return &sdk.ToolAnnotations{IdempotentHint: true} + } + return nil +} + +func errorResult(text string) *sdk.CallToolResult { + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: text}}, + IsError: true, + } +} diff --git a/server/api/mcp/doc.go b/server/api/mcp/doc.go index 8f1605a..4bd7975 100644 --- a/server/api/mcp/doc.go +++ b/server/api/mcp/doc.go @@ -1,19 +1,15 @@ // Package mcp exposes the Magec admin API as MCP tools over Streamable HTTP. // -// The server is embedded inside magec-server and runs on its own port (default -// 8082) when server.mcp.enabled is true. Authentication reuses -// server.adminPassword as a bearer token, validated with constant-time -// comparison and a per-IP rate limiter (5 failures per minute), the same as -// the admin REST API. +// The catalogue is built at startup by reading the admin swagger spec +// (swag.ReadDoc), converting it from swagger 2.0 to OpenAPI 3.0, and feeding +// it to github.com/achetronic/openapi2tools. Each operation becomes one tool +// whose handler proxies the call back to the admin REST API as an HTTP +// request — same validation, same redaction, same conversation logging as a +// direct admin client. // -// Tools call the data store directly. There is no HTTP roundtrip back to the -// admin port. The package mirrors the layout of server/api/admin: -// -// handler.go Handler container and registration aggregator -// server.go *mcp.Server construction and HTTP wiring -// schemas.go shared input/output structs -// errors.go small error helpers -// tools_.go one file per admin resource group +// Adding a new admin endpoint (a new @Router annotation) automatically grows +// the MCP catalogue on the next build; the MCP package itself only contains +// the wiring, never per-resource tool definitions. // // See decision #30 in .agents/DECISIONS.md and the public docs at // website/content/docs/admin-mcp-server.md. diff --git a/server/api/mcp/errors.go b/server/api/mcp/errors.go deleted file mode 100644 index eb13074..0000000 --- a/server/api/mcp/errors.go +++ /dev/null @@ -1,22 +0,0 @@ -package mcp - -import "errors" - -// errValidation is returned when a required field is missing or invalid. -// Wrapping it via fmt.Errorf("...: %w", errValidation(...)) lets the caller -// (mcp tool handler) surface a clean message back to the client; the SDK -// turns the returned error into a tool error response. -type validationError struct{ msg string } - -func (e validationError) Error() string { return e.msg } - -func errValidation(msg string) error { - return validationError{msg: msg} -} - -// IsValidation reports whether err originates from errValidation. Reserved -// for tests; the runtime treats every tool error the same way. -func IsValidation(err error) bool { - var v validationError - return errors.As(err, &v) -} diff --git a/server/api/mcp/handler.go b/server/api/mcp/handler.go index 21a3904..dcb9e26 100644 --- a/server/api/mcp/handler.go +++ b/server/api/mcp/handler.go @@ -1,64 +1,156 @@ package mcp import ( + "encoding/json" + "fmt" "net/http" "time" + "github.com/achetronic/openapi2tools/mcptools" + "github.com/achetronic/openapi2tools/openapi" + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" sdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/swaggo/swag" - "github.com/achetronic/magec/server/api/admin" - "github.com/achetronic/magec/server/store" + // Importing the admin docs package registers the spec with swaggo's + // global registry so swag.ReadDoc("swagger") returns the rendered JSON. + _ "github.com/achetronic/magec/server/api/admin/docs" ) -// Handler is the embedded MCP server's dependency container. It mirrors -// admin.Handler so MCP tools have access to the same store, conversation -// store, and ADK session service. -type Handler struct { - store *store.Store - conversations *store.ConversationStore - adminHandler *admin.Handler +// swaggerInstanceName is the swag instance identifier set in the admin +// docs package. Anything that loads the spec uses the same constant. +const swaggerInstanceName = "swagger" + +// toolNamePrefix is prepended to every tool generated from the admin spec. +// Keeps the catalogue easy to spot when the client connects to several MCP +// servers at once. +const toolNamePrefix = "magec_" + +// HandlerConfig wires the MCP handler to the admin REST API. AdminBaseURL is +// the absolute base for the admin endpoints (host + /api/v1/admin), and +// AdminPassword is forwarded as the bearer token used by AdminAuth so the +// loopback request authenticates the same way an external client would. +type HandlerConfig struct { + AdminBaseURL string + AdminPassword string +} +// Handler is the embedded MCP server's dependency container. It loads the +// admin OpenAPI spec at construction time, registers one tool per filtered +// operation, and exposes a *mcp.StreamableHTTPHandler for the main server to +// mount behind the bearer middleware. +type Handler struct { server *sdk.Server streamable *sdk.StreamableHTTPHandler toolCount int } -// NewHandler builds a fresh MCP handler. The admin handler is borrowed only -// for its ADK session service accessor (used by the reset-session tool); it -// can be nil in tests that do not exercise that path. -func NewHandler(s *store.Store, cs *store.ConversationStore, ah *admin.Handler) *Handler { - h := &Handler{store: s, conversations: cs, adminHandler: ah} - h.server = sdk.NewServer(&sdk.Implementation{ +// NewHandler builds a fresh MCP handler from the admin spec. The spec is +// embedded in the binary via swaggo; no network I/O happens here. +func NewHandler(cfg HandlerConfig) (*Handler, error) { + spec, err := loadAdminSpec() + if err != nil { + return nil, fmt.Errorf("load admin spec: %w", err) + } + + filters, err := openapi.CompileRouteFilters(defaultRouteFilters()) + if err != nil { + return nil, fmt.Errorf("compile route filters: %w", err) + } + routes := openapi.FilterRoutes(spec, filters) + + exec := &mcptools.HTTPExecutor{ + BaseURL: cfg.AdminBaseURL, + Client: &http.Client{Timeout: 30 * time.Second}, + } + if cfg.AdminPassword != "" { + exec.DefaultHeaders = map[string]string{ + "Authorization": "Bearer " + cfg.AdminPassword, + } + } + + descriptors := mcptools.Describe(routes, mcptools.DescribeOptions{ + NamePrefix: toolNamePrefix, + CustomHandlerFactory: mcptools.HTTPHandlerFactory(exec), + }) + + server := sdk.NewServer(&sdk.Implementation{ Name: "magec-admin", Version: "1.0.0", }, nil) - h.registerAll() - h.streamable = sdk.NewStreamableHTTPHandler( - func(*http.Request) *sdk.Server { return h.server }, - &sdk.StreamableHTTPOptions{ - SessionTimeout: 10 * time.Minute, - }, + + count, err := registerDescriptors(server, descriptors) + if err != nil { + return nil, fmt.Errorf("register descriptors: %w", err) + } + + streamable := sdk.NewStreamableHTTPHandler( + func(*http.Request) *sdk.Server { return server }, + &sdk.StreamableHTTPOptions{SessionTimeout: 10 * time.Minute}, ) - return h + + return &Handler{ + server: server, + streamable: streamable, + toolCount: count, + }, nil } // HTTPHandler returns the http.Handler that speaks the MCP Streamable HTTP -// transport. Callers wrap it with their own auth/CORS middleware. +// transport. Callers wrap it with their own auth and CORS middleware. func (h *Handler) HTTPHandler() http.Handler { return h.streamable } // Server returns the underlying *mcp.Server. Used by tests that drive the // server through the in-memory transport. func (h *Handler) Server() *sdk.Server { return h.server } -// ToolCount returns the number of tools registered on the server. Used by -// the startup log line and the smoke test. +// ToolCount returns the number of tools registered on the server. Surfaced +// in the startup log so operators can sanity-check the catalogue. func (h *Handler) ToolCount() int { return h.toolCount } -// sessionService returns the borrowed ADK session service, or nil when the -// admin handler is not wired in. -func (h *Handler) sessionService() interface{} { - if h.adminHandler == nil { - return nil +// loadAdminSpec reads the admin swagger 2.0 document registered by swaggo, +// converts it to OpenAPI 3.0, and lets openapi2tools do the heavy lifting +// (ref resolution, example stripping, flexible numeric parameters). +func loadAdminSpec() (*openapi.Spec, error) { + raw, err := swag.ReadDoc(swaggerInstanceName) + if err != nil { + return nil, fmt.Errorf("read swagger doc: %w", err) + } + var v2 openapi2.T + if err := json.Unmarshal([]byte(raw), &v2); err != nil { + return nil, fmt.Errorf("unmarshal swagger 2.0: %w", err) + } + v3, err := openapi2conv.ToV3(&v2) + if err != nil { + return nil, fmt.Errorf("convert swagger 2.0 to openapi 3.0: %w", err) + } + v3JSON, err := json.Marshal(v3) + if err != nil { + return nil, fmt.Errorf("marshal openapi 3.0: %w", err) + } + loader := openapi.NewLoader(openapi.LoadOptions{ + ResolveRefs: true, + RemoveExamples: true, + FlexibleParameters: true, + }) + return loader.LoadBytes(v3JSON) +} + +// defaultRouteFilters trims the admin surface down to the operations that +// make sense as MCP tools. Binary streams (skills upload/download, backup +// and restore) and the admin-UI helper endpoints (auth/check, overview) are +// out — the first three because MCP tool inputs and outputs cannot carry +// large archives cleanly, and the last two because they are not operator +// actions. +func defaultRouteFilters() []openapi.RouteFilterConfig { + return []openapi.RouteFilterConfig{ + {Paths: `^/skills/upload$`, Exclude: true}, + {Paths: `^/skills/[^/]+/download$`, Exclude: true}, + {Paths: `^/settings/backup$`, Exclude: true}, + {Paths: `^/settings/restore$`, Exclude: true}, + {Paths: `^/auth/check$`, Exclude: true}, + {Paths: `^/overview$`, Exclude: true}, + {Paths: `.*`}, } - return h.adminHandler.SessionService() } diff --git a/server/api/mcp/handler_test.go b/server/api/mcp/handler_test.go new file mode 100644 index 0000000..5a91efa --- /dev/null +++ b/server/api/mcp/handler_test.go @@ -0,0 +1,206 @@ +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/middleware" +) + +// newTestHandler builds an MCP handler that talks to a stub admin server, +// so the unit suite never depends on a real magec instance. The stub records +// every request it sees and replies with a canned payload — enough to assert +// the dispatcher serialised arguments correctly and propagated the bearer. +func newTestHandler(t *testing.T) (*Handler, *adminStub) { + t.Helper() + stub := newAdminStub() + srv := httptest.NewServer(stub) + t.Cleanup(srv.Close) + + h, err := NewHandler(HandlerConfig{ + AdminBaseURL: srv.URL + "/api/v1/admin", + AdminPassword: "test-pwd", + }) + if err != nil { + t.Fatalf("NewHandler: %v", err) + } + return h, stub +} + +func TestSmoke_ToolsRegistered(t *testing.T) { + h, _ := newTestHandler(t) + ctx := context.Background() + + serverT, clientT := sdk.NewInMemoryTransports() + if _, err := h.Server().Connect(ctx, serverT, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "0"}, nil) + sess, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer sess.Close() + + res, err := sess.ListTools(ctx, nil) + if err != nil { + t.Fatalf("list tools: %v", err) + } + if len(res.Tools) == 0 { + t.Fatal("no tools registered") + } + if h.ToolCount() != len(res.Tools) { + t.Fatalf("tool count mismatch: ToolCount=%d ListTools=%d", h.ToolCount(), len(res.Tools)) + } + // Spot-check a few that we expect to exist regardless of filter changes. + have := map[string]bool{} + for _, tool := range res.Tools { + have[tool.Name] = true + if !strings.HasPrefix(tool.Name, toolNamePrefix) { + t.Errorf("tool %q missing %q prefix", tool.Name, toolNamePrefix) + } + } + for _, want := range []string{"magec_get_backends", "magec_post_agents"} { + if !have[want] { + t.Errorf("expected representative tool %q to be registered", want) + } + } +} + +func TestDispatcher_ForwardsArgumentsAndBearer(t *testing.T) { + h, stub := newTestHandler(t) + ctx := context.Background() + + serverT, clientT := sdk.NewInMemoryTransports() + if _, err := h.Server().Connect(ctx, serverT, nil); err != nil { + t.Fatalf("server connect: %v", err) + } + client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "0"}, nil) + sess, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer sess.Close() + + stub.respond("POST", "/api/v1/admin/backends", http.StatusCreated, + `{"id":"abc","name":"OpenAI","type":"openai"}`) + + res, err := sess.CallTool(ctx, &sdk.CallToolParams{ + Name: "magec_post_backends", + Arguments: map[string]any{ + "name": "OpenAI", + "type": "openai", + }, + }) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if res.IsError { + t.Fatalf("tool returned error: %+v", res.Content) + } + if stub.lastAuth != "Bearer test-pwd" { + t.Errorf("bearer not forwarded; got %q", stub.lastAuth) + } + if stub.lastMethod != "POST" { + t.Errorf("method: got %q, want POST", stub.lastMethod) + } + if stub.lastPath != "/api/v1/admin/backends" { + t.Errorf("path: got %q, want /api/v1/admin/backends", stub.lastPath) + } + var body map[string]any + if err := json.Unmarshal(stub.lastBody, &body); err != nil { + t.Fatalf("body parse: %v", err) + } + if body["name"] != "OpenAI" || body["type"] != "openai" { + t.Errorf("body mismatch: %v", body) + } +} + +func TestHTTP_BearerRequired(t *testing.T) { + h, _ := newTestHandler(t) + srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "secret")) + defer srv.Close() + + const initFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}` + + resp, err := http.Post(srv.URL, "application/json", strings.NewReader(initFrame)) + if err != nil { + t.Fatalf("POST: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no auth: got %d, want 401", resp.StatusCode) + } + + req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("Do: %v", err) + } + resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + t.Fatalf("authenticated request rejected: status %d", resp.StatusCode) + } +} + +// adminStub is a minimal HTTP server that records the request it received +// and replies with whatever the test rigged. +type adminStub struct { + lastAuth string + lastMethod string + lastPath string + lastBody []byte + + responses map[string]stubResponse +} + +type stubResponse struct { + status int + body string +} + +func newAdminStub() *adminStub { + return &adminStub{responses: map[string]stubResponse{}} +} + +func (s *adminStub) respond(method, path string, status int, body string) { + s.responses[method+" "+path] = stubResponse{status: status, body: body} +} + +func (s *adminStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.lastAuth = r.Header.Get("Authorization") + s.lastMethod = r.Method + s.lastPath = r.URL.Path + body := make([]byte, 0, 1024) + if r.Body != nil { + defer r.Body.Close() + buf := make([]byte, 4096) + for { + n, err := r.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + } + s.lastBody = body + + resp, ok := s.responses[r.Method+" "+r.URL.Path] + if !ok { + resp = stubResponse{status: http.StatusOK, body: "{}"} + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.status) + _, _ = w.Write([]byte(resp.body)) +} diff --git a/server/api/mcp/schemas.go b/server/api/mcp/schemas.go deleted file mode 100644 index 181eddd..0000000 --- a/server/api/mcp/schemas.go +++ /dev/null @@ -1,24 +0,0 @@ -package mcp - -// idInput is the canonical shape for tools that target a resource by ID. -type idInput struct { - ID string `json:"id" jsonschema:"resource id"` -} - -// agentMCPLinkInput targets the agent/{id}/mcps/{mcpId} link operations. -type agentMCPLinkInput struct { - AgentID string `json:"agentId" jsonschema:"agent id"` - MCPID string `json:"mcpId" jsonschema:"mcp server id"` -} - -// idsOutput is reused by tools that return a list of IDs. -type idsOutput struct { - IDs []string `json:"ids"` -} - -// emptyOutput is returned by tools that have no payload (delete, link, etc.). -type emptyOutput struct { - OK bool `json:"ok"` -} - -var okOutput = emptyOutput{OK: true} diff --git a/server/api/mcp/server.go b/server/api/mcp/server.go deleted file mode 100644 index 4bac0b9..0000000 --- a/server/api/mcp/server.go +++ /dev/null @@ -1,19 +0,0 @@ -package mcp - -// registerAll wires every tool group on the MCP server. Each helper -// increments h.toolCount so the startup log can report the catalogue size -// without re-introspecting the SDK. -func (h *Handler) registerAll() { - h.registerBackendTools() - h.registerMemoryTools() - h.registerMCPServerTools() - h.registerAgentTools() - h.registerClientTools() - h.registerCommandTools() - h.registerFlowTools() - h.registerSkillTools() - h.registerSettingsTools() - h.registerSecretTools() - h.registerConversationTools() - h.registerVoiceTools() -} diff --git a/server/api/mcp/server_http_test.go b/server/api/mcp/server_http_test.go deleted file mode 100644 index 438cc33..0000000 --- a/server/api/mcp/server_http_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package mcp - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/achetronic/magec/server/middleware" -) - -// initFrame is a minimal MCP initialize request used to coerce the SDK into -// a response. We only check that auth is enforced; whether the SDK accepts -// the payload is incidental. -const initFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}` - -func TestHTTP_BearerRequired(t *testing.T) { - h := newTestHandler(t) - srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "secret")) - defer srv.Close() - - // No bearer -> 401. - resp, err := http.Post(srv.URL, "application/json", strings.NewReader(initFrame)) - if err != nil { - t.Fatalf("POST: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("no auth: got %d, want 401", resp.StatusCode) - } - - // Wrong bearer -> 401. - req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) - req.Header.Set("Authorization", "Bearer wrong") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - resp, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("wrong auth: got %d, want 401", resp.StatusCode) - } - - // Correct bearer -> anything but 401. - req, _ = http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) - req.Header.Set("Authorization", "Bearer secret") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - resp, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - t.Fatalf("authenticated request rejected: status %d", resp.StatusCode) - } -} - -func TestHTTP_OpenModeBypass(t *testing.T) { - h := newTestHandler(t) - // Empty password -> middleware short-circuits, requests pass through. - srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "")) - defer srv.Close() - - req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("Do: %v", err) - } - resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - t.Fatal("open mode unexpectedly returned 401") - } -} diff --git a/server/api/mcp/server_test.go b/server/api/mcp/server_test.go deleted file mode 100644 index 7bd1abc..0000000 --- a/server/api/mcp/server_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package mcp - -import ( - "context" - "testing" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// TestSmoke_ToolsRegistered drives the embedded MCP server through the -// in-memory transport and verifies the full tool catalogue is exposed. -func TestSmoke_ToolsRegistered(t *testing.T) { - h := newTestHandler(t) - ctx := context.Background() - - serverT, clientT := sdk.NewInMemoryTransports() - if _, err := h.server.Connect(ctx, serverT, nil); err != nil { - t.Fatalf("server connect: %v", err) - } - - client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "0"}, nil) - sess, err := client.Connect(ctx, clientT, nil) - if err != nil { - t.Fatalf("client connect: %v", err) - } - defer sess.Close() - - res, err := sess.ListTools(ctx, nil) - if err != nil { - t.Fatalf("list tools: %v", err) - } - if len(res.Tools) < 50 { - t.Fatalf("expected at least 50 tools registered, got %d", len(res.Tools)) - } - - // Check that the SDK-reported count matches the handler's internal count. - if got, want := len(res.Tools), h.ToolCount(); got != want { - t.Fatalf("tool count mismatch: ListTools=%d, ToolCount=%d", got, want) - } - - // Spot-check that a representative tool from each major group is present. - required := []string{ - "magec_list_backends", - "magec_create_agent", - "magec_list_flows", - "magec_list_clients", - "magec_list_commands", - "magec_list_skills", - "magec_get_settings", - "magec_list_secrets", - "magec_list_conversations", - "magec_list_voice_types", - "magec_list_mcp_servers", - "magec_list_memory_providers", - } - have := make(map[string]bool, len(res.Tools)) - for _, tool := range res.Tools { - have[tool.Name] = true - } - for _, name := range required { - if !have[name] { - t.Errorf("required tool missing from server: %s", name) - } - } -} diff --git a/server/api/mcp/tools_agents.go b/server/api/mcp/tools_agents.go deleted file mode 100644 index 1b27349..0000000 --- a/server/api/mcp/tools_agents.go +++ /dev/null @@ -1,138 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -type listAgentsOutput struct { - Agents []store.AgentDefinition `json:"agents"` -} - -func (h *Handler) listAgents(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listAgentsOutput, error) { - return nil, listAgentsOutput{Agents: h.store.ListRawAgents()}, nil -} - -func (h *Handler) getAgent(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.AgentDefinition, error) { - a, ok := h.store.GetRawAgent(in.ID) - if !ok { - return nil, store.AgentDefinition{}, fmt.Errorf("get agent: %w", errValidation("not found: "+in.ID)) - } - return nil, a, nil -} - -type createAgentInput struct { - Agent store.AgentDefinition `json:"agent" jsonschema:"agent definition"` -} - -func (h *Handler) createAgent(_ context.Context, _ *sdk.CallToolRequest, in createAgentInput) (*sdk.CallToolResult, store.AgentDefinition, error) { - if in.Agent.Name == "" { - return nil, store.AgentDefinition{}, fmt.Errorf("create agent: %w", errValidation("name is required")) - } - created, err := h.store.CreateAgent(in.Agent) - if err != nil { - return nil, store.AgentDefinition{}, fmt.Errorf("create agent: %w", err) - } - return nil, created, nil -} - -type updateAgentInput struct { - ID string `json:"id" jsonschema:"agent id"` - Agent store.AgentDefinition `json:"agent" jsonschema:"new agent definition"` -} - -func (h *Handler) updateAgent(_ context.Context, _ *sdk.CallToolRequest, in updateAgentInput) (*sdk.CallToolResult, store.AgentDefinition, error) { - if err := h.store.UpdateAgent(in.ID, in.Agent); err != nil { - return nil, store.AgentDefinition{}, fmt.Errorf("update agent: %w", err) - } - updated, _ := h.store.GetRawAgent(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteAgent(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteAgent(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete agent: %w", err) - } - return nil, okOutput, nil -} - -type listAgentMCPsOutput struct { - MCPServers []store.MCPServer `json:"mcpServers"` -} - -func (h *Handler) listAgentMCPs(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, listAgentMCPsOutput, error) { - mcps, err := h.store.ResolveRawAgentMCPs(in.ID) - if err != nil { - return nil, listAgentMCPsOutput{}, fmt.Errorf("list agent mcps: %w", err) - } - return nil, listAgentMCPsOutput{MCPServers: mcps}, nil -} - -func (h *Handler) linkAgentMCP(_ context.Context, _ *sdk.CallToolRequest, in agentMCPLinkInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.LinkAgentMCP(in.AgentID, in.MCPID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("link agent mcp: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) unlinkAgentMCP(_ context.Context, _ *sdk.CallToolRequest, in agentMCPLinkInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.UnlinkAgentMCP(in.AgentID, in.MCPID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("unlink agent mcp: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerAgentTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_agents", Title: "List agents", - Description: "List every agent definition.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listAgents) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_agent", Title: "Get agent", - Description: "Return one agent by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getAgent) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_agent", Title: "Create agent", - Description: "Create a new agent.", - }, h.createAgent) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_agent", Title: "Update agent", - Description: "Replace the agent identified by id.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateAgent) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_agent", Title: "Delete agent", - Description: "Delete an agent by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteAgent) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_agent_mcps", Title: "List agent MCP servers", - Description: "Resolve the MCP servers linked to an agent.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listAgentMCPs) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_link_agent_mcp", Title: "Link MCP to agent", - Description: "Attach an MCP server to an agent.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.linkAgentMCP) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_unlink_agent_mcp", Title: "Unlink MCP from agent", - Description: "Detach an MCP server from an agent.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.unlinkAgentMCP) - h.toolCount++ -} diff --git a/server/api/mcp/tools_agents_test.go b/server/api/mcp/tools_agents_test.go deleted file mode 100644 index cf3aa53..0000000 --- a/server/api/mcp/tools_agents_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package mcp - -import ( - "context" - "testing" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -func TestAgentCRUD_LifecycleAndMCPLinks(t *testing.T) { - h := newTestHandler(t) - ctx := context.Background() - - _, _, err := h.createAgent(ctx, &sdk.CallToolRequest{}, createAgentInput{Agent: store.AgentDefinition{Name: ""}}) - if err == nil || !IsValidation(err) { - t.Fatalf("expected validation error for empty name, got %v", err) - } - - _, agent, err := h.createAgent(ctx, &sdk.CallToolRequest{}, createAgentInput{Agent: store.AgentDefinition{Name: "weather"}}) - if err != nil { - t.Fatalf("create agent: %v", err) - } - if agent.ID == "" { - t.Fatal("agent id is empty") - } - - _, mcp, err := h.createMCPServer(ctx, &sdk.CallToolRequest{}, createMCPInput{Server: store.MCPServer{Name: "weather-mcp", Type: "http", Endpoint: "http://example.com"}}) - if err != nil { - t.Fatalf("create mcp server: %v", err) - } - - if _, _, err := h.linkAgentMCP(ctx, &sdk.CallToolRequest{}, agentMCPLinkInput{AgentID: agent.ID, MCPID: mcp.ID}); err != nil { - t.Fatalf("link agent mcp: %v", err) - } - - _, list, err := h.listAgentMCPs(ctx, &sdk.CallToolRequest{}, idInput{ID: agent.ID}) - if err != nil { - t.Fatalf("list agent mcps: %v", err) - } - if len(list.MCPServers) != 1 || list.MCPServers[0].ID != mcp.ID { - t.Fatalf("unexpected agent mcps: %+v", list) - } - - if _, _, err := h.unlinkAgentMCP(ctx, &sdk.CallToolRequest{}, agentMCPLinkInput{AgentID: agent.ID, MCPID: mcp.ID}); err != nil { - t.Fatalf("unlink agent mcp: %v", err) - } - - if _, _, err := h.deleteAgent(ctx, &sdk.CallToolRequest{}, idInput{ID: agent.ID}); err != nil { - t.Fatalf("delete agent: %v", err) - } - if got := len(h.store.ListRawAgents()); got != 0 { - t.Fatalf("expected 0 agents after delete, got %d", got) - } -} diff --git a/server/api/mcp/tools_backends.go b/server/api/mcp/tools_backends.go deleted file mode 100644 index 35f2814..0000000 --- a/server/api/mcp/tools_backends.go +++ /dev/null @@ -1,106 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -type listBackendsOutput struct { - Backends []store.BackendDefinition `json:"backends"` -} - -func (h *Handler) listBackends(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listBackendsOutput, error) { - return nil, listBackendsOutput{Backends: h.store.ListRawBackends()}, nil -} - -func (h *Handler) getBackend(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.BackendDefinition, error) { - b, ok := h.store.GetRawBackend(in.ID) - if !ok { - return nil, store.BackendDefinition{}, fmt.Errorf("get backend: %w", errValidation("backend not found: "+in.ID)) - } - return nil, b, nil -} - -type createBackendInput struct { - Definition store.BackendDefinition `json:"definition" jsonschema:"backend definition (id is assigned by the server)"` -} - -func (h *Handler) createBackend(_ context.Context, _ *sdk.CallToolRequest, in createBackendInput) (*sdk.CallToolResult, store.BackendDefinition, error) { - if in.Definition.Name == "" { - return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", errValidation("name is required")) - } - if in.Definition.Type == "" { - return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", errValidation("type is required")) - } - created, err := h.store.CreateBackend(in.Definition) - if err != nil { - return nil, store.BackendDefinition{}, fmt.Errorf("create backend: %w", err) - } - return nil, created, nil -} - -type updateBackendInput struct { - ID string `json:"id" jsonschema:"backend id"` - Definition store.BackendDefinition `json:"definition" jsonschema:"new backend definition (id is taken from the path)"` -} - -func (h *Handler) updateBackend(_ context.Context, _ *sdk.CallToolRequest, in updateBackendInput) (*sdk.CallToolResult, store.BackendDefinition, error) { - if err := h.store.UpdateBackend(in.ID, in.Definition); err != nil { - return nil, store.BackendDefinition{}, fmt.Errorf("update backend: %w", err) - } - updated, _ := h.store.GetRawBackend(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteBackend(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteBackend(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete backend: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerBackendTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_backends", - Title: "List backends", - Description: "List every configured LLM/TTS/transcription backend.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listBackends) - h.toolCount++ - - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_backend", - Title: "Get backend", - Description: "Return one backend by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getBackend) - h.toolCount++ - - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_backend", - Title: "Create backend", - Description: "Create a new backend. The server assigns the id.", - }, h.createBackend) - h.toolCount++ - - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_backend", - Title: "Update backend", - Description: "Replace the backend identified by id with the provided definition.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateBackend) - h.toolCount++ - - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_backend", - Title: "Delete backend", - Description: "Delete a backend by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteBackend) - h.toolCount++ -} diff --git a/server/api/mcp/tools_backends_test.go b/server/api/mcp/tools_backends_test.go deleted file mode 100644 index f8d8dae..0000000 --- a/server/api/mcp/tools_backends_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package mcp - -import ( - "context" - "path/filepath" - "testing" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -func newTestHandler(t *testing.T) *Handler { - t.Helper() - s, err := store.New(filepath.Join(t.TempDir(), "store.json"), "") - if err != nil { - t.Fatalf("store.New: %v", err) - } - return NewHandler(s, nil, nil) -} - -func TestListBackends_Empty(t *testing.T) { - h := newTestHandler(t) - _, out, err := h.listBackends(context.Background(), &sdk.CallToolRequest{}, struct{}{}) - if err != nil { - t.Fatalf("listBackends: %v", err) - } - if len(out.Backends) != 0 { - t.Fatalf("got %d backends, want 0", len(out.Backends)) - } -} - -func TestCreateBackend(t *testing.T) { - cases := []struct { - name string - in store.BackendDefinition - wantErr bool - wantStore int - }{ - {"valid", store.BackendDefinition{Name: "openai-1", Type: "openai"}, false, 1}, - {"missing name", store.BackendDefinition{Type: "openai"}, true, 0}, - {"missing type", store.BackendDefinition{Name: "x"}, true, 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - h := newTestHandler(t) - _, out, err := h.createBackend(context.Background(), &sdk.CallToolRequest{}, createBackendInput{Definition: tc.in}) - if (err != nil) != tc.wantErr { - t.Fatalf("err=%v wantErr=%v", err, tc.wantErr) - } - if !tc.wantErr { - if out.ID == "" { - t.Fatal("expected non-empty id") - } - if err != nil && !IsValidation(err) { - t.Fatalf("expected validation error, got %v", err) - } - } - if got := len(h.store.ListRawBackends()); got != tc.wantStore { - t.Fatalf("store size: got %d want %d", got, tc.wantStore) - } - }) - } -} - -func TestUpdateAndDeleteBackend(t *testing.T) { - h := newTestHandler(t) - _, created, err := h.createBackend(context.Background(), &sdk.CallToolRequest{}, createBackendInput{ - Definition: store.BackendDefinition{Name: "openai-1", Type: "openai"}, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - - _, updated, err := h.updateBackend(context.Background(), &sdk.CallToolRequest{}, updateBackendInput{ - ID: created.ID, - Definition: store.BackendDefinition{Name: "openai-renamed", Type: "openai"}, - }) - if err != nil { - t.Fatalf("update: %v", err) - } - if updated.Name != "openai-renamed" { - t.Fatalf("update did not persist; got name=%q", updated.Name) - } - - if _, _, err := h.deleteBackend(context.Background(), &sdk.CallToolRequest{}, idInput{ID: created.ID}); err != nil { - t.Fatalf("delete: %v", err) - } - if got := len(h.store.ListRawBackends()); got != 0 { - t.Fatalf("after delete: %d backends remain", got) - } - - if _, _, err := h.getBackend(context.Background(), &sdk.CallToolRequest{}, idInput{ID: created.ID}); err == nil { - t.Fatal("expected error fetching deleted backend") - } -} diff --git a/server/api/mcp/tools_clients.go b/server/api/mcp/tools_clients.go deleted file mode 100644 index 60c8e10..0000000 --- a/server/api/mcp/tools_clients.go +++ /dev/null @@ -1,152 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/api/admin" - "github.com/achetronic/magec/server/clients" - "github.com/achetronic/magec/server/store" -) - -type listClientsOutput struct { - Clients []store.ClientDefinition `json:"clients"` -} - -func (h *Handler) listClients(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listClientsOutput, error) { - return nil, listClientsOutput{Clients: h.store.ListRawClients()}, nil -} - -func (h *Handler) getClient(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.ClientDefinition, error) { - c, ok := h.store.GetRawClient(in.ID) - if !ok { - return nil, store.ClientDefinition{}, fmt.Errorf("get client: %w", errValidation("not found: "+in.ID)) - } - return nil, c, nil -} - -type createClientInput struct { - Client store.ClientDefinition `json:"client" jsonschema:"client definition (token is auto-generated)"` -} - -func (h *Handler) createClient(_ context.Context, _ *sdk.CallToolRequest, in createClientInput) (*sdk.CallToolResult, store.ClientDefinition, error) { - c := in.Client - if c.Name == "" { - return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("name is required")) - } - if c.Type == "" { - return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("type is required")) - } - if !clients.ValidType(c.Type) { - return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", errValidation("unsupported client type: "+c.Type)) - } - if err := admin.ValidateClientConfig(c); err != nil { - return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", err) - } - created, err := h.store.CreateClient(c) - if err != nil { - return nil, store.ClientDefinition{}, fmt.Errorf("create client: %w", err) - } - return nil, created, nil -} - -type updateClientInput struct { - ID string `json:"id" jsonschema:"client id"` - Client store.ClientDefinition `json:"client" jsonschema:"new client definition"` -} - -func (h *Handler) updateClient(_ context.Context, _ *sdk.CallToolRequest, in updateClientInput) (*sdk.CallToolResult, store.ClientDefinition, error) { - if in.Client.Type != "" { - if err := admin.ValidateClientConfig(in.Client); err != nil { - return nil, store.ClientDefinition{}, fmt.Errorf("update client: %w", err) - } - } - if err := h.store.UpdateClient(in.ID, in.Client); err != nil { - return nil, store.ClientDefinition{}, fmt.Errorf("update client: %w", err) - } - updated, _ := h.store.GetRawClient(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteClient(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteClient(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete client: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) regenerateClientToken(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.ClientDefinition, error) { - cl, err := h.store.RegenerateClientToken(in.ID) - if err != nil { - return nil, store.ClientDefinition{}, fmt.Errorf("regenerate client token: %w", err) - } - return nil, cl, nil -} - -type clientTypeInfo struct { - Type string `json:"type"` - DisplayName string `json:"displayName"` - ConfigSchema clients.Schema `json:"configSchema"` -} - -type listClientTypesOutput struct { - Types []clientTypeInfo `json:"types"` -} - -func (h *Handler) listClientTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listClientTypesOutput, error) { - var types []clientTypeInfo - for _, p := range clients.All() { - types = append(types, clientTypeInfo{ - Type: p.Type(), - DisplayName: p.DisplayName(), - ConfigSchema: p.ConfigSchema(), - }) - } - return nil, listClientTypesOutput{Types: types}, nil -} - -func (h *Handler) registerClientTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_clients", Title: "List clients", - Description: "List every configured client (telegram, slack, discord, cron, webhook, direct).", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listClients) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_client", Title: "Get client", - Description: "Return one client by id (including the auth token).", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getClient) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_client", Title: "Create client", - Description: "Create a new client. Type must be registered (magec_list_client_types) and config must validate against the type's schema.", - }, h.createClient) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_client", Title: "Update client", - Description: "Replace the client identified by id. Token and id are preserved.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateClient) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_client", Title: "Delete client", - Description: "Delete a client by id, revoking its token.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteClient) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_regenerate_client_token", Title: "Regenerate client token", - Description: "Generate a fresh auth token for a client, invalidating the previous one.", - }, h.regenerateClientToken) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_client_types", Title: "List client types", - Description: "List registered client types with their JSON schemas.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listClientTypes) - h.toolCount++ -} diff --git a/server/api/mcp/tools_commands.go b/server/api/mcp/tools_commands.go deleted file mode 100644 index 97c0be6..0000000 --- a/server/api/mcp/tools_commands.go +++ /dev/null @@ -1,97 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -type listCommandsOutput struct { - Commands []store.Command `json:"commands"` -} - -func (h *Handler) listCommands(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listCommandsOutput, error) { - return nil, listCommandsOutput{Commands: h.store.ListRawCommands()}, nil -} - -func (h *Handler) getCommand(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.Command, error) { - c, ok := h.store.GetRawCommand(in.ID) - if !ok { - return nil, store.Command{}, fmt.Errorf("get command: %w", errValidation("not found: "+in.ID)) - } - return nil, c, nil -} - -type createCommandInput struct { - Command store.Command `json:"command" jsonschema:"command definition"` -} - -func (h *Handler) createCommand(_ context.Context, _ *sdk.CallToolRequest, in createCommandInput) (*sdk.CallToolResult, store.Command, error) { - if in.Command.Name == "" { - return nil, store.Command{}, fmt.Errorf("create command: %w", errValidation("name is required")) - } - if in.Command.Prompt == "" { - return nil, store.Command{}, fmt.Errorf("create command: %w", errValidation("prompt is required")) - } - created, err := h.store.CreateCommand(in.Command) - if err != nil { - return nil, store.Command{}, fmt.Errorf("create command: %w", err) - } - return nil, created, nil -} - -type updateCommandInput struct { - ID string `json:"id" jsonschema:"command id"` - Command store.Command `json:"command" jsonschema:"new command definition"` -} - -func (h *Handler) updateCommand(_ context.Context, _ *sdk.CallToolRequest, in updateCommandInput) (*sdk.CallToolResult, store.Command, error) { - if err := h.store.UpdateCommand(in.ID, in.Command); err != nil { - return nil, store.Command{}, fmt.Errorf("update command: %w", err) - } - updated, _ := h.store.GetRawCommand(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteCommand(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteCommand(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete command: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerCommandTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_commands", Title: "List commands", - Description: "List reusable prompts that can be invoked via cron or webhook clients.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listCommands) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_command", Title: "Get command", - Description: "Return one command by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getCommand) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_command", Title: "Create command", - Description: "Create a new reusable command.", - }, h.createCommand) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_command", Title: "Update command", - Description: "Replace the command identified by id.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateCommand) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_command", Title: "Delete command", - Description: "Delete a command by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteCommand) - h.toolCount++ -} diff --git a/server/api/mcp/tools_conversations.go b/server/api/mcp/tools_conversations.go deleted file mode 100644 index 76a8838..0000000 --- a/server/api/mcp/tools_conversations.go +++ /dev/null @@ -1,228 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - "google.golang.org/adk/session" - - "github.com/achetronic/magec/server/store" -) - -type listConversationsInput struct { - AgentID string `json:"agentId,omitempty" jsonschema:"filter by agent or flow id"` - Source string `json:"source,omitempty" jsonschema:"filter by source (voice-ui, telegram, executor, direct, cron, webhook)"` - ClientID string `json:"clientId,omitempty"` - Perspective string `json:"perspective,omitempty" jsonschema:"filter by perspective (admin or user)"` - Limit int `json:"limit,omitempty" jsonschema:"max items to return (default 30, 0 for all)"` - Offset int `json:"offset,omitempty"` -} - -func (h *Handler) listConversations(_ context.Context, _ *sdk.CallToolRequest, in listConversationsInput) (*sdk.CallToolResult, store.PaginatedResult[store.Conversation], error) { - if h.conversations == nil { - return nil, store.PaginatedResult[store.Conversation]{Items: []store.Conversation{}}, nil - } - limit := in.Limit - if limit == 0 { - limit = 30 - } - return nil, h.conversations.List(in.AgentID, in.Source, in.ClientID, in.Perspective, limit, in.Offset), nil -} - -type getConversationInput struct { - ID string `json:"id" jsonschema:"conversation id"` - MsgLimit int `json:"msgLimit,omitempty" jsonschema:"max messages to return (default 50, 0 for all)"` - MsgOffset int `json:"msgOffset,omitempty"` -} - -type getConversationOutput struct { - Conversation store.Conversation `json:"conversation"` - TotalMessages int `json:"totalMessages"` -} - -func (h *Handler) getConversation(_ context.Context, _ *sdk.CallToolRequest, in getConversationInput) (*sdk.CallToolResult, getConversationOutput, error) { - if h.conversations == nil { - return nil, getConversationOutput{}, fmt.Errorf("get conversation: %w", errValidation("conversation store not initialized")) - } - limit := in.MsgLimit - if limit == 0 { - limit = 50 - } - convo, total, ok := h.conversations.Get(in.ID, limit, in.MsgOffset) - if !ok { - return nil, getConversationOutput{}, fmt.Errorf("get conversation: %w", errValidation("not found: "+in.ID)) - } - return nil, getConversationOutput{Conversation: convo, TotalMessages: total}, nil -} - -func (h *Handler) deleteConversation(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if h.conversations == nil { - return nil, emptyOutput{}, fmt.Errorf("delete conversation: %w", errValidation("conversation store not initialized")) - } - if err := h.conversations.Delete(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete conversation: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) clearConversations(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, emptyOutput, error) { - if h.conversations == nil { - return nil, emptyOutput{}, fmt.Errorf("clear conversations: %w", errValidation("conversation store not initialized")) - } - if err := h.conversations.Clear(); err != nil { - return nil, emptyOutput{}, fmt.Errorf("clear conversations: %w", err) - } - return nil, okOutput, nil -} - -type conversationStatsOutput struct { - Total int `json:"total"` - BySources map[string]int `json:"bySources"` - ByAgents map[string]int `json:"byAgents"` -} - -func (h *Handler) conversationStats(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, conversationStatsOutput, error) { - if h.conversations == nil { - return nil, conversationStatsOutput{BySources: map[string]int{}, ByAgents: map[string]int{}}, nil - } - all := h.conversations.List("", "", "", "", 0, 0) - source := map[string]int{} - agent := map[string]int{} - for _, c := range all.Items { - source[c.Source]++ - if c.AgentName != "" { - agent[c.AgentName]++ - } else { - agent[c.AgentID]++ - } - } - return nil, conversationStatsOutput{Total: all.Total, BySources: source, ByAgents: agent}, nil -} - -type updateConversationSummaryInput struct { - ID string `json:"id" jsonschema:"conversation id"` - Summary string `json:"summary" jsonschema:"new summary text"` -} - -func (h *Handler) updateConversationSummary(_ context.Context, _ *sdk.CallToolRequest, in updateConversationSummaryInput) (*sdk.CallToolResult, store.Conversation, error) { - if h.conversations == nil { - return nil, store.Conversation{}, fmt.Errorf("update conversation summary: %w", errValidation("conversation store not initialized")) - } - if err := h.conversations.SetSummary(in.ID, in.Summary); err != nil { - return nil, store.Conversation{}, fmt.Errorf("update conversation summary: %w", err) - } - convo, _, _ := h.conversations.Get(in.ID, 0, 0) - return nil, convo, nil -} - -type findPairOutput struct { - PairID string `json:"pairId,omitempty"` -} - -func (h *Handler) findConversationPair(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, findPairOutput, error) { - if h.conversations == nil { - return nil, findPairOutput{}, fmt.Errorf("find conversation pair: %w", errValidation("conversation store not initialized")) - } - convo, _, ok := h.conversations.Get(in.ID, 0, 0) - if !ok { - return nil, findPairOutput{}, fmt.Errorf("find conversation pair: %w", errValidation("not found: "+in.ID)) - } - pair, found := h.conversations.FindExactPair(convo.ID, convo.SessionID, convo.AgentID, convo.Perspective) - if !found { - return nil, findPairOutput{}, nil - } - return nil, findPairOutput{PairID: pair.ID}, nil -} - -type resetSessionOutput struct { - Message string `json:"message"` - AgentID string `json:"agentId"` - SessionID string `json:"sessionId"` -} - -func (h *Handler) resetConversationSession(ctx context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, resetSessionOutput, error) { - if h.conversations == nil { - return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation store not initialized")) - } - svc, _ := h.sessionService().(session.Service) - if svc == nil { - return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("session service not available")) - } - convo, _, ok := h.conversations.Get(in.ID, 0, 0) - if !ok { - return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation not found: "+in.ID)) - } - if convo.AgentID == "" || convo.SessionID == "" { - return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", errValidation("conversation has no agent or session id")) - } - userID := convo.UserID - if userID == "" { - userID = "user" - } - if err := svc.Delete(ctx, &session.DeleteRequest{ - AppName: convo.AgentID, - UserID: userID, - SessionID: convo.SessionID, - }); err != nil { - return nil, resetSessionOutput{}, fmt.Errorf("reset session: %w", err) - } - _ = h.conversations.CloseBySession(convo.SessionID, convo.AgentID, "admin") - _ = h.conversations.CloseBySession(convo.SessionID, convo.AgentID, "user") - return nil, resetSessionOutput{ - Message: "session reset successfully", - AgentID: convo.AgentID, - SessionID: convo.SessionID, - }, nil -} - -func (h *Handler) registerConversationTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_conversations", Title: "List conversations", - Description: "Paginated list of conversation audit logs (newest first). Filters by agent, source, client, perspective.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listConversations) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_conversation", Title: "Get conversation", - Description: "Return one conversation by id with paginated messages.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getConversation) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_conversation", Title: "Delete conversation", - Description: "Delete a conversation by id (also deletes the paired perspective).", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteConversation) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_clear_conversations", Title: "Clear all conversations", - Description: "Delete every conversation audit log. Does not affect ADK sessions.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive}, - }, h.clearConversations) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_conversation_stats", Title: "Conversation stats", - Description: "Return totals broken down by source and agent.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.conversationStats) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_conversation_summary", Title: "Update conversation summary", - Description: "Set the summary text used by context window compaction.", - }, h.updateConversationSummary) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_find_conversation_pair", Title: "Find conversation pair", - Description: "Return the id of the conversation that records the opposite perspective (admin/user) of a given conversation.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.findConversationPair) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_reset_conversation_session", Title: "Reset ADK session for a conversation", - Description: "Delete the ADK session associated with a conversation so the next message starts a fresh one. Requires the ADK session service to be wired in.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive}, - }, h.resetConversationSession) - h.toolCount++ -} diff --git a/server/api/mcp/tools_flows.go b/server/api/mcp/tools_flows.go deleted file mode 100644 index fbec7ed..0000000 --- a/server/api/mcp/tools_flows.go +++ /dev/null @@ -1,118 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - "github.com/google/jsonschema-go/jsonschema" - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/api/admin" - "github.com/achetronic/magec/server/store" -) - -// openObjectSchema is a permissive JSON Schema that accepts any object. We -// pin it on flow tools because store.FlowStep is self-referential (each step -// has Steps []FlowStep), and the SDK's reflection-based schema generator does -// not support cycles. Tool argument validation falls back to "any object" for -// these tools; the tool handler still receives a typed store.FlowDefinition -// via the SDK's JSON unmarshalling, so runtime behaviour is unchanged. -func openObjectSchema() *jsonschema.Schema { - return &jsonschema.Schema{Type: "object"} -} - -type listFlowsOutput struct { - Flows []store.FlowDefinition `json:"flows"` -} - -func (h *Handler) listFlows(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listFlowsOutput, error) { - return nil, listFlowsOutput{Flows: h.store.ListRawFlows()}, nil -} - -func (h *Handler) getFlow(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.FlowDefinition, error) { - f, ok := h.store.GetRawFlow(in.ID) - if !ok { - return nil, store.FlowDefinition{}, fmt.Errorf("get flow: %w", errValidation("not found: "+in.ID)) - } - return nil, f, nil -} - -type createFlowInput struct { - Flow store.FlowDefinition `json:"flow" jsonschema:"flow definition"` -} - -func (h *Handler) createFlow(_ context.Context, _ *sdk.CallToolRequest, in createFlowInput) (*sdk.CallToolResult, store.FlowDefinition, error) { - if in.Flow.Name == "" { - return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", errValidation("name is required")) - } - if err := admin.ValidateFlowStep(&in.Flow.Root); err != nil { - return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", err) - } - created, err := h.store.CreateFlow(in.Flow) - if err != nil { - return nil, store.FlowDefinition{}, fmt.Errorf("create flow: %w", err) - } - return nil, created, nil -} - -type updateFlowInput struct { - ID string `json:"id" jsonschema:"flow id"` - Flow store.FlowDefinition `json:"flow" jsonschema:"new flow definition"` -} - -func (h *Handler) updateFlow(_ context.Context, _ *sdk.CallToolRequest, in updateFlowInput) (*sdk.CallToolResult, store.FlowDefinition, error) { - if err := admin.ValidateFlowStep(&in.Flow.Root); err != nil { - return nil, store.FlowDefinition{}, fmt.Errorf("update flow: %w", err) - } - if err := h.store.UpdateFlow(in.ID, in.Flow); err != nil { - return nil, store.FlowDefinition{}, fmt.Errorf("update flow: %w", err) - } - updated, _ := h.store.GetRawFlow(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteFlow(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteFlow(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete flow: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerFlowTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_flows", Title: "List flows", - Description: "List every multi-agent flow.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - OutputSchema: openObjectSchema(), - }, h.listFlows) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_flow", Title: "Get flow", - Description: "Return one flow by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - OutputSchema: openObjectSchema(), - }, h.getFlow) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_flow", Title: "Create flow", - Description: "Create a new flow. The Root step tree is validated recursively. Loop steps must set at most one of exitLoop or exitWhen.", - InputSchema: openObjectSchema(), - OutputSchema: openObjectSchema(), - }, h.createFlow) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_flow", Title: "Update flow", - Description: "Replace the flow identified by id. The Root step tree is validated recursively.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - InputSchema: openObjectSchema(), - OutputSchema: openObjectSchema(), - }, h.updateFlow) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_flow", Title: "Delete flow", - Description: "Delete a flow by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteFlow) - h.toolCount++ -} diff --git a/server/api/mcp/tools_flows_test.go b/server/api/mcp/tools_flows_test.go deleted file mode 100644 index d1f0f30..0000000 --- a/server/api/mcp/tools_flows_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package mcp - -import ( - "context" - "testing" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -func TestCreateFlow_ValidatesRootStep(t *testing.T) { - h := newTestHandler(t) - ctx := context.Background() - - // Loop step with both exitLoop and exitWhen → admin validator rejects it. - bad := store.FlowDefinition{ - Name: "bad", - Root: store.FlowStep{ - Type: store.FlowStepLoop, - ExitLoop: true, - ExitWhen: "state.done == true", - Steps: []store.FlowStep{ - {Type: store.FlowStepAgent, AgentID: "agent-1"}, - }, - }, - } - if _, _, err := h.createFlow(ctx, &sdk.CallToolRequest{}, createFlowInput{Flow: bad}); err == nil { - t.Fatal("expected validation error for mutually exclusive exitLoop/exitWhen") - } - - good := store.FlowDefinition{ - Name: "good", - Root: store.FlowStep{ - Type: store.FlowStepSequential, - Steps: []store.FlowStep{ - {Type: store.FlowStepAgent, AgentID: "agent-1"}, - }, - }, - } - _, created, err := h.createFlow(ctx, &sdk.CallToolRequest{}, createFlowInput{Flow: good}) - if err != nil { - t.Fatalf("create good flow: %v", err) - } - if created.ID == "" { - t.Fatal("expected non-empty flow id") - } -} diff --git a/server/api/mcp/tools_mcps.go b/server/api/mcp/tools_mcps.go deleted file mode 100644 index 1a5e10c..0000000 --- a/server/api/mcp/tools_mcps.go +++ /dev/null @@ -1,94 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -type listMCPServersOutput struct { - Servers []store.MCPServer `json:"servers"` -} - -func (h *Handler) listMCPServers(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMCPServersOutput, error) { - return nil, listMCPServersOutput{Servers: h.store.ListRawMCPServers()}, nil -} - -func (h *Handler) getMCPServer(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.MCPServer, error) { - m, ok := h.store.GetRawMCPServer(in.ID) - if !ok { - return nil, store.MCPServer{}, fmt.Errorf("get mcp server: %w", errValidation("not found: "+in.ID)) - } - return nil, m, nil -} - -type createMCPInput struct { - Server store.MCPServer `json:"server" jsonschema:"mcp server definition"` -} - -func (h *Handler) createMCPServer(_ context.Context, _ *sdk.CallToolRequest, in createMCPInput) (*sdk.CallToolResult, store.MCPServer, error) { - if in.Server.Name == "" { - return nil, store.MCPServer{}, fmt.Errorf("create mcp server: %w", errValidation("name is required")) - } - created, err := h.store.CreateMCPServer(in.Server) - if err != nil { - return nil, store.MCPServer{}, fmt.Errorf("create mcp server: %w", err) - } - return nil, created, nil -} - -type updateMCPInput struct { - ID string `json:"id" jsonschema:"mcp server id"` - Server store.MCPServer `json:"server" jsonschema:"new mcp server definition"` -} - -func (h *Handler) updateMCPServer(_ context.Context, _ *sdk.CallToolRequest, in updateMCPInput) (*sdk.CallToolResult, store.MCPServer, error) { - if err := h.store.UpdateMCPServer(in.ID, in.Server); err != nil { - return nil, store.MCPServer{}, fmt.Errorf("update mcp server: %w", err) - } - updated, _ := h.store.GetRawMCPServer(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteMCPServer(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteMCPServer(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete mcp server: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerMCPServerTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_mcp_servers", Title: "List MCP servers", - Description: "List every MCP server registered as a tool source. Not to be confused with the embedded MCP server you are currently talking to.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listMCPServers) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_mcp_server", Title: "Get MCP server", - Description: "Return one registered MCP server by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getMCPServer) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_mcp_server", Title: "Create MCP server", - Description: "Register an external MCP server. Use type=http with endpoint+headers, or type=stdio with command+args+env.", - }, h.createMCPServer) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_mcp_server", Title: "Update MCP server", - Description: "Replace the MCP server identified by id.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateMCPServer) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_mcp_server", Title: "Delete MCP server", - Description: "Delete a registered MCP server by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteMCPServer) - h.toolCount++ -} diff --git a/server/api/mcp/tools_memory.go b/server/api/mcp/tools_memory.go deleted file mode 100644 index 8d552d0..0000000 --- a/server/api/mcp/tools_memory.go +++ /dev/null @@ -1,167 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "time" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/memory" - "github.com/achetronic/magec/server/store" -) - -type listMemoryOutput struct { - Providers []store.MemoryProvider `json:"providers"` -} - -func (h *Handler) listMemoryProviders(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMemoryOutput, error) { - return nil, listMemoryOutput{Providers: h.store.ListRawMemoryProviders()}, nil -} - -func (h *Handler) getMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.MemoryProvider, error) { - m, ok := h.store.GetRawMemoryProvider(in.ID) - if !ok { - return nil, store.MemoryProvider{}, fmt.Errorf("get memory provider: %w", errValidation("not found: "+in.ID)) - } - return nil, m, nil -} - -type createMemoryInput struct { - Provider store.MemoryProvider `json:"provider" jsonschema:"memory provider definition"` -} - -func (h *Handler) createMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in createMemoryInput) (*sdk.CallToolResult, store.MemoryProvider, error) { - m := in.Provider - if m.Name == "" { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("name is required")) - } - if m.Type == "" { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("type is required")) - } - if m.Category == "" { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("category is required")) - } - if !memory.ValidType(m.Type) { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation("unsupported provider type: "+m.Type)) - } - if !memory.ValidTypeForCategory(m.Type, memory.Category(m.Category)) { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", errValidation(fmt.Sprintf("provider type %q does not support category %q", m.Type, m.Category))) - } - created, err := h.store.CreateMemoryProvider(m) - if err != nil { - return nil, store.MemoryProvider{}, fmt.Errorf("create memory provider: %w", err) - } - return nil, created, nil -} - -type updateMemoryInput struct { - ID string `json:"id" jsonschema:"memory provider id"` - Provider store.MemoryProvider `json:"provider" jsonschema:"new memory provider definition"` -} - -func (h *Handler) updateMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in updateMemoryInput) (*sdk.CallToolResult, store.MemoryProvider, error) { - if err := h.store.UpdateMemoryProvider(in.ID, in.Provider); err != nil { - return nil, store.MemoryProvider{}, fmt.Errorf("update memory provider: %w", err) - } - updated, _ := h.store.GetRawMemoryProvider(in.ID) - return nil, updated, nil -} - -func (h *Handler) deleteMemoryProvider(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteMemoryProvider(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete memory provider: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) checkMemoryHealth(ctx context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, memory.HealthResult, error) { - m, ok := h.store.GetMemoryProvider(in.ID) - if !ok { - return nil, memory.HealthResult{}, fmt.Errorf("check memory health: %w", errValidation("not found: "+in.ID)) - } - provider := memory.Get(m.Type) - if provider == nil { - return nil, memory.HealthResult{Healthy: false, Detail: "unsupported provider type: " + m.Type}, nil - } - checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - cfg := m.Config - if cfg == nil { - cfg = map[string]interface{}{} - } - return nil, provider.Ping(checkCtx, cfg), nil -} - -type memoryTypeInfo struct { - Type string `json:"type"` - DisplayName string `json:"displayName"` - Categories []string `json:"categories"` - ConfigSchema memory.Schema `json:"configSchema"` -} - -type listMemoryTypesOutput struct { - Types []memoryTypeInfo `json:"types"` -} - -func (h *Handler) listMemoryTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listMemoryTypesOutput, error) { - var types []memoryTypeInfo - for _, p := range memory.All() { - cats := make([]string, len(p.SupportedCategories())) - for i, c := range p.SupportedCategories() { - cats[i] = string(c) - } - types = append(types, memoryTypeInfo{ - Type: p.Type(), - DisplayName: p.DisplayName(), - Categories: cats, - ConfigSchema: p.ConfigSchema(), - }) - } - return nil, listMemoryTypesOutput{Types: types}, nil -} - -func (h *Handler) registerMemoryTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_memory_providers", Title: "List memory providers", - Description: "List every configured memory provider (Redis session, Postgres long-term, etc.).", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listMemoryProviders) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_memory_provider", Title: "Get memory provider", - Description: "Return one memory provider by id.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getMemoryProvider) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_memory_provider", Title: "Create memory provider", - Description: "Create a new memory provider. Type must be registered (see magec_list_memory_types) and compatible with the category.", - }, h.createMemoryProvider) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_memory_provider", Title: "Update memory provider", - Description: "Replace the memory provider identified by id.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateMemoryProvider) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_memory_provider", Title: "Delete memory provider", - Description: "Delete a memory provider by id.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteMemoryProvider) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_check_memory_health", Title: "Check memory provider health", - Description: "Ping the memory provider's backing service (5s timeout). Returns connectivity status.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.checkMemoryHealth) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_memory_types", Title: "List memory provider types", - Description: "List registered memory provider types with their JSON schemas.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listMemoryTypes) - h.toolCount++ -} diff --git a/server/api/mcp/tools_secrets.go b/server/api/mcp/tools_secrets.go deleted file mode 100644 index b49bbd6..0000000 --- a/server/api/mcp/tools_secrets.go +++ /dev/null @@ -1,122 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/api/admin" - "github.com/achetronic/magec/server/store" -) - -type listSecretsOutput struct { - Secrets []admin.SecretResponse `json:"secrets"` -} - -func (h *Handler) listSecrets(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listSecretsOutput, error) { - stored := h.store.ListSecrets() - out := make([]admin.SecretResponse, len(stored)) - for i, s := range stored { - out[i] = admin.SecretToResponse(s) - } - return nil, listSecretsOutput{Secrets: out}, nil -} - -func (h *Handler) getSecret(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, admin.SecretResponse, error) { - s, ok := h.store.GetSecret(in.ID) - if !ok { - return nil, admin.SecretResponse{}, fmt.Errorf("get secret: %w", errValidation("not found: "+in.ID)) - } - return nil, admin.SecretToResponse(s), nil -} - -type createSecretInput struct { - Name string `json:"name" jsonschema:"human-readable secret name"` - Key string `json:"key" jsonschema:"environment variable name (e.g. OPENAI_API_KEY)"` - Value string `json:"value" jsonschema:"secret value (never returned in subsequent reads)"` - Description string `json:"description,omitempty"` -} - -func (h *Handler) createSecret(_ context.Context, _ *sdk.CallToolRequest, in createSecretInput) (*sdk.CallToolResult, admin.SecretResponse, error) { - if in.Name == "" { - return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("name is required")) - } - if in.Key == "" { - return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("key is required")) - } - if in.Value == "" { - return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", errValidation("value is required")) - } - s, err := h.store.CreateSecret(store.Secret{ - Name: in.Name, - Key: in.Key, - Value: in.Value, - Description: in.Description, - }) - if err != nil { - return nil, admin.SecretResponse{}, fmt.Errorf("create secret: %w", err) - } - return nil, admin.SecretToResponse(s), nil -} - -type updateSecretInput struct { - ID string `json:"id" jsonschema:"secret id"` - Name string `json:"name"` - Key string `json:"key"` - Value string `json:"value,omitempty" jsonschema:"new value (omit to keep the existing one)"` - Description string `json:"description,omitempty"` -} - -func (h *Handler) updateSecret(_ context.Context, _ *sdk.CallToolRequest, in updateSecretInput) (*sdk.CallToolResult, admin.SecretResponse, error) { - if err := h.store.UpdateSecret(in.ID, store.Secret{ - Name: in.Name, - Key: in.Key, - Value: in.Value, - Description: in.Description, - }); err != nil { - return nil, admin.SecretResponse{}, fmt.Errorf("update secret: %w", err) - } - updated, _ := h.store.GetSecret(in.ID) - return nil, admin.SecretToResponse(updated), nil -} - -func (h *Handler) deleteSecret(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteSecret(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete secret: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerSecretTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_secrets", Title: "List secrets", - Description: "List every secret (id, name, key, description). Values are never returned.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listSecrets) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_secret", Title: "Get secret", - Description: "Return one secret by id (without the value).", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getSecret) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_create_secret", Title: "Create secret", - Description: "Create a new secret. Value is stored encrypted at rest if server.encryptionKey is configured.", - }, h.createSecret) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_secret", Title: "Update secret", - Description: "Replace a secret by id. Leave value empty to keep the existing one (other fields are still rewritten).", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateSecret) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_secret", Title: "Delete secret", - Description: "Delete a secret by id. Unsets the corresponding env var.", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteSecret) - h.toolCount++ -} diff --git a/server/api/mcp/tools_secrets_test.go b/server/api/mcp/tools_secrets_test.go deleted file mode 100644 index 13babc7..0000000 --- a/server/api/mcp/tools_secrets_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "testing" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" -) - -func TestSecretTools_ValueRedacted(t *testing.T) { - h := newTestHandler(t) - ctx := context.Background() - - _, created, err := h.createSecret(ctx, &sdk.CallToolRequest{}, createSecretInput{ - Name: "OpenAI Key", - Key: "OPENAI_API_KEY", - Value: "sk-secret-do-not-leak", - }) - if err != nil { - t.Fatalf("create secret: %v", err) - } - if created.ID == "" { - t.Fatal("expected non-empty id") - } - // SecretResponse intentionally has no Value field; JSON encoding must - // never include it. - raw, _ := json.Marshal(created) - if got := string(raw); contains(got, "sk-secret") { - t.Fatalf("secret value leaked in create response: %s", got) - } - - _, got, err := h.getSecret(ctx, &sdk.CallToolRequest{}, idInput{ID: created.ID}) - if err != nil { - t.Fatalf("get secret: %v", err) - } - raw, _ = json.Marshal(got) - if s := string(raw); contains(s, "sk-secret") { - t.Fatalf("secret value leaked in get response: %s", s) - } - - _, list, err := h.listSecrets(ctx, &sdk.CallToolRequest{}, struct{}{}) - if err != nil { - t.Fatalf("list secrets: %v", err) - } - raw, _ = json.Marshal(list) - if s := string(raw); contains(s, "sk-secret") { - t.Fatalf("secret value leaked in list response: %s", s) - } -} - -func TestSecret_ValidationErrors(t *testing.T) { - h := newTestHandler(t) - ctx := context.Background() - cases := []createSecretInput{ - {Name: "", Key: "K", Value: "v"}, - {Name: "n", Key: "", Value: "v"}, - {Name: "n", Key: "K", Value: ""}, - } - for i, in := range cases { - if _, _, err := h.createSecret(ctx, &sdk.CallToolRequest{}, in); err == nil || !IsValidation(err) { - t.Fatalf("case %d: expected validation error, got %v", i, err) - } - } -} - -func contains(haystack, needle string) bool { - for i := 0; i+len(needle) <= len(haystack); i++ { - if haystack[i:i+len(needle)] == needle { - return true - } - } - return false -} diff --git a/server/api/mcp/tools_settings.go b/server/api/mcp/tools_settings.go deleted file mode 100644 index 06d2dda..0000000 --- a/server/api/mcp/tools_settings.go +++ /dev/null @@ -1,40 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -func (h *Handler) getSettings(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, store.Settings, error) { - return nil, h.store.GetSettings(), nil -} - -type updateSettingsInput struct { - Settings store.Settings `json:"settings" jsonschema:"new global settings"` -} - -func (h *Handler) updateSettings(_ context.Context, _ *sdk.CallToolRequest, in updateSettingsInput) (*sdk.CallToolResult, store.Settings, error) { - if err := h.store.UpdateSettings(in.Settings); err != nil { - return nil, store.Settings{}, fmt.Errorf("update settings: %w", err) - } - return nil, h.store.GetSettings(), nil -} - -func (h *Handler) registerSettingsTools() { - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_settings", Title: "Get settings", - Description: "Return the global runtime settings (session/long-term memory provider selection, temporary dir).", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getSettings) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_update_settings", Title: "Update settings", - Description: "Replace the global runtime settings.", - Annotations: &sdk.ToolAnnotations{IdempotentHint: true}, - }, h.updateSettings) - h.toolCount++ -} diff --git a/server/api/mcp/tools_skills.go b/server/api/mcp/tools_skills.go deleted file mode 100644 index a958b52..0000000 --- a/server/api/mcp/tools_skills.go +++ /dev/null @@ -1,55 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/store" -) - -type listSkillsOutput struct { - Skills []store.Skill `json:"skills"` -} - -func (h *Handler) listSkills(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listSkillsOutput, error) { - return nil, listSkillsOutput{Skills: h.store.ListRawSkills()}, nil -} - -func (h *Handler) getSkill(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, store.Skill, error) { - sk, ok := h.store.GetRawSkill(in.ID) - if !ok { - return nil, store.Skill{}, fmt.Errorf("get skill: %w", errValidation("not found: "+in.ID)) - } - return nil, sk, nil -} - -func (h *Handler) deleteSkill(_ context.Context, _ *sdk.CallToolRequest, in idInput) (*sdk.CallToolResult, emptyOutput, error) { - if err := h.store.DeleteSkill(in.ID); err != nil { - return nil, emptyOutput{}, fmt.Errorf("delete skill: %w", err) - } - return nil, okOutput, nil -} - -func (h *Handler) registerSkillTools() { - destructive := true - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_skills", Title: "List skills", - Description: "List every registered skill package (id and slug only). Use the admin UI to read SKILL.md content.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listSkills) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_get_skill", Title: "Get skill", - Description: "Return one skill stub by id. Content lives on disk at data/skills/{slug}/.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.getSkill) - h.toolCount++ - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_delete_skill", Title: "Delete skill", - Description: "Delete a skill by id (store record and the on-disk package).", - Annotations: &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true}, - }, h.deleteSkill) - h.toolCount++ -} diff --git a/server/api/mcp/tools_voice.go b/server/api/mcp/tools_voice.go deleted file mode 100644 index 4bec740..0000000 --- a/server/api/mcp/tools_voice.go +++ /dev/null @@ -1,46 +0,0 @@ -package mcp - -import ( - "context" - - sdk "github.com/modelcontextprotocol/go-sdk/mcp" - - "github.com/achetronic/magec/server/voice" -) - -type voiceProviderInfo struct { - Type string `json:"type"` - DisplayName string `json:"displayName"` - SupportsTTS bool `json:"supportsTts"` - SupportsSTT bool `json:"supportsStt"` - TTSConfigSchema voice.Schema `json:"ttsConfigSchema"` - STTConfigSchema voice.Schema `json:"sttConfigSchema"` -} - -type listVoiceTypesOutput struct { - Types []voiceProviderInfo `json:"types"` -} - -func (h *Handler) listVoiceTypes(_ context.Context, _ *sdk.CallToolRequest, _ struct{}) (*sdk.CallToolResult, listVoiceTypesOutput, error) { - var types []voiceProviderInfo - for _, p := range voice.All() { - types = append(types, voiceProviderInfo{ - Type: p.Type(), - DisplayName: p.DisplayName(), - SupportsTTS: p.SupportsTTS(), - SupportsSTT: p.SupportsSTT(), - TTSConfigSchema: p.TTSConfigSchema(), - STTConfigSchema: p.STTConfigSchema(), - }) - } - return nil, listVoiceTypesOutput{Types: types}, nil -} - -func (h *Handler) registerVoiceTools() { - sdk.AddTool(h.server, &sdk.Tool{ - Name: "magec_list_voice_types", Title: "List voice provider types", - Description: "List registered voice providers (TTS/STT) with their JSON schemas.", - Annotations: &sdk.ToolAnnotations{ReadOnlyHint: true}, - }, h.listVoiceTypes) - h.toolCount++ -} diff --git a/server/go.mod b/server/go.mod index a66ef84..f12be01 100644 --- a/server/go.mod +++ b/server/go.mod @@ -33,6 +33,7 @@ require ( cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/achetronic/openapi2tools v0.1.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/anthropics/anthropic-sdk-go v1.19.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -46,12 +47,13 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/getkin/kin-openapi v0.138.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/spec v0.20.6 // indirect - github.com/go-openapi/swag v0.19.15 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/safehtml v0.1.0 // indirect @@ -62,11 +64,16 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect - github.com/mailru/easyjson v0.7.6 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/openai/openai-go/v3 v3.16.0 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect @@ -80,6 +87,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect diff --git a/server/go.sum b/server/go.sum index 0d78d18..06df59c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -32,6 +32,8 @@ github.com/a2aproject/a2a-go v0.3.13 h1:WpIcSHgCySIxD7OQEdV7U7WJc/HL/G2QQj0RJ0Yh github.com/a2aproject/a2a-go v0.3.13/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/achetronic/adk-utils-go v0.16.0 h1:teR4mF6feoHKIhVVGlFCYFbiRNqIHotZa8TfvXQeR3E= github.com/achetronic/adk-utils-go v0.16.0/go.mod h1:3yHK7hao+ZMecAAuGog0Ybj2dQy9vhRlv0/kBC7q28c= +github.com/achetronic/openapi2tools v0.1.0 h1:gi94cRssRxGmGnIInQ5xsv/ocPa61CdIN8RY830y5cU= +github.com/achetronic/openapi2tools v0.1.0/go.mod h1:A0Lcxm/Ec84BZJIq5Mdg9zgniuIM+kXiQTXaGsILFOU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= @@ -93,6 +95,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJP github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/getkin/kin-openapi v0.138.0 h1:ebfE0JAmF6AqHrNBy1KO3Fs68K9tPs48HalvLPo7Rv4= +github.com/getkin/kin-openapi v0.138.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/glebarez/go-sqlite v1.21.1/go.mod h1:ISs8MF6yk5cL4n/43rSOmVMGJJjHYr7L2MbZZ5Q4E2E= github.com/glebarez/sqlite v1.8.0/go.mod h1:bpET16h1za2KOOMb8+jCp6UBP/iahDpfPQqSaYLTLx8= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= @@ -104,6 +108,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= @@ -111,6 +117,8 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6 github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -172,17 +180,27 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8= github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/openai/openai-go/v3 v3.16.0 h1:VdqS+GFZgAvEOBcWNyvLVwPlYEIboW5xwiUCcLrVf8c= github.com/openai/openai-go/v3 v3.16.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -199,6 +217,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= @@ -248,6 +268,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yalue/onnxruntime_go v1.25.0 h1:nlhVau1BpLZ/BYr+WpPZCJRD/WES0qo6dK7aKyyAs3g= diff --git a/server/main.go b/server/main.go index e93d927..13cbcd2 100644 --- a/server/main.go +++ b/server/main.go @@ -102,7 +102,7 @@ func main() { // Embedded MCP server (opt-in via server.mcp.enabled). Exposes the same // admin surface as MCP tools over Streamable HTTP. See decision #30. - mcpServer, mcpCtx, mcpCancel := startMCPServer(cfg, dataStore, convoStore, adminHandler) + mcpServer, mcpCtx, mcpCancel := startMCPServer(cfg) // cwRegistry provides LLM context window sizes cwRegistry := contextguard.NewCrushRegistry() @@ -243,7 +243,7 @@ func startAdminServer(cfg *config.Config, adminHandler *admin.Handler) (*http.Se // requests with the same bearer token used by the admin REST API // (server.adminPassword). Returns nil sentinel values when disabled so the // shutdown path stays linear. -func startMCPServer(cfg *config.Config, dataStore *store.Store, convoStore *store.ConversationStore, adminHandler *admin.Handler) (*http.Server, context.Context, context.CancelFunc) { +func startMCPServer(cfg *config.Config) (*http.Server, context.Context, context.CancelFunc) { if !cfg.Server.MCP.Enabled { return nil, nil, nil } @@ -252,7 +252,21 @@ func startMCPServer(cfg *config.Config, dataStore *store.Store, convoStore *stor slog.Warn("MCP server enabled without server.adminPassword — admin tools are exposed without authentication") } - mcpHandler := magecmcp.NewHandler(dataStore, convoStore, adminHandler) + adminHost := cfg.Server.Host + if adminHost == "0.0.0.0" || adminHost == "" { + // Talk to ourselves on the loopback interface even when the admin + // server is bound to all interfaces; avoids surprises if the public + // IP changes underneath us at runtime. + adminHost = "127.0.0.1" + } + mcpHandler, err := magecmcp.NewHandler(magecmcp.HandlerConfig{ + AdminBaseURL: fmt.Sprintf("http://%s:%d/api/v1/admin", adminHost, cfg.Server.AdminPort), + AdminPassword: cfg.Server.AdminPassword, + }) + if err != nil { + slog.Error("MCP server failed to initialise", "error", err) + return nil, nil, nil + } mcpMux := http.NewServeMux() mcpMux.Handle("/", mcpHandler.HTTPHandler()) diff --git a/server/store/types.go b/server/store/types.go index 000aae4..0d7f3e0 100644 --- a/server/store/types.go +++ b/server/store/types.go @@ -13,7 +13,7 @@ func generateID() string { // AgentDefinition represents a single agent's full configuration in the store. type AgentDefinition struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` SystemPrompt string `json:"systemPrompt,omitempty" yaml:"systemPrompt,omitempty"` @@ -36,7 +36,7 @@ type A2AConfig struct { // BackendDefinition represents a reusable AI backend. type BackendDefinition struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` URL string `json:"url,omitempty" yaml:"url,omitempty"` @@ -105,7 +105,7 @@ type ContextGuardConfig struct { // MemoryProvider represents a reusable memory backend (Redis, Postgres, etc.). type MemoryProvider struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` Category string `json:"category" yaml:"category"` @@ -115,7 +115,7 @@ type MemoryProvider struct { // MCPServer represents an MCP server configuration. type MCPServer struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type,omitempty" yaml:"type,omitempty"` Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"` @@ -131,10 +131,10 @@ type MCPServer struct { // ClientDefinition represents an access point (voice-ui, Telegram, Discord, webhook, etc.). // Type determines what platform-specific config is expected inside Config. type ClientDefinition struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Type string `json:"type" yaml:"type"` - Token string `json:"token,omitempty" yaml:"token"` + Token string `json:"token" yaml:"token"` AllowedAgents []string `json:"allowedAgents" yaml:"allowedAgents"` Enabled bool `json:"enabled" yaml:"enabled"` Config ClientConfig `json:"config" yaml:"config"` @@ -209,14 +209,14 @@ type WebhookClientConfig struct { // upload time; renames go through the upload endpoint, which rewrites the // frontmatter and renames the directory atomically. type Skill struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Slug string `json:"slug" yaml:"slug"` } // Command represents a reusable prompt that can be invoked against an agent // via cron or webhook clients. type Command struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Prompt string `json:"prompt" yaml:"prompt"` @@ -355,7 +355,7 @@ func collectAgentIDs(step *FlowStep, seen map[string]bool, ids *[]string) { // FlowDefinition represents a multi-agent workflow stored as a recursive tree // of steps that maps directly to ADK workflow agents. type FlowDefinition struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` Root FlowStep `json:"root" yaml:"root"` @@ -379,7 +379,7 @@ type Settings struct { // The Key field is the environment variable name (e.g. OPENAI_API_KEY). // The Value is stored encrypted at rest when an admin password is configured. type Secret struct { - ID string `json:"id,omitempty" yaml:"id"` + ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` Key string `json:"key" yaml:"key"` Value string `json:"value" yaml:"value"` diff --git a/website/content/docs/admin-mcp-server.md b/website/content/docs/admin-mcp-server.md index 376121b..6aa500b 100644 --- a/website/content/docs/admin-mcp-server.md +++ b/website/content/docs/admin-mcp-server.md @@ -10,7 +10,7 @@ This page covers the embedded server. For consuming external MCP servers (Home A The server runs alongside the user and admin HTTP servers, on its own port. It speaks Streamable HTTP, so every MCP client that follows the spec works without extra adapters. -It exposes one tool per admin operation. Listing, getting, creating, updating and deleting works for every resource the admin REST API understands: backends, memory providers, MCP servers, agents, clients, commands, flows, secrets, settings, conversations, plus the type catalogues for clients, memory and voice providers. +The tool catalogue is built at startup by reading the admin OpenAPI spec embedded in the binary. Each admin endpoint becomes one MCP tool. When new admin endpoints land, the next build picks them up automatically. ## Enabling it @@ -30,7 +30,7 @@ server: The startup log shows a line like: ``` -INFO MCP server started addr=0.0.0.0:8082 url=http://0.0.0.0:8082 tools=61 +INFO MCP server started addr=0.0.0.0:8082 url=http://0.0.0.0:8082 tools=33 ``` ## Authentication @@ -39,6 +39,8 @@ The MCP server reuses `server.adminPassword`. Every request must carry the `Auth If `adminPassword` is empty, the MCP server still starts but logs a warning and accepts every request. Do not expose port 8082 outside your trust boundary in that mode. +Internally the MCP layer forwards each tool call as an HTTP request back to the admin port on the loopback interface. Validation, secret redaction and conversation logging stay in the admin handlers and apply uniformly whether the caller is the admin UI, an HTTP client, or an MCP tool. + ## Connecting from Claude Code Add an entry to `~/.claude/mcp.json`: @@ -69,121 +71,25 @@ npx @wong2/mcp-cli streamable-http http://localhost:8082/ \ ## Tool catalogue -Tool names use the `magec_` prefix and snake_case. - -### Backends - -- `magec_list_backends` -- `magec_get_backend` -- `magec_create_backend` -- `magec_update_backend` -- `magec_delete_backend` - -### Memory providers - -- `magec_list_memory_providers` -- `magec_get_memory_provider` -- `magec_create_memory_provider` -- `magec_update_memory_provider` -- `magec_delete_memory_provider` -- `magec_check_memory_health` -- `magec_list_memory_types` - -### MCP servers (the ones agents consume) - -- `magec_list_mcp_servers` -- `magec_get_mcp_server` -- `magec_create_mcp_server` -- `magec_update_mcp_server` -- `magec_delete_mcp_server` - -### Agents - -- `magec_list_agents` -- `magec_get_agent` -- `magec_create_agent` -- `magec_update_agent` -- `magec_delete_agent` -- `magec_list_agent_mcps` -- `magec_link_agent_mcp` -- `magec_unlink_agent_mcp` - -### Clients - -- `magec_list_clients` -- `magec_get_client` -- `magec_create_client` -- `magec_update_client` -- `magec_delete_client` -- `magec_regenerate_client_token` -- `magec_list_client_types` - -### Commands - -- `magec_list_commands` -- `magec_get_command` -- `magec_create_command` -- `magec_update_command` -- `magec_delete_command` - -### Flows - -- `magec_list_flows` -- `magec_get_flow` -- `magec_create_flow` -- `magec_update_flow` -- `magec_delete_flow` - -### Skills - -- `magec_list_skills` -- `magec_get_skill` -- `magec_delete_skill` - -### Settings - -- `magec_get_settings` -- `magec_update_settings` - -### Secrets - -- `magec_list_secrets` -- `magec_get_secret` -- `magec_create_secret` -- `magec_update_secret` -- `magec_delete_secret` - -### Conversations - -- `magec_list_conversations` -- `magec_get_conversation` -- `magec_delete_conversation` -- `magec_clear_conversations` -- `magec_conversation_stats` -- `magec_update_conversation_summary` -- `magec_find_conversation_pair` -- `magec_reset_conversation_session` - -### Voice - -- `magec_list_voice_types` +Tool names use the `magec_` prefix and follow the pattern `_` derived from the admin OpenAPI operation (for example `magec_post_agents`, `magec_delete_clients_id`, `magec_get_flows`). Run `tools/list` on the server for the live list — it always matches the admin REST surface in the running binary. ## What is not exposed -Skill upload and download (one SKILL.md plus optional `references/`, `assets/`, `scripts/`) and the backup and restore endpoints stream binary archives. They do not map cleanly to MCP tool inputs and outputs, so they stay on the admin REST API. Use the admin UI or call `POST /api/v1/admin/skills/upload` and `GET /api/v1/admin/settings/backup` directly when you need them. +Skill upload and download, plus the backup and restore endpoints, stream binary archives that do not map cleanly to MCP tool inputs and outputs. Those routes are filtered out at startup; use the admin UI or call `POST /api/v1/admin/skills/upload` and `GET /api/v1/admin/settings/backup` directly when you need them. The admin-UI helper endpoints (`/auth/check`, `/overview`) are also skipped because they are not operator actions. ## Security notes -- Secret values are never returned by `magec_get_secret` or `magec_list_secrets`. The MCP server mirrors the admin REST behaviour: GET responses contain the metadata only. Use the create/update tools to write new values. +- Secret values are never returned by the secrets endpoints. Admin REST already redacts them; the MCP server inherits that redaction because the tools are thin wrappers over the same handlers. - The MCP server has the same authority as the admin API. Treat the bearer token with the same care. -- Running a Magec agent against the admin MCP creates a recursive surface: the agent can call destructive tools (`magec_delete_*`, `magec_clear_conversations`) against the very instance running it. Use sparingly. +- Running a Magec agent against the admin MCP creates a recursive surface: the agent can call destructive tools against the very instance running it. Use sparingly. - Streamable HTTP allows server-sent events, so the MCP server's `WriteTimeout` is set to zero. The HTTP client manages cancellation through request context, which is what every spec-compliant MCP client does. ## Troubleshooting | Symptom | Likely cause | |--------|-------------| -| `tools=0` at startup | Build is stale, restart the server | +| `tools=0` at startup | Swagger spec missing or empty; rerun `make swagger` and rebuild | | 401 on every request | Wrong or missing bearer; verify `Authorization: Bearer ...` | | 429 with `Retry-After: 60` | Hit the 5-failed-attempts-per-minute rate limit; wait one minute | | 403 from a localhost client | DNS rebinding protection kicked in; make sure the client sends a `Host` header matching `localhost` or `127.0.0.1` | +| `admin API returned 5xx` in a tool response | The admin REST API failed; check the admin server log for the underlying error | From 723927fa4c8c1627c39008de7ba090c7935247a5 Mon Sep 17 00:00:00 2001 From: dfradehubs Date: Mon, 25 May 2026 08:46:58 +0200 Subject: [PATCH 4/4] refactor(mcp): address review notes from style audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit middleware: - Extract a shared passwordCheck primitive (matches/allow/writeJSONError) used by both AdminAuth and BearerAuth, replacing 40 LOC of copy-paste. - Carve-out logic in AdminAuth is now obvious (static SPA path, auth/check endpoint that skips the rate limiter). BearerAuth is six lines. api/mcp/adapter.go: - Use http.MethodGet/Put/Delete instead of bare string literals. - Extract makeToolHandler(td) so the registration loop stays readable and the dispatch path can be followed in isolation. - Move the destructive=true sentinel inside the DELETE branch — the only one that uses it. - Wrap json marshal/unmarshal errors with context to match the project's error-handling convention. api/mcp/handler.go: - Rename opaque v2/v3 locals to rawSwagger/swaggerSpec/openAPISpec/openAPIBytes in loadAdminSpec. - Document HandlerConfig fields individually instead of cramming the config and middleware names into a single paragraph. api/mcp/handler_test.go: - Replace the manual Read/buf loop in adminStub.ServeHTTP with io.ReadAll. - Factor connectInMemory(t, h) so the two MCP tests do not duplicate the in-memory transport dance. - Use http.MethodPost and the package-level initFrame constant in the bearer-required test. main.go: - Drop a stale comment about BearerAuth's internals; the middleware package now documents them. --- server/api/mcp/adapter.go | 84 +++++++++++-------- server/api/mcp/handler.go | 36 +++++--- server/api/mcp/handler_test.go | 91 ++++++++++---------- server/main.go | 13 ++- server/middleware/middleware.go | 144 ++++++++++++++++++-------------- 5 files changed, 199 insertions(+), 169 deletions(-) diff --git a/server/api/mcp/adapter.go b/server/api/mcp/adapter.go index cb6bea5..bdd91a8 100644 --- a/server/api/mcp/adapter.go +++ b/server/api/mcp/adapter.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "net/http" "github.com/achetronic/openapi2tools/mcptools" "github.com/google/jsonschema-go/jsonschema" @@ -15,85 +16,90 @@ import ( // version; we keep our own thin one here so we can stay on the currently // vendored v1.4.1 without dragging an incompatible transitive dep. func registerDescriptors(s *sdk.Server, descriptors []mcptools.ToolDescriptor) (int, error) { - count := 0 - for i := range descriptors { - if err := registerDescriptor(s, descriptors[i]); err != nil { - return count, fmt.Errorf("register tool %q: %w", descriptors[i].Name, err) + for _, td := range descriptors { + if err := registerDescriptor(s, td); err != nil { + return 0, fmt.Errorf("register tool %q: %w", td.Name, err) } - count++ } - return count, nil + return len(descriptors), nil } +// registerDescriptor turns a single openapi2tools descriptor into a fully +// wired tool on the go-sdk server. func registerDescriptor(s *sdk.Server, td mcptools.ToolDescriptor) error { schema, err := schemaFromMap(td.InputSchema) if err != nil { return fmt.Errorf("build input schema: %w", err) } - - tool := &sdk.Tool{ + s.AddTool(&sdk.Tool{ Name: td.Name, Description: td.Description, InputSchema: schema, Annotations: annotationsForMethod(td.Route.Method), - } + }, makeToolHandler(td)) + return nil +} - handler := td.Handler - s.AddTool(tool, func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { +// makeToolHandler builds the go-sdk handler closure that bridges the +// JSON-RPC envelope to the library-agnostic openapi2tools.ToolHandler. +// Extracted into a named helper so the registration loop stays compact and +// the dispatch path is easy to read in isolation. +func makeToolHandler(td mcptools.ToolDescriptor) sdk.ToolHandler { + inner := td.Handler + return func(ctx context.Context, req *sdk.CallToolRequest) (*sdk.CallToolResult, error) { + if inner == nil { + return errorResult("no handler configured for this tool"), nil + } args, err := decodeArguments(req) if err != nil { return errorResult(fmt.Sprintf("invalid arguments: %s", err)), nil } - if handler == nil { - return errorResult("no handler configured for this tool"), nil - } - - out, err := handler(ctx, mcptools.ToolCall{ + result, err := inner(ctx, mcptools.ToolCall{ Name: td.Name, Arguments: args, }) if err != nil { return nil, err } - if out == nil { - out = &mcptools.ToolResult{} + if result == nil { + result = &mcptools.ToolResult{} } return &sdk.CallToolResult{ - Content: []sdk.Content{&sdk.TextContent{Text: out.Text}}, - IsError: out.IsError, + Content: []sdk.Content{&sdk.TextContent{Text: result.Text}}, + IsError: result.IsError, }, nil - }) - return nil + } } -// schemaFromMap roundtrips a JSON-schema-compatible map through JSON into the -// go-sdk's typed jsonschema.Schema. AddTool requires a non-nil schema; an -// empty object accepts anything which is fine for tools without parameters. +// schemaFromMap roundtrips a JSON-schema-compatible map through JSON into +// the go-sdk's typed jsonschema.Schema. AddTool requires a non-nil schema; +// an empty object accepts anything, which is the right default for tools +// that take no input. func schemaFromMap(m map[string]any) (*jsonschema.Schema, error) { if m == nil { return &jsonschema.Schema{Type: "object"}, nil } raw, err := json.Marshal(m) if err != nil { - return nil, err + return nil, fmt.Errorf("marshal schema map: %w", err) } var schema jsonschema.Schema if err := json.Unmarshal(raw, &schema); err != nil { - return nil, err + return nil, fmt.Errorf("unmarshal schema: %w", err) } return &schema, nil } // decodeArguments unmarshals the JSON-RPC arguments envelope into a -// map[string]any. The go-sdk represents Arguments as json.RawMessage so we -// can decode lazily and avoid a per-tool input type. +// map[string]any. The go-sdk represents Arguments as json.RawMessage so +// each tool can decode lazily and avoid a per-tool input type. func decodeArguments(req *sdk.CallToolRequest) (map[string]any, error) { if req == nil || len(req.Params.Arguments) == 0 { return map[string]any{}, nil } var args map[string]any if err := json.Unmarshal(req.Params.Arguments, &args); err != nil { - return nil, err + return nil, fmt.Errorf("decode arguments: %w", err) } if args == nil { args = map[string]any{} @@ -102,21 +108,25 @@ func decodeArguments(req *sdk.CallToolRequest) (map[string]any, error) { } // annotationsForMethod derives the MCP tool hints from the HTTP verb. The -// admin REST API is conventional enough (GET reads, DELETE removes, PUT -// updates) that this mapping is accurate without per-route overrides. +// admin REST API is conventional enough — GET reads, DELETE removes, PUT +// updates — that this mapping is accurate without per-route overrides. func annotationsForMethod(method string) *sdk.ToolAnnotations { - destructive := true switch method { - case "GET": + case http.MethodGet: return &sdk.ToolAnnotations{ReadOnlyHint: true} - case "DELETE": - return &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true} - case "PUT": + case http.MethodPut: return &sdk.ToolAnnotations{IdempotentHint: true} + case http.MethodDelete: + destructive := true + return &sdk.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true} } return nil } +// errorResult returns a CallToolResult flagged as a tool-level error with +// the given text. Used by the dispatcher when the call cannot reach the +// inner handler (bad arguments, missing handler) — distinct from a JSON-RPC +// protocol error, which is returned as the second value instead. func errorResult(text string) *sdk.CallToolResult { return &sdk.CallToolResult{ Content: []sdk.Content{&sdk.TextContent{Text: text}}, diff --git a/server/api/mcp/handler.go b/server/api/mcp/handler.go index dcb9e26..9214b1a 100644 --- a/server/api/mcp/handler.go +++ b/server/api/mcp/handler.go @@ -27,12 +27,17 @@ const swaggerInstanceName = "swagger" // servers at once. const toolNamePrefix = "magec_" -// HandlerConfig wires the MCP handler to the admin REST API. AdminBaseURL is -// the absolute base for the admin endpoints (host + /api/v1/admin), and -// AdminPassword is forwarded as the bearer token used by AdminAuth so the -// loopback request authenticates the same way an external client would. +// HandlerConfig wires the MCP handler to the admin REST API. type HandlerConfig struct { - AdminBaseURL string + // AdminBaseURL is the absolute base for the admin endpoints — typically + // "http://127.0.0.1:/api/v1/admin". Tool calls are issued + // against this base over the loopback interface. + AdminBaseURL string + + // AdminPassword is forwarded as the Authorization bearer on every + // outgoing call so AdminAuth authenticates the loopback request the + // same way it would authenticate an external client. Empty when the + // admin server is running in open mode. AdminPassword string } @@ -110,31 +115,36 @@ func (h *Handler) Server() *sdk.Server { return h.server } func (h *Handler) ToolCount() int { return h.toolCount } // loadAdminSpec reads the admin swagger 2.0 document registered by swaggo, -// converts it to OpenAPI 3.0, and lets openapi2tools do the heavy lifting -// (ref resolution, example stripping, flexible numeric parameters). +// converts it to OpenAPI 3.0 (the format openapi2tools speaks), and lets the +// library do the heavy lifting: ref resolution, example stripping, flexible +// numeric parameters. func loadAdminSpec() (*openapi.Spec, error) { - raw, err := swag.ReadDoc(swaggerInstanceName) + rawSwagger, err := swag.ReadDoc(swaggerInstanceName) if err != nil { return nil, fmt.Errorf("read swagger doc: %w", err) } - var v2 openapi2.T - if err := json.Unmarshal([]byte(raw), &v2); err != nil { + + var swaggerSpec openapi2.T + if err := json.Unmarshal([]byte(rawSwagger), &swaggerSpec); err != nil { return nil, fmt.Errorf("unmarshal swagger 2.0: %w", err) } - v3, err := openapi2conv.ToV3(&v2) + + openAPISpec, err := openapi2conv.ToV3(&swaggerSpec) if err != nil { return nil, fmt.Errorf("convert swagger 2.0 to openapi 3.0: %w", err) } - v3JSON, err := json.Marshal(v3) + + openAPIBytes, err := json.Marshal(openAPISpec) if err != nil { return nil, fmt.Errorf("marshal openapi 3.0: %w", err) } + loader := openapi.NewLoader(openapi.LoadOptions{ ResolveRefs: true, RemoveExamples: true, FlexibleParameters: true, }) - return loader.LoadBytes(v3JSON) + return loader.LoadBytes(openAPIBytes) } // defaultRouteFilters trims the admin surface down to the operations that diff --git a/server/api/mcp/handler_test.go b/server/api/mcp/handler_test.go index 5a91efa..011b904 100644 --- a/server/api/mcp/handler_test.go +++ b/server/api/mcp/handler_test.go @@ -3,6 +3,7 @@ package mcp import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -13,10 +14,14 @@ import ( "github.com/achetronic/magec/server/middleware" ) -// newTestHandler builds an MCP handler that talks to a stub admin server, -// so the unit suite never depends on a real magec instance. The stub records -// every request it sees and replies with a canned payload — enough to assert -// the dispatcher serialised arguments correctly and propagated the bearer. +// initFrame is the minimal MCP initialise envelope used by HTTP-level tests +// where we only care about the transport, not the protocol response. +const initFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}` + +// newTestHandler builds an MCP handler wired to a stub admin server, so the +// unit suite never depends on a real magec instance. The stub records every +// request it sees and replies with a canned payload — enough to assert the +// dispatcher serialised arguments correctly and propagated the bearer. func newTestHandler(t *testing.T) (*Handler, *adminStub) { t.Helper() stub := newAdminStub() @@ -33,10 +38,12 @@ func newTestHandler(t *testing.T) (*Handler, *adminStub) { return h, stub } -func TestSmoke_ToolsRegistered(t *testing.T) { - h, _ := newTestHandler(t) +// connectInMemory spins up the MCP server over the in-memory transport and +// returns an open client session. Centralised so individual tests don't +// re-implement the four-line dance. +func connectInMemory(t *testing.T, h *Handler) *sdk.ClientSession { + t.Helper() ctx := context.Background() - serverT, clientT := sdk.NewInMemoryTransports() if _, err := h.Server().Connect(ctx, serverT, nil); err != nil { t.Fatalf("server connect: %v", err) @@ -46,9 +53,15 @@ func TestSmoke_ToolsRegistered(t *testing.T) { if err != nil { t.Fatalf("client connect: %v", err) } - defer sess.Close() + t.Cleanup(func() { _ = sess.Close() }) + return sess +} - res, err := sess.ListTools(ctx, nil) +func TestSmoke_ToolsRegistered(t *testing.T) { + h, _ := newTestHandler(t) + sess := connectInMemory(t, h) + + res, err := sess.ListTools(context.Background(), nil) if err != nil { t.Fatalf("list tools: %v", err) } @@ -58,8 +71,8 @@ func TestSmoke_ToolsRegistered(t *testing.T) { if h.ToolCount() != len(res.Tools) { t.Fatalf("tool count mismatch: ToolCount=%d ListTools=%d", h.ToolCount(), len(res.Tools)) } - // Spot-check a few that we expect to exist regardless of filter changes. - have := map[string]bool{} + + have := make(map[string]bool, len(res.Tools)) for _, tool := range res.Tools { have[tool.Name] = true if !strings.HasPrefix(tool.Name, toolNamePrefix) { @@ -75,23 +88,12 @@ func TestSmoke_ToolsRegistered(t *testing.T) { func TestDispatcher_ForwardsArgumentsAndBearer(t *testing.T) { h, stub := newTestHandler(t) - ctx := context.Background() - - serverT, clientT := sdk.NewInMemoryTransports() - if _, err := h.Server().Connect(ctx, serverT, nil); err != nil { - t.Fatalf("server connect: %v", err) - } - client := sdk.NewClient(&sdk.Implementation{Name: "test-client", Version: "0"}, nil) - sess, err := client.Connect(ctx, clientT, nil) - if err != nil { - t.Fatalf("client connect: %v", err) - } - defer sess.Close() + sess := connectInMemory(t, h) stub.respond("POST", "/api/v1/admin/backends", http.StatusCreated, `{"id":"abc","name":"OpenAI","type":"openai"}`) - res, err := sess.CallTool(ctx, &sdk.CallToolParams{ + res, err := sess.CallTool(context.Background(), &sdk.CallToolParams{ Name: "magec_post_backends", Arguments: map[string]any{ "name": "OpenAI", @@ -104,14 +106,15 @@ func TestDispatcher_ForwardsArgumentsAndBearer(t *testing.T) { if res.IsError { t.Fatalf("tool returned error: %+v", res.Content) } - if stub.lastAuth != "Bearer test-pwd" { - t.Errorf("bearer not forwarded; got %q", stub.lastAuth) + + if got, want := stub.lastAuth, "Bearer test-pwd"; got != want { + t.Errorf("bearer not forwarded: got %q want %q", got, want) } - if stub.lastMethod != "POST" { - t.Errorf("method: got %q, want POST", stub.lastMethod) + if got, want := stub.lastMethod, http.MethodPost; got != want { + t.Errorf("method: got %q want %q", got, want) } - if stub.lastPath != "/api/v1/admin/backends" { - t.Errorf("path: got %q, want /api/v1/admin/backends", stub.lastPath) + if got, want := stub.lastPath, "/api/v1/admin/backends"; got != want { + t.Errorf("path: got %q want %q", got, want) } var body map[string]any if err := json.Unmarshal(stub.lastBody, &body); err != nil { @@ -127,18 +130,18 @@ func TestHTTP_BearerRequired(t *testing.T) { srv := httptest.NewServer(middleware.BearerAuth(h.HTTPHandler(), "secret")) defer srv.Close() - const initFrame = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}` - + // No bearer → 401. resp, err := http.Post(srv.URL, "application/json", strings.NewReader(initFrame)) if err != nil { t.Fatalf("POST: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("no auth: got %d, want 401", resp.StatusCode) + t.Fatalf("no auth: got %d want 401", resp.StatusCode) } - req, _ := http.NewRequest("POST", srv.URL, strings.NewReader(initFrame)) + // Correct bearer → anything but 401. + req, _ := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(initFrame)) req.Header.Set("Authorization", "Bearer secret") req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") @@ -152,8 +155,8 @@ func TestHTTP_BearerRequired(t *testing.T) { } } -// adminStub is a minimal HTTP server that records the request it received -// and replies with whatever the test rigged. +// adminStub is a tiny HTTP server that records the most recent request it +// received and replies with whatever the test rigged via respond. type adminStub struct { lastAuth string lastMethod string @@ -180,21 +183,11 @@ func (s *adminStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.lastAuth = r.Header.Get("Authorization") s.lastMethod = r.Method s.lastPath = r.URL.Path - body := make([]byte, 0, 1024) if r.Body != nil { - defer r.Body.Close() - buf := make([]byte, 4096) - for { - n, err := r.Body.Read(buf) - if n > 0 { - body = append(body, buf[:n]...) - } - if err != nil { - break - } - } + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + s.lastBody = body } - s.lastBody = body resp, ok := s.responses[r.Method+" "+r.URL.Path] if !ok { diff --git a/server/main.go b/server/main.go index 13cbcd2..4a8a449 100644 --- a/server/main.go +++ b/server/main.go @@ -271,22 +271,19 @@ func startMCPServer(cfg *config.Config) (*http.Server, context.Context, context. mcpMux := http.NewServeMux() mcpMux.Handle("/", mcpHandler.HTTPHandler()) + // The middleware stack mirrors the admin port (AccessLog → CORS → + // bearer) but writes are unbounded: MCP serves Streamable HTTP over + // long-lived SSE connections, which the admin's 30s WriteTimeout + // would tear down. mcpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.MCP.Port) mcpServer := &http.Server{ Addr: mcpAddr, - // CORS is permissive (same as the admin port) so browsers can drive - // the MCP server. BearerAuth reuses the same rate-limited - // constant-time compare as AdminAuth, without the /api/ carve-out - // because MCP serves the root path. Handler: middleware.AccessLog( middleware.CORS( middleware.BearerAuth(mcpMux, cfg.Server.AdminPassword), ), ), - ReadTimeout: 30 * time.Second, - // SSE streams may stay open longer than a typical admin request, so - // write timeout is left at zero. Per-request context cancellation - // keeps abandoned connections from leaking. + ReadTimeout: 30 * time.Second, WriteTimeout: 0, IdleTimeout: 120 * time.Second, } diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go index a668949..eaba485 100644 --- a/server/middleware/middleware.go +++ b/server/middleware/middleware.go @@ -2,6 +2,7 @@ package middleware import ( "crypto/subtle" + "fmt" "log/slog" "net/http" "strings" @@ -93,10 +94,7 @@ func AdminAuth(next http.Handler, password string) http.Handler { return next } - rl := newRateLimiter(5, time.Minute) - go rl.cleanup(30 * time.Second) - - passwordBytes := []byte(password) + check := newPasswordCheck(password) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { @@ -104,99 +102,121 @@ func AdminAuth(next http.Handler, password string) http.Handler { return } - path := r.URL.Path - - if !strings.HasPrefix(path, "/api/") { + // Static files served by the admin UI live outside /api/ and must + // pass through unauthenticated so the SPA can load before the user + // types the password. + if !strings.HasPrefix(r.URL.Path, "/api/") { next.ServeHTTP(w, r) return } - if path == "/api/v1/admin/auth/check" { - token := extractBearerToken(r) - if token == "" || subtle.ConstantTimeCompare([]byte(token), passwordBytes) != 1 { + // /auth/check lets the admin UI validate a password without + // triggering the rate limiter — otherwise an interactive prompt + // would lock the user's IP out after a handful of typos. + if r.URL.Path == "/api/v1/admin/auth/check" { + if check.matches(r) { w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"ok":true}`)) - return - } - - ip := extractIP(r) - if !rl.allow(ip) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Retry-After", "60") - http.Error(w, `{"error":"too many failed attempts, try again later"}`, http.StatusTooManyRequests) - return - } - - token := extractBearerToken(r) - if token == "" { - w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } - if subtle.ConstantTimeCompare([]byte(token), passwordBytes) != 1 { - rl.record(ip) - w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + if !check.allow(w, r) { return } - next.ServeHTTP(w, r) }) } // BearerAuth protects an HTTP handler with `Authorization: Bearer ` // authentication. Used by surfaces that serve at the root path (no `/api/` -// prefix), such as the embedded MCP server. Reuses the same constant-time -// comparison and per-IP rate limiter as [AdminAuth]. +// prefix), such as the embedded MCP server. Shares the rate-limited bearer +// check with [AdminAuth]; the only behavioural difference is that this +// middleware does not carve out the SPA static-file and auth-check paths. // -// If password is empty, all requests pass through (open mode) and a warning -// is the caller's responsibility. +// If password is empty, all requests pass through (open mode) and emitting +// a warning is the caller's responsibility. func BearerAuth(next http.Handler, password string) http.Handler { if password == "" { return next } - rl := newRateLimiter(5, time.Minute) - go rl.cleanup(30 * time.Second) - - passwordBytes := []byte(password) + check := newPasswordCheck(password) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } - - ip := extractIP(r) - if !rl.allow(ip) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("Retry-After", "60") - http.Error(w, `{"error":"too many failed attempts, try again later"}`, http.StatusTooManyRequests) + if !check.allow(w, r) { return } + next.ServeHTTP(w, r) + }) +} - token := extractBearerToken(r) - if token == "" { - w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } +// passwordCheck is the shared state for the bearer-token middlewares: +// the constant-time-comparable secret and the per-IP rate limiter that +// throttles repeated bad attempts. +type passwordCheck struct { + passwordBytes []byte + limiter *rateLimiter +} - if subtle.ConstantTimeCompare([]byte(token), passwordBytes) != 1 { - rl.record(ip) - w.Header().Set("Content-Type", "application/json") - http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) - return - } +// newPasswordCheck returns a checker pre-wired with a 5-failure-per-minute +// rate limiter. The cleanup goroutine runs for the lifetime of the process, +// which is fine because middlewares are constructed once at startup. +func newPasswordCheck(password string) *passwordCheck { + rl := newRateLimiter(5, time.Minute) + go rl.cleanup(30 * time.Second) + return &passwordCheck{ + passwordBytes: []byte(password), + limiter: rl, + } +} - next.ServeHTTP(w, r) - }) +// matches reports whether the request carries the expected bearer token. +// It does not touch the rate limiter and never writes to the response; +// callers use it for endpoints where validation must stay silent. +func (c *passwordCheck) matches(r *http.Request) bool { + token := extractBearerToken(r) + if token == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(token), c.passwordBytes) == 1 +} + +// allow runs the full rate-limited bearer check. When it returns true the +// caller may serve the request; when it returns false the response has +// already been written (401 or 429) and the caller must stop. +func (c *passwordCheck) allow(w http.ResponseWriter, r *http.Request) bool { + ip := extractIP(r) + if !c.limiter.allow(ip) { + w.Header().Set("Retry-After", "60") + writeJSONError(w, http.StatusTooManyRequests, "too many failed attempts, try again later") + return false + } + token := extractBearerToken(r) + if token == "" { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return false + } + if subtle.ConstantTimeCompare([]byte(token), c.passwordBytes) != 1 { + c.limiter.record(ip) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return false + } + return true +} + +// writeJSONError emits the canonical {"error":"..."} body that every other +// middleware in this package returns on a hard failure. +func writeJSONError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + http.Error(w, fmt.Sprintf(`{"error":%q}`, message), status) } func extractBearerToken(r *http.Request) string {