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..c7b0990 100644 --- a/.agents/DECISIONS.md +++ b/.agents/DECISIONS.md @@ -883,3 +883,48 @@ 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 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. 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**: + +- 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. + +**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/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/.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/mcp/adapter.go b/server/api/mcp/adapter.go new file mode 100644 index 0000000..bdd91a8 --- /dev/null +++ b/server/api/mcp/adapter.go @@ -0,0 +1,135 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "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) { + for _, td := range descriptors { + if err := registerDescriptor(s, td); err != nil { + return 0, fmt.Errorf("register tool %q: %w", td.Name, err) + } + } + 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) + } + s.AddTool(&sdk.Tool{ + Name: td.Name, + Description: td.Description, + InputSchema: schema, + Annotations: annotationsForMethod(td.Route.Method), + }, makeToolHandler(td)) + return nil +} + +// 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 + } + result, err := inner(ctx, mcptools.ToolCall{ + Name: td.Name, + Arguments: args, + }) + if err != nil { + return nil, err + } + if result == nil { + result = &mcptools.ToolResult{} + } + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: result.Text}}, + IsError: result.IsError, + }, 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 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, fmt.Errorf("marshal schema map: %w", err) + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + 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 +// 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, fmt.Errorf("decode arguments: %w", 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 { + switch method { + case http.MethodGet: + return &sdk.ToolAnnotations{ReadOnlyHint: true} + 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}}, + IsError: true, + } +} diff --git a/server/api/mcp/doc.go b/server/api/mcp/doc.go new file mode 100644 index 0000000..4bd7975 --- /dev/null +++ b/server/api/mcp/doc.go @@ -0,0 +1,16 @@ +// Package mcp exposes the Magec admin API as MCP tools over Streamable HTTP. +// +// 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. +// +// 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. +package mcp diff --git a/server/api/mcp/handler.go b/server/api/mcp/handler.go new file mode 100644 index 0000000..9214b1a --- /dev/null +++ b/server/api/mcp/handler.go @@ -0,0 +1,166 @@ +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" + + // 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" +) + +// 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. +type HandlerConfig struct { + // 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 +} + +// 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 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) + + 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 &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 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. Surfaced +// in the startup log so operators can sanity-check the catalogue. +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 (the format openapi2tools speaks), and lets the +// library do the heavy lifting: ref resolution, example stripping, flexible +// numeric parameters. +func loadAdminSpec() (*openapi.Spec, error) { + rawSwagger, err := swag.ReadDoc(swaggerInstanceName) + if err != nil { + return nil, fmt.Errorf("read swagger doc: %w", err) + } + + var swaggerSpec openapi2.T + if err := json.Unmarshal([]byte(rawSwagger), &swaggerSpec); err != nil { + return nil, fmt.Errorf("unmarshal swagger 2.0: %w", err) + } + + openAPISpec, err := openapi2conv.ToV3(&swaggerSpec) + if err != nil { + return nil, fmt.Errorf("convert swagger 2.0 to openapi 3.0: %w", err) + } + + 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(openAPIBytes) +} + +// 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: `.*`}, + } +} diff --git a/server/api/mcp/handler_test.go b/server/api/mcp/handler_test.go new file mode 100644 index 0000000..011b904 --- /dev/null +++ b/server/api/mcp/handler_test.go @@ -0,0 +1,199 @@ +package mcp + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + sdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/achetronic/magec/server/middleware" +) + +// 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() + 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 +} + +// 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) + } + 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) + } + t.Cleanup(func() { _ = sess.Close() }) + return sess +} + +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) + } + 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)) + } + + have := make(map[string]bool, len(res.Tools)) + 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) + sess := connectInMemory(t, h) + + stub.respond("POST", "/api/v1/admin/backends", http.StatusCreated, + `{"id":"abc","name":"OpenAI","type":"openai"}`) + + res, err := sess.CallTool(context.Background(), &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 got, want := stub.lastAuth, "Bearer test-pwd"; got != want { + t.Errorf("bearer not forwarded: got %q want %q", got, want) + } + if got, want := stub.lastMethod, http.MethodPost; got != want { + t.Errorf("method: got %q want %q", got, want) + } + 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 { + 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() + + // 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) + } + + // 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") + 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 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 + 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 + if r.Body != nil { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + 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/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.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 3dbff9f..06df59c 100644 --- a/server/go.sum +++ b/server/go.sum @@ -4,17 +4,36 @@ 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= 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= @@ -23,6 +42,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 +69,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 +87,19 @@ 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/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= 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= @@ -60,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= @@ -67,18 +117,26 @@ 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= 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 +154,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,30 +173,52 @@ 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/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= 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= 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= @@ -143,6 +227,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= @@ -181,18 +268,28 @@ 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= 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 +302,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 +329,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 +343,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 +369,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..4a8a449 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) + // 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,67 @@ 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) (*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") + } + + 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()) + + // 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, + Handler: middleware.AccessLog( + middleware.CORS( + middleware.BearerAuth(mcpMux, cfg.Server.AdminPassword), + ), + ), + ReadTimeout: 30 * time.Second, + 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 +477,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 +497,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..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,52 +102,123 @@ 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}`)) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") 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) +// 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. 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 emitting +// a warning is the caller's responsibility. +func BearerAuth(next http.Handler, password string) http.Handler { + if password == "" { + return next + } + + check := newPasswordCheck(password) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + next.ServeHTTP(w, r) 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) }) } +// 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 +} + +// 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, + } +} + +// 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 { auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") { diff --git a/website/content/docs/admin-mcp-server.md b/website/content/docs/admin-mcp-server.md new file mode 100644 index 0000000..6aa500b --- /dev/null +++ b/website/content/docs/admin-mcp-server.md @@ -0,0 +1,95 @@ +--- +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. + +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 + +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=33 +``` + +## 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. + +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`: + +```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 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, 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 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 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 | 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 | 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'