Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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**:
Expand All @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions .agents/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<adminPort>/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.
4 changes: 4 additions & 0 deletions .agents/MULTI_AGENT_ADMIN_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .agents/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions server/api/mcp/adapter.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
16 changes: 16 additions & 0 deletions server/api/mcp/doc.go
Original file line number Diff line number Diff line change
@@ -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
Loading