← back to Cli Printing Press
feat(cli): mega MCP — generic HTTP proxy with activation model (#147)
e041f50e7b46f29875e7eee342a0e3081a3868dd · 2026-04-07 17:59:00 -0700 · Trevin Chow
* feat(cli): generate tools-manifest.json at publish time for mega MCP
Add WriteToolsManifest() to the publish pipeline that generates a
tools-manifest.json alongside .printing-press.json. The manifest
contains pre-computed tool schemas (names, descriptions, parameters
with location classification, auth config, base URL, required headers)
that the mega MCP server reads at runtime — eliminating the need for
runtime OpenAPI spec parsing.
Key design decisions:
- Sorted map iteration for deterministic JSON (stable checksums)
- Explicit param location field (path/query/body) fixing the existing
template bug where POST body includes path params
- Cookie/composed auth APIs emit only NoAuth endpoints
- Uses toSnake() for tool names (matching MCP template convention)
- Non-blocking: publish continues if manifest generation fails
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(skills): add manifest_checksum, spec_format, manifest_url to registry schema
Extend the registry.json mcp block with three new fields for the mega
MCP server:
- manifest_checksum: SHA-256 of tools-manifest.json for integrity verification
- spec_format: the spec format (openapi3, graphql, internal)
- manifest_url: relative path to tools-manifest.json in the library repo
The publish skill computes manifest_checksum at publish time and derives
manifest_url from the entry's path field. These fields enable the mega
MCP to fetch and verify tools manifests from the public library repo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP skeleton — registry fetching, manifest caching, security
Create the internal/megamcp/ package and cmd/printing-press-mcp/ entry
point for the mega MCP generic HTTP proxy server.
Infrastructure:
- types.go: re-exports ToolsManifest from pipeline (no duplication),
adds RegistryEntry, Registry, APIEntry types
- registry.go: FetchRegistry with injectable baseURL for testing
- manifest.go: LoadManifests with parallel errgroup loading, checksum
verification, temp-then-rename cache writes, PRINTING_PRESS_APIS
filter, slugToToolPrefix normalization
- security.go: ValidateBaseURL (SSRF protection with DNS resolution),
SanitizeText, VerifyChecksum, ValidateSlug, ValidateCachePath
Add mcp-go v0.47.0 as direct dependency. Minimal main.go skeleton
with MCP server creation and stdio serving.
53 new tests covering registry, manifest, and security packages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP generic HTTP handler with auth and param routing
Add the core HTTP proxying layer for the mega MCP server:
handler.go — MakeToolHandler builds MCP tool handlers from manifest data:
- Parameter classification: path→URL substitution, query→URL params, body→JSON
- Fail-closed auth: blocks unauthenticated calls on non-NoAuth endpoints
- Post-assembly URL validation: verifies host matches base_url (SSRF prevention)
- Missing path param detection with actionable error messages
- RequiredHeaders and per-tool HeaderOverrides
- Response body capped at 10MB, credential redaction on 4xx errors
- Auth-aware error handling: 401/403→generic hint, 429→surface body
auth.go — Auth format string expansion and credential management:
- ApplyAuthFormat: {PLACEHOLDER} expansion with semantic aliases ({token})
- BuildAuthHeader/BuildAuthQueryParam: routes auth to header or query
- RedactCredentials: scrubs known credential values from error responses
106 tests covering happy paths, auth types, edge cases, and integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP meta-tools and activation model
Add the agent-facing discovery and activation layer:
metatools.go — 6 meta-tools registered at startup:
- library_info: API catalog with auth status (boolean only, no env var
names), upgrade availability, activation state
- setup_guide: per-API auth instructions with env vars and key URLs
- activate_api: dynamically registers API tools via mcp-go AddTool,
sends tools/list_changed notification automatically
- deactivate_api: removes API tools via DeleteTools
- search_tools: keyword search across ALL manifests (activated or not)
- about: version, API count, tool count
activation.go — ActivationManager for tool lifecycle:
- Per-API HTTP clients created at activation time
- Idempotent activation (no duplicate tools)
- SearchTools with case-insensitive substring matching
- Thread-safe via sync.RWMutex
Confirmed mcp-go v0.47.0 fully supports dynamic tool add/remove after
ServeStdio — no fallback needed. tools/list_changed sent automatically.
50 new tests across activation and meta-tools.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP entry point, goreleaser, and marketplace metadata
Wire cmd/printing-press-mcp/main.go with the full startup pipeline:
registry fetch → manifest loading → activation manager → meta-tools →
serve on stdio. Graceful shutdown on SIGINT/SIGTERM.
Distribution:
- Goreleaser builds both printing-press and printing-press-mcp as
separate archives (no bundling confusion)
- smithery.yaml at repo root for Smithery marketplace listing
- Configurable via PRINTING_PRESS_MCP_BASE_URL and
PRINTING_PRESS_MCP_CACHE_DIR env vars
Install: go install .../cmd/printing-press-mcp@latest
Setup: claude mcp add printing-press -- printing-press-mcp
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): address code review feedback — 3 bugs in mega MCP
1. Cached manifests now load on registry fetch failure: LoadManifests
distinguishes nil entries (registry failed → scan cache) from empty
entries (all filtered out → return empty). Previously, a GitHub
outage after a successful first run would boot with zero APIs.
2. Auth failure guidance uses API slug, not display name: MakeToolHandler
now takes an apiSlug parameter so setup_guide references like
setup_guide("dub") match what the meta-tool expects, instead of
setup_guide("Dub") which returns "API not found".
3. ValidateBaseURL wired into manifest loading: both fresh-fetch and
cache paths now call ValidateBaseURL before accepting a manifest.
Rejects literal private IPs; DNS-based SSRF checks deferred to
request-time SafeDialer (prevents DNS rebinding).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add SafeDialer for runtime SSRF protection, validate cached manifests
Two fixes from code review:
1. SafeDialer: ValidateBaseURL deferred DNS checks to a runtime dialer
that didn't exist. Added safeDialer that resolves hostnames and
rejects private/loopback/link-local IPs before connecting. All
per-API HTTP clients now use SafeHTTPClient() instead of plain
http.Client. This prevents https://localhost and other hostname-based
SSRF attacks that passed the format-only ValidateBaseURL check.
2. Cache fallback now validates base URLs: the offline fallback path
(loadFromCacheOnly) was loading cached manifests without calling
ValidateBaseURL. A tampered or outdated cached manifest with an
unsafe base URL would be accepted. Now both the normal path and
the cache-only fallback validate base URLs before returning entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): premortem fixes — auth normalization, stubs, truncation, debug tool
Four fixes from premortem analysis:
1. Auth format string normalization: WriteToolsManifest now normalizes
format strings at publish time so derived placeholders like {token}
become {DUB_TOKEN}. Prevents silent auth failures when the mega MCP's
runtime expansion doesn't match the generated config's multi-key map.
2. Stub tools at startup: All API tools are now registered as stubs when
NewActivationManager is created. Stubs prompt "call activate_api first"
instead of making HTTP requests. This lets agents discover tool names
via tools/list without activation, fixing the UX issue where agents
couldn't find tools they hadn't activated yet. Deactivation re-registers
stubs instead of removing tools entirely.
3. Response truncation: 2xx responses larger than 32KB are truncated with
a size note before returning to the agent, preventing context window
flooding from large API responses (e.g., Steam's 164-endpoint API).
4. debug_api meta-tool: New meta-tool that performs a health check GET to
the API's base URL and returns diagnostic info — base URL, auth status,
activation state, HTTP status, and response headers. Gives users a way
to diagnose failures without guessing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add manifest-gen tool for backfilling tools manifests
Standalone tool that downloads an API spec (OpenAPI, internal YAML, or
GraphQL) and generates a tools-manifest.json without running the full
printing press pipeline. Prints the SHA-256 checksum for registry.json.
Usage:
manifest-gen -spec <url-or-path> [-output <dir>] [-format auto|openapi|internal|graphql]
Examples:
manifest-gen -spec https://api.example.com/openapi.yaml -output ./out
manifest-gen -spec ./local-spec.yaml -format internal -output ./out
Used to backfill tools manifests for the 6 existing CLIs in the public
library repo that were published before tools-manifest.json generation
was added to the publish pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): mega MCP brainstorm, plans, and requirements documents
Add the planning artifacts for the mega MCP generic proxy:
- Requirements doc from brainstorm session
- Original subprocess-based plan (superseded, kept for hybrid mode reference)
- Final generic proxy plan with activation model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Files touched
M .goreleaser.yamlA cmd/manifest-gen/main.goA cmd/printing-press-mcp/main.goA docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.mdA docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.mdA docs/plans/2026-04-06-003-feat-mega-mcp-generic-proxy-plan.mdM go.modM go.sumA internal/megamcp/activation.goA internal/megamcp/activation_test.goA internal/megamcp/auth.goA internal/megamcp/auth_test.goA internal/megamcp/handler.goA internal/megamcp/handler_test.goA internal/megamcp/manifest.goA internal/megamcp/manifest_test.goA internal/megamcp/metatools.goA internal/megamcp/metatools_test.goA internal/megamcp/registry.goA internal/megamcp/registry_test.goA internal/megamcp/security.goA internal/megamcp/security_test.goA internal/megamcp/types.goM internal/pipeline/publish.goA internal/pipeline/toolsmanifest.goA internal/pipeline/toolsmanifest_test.goM skills/printing-press-publish/SKILL.mdA smithery.yaml
Diff
commit e041f50e7b46f29875e7eee342a0e3081a3868dd
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue Apr 7 17:59:00 2026 -0700
feat(cli): mega MCP — generic HTTP proxy with activation model (#147)
* feat(cli): generate tools-manifest.json at publish time for mega MCP
Add WriteToolsManifest() to the publish pipeline that generates a
tools-manifest.json alongside .printing-press.json. The manifest
contains pre-computed tool schemas (names, descriptions, parameters
with location classification, auth config, base URL, required headers)
that the mega MCP server reads at runtime — eliminating the need for
runtime OpenAPI spec parsing.
Key design decisions:
- Sorted map iteration for deterministic JSON (stable checksums)
- Explicit param location field (path/query/body) fixing the existing
template bug where POST body includes path params
- Cookie/composed auth APIs emit only NoAuth endpoints
- Uses toSnake() for tool names (matching MCP template convention)
- Non-blocking: publish continues if manifest generation fails
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(skills): add manifest_checksum, spec_format, manifest_url to registry schema
Extend the registry.json mcp block with three new fields for the mega
MCP server:
- manifest_checksum: SHA-256 of tools-manifest.json for integrity verification
- spec_format: the spec format (openapi3, graphql, internal)
- manifest_url: relative path to tools-manifest.json in the library repo
The publish skill computes manifest_checksum at publish time and derives
manifest_url from the entry's path field. These fields enable the mega
MCP to fetch and verify tools manifests from the public library repo.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP skeleton — registry fetching, manifest caching, security
Create the internal/megamcp/ package and cmd/printing-press-mcp/ entry
point for the mega MCP generic HTTP proxy server.
Infrastructure:
- types.go: re-exports ToolsManifest from pipeline (no duplication),
adds RegistryEntry, Registry, APIEntry types
- registry.go: FetchRegistry with injectable baseURL for testing
- manifest.go: LoadManifests with parallel errgroup loading, checksum
verification, temp-then-rename cache writes, PRINTING_PRESS_APIS
filter, slugToToolPrefix normalization
- security.go: ValidateBaseURL (SSRF protection with DNS resolution),
SanitizeText, VerifyChecksum, ValidateSlug, ValidateCachePath
Add mcp-go v0.47.0 as direct dependency. Minimal main.go skeleton
with MCP server creation and stdio serving.
53 new tests covering registry, manifest, and security packages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP generic HTTP handler with auth and param routing
Add the core HTTP proxying layer for the mega MCP server:
handler.go — MakeToolHandler builds MCP tool handlers from manifest data:
- Parameter classification: path→URL substitution, query→URL params, body→JSON
- Fail-closed auth: blocks unauthenticated calls on non-NoAuth endpoints
- Post-assembly URL validation: verifies host matches base_url (SSRF prevention)
- Missing path param detection with actionable error messages
- RequiredHeaders and per-tool HeaderOverrides
- Response body capped at 10MB, credential redaction on 4xx errors
- Auth-aware error handling: 401/403→generic hint, 429→surface body
auth.go — Auth format string expansion and credential management:
- ApplyAuthFormat: {PLACEHOLDER} expansion with semantic aliases ({token})
- BuildAuthHeader/BuildAuthQueryParam: routes auth to header or query
- RedactCredentials: scrubs known credential values from error responses
106 tests covering happy paths, auth types, edge cases, and integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP meta-tools and activation model
Add the agent-facing discovery and activation layer:
metatools.go — 6 meta-tools registered at startup:
- library_info: API catalog with auth status (boolean only, no env var
names), upgrade availability, activation state
- setup_guide: per-API auth instructions with env vars and key URLs
- activate_api: dynamically registers API tools via mcp-go AddTool,
sends tools/list_changed notification automatically
- deactivate_api: removes API tools via DeleteTools
- search_tools: keyword search across ALL manifests (activated or not)
- about: version, API count, tool count
activation.go — ActivationManager for tool lifecycle:
- Per-API HTTP clients created at activation time
- Idempotent activation (no duplicate tools)
- SearchTools with case-insensitive substring matching
- Thread-safe via sync.RWMutex
Confirmed mcp-go v0.47.0 fully supports dynamic tool add/remove after
ServeStdio — no fallback needed. tools/list_changed sent automatically.
50 new tests across activation and meta-tools.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): mega MCP entry point, goreleaser, and marketplace metadata
Wire cmd/printing-press-mcp/main.go with the full startup pipeline:
registry fetch → manifest loading → activation manager → meta-tools →
serve on stdio. Graceful shutdown on SIGINT/SIGTERM.
Distribution:
- Goreleaser builds both printing-press and printing-press-mcp as
separate archives (no bundling confusion)
- smithery.yaml at repo root for Smithery marketplace listing
- Configurable via PRINTING_PRESS_MCP_BASE_URL and
PRINTING_PRESS_MCP_CACHE_DIR env vars
Install: go install .../cmd/printing-press-mcp@latest
Setup: claude mcp add printing-press -- printing-press-mcp
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): address code review feedback — 3 bugs in mega MCP
1. Cached manifests now load on registry fetch failure: LoadManifests
distinguishes nil entries (registry failed → scan cache) from empty
entries (all filtered out → return empty). Previously, a GitHub
outage after a successful first run would boot with zero APIs.
2. Auth failure guidance uses API slug, not display name: MakeToolHandler
now takes an apiSlug parameter so setup_guide references like
setup_guide("dub") match what the meta-tool expects, instead of
setup_guide("Dub") which returns "API not found".
3. ValidateBaseURL wired into manifest loading: both fresh-fetch and
cache paths now call ValidateBaseURL before accepting a manifest.
Rejects literal private IPs; DNS-based SSRF checks deferred to
request-time SafeDialer (prevents DNS rebinding).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): add SafeDialer for runtime SSRF protection, validate cached manifests
Two fixes from code review:
1. SafeDialer: ValidateBaseURL deferred DNS checks to a runtime dialer
that didn't exist. Added safeDialer that resolves hostnames and
rejects private/loopback/link-local IPs before connecting. All
per-API HTTP clients now use SafeHTTPClient() instead of plain
http.Client. This prevents https://localhost and other hostname-based
SSRF attacks that passed the format-only ValidateBaseURL check.
2. Cache fallback now validates base URLs: the offline fallback path
(loadFromCacheOnly) was loading cached manifests without calling
ValidateBaseURL. A tampered or outdated cached manifest with an
unsafe base URL would be accepted. Now both the normal path and
the cache-only fallback validate base URLs before returning entries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(cli): premortem fixes — auth normalization, stubs, truncation, debug tool
Four fixes from premortem analysis:
1. Auth format string normalization: WriteToolsManifest now normalizes
format strings at publish time so derived placeholders like {token}
become {DUB_TOKEN}. Prevents silent auth failures when the mega MCP's
runtime expansion doesn't match the generated config's multi-key map.
2. Stub tools at startup: All API tools are now registered as stubs when
NewActivationManager is created. Stubs prompt "call activate_api first"
instead of making HTTP requests. This lets agents discover tool names
via tools/list without activation, fixing the UX issue where agents
couldn't find tools they hadn't activated yet. Deactivation re-registers
stubs instead of removing tools entirely.
3. Response truncation: 2xx responses larger than 32KB are truncated with
a size note before returning to the agent, preventing context window
flooding from large API responses (e.g., Steam's 164-endpoint API).
4. debug_api meta-tool: New meta-tool that performs a health check GET to
the API's base URL and returns diagnostic info — base URL, auth status,
activation state, HTTP status, and response headers. Gives users a way
to diagnose failures without guessing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(cli): add manifest-gen tool for backfilling tools manifests
Standalone tool that downloads an API spec (OpenAPI, internal YAML, or
GraphQL) and generates a tools-manifest.json without running the full
printing press pipeline. Prints the SHA-256 checksum for registry.json.
Usage:
manifest-gen -spec <url-or-path> [-output <dir>] [-format auto|openapi|internal|graphql]
Examples:
manifest-gen -spec https://api.example.com/openapi.yaml -output ./out
manifest-gen -spec ./local-spec.yaml -format internal -output ./out
Used to backfill tools manifests for the 6 existing CLIs in the public
library repo that were published before tools-manifest.json generation
was added to the publish pipeline.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs(cli): mega MCP brainstorm, plans, and requirements documents
Add the planning artifacts for the mega MCP generic proxy:
- Requirements doc from brainstorm session
- Original subprocess-based plan (superseded, kept for hybrid mode reference)
- Final generic proxy plan with activation model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
.goreleaser.yaml | 37 +-
cmd/manifest-gen/main.go | 157 ++++
cmd/printing-press-mcp/main.go | 77 ++
...26-04-06-mega-mcp-generic-proxy-requirements.md | 115 +++
...4-06-002-feat-mega-mcp-aggregate-server-plan.md | 544 ++++++++++++
...6-04-06-003-feat-mega-mcp-generic-proxy-plan.md | 460 ++++++++++
go.mod | 5 +
go.sum | 14 +
internal/megamcp/activation.go | 290 +++++++
internal/megamcp/activation_test.go | 315 +++++++
internal/megamcp/auth.go | 184 ++++
internal/megamcp/auth_test.go | 352 ++++++++
internal/megamcp/handler.go | 241 ++++++
internal/megamcp/handler_test.go | 835 ++++++++++++++++++
internal/megamcp/manifest.go | 348 ++++++++
internal/megamcp/manifest_test.go | 445 ++++++++++
internal/megamcp/metatools.go | 476 +++++++++++
internal/megamcp/metatools_test.go | 524 ++++++++++++
internal/megamcp/registry.go | 39 +
internal/megamcp/registry_test.go | 98 +++
internal/megamcp/security.go | 194 +++++
internal/megamcp/security_test.go | 330 ++++++++
internal/megamcp/types.go | 52 ++
internal/pipeline/publish.go | 8 +
internal/pipeline/toolsmanifest.go | 353 ++++++++
internal/pipeline/toolsmanifest_test.go | 929 +++++++++++++++++++++
skills/printing-press-publish/SKILL.md | 8 +-
smithery.yaml | 8 +
28 files changed, 7435 insertions(+), 3 deletions(-)
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index a98fa176..55483b44 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -4,7 +4,8 @@ gomod:
proxy: true
builds:
- - main: ./cmd/printing-press
+ - id: printing-press
+ main: ./cmd/printing-press
binary: printing-press
env:
- CGO_ENABLED=0
@@ -21,8 +22,29 @@ builds:
- -s -w -X github.com/mvanhorn/cli-printing-press/internal/version.Version={{.Version}}
mod_timestamp: "{{ .CommitTimestamp }}"
+ - id: printing-press-mcp
+ main: ./cmd/printing-press-mcp
+ binary: printing-press-mcp
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ flags:
+ - -trimpath
+ ldflags:
+ - -s -w -X github.com/mvanhorn/cli-printing-press/internal/version.Version={{.Version}}
+ mod_timestamp: "{{ .CommitTimestamp }}"
+
archives:
- - formats:
+ - id: printing-press
+ builds:
+ - printing-press
+ formats:
- tar.gz
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
@@ -30,6 +52,17 @@ archives:
formats:
- zip
+ - id: printing-press-mcp
+ builds:
+ - printing-press-mcp
+ formats:
+ - tar.gz
+ name_template: "printing-press-mcp_{{ .Os }}_{{ .Arch }}"
+ format_overrides:
+ - goos: windows
+ formats:
+ - zip
+
checksum:
name_template: checksums.txt
diff --git a/cmd/manifest-gen/main.go b/cmd/manifest-gen/main.go
new file mode 100644
index 00000000..30b111c2
--- /dev/null
+++ b/cmd/manifest-gen/main.go
@@ -0,0 +1,157 @@
+// manifest-gen downloads an OpenAPI/internal spec and generates a tools-manifest.json.
+//
+// Usage:
+//
+// manifest-gen -spec <url-or-path> -output <dir>
+// manifest-gen -spec https://api.dub.co/openapi.yaml -output ./out
+// manifest-gen -spec ./local-spec.yaml -output ./out
+//
+// For sniffed/internal specs (not OpenAPI), use -format internal:
+//
+// manifest-gen -spec ./espn-spec.yaml -format internal -output ./out
+//
+// The tool writes tools-manifest.json to the output directory.
+// It also prints a SHA-256 checksum for use in registry.json.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/mvanhorn/cli-printing-press/internal/graphql"
+ "github.com/mvanhorn/cli-printing-press/internal/openapi"
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+func main() {
+ specFlag := flag.String("spec", "", "URL or local path to the API spec")
+ outputFlag := flag.String("output", ".", "Output directory for tools-manifest.json")
+ formatFlag := flag.String("format", "auto", "Spec format: auto, openapi, internal, graphql")
+ flag.Parse()
+
+ if *specFlag == "" {
+ fmt.Fprintln(os.Stderr, "usage: manifest-gen -spec <url-or-path> [-output <dir>] [-format auto|openapi|internal|graphql]")
+ os.Exit(1)
+ }
+
+ // Load spec data.
+ data, err := loadSpec(*specFlag)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error loading spec: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Detect format if auto.
+ format := *formatFlag
+ if format == "auto" {
+ format = detectFormat(data, *specFlag)
+ }
+
+ // Parse spec.
+ var parsed *spec.APISpec
+ switch format {
+ case "openapi":
+ parsed, err = openapi.ParseLenient(data)
+ case "internal":
+ parsed, err = spec.ParseBytes(data)
+ case "graphql":
+ parsed, err = graphql.ParseSDLBytes(*specFlag, data)
+ default:
+ fmt.Fprintf(os.Stderr, "unknown format: %q\n", format)
+ os.Exit(1)
+ }
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error parsing spec: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Fprintf(os.Stderr, "Parsed: %s (%s)\n", parsed.Name, format)
+ fmt.Fprintf(os.Stderr, " Base URL: %s\n", parsed.BaseURL)
+ fmt.Fprintf(os.Stderr, " Auth: %s\n", parsed.Auth.Type)
+
+ total := 0
+ for _, r := range parsed.Resources {
+ total += len(r.Endpoints)
+ for _, sr := range r.SubResources {
+ total += len(sr.Endpoints)
+ }
+ }
+ fmt.Fprintf(os.Stderr, " Endpoints: %d\n", total)
+
+ // Generate tools manifest.
+ if err := os.MkdirAll(*outputFlag, 0o755); err != nil {
+ fmt.Fprintf(os.Stderr, "error creating output dir: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err := pipeline.WriteToolsManifest(*outputFlag, parsed); err != nil {
+ fmt.Fprintf(os.Stderr, "error writing tools manifest: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Compute and print checksum.
+ manifestPath := filepath.Join(*outputFlag, "tools-manifest.json")
+ manifestData, err := os.ReadFile(manifestPath)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error reading manifest: %v\n", err)
+ os.Exit(1)
+ }
+
+ checksum := pipeline.ComputeToolsManifestChecksum(manifestData)
+ fmt.Fprintf(os.Stderr, "\nWrote %s (%d bytes)\n", manifestPath, len(manifestData))
+ fmt.Fprintf(os.Stderr, "Checksum: %s\n", checksum)
+ fmt.Fprintf(os.Stderr, "\nFor registry.json:\n")
+ fmt.Fprintf(os.Stderr, " \"manifest_checksum\": %q,\n", checksum)
+ fmt.Fprintf(os.Stderr, " \"spec_format\": %q\n", format)
+
+ // Also print the checksum to stdout for scripting.
+ fmt.Println(checksum)
+}
+
+func loadSpec(source string) ([]byte, error) {
+ if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
+ resp, err := http.Get(source)
+ if err != nil {
+ return nil, fmt.Errorf("fetching %s: %w", source, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("fetching %s: HTTP %d", source, resp.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(resp.Body, 50*1024*1024))
+ }
+ return os.ReadFile(source)
+}
+
+func detectFormat(data []byte, path string) string {
+ s := string(data)
+ lowerPath := strings.ToLower(path)
+
+ // GraphQL SDL detection.
+ if strings.HasSuffix(lowerPath, ".graphql") || strings.HasSuffix(lowerPath, ".gql") {
+ return "graphql"
+ }
+ if strings.Contains(s, "type Query") || strings.Contains(s, "type Mutation") {
+ return "graphql"
+ }
+
+ // OpenAPI detection.
+ if strings.Contains(s, "openapi:") || strings.Contains(s, "\"openapi\"") ||
+ strings.Contains(s, "swagger:") || strings.Contains(s, "\"swagger\"") {
+ return "openapi"
+ }
+
+ // Internal spec detection.
+ if strings.Contains(s, "base_url:") || strings.Contains(s, "resources:") {
+ return "internal"
+ }
+
+ // Default to OpenAPI.
+ return "openapi"
+}
diff --git a/cmd/printing-press-mcp/main.go b/cmd/printing-press-mcp/main.go
new file mode 100644
index 00000000..1d1f70a6
--- /dev/null
+++ b/cmd/printing-press-mcp/main.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "syscall"
+
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/cli-printing-press/internal/megamcp"
+ "github.com/mvanhorn/cli-printing-press/internal/version"
+)
+
+const (
+ defaultBaseURL = "https://raw.githubusercontent.com/mvanhorn/printing-press-library/main"
+)
+
+func main() {
+ baseURL := os.Getenv("PRINTING_PRESS_MCP_BASE_URL")
+ if baseURL == "" {
+ baseURL = defaultBaseURL
+ }
+
+ cacheDir := os.Getenv("PRINTING_PRESS_MCP_CACHE_DIR")
+ if cacheDir == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "error: cannot determine home directory: %v\n", err)
+ os.Exit(1)
+ }
+ cacheDir = filepath.Join(home, ".cache", "printing-press-mcp")
+ }
+
+ // Fetch registry from GitHub (or cache).
+ registry, err := megamcp.FetchRegistry(baseURL)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: could not fetch registry: %v\n", err)
+ // Continue with empty entries — LoadManifests handles nil gracefully,
+ // and cached manifests from a previous successful run will still be used.
+ }
+
+ // Load and cache tools manifests.
+ var registryEntries []megamcp.RegistryEntry
+ if registry != nil {
+ registryEntries = registry.Entries
+ }
+ entries, warnings := megamcp.LoadManifests(registryEntries, cacheDir, baseURL)
+ for _, w := range warnings {
+ fmt.Fprintf(os.Stderr, "warning: %s\n", w)
+ }
+
+ // Create the MCP server with dynamic tool capabilities.
+ s := server.NewMCPServer("printing-press-mcp", version.Version,
+ server.WithToolCapabilities(true))
+
+ // Set up activation manager and register meta-tools.
+ am := megamcp.NewActivationManager(s, entries)
+ megamcp.RegisterMetaTools(s, am)
+
+ fmt.Fprintf(os.Stderr, "printing-press-mcp %s: %d APIs loaded, serving on stdio\n",
+ version.Version, len(entries))
+
+ // Handle graceful shutdown.
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ go func() {
+ <-sigCh
+ fmt.Fprintf(os.Stderr, "\nshutting down...\n")
+ os.Exit(0)
+ }()
+
+ if err := server.ServeStdio(s); err != nil {
+ fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+ os.Exit(1)
+ }
+}
diff --git a/docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md b/docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md
new file mode 100644
index 00000000..4ec351cb
--- /dev/null
+++ b/docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md
@@ -0,0 +1,115 @@
+---
+date: 2026-04-06
+topic: mega-mcp-generic-proxy
+---
+
+# Mega MCP: Generic HTTP Proxy for the Printing Press Library
+
+## Problem Frame
+
+Every printed CLI ships an MCP binary, but using them requires installing each one individually — `go install` of a long GitHub path, Go toolchain required, one `claude mcp add` per API. With 6 CLIs today and a growing catalog, this friction prevents adoption. Agents have no way to discover available APIs without the user manually configuring each MCP connection.
+
+Step 1 (PR #145) built the data layer: MCP metadata in manifests and registry, per-endpoint auth awareness, self-describing `about` tools. This plan builds the access layer: a single MCP server binary that gives agents access to every API in the library through one install.
+
+**The key insight:** the per-API MCP servers' core handler (`makeAPIHandler`) is a generic HTTP client — method, path template, positional params, query params. The mega MCP replicates this pattern at runtime using pre-computed tool manifests from the publish pipeline, rather than requiring pre-generated code or local binaries. The mega MCP is a **discovery and demo layer** — it covers the 95% CRUD case. Users who want the full experience (sync, search, SQL, adaptive rate limiting, cookie auth) install the per-API CLI/MCP individually.
+
+## Requirements
+
+**Distribution & Installation**
+
+- R1. Single binary (`printing-press-mcp`) installable via `go install` and Homebrew. No local printing press library required. No Go toolchain required at runtime for Homebrew users.
+- R2. One-command setup for Claude Code (`claude mcp add printing-press -- printing-press-mcp`) and Claude Desktop (single JSON config entry).
+- R3. Marketplace-ready: the mega MCP includes `smithery.yaml` and documentation sufficient for listing on Smithery and the Claude marketplace as a single entry covering all library APIs. Exact marketplace requirements TBD in planning.
+
+**Discovery & Activation**
+
+- R4. At startup, fetch `registry.json` from the public library repo (`mvanhorn/printing-press-library`) on GitHub to discover available APIs and their metadata.
+- R5. For each API, fetch and cache the **tools manifest** (`tools-manifest.json`) from the API's path in the public library repo. The manifest is generated at publish time and contains tool names, descriptions, parameter schemas (with location: path/query/body), auth config, base URL, and required headers. Cache at `~/.cache/printing-press-mcp/` with a 24h TTL. Verify cached manifests against the registry's `spec_checksum` field on each startup — re-fetch on mismatch. Graceful degradation: use cache if fetch fails; on first run with no cache and no network, start with zero APIs and surface the error through `library_info`.
+- R6. Expose a `library_info` meta-tool listing all available APIs with: name, description, tool count, auth type, auth status (configured or not), novel features, and whether a richer per-API MCP exists locally. When a per-API MCP is available, include the install command. Does NOT expose specific env var names in the response — agents use `library_info` for discovery, and the `setup_guide` meta-tool for auth configuration details.
+- R7. Expose a `setup_guide` meta-tool that returns auth configuration instructions for a specific API: required env vars, key URL, and example `claude mcp add --env` commands. Separated from `library_info` so credential-related metadata is behind a deliberate introspection step, not broadcast in the catalog listing.
+- R8. Expose an `activate_api` meta-tool that dynamically registers all tools for a specific API by slug. By default, only meta-tools are registered at startup. The agent calls `library_info` → discovers APIs → calls `activate_api("espn")` → ESPN tools become available. This keeps the default tool count at ~4 (meta-tools only) and scales to any library size. Uses the MCP `tools/list_changed` notification to inform the client. A `deactivate_api` meta-tool unregisters tools for an API.
+- R9. Expose a `search_tools` meta-tool that searches across all APIs' tool names and descriptions by keyword, returning matching tools with their API slug — without requiring activation first. Lets the agent find the right API for a task before activating it.
+
+**Tool Registration & Routing**
+
+- R10. Register MCP tools from the tools manifest (not by parsing OpenAPI specs at runtime). Tool names follow the existing `{snake_resource}_{snake_endpoint}` convention including sub-resource endpoints, prefixed with `{normalized_slug}__` (double underscore separator, slug hyphens → underscores). Registration-time collision detection: reject duplicate prefixed names with a warning.
+- R11. When a tool is called, make the HTTP request directly to the API using a generic HTTP client. The handler classifies each parameter by location (from the manifest): **path** params for URL template substitution, **query** params appended to the URL, **body** params serialized as the JSON request body. Path and query params are excluded from the body. Attach RequiredHeaders (API version headers like `cal-api-version`, `Stripe-Version`) and per-endpoint HeaderOverrides from the manifest.
+- R12. Startup latency: the mega MCP initializes meta-tools and loads manifests in under 5 seconds with cached data (parallel loading). First-run startup (no cache) targets <30 seconds.
+
+**Authentication**
+
+- R13. Auth handled via env vars configured through `claude mcp add --env` or Claude Desktop config. The mega MCP reads each API's auth config from the tools manifest — auth type, header name, format string (e.g., `Bearer {TOKEN}`), header-vs-query placement (`In` field), and env var names. At runtime: read the env var value, apply the format string substitution, set the result on the correct header or query param. Validate format strings against a safe pattern set (header/query substitution only, never path segment) before accepting them from the manifest.
+- R14. Supported auth types: `api_key` (header or query), `bearer_token` (Authorization header), `oauth2` (same treatment as `bearer_token` — env var holds the token; interactive OAuth flows are out of scope), `none`. For `cookie`/`composed` auth types, only endpoints marked `NoAuth` in the manifest are registered (public endpoints work, auth-required endpoints are excluded with a description noting the limitation).
+- R15. Credential isolation: env vars for one API are not used in requests to another API. Each API's HTTP client reads only its own `AuthEnvVars`. Startup-time collision check: if two APIs declare the same env var name, warn the user and require the API filter to separate them.
+- R16. Auth-aware error handling: 401/403 responses return a generic error ("authentication not configured for this API — call `setup_guide` for instructions"). 429 responses surface the error body. Credentials never appear in error output or logs.
+
+**Integrity & Security**
+
+- R17. Spec integrity: verify fetched tools manifests against the registry's `spec_checksum` before accepting them. Reject manifests that fail verification and fall back to cache. Require all fetch URLs to use HTTPS.
+- R18. Server URL validation: before registering tools from a manifest, validate that the API's base URL is HTTPS, not a private IP range (RFC 1918, loopback, link-local, cloud metadata), and resolves to a public address. Reject APIs with invalid base URLs with a warning.
+- R19. Sanitize all manifest-derived text (tool descriptions, parameter descriptions) before registering as MCP tool metadata — strip control characters and length-limit to prevent prompt injection via malicious manifest content.
+
+**Scaling & Filtering**
+
+- R20. API filter: `PRINTING_PRESS_APIS=espn,dub` env var limits which APIs are available for activation. Default is all APIs in the registry.
+- R21. The activation model (R8) keeps the default tool count at ~4-5 meta-tools regardless of library size. Tool count only grows when the agent explicitly activates APIs. This scales to any number of APIs without degrading agent tool selection.
+
+## Success Criteria
+
+- A user installs `printing-press-mcp` and runs `claude mcp add printing-press -- printing-press-mcp` with no other setup. Agent calls `library_info` and sees all available APIs.
+- Agent calls `activate_api("espn")` → ESPN tools appear → agent calls `espn__scores_get` (no auth) and receives real ESPN data.
+- Agent calls `search_tools("pizza")` → finds `pagliacci_pizza__stores_list` → calls `activate_api("pagliacci-pizza")` → calls the tool and gets store data.
+- Agent calls `setup_guide("dub")` → gets auth instructions → user configures `DUB_TOKEN` → agent calls `dub__links_list` and receives real Dub data.
+- Auth-required Pagliacci endpoints are not registered. Tool descriptions note "install pagliacci-pizza-pp-mcp for full access including ordering."
+- The mega MCP works identically in Claude Code and Claude Desktop.
+- Newly published APIs appear on restart without rebuilding the binary (consequence of R4's startup-time fetch).
+
+## Scope Boundaries
+
+- **In scope:** Generic HTTP proxy for all APIs in the public library registry. Marketplace metadata (`smithery.yaml`) for the mega MCP itself. Activation-based tool registration for agent-controlled scaling.
+- **Out of scope:** Subprocess/hybrid mode (spawning per-API MCP binaries for richer features). This is a follow-up — the generic proxy is the v1.
+- **Out of scope:** Per-API MCP binary distribution (goreleaser per API). The mega MCP IS the distribution strategy.
+- **Out of scope:** Directory restructure of `~/printing-press/library/`. The mega MCP reads from GitHub, not local library.
+- **Out of scope:** Novel features (sync, search, SQL), adaptive rate limiting, response caching — these require the per-API MCP binary. The mega MCP is a discovery/demo layer; tool descriptions surface when a richer per-API MCP exists with install instructions.
+- **Out of scope:** Hot-reloading. A restart picks up registry changes.
+- **Out of scope:** Interactive OAuth2 flows, cookie/composed auth beyond public endpoints.
+- **Known limitation:** Cookie/composed auth APIs (Pagliacci) only expose public endpoints (~17% of tools). Tool descriptions note this and include upgrade instructions.
+
+## Key Decisions
+
+- **Tools manifest over runtime spec parsing:** The publish pipeline generates a `tools-manifest.json` per API containing pre-computed tool schemas (names, descriptions, parameters with location classification, auth config, base URL, required headers). The mega MCP reads these lightweight JSON files instead of fetching and parsing raw OpenAPI/internal/GraphQL specs at runtime. This eliminates: runtime kin-openapi dependency, spec-format concerns (OpenAPI vs internal YAML vs GraphQL), parser global mutable state, auth format re-derivation, and startup performance issues. The manifest is format-agnostic — it's always the output of whichever parser was used at generation time.
+- **Activation model over all-at-once registration:** Only meta-tools (`library_info`, `setup_guide`, `activate_api`, `deactivate_api`, `search_tools`, `about`) are registered at startup. API tools are registered on demand via `activate_api`. This keeps the default tool count at ~6 regardless of library size, preventing agent tool selection degradation. Uses MCP's `tools/list_changed` notification to inform clients.
+- **Discovery/demo layer with upgrade path:** The mega MCP is explicitly positioned as a discovery and demo layer — not the full per-API MCP experience. It covers CRUD operations via generic HTTP proxy. Tool descriptions surface when a richer per-API MCP exists locally (with install commands). `library_info` shows upgrade availability. This is honest framing that drives adoption of the full printing press stack.
+- **Generic HTTP proxy over subprocess architecture:** The original plan (Step 2) used per-API MCP subprocesses. This required a local library with pre-built binaries, Go at runtime, and complex subprocess management. The generic proxy eliminates all of that — one binary, no local library, no runtime Go dependency for binary-distribution users.
+- **Fetch from GitHub, our copies:** Tool manifests and registry live in the public library repo under our control. Spec URLs point to our copies (not third-party vendor URLs), ensuring stability and known formats. Integrity verified via checksums.
+- **Cookie/composed auth: public endpoints only:** Rather than excluding cookie-auth APIs entirely, register their public endpoints. Pagliacci's store finder and menu browser work without auth. Tool descriptions note the limitation and include the per-API MCP install command for full access.
+- **Mega MCP as the marketplace listing:** One Smithery/Claude marketplace entry for the aggregator, not one per API. Individual per-API `smithery.yaml` files still exist for standalone discovery, but the mega MCP is the primary install path.
+- **Hybrid mode deferred:** The subprocess-based hybrid mode (detect local per-API binary → route through it for richer features) is explicitly deferred. v1 is pure generic proxy. The tool handler interface is designed so hybrid can slot in later: each API's handler is a function that takes a tool call and returns a result — swapping the generic HTTP handler for a subprocess handler is a per-API config change, not an architectural change.
+
+## Prerequisites (must ship before or alongside the mega MCP)
+
+- **Tools manifest generation in the publish pipeline:** The publish pipeline must generate `tools-manifest.json` per API at publish time, containing: tool names, descriptions, parameter schemas with location (path/query/body), auth config (type, header, format, in, env vars), base URL, required headers, and per-endpoint header overrides. This is a machine change to the printing press generator/publish flow.
+- **Registry schema extension:** Add `spec_checksum`, `spec_format`, and `manifest_url` (pointing to `tools-manifest.json` in the repo) to `registry.json` entries. Update the publish skill to populate these from the CLI manifest.
+- **Re-publish existing CLIs:** The 6 existing CLIs need to be re-published with tools manifests. This can be done as part of the prerequisite work.
+
+## Dependencies / Assumptions
+
+- The public library repo (`mvanhorn/printing-press-library`) has a `registry.json` with MCP metadata (added in Step 1, PR #145). The registry will be extended with `manifest_url` and `spec_checksum` per the prerequisites above.
+- The tools manifest format is generated by the printing press publish pipeline and is format-agnostic (works for OpenAPI, internal YAML, GraphQL, and sniffed specs).
+- Tool manifests live in the public library repo at the path indicated by each registry entry's `path` field: `{path}/tools-manifest.json`.
+- The `mcp-go` library (`github.com/mark3labs/mcp-go`) must be added as a direct dependency in `go.mod`. It supports `tools/list_changed` notifications for dynamic tool registration.
+- MCP marketplace listing (Smithery) accepts a `smithery.yaml` at the repo root level, not just per-directory.
+- The deployment model is single-user local: the MCP server runs as a subprocess of Claude Code/Desktop, which is the only process that can reach its stdio. Multi-user or network-exposed deployments are out of scope.
+
+## Outstanding Questions
+
+### Deferred to Planning
+
+- [Affects R3][Needs research] What are the exact requirements for Smithery marketplace listing? Does it need a Docker image, or can it reference a binary?
+- [Affects R10][Technical] The `__` separator could theoretically collide with tool names containing double underscores. Validate against current APIs during implementation and confirm registration-time collision detection catches any issues.
+- [Affects R20][Technical] Should the API filter support category-based filtering (e.g., `PRINTING_PRESS_CATEGORIES=sports,developer-tools`) in addition to slug-based?
+- [Affects R8][Technical] Does `mcp-go` support the `tools/list_changed` notification? If not, the activation model may require the client to re-fetch tools manually (e.g., agent instructs user to "refresh tools" after activation).
+
+## Next Steps
+
+-> `ce:plan` for structured implementation planning. The old subprocess plan (`docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md`) is superseded by this requirements doc. It remains as reference material for the deferred hybrid mode.
diff --git a/docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md b/docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md
new file mode 100644
index 00000000..13358341
--- /dev/null
+++ b/docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md
@@ -0,0 +1,544 @@
+---
+title: "feat: Mega MCP — Library-Wide Aggregate MCP Server with Catalog Discovery and Tool Passthrough"
+type: feat
+status: active
+date: 2026-04-06
+deepened: 2026-04-06
+---
+
+# feat: Mega MCP — Library-Wide Aggregate MCP Server with Catalog Discovery and Tool Passthrough
+
+## Overview
+
+**Step 2 of 2** toward a Printing Press Mega MCP. Step 1 (PR #145) built the data layer: per-endpoint auth awareness, MCP metadata in manifests, self-describing `about` tools, minority-side auth annotations, smithery.yaml, and registry.json MCP fields.
+
+This plan builds the access layer: a single MCP server binary that aggregates every API in the local library into one installable MCP connection. An agent adds one MCP server and gets access to every printed CLI's tools — Dub links, ESPN scores, Steam achievements, Pagliacci menus — with catalog discovery, auth awareness, and passthrough execution to the per-API MCP binaries.
+
+The plan has two phases:
+1. **Directory restructure** — library directories keyed by API slug (`dub/`) instead of CLI name (`dub-pp-cli/`). Prerequisite for clean mega MCP discovery and an overdue alignment with manuscripts (already slug-keyed).
+2. **Mega MCP server** — new binary that scans the library, discovers tools from per-API MCP servers, and routes tool calls through.
+
+## Problem Frame
+
+Every printed CLI already ships an MCP binary — but using them requires adding each one individually to Claude Code or Claude Desktop. With 6 CLIs today and a growing catalog, this is friction that compounds. Agents have no way to discover what APIs are available without the user manually configuring each MCP connection.
+
+The data layer from Step 1 makes each MCP server self-describing. This plan makes them collectively accessible: one `claude mcp add` gives agents access to every API in the library.
+
+A prerequisite directory restructure is included because the current library layout (`dub-pp-cli/`) names directories after the CLI binary, which misrepresents the contents (each directory contains both CLI and MCP binaries) and creates awkward mapping for the mega MCP's catalog-style discovery. Manuscripts are already keyed by API slug; the library should match.
+
+## Requirements Trace
+
+- R1. Library directories keyed by API slug, not CLI name
+- R2. Backward-compatible discovery: accept both old (`dub-pp-cli/`) and new (`dub/`) directory layouts during transition
+- R3. Mega MCP binary discovers all MCP-ready CLIs in `~/printing-press/library/` via manifest scanning
+- R4. Mega MCP exposes a `library_info` tool listing all available APIs, tool counts, auth status, and readiness
+- R5. Mega MCP registers all per-API tools with namespaced names and routes calls to per-API MCP subprocesses
+- R6. Auth-aware: surfaces missing credentials and forwards per-API env vars to subprocesses with credential isolation (each subprocess receives only its own API's env vars)
+- R7. Extensible: picks up newly published CLIs on restart without rebuilding the mega MCP binary
+- R8. Installable via `claude mcp add` with a single command
+- R9. Publish pipeline and skills updated for slug-keyed library paths
+
+## Scope Boundaries
+
+- **In scope:** Local library aggregation only. The mega MCP reads `~/printing-press/library/`, not the public library repo.
+- **In scope:** Startup-time probing of per-API MCP binaries via `tools/list` to discover full tool schemas. This avoids a new generation artifact and uses the MCP protocol as designed.
+- **In scope:** `mcp-go` as a direct dependency in the printing-press `go.mod` — the mega MCP is a first-class MCP server.
+- **Out of scope:** Public library repo directory migration — that's a separate PR in `mvanhorn/printing-press-library` after the machine changes land.
+- **Out of scope:** MCP marketplace submission for the mega MCP itself. We build the binary; marketplace listing is a separate effort.
+- **Out of scope:** Hot-reloading of the library while the mega MCP is running. A restart picks up changes. Live watch can be added later.
+- **Out of scope:** Cross-API workflows or tool composition. The mega MCP is a multiplexer, not an orchestrator.
+- **Out of scope:** Changing binary names (`dub-pp-cli`, `dub-pp-mcp`) — these stay as-is. Only the directory key changes.
+- **Known limitation:** CLIs without MCP metadata in their manifest (e.g., generated via `--docs` before Step 1) will not appear in the mega MCP. They need to be regenerated or manually have MCP metadata added.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Library scanner:** `scanLibrary()` at `internal/cli/library.go:86-144` already iterates `~/printing-press/library/`, reads `.printing-press.json` manifests, and returns structured entries. Inclusion criteria: `naming.IsCLIDirName(dirName) || entry.APIName != ""`. The `APIName != ""` branch means manifest-bearing directories are accepted regardless of naming — this is the migration bridge.
+- **Path construction:** `DefaultOutputDir(apiName)` at `internal/pipeline/pipeline.go:16` returns `filepath.Join(PublishedLibraryRoot(), naming.CLI(apiName))`. This is the single point that determines library directory names.
+- **Naming functions:** `internal/naming/naming.go` has `CLI()`, `MCP()`, `TrimCLISuffix()`, `IsCLIDirName()`. The restructure needs a counterpart for slug-keyed dirs.
+- **CLIManifest MCP fields:** `MCPBinary`, `MCPToolCount`, `MCPPublicToolCount`, `MCPReady`, `AuthType`, `AuthEnvVars`, `NovelFeatures` — all added in Step 1. The mega MCP reads these directly.
+- **Per-API MCP server template:** `main_mcp.go.tmpl` creates an `mcp-go` server via `server.NewMCPServer()` + `server.ServeStdio()`. Tool names follow `{resource}_{endpoint}` pattern (e.g., `links_list`, `links_create`). The `about` tool returns self-describing metadata.
+- **MCP Go SDK (`mark3labs/mcp-go`):** Used in generated templates for per-API MCP servers. Currently NOT a direct dependency of the printing-press binary. Supports `server.ServeStdio()` for server-side. Client-side capabilities (for probing sub-MCPs) need to be verified during implementation.
+- **Publish pipeline:** `PublishWorkingCLI()` at `internal/pipeline/publish.go:83` copies working dir to library and writes manifest. Uses `DefaultOutputDir()` for the target path.
+- **`ClaimOutputDir()`:** Atomic directory claiming with `-2`, `-3` suffixes for reruns. Works on any base path — slug-keyed dirs will work without changes to this function.
+- **`RenameCLI()`** at `internal/pipeline/renamecli.go` already handles MCP directory renaming alongside CLI renaming. Needs update for slug-keyed outer directories.
+- **Goreleaser config:** `internal/generator/templates/goreleaser.yaml.tmpl` builds both CLI and MCP binaries. The printing-press binary's own `.goreleaser.yaml` would need a new entry for the mega MCP binary.
+- **Test patterns:** `PRINTING_PRESS_HOME` env var override for isolation. Table-driven tests with `testify/assert`. `writeTestManifest` helper in pipeline tests.
+
+### Institutional Learnings
+
+- **Layout contract** (`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout`): Published CLIs live at `~/printing-press/library/<dir>/`. The manifest is the source of truth for identity, not the directory name. Rerun suffixes (`-pp-cli-2`) are valid — the actual command entrypoint lives inside `cmd/<api>-pp-cli/`.
+- **Filepath traversal protection** (`docs/solutions/security-issues/filepath-join-traversal-with-user-input`): Any MCP tool name or argument that feeds into `filepath.Join` is an attack surface. Belt-and-suspenders: reject `..`, `/`, `\` in input AND verify resolved path is under library root with `strings.HasPrefix(absResult, absRoot + string(filepath.Separator))`.
+- **Validation must not mutate source directory** (`docs/solutions/best-practices/validation-must-not-mutate-source-directory`): The mega MCP must treat `~/printing-press/library/` as read-only. If it needs to build MCP binaries, use temp directories.
+- **Independent source discovery** (`docs/solutions/best-practices/multi-source-api-discovery-design`): Use `errgroup.Group` (not `WithContext`) when probing CLIs. A broken CLI binary should degrade gracefully (log warning, exclude), not crash the server.
+
+## Key Technical Decisions
+
+- **Separate binary, not a subcommand:** The mega MCP lives at `cmd/printing-press-mcp/main.go` as its own binary. MCP servers are long-running processes invoked by `claude mcp add`; they should not depend on invoking the full printing-press generator binary. The printing-press binary is for code generation; the mega MCP binary is for runtime tool serving. Goreleaser builds both.
+
+- **API-slug directory key with backward compat:** `DefaultOutputDir` changes from `naming.CLI(apiName)` to just `apiName`. The library scanner accepts both layouts — manifest presence (`APIName != ""`) is the inclusion criterion, not directory name suffix. This means existing `dub-pp-cli/` directories continue to work alongside new `dub/` directories. A `library migrate` command is deferred to a follow-up since it's cosmetic — backward-compat discovery makes it unnecessary for functionality.
+
+- **Double-underscore tool namespacing (`dub__links_list`):** Per-API tool names are prefixed with `{normalized-slug}__` (double underscore). Single underscore is already used within tool names (`links_list`), so double underscore is the unambiguous separator. The mega MCP strips the prefix to route to the correct subprocess. Examples: `dub__links_list`, `espn__get_scores`, `steam_web__get_player_summaries`. **Slug-to-prefix normalization:** API slugs are kebab-case (`steam-web`), but MCP tool names cannot contain hyphens. Slugs are normalized to snake_case for the prefix: hyphens → underscores, consecutive underscores collapsed to single (`steam--web` → `steam_web`, not `steam__web`). The normalizer rejects any slug that produces a prefix containing `__` after conversion (would be ambiguous with the separator). In practice this cannot happen with `cleanSpecName()`'s output, but the guard prevents future regressions.
+
+- **Persistent per-API MCP subprocesses:** The mega MCP starts each per-API MCP binary as a long-running subprocess at startup, rather than spawning ephemeral processes per tool call. Rationale: (1) amortizes Go binary startup time, (2) the per-API MCP server may hold state (config, connection pools), (3) the MCP protocol is designed for persistent connections. The subprocess manager restarts crashed processes on the next tool call.
+
+- **Startup-time tool discovery via `tools/list`:** At startup, the mega MCP sends a `tools/list` JSON-RPC request to each running sub-MCP to discover the full tool schema (names, descriptions, parameters). This avoids a new generation artifact and uses the MCP protocol as designed. Discovery results are cached in memory. A `library_info` meta-tool surfaces the catalog for agents.
+
+- **Require pre-built MCP binaries (no build-on-demand):** The mega MCP expects each per-API MCP binary to be pre-built and present in the library directory. It looks for the binary at `cmd/{mcp-binary-name}/{mcp-binary-name}` (the standard `go build` output location) or the Makefile's output path. If the binary doesn't exist, the API is excluded with a clear error message telling the user to run `cd ~/printing-press/library/{slug} && go build -o ./cmd/{name}-pp-mcp/{name}-pp-mcp ./cmd/{name}-pp-mcp`. This avoids the complexity of a build cache, eliminates the runtime Go toolchain requirement for users who receive pre-built binaries, and keeps the mega MCP's startup fast. A follow-up can add build-on-demand for convenience.
+
+- **Credential isolation via env var subtraction:** Each subprocess receives the full parent environment **minus other APIs' `AuthEnvVars`**. For example, the Dub subprocess inherits `DUB_TOKEN` (its own) but NOT `STEAM_WEB_API_KEY` (Steam's). This is implemented by starting from `os.Environ()`, collecting all `AuthEnvVars` from ALL discovered APIs, then removing everything except the current API's vars from that set. System vars (`PATH`, `HOME`, `TMPDIR`, proxy vars like `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY`, TLS vars like `SSL_CERT_FILE`/`SSL_CERT_DIR`, locale vars like `LANG`) are preserved — stripping these would silently break HTTP requests in corporate/non-default environments (Go's `exec.Cmd.Env`, when non-nil, replaces the entire environment). This prevents credential leakage across APIs while preserving all networking and system functionality.
+
+- **`mcp-go` as direct dependency:** Added to the printing-press `go.mod`. The mega MCP uses both server-side (for its own MCP interface) and client-side (for probing sub-MCPs) capabilities. Verify `mcp-go` client support (`client.NewStdioMCPClient` or equivalent) at the start of Unit 5. If `mcp-go` lacks client-side support, implement a JSON-RPC client that handles the MCP initialization handshake (`initialize` → response → `initialized` notification) before sending `tools/list` or `tools/call`. Budget ~200-300 lines for a proper client with handshake, request-response correlation, and timeout handling — not the ~100 lines originally estimated.
+
+- **Printing-press MCP binary naming:** The mega MCP binary is called `printing-press-mcp` (no API-slug prefix, no `-pp-` infix). It represents the printing press itself, not a single API. This distinguishes it from per-API MCP servers (`dub-pp-mcp`, `espn-pp-mcp`).
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should the directory restructure be in this plan or separate?** → In this plan as Phase 1. The restructure is a prerequisite that makes the mega MCP's discovery cleaner, and the user presented both together.
+- **Separate binary or subcommand?** → Separate binary (`printing-press-mcp`). Clean separation between generation-time and runtime concerns. MCP servers are long-running; coupling with the generator binary adds unnecessary weight.
+- **How to namespace tool names?** → Double-underscore prefix: `{api-slug}__{tool-name}`. Unambiguous since single underscore is already used within tool names. Easy to split for routing.
+- **Ephemeral or persistent subprocesses?** → Persistent. Amortizes startup, supports stateful per-API servers, matches MCP protocol's session model.
+- **Where does tool schema come from?** → Probed from running sub-MCPs via `tools/list` at startup. No new generation artifact needed.
+- **How does auth work?** → Environment inheritance. User configures env vars in `claude mcp add --env`. Mega MCP passes them through to subprocesses.
+
+### Deferred to Implementation
+
+- **`mcp-go` client-side capabilities** — verify whether `mark3labs/mcp-go` supports MCP client operations (`initialize`, `tools/list`, `tools/call` over stdio). If not, implement a JSON-RPC client with MCP handshake support (~200-300 lines). Check at the start of Unit 5 since it affects subprocess manager design.
+- **Tool name collision across APIs** — the prefix + `__` + original name scheme could theoretically produce collisions. Unit 5 validates uniqueness at registration time. Verify with the current 6 CLIs during implementation.
+- **MCP proxy alternatives** — the MCP specification supports native proxy/aggregation patterns, and tools like `mcp-proxy` exist. The custom approach is chosen for: control over tool naming, `library_info` integration, auth-aware credential isolation, and library-specific discovery. If implementation complexity exceeds expectations, evaluate off-the-shelf alternatives before building more than Unit 4.
+
+### Resolved During Document Review
+
+- **Subprocess restart strategy** — resolved: restart once on crash (not 3 retries). Log crash events visibly. Don't retry the call that caused the crash. Exclude on failed restart.
+- **RenameCLI adaptation for slug-keyed directories** — resolved: option (a). Modify `validateRenameInputs()` to accept names that pass either `IsCLIDirName()` or `IsLibraryDir()`.
+- **Serialized vs multiplexed subprocess communication** — resolved: multiplexed using JSON-RPC `id` correlation. Head-of-line blocking from serialized model would degrade UX, and multiplexing is ~50 lines beyond what serialized needs.
+- **Credential isolation** — resolved: subtraction model. Each subprocess gets full parent env minus other APIs' AuthEnvVars. Preserves networking/TLS/proxy vars. Safer than allowlist approach which would silently break HTTP in corporate environments.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
+
+```
+ ┌─────────────────────────┐
+ │ Agent (Claude) │
+ └──────────┬──────────────┘
+ │ MCP (stdio)
+ ┌──────────▼──────────────┐
+ │ printing-press-mcp │
+ │ (Mega MCP Server) │
+ │ │
+ │ ┌───────────────────┐ │
+ │ │ Tool Router │ │
+ │ │ dub__* → dub │ │
+ │ │ espn__* → espn │ │
+ │ │ steam_web__* → … │ │
+ │ └───────┬───────────┘ │
+ │ │ │
+ │ ┌───────▼───────────┐ │
+ │ │ Subprocess Manager │ │
+ │ │ (per-API MCP bins) │ │
+ │ └───────┬───────────┘ │
+ └──────────┼──────────────┘
+ ┌─────────────────┼──────────────────┐
+ │ │ │
+ ┌────────▼──────┐ ┌───────▼───────┐ ┌───────▼───────┐
+ │ dub-pp-mcp │ │ espn-pp-mcp │ │steam-web-pp- │
+ │ (subprocess) │ │ (subprocess) │ │mcp (subproc) │
+ └───────────────┘ └───────────────┘ └───────────────┘
+```
+
+**Startup flow:**
+1. Scan `~/printing-press/library/` for directories with `.printing-press.json`
+2. Filter to `MCPReady != ""` entries with pre-built MCP binaries
+3. For each: start MCP binary as subprocess, perform MCP handshake (`initialize` → response → `initialized`)
+4. Send `tools/list` to each initialized subprocess, collect tool schemas
+5. Register all tools in the mega MCP with `{normalized-slug}__` prefix
+6. Register `library_info` and `about` meta-tools
+7. Begin serving on stdio
+
+**Tool call flow:**
+1. Agent calls `dub__links_list` with arguments
+2. Mega MCP extracts prefix `dub`, tool name `links_list`
+3. Routes to the `dub` subprocess: sends `tools/call` with original tool name and arguments
+4. Returns the subprocess response to the agent
+
+## Implementation Units
+
+### Phase 1: Directory Restructure
+
+- [ ] **Unit 1: Change library directory key from CLI name to API slug**
+
+ **Goal:** New CLIs land in `~/printing-press/library/{api-slug}/` instead of `~/printing-press/library/{api-slug}-pp-cli/`. Existing directories with either naming continue to be discovered.
+
+ **Requirements:** R1, R2
+
+ **Dependencies:** None
+
+ **Files:**
+ - Modify: `internal/pipeline/pipeline.go` (`DefaultOutputDir`)
+ - Modify: `internal/cli/root.go` (lines 325-338: the `generate --spec` path renames output dir to `naming.CLI(apiSpec.Name)` — this would undo the slug-keyed change. Update `derivedDir` to use API slug instead of `naming.CLI()`)
+ - Modify: `internal/cli/library.go` (`scanLibrary` inclusion criteria)
+ - Modify: `internal/cli/emboss.go` (lines 196-211: library CLI resolution tries exact match then `naming.CLI(target)` — add `naming.TrimCLISuffix(target)` fallback for slug-keyed lookup)
+ - Modify: `internal/naming/naming.go` (add `IsLibraryDir()` that accepts slug-keyed dirs)
+ - Test: `internal/pipeline/pipeline_test.go`
+ - Test: `internal/cli/library_test.go`
+ - Test: `internal/naming/naming_test.go`
+
+ **Not modified:** `internal/pipeline/paths.go` (`WorkingCLIDir`) — this constructs ephemeral working directories during generation runs, not published library paths. The working dir naming (`naming.CLI()`) is unchanged. Also not modified: `internal/pipeline/dogfood.go` and `internal/pipeline/runtime.go` — their `IsCLIDirName` calls scan `cmd/` subdirectories within CLIs (binary names), not library directories.
+
+ **Approach:**
+ `DefaultOutputDir` changes from `filepath.Join(PublishedLibraryRoot(), naming.CLI(apiName))` to `filepath.Join(PublishedLibraryRoot(), apiName)`. `scanLibrary` already has the `entry.APIName != ""` branch that accepts manifest-bearing directories regardless of naming — this becomes the primary inclusion path. `IsCLIDirName` check stays as a fallback for legacy directories without manifests (unlikely but safe). Add `IsLibraryDir(name)` to naming package that returns true for valid API slugs (no path separators, no `..`, non-empty).
+
+ **This unit does NOT rename existing directories.** Old `dub-pp-cli/` directories continue to be discovered via backward-compat logic. Only newly published CLIs get slug-keyed directories. A `library migrate` command is deferred to a follow-up since backward-compat discovery means migration is cosmetic, not functional.
+
+ **Critical: `generate --spec` rename logic.** At `internal/cli/root.go:325-338`, the `--spec` code path explicitly renames the output directory to `naming.CLI(apiSpec.Name)` after generation. This would undo the `DefaultOutputDir` change for every `--spec` run without explicit `--output`. Update `derivedDir` to use the API slug directly instead of `naming.CLI()`. The `--docs` code path at lines 148-200 does NOT have this rename logic and works correctly as-is.
+
+ **Emboss library resolution.** At `internal/cli/emboss.go:196-211`, the emboss command resolves library CLIs by trying the exact target name, then `naming.CLI(target)`. After restructuring, `emboss dub-pp-cli` would try `naming.CLI("dub-pp-cli")` = `"dub-pp-cli-pp-cli"` (double-suffixed). Add a fallback: try `naming.TrimCLISuffix(target)` to resolve the slug.
+
+ **Patterns to follow:**
+ - `naming.IsCLIDirName()` pattern for the new `IsLibraryDir()`
+ - `scanLibrary()` manifest-first discovery pattern
+ - `PRINTING_PRESS_HOME` env var override in tests
+
+ **Test scenarios:**
+ - Happy path: `DefaultOutputDir("dub")` returns `~/printing-press/library/dub` (not `dub-pp-cli`)
+ - Happy path: `scanLibrary` finds a CLI at `library/dub/` with a valid manifest
+ - Happy path: `scanLibrary` finds a CLI at `library/dub-pp-cli/` with a valid manifest (backward compat)
+ - Happy path: `IsLibraryDir("dub")` returns true, `IsLibraryDir("dub-pp-cli")` returns true
+ - Edge case: `scanLibrary` ignores directories without manifests AND without CLI suffix (e.g., `library/.DS_Store/`)
+ - Edge case: `IsLibraryDir("")` returns false, `IsLibraryDir("../etc")` returns false
+ - Edge case: Rerun suffix directories (`library/dub-2/`) are discovered when they have manifests
+ - Edge case: `ClaimOutputDir` works correctly with slug-keyed base path (append `-2`, `-3`)
+
+ **Verification:** `go test ./internal/pipeline/... ./internal/cli/... ./internal/naming/...` passes. New CLIs published during a fullrun land in slug-keyed directories.
+
+- [ ] **Unit 2: Update publish pipeline for slug-keyed paths**
+
+ **Goal:** The publish pipeline writes to slug-keyed directories and updates module paths, branch names, and collision detection accordingly.
+
+ **Requirements:** R9
+
+ **Dependencies:** Unit 1
+
+ **Files:**
+ - Modify: `internal/pipeline/publish.go` (if any path construction uses `naming.CLI` for library dir)
+ - Modify: `internal/pipeline/renamecli.go` (directory rename logic — now renames API slug as directory, binary names as content)
+ - Modify: `internal/cli/publish.go` (the `publish package` command constructs `outCLIDir` using `cliName` — must use API slug from `manifest.APIName` instead; also `stashExistingCLI` search, `RewriteModulePath`, and module path construction)
+
+ **Not modified in this plan:** `skills/printing-press-publish/SKILL.md` — the skill's branch naming (`feat/dub-pp-cli` → `feat/dub`) and registry entry paths reference the public library repo, which is out of scope. Updating the skill before the public repo accepts slug-keyed directories would cause the skill to produce broken PRs. The skill update belongs in the public library migration PR.
+
+ **Not modified in this plan:** `internal/generator/templates/readme.md.tmpl` — the README template's `go install` path points at the public library repo, which is out of scope for this plan. Changing the template now would generate install paths referencing a directory layout (`library/<cat>/dub/...`) that doesn't exist in the public repo until the separate migration PR lands. The template change belongs in that PR.
+ - Test: `internal/pipeline/renamecli_test.go`
+
+ **Approach:**
+ `PublishWorkingCLI` already delegates to `DefaultOutputDir` (updated in Unit 1), so the local publish path changes automatically. `RenameCLI` needs two changes: (1) **Validation:** Modify `validateRenameInputs()` at `renamecli.go:162-165` to accept names that pass either `IsCLIDirName()` or `IsLibraryDir()` (from Unit 1). Currently it strictly requires `-pp-cli`/`-cli` suffix and would reject slug-keyed names like `dub`. (2) **Inner logic:** `RenameCLI`'s inner logic derives `cmd/` subdirectory names from `naming.TrimCLISuffix(oldCLIName)`. When the outer directory is slug-keyed (e.g., `dub`), the function must still receive the CLI name (e.g., `dub-pp-cli`) as the `oldCLIName` parameter so inner `cmd/` directory resolution works correctly. The slug is the directory key; the CLI name is the rename parameter.
+
+ **`internal/cli/publish.go` changes:** The `publish package` command at line ~290 constructs `outCLIDir = filepath.Join(dest, "library", category, cliName)` — this must change to use the API slug (from the manifest's `APIName` field) instead of `cliName`. The `stashExistingCLI` function searches by CLI name across categories — update to search by API slug. The `RewriteModulePath` call uses the directory path which now contains the slug. The `modulePath` flag's default also needs updating.
+
+ The publish skill's collision detection and branch naming are deferred to the public library migration PR (see "Not modified" note above).
+
+ **Patterns to follow:**
+ - Existing `RenameCLI` test patterns in `renamecli_test.go`
+ - README template auth-conditional blocks
+
+ **Test scenarios:**
+ - Happy path: `RenameCLI` renames a slug-keyed directory (e.g., `dub/` to `newname/`) and updates CLI/MCP binary names in content
+ - Happy path: `RenameCLI` with slug-keyed directory correctly renames inner `cmd/` subdirectories when passed the CLI name as the rename parameter
+ - Edge case: `RenameCLI` correctly handles the case where the old directory is CLI-name-keyed (backward compat during transition)
+
+ **Verification:** `go test ./internal/pipeline/...` passes.
+
+### Phase 2: Mega MCP Server
+
+- [ ] **Unit 3: Add `mcp-go` dependency and create `internal/megamcp/` package with discovery**
+
+ **Goal:** The mega MCP can scan the local library and build an in-memory catalog of all MCP-ready APIs with their metadata.
+
+ **Requirements:** R3, R7
+
+ **Dependencies:** Units 1-2 (slug-keyed paths make discovery cleaner, but the scanner works with both layouts)
+
+ **Files:**
+ - Modify: `go.mod` (add `github.com/mark3labs/mcp-go`)
+ - Create: `internal/megamcp/discovery.go`
+ - Create: `internal/megamcp/discovery_test.go`
+
+ **Approach:**
+ Add `mcp-go` as a direct dependency. Create the `internal/megamcp/` package with a `DiscoverAPIs(libraryRoot string) ([]APIEntry, error)` function. `APIEntry` contains:
+ - `Slug string` — API slug derived from the manifest's `APIName` field (e.g., `"dub"`)
+ - `Dir string` — absolute path to the library directory
+ - `Manifest pipeline.CLIManifest` — the full parsed manifest
+ - `MCPBinaryPath string` — absolute path to the pre-built MCP binary (resolved in Unit 5)
+ - `NormalizedPrefix string` — slug normalized to snake_case for tool namespacing (e.g., `"steam_web"`)
+
+ The `Manifest.MCPBinary` field (e.g., `"dub-pp-mcp"`) is set by `naming.MCP(apiName)` during generation. It matches the `cmd/{name}-pp-mcp/` source directory name exactly. This is guaranteed by the generator — both `cmd/` directory creation and manifest population use `naming.MCP()`.
+
+ Discovery iterates `libraryRoot`, reads `.printing-press.json` from each directory (reusing `pipeline.CLIManifest` struct), and filters to entries where `MCPReady` is non-empty and `MCPBinary` is non-empty. Uses `errgroup.Group` (not `WithContext`) so a broken manifest doesn't block discovery of other CLIs. Applies path traversal validation from the institutional learning: reject directory names containing `..`, `/`, or `\`.
+
+ `NormalizedPrefix` is computed from the slug: hyphens → underscores, consecutive underscores collapsed, validated to not contain `__` (which would collide with the tool name separator).
+
+ **Binary resolution (merged from former Unit 4):** After manifest parsing, `ResolveMCPBinary(entry *APIEntry) error` searches for the pre-built MCP binary within the library directory. Search order: (1) `{dir}/cmd/{mcp-binary}/{mcp-binary}` (standard `go build` output), (2) `{dir}/{mcp-binary}` (Makefile output to project root). The `mcp-binary` name comes from `entry.Manifest.MCPBinary` (e.g., `dub-pp-mcp`), which is set by `naming.MCP(apiName)` during generation (Step 1, PR #145). This field is guaranteed to match the `cmd/{name}-pp-mcp/` directory name.
+
+ `FilterReady(entries []APIEntry) (ready []APIEntry, warnings []string)` returns only entries with resolvable binaries. For each entry without a binary, it returns a warning message telling the user exactly how to build it: `cd ~/printing-press/library/{slug} && go build -o ./cmd/{name}-pp-mcp/{name}-pp-mcp ./cmd/{name}-pp-mcp`. This is deliberately minimal — no build cache, no `go build` invocation, no Go toolchain dependency.
+
+ **Patterns to follow:**
+ - `scanLibrary()` in `internal/cli/library.go` — directory iteration and manifest parsing pattern
+ - `errgroup.Group` without context per multi-source discovery learning
+ - `os.Stat` for binary existence checking
+ - `PRINTING_PRESS_HOME` env var in tests
+
+ **Test scenarios:**
+ - Happy path: Library with 3 CLIs (full, partial, cli-only readiness) → returns 2 entries (full + partial), excludes cli-only
+ - Happy path: CLI in slug-keyed directory (`library/dub/`) discovered correctly
+ - Happy path: CLI in CLI-name-keyed directory (`library/dub-pp-cli/`) discovered correctly
+ - Happy path: API slug `steam-web` → NormalizedPrefix `steam_web`
+ - Happy path: Binary exists at `cmd/{name}-pp-mcp/{name}-pp-mcp` → resolved
+ - Happy path: Binary exists at project root `{name}-pp-mcp` → resolved
+ - Edge case: Empty library → returns empty slice, no error
+ - Edge case: Library with a directory missing `.printing-press.json` → skipped with no error
+ - Edge case: Library with a corrupt manifest JSON → skipped with warning, other CLIs still discovered
+ - Edge case: Directory name contains `..` → rejected (traversal protection)
+ - Edge case: Binary missing → excluded with actionable warning message
+ - Edge case: Binary exists but is not executable → excluded with warning
+
+ **Verification:** `go test ./internal/megamcp/...` passes. `go mod tidy` succeeds.
+
+- [ ] **Unit 4: Subprocess manager**
+
+ **Goal:** The mega MCP can start, monitor, and communicate with per-API MCP subprocesses over stdio.
+
+ **Requirements:** R5, R6
+
+ **Dependencies:** Unit 3
+
+ **Files:**
+ - Create: `internal/megamcp/subprocess.go`
+ - Create: `internal/megamcp/subprocess_test.go`
+
+ **Approach:**
+ `SubprocessManager` manages the lifecycle of per-API MCP subprocesses. Each subprocess is a `*exec.Cmd` running the pre-built MCP binary with stdin/stdout piped for JSON-RPC communication.
+
+ **First task in this unit:** Verify `mcp-go` client support. Check whether `mark3labs/mcp-go` provides `client.NewStdioMCPClient` or equivalent. If it does, use it — it will handle the MCP initialization handshake, request-response correlation, and JSON-RPC framing. If not, implement a lightweight MCP client (~200-300 lines) that handles: (a) the three-message initialization handshake (`initialize` request → server response → `initialized` notification), (b) JSON-RPC 2.0 request/response correlation via `id` field, (c) timeout per request.
+
+ `Start(entry APIEntry) (*ManagedProcess, error)` starts the MCP binary with a credential-isolated environment (full parent env minus other APIs' `AuthEnvVars` per R6), then performs the MCP initialization handshake. The subprocess is not considered ready until the handshake completes successfully. `Stop(slug string)` sends SIGTERM and waits. `StopAll()` stops all subprocesses (called at mega MCP shutdown).
+
+ `SendRequest(slug string, method string, params json.RawMessage) (json.RawMessage, error)` sends a JSON-RPC request to a subprocess and reads the response. Used for both `tools/list` (at startup) and `tools/call` (at runtime). Includes a timeout to prevent hanging on unresponsive subprocesses.
+
+ **Concurrency model:** Per-subprocess communication is **multiplexed** using JSON-RPC `id` correlation. The `SubprocessManager` maintains a `map[int]chan json.RawMessage` of pending requests and a goroutine per subprocess reading stdout responses and dispatching them to the correct channel by `id`. This allows concurrent tool calls to the same API (~50 lines beyond what a serialized approach would need). Multiplexing is chosen over serialization because: (1) the mega MCP aggregates N APIs behind one connection — head-of-line blocking from a slow upstream API would stall all subsequent calls to that API, (2) the JSON-RPC id correlation is already needed for the MCP handshake, and (3) retrofitting multiplexing later requires rewriting the core I/O path.
+
+ **Stdout reader dispatch logic:** The reader goroutine must handle three message types: (1) JSON-RPC responses with `id` field → dispatch to matching pending request channel, (2) JSON-RPC notifications without `id` field (MCP servers may send progress, logging, resource update notifications at any time) → discard or route to a notification handler, (3) non-JSON lines (Go panics, diagnostic output) → skip and log. Restart is protected by a per-subprocess mutex so concurrent crash detection results in exactly one restart attempt.
+
+ If a subprocess has died when a tool call arrives, the manager attempts one restart (including re-running the MCP handshake). If the restart fails, the subprocess is excluded until the next mega MCP restart. Crash events are logged visibly to stderr with the subprocess name and timestamp. The manager does NOT silently retry the tool call that caused the crash — it returns an error to the agent. The next distinct tool call triggers the restart attempt.
+
+ **Subprocess stderr:** Explicitly set `cmd.Stderr` to a per-subprocess log buffer or file — do NOT inherit the mega MCP's stderr (which would leak per-API diagnostic messages, potentially including credential-shaped strings, into the mega MCP's output stream). The stdout reader must handle non-JSON lines gracefully (skip and log) since Go panics and diagnostic output may appear on stdout despite best efforts.
+
+ **Patterns to follow:**
+ - `exec.Command` with `StdinPipe()` and `StdoutPipe()` for bidirectional communication
+ - JSON-RPC 2.0 message format matching `mcp-go`'s wire protocol — verify framing (newline-delimited JSON vs LSP-style Content-Length headers) by reading `mcp-go`'s `ServeStdio` source at the start of this unit
+ - MCP specification initialization handshake: `initialize` (client→server), response (server→client), `initialized` notification (client→server)
+
+ **Test scenarios:**
+ - Happy path: Start a subprocess, MCP handshake completes, send `tools/list`, receive tool list response
+ - Happy path: Send `tools/call` with arguments, receive tool result
+ - Happy path: Subprocess receives its own AuthEnvVars but NOT other APIs' AuthEnvVars (DUB_TOKEN present for Dub, STEAM_WEB_API_KEY absent; system/networking vars like PATH, HTTPS_PROXY preserved)
+ - Happy path: Concurrent tool calls to same API are multiplexed (both in-flight simultaneously, responses matched by id)
+ - Edge case: MCP handshake fails (subprocess returns error) → subprocess excluded with warning
+ - Edge case: Subprocess crashes → crash logged to stderr, error returned to agent, next tool call triggers one restart attempt
+ - Edge case: Subprocess unresponsive → request times out with error
+ - Edge case: Stop all subprocesses → all processes terminated, no zombies
+ - Edge case: Subprocess fails to restart → excluded from routing with warning
+ - Integration: Full round-trip with a real (simple) MCP binary — verify initialization handshake + tool call correctness
+
+ **Verification:** `go test ./internal/megamcp/...` passes. No zombie processes after test cleanup.
+
+- [ ] **Unit 5: Tool aggregation and routing**
+
+ **Goal:** The mega MCP discovers tools from all sub-MCPs, registers them with namespaced names, and routes incoming tool calls to the correct subprocess.
+
+ **Requirements:** R5
+
+ **Dependencies:** Unit 4
+
+ **Files:**
+ - Create: `internal/megamcp/router.go`
+ - Create: `internal/megamcp/router_test.go`
+
+ **Approach:**
+ `ToolRouter` is initialized at startup. For each running subprocess, it sends `tools/list` and collects the tool schemas. Each tool is re-registered in the mega MCP with the name `{normalized-prefix}__{original-name}` (double underscore separator). The router maintains a mapping from prefixed tool name → (normalized prefix, original tool name).
+
+ When a tool call arrives:
+ 1. Parse the prefix: split on `__` to extract the normalized prefix and original tool name
+ 2. Validate the prefix exists in the router
+ 3. Forward the call to the correct subprocess via `SendRequest("tools/call", ...)`
+ 4. Return the subprocess response to the agent
+
+ **Tool name collision detection:** At registration time, validate that no prefixed tool name is ambiguous. The `__` separator could theoretically collide if an original tool name starts with a known prefix + `_` (e.g., API slug `user` + tool `accounts_list` → `user__accounts_list`, but tool `user_accounts_list` from a different API with slug `user` would produce the same prefixed name). The router validates at registration time that all prefixed names are unique and logs a warning for any collision. If a tool name itself contains `__`, the router splits on the first occurrence only.
+
+ **Tool count awareness:** With 6 CLIs and ~270 tools, MCP clients may experience degraded tool selection. The plan ships with all tools registered at startup. If tool count becomes a UX problem at scale, a follow-up can implement lazy tool registration (register only meta-tools initially, dynamically register per-API tools on agent intent via a `use_api` meta-tool). This is documented as a known tradeoff.
+
+ Traversal protection: the normalized prefix extracted from the tool name is validated against the known set of prefixes — it never flows into path construction.
+
+ **Patterns to follow:**
+ - `mcp-go` tool registration via `server.AddTool()`
+ - Existing `makeAPIHandler` pattern in `mcp_tools.go.tmpl` for handler function signatures
+
+ **Test scenarios:**
+ - Happy path: Tools from 3 APIs registered with correct prefixes → `dub__links_list`, `espn__get_scores`, etc.
+ - Happy path: Tool call `dub__links_list` routed to dub subprocess with original name `links_list`
+ - Happy path: Tool call arguments forwarded correctly to subprocess
+ - Edge case: Tool call with unknown prefix → returns MCP error "unknown API: xyz"
+ - Edge case: Tool call with unknown tool name for a known API → forwards to subprocess (let the sub-MCP handle the error)
+ - Edge case: Tool name containing `__` within the original name → split on first occurrence only
+ - Edge case: Subprocess died → router returns error to agent, next distinct call triggers restart attempt
+
+ **Verification:** `go test ./internal/megamcp/...` passes. End-to-end tool call routing works with mock subprocess.
+
+- [ ] **Unit 6: `library_info` and `about` meta-tools**
+
+ **Goal:** The mega MCP exposes discovery tools that let agents learn what APIs are available, their capabilities, and auth requirements.
+
+ **Requirements:** R4, R6
+
+ **Dependencies:** Unit 5
+
+ **Files:**
+ - Create: `internal/megamcp/metatools.go`
+ - Create: `internal/megamcp/metatools_test.go`
+
+ **Approach:**
+ Two meta-tools registered directly in the mega MCP (not routed to subprocesses):
+
+ **`library_info`**: Returns a JSON object listing all discovered APIs:
+ ```
+ {
+ "apis": [
+ {
+ "slug": "dub",
+ "name": "Dub",
+ "description": "Short link management",
+ "tool_count": 53,
+ "public_tool_count": 0,
+ "auth_type": "api_key",
+ "auth_env_vars": ["DUB_TOKEN"],
+ "mcp_ready": "full",
+ "auth_configured": true,
+ "novel_features": [...]
+ },
+ ...
+ ],
+ "total_tools": 270,
+ "total_apis": 6
+ }
+ ```
+ The `auth_configured` field checks `os.Getenv()` for each API's env vars — returning a boolean "ready" status per API without naming which specific env vars are or aren't set. This prevents `library_info` from serving as a credential reconnaissance tool if a prompt injection reaches the agent. The `auth_env_vars` array lists what's needed (from the manifest); `auth_configured` says whether it's satisfied.
+
+ **`about`**: Returns metadata about the mega MCP itself — version, library root, total API count, total tool count. Matches the pattern of per-API `about` tools from Step 1.
+
+ **Patterns to follow:**
+ - Per-API `about` tool pattern from `mcp_tools.go.tmpl`
+ - `CLIManifest` field access patterns
+
+ **Test scenarios:**
+ - Happy path: `library_info` returns all discovered APIs with correct metadata
+ - Happy path: `library_info` shows `auth_configured: true` when env var is set
+ - Happy path: `library_info` shows `auth_configured: false` when env var is missing
+ - Happy path: `about` returns mega MCP version and summary stats
+ - Edge case: No APIs discovered → `library_info` returns empty `apis` array with `total_tools: 0`
+
+ **Verification:** `go test ./internal/megamcp/...` passes. Meta-tool responses are valid JSON.
+
+- [ ] **Unit 7: Entry point, distribution, and setup**
+
+ **Goal:** The mega MCP is buildable, distributable, and installable with a single `claude mcp add` command.
+
+ **Requirements:** R8
+
+ **Dependencies:** Unit 6
+
+ **Files:**
+ - Create: `cmd/printing-press-mcp/main.go`
+ - Modify: `internal/version/version.go` (reuse for mega MCP version reporting)
+
+ **Approach:**
+ `main.go` creates an `mcp-go` server, runs the discovery + subprocess startup pipeline, registers the aggregated tools and meta-tools, and serves on stdio via `server.ServeStdio()`. Graceful shutdown on SIGINT/SIGTERM stops all subprocesses.
+
+ **Initial distribution: `go install` only.** Do NOT add a goreleaser entry yet. The mega MCP requires per-API MCP binaries to be pre-built in the library, which only works when the user has Go installed (the `go install` path). Adding goreleaser would distribute a standalone binary to users who may not have Go, resulting in a mega MCP that silently excludes all APIs. Goreleaser distribution is a follow-up that depends on pre-building MCP binaries during the publish pipeline.
+
+ Install and usage:
+ ```
+ # Install
+ go install github.com/mvanhorn/cli-printing-press/cmd/printing-press-mcp@latest
+
+ # Add to Claude Code
+ claude mcp add printing-press -- printing-press-mcp
+
+ # Add to Claude Desktop (config.json)
+ { "mcpServers": { "printing-press": { "command": "printing-press-mcp" } } }
+ ```
+
+ The mega MCP reuses `internal/version.Version` for its version string so it stays in sync with the printing-press binary across releases.
+
+ **Patterns to follow:**
+ - `cmd/printing-press/main.go` entry point pattern
+ - `main_mcp.go.tmpl` for `mcp-go` server setup
+
+ **Test scenarios:**
+ - Happy path: `go build ./cmd/printing-press-mcp` succeeds
+ - Happy path: `printing-press-mcp --help` or version flag works (if supported by mcp-go server)
+ - Integration: Start mega MCP with a test library containing 2 CLIs → `tools/list` returns all tools prefixed, `library_info` returns catalog
+
+ **Verification:** `go build -o ./printing-press-mcp ./cmd/printing-press-mcp` succeeds. Binary starts and serves MCP on stdio.
+
+## System-Wide Impact
+
+- **Interaction graph:** The mega MCP introduces a new process tree: `printing-press-mcp` → N per-API MCP subprocesses. Claude Desktop/Code communicates with the mega MCP via stdio; the mega MCP communicates with sub-MCPs via stdio. The per-API MCP servers make HTTP requests to their respective APIs (unchanged).
+- **Error propagation:** Subprocess errors (crashed process, build failure, timeout) are caught at the mega MCP layer and returned as MCP tool errors. Individual API failures do not crash the mega MCP or affect other APIs.
+- **State lifecycle risks:** The subprocess manager holds process handles that must be cleaned up on shutdown. If the mega MCP crashes without cleanup, orphan processes may linger. Graceful shutdown handlers and process group management mitigate this.
+- **API surface parity:** The mega MCP's tool list should exactly match the union of all per-API MCP tool lists (plus meta-tools). Any discrepancy means the discovery or registration logic has a bug.
+- **Unchanged invariants:** Per-API MCP servers (`dub-pp-mcp`, `espn-pp-mcp`) continue to work independently. The mega MCP is additive — it does not modify or replace per-API MCP functionality. Users who prefer individual MCP connections can still use them.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| `mcp-go` lacks client-side support for communicating with sub-MCPs | Implement a JSON-RPC client with MCP handshake support (~200-300 lines). Verify `mcp-go` client support at start of Unit 6 before committing to custom implementation. |
+| Pre-built MCP binaries not present in library | Clear error messages with exact build commands. Initially scoped to `go install` users (who have Go). Follow-up: pre-build during publish pipeline to support binary-only distribution. |
+| Subprocess startup time for large libraries | With 6 CLIs, startup should be <10s. If the library grows to 50+, consider lazy subprocess startup (start on first tool call, not at boot). |
+| Orphan processes on ungraceful shutdown | Primary mitigation: sub-MCPs detect parent death via stdin EOF (mcp-go's `ServeStdio` exits when stdin closes, which happens when the parent dies). Secondary: SIGINT/SIGTERM handler calls `StopAll()`. Known limitation: SIGKILL leaves no cleanup window — document that orphan processes are possible after hard kills. macOS has no `PR_SET_PDEATHSIG`; stdin EOF is the cross-platform mechanism. |
+| Breaking `go install` for existing users after directory restructure | Old `go install .../dub-pp-cli/cmd/dub-pp-cli@latest` paths stop working. The migration PR in the public library repo must document this. Binary names are unchanged — only the directory component of the install path changes. |
+| Directory restructure conflicts with in-flight publish PRs | Coordinate: ensure no publish PRs are open when the public library migration PR lands. The 6 existing CLIs are stable per the user's input. |
+
+## Phased Delivery
+
+### Phase 1: Directory Restructure (Units 1-2)
+Can be shipped as its own PR. Unblocks Phase 2 but also stands alone as a correctness improvement (manuscripts already slug-keyed; library should match).
+
+### Phase 2: Mega MCP Server (Units 3-7)
+Depends on Phase 1 for clean discovery, but the mega MCP scanner works against either layout via backward-compat logic. Can be shipped as one PR or split into two (discovery/subprocess in one, routing/meta-tools/entry-point in another).
+
+**Prerequisite for testing:** Existing library CLIs were generated before Step 1 (PR #145) and lack MCP metadata. At least 2-3 CLIs must be regenerated (or embossed) with the MCP readiness layer before the mega MCP can be validated against real data. Integration tests in Unit 7 should use both synthetic test fixtures and at least one real regenerated CLI.
+
+### Follow-up (not in this plan)
+- `library migrate` command — rename existing `dub-pp-cli/` directories to `dub/` (cosmetic since backward-compat discovery works without it)
+- Public library repo migration (rename directories, update registry.json paths, update go.mod module paths)
+- Goreleaser distribution of `printing-press-mcp` (depends on pre-built MCP binaries)
+- Build MCP binaries at publish time (avoid runtime Go toolchain requirement, enable goreleaser distribution)
+- Build-on-demand cache for MCP binaries (convenience for users who modify library source)
+- MCP binary integrity verification — record SHA-256 hash in manifest at publish time, verify before execution at startup
+- Lazy tool registration — register only meta-tools at startup, dynamically register per-API tools on agent intent (addresses tool count scaling at 50+ APIs)
+- Lazy subprocess startup for large libraries (start on first tool call, idle timeout to reclaim memory)
+- Live library watch (detect newly published CLIs without restart)
+- Marketplace listing for the mega MCP
+
+## Sources & References
+
+- Related PR: mvanhorn/cli-printing-press#145 (Step 1: MCP readiness layer)
+- Related plan: `docs/plans/2026-04-05-001-feat-mcp-readiness-layer-plan.md`
+- Institutional learnings:
+ - `docs/solutions/best-practices/checkout-scoped-printing-press-output-layout` (layout contract)
+ - `docs/solutions/security-issues/filepath-join-traversal-with-user-input` (traversal protection)
+ - `docs/solutions/best-practices/validation-must-not-mutate-source-directory` (immutable source)
+ - `docs/solutions/best-practices/multi-source-api-discovery-design` (errgroup pattern)
diff --git a/docs/plans/2026-04-06-003-feat-mega-mcp-generic-proxy-plan.md b/docs/plans/2026-04-06-003-feat-mega-mcp-generic-proxy-plan.md
new file mode 100644
index 00000000..6419ab77
--- /dev/null
+++ b/docs/plans/2026-04-06-003-feat-mega-mcp-generic-proxy-plan.md
@@ -0,0 +1,460 @@
+---
+title: "feat: Mega MCP — Generic HTTP Proxy with Activation Model"
+type: feat
+status: active
+date: 2026-04-06
+origin: docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md
+---
+
+# feat: Mega MCP — Generic HTTP Proxy with Activation Model
+
+## Overview
+
+A single MCP server binary (`printing-press-mcp`) that gives agents access to every API in the printing press library through one install. The mega MCP fetches pre-computed tool manifests from the public library repo on GitHub, registers tools on demand via an activation model, and proxies HTTP requests directly to APIs — no local library, no subprocess management, no Go toolchain at runtime.
+
+This plan has two phases:
+1. **Publish pipeline prerequisites** — generate `tools-manifest.json` per API at publish time, extend the registry schema
+2. **Mega MCP binary** — new binary that fetches manifests, registers meta-tools, proxies HTTP calls
+
+## Problem Frame
+
+Every printed CLI ships an MCP binary, but each requires individual installation via `go install` of a long GitHub path. With 6 CLIs and a growing catalog, this friction prevents adoption. The mega MCP reduces setup to one `claude mcp add` command for all APIs. (see origin: `docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md`)
+
+## Requirements Trace
+
+**Distribution:** R1 (single binary), R2 (one-command setup), R3 (marketplace-ready)
+**Discovery:** R4 (registry fetch), R5 (manifest fetch + cache), R6 (library_info), R7 (setup_guide), R8 (activate_api), R9 (search_tools)
+**Routing:** R10 (tool registration from manifest), R11 (generic HTTP handler with param classification), R12 (startup latency)
+**Auth:** R13 (format string expansion), R14 (auth type support), R15 (credential isolation), R16 (error handling)
+**Security:** R17 (integrity verification), R18 (URL validation), R19 (text sanitization)
+**Scaling:** R20 (API filter), R21 (activation model scaling)
+
+## Scope Boundaries
+
+- **In scope:** Generic HTTP proxy, activation model, manifest generation, registry extension, marketplace metadata
+- **Out of scope:** Subprocess/hybrid mode, novel features (sync/search/SQL), directory restructure, hot-reloading, interactive OAuth, adaptive rate limiting
+- **Known limitation:** Cookie/composed auth APIs expose only public endpoints. Tool descriptions include per-API MCP install instructions for full access.
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Publish pipeline:** `internal/pipeline/publish.go` — `writeCLIManifestForPublish()` has access to the parsed spec and writes `.printing-press.json`. The new `writeToolsManifest()` goes alongside it at the same call sites: `PublishWorkingCLI()` and `PromoteWorkingCLI()`.
+- **CLIManifest:** `internal/pipeline/climanifest.go` — already has `SpecChecksum`, `SpecFormat`, `MCPReady`, `AuthType`, `AuthEnvVars`. `populateMCPMetadata()` at line 104 shows how to compute MCP fields from a parsed spec.
+- **MCP tool registration:** `internal/generator/templates/mcp_tools.go.tmpl` — `RegisterTools()` iterates `Resources/SubResources/Endpoints`, registers each via `s.AddTool()`. Tool names: `{{snake $name}}_{{snake $eName}}`. `makeAPIHandler()` at line 148 is the generic HTTP handler pattern.
+- **Auth config:** `internal/spec/spec.go` `AuthConfig` struct — `Type`, `Header`, `Format`, `In`, `EnvVars`, `KeyURL`. Generated `config.go.tmpl` expands format strings via `applyAuthFormat()`.
+- **RequiredHeaders:** `internal/spec/spec.go` `RequiredHeader` struct — `Name`, `Value`. Applied at `client.go.tmpl:368-370`. Per-endpoint `HeaderOverrides` at line 372.
+- **HTTP client:** `internal/generator/templates/client.go.tmpl` — `do()` method handles auth injection, required headers, retries, error classification (401/403/429/5xx).
+- **Parameter classification:** Implicit in templates — `Positional == true` means path param, others are query (GET) or body (POST/PUT/PATCH). `Endpoint.Params` vs `Endpoint.Body` in `spec.go:66-78`.
+- **Naming:** `internal/naming/naming.go` — `CLI()`, `MCP()`. `toSnakeCase()` at `internal/openapi/schema_builder.go:362` handles hyphens (needed for slug normalization).
+- **Registry schema:** `skills/printing-press-publish/SKILL.md` lines 566-587 — current schema has `name`, `category`, `api`, `description`, `path`, `mcp` block.
+- **Goreleaser:** `.goreleaser.yaml` — single binary build. Needs second entry for `printing-press-mcp`.
+- **Catalog embed:** `catalog/catalog.go` — `//go:embed *.yaml` pattern. Not needed for mega MCP (fetches at runtime) but informs the caching pattern.
+- **Test patterns:** `setPressTestEnv(t)` for isolation, `t.TempDir()` for filesystem tests, table-driven with `testify/assert`.
+
+### Institutional Learnings
+
+- **Filepath traversal:** Belt-and-suspenders — reject `..`/`/`/`\` in inputs AND verify resolved path is under expected root. Applies to cache path construction from API slugs. (`docs/solutions/security-issues/filepath-join-traversal-with-user-input`)
+- **Multi-source discovery:** Use `errgroup.Group` (not `WithContext`) for parallel manifest fetches. Injectable base URLs for testable HTTP clients. HTTPS-only enforcement. (`docs/solutions/best-practices/multi-source-api-discovery-design`)
+- **Immutable source:** Cache writes via temp-then-rename to prevent partial reads on restart. (`docs/solutions/best-practices/validation-must-not-mutate-source-directory`)
+
+## Key Technical Decisions
+
+- **Tools manifest generated at publish time, not runtime spec parsing:** The publish pipeline writes `tools-manifest.json` alongside `.printing-press.json`. Contains tool names, descriptions, parameter schemas with location (path/query/body), auth config, base URL, required headers. Format-agnostic — works for OpenAPI, internal YAML, GraphQL, and sniffed specs. Eliminates runtime kin-openapi dependency and parser global state concerns.
+
+- **Activation model with `tools/list_changed`:** Only ~6 meta-tools registered at startup. Agent calls `activate_api("espn")` → tools registered → `tools/list_changed` notification sent to client. If `mcp-go` v0.26+ doesn't support `tools/list_changed`, fall back to re-registering tools and documenting that the client may need to re-fetch. Verify at the start of Unit 5 implementation.
+
+- **Auth format string expansion at runtime:** The manifest contains `auth.format` (e.g., `Bearer {DUB_TOKEN}`), `auth.header` (e.g., `Authorization`), `auth.in` (header/query). At runtime: read env var value, substitute into format string, set on the correct header or query param. Validate format strings at manifest load time — reject patterns that would place credentials in URL path segments.
+
+- **Parameter classification from manifest, not guessed at runtime:** Each tool parameter in the manifest has an explicit `location` field: `path`, `query`, or `body`. The generic HTTP handler uses this directly — no heuristics needed. Path params substituted into URL template, query params appended to URL, body params serialized as JSON. This fixes the existing template bug where POST/PUT/PATCH sends path params in the body.
+
+- **Two different snake functions for two different jobs:** Tool name segments use `toSnake()` from `generator.go:794` (CamelCase → snake_case only, does NOT convert hyphens). API slug prefixes use `toSnakeCase()` from `schema_builder.go:362` (also converts hyphens → underscores). `WriteToolsManifest` must use `toSnake` for resource/endpoint names (matching the MCP template's `{{snake}}` function) and `toSnakeCase` behavior for the slug prefix. The slug normalizer lives in `internal/megamcp/` as a private function (no second consumer justifies exporting to the naming package yet). Collapse consecutive underscores, reject if result contains `__`.
+
+- **Credential isolation via separate HTTP clients:** Each API gets its own `http.Client` (or at minimum its own auth configuration). The generic handler reads only the env vars declared in that API's manifest `auth.env_vars`. Startup-time collision check warns if two APIs share an env var name.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Where do tools manifests live?** → In the public library repo at `{path}/tools-manifest.json`, fetched via GitHub raw content URL. Our copies, not third-party URLs.
+- **Which `toSnake` function for slug normalization?** → `toSnakeCase()` from `schema_builder.go:362` (handles hyphens). Export as `naming.SlugToToolPrefix()`.
+- **Where does `writeToolsManifest()` go?** → In `internal/pipeline/publish.go`, called from the same sites as `writeCLIManifestForPublish()`: `PublishWorkingCLI()` and `PromoteWorkingCLI()`.
+- **How does the mega MCP know param location (path/query/body)?** → Explicit `location` field in the tools manifest. Derived from `Param.Positional` (true = path) and `Endpoint.Body` (body params) at manifest generation time.
+
+### Deferred to Implementation
+
+- **`mcp-go` `tools/list_changed` support** — verify at start of Unit 5. If unsupported, the agent can instruct the user to refresh, or the mega MCP can return the tool list in the `activate_api` response text as a workaround.
+- **Smithery marketplace listing requirements** — needs research. Does it need Docker, or can it reference a Go binary?
+- **Category-based filtering** — whether `PRINTING_PRESS_CATEGORIES` is needed in addition to `PRINTING_PRESS_APIS`. Defer to user feedback.
+
+## High-Level Technical Design
+
+> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
+
+```
+User: claude mcp add printing-press -- printing-press-mcp
+ │
+ ▼
+ ┌──────────────────────┐
+ │ printing-press-mcp │
+ │ (Mega MCP Server) │
+ │ │
+ │ Startup: │
+ │ 1. Fetch registry │
+ │ 2. Fetch manifests │◄── GitHub raw content
+ │ 3. Cache locally │ (our copies)
+ │ 4. Register 6 meta- │
+ │ tools only │
+ │ │
+ │ Runtime: │
+ │ library_info → catalog│
+ │ activate_api(espn) → │
+ │ register tools + │
+ │ tools/list_changed │
+ │ espn__scores_get → │
+ │ generic HTTP GET │──► https://site.api.espn.com/...
+ │ to API │◄── JSON response
+ │ return to agent │
+ └──────────────────────┘
+```
+
+**Activation flow:**
+1. Agent calls `library_info` → sees 6 APIs with descriptions and auth status
+2. Agent calls `activate_api("espn")` → loads ESPN manifest, registers ~3 tools with `espn__` prefix
+3. Agent calls `espn__scores_get` → mega MCP substitutes path params, makes GET request, returns response
+4. Agent calls `setup_guide("dub")` → gets "Set DUB_TOKEN via `claude mcp add --env DUB_TOKEN=xxx`"
+
+## Implementation Units
+
+### Phase 1: Publish Pipeline Prerequisites
+
+- [ ] **Unit 1: Generate tools-manifest.json at publish time**
+
+ **Goal:** The publish pipeline writes a `tools-manifest.json` per API containing everything the mega MCP needs to register and execute tools — without runtime spec parsing.
+
+ **Requirements:** R5, R10, R11, R13
+
+ **Dependencies:** None
+
+ **Files:**
+ - Create: `internal/pipeline/toolsmanifest.go`
+ - Modify: `internal/pipeline/publish.go` (call `WriteToolsManifest` from inside `writeCLIManifestForPublish` after spec parsing)
+ - Test: `internal/pipeline/toolsmanifest_test.go`
+
+ **Approach:**
+ `WriteToolsManifest(dir string, parsed *spec.APISpec) error` iterates the same `Resources/SubResources/Endpoints` hierarchy the MCP template uses, with map keys sorted alphabetically for deterministic JSON output (critical for stable `manifest_checksum` values across re-publishes). For each endpoint, emits a tool entry with: name (same `snake(resource)_snake(endpoint)` convention), description (with `mcpDescription` minority-side annotations), method, path template, `no_auth` flag, and parameters with explicit `location` field.
+
+ Parameter location is derived from existing spec data: `Param.Positional == true` → `"path"`, params from `Endpoint.Body` → `"body"`, all others → `"query"`. This makes explicit what the template currently leaves implicit.
+
+ API-level metadata includes: `api_name`, `base_url` (from `spec.BaseURL`), full `auth` config (type, header, format, in, env_vars, key_url), `required_headers`, and `mcp_ready`.
+
+ The function is called from a single site: inside `writeCLIManifestForPublish()` after the spec parsing block (after `publish.go:248`), where the `parsed` variable is available. Both `PublishWorkingCLI()` and `PromoteWorkingCLI()` flow through `writeCLIManifestForPublish`, so both paths get the tools manifest automatically. If `parsed` is nil (e.g., `--docs` mode, no spec.json), `WriteToolsManifest` is skipped — no manifest generated, and the API is invisible to the mega MCP.
+
+ `SlugToToolPrefix` in the naming package converts API slugs to tool name prefixes: hyphens → underscores, consecutive underscores collapsed, rejects if result contains `__`.
+
+ **Patterns to follow:**
+ - `writeCLIManifestForPublish()` at `publish.go:176` — manifest writing pattern
+ - `mcp_tools.go.tmpl` `RegisterTools()` — resource/endpoint iteration and tool naming
+ - `mcpDescription()` template function in `generator.go` — minority-side auth annotation logic
+ - `populateMCPMetadata()` at `climanifest.go:104` — spec-to-manifest data flow
+
+ **Test scenarios:**
+ - Happy path: OpenAPI spec with 3 resources, 10 endpoints → manifest has 10 tool entries with correct names, methods, paths
+ - Happy path: Sub-resource endpoints included (`guild_members_list`)
+ - Happy path: Each param has explicit `location` field — positional params marked `"path"`, body params marked `"body"`, others `"query"`
+ - Happy path: Auth config serialized completely (type, header, format, in, env_vars, key_url)
+ - Happy path: RequiredHeaders and per-endpoint HeaderOverrides included
+ - Happy path: NoAuth endpoints flagged correctly
+ - Happy path: `SlugToToolPrefix("steam-web")` → `"steam_web"`, `SlugToToolPrefix("dub")` → `"dub"`
+ - Edge case: API with no auth (type "none") → auth block present but minimal
+ - Edge case: Cookie/composed auth → only NoAuth endpoints in manifest tools list
+ - Edge case: `SlugToToolPrefix("steam--web")` → `"steam_web"` (consecutive underscores collapsed)
+ - Edge case: API with empty description → manifest still valid
+ - Edge case: Round-trip: write manifest, read back, all fields preserved
+
+ **Verification:** `go test ./internal/pipeline/... ./internal/naming/...` passes. A generated `tools-manifest.json` for the petstore test fixture contains all expected tools with correct parameter locations.
+
+- [ ] **Unit 2: Extend registry schema and publish skill**
+
+ **Goal:** Registry entries include `manifest_checksum`, `spec_format`, and `manifest_url` so the mega MCP can fetch and verify tools manifests.
+
+ **Requirements:** R4, R5, R17
+
+ **Dependencies:** Unit 1
+
+ **Files:**
+ - Modify: `skills/printing-press-publish/SKILL.md` (add fields to registry entry schema in Step 8)
+ - Modify: `internal/pipeline/climanifest.go` (ensure `SpecChecksum` and `SpecFormat` are always populated when a spec is available)
+
+ **Approach:**
+ The registry entry's `mcp` block gains three fields:
+ - `manifest_checksum`: SHA-256 hash of the `tools-manifest.json` file, computed at publish time. Distinct from `CLIManifest.SpecChecksum` which hashes the raw spec file — different artifacts, different checksums
+ - `spec_format`: from `CLIManifest.SpecFormat` (openapi3, graphql, internal)
+ - `manifest_url`: derived from the entry's `path` field: `{path}/tools-manifest.json`
+
+ The publish skill's Step 8 (registry entry construction) is updated to include these fields. The skill reads them from the CLI manifest which is already written by this point in the pipeline.
+
+ `manifest_url` is a relative path within the library repo, not a full URL. The mega MCP constructs the full GitHub raw content URL at runtime: `https://raw.githubusercontent.com/mvanhorn/printing-press-library/main/{manifest_url}`.
+
+ **Patterns to follow:**
+ - Existing registry schema extension pattern in `SKILL.md` Step 8
+ - `specChecksum()` at `publish.go` for checksum computation
+
+ **Test scenarios:**
+ - Happy path: Publish skill Step 8 includes `manifest_checksum`, `spec_format`, `manifest_url` in the registry entry
+ - Happy path: `manifest_url` is `library/<category>/<cli-name>/tools-manifest.json`
+ - Edge case: CLI without a spec (--docs mode) → `manifest_checksum` and `manifest_url` empty, mega MCP skips this API
+
+ **Verification:** A simulated publish produces a registry entry with all three new fields populated.
+
+### Phase 2: Mega MCP Binary
+
+- [ ] **Unit 3: Skeleton, registry fetching, and manifest caching**
+
+ **Goal:** The mega MCP binary starts, fetches the registry from GitHub, fetches and caches tools manifests, and verifies integrity.
+
+ **Requirements:** R1, R4, R5, R12, R17, R18, R19, R20
+
+ **Dependencies:** Units 1-2
+
+ **Files:**
+ - Modify: `go.mod` (add `github.com/mark3labs/mcp-go`)
+ - Create: `cmd/printing-press-mcp/main.go`
+ - Create: `internal/megamcp/registry.go` (registry fetcher)
+ - Create: `internal/megamcp/manifest.go` (manifest fetcher + cache)
+ - Create: `internal/megamcp/types.go` (ToolsManifest, ToolEntry, ParamEntry structs)
+ - Create: `internal/megamcp/security.go` (URL validation, text sanitization, checksum verification)
+ - Test: `internal/megamcp/registry_test.go`
+ - Test: `internal/megamcp/manifest_test.go`
+ - Test: `internal/megamcp/security_test.go`
+
+ **Approach:**
+ `FetchRegistry(baseURL string) ([]RegistryEntry, error)` fetches `registry.json` from GitHub raw content. `baseURL` is injectable for testing (per multi-source discovery learning). Filters to entries with non-empty `manifest_url` and `mcp_ready != "cli-only"`. Applies `PRINTING_PRESS_APIS` env var filter (R20).
+
+ `FetchManifest(entry RegistryEntry, cacheDir string) (*ToolsManifest, error)` fetches `tools-manifest.json` from the manifest_url. Cache logic: check cache → if cached, verify checksum against registry's `manifest_checksum` → if match, use cache → if mismatch or missing, fetch from GitHub → verify checksum → write to cache via temp-then-rename. Cache dir: `~/.cache/printing-press-mcp/manifests/{slug}/tools-manifest.json` with `0700` dir permissions, `0600` file permissions.
+
+ Security: `ValidateBaseURL(url string) error` rejects non-HTTPS, private IP ranges (10.x, 172.16-31.x, 192.168.x), loopback (127.x, ::1), link-local (169.254.x, fe80::), and cloud metadata (169.254.169.254). `SanitizeText(s string, maxLen int) string` strips control characters and length-limits manifest-derived descriptions. `VerifyChecksum(data []byte, expected string) error` compares SHA-256.
+
+ Path traversal protection on cache paths: reject API slugs containing `..`, `/`, `\` AND verify resolved cache path is under cache root.
+
+ Parallel manifest loading via `errgroup.Group` (not `WithContext`). Failed fetches log warnings and exclude the API — other APIs continue.
+
+ First run with no cache and no network: start with zero APIs, surface error through `library_info`.
+
+ **Patterns to follow:**
+ - `setPressTestEnv(t)` pattern for test isolation
+ - `httptest.NewServer` for mock GitHub responses
+ - `errgroup.Group` per multi-source discovery learning
+ - Temp-then-rename for cache writes per immutable source learning
+ - Belt-and-suspenders path traversal per filepath traversal learning
+
+ **Test scenarios:**
+ - Happy path: Fetch registry with 3 entries → filter to 2 (one is cli-only) → fetch 2 manifests → both cached
+ - Happy path: Cached manifest with matching checksum → no re-fetch
+ - Happy path: Cached manifest with mismatched checksum → re-fetched and updated
+ - Happy path: `PRINTING_PRESS_APIS=espn,dub` → only 2 APIs loaded
+ - Edge case: GitHub returns 404 for registry → start with zero APIs, library_info shows error
+ - Edge case: One manifest fetch fails, others succeed → failed API excluded with warning, others available
+ - Edge case: First run, no cache, no network → zero APIs, clear error in library_info
+ - Edge case: API slug with traversal characters (`../etc`) → rejected before path construction
+ - Edge case: Manifest base_url is private IP (10.0.0.1) → API rejected with warning
+ - Edge case: Manifest base_url is HTTP (not HTTPS) → API rejected with warning
+ - Edge case: Manifest description contains control characters → sanitized before registration
+ - Edge case: Cache directory doesn't exist → created with 0700 permissions
+
+ **Verification:** `go test ./internal/megamcp/...` passes. `go build ./cmd/printing-press-mcp` succeeds. Binary starts and loads manifests from a mock server.
+
+- [ ] **Unit 4: Generic HTTP handler with auth and param routing**
+
+ **Goal:** The mega MCP can make HTTP requests to any API using the tools manifest data — correct auth headers, proper parameter classification, required headers.
+
+ **Requirements:** R11, R13, R14, R15, R16
+
+ **Dependencies:** Unit 3
+
+ **Files:**
+ - Create: `internal/megamcp/handler.go`
+ - Create: `internal/megamcp/auth.go` (format string expansion, auth header construction)
+ - Test: `internal/megamcp/handler_test.go`
+ - Test: `internal/megamcp/auth_test.go`
+
+ **Approach:**
+ `MakeToolHandler(manifest *ToolsManifest, tool ToolEntry) server.ToolHandlerFunc` returns a handler that:
+ 1. Builds the URL: substitute `location: "path"` params into the path template, append `location: "query"` params as query string
+ 2. Builds the body: serialize `location: "body"` params as JSON (POST/PUT/PATCH only). Path and query params are excluded from the body.
+ 3. Constructs auth: read env var from `manifest.Auth.EnvVars`, apply `manifest.Auth.Format` string substitution (e.g., `Bearer {DUB_TOKEN}` → `Bearer abc123`), set on `manifest.Auth.Header` or query param based on `manifest.Auth.In`
+ 4. Attaches `RequiredHeaders` from manifest to every request
+ 5. Attaches per-tool `HeaderOverrides` if present
+ 6. **Fail-closed auth check:** Before making the request, if `auth.type` is not `"none"` and the required env var is absent or empty AND the tool's `no_auth` flag is false, return an MCP error immediately ("Authentication not configured — call `setup_guide` for instructions"). Only proceed without auth when `no_auth: true`.
+ 7. **Post-assembly URL validation:** After substituting path params and appending query params, verify the assembled URL's host still matches the validated base_url host. Reject if the host changed (prevents path-param-based SSRF). URL-encode path param values before substitution. Use `url.Values` for query param construction (prevents `&`/`#` injection).
+ 8. **Missing path param check:** Before making the request, verify all `{placeholder}` tokens in the path template were substituted. If any remain, return an MCP error listing the missing params.
+ 9. Makes the HTTP request with a per-API `http.Client` (30s timeout, response body capped at 10MB via `io.LimitReader`)
+ 10. Handles errors: 401/403 → generic "auth not configured" message (no env var names in error). 429 → surface response body. 4xx → return API error (with credential value redaction pass — replace known auth values with `[REDACTED]` before returning). 5xx/network → MCP error
+
+ `ApplyAuthFormat(format string, envVars []string) (string, error)` expands `{PLACEHOLDER}` in the format string with env var values. Validates that the format string only contains recognized placeholders and doesn't place credentials in URL paths.
+
+ Credential isolation: each API's handler reads only its own `Auth.EnvVars`. A per-API HTTP client is constructed at activation time (not shared across APIs).
+
+ **Patterns to follow:**
+ - `makeAPIHandler` in `mcp_tools.go.tmpl` — path substitution, query params, body construction, error handling
+ - `AuthHeader()` in `config.go.tmpl` — format string expansion
+ - `do()` in `client.go.tmpl` — required headers, error classification
+
+ **Test scenarios:**
+ - Happy path: GET request with path param → param substituted in URL, not in query or body
+ - Happy path: GET request with query params → appended to URL
+ - Happy path: POST request with body params → serialized as JSON body. Path params excluded from body
+ - Happy path: API key auth with `In: "header"` → auth header set correctly
+ - Happy path: API key auth with `In: "query"` → auth appended as query param
+ - Happy path: Bearer token auth → `Authorization: Bearer {value}` header set
+ - Happy path: RequiredHeaders attached to every request
+ - Happy path: Per-tool HeaderOverrides applied
+ - Happy path: No auth (type "none") → no auth header set
+ - Edge case: Missing env var for auth on non-NoAuth endpoint → MCP error returned before request ("call setup_guide")
+ - Edge case: Missing env var for auth on NoAuth endpoint → request proceeds without auth
+ - Edge case: 401 response → generic error message, no env var names, credential values redacted from response body
+ - Edge case: 429 response → error includes response body
+ - Edge case: Network timeout → MCP error
+ - Edge case: Response body exceeds 10MB → truncated with error
+ - Edge case: Format string with unrecognized placeholder → rejected at load time
+ - Edge case: Path param value containing `../` → URL-encoded before substitution, assembled URL host matches base_url
+ - Edge case: Required path param missing from arguments → MCP error listing missing params
+ - Edge case: Two APIs share an env var name → startup warning
+ - Integration: Round-trip against `httptest.NewServer` — GET with params, POST with body, auth header verified, assembled URL host matches
+
+ **Verification:** `go test ./internal/megamcp/...` passes. A test hitting a mock API server receives correctly formed requests with proper auth and parameters.
+
+- [ ] **Unit 5: Meta-tools and activation model**
+
+ **Goal:** The mega MCP exposes discovery and activation tools that let agents find APIs, get setup instructions, and dynamically register API tools.
+
+ **Requirements:** R6, R7, R8, R9, R21
+
+ **Dependencies:** Units 3-4
+
+ **Files:**
+ - Create: `internal/megamcp/metatools.go`
+ - Create: `internal/megamcp/activation.go` (tool registration/deregistration state)
+ - Test: `internal/megamcp/metatools_test.go`
+ - Test: `internal/megamcp/activation_test.go`
+
+ **Approach:**
+ Six meta-tools registered at startup:
+
+ **`library_info`**: Returns JSON listing all loaded APIs: name, description, tool count, auth type, auth status (checks `os.Getenv` for each API's env vars — returns boolean only, NOT env var names), `mcp_ready` level, novel features, and whether a local per-API MCP binary exists (checks `~/printing-press/library/{slug}/cmd/{mcp-binary}/` path). If local binary exists, includes install hint.
+
+ **`setup_guide(api_slug)`**: Returns auth configuration instructions for a specific API: required env vars, key URL, example `claude mcp add --env` command. This is the deliberate introspection step for credential metadata — separated from `library_info` to avoid broadcasting env var names in catalog listings.
+
+ **`activate_api(api_slug)`**: Loads the API's tools manifest (already cached from startup), registers all tools with `{normalized_slug}__` prefix via `s.AddTool()`. Sends `tools/list_changed` notification if `mcp-go` supports it. Returns confirmation with tool count.
+
+ **`deactivate_api(api_slug)`**: Removes all tools for an API. Sends `tools/list_changed`.
+
+ **`search_tools(query)`**: Searches across ALL loaded manifests' tool names and descriptions by keyword (case-insensitive substring match). Returns matching tools with API slug, tool name, and description — without requiring activation. Lets the agent find the right API before activating it.
+
+ **`about`**: Returns mega MCP version (from `internal/version.Version`), total API count, total tool count across all manifests, and library root path.
+
+ **First task in this unit:** Verify `mcp-go` support for `tools/list_changed` notification and dynamic tool add/remove. If `mcp-go` doesn't support removing tools after registration, the `deactivate_api` implementation may need to re-create the MCP server with the reduced tool set (heavier but functional).
+
+ **Patterns to follow:**
+ - Per-API `about` tool in `mcp_tools.go.tmpl` — handler pattern
+ - `mcp-go` `server.AddTool()` API for dynamic registration
+ - `version.Version` for version reporting
+
+ **Test scenarios:**
+ - Happy path: `library_info` returns all APIs with correct metadata, auth_configured reflects env var presence
+ - Happy path: `library_info` shows local upgrade available when per-API MCP binary exists
+ - Happy path: `setup_guide("dub")` returns env var names, key URL, example command
+ - Happy path: `activate_api("espn")` → 3 ESPN tools registered with `espn__` prefix
+ - Happy path: After activation, `espn__scores_get` is callable
+ - Happy path: `deactivate_api("espn")` → ESPN tools removed
+ - Happy path: `search_tools("scores")` → returns `espn__scores_get` match with description
+ - Happy path: `search_tools("pizza")` → returns Pagliacci tools even before activation
+ - Happy path: `about` returns version and counts
+ - Edge case: `activate_api` with unknown slug → error "API not found"
+ - Edge case: `activate_api` called twice for same API → idempotent (no duplicate tools)
+ - Edge case: `search_tools` with no matches → empty results array
+ - Edge case: `library_info` with no APIs loaded (network failure) → empty list with error message
+ - Edge case: `setup_guide` for no-auth API → "No authentication required"
+
+ **Verification:** `go test ./internal/megamcp/...` passes. Meta-tools return valid JSON responses.
+
+- [ ] **Unit 6: Entry point, goreleaser, and marketplace metadata**
+
+ **Goal:** The mega MCP binary is buildable, distributable via `go install` and goreleaser, and includes marketplace metadata.
+
+ **Requirements:** R1, R2, R3
+
+ **Dependencies:** Unit 5
+
+ **Files:**
+ - Modify: `cmd/printing-press-mcp/main.go` (wire everything together)
+ - Modify: `.goreleaser.yaml` (add second binary build)
+ - Create: `smithery.yaml` (repo-root level, for the mega MCP)
+ - Modify: `internal/version/version.go` (reuse for mega MCP)
+
+ **Approach:**
+ `main.go` creates an `mcp-go` server, runs the registry fetch → manifest load → cache pipeline, registers the 6 meta-tools, and serves on stdio. Graceful shutdown on SIGINT/SIGTERM.
+
+ Add `printing-press-mcp` as a second build in `.goreleaser.yaml` alongside the existing `printing-press` binary. Both distributed in the same release. The mega MCP binary reuses `internal/version.Version` for its version string.
+
+ `smithery.yaml` at repo root describes the mega MCP for Smithery marketplace listing. No per-API env vars are required (they're all optional). Description highlights the catalog: "270+ tools across 6 APIs — sports scores, link management, pizza ordering, and more."
+
+ Install and usage:
+ ```
+ go install github.com/mvanhorn/cli-printing-press/cmd/printing-press-mcp@latest
+ claude mcp add printing-press -- printing-press-mcp
+ ```
+
+ **Patterns to follow:**
+ - `cmd/printing-press/main.go` entry point pattern
+ - `.goreleaser.yaml` existing build configuration
+ - `main_mcp.go.tmpl` for `mcp-go` server setup (`server.NewMCPServer`, `server.ServeStdio`)
+ - `smithery.yaml` generation pattern from `writeSmitheryYAML` in `publish.go`
+
+ **Test scenarios:**
+ - Happy path: `go build ./cmd/printing-press-mcp` succeeds
+ - Integration: Start mega MCP with mock GitHub server → `library_info` returns API catalog → `activate_api` registers tools → tool call returns mock API response
+
+ **Verification:** Binary builds and starts. End-to-end flow works with mock server: library_info → activate → tool call → response.
+
+## System-Wide Impact
+
+- **Interaction graph:** The mega MCP binary is a new artifact in this repo. It imports `internal/megamcp/` (new), `internal/naming/` (modified), `internal/pipeline/` (types only), and `internal/version/`. The publish pipeline gains `writeToolsManifest()`. The publish skill gains registry fields. No existing behavior changes — all additions.
+- **Error propagation:** HTTP errors from APIs are caught in the handler and returned as MCP tool errors. Registry/manifest fetch failures are caught at startup and surfaced via `library_info`. Individual API failures do not affect other APIs.
+- **State lifecycle risks:** The manifest cache at `~/.cache/printing-press-mcp/` persists across restarts. Stale cache is mitigated by checksum verification against the registry. Cache corruption is mitigated by temp-then-rename writes.
+- **API surface parity:** Tool names match (both use `toSnake` on `spec.APISpec` keys). Tool parameter schemas intentionally diverge for POST/PUT/PATCH: the mega MCP registers body params separately (with `location: "body"`) and excludes path params from the request body. The per-API MCP template has a known bug where it sends path params in the body. This is an improvement, not a parity violation — a follow-up should fix the template to match. Agents switching from mega MCP to per-API MCP may notice different parameter lists for mutation endpoints.
+- **Unchanged invariants:** Per-API MCP servers continue to work independently. The mega MCP is additive. The publish pipeline still generates all existing artifacts (`.printing-press.json`, CLI binary, MCP binary). `tools-manifest.json` is a new addition.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| `mcp-go` doesn't support `tools/list_changed` or dynamic tool removal | Verify at start of Unit 5. Fallback: return tool list in activation response text; re-create server for deactivation. |
+| GitHub rate limits on raw content fetches | Cache aggressively (24h TTL + checksum-based invalidation). First-run fetches ~7 files (1 registry + 6 manifests). Well within anonymous rate limits. |
+| Manifest format drift between publish pipeline and mega MCP | Both are in the same repo. `ToolsManifest` struct is shared. Test fixtures verify round-trip compatibility. |
+| Tool naming mismatch between mega MCP and per-API MCP servers | Both derive tool names from `spec.APISpec` using the same naming convention. Unit 1 tests verify parity with template output. |
+| Existing CLIs need re-publishing with tools manifests | Documented as a prerequisite. Can be batched as a single publish run after Units 1-2 land. |
+
+## Phased Delivery
+
+### Phase 1: Publish Pipeline Prerequisites (Units 1-2)
+Ship as one PR. Additive only — doesn't change existing publish behavior, just generates an additional artifact. Can be validated by running a test publish and inspecting the `tools-manifest.json`.
+
+### Phase 2: Mega MCP Binary (Units 3-6)
+Ship as one or two PRs. Can be split: Units 3-4 (fetching + handler) in one PR, Units 5-6 (meta-tools + entry point) in another. The binary is usable only after all 4 units land.
+
+### Post-ship
+- Re-publish existing 6 CLIs with tools manifests (batch operation)
+- List mega MCP on Smithery marketplace
+- Announce via README update in the public library repo
+
+## Sources & References
+
+- **Origin document:** `docs/brainstorms/2026-04-06-mega-mcp-generic-proxy-requirements.md`
+- **Superseded plan:** `docs/plans/2026-04-06-002-feat-mega-mcp-aggregate-server-plan.md` (subprocess architecture — reference for deferred hybrid mode)
+- **Step 1 PR:** mvanhorn/cli-printing-press#145 (MCP readiness layer)
+- **Step 1 plan:** `docs/plans/2026-04-05-001-feat-mcp-readiness-layer-plan.md`
+- Institutional learnings:
+ - `docs/solutions/security-issues/filepath-join-traversal-with-user-input` (traversal protection)
+ - `docs/solutions/best-practices/multi-source-api-discovery-design` (errgroup, injectable URLs)
+ - `docs/solutions/best-practices/validation-must-not-mutate-source-directory` (temp-then-rename)
diff --git a/go.mod b/go.mod
index 9338cf5b..01f2b989 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.26.1
require (
github.com/getkin/kin-openapi v0.133.0
+ github.com/mark3labs/mcp-go v0.47.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
@@ -15,6 +16,8 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
@@ -23,6 +26,8 @@ require (
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
)
diff --git a/go.sum b/go.sum
index 8cebd941..05877e46 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -9,6 +11,12 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -19,6 +27,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
+github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
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/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
@@ -32,6 +42,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
@@ -42,6 +54,8 @@ github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
+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=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
diff --git a/internal/megamcp/activation.go b/internal/megamcp/activation.go
new file mode 100644
index 00000000..4cf67b4e
--- /dev/null
+++ b/internal/megamcp/activation.go
@@ -0,0 +1,290 @@
+package megamcp
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// ActivationManager manages the state of which APIs are activated
+// and handles dynamic tool registration/deregistration.
+type ActivationManager struct {
+ mu sync.RWMutex
+ server *server.MCPServer
+ manifests map[string]*APIEntry // all loaded manifests by slug
+ activated map[string]bool // which slugs have been activated
+ clients map[string]*http.Client // per-API HTTP clients
+}
+
+// NewActivationManager creates a new ActivationManager with all loaded API entries.
+// Registers stub tools for ALL APIs immediately so agents can discover tool names
+// via tools/list. Stubs prompt the agent to call activate_api before execution.
+func NewActivationManager(s *server.MCPServer, entries []*APIEntry) *ActivationManager {
+ manifests := make(map[string]*APIEntry, len(entries))
+ for _, entry := range entries {
+ manifests[entry.Slug] = entry
+ }
+ am := &ActivationManager{
+ server: s,
+ manifests: manifests,
+ activated: make(map[string]bool),
+ clients: make(map[string]*http.Client),
+ }
+
+ // Register stub tools for all APIs so agents can see tool names in tools/list.
+ am.registerStubs()
+
+ return am
+}
+
+// registerStubs registers lightweight stub handlers for all tools across all APIs.
+// When called, stubs return an activation prompt instead of making HTTP requests.
+func (am *ActivationManager) registerStubs() {
+ var stubs []server.ServerTool
+ for _, entry := range am.manifests {
+ prefix := entry.NormalizedPrefix
+ slug := entry.Slug
+ apiName := entry.Manifest.APIName
+ toolCount := len(entry.Manifest.Tools)
+
+ for _, tool := range entry.Manifest.Tools {
+ toolName := prefix + "__" + tool.Name
+
+ toolOpts := []mcp.ToolOption{
+ mcp.WithDescription(SanitizeText(tool.Description, 500)),
+ }
+ for _, param := range tool.Params {
+ paramOpts := []mcp.PropertyOption{
+ mcp.Description(SanitizeText(param.Description, 200)),
+ }
+ if param.Required {
+ paramOpts = append(paramOpts, mcp.Required())
+ }
+ toolOpts = append(toolOpts, mcp.WithString(param.Name, paramOpts...))
+ }
+
+ mcpTool := mcp.NewTool(toolName, toolOpts...)
+ handler := makeStubHandler(slug, apiName, toolCount)
+ stubs = append(stubs, server.ServerTool{Tool: mcpTool, Handler: handler})
+ }
+ }
+
+ if len(stubs) > 0 {
+ am.server.AddTools(stubs...)
+ }
+}
+
+// makeStubHandler returns a handler that prompts the agent to activate the API first.
+func makeStubHandler(slug, apiName string, toolCount int) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ return mcp.NewToolResultError(
+ fmt.Sprintf("This API is not yet activated. Call activate_api(%q) first to enable %d tools for %s.",
+ slug, toolCount, apiName),
+ ), nil
+ }
+}
+
+// Activate registers real HTTP handlers for the given API slug, replacing
+// the stub handlers. Returns the number of tools registered. Idempotent:
+// calling twice for the same slug returns the same count without duplicating tools.
+func (am *ActivationManager) Activate(slug string) (int, error) {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ entry, ok := am.manifests[slug]
+ if !ok {
+ return 0, fmt.Errorf("API not found: %q", slug)
+ }
+
+ if am.activated[slug] {
+ // Already activated — return existing tool count.
+ return len(entry.Manifest.Tools), nil
+ }
+
+ // Create a per-API HTTP client with SSRF-safe dialer.
+ client := SafeHTTPClient(30 * time.Second)
+ am.clients[slug] = client
+
+ // Delete stubs first, then register real handlers.
+ prefix := entry.NormalizedPrefix
+ var stubNames []string
+ var tools []server.ServerTool
+ for _, tool := range entry.Manifest.Tools {
+ toolName := prefix + "__" + tool.Name
+ stubNames = append(stubNames, toolName)
+
+ // Build MCP tool definition with parameter schema.
+ toolOpts := []mcp.ToolOption{
+ mcp.WithDescription(SanitizeText(tool.Description, 500)),
+ }
+ for _, param := range tool.Params {
+ paramOpts := []mcp.PropertyOption{
+ mcp.Description(SanitizeText(param.Description, 200)),
+ }
+ if param.Required {
+ paramOpts = append(paramOpts, mcp.Required())
+ }
+ toolOpts = append(toolOpts, mcp.WithString(param.Name, paramOpts...))
+ }
+
+ mcpTool := mcp.NewTool(toolName, toolOpts...)
+ handler := MakeToolHandler(entry.Manifest, tool, client, slug)
+ tools = append(tools, server.ServerTool{Tool: mcpTool, Handler: handler})
+ }
+
+ // Remove stubs, then add real handlers.
+ if len(stubNames) > 0 {
+ am.server.DeleteTools(stubNames...)
+ }
+ if len(tools) > 0 {
+ am.server.AddTools(tools...)
+ }
+
+ am.activated[slug] = true
+ return len(entry.Manifest.Tools), nil
+}
+
+// Deactivate removes real handlers for the given API slug and re-registers stubs.
+func (am *ActivationManager) Deactivate(slug string) error {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ entry, ok := am.manifests[slug]
+ if !ok {
+ return fmt.Errorf("API not found: %q", slug)
+ }
+
+ if !am.activated[slug] {
+ return fmt.Errorf("API %q is not currently activated", slug)
+ }
+
+ // Collect tool names to delete.
+ prefix := entry.NormalizedPrefix
+ var names []string
+ var stubs []server.ServerTool
+ apiName := entry.Manifest.APIName
+ toolCount := len(entry.Manifest.Tools)
+
+ for _, tool := range entry.Manifest.Tools {
+ toolName := prefix + "__" + tool.Name
+ names = append(names, toolName)
+
+ // Re-register as stub.
+ toolOpts := []mcp.ToolOption{
+ mcp.WithDescription(SanitizeText(tool.Description, 500)),
+ }
+ for _, param := range tool.Params {
+ paramOpts := []mcp.PropertyOption{
+ mcp.Description(SanitizeText(param.Description, 200)),
+ }
+ if param.Required {
+ paramOpts = append(paramOpts, mcp.Required())
+ }
+ toolOpts = append(toolOpts, mcp.WithString(param.Name, paramOpts...))
+ }
+
+ mcpTool := mcp.NewTool(toolName, toolOpts...)
+ handler := makeStubHandler(slug, apiName, toolCount)
+ stubs = append(stubs, server.ServerTool{Tool: mcpTool, Handler: handler})
+ }
+
+ // Delete real handlers, add back stubs.
+ if len(names) > 0 {
+ am.server.DeleteTools(names...)
+ }
+ if len(stubs) > 0 {
+ am.server.AddTools(stubs...)
+ }
+
+ delete(am.activated, slug)
+ delete(am.clients, slug)
+ return nil
+}
+
+// IsActivated returns whether the given API slug is currently activated.
+func (am *ActivationManager) IsActivated(slug string) bool {
+ am.mu.RLock()
+ defer am.mu.RUnlock()
+ return am.activated[slug]
+}
+
+// GetManifest returns the APIEntry for a given slug, or nil if not found.
+func (am *ActivationManager) GetManifest(slug string) *APIEntry {
+ am.mu.RLock()
+ defer am.mu.RUnlock()
+ return am.manifests[slug]
+}
+
+// AllManifests returns all loaded API entries.
+func (am *ActivationManager) AllManifests() []*APIEntry {
+ am.mu.RLock()
+ defer am.mu.RUnlock()
+ result := make([]*APIEntry, 0, len(am.manifests))
+ for _, entry := range am.manifests {
+ result = append(result, entry)
+ }
+ return result
+}
+
+// SearchResult represents a matching tool from a search query.
+type SearchResult struct {
+ APISlug string `json:"api_slug"`
+ ToolName string `json:"tool_name"`
+ Description string `json:"description"`
+}
+
+// SearchTools searches across ALL manifests (not just activated) for tools
+// matching the given query. Case-insensitive substring match on tool name
+// and description.
+func (am *ActivationManager) SearchTools(query string) []SearchResult {
+ am.mu.RLock()
+ defer am.mu.RUnlock()
+
+ if query == "" {
+ return nil
+ }
+
+ lowerQuery := strings.ToLower(query)
+ var results []SearchResult
+
+ for _, entry := range am.manifests {
+ prefix := entry.NormalizedPrefix
+ for _, tool := range entry.Manifest.Tools {
+ fullName := prefix + "__" + tool.Name
+ if strings.Contains(strings.ToLower(fullName), lowerQuery) ||
+ strings.Contains(strings.ToLower(tool.Description), lowerQuery) {
+ results = append(results, SearchResult{
+ APISlug: entry.Slug,
+ ToolName: fullName,
+ Description: tool.Description,
+ })
+ }
+ }
+ }
+
+ return results
+}
+
+// toolNamesForSlug returns the full prefixed tool names for a given API slug.
+// Used for display purposes.
+func (am *ActivationManager) toolNamesForSlug(slug string) []string {
+ am.mu.RLock()
+ defer am.mu.RUnlock()
+
+ entry, ok := am.manifests[slug]
+ if !ok {
+ return nil
+ }
+
+ names := make([]string, 0, len(entry.Manifest.Tools))
+ for _, tool := range entry.Manifest.Tools {
+ names = append(names, entry.NormalizedPrefix+"__"+tool.Name)
+ }
+ return names
+}
diff --git a/internal/megamcp/activation_test.go b/internal/megamcp/activation_test.go
new file mode 100644
index 00000000..6256ca54
--- /dev/null
+++ b/internal/megamcp/activation_test.go
@@ -0,0 +1,315 @@
+package megamcp
+
+import (
+ "testing"
+
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestServer() *server.MCPServer {
+ return server.NewMCPServer("test-server", "0.0.1",
+ server.WithToolCapabilities(true))
+}
+
+func newTestAPIEntries() []*APIEntry {
+ return []*APIEntry{
+ {
+ Slug: "espn",
+ NormalizedPrefix: "espn",
+ Manifest: &ToolsManifest{
+ APIName: "ESPN",
+ BaseURL: "https://site.api.espn.com",
+ Auth: ManifestAuth{Type: "api_key", EnvVars: []string{"ESPN_KEY"}, Header: "apikey", In: "query"},
+ Tools: []ManifestTool{
+ {Name: "scores_get", Description: "Get live scores", Method: "GET", Path: "/scores"},
+ {Name: "teams_list", Description: "List all teams", Method: "GET", Path: "/teams"},
+ {Name: "standings_get", Description: "Get league standings", Method: "GET", Path: "/standings"},
+ },
+ },
+ },
+ {
+ Slug: "dub",
+ NormalizedPrefix: "dub",
+ Manifest: &ToolsManifest{
+ APIName: "Dub",
+ BaseURL: "https://api.dub.co",
+ Auth: ManifestAuth{Type: "bearer_token", EnvVars: []string{"DUB_TOKEN"}, Header: "Authorization", Format: "Bearer {DUB_TOKEN}"},
+ Tools: []ManifestTool{
+ {Name: "links_list", Description: "List all links", Method: "GET", Path: "/links"},
+ {Name: "links_create", Description: "Create a new short link", Method: "POST", Path: "/links"},
+ },
+ },
+ },
+ {
+ Slug: "public-api",
+ NormalizedPrefix: "public_api",
+ Manifest: &ToolsManifest{
+ APIName: "Public API",
+ BaseURL: "https://api.publicapis.org",
+ Auth: ManifestAuth{Type: "none"},
+ Tools: []ManifestTool{
+ {Name: "entries_list", Description: "List all public API entries", Method: "GET", Path: "/entries"},
+ },
+ },
+ },
+ }
+}
+
+func TestActivate_RegistersToolsWithPrefix(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ count, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.Equal(t, 3, count)
+
+ // Verify tools are registered on the server with prefix.
+ tools := s.ListTools()
+ assert.NotNil(t, tools["espn__scores_get"], "espn__scores_get should be registered")
+ assert.NotNil(t, tools["espn__teams_list"], "espn__teams_list should be registered")
+ assert.NotNil(t, tools["espn__standings_get"], "espn__standings_get should be registered")
+}
+
+func TestActivate_Idempotent(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ count1, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.Equal(t, 3, count1)
+
+ count2, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.Equal(t, 3, count2)
+
+ // Verify no duplicates — still only 3 ESPN tools.
+ tools := s.ListTools()
+ espnTools := 0
+ for name := range tools {
+ if len(name) > 6 && name[:6] == "espn__" {
+ espnTools++
+ }
+ }
+ assert.Equal(t, 3, espnTools, "should have exactly 3 ESPN tools, not duplicated")
+}
+
+func TestActivate_UnknownSlug(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ _, err := am.Activate("nonexistent")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "API not found")
+}
+
+func TestDeactivate_ReregistersStubTools(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+
+ err = am.Deactivate("espn")
+ require.NoError(t, err)
+
+ // After deactivation, tools should still be registered (as stubs).
+ tools := s.ListTools()
+ assert.NotNil(t, tools["espn__scores_get"], "espn__scores_get should be a stub")
+ assert.NotNil(t, tools["espn__teams_list"], "espn__teams_list should be a stub")
+
+ // But the API should not be marked as activated.
+ assert.False(t, am.IsActivated("espn"))
+
+ // Calling the stub should return an activation prompt.
+ result, callErr := tools["espn__scores_get"].Handler(t.Context(), makeToolRequest(nil))
+ require.NoError(t, callErr)
+ assert.True(t, result.IsError)
+ text := extractResultText(result)
+ assert.Contains(t, text, "not yet activated")
+}
+
+func TestDeactivate_NotActivated(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ err := am.Deactivate("espn")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not currently activated")
+}
+
+func TestDeactivate_UnknownSlug(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ err := am.Deactivate("nonexistent")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "API not found")
+}
+
+func TestIsActivated(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ assert.False(t, am.IsActivated("espn"), "should be false before activation")
+
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.True(t, am.IsActivated("espn"), "should be true after activation")
+
+ err = am.Deactivate("espn")
+ require.NoError(t, err)
+ assert.False(t, am.IsActivated("espn"), "should be false after deactivation")
+}
+
+func TestGetManifest(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ entry := am.GetManifest("espn")
+ require.NotNil(t, entry)
+ assert.Equal(t, "ESPN", entry.Manifest.APIName)
+
+ missing := am.GetManifest("nonexistent")
+ assert.Nil(t, missing)
+}
+
+func TestAllManifests(t *testing.T) {
+ s := newTestServer()
+ entries := newTestAPIEntries()
+ am := NewActivationManager(s, entries)
+
+ all := am.AllManifests()
+ assert.Len(t, all, 3)
+}
+
+func TestSearchTools_MatchesName(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ results := am.SearchTools("scores")
+ require.Len(t, results, 1)
+ assert.Equal(t, "espn", results[0].APISlug)
+ assert.Equal(t, "espn__scores_get", results[0].ToolName)
+}
+
+func TestSearchTools_CaseInsensitive(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ results := am.SearchTools("SCORES")
+ require.Len(t, results, 1)
+ assert.Equal(t, "espn__scores_get", results[0].ToolName)
+}
+
+func TestSearchTools_MatchesDescription(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ results := am.SearchTools("short link")
+ require.Len(t, results, 1)
+ assert.Equal(t, "dub", results[0].APISlug)
+ assert.Equal(t, "dub__links_create", results[0].ToolName)
+}
+
+func TestSearchTools_AcrossUnactivatedAPIs(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ // Search across all APIs without activating any.
+ assert.False(t, am.IsActivated("espn"))
+ assert.False(t, am.IsActivated("dub"))
+
+ results := am.SearchTools("list")
+ // Should match: espn__teams_list, dub__links_list, public_api__entries_list
+ assert.GreaterOrEqual(t, len(results), 3)
+}
+
+func TestSearchTools_NoMatches(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ results := am.SearchTools("pizza")
+ assert.Empty(t, results)
+}
+
+func TestSearchTools_EmptyQuery(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ results := am.SearchTools("")
+ assert.Nil(t, results)
+}
+
+func TestActivateMultipleAPIs(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+
+ _, err = am.Activate("dub")
+ require.NoError(t, err)
+
+ tools := s.ListTools()
+ // Should have 3 ESPN + 2 Dub = 5 tools.
+ assert.NotNil(t, tools["espn__scores_get"])
+ assert.NotNil(t, tools["dub__links_list"])
+ assert.NotNil(t, tools["dub__links_create"])
+}
+
+func TestToolNamesForSlug(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ names := am.toolNamesForSlug("espn")
+ assert.Len(t, names, 3)
+ assert.Contains(t, names, "espn__scores_get")
+ assert.Contains(t, names, "espn__teams_list")
+ assert.Contains(t, names, "espn__standings_get")
+
+ missing := am.toolNamesForSlug("nonexistent")
+ assert.Nil(t, missing)
+}
+
+func TestStubToolsRegisteredAtStartup(t *testing.T) {
+ s := newTestServer()
+ entries := newTestAPIEntries()
+ _ = NewActivationManager(s, entries)
+
+ // Stub tools should be visible in the server's tool list immediately.
+ // We can't directly query the server's tool list, but we can verify
+ // that calling a stub returns the activation prompt.
+ // The stubs are registered via AddTools in registerStubs().
+ // We verify this indirectly by checking that the stub handler works.
+ handler := makeStubHandler("espn", "ESPN", 3)
+ result, err := handler(t.Context(), makeToolRequest(nil))
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ text := extractResultText(result)
+ assert.Contains(t, text, "not yet activated")
+ assert.Contains(t, text, "activate_api")
+ assert.Contains(t, text, "espn")
+ assert.Contains(t, text, "3 tools")
+}
+
+func TestDeactivate_ReregistersStubs(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ // Activate then deactivate.
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.True(t, am.IsActivated("espn"))
+
+ err = am.Deactivate("espn")
+ require.NoError(t, err)
+ assert.False(t, am.IsActivated("espn"))
+
+ // After deactivation, re-activating should work (stubs were re-registered).
+ count, err := am.Activate("espn")
+ require.NoError(t, err)
+ assert.Equal(t, 3, count)
+ assert.True(t, am.IsActivated("espn"))
+}
diff --git a/internal/megamcp/auth.go b/internal/megamcp/auth.go
new file mode 100644
index 00000000..11986924
--- /dev/null
+++ b/internal/megamcp/auth.go
@@ -0,0 +1,184 @@
+package megamcp
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+// ApplyAuthFormat expands {PLACEHOLDER} tokens in the format string with values
+// from the envVars map. envVars is keyed by env var name, values are from os.Getenv.
+// Also supports semantic placeholders: {token} and {access_token} map to the first
+// env var value. Rejects format strings with unrecognized placeholders.
+func ApplyAuthFormat(format string, envVars map[string]string) (string, error) {
+ if format == "" {
+ // No format string — return the first env var value directly.
+ for _, v := range envVars {
+ return v, nil
+ }
+ return "", nil
+ }
+
+ result := format
+
+ // Build replacement map: env var names and their semantic aliases.
+ replacements := make(map[string]string)
+ var firstValue string
+ for name, value := range envVars {
+ replacements[name] = value
+ if firstValue == "" {
+ firstValue = value
+ }
+ }
+ // Semantic placeholders map to the first env var value.
+ if firstValue != "" {
+ replacements["token"] = firstValue
+ replacements["access_token"] = firstValue
+ }
+
+ // Perform substitutions.
+ for key, value := range replacements {
+ result = strings.ReplaceAll(result, "{"+key+"}", value)
+ }
+
+ // Reject if any unrecognized placeholders remain.
+ if idx := strings.Index(result, "{"); idx >= 0 {
+ end := strings.Index(result[idx:], "}")
+ if end > 0 {
+ placeholder := result[idx : idx+end+1]
+ return "", fmt.Errorf("unrecognized placeholder %s in auth format %q", placeholder, format)
+ }
+ }
+
+ return result, nil
+}
+
+// BuildAuthHeader reads env vars from manifest.Auth.EnvVars via os.Getenv,
+// applies format string expansion, and returns the header name and value.
+// Returns empty strings if no auth configured (type is "none" or empty).
+func BuildAuthHeader(manifest *ToolsManifest) (headerName string, headerValue string, err error) {
+ if manifest == nil || manifest.Auth.Type == "" || manifest.Auth.Type == "none" {
+ return "", "", nil
+ }
+
+ // Build envVars map from os.Getenv.
+ envVars := make(map[string]string)
+ for _, envName := range manifest.Auth.EnvVars {
+ val := os.Getenv(envName)
+ if val != "" {
+ envVars[envName] = val
+ }
+ }
+
+ // If no env vars are set, return empty — caller decides if that's an error.
+ if len(envVars) == 0 {
+ return "", "", nil
+ }
+
+ // Determine the header name.
+ headerName = manifest.Auth.Header
+ if headerName == "" {
+ headerName = "Authorization"
+ }
+
+ // Apply the format string, or construct a default value.
+ if manifest.Auth.Format != "" {
+ headerValue, err = ApplyAuthFormat(manifest.Auth.Format, envVars)
+ if err != nil {
+ return "", "", fmt.Errorf("applying auth format: %w", err)
+ }
+ } else {
+ // No format string — use the first env var value directly.
+ // For bearer_token, default to "Bearer {value}".
+ for _, envName := range manifest.Auth.EnvVars {
+ val := envVars[envName]
+ if val != "" {
+ if manifest.Auth.Type == "bearer_token" {
+ headerValue = "Bearer " + val
+ } else {
+ headerValue = val
+ }
+ break
+ }
+ }
+ }
+
+ return headerName, headerValue, nil
+}
+
+// BuildAuthQueryParam constructs the auth query parameter name and value
+// for APIs that use In:"query" auth. Returns empty strings if not applicable.
+func BuildAuthQueryParam(manifest *ToolsManifest) (paramName string, paramValue string, err error) {
+ if manifest == nil || manifest.Auth.Type == "" || manifest.Auth.Type == "none" {
+ return "", "", nil
+ }
+ if manifest.Auth.In != "query" {
+ return "", "", nil
+ }
+
+ // Build envVars map from os.Getenv.
+ envVars := make(map[string]string)
+ for _, envName := range manifest.Auth.EnvVars {
+ val := os.Getenv(envName)
+ if val != "" {
+ envVars[envName] = val
+ }
+ }
+
+ if len(envVars) == 0 {
+ return "", "", nil
+ }
+
+ // The query param name comes from auth.header (or defaults to "api_key").
+ paramName = manifest.Auth.Header
+ if paramName == "" {
+ paramName = "api_key"
+ }
+
+ // Apply format or use raw value.
+ if manifest.Auth.Format != "" {
+ paramValue, err = ApplyAuthFormat(manifest.Auth.Format, envVars)
+ if err != nil {
+ return "", "", fmt.Errorf("applying auth format: %w", err)
+ }
+ } else {
+ for _, envName := range manifest.Auth.EnvVars {
+ val := envVars[envName]
+ if val != "" {
+ paramValue = val
+ break
+ }
+ }
+ }
+
+ return paramName, paramValue, nil
+}
+
+// RedactCredentials replaces known credential values (from env vars) with
+// [REDACTED] in the given string. Used before returning 4xx error bodies
+// to prevent credential leakage.
+func RedactCredentials(body string, manifest *ToolsManifest) string {
+ if manifest == nil {
+ return body
+ }
+ for _, envName := range manifest.Auth.EnvVars {
+ val := os.Getenv(envName)
+ if val != "" && len(val) >= 4 {
+ body = strings.ReplaceAll(body, val, "[REDACTED]")
+ }
+ }
+ return body
+}
+
+// hasAuthConfigured checks whether the required auth env vars are set.
+func hasAuthConfigured(manifest *ToolsManifest) bool {
+ if manifest == nil || manifest.Auth.Type == "" || manifest.Auth.Type == "none" {
+ return true // No auth needed.
+ }
+ for _, envName := range manifest.Auth.EnvVars {
+ if os.Getenv(envName) != "" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/megamcp/auth_test.go b/internal/megamcp/auth_test.go
new file mode 100644
index 00000000..3c1cd205
--- /dev/null
+++ b/internal/megamcp/auth_test.go
@@ -0,0 +1,352 @@
+package megamcp
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyAuthFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ format string
+ envVars map[string]string
+ want string
+ wantErr string
+ }{
+ {
+ name: "simple substitution",
+ format: "{API_KEY}",
+ envVars: map[string]string{"API_KEY": "my-secret-key"},
+ want: "my-secret-key",
+ },
+ {
+ name: "bearer format",
+ format: "Bearer {DUB_TOKEN}",
+ envVars: map[string]string{"DUB_TOKEN": "abc123"},
+ want: "Bearer abc123",
+ },
+ {
+ name: "semantic placeholder token",
+ format: "Bearer {token}",
+ envVars: map[string]string{"SOME_TOKEN": "xyz789"},
+ want: "Bearer xyz789",
+ },
+ {
+ name: "semantic placeholder access_token",
+ format: "Bearer {access_token}",
+ envVars: map[string]string{"MY_TOKEN": "tok456"},
+ want: "Bearer tok456",
+ },
+ {
+ name: "empty format returns first value",
+ format: "",
+ envVars: map[string]string{"KEY": "val"},
+ want: "val",
+ },
+ {
+ name: "unrecognized placeholder rejected",
+ format: "Bearer {UNKNOWN_VAR}",
+ envVars: map[string]string{"API_KEY": "secret"},
+ wantErr: "unrecognized placeholder {UNKNOWN_VAR}",
+ },
+ {
+ name: "empty env vars with format",
+ format: "Bearer {token}",
+ envVars: map[string]string{},
+ wantErr: "unrecognized placeholder {token}",
+ },
+ {
+ name: "empty env vars no format",
+ format: "",
+ envVars: map[string]string{},
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ApplyAuthFormat(tt.format, tt.envVars)
+ if tt.wantErr != "" {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestBuildAuthHeader(t *testing.T) {
+ tests := []struct {
+ name string
+ manifest *ToolsManifest
+ envSetup map[string]string
+ wantHeader string
+ wantValue string
+ wantErr bool
+ }{
+ {
+ name: "nil manifest",
+ manifest: nil,
+ },
+ {
+ name: "auth type none",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{Type: "none"},
+ },
+ },
+ {
+ name: "api_key with format",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ Format: "{MY_API_KEY}",
+ EnvVars: []string{"MY_API_KEY"},
+ },
+ },
+ envSetup: map[string]string{"MY_API_KEY": "secret123"},
+ wantHeader: "X-Api-Key",
+ wantValue: "secret123",
+ },
+ {
+ name: "bearer_token no format defaults to Bearer prefix",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "bearer_token",
+ EnvVars: []string{"BEARER_TOK"},
+ },
+ },
+ envSetup: map[string]string{"BEARER_TOK": "tok999"},
+ wantHeader: "Authorization",
+ wantValue: "Bearer tok999",
+ },
+ {
+ name: "bearer_token with custom format",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Token {GITHUB_TOKEN}",
+ EnvVars: []string{"GITHUB_TOKEN"},
+ },
+ },
+ envSetup: map[string]string{"GITHUB_TOKEN": "ghp_abc"},
+ wantHeader: "Authorization",
+ wantValue: "Token ghp_abc",
+ },
+ {
+ name: "env var not set returns empty",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"MISSING_KEY"},
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Set up env vars.
+ for k, v := range tt.envSetup {
+ t.Setenv(k, v)
+ }
+
+ hdr, val, err := BuildAuthHeader(tt.manifest)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantHeader, hdr)
+ assert.Equal(t, tt.wantValue, val)
+ }
+ })
+ }
+}
+
+func TestBuildAuthQueryParam(t *testing.T) {
+ tests := []struct {
+ name string
+ manifest *ToolsManifest
+ envSetup map[string]string
+ wantName string
+ wantValue string
+ }{
+ {
+ name: "nil manifest",
+ manifest: nil,
+ },
+ {
+ name: "in header not query",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ In: "header",
+ },
+ },
+ },
+ {
+ name: "api key in query",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "key",
+ In: "query",
+ EnvVars: []string{"ESPN_KEY"},
+ },
+ },
+ envSetup: map[string]string{"ESPN_KEY": "qwerty"},
+ wantName: "key",
+ wantValue: "qwerty",
+ },
+ {
+ name: "query auth defaults to api_key param name",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ In: "query",
+ EnvVars: []string{"MY_KEY"},
+ },
+ },
+ envSetup: map[string]string{"MY_KEY": "abc"},
+ wantName: "api_key",
+ wantValue: "abc",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for k, v := range tt.envSetup {
+ t.Setenv(k, v)
+ }
+
+ name, val, err := BuildAuthQueryParam(tt.manifest)
+ require.NoError(t, err)
+ assert.Equal(t, tt.wantName, name)
+ assert.Equal(t, tt.wantValue, val)
+ })
+ }
+}
+
+func TestRedactCredentials(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ manifest *ToolsManifest
+ envSetup map[string]string
+ want string
+ }{
+ {
+ name: "nil manifest returns body unchanged",
+ body: "error: bad token abc123",
+ manifest: nil,
+ want: "error: bad token abc123",
+ },
+ {
+ name: "credential value redacted",
+ body: "Invalid API key: sk-secret-key-here is not valid",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"MY_KEY"},
+ },
+ },
+ envSetup: map[string]string{"MY_KEY": "sk-secret-key-here"},
+ want: "Invalid API key: [REDACTED] is not valid",
+ },
+ {
+ name: "short credential not redacted to avoid false positives",
+ body: "error: bad key ab",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"SHORT_KEY"},
+ },
+ },
+ envSetup: map[string]string{"SHORT_KEY": "ab"},
+ want: "error: bad key ab",
+ },
+ {
+ name: "multiple env vars redacted",
+ body: "key1=first-secret key2=second-secret",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"KEY1", "KEY2"},
+ },
+ },
+ envSetup: map[string]string{"KEY1": "first-secret", "KEY2": "second-secret"},
+ want: "key1=[REDACTED] key2=[REDACTED]",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for k, v := range tt.envSetup {
+ t.Setenv(k, v)
+ }
+
+ got := RedactCredentials(tt.body, tt.manifest)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestHasAuthConfigured(t *testing.T) {
+ tests := []struct {
+ name string
+ manifest *ToolsManifest
+ envSetup map[string]string
+ want bool
+ }{
+ {
+ name: "nil manifest",
+ manifest: nil,
+ want: true,
+ },
+ {
+ name: "no auth type",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{Type: "none"},
+ },
+ want: true,
+ },
+ {
+ name: "env var set",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"MY_KEY"},
+ },
+ },
+ envSetup: map[string]string{"MY_KEY": "secret"},
+ want: true,
+ },
+ {
+ name: "env var not set",
+ manifest: &ToolsManifest{
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"MISSING_KEY"},
+ },
+ },
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for k, v := range tt.envSetup {
+ t.Setenv(k, v)
+ }
+
+ got := hasAuthConfigured(tt.manifest)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/internal/megamcp/handler.go b/internal/megamcp/handler.go
new file mode 100644
index 00000000..ceb7fb40
--- /dev/null
+++ b/internal/megamcp/handler.go
@@ -0,0 +1,241 @@
+package megamcp
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// maxResponseBody is the maximum response body size the handler will read from the network (10MB).
+const maxResponseBody = 10 * 1024 * 1024
+
+// maxAgentResponse is the maximum response text returned to the agent (32KB).
+// Larger responses are truncated with a size note to prevent context window flooding.
+const maxAgentResponse = 32 * 1024
+
+// MakeToolHandler returns an MCP tool handler function that makes HTTP requests
+// to APIs using the tools manifest data. It handles auth, parameter routing
+// (path/query/body), required headers, and response classification.
+// The apiSlug is the canonical slug used in setup_guide references (e.g., "dub", not "Dub").
+func MakeToolHandler(manifest *ToolsManifest, tool ManifestTool, httpClient *http.Client, apiSlug string) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ slug := apiSlug
+
+ // 1. Fail-closed auth check.
+ if !tool.NoAuth && manifest.Auth.Type != "" && manifest.Auth.Type != "none" {
+ if !hasAuthConfigured(manifest) {
+ return mcp.NewToolResultError(
+ fmt.Sprintf("Authentication not configured for this API — call setup_guide(%q) for instructions", slug),
+ ), nil
+ }
+ }
+
+ // 2. Build URL: substitute path params into the path template.
+ args := req.GetArguments()
+ apiPath := tool.Path
+
+ for _, param := range tool.Params {
+ if param.Location != "path" {
+ continue
+ }
+ val, ok := args[param.Name]
+ if !ok || val == nil {
+ continue
+ }
+ encoded := url.PathEscape(fmt.Sprintf("%v", val))
+ apiPath = strings.Replace(apiPath, "{"+param.Name+"}", encoded, 1)
+ }
+
+ // Check for unsubstituted path placeholders.
+ if idx := strings.Index(apiPath, "{"); idx >= 0 {
+ end := strings.Index(apiPath[idx:], "}")
+ if end > 0 {
+ var missing []string
+ remaining := apiPath
+ for {
+ start := strings.Index(remaining, "{")
+ if start < 0 {
+ break
+ }
+ close := strings.Index(remaining[start:], "}")
+ if close < 0 {
+ break
+ }
+ placeholder := remaining[start+1 : start+close]
+ missing = append(missing, placeholder)
+ remaining = remaining[start+close+1:]
+ }
+ return mcp.NewToolResultError(
+ fmt.Sprintf("Missing required path parameter(s): %s", strings.Join(missing, ", ")),
+ ), nil
+ }
+ }
+
+ // 3. Build query string.
+ queryValues := url.Values{}
+ for _, param := range tool.Params {
+ if param.Location != "query" {
+ continue
+ }
+ val, ok := args[param.Name]
+ if !ok || val == nil {
+ continue
+ }
+ queryValues.Set(param.Name, fmt.Sprintf("%v", val))
+ }
+
+ // 4. Build body for POST/PUT/PATCH.
+ var bodyReader io.Reader
+ method := strings.ToUpper(tool.Method)
+ if method == "POST" || method == "PUT" || method == "PATCH" {
+ bodyParams := make(map[string]any)
+ for _, param := range tool.Params {
+ if param.Location != "body" {
+ continue
+ }
+ val, ok := args[param.Name]
+ if !ok || val == nil {
+ continue
+ }
+ bodyParams[param.Name] = val
+ }
+ if len(bodyParams) > 0 {
+ bodyBytes, err := json.Marshal(bodyParams)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error serializing request body: %v", err)), nil
+ }
+ bodyReader = bytes.NewReader(bodyBytes)
+ }
+ }
+
+ // Construct the full URL.
+ baseURL := strings.TrimRight(manifest.BaseURL, "/")
+ fullURL := baseURL + apiPath
+
+ if len(queryValues) > 0 {
+ fullURL += "?" + queryValues.Encode()
+ }
+
+ // 5. Post-assembly URL validation: verify host matches base URL.
+ assembledURL, err := url.Parse(fullURL)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Invalid assembled URL: %v", err)), nil
+ }
+ baseURLParsed, err := url.Parse(baseURL)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Invalid base URL: %v", err)), nil
+ }
+ if assembledURL.Host != baseURLParsed.Host {
+ return mcp.NewToolResultError(
+ fmt.Sprintf("URL host mismatch: expected %q but got %q — possible injection attempt", baseURLParsed.Host, assembledURL.Host),
+ ), nil
+ }
+
+ // Build HTTP request.
+ httpReq, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error creating HTTP request: %v", err)), nil
+ }
+
+ // 6. Set headers.
+ // Auth header or query param.
+ if manifest.Auth.In == "query" {
+ paramName, paramValue, authErr := BuildAuthQueryParam(manifest)
+ if authErr != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error building auth query param: %v", authErr)), nil
+ }
+ if paramName != "" && paramValue != "" {
+ q := httpReq.URL.Query()
+ q.Set(paramName, paramValue)
+ httpReq.URL.RawQuery = q.Encode()
+ }
+ } else {
+ headerName, headerValue, authErr := BuildAuthHeader(manifest)
+ if authErr != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error building auth header: %v", authErr)), nil
+ }
+ if headerName != "" && headerValue != "" {
+ httpReq.Header.Set(headerName, headerValue)
+ }
+ }
+
+ // Required headers from manifest.
+ for _, h := range manifest.RequiredHeaders {
+ httpReq.Header.Set(h.Name, h.Value)
+ }
+
+ // Per-tool header overrides.
+ for _, h := range tool.HeaderOverrides {
+ httpReq.Header.Set(h.Name, h.Value)
+ }
+
+ // Content-Type for body-bearing methods.
+ if method == "POST" || method == "PUT" || method == "PATCH" {
+ httpReq.Header.Set("Content-Type", "application/json")
+ }
+
+ httpReq.Header.Set("User-Agent", "printing-press-mcp")
+
+ // 7. Make request.
+ resp, err := httpClient.Do(httpReq)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Network error: %v", err)), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error reading response body: %v", err)), nil
+ }
+
+ // 8. Handle response.
+ respText := string(respBody)
+
+ // Success (2xx).
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ if len(respText) > maxAgentResponse {
+ truncated := respText[:maxAgentResponse]
+ totalKB := len(respText) / 1024
+ return mcp.NewToolResultText(fmt.Sprintf("%s\n\n[Response truncated — showing first 32KB of %dKB. Use more specific query parameters to narrow results, or install the per-API MCP for full responses.]", truncated, totalKB)), nil
+ }
+ return mcp.NewToolResultText(respText), nil
+ }
+
+ // 401/403 — generic auth error, no env var names, redact credentials.
+ if resp.StatusCode == 401 || resp.StatusCode == 403 {
+ redacted := RedactCredentials(respText, manifest)
+ return mcp.NewToolResultError(
+ fmt.Sprintf("Authentication error (HTTP %d): %s\n\nAuthentication not configured — call setup_guide(%q) for instructions",
+ resp.StatusCode, SanitizeText(redacted, 500), slug),
+ ), nil
+ }
+
+ // 429 — rate limited, surface response body.
+ if resp.StatusCode == 429 {
+ return mcp.NewToolResultError(
+ fmt.Sprintf("Rate limited (HTTP 429): %s", SanitizeText(respText, 1000)),
+ ), nil
+ }
+
+ // Other 4xx — return API error with credential redaction.
+ if resp.StatusCode >= 400 && resp.StatusCode < 500 {
+ redacted := RedactCredentials(respText, manifest)
+ return mcp.NewToolResultError(
+ fmt.Sprintf("API error (HTTP %d): %s", resp.StatusCode, SanitizeText(redacted, 1000)),
+ ), nil
+ }
+
+ // 5xx — server error.
+ return mcp.NewToolResultError(
+ fmt.Sprintf("Server error (HTTP %d): %s", resp.StatusCode, SanitizeText(respText, 500)),
+ ), nil
+ }
+}
diff --git a/internal/megamcp/handler_test.go b/internal/megamcp/handler_test.go
new file mode 100644
index 00000000..2e4b7425
--- /dev/null
+++ b/internal/megamcp/handler_test.go
@@ -0,0 +1,835 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newTestManifest returns a basic manifest for testing.
+func newTestManifest(baseURL string) *ToolsManifest {
+ return &ToolsManifest{
+ APIName: "testapi",
+ BaseURL: baseURL,
+ Auth: ManifestAuth{
+ Type: "none",
+ },
+ }
+}
+
+// newTestManifestWithAuth returns a manifest with API key auth.
+func newTestManifestWithAuth(baseURL, authType string, envVars []string) *ToolsManifest {
+ m := newTestManifest(baseURL)
+ m.Auth = ManifestAuth{
+ Type: authType,
+ Header: "X-Api-Key",
+ EnvVars: envVars,
+ }
+ return m
+}
+
+// makeToolRequest creates an MCP CallToolRequest with the given arguments.
+func makeToolRequest(args map[string]any) mcp.CallToolRequest {
+ // Arguments is typed as `any` in CallToolParams, but GetArguments()
+ // casts it to map[string]any. We pass args as map[string]any.
+ var arguments any
+ if args != nil {
+ arguments = map[string]any(args)
+ }
+ return mcp.CallToolRequest{
+ Params: mcp.CallToolParams{
+ Arguments: arguments,
+ },
+ }
+}
+
+func TestMakeToolHandler_GetWithPathParam(t *testing.T) {
+ var capturedPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedPath = r.URL.Path
+ w.WriteHeader(200)
+ w.Write([]byte(`{"id": 42}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "users_get",
+ Method: "GET",
+ Path: "/users/{user_id}",
+ Params: []ManifestParam{
+ {Name: "user_id", Type: "string", Location: "path", Required: true},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "user_id": "123",
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "/users/123", capturedPath)
+}
+
+func TestMakeToolHandler_GetWithQueryParams(t *testing.T) {
+ var capturedQuery string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.RawQuery
+ w.WriteHeader(200)
+ w.Write([]byte(`[]`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "users_list",
+ Method: "GET",
+ Path: "/users",
+ Params: []ManifestParam{
+ {Name: "page", Type: "integer", Location: "query"},
+ {Name: "limit", Type: "integer", Location: "query"},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "page": float64(2),
+ "limit": float64(10),
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Contains(t, capturedQuery, "page=2")
+ assert.Contains(t, capturedQuery, "limit=10")
+}
+
+func TestMakeToolHandler_PostWithBodyParams(t *testing.T) {
+ var capturedBody string
+ var capturedPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedPath = r.URL.Path
+ body, _ := io.ReadAll(r.Body)
+ capturedBody = string(body)
+ w.WriteHeader(201)
+ w.Write([]byte(`{"id": 1}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "users_create",
+ Method: "POST",
+ Path: "/orgs/{org_id}/users",
+ Params: []ManifestParam{
+ {Name: "org_id", Type: "string", Location: "path", Required: true},
+ {Name: "name", Type: "string", Location: "body", Required: true},
+ {Name: "email", Type: "string", Location: "body", Required: true},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "org_id": "acme",
+ "name": "Alice",
+ "email": "alice@example.com",
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "/orgs/acme/users", capturedPath)
+
+ // Verify body contains name and email but NOT org_id (path param).
+ var bodyMap map[string]any
+ require.NoError(t, json.Unmarshal([]byte(capturedBody), &bodyMap))
+ assert.Equal(t, "Alice", bodyMap["name"])
+ assert.Equal(t, "alice@example.com", bodyMap["email"])
+ _, hasOrgID := bodyMap["org_id"]
+ assert.False(t, hasOrgID, "path param org_id should not appear in body")
+}
+
+func TestMakeToolHandler_ApiKeyAuthInHeader(t *testing.T) {
+ var capturedAuthHeader string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedAuthHeader = r.Header.Get("X-Api-Key")
+ w.WriteHeader(200)
+ w.Write([]byte(`{"ok": true}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("TEST_API_KEY", "sk-test-12345")
+ manifest := newTestManifestWithAuth(srv.URL, "api_key", []string{"TEST_API_KEY"})
+
+ tool := ManifestTool{
+ Name: "status_get",
+ Method: "GET",
+ Path: "/status",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "sk-test-12345", capturedAuthHeader)
+}
+
+func TestMakeToolHandler_ApiKeyAuthInQuery(t *testing.T) {
+ var capturedQuery string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedQuery = r.URL.RawQuery
+ w.WriteHeader(200)
+ w.Write([]byte(`{"ok": true}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("ESPN_KEY", "espn-abc")
+ manifest := &ToolsManifest{
+ APIName: "espn",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "apikey",
+ In: "query",
+ EnvVars: []string{"ESPN_KEY"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "scores_get",
+ Method: "GET",
+ Path: "/scores",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Contains(t, capturedQuery, "apikey=espn-abc")
+}
+
+func TestMakeToolHandler_BearerTokenAuth(t *testing.T) {
+ var capturedAuth string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedAuth = r.Header.Get("Authorization")
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("DUB_TOKEN", "dub-tok-xyz")
+ manifest := &ToolsManifest{
+ APIName: "dub",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {DUB_TOKEN}",
+ EnvVars: []string{"DUB_TOKEN"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "links_list",
+ Method: "GET",
+ Path: "/links",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "Bearer dub-tok-xyz", capturedAuth)
+}
+
+func TestMakeToolHandler_RequiredHeaders(t *testing.T) {
+ var capturedHeaders http.Header
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHeaders = r.Header
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ manifest.RequiredHeaders = []ManifestHeader{
+ {Name: "Accept", Value: "application/json"},
+ {Name: "X-Custom", Value: "custom-value"},
+ }
+
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "application/json", capturedHeaders.Get("Accept"))
+ assert.Equal(t, "custom-value", capturedHeaders.Get("X-Custom"))
+}
+
+func TestMakeToolHandler_PerToolHeaderOverrides(t *testing.T) {
+ var capturedVersion string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedVersion = r.Header.Get("X-API-Version")
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ manifest.RequiredHeaders = []ManifestHeader{
+ {Name: "X-API-Version", Value: "2023-01-01"},
+ }
+
+ tool := ManifestTool{
+ Name: "v2_data_get",
+ Method: "GET",
+ Path: "/v2/data",
+ HeaderOverrides: []ManifestHeader{
+ {Name: "X-API-Version", Value: "2024-06-01"},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ // Per-tool override should win over required headers.
+ assert.Equal(t, "2024-06-01", capturedVersion)
+}
+
+func TestMakeToolHandler_NoAuth(t *testing.T) {
+ var capturedAuthHeader string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedAuthHeader = r.Header.Get("Authorization")
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+
+ tool := ManifestTool{
+ Name: "public_get",
+ Method: "GET",
+ Path: "/public",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Empty(t, capturedAuthHeader)
+}
+
+func TestMakeToolHandler_MissingEnvVarNonNoAuth(t *testing.T) {
+ // Don't start a server — request should never be made.
+ manifest := &ToolsManifest{
+ APIName: "myapi",
+ BaseURL: "https://api.example.com",
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"MISSING_ENV_VAR"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, &http.Client{Timeout: time.Second}, "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Authentication not configured")
+ assertResultContains(t, result, "setup_guide")
+}
+
+func TestMakeToolHandler_MissingEnvVarNoAuthEndpoint(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte(`{"public": true}`))
+ }))
+ defer srv.Close()
+
+ manifest := &ToolsManifest{
+ APIName: "myapi",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"MISSING_ENV_VAR"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "public_get",
+ Method: "GET",
+ Path: "/public",
+ NoAuth: true, // This endpoint doesn't need auth.
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError, "NoAuth endpoint should succeed without env var")
+}
+
+func TestMakeToolHandler_401Response(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(401)
+ w.Write([]byte(`{"error": "Invalid token: sk-real-key-123"}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("MY_TOKEN", "sk-real-key-123")
+ manifest := &ToolsManifest{
+ APIName: "myapi",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"MY_TOKEN"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Authentication error")
+ assertResultContains(t, result, "setup_guide")
+ // Credential should be redacted.
+ assertResultNotContains(t, result, "sk-real-key-123")
+ assertResultContains(t, result, "[REDACTED]")
+}
+
+func TestMakeToolHandler_429Response(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(429)
+ w.Write([]byte(`{"error": "rate limit exceeded", "retry_after": 30}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Rate limited")
+ assertResultContains(t, result, "rate limit exceeded")
+}
+
+func TestMakeToolHandler_LargeResponseTruncated(t *testing.T) {
+ // Create a response larger than 10MB.
+ largeBody := strings.Repeat("x", maxResponseBody+1000)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte(largeBody))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ // The response should be truncated at maxResponseBody.
+ text := extractResultText(result)
+ assert.LessOrEqual(t, len(text), maxResponseBody)
+}
+
+func TestMakeToolHandler_AgentResponseTruncation(t *testing.T) {
+ // Returns a 50KB response — should be truncated to 32KB for the agent.
+ bigBody := strings.Repeat("x", 50*1024)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte(bigBody))
+ }))
+ defer srv.Close()
+
+ manifest := &ToolsManifest{
+ APIName: "bigapi",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{Type: "none"},
+ }
+ tool := ManifestTool{Name: "big_get", Method: "GET", Path: "/big"}
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ text := extractResultText(result)
+ assert.Contains(t, text, "[Response truncated")
+ assert.Contains(t, text, "32KB of 50KB")
+ // The truncated output should be around 32KB + the truncation message.
+ assert.Less(t, len(text), 34*1024)
+}
+
+func TestMakeToolHandler_SmallResponseNotTruncated(t *testing.T) {
+ body := `{"status": "ok"}`
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ w.Write([]byte(body))
+ }))
+ defer srv.Close()
+
+ manifest := &ToolsManifest{
+ APIName: "smallapi",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{Type: "none"},
+ }
+ tool := ManifestTool{Name: "small_get", Method: "GET", Path: "/small"}
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ text := extractResultText(result)
+ assert.Equal(t, body, text)
+ assert.NotContains(t, text, "[Response truncated")
+}
+
+func TestMakeToolHandler_MissingRequiredPathParam(t *testing.T) {
+ manifest := &ToolsManifest{
+ APIName: "myapi",
+ BaseURL: "https://api.example.com",
+ Auth: ManifestAuth{Type: "none"},
+ }
+
+ tool := ManifestTool{
+ Name: "users_get",
+ Method: "GET",
+ Path: "/users/{user_id}/posts/{post_id}",
+ Params: []ManifestParam{
+ {Name: "user_id", Type: "string", Location: "path", Required: true},
+ {Name: "post_id", Type: "string", Location: "path", Required: true},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, &http.Client{Timeout: time.Second}, "test-api")
+ // Only provide user_id, not post_id.
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "user_id": "123",
+ }))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Missing required path parameter")
+ assertResultContains(t, result, "post_id")
+}
+
+func TestMakeToolHandler_PathParamWithTraversal(t *testing.T) {
+ var capturedRawPath string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedRawPath = r.URL.RawPath
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "files_get",
+ Method: "GET",
+ Path: "/files/{file_id}",
+ Params: []ManifestParam{
+ {Name: "file_id", Type: "string", Location: "path", Required: true},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "file_id": "../../../etc/passwd",
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError, "URL-encoded path traversal should not cause host mismatch")
+ // The ../ should be URL-encoded in the raw path (Go's HTTP server decodes
+ // %2F back to / in r.URL.Path, but RawPath preserves the encoding).
+ assert.Contains(t, capturedRawPath, "%2F")
+}
+
+func TestMakeToolHandler_500Response(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(500)
+ w.Write([]byte(`internal server error`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_get",
+ Method: "GET",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Server error (HTTP 500)")
+}
+
+func TestMakeToolHandler_IntegrationRoundTrip(t *testing.T) {
+ // Full round-trip test: GET with path+query params, auth header, required headers.
+ var capturedReq struct {
+ Method string
+ Path string
+ Query string
+ Headers http.Header
+ Body string
+ }
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedReq.Method = r.Method
+ capturedReq.Path = r.URL.Path
+ capturedReq.Query = r.URL.RawQuery
+ capturedReq.Headers = r.Header
+ body, _ := io.ReadAll(r.Body)
+ capturedReq.Body = string(body)
+ w.WriteHeader(200)
+ w.Write([]byte(`{"result": "success"}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("DUB_TOKEN", "tok-abc")
+ manifest := &ToolsManifest{
+ APIName: "dub",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {DUB_TOKEN}",
+ EnvVars: []string{"DUB_TOKEN"},
+ },
+ RequiredHeaders: []ManifestHeader{
+ {Name: "Accept", Value: "application/json"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "links_update",
+ Method: "PATCH",
+ Path: "/links/{link_id}",
+ Params: []ManifestParam{
+ {Name: "link_id", Type: "string", Location: "path", Required: true},
+ {Name: "url", Type: "string", Location: "body"},
+ {Name: "title", Type: "string", Location: "body"},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "link_id": "lnk_42",
+ "url": "https://example.com",
+ "title": "Example",
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+
+ // Verify request details.
+ assert.Equal(t, "PATCH", capturedReq.Method)
+ assert.Equal(t, "/links/lnk_42", capturedReq.Path)
+ assert.Equal(t, "Bearer tok-abc", capturedReq.Headers.Get("Authorization"))
+ assert.Equal(t, "application/json", capturedReq.Headers.Get("Accept"))
+ assert.Equal(t, "application/json", capturedReq.Headers.Get("Content-Type"))
+
+ // Verify body: url and title present, link_id absent.
+ var bodyMap map[string]any
+ require.NoError(t, json.Unmarshal([]byte(capturedReq.Body), &bodyMap))
+ assert.Equal(t, "https://example.com", bodyMap["url"])
+ assert.Equal(t, "Example", bodyMap["title"])
+ _, hasLinkID := bodyMap["link_id"]
+ assert.False(t, hasLinkID, "path param link_id should not be in body")
+}
+
+func TestMakeToolHandler_403Response(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(403)
+ w.Write([]byte(`{"error": "forbidden"}`))
+ }))
+ defer srv.Close()
+
+ t.Setenv("MY_KEY", "secret-key-value")
+ manifest := &ToolsManifest{
+ APIName: "myapi",
+ BaseURL: srv.URL,
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "X-Api-Key",
+ EnvVars: []string{"MY_KEY"},
+ },
+ }
+
+ tool := ManifestTool{
+ Name: "admin_get",
+ Method: "GET",
+ Path: "/admin",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "Authentication error")
+ assertResultContains(t, result, "setup_guide")
+ // Env var name should not be in the error.
+ assertResultNotContains(t, result, "MY_KEY")
+}
+
+func TestMakeToolHandler_4xxResponse(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(422)
+ w.Write([]byte(`{"error": "validation failed"}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_create",
+ Method: "POST",
+ Path: "/data",
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(nil))
+
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "API error (HTTP 422)")
+ assertResultContains(t, result, "validation failed")
+}
+
+func TestMakeToolHandler_ContentTypeForPOST(t *testing.T) {
+ var capturedContentType string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedContentType = r.Header.Get("Content-Type")
+ w.WriteHeader(200)
+ w.Write([]byte(`{}`))
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_create",
+ Method: "POST",
+ Path: "/data",
+ Params: []ManifestParam{
+ {Name: "name", Type: "string", Location: "body"},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ _, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "name": "test",
+ }))
+
+ require.NoError(t, err)
+ assert.Equal(t, "application/json", capturedContentType)
+}
+
+func TestMakeToolHandler_DeleteMethod(t *testing.T) {
+ var capturedMethod string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedMethod = r.Method
+ w.WriteHeader(204)
+ }))
+ defer srv.Close()
+
+ manifest := newTestManifest(srv.URL)
+ tool := ManifestTool{
+ Name: "data_delete",
+ Method: "DELETE",
+ Path: "/data/{id}",
+ Params: []ManifestParam{
+ {Name: "id", Type: "string", Location: "path", Required: true},
+ },
+ }
+
+ handler := MakeToolHandler(manifest, tool, srv.Client(), "test-api")
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{
+ "id": "42",
+ }))
+
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+ assert.Equal(t, "DELETE", capturedMethod)
+}
+
+// --- Helper functions ---
+
+// extractResultText extracts the text content from an MCP CallToolResult.
+func extractResultText(result *mcp.CallToolResult) string {
+ if result == nil || len(result.Content) == 0 {
+ return ""
+ }
+ for _, c := range result.Content {
+ if tc, ok := c.(mcp.TextContent); ok {
+ return tc.Text
+ }
+ }
+ return ""
+}
+
+// assertResultContains checks that the result text contains the expected substring.
+func assertResultContains(t *testing.T, result *mcp.CallToolResult, expected string) {
+ t.Helper()
+ text := extractResultText(result)
+ assert.Contains(t, text, expected, "expected result to contain %q, got %q", expected, text)
+}
+
+// assertResultNotContains checks that the result text does NOT contain the given substring.
+func assertResultNotContains(t *testing.T, result *mcp.CallToolResult, unexpected string) {
+ t.Helper()
+ text := extractResultText(result)
+ assert.NotContains(t, text, unexpected, "expected result NOT to contain %q, got %q", unexpected, text)
+}
diff --git a/internal/megamcp/manifest.go b/internal/megamcp/manifest.go
new file mode 100644
index 00000000..056077b1
--- /dev/null
+++ b/internal/megamcp/manifest.go
@@ -0,0 +1,348 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "golang.org/x/sync/errgroup"
+)
+
+// LoadManifests fetches and caches tools manifests for all eligible registry entries.
+// It applies the PRINTING_PRESS_APIS env var filter, loads manifests in parallel,
+// and returns successfully loaded API entries plus any warnings.
+// Failed fetches produce warnings but do not block other APIs.
+// If entries is nil (registry fetch failed), falls back to cached manifests.
+func LoadManifests(entries []RegistryEntry, cacheDir, baseURL string) ([]*APIEntry, []string) {
+ // If no registry was provided (fetch failed), try loading from cache.
+ if entries == nil {
+ return loadFromCacheOnly(cacheDir)
+ }
+
+ // Apply PRINTING_PRESS_APIS filter if set.
+ filtered := filterEntries(entries)
+
+ var (
+ mu sync.Mutex
+ results []*APIEntry
+ warnings []string
+ )
+
+ var g errgroup.Group
+
+ for _, entry := range filtered {
+ g.Go(func() error {
+ apiEntry, warn := loadSingleManifest(entry, cacheDir, baseURL)
+ mu.Lock()
+ defer mu.Unlock()
+ if warn != "" {
+ warnings = append(warnings, warn)
+ }
+ if apiEntry != nil {
+ results = append(results, apiEntry)
+ }
+ return nil // never fail the group; warnings are collected
+ })
+ }
+
+ // errgroup.Group (not WithContext) — errors don't cancel siblings.
+ _ = g.Wait()
+
+ return results, warnings
+}
+
+// loadFromCacheOnly scans the cache directory for previously cached manifests.
+// Used as a fallback when the registry fetch fails.
+func loadFromCacheOnly(cacheDir string) ([]*APIEntry, []string) {
+ manifestsDir := filepath.Join(cacheDir, "manifests")
+ dirEntries, err := os.ReadDir(manifestsDir)
+ if err != nil {
+ return nil, []string{"no cached manifests available (registry fetch failed)"}
+ }
+
+ apiFilter := parseAPIFilter()
+ var results []*APIEntry
+ var warnings []string
+
+ for _, de := range dirEntries {
+ if !de.IsDir() {
+ continue
+ }
+ slug := de.Name()
+ if err := ValidateSlug(slug); err != nil {
+ continue
+ }
+ if apiFilter != nil && !apiFilter[slug] {
+ continue
+ }
+
+ cachePath := filepath.Join(manifestsDir, slug, "tools-manifest.json")
+ data, err := os.ReadFile(cachePath)
+ if err != nil {
+ continue
+ }
+
+ manifest, err := parseManifest(data)
+ if err != nil {
+ warnings = append(warnings, fmt.Sprintf("cached manifest for %q is corrupt: %v", slug, err))
+ continue
+ }
+
+ // Validate base URL even for cached manifests.
+ if err := ValidateBaseURL(manifest.BaseURL); err != nil {
+ warnings = append(warnings, fmt.Sprintf("skipping cached %q: unsafe base URL: %v", slug, err))
+ continue
+ }
+
+ prefix, err := slugToToolPrefix(slug)
+ if err != nil {
+ continue
+ }
+
+ results = append(results, &APIEntry{
+ Slug: slug,
+ Dir: filepath.Join(manifestsDir, slug),
+ Manifest: manifest,
+ NormalizedPrefix: prefix,
+ })
+ }
+
+ if len(results) > 0 {
+ warnings = append(warnings, fmt.Sprintf("registry fetch failed; loaded %d APIs from cache", len(results)))
+ }
+
+ return results, warnings
+}
+
+// filterEntries applies the PRINTING_PRESS_APIS env var filter and excludes
+// entries that are cli-only or have no ManifestURL.
+func filterEntries(entries []RegistryEntry) []RegistryEntry {
+ apiFilter := parseAPIFilter()
+
+ var filtered []RegistryEntry
+ for _, entry := range entries {
+ // Skip entries without manifest URL.
+ if entry.MCP.ManifestURL == "" {
+ continue
+ }
+ // Skip cli-only entries.
+ if entry.MCP.MCPReady == "cli-only" {
+ continue
+ }
+ // Apply PRINTING_PRESS_APIS filter if set.
+ if apiFilter != nil {
+ slug := extractSlug(entry)
+ if !apiFilter[slug] {
+ continue
+ }
+ }
+ filtered = append(filtered, entry)
+ }
+ return filtered
+}
+
+// parseAPIFilter returns a set of allowed API slugs from PRINTING_PRESS_APIS,
+// or nil if the env var is not set (meaning all APIs are allowed).
+func parseAPIFilter() map[string]bool {
+ val := os.Getenv("PRINTING_PRESS_APIS")
+ if val == "" {
+ return nil
+ }
+ parts := strings.Split(val, ",")
+ filter := make(map[string]bool, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ filter[p] = true
+ }
+ }
+ return filter
+}
+
+// extractSlug derives the API slug from a registry entry's API field.
+func extractSlug(entry RegistryEntry) string {
+ return entry.API
+}
+
+// loadSingleManifest loads and caches a single API's tools manifest.
+// Returns the APIEntry on success, or a warning string on failure.
+func loadSingleManifest(entry RegistryEntry, cacheDir, baseURL string) (*APIEntry, string) {
+ slug := extractSlug(entry)
+
+ // Validate slug before constructing any paths.
+ if err := ValidateSlug(slug); err != nil {
+ return nil, fmt.Sprintf("skipping %q: %v", slug, err)
+ }
+
+ prefix, err := slugToToolPrefix(slug)
+ if err != nil {
+ return nil, fmt.Sprintf("skipping %q: invalid tool prefix: %v", slug, err)
+ }
+
+ // Construct cache path and validate it.
+ cacheSubDir := filepath.Join(cacheDir, "manifests", slug)
+ cachePath := filepath.Join(cacheSubDir, "tools-manifest.json")
+
+ if err := ValidateCachePath(cachePath, cacheDir); err != nil {
+ return nil, fmt.Sprintf("skipping %q: %v", slug, err)
+ }
+
+ // Try cached manifest first.
+ manifest, err := tryCache(cachePath, entry.MCP.ManifestChecksum)
+ if err == nil && manifest != nil {
+ // Validate base URL even for cached manifests.
+ if urlErr := ValidateBaseURL(manifest.BaseURL); urlErr != nil {
+ return nil, fmt.Sprintf("skipping %q: unsafe base URL %q: %v", slug, manifest.BaseURL, urlErr)
+ }
+ return &APIEntry{
+ Slug: slug,
+ Dir: cacheSubDir,
+ Manifest: manifest,
+ NormalizedPrefix: prefix,
+ }, ""
+ }
+
+ // Cache miss or checksum mismatch — fetch from remote.
+ manifestURL := baseURL + "/" + entry.MCP.ManifestURL
+ data, err := fetchManifestData(manifestURL)
+ if err != nil {
+ return nil, fmt.Sprintf("skipping %q: fetch failed: %v", slug, err)
+ }
+
+ // Verify checksum if provided.
+ if entry.MCP.ManifestChecksum != "" {
+ if err := VerifyChecksum(data, entry.MCP.ManifestChecksum); err != nil {
+ return nil, fmt.Sprintf("skipping %q: %v", slug, err)
+ }
+ }
+
+ // Parse manifest.
+ manifest, err = parseManifest(data)
+ if err != nil {
+ return nil, fmt.Sprintf("skipping %q: %v", slug, err)
+ }
+
+ // Validate the manifest's base URL against SSRF protections.
+ if err := ValidateBaseURL(manifest.BaseURL); err != nil {
+ return nil, fmt.Sprintf("skipping %q: unsafe base URL %q: %v", slug, manifest.BaseURL, err)
+ }
+
+ // Write to cache via temp-then-rename.
+ if err := writeCache(cacheSubDir, cachePath, data); err != nil {
+ // Cache write failure is a warning, not a blocker.
+ return &APIEntry{
+ Slug: slug,
+ Dir: cacheSubDir,
+ Manifest: manifest,
+ NormalizedPrefix: prefix,
+ }, fmt.Sprintf("warning: %q: cache write failed: %v", slug, err)
+ }
+
+ return &APIEntry{
+ Slug: slug,
+ Dir: cacheSubDir,
+ Manifest: manifest,
+ NormalizedPrefix: prefix,
+ }, ""
+}
+
+// tryCache attempts to read and verify a cached manifest.
+// Returns nil, nil if cache doesn't exist.
+// Returns nil, error if cache exists but checksum mismatches or is unreadable.
+func tryCache(cachePath, expectedChecksum string) (*ToolsManifest, error) {
+ data, err := os.ReadFile(cachePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("reading cache: %w", err)
+ }
+
+ // Verify checksum if expected is provided.
+ if expectedChecksum != "" {
+ if err := VerifyChecksum(data, expectedChecksum); err != nil {
+ return nil, fmt.Errorf("cache checksum mismatch: %w", err)
+ }
+ }
+
+ manifest, err := parseManifest(data)
+ if err != nil {
+ return nil, fmt.Errorf("parsing cached manifest: %w", err)
+ }
+
+ return manifest, nil
+}
+
+// fetchManifestData downloads manifest data from a URL.
+func fetchManifestData(manifestURL string) ([]byte, error) {
+ resp, err := http.Get(manifestURL)
+ if err != nil {
+ return nil, fmt.Errorf("HTTP request: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+
+ data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ return data, nil
+}
+
+// parseManifest unmarshals JSON data into a ToolsManifest.
+func parseManifest(data []byte) (*ToolsManifest, error) {
+ var manifest ToolsManifest
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return nil, fmt.Errorf("parsing manifest JSON: %w", err)
+ }
+ return &manifest, nil
+}
+
+// writeCache writes manifest data to disk via temp-then-rename for atomicity.
+// Creates the directory structure with 0700 permissions and writes files with 0600.
+func writeCache(dir, cachePath string, data []byte) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("creating cache directory: %w", err)
+ }
+
+ tmpPath := cachePath + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
+ return fmt.Errorf("writing temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, cachePath); err != nil {
+ _ = os.Remove(tmpPath) // clean up on failure
+ return fmt.Errorf("renaming temp file: %w", err)
+ }
+
+ return nil
+}
+
+// slugToToolPrefix converts an API slug to a tool name prefix.
+// Hyphens become underscores, consecutive underscores are collapsed,
+// and the result is rejected if it still contains "__".
+func slugToToolPrefix(slug string) (string, error) {
+ // Replace hyphens with underscores.
+ result := strings.ReplaceAll(slug, "-", "_")
+
+ // Collapse consecutive underscores.
+ for strings.Contains(result, "__") {
+ result = strings.ReplaceAll(result, "__", "_")
+ }
+
+ // Reject if empty after normalization.
+ if result == "" || result == "_" {
+ return "", fmt.Errorf("slug %q normalizes to empty prefix", slug)
+ }
+
+ return result, nil
+}
diff --git a/internal/megamcp/manifest_test.go b/internal/megamcp/manifest_test.go
new file mode 100644
index 00000000..10edd344
--- /dev/null
+++ b/internal/megamcp/manifest_test.go
@@ -0,0 +1,445 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// makeTestManifest creates a ToolsManifest for testing.
+func makeTestManifest(apiName string) *ToolsManifest {
+ return &ToolsManifest{
+ APIName: apiName,
+ BaseURL: "https://api.example.com",
+ Description: apiName + " API",
+ MCPReady: "full",
+ Auth: ManifestAuth{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {TOKEN}",
+ In: "header",
+ EnvVars: []string{"TOKEN"},
+ },
+ RequiredHeaders: []ManifestHeader{},
+ Tools: []ManifestTool{
+ {
+ Name: "items_list",
+ Description: "List items",
+ Method: "GET",
+ Path: "/items",
+ Params: []ManifestParam{
+ {Name: "limit", Type: "integer", Location: "query"},
+ },
+ },
+ },
+ }
+}
+
+// marshalManifest marshals a manifest to JSON bytes.
+func marshalManifest(t *testing.T, m *ToolsManifest) []byte {
+ t.Helper()
+ data, err := json.MarshalIndent(m, "", " ")
+ require.NoError(t, err)
+ return data
+}
+
+// makeManifestServer creates an httptest server that serves manifests by slug.
+func makeManifestServer(t *testing.T, manifests map[string][]byte) *httptest.Server {
+ t.Helper()
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ for slug, data := range manifests {
+ if r.URL.Path == "/library/"+slug+"/tools-manifest.json" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(data)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+}
+
+func TestLoadManifestsHappyPath(t *testing.T) {
+ espnManifest := makeTestManifest("ESPN")
+ dubManifest := makeTestManifest("Dub")
+ espnData := marshalManifest(t, espnManifest)
+ dubData := marshalManifest(t, dubManifest)
+ espnChecksum := ComputeChecksum(espnData)
+ dubChecksum := ComputeChecksum(dubData)
+
+ server := makeManifestServer(t, map[string][]byte{
+ "espn": espnData,
+ "dub": dubData,
+ })
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: espnChecksum,
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ {
+ API: "dub",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: dubChecksum,
+ ManifestURL: "library/dub/tools-manifest.json",
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ assert.Len(t, results, 2)
+
+ // Both manifests should be cached.
+ for _, entry := range results {
+ cachePath := filepath.Join(cacheDir, "manifests", entry.Slug, "tools-manifest.json")
+ assert.FileExists(t, cachePath)
+ }
+}
+
+func TestLoadManifestsCachedWithMatchingChecksum(t *testing.T) {
+ manifest := makeTestManifest("ESPN")
+ data := marshalManifest(t, manifest)
+ checksum := ComputeChecksum(data)
+
+ // Pre-populate cache.
+ cacheDir := t.TempDir()
+ cacheSubDir := filepath.Join(cacheDir, "manifests", "espn")
+ require.NoError(t, os.MkdirAll(cacheSubDir, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(cacheSubDir, "tools-manifest.json"), data, 0o600))
+
+ // Server that should NOT be called.
+ fetchCalled := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ fetchCalled = true
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: checksum,
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ }
+
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ assert.Len(t, results, 1)
+ assert.Equal(t, "espn", results[0].Slug)
+ assert.Equal(t, "ESPN", results[0].Manifest.APIName)
+ assert.False(t, fetchCalled, "server should not have been called when cache is valid")
+}
+
+func TestLoadManifestsCachedWithMismatchedChecksum(t *testing.T) {
+ oldManifest := makeTestManifest("ESPN Old")
+ oldData := marshalManifest(t, oldManifest)
+
+ newManifest := makeTestManifest("ESPN New")
+ newData := marshalManifest(t, newManifest)
+ newChecksum := ComputeChecksum(newData)
+
+ // Pre-populate cache with old data.
+ cacheDir := t.TempDir()
+ cacheSubDir := filepath.Join(cacheDir, "manifests", "espn")
+ require.NoError(t, os.MkdirAll(cacheSubDir, 0o700))
+ require.NoError(t, os.WriteFile(filepath.Join(cacheSubDir, "tools-manifest.json"), oldData, 0o600))
+
+ // Server returns new manifest.
+ server := makeManifestServer(t, map[string][]byte{
+ "espn": newData,
+ })
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: newChecksum,
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ }
+
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ require.Len(t, results, 1)
+ assert.Equal(t, "ESPN New", results[0].Manifest.APIName, "should have fetched new manifest")
+
+ // Verify cache was updated.
+ cachedData, err := os.ReadFile(filepath.Join(cacheSubDir, "tools-manifest.json"))
+ require.NoError(t, err)
+ assert.NoError(t, VerifyChecksum(cachedData, newChecksum), "cache should contain new data")
+}
+
+func TestLoadManifestsPrintingPressAPIsFilter(t *testing.T) {
+ espnData := marshalManifest(t, makeTestManifest("ESPN"))
+ dubData := marshalManifest(t, makeTestManifest("Dub"))
+ espnChecksum := ComputeChecksum(espnData)
+ dubChecksum := ComputeChecksum(dubData)
+
+ server := makeManifestServer(t, map[string][]byte{
+ "espn": espnData,
+ "dub": dubData,
+ })
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: espnChecksum,
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ {
+ API: "dub",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: dubChecksum,
+ ManifestURL: "library/dub/tools-manifest.json",
+ },
+ },
+ }
+
+ t.Setenv("PRINTING_PRESS_APIS", "espn")
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ require.Len(t, results, 1)
+ assert.Equal(t, "espn", results[0].Slug)
+}
+
+func TestLoadManifestsOneFetchFailsOthersSucceed(t *testing.T) {
+ dubData := marshalManifest(t, makeTestManifest("Dub"))
+ dubChecksum := ComputeChecksum(dubData)
+
+ // Server returns 404 for espn, 200 for dub.
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/library/dub/tools-manifest.json" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(dubData)
+ return
+ }
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: "sha256:abc",
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ {
+ API: "dub",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: dubChecksum,
+ ManifestURL: "library/dub/tools-manifest.json",
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ require.Len(t, results, 1)
+ assert.Equal(t, "dub", results[0].Slug)
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "espn")
+}
+
+func TestLoadManifestsCLIOnlyFiltered(t *testing.T) {
+ entries := []RegistryEntry{
+ {
+ API: "pagliacci",
+ MCP: RegistryMCP{
+ MCPReady: "cli-only",
+ ManifestURL: "library/pagliacci/tools-manifest.json",
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ // No server needed since cli-only entries are filtered out before fetch.
+ results, warnings := LoadManifests(entries, cacheDir, "http://unused")
+
+ assert.Empty(t, results)
+ assert.Empty(t, warnings)
+}
+
+func TestLoadManifestsSlugTraversalRejected(t *testing.T) {
+ entries := []RegistryEntry{
+ {
+ API: "../etc",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestURL: "library/etc/tools-manifest.json",
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, "http://unused")
+
+ assert.Empty(t, results)
+ require.Len(t, warnings, 1)
+ assert.Contains(t, warnings[0], "path traversal")
+}
+
+func TestLoadManifestsCacheDirCreated(t *testing.T) {
+ manifest := makeTestManifest("ESPN")
+ data := marshalManifest(t, manifest)
+ checksum := ComputeChecksum(data)
+
+ server := makeManifestServer(t, map[string][]byte{
+ "espn": data,
+ })
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "espn",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: checksum,
+ ManifestURL: "library/espn/tools-manifest.json",
+ },
+ },
+ }
+
+ // Use a nested cache dir that doesn't exist yet.
+ cacheDir := filepath.Join(t.TempDir(), "nested", "cache")
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ require.Len(t, results, 1)
+
+ // Verify the cache directory was created.
+ cachePath := filepath.Join(cacheDir, "manifests", "espn", "tools-manifest.json")
+ assert.FileExists(t, cachePath)
+}
+
+func TestLoadManifestsEmptyManifestURLFiltered(t *testing.T) {
+ entries := []RegistryEntry{
+ {
+ API: "nourl",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestURL: "", // empty
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, "http://unused")
+
+ assert.Empty(t, results)
+ assert.Empty(t, warnings)
+}
+
+func TestSlugToToolPrefix(t *testing.T) {
+ tests := []struct {
+ name string
+ slug string
+ want string
+ wantErr bool
+ }{
+ {
+ name: "simple slug",
+ slug: "dub",
+ want: "dub",
+ },
+ {
+ name: "hyphenated slug",
+ slug: "steam-web",
+ want: "steam_web",
+ },
+ {
+ name: "consecutive hyphens collapsed",
+ slug: "steam--web",
+ want: "steam_web",
+ },
+ {
+ name: "multiple hyphens",
+ slug: "my-cool-api",
+ want: "my_cool_api",
+ },
+ {
+ name: "empty slug errors",
+ slug: "",
+ wantErr: true,
+ },
+ {
+ name: "single hyphen errors",
+ slug: "-",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := slugToToolPrefix(tt.slug)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestLoadManifestsNormalizedPrefix(t *testing.T) {
+ data := marshalManifest(t, makeTestManifest("Steam Web"))
+ checksum := ComputeChecksum(data)
+
+ server := makeManifestServer(t, map[string][]byte{
+ "steam-web": data,
+ })
+ defer server.Close()
+
+ entries := []RegistryEntry{
+ {
+ API: "steam-web",
+ MCP: RegistryMCP{
+ MCPReady: "full",
+ ManifestChecksum: checksum,
+ ManifestURL: "library/steam-web/tools-manifest.json",
+ },
+ },
+ }
+
+ cacheDir := t.TempDir()
+ results, warnings := LoadManifests(entries, cacheDir, server.URL)
+
+ assert.Empty(t, warnings)
+ require.Len(t, results, 1)
+ assert.Equal(t, "steam_web", results[0].NormalizedPrefix)
+}
diff --git a/internal/megamcp/metatools.go b/internal/megamcp/metatools.go
new file mode 100644
index 00000000..80e8c626
--- /dev/null
+++ b/internal/megamcp/metatools.go
@@ -0,0 +1,476 @@
+package megamcp
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/mvanhorn/cli-printing-press/internal/naming"
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+ "github.com/mvanhorn/cli-printing-press/internal/version"
+)
+
+// RegisterMetaTools registers the 6 agent-facing discovery and activation tools.
+func RegisterMetaTools(s *server.MCPServer, am *ActivationManager) {
+ s.AddTool(
+ mcp.NewTool("library_info",
+ mcp.WithDescription("List all available APIs in the printing press library with metadata, auth status, and tool counts"),
+ ),
+ makeLibraryInfoHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("setup_guide",
+ mcp.WithDescription("Get auth setup instructions for a specific API: env var names, key URL, and example claude mcp add command"),
+ mcp.WithString("api_slug", mcp.Required(), mcp.Description("API slug (e.g., 'dub', 'espn')")),
+ ),
+ makeSetupGuideHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("activate_api",
+ mcp.WithDescription("Activate an API to register its tools for use. Call library_info first to see available APIs."),
+ mcp.WithString("api_slug", mcp.Required(), mcp.Description("API slug to activate (e.g., 'dub', 'espn')")),
+ ),
+ makeActivateAPIHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("deactivate_api",
+ mcp.WithDescription("Deactivate an API and remove its tools"),
+ mcp.WithString("api_slug", mcp.Required(), mcp.Description("API slug to deactivate")),
+ ),
+ makeDeactivateAPIHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("search_tools",
+ mcp.WithDescription("Search for tools across all APIs by keyword. Works on unactivated APIs too."),
+ mcp.WithString("query", mcp.Required(), mcp.Description("Search query to match against tool names and descriptions")),
+ ),
+ makeSearchToolsHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("about",
+ mcp.WithDescription("Describe this mega MCP server's version, API count, and total tool count"),
+ ),
+ makeAboutHandler(am),
+ )
+
+ s.AddTool(
+ mcp.NewTool("debug_api",
+ mcp.WithDescription("Health check an API: verifies base URL, auth configuration, and connectivity. Use when API calls are failing."),
+ mcp.WithString("api_slug", mcp.Required(), mcp.Description("API slug to debug (e.g., 'dub', 'espn')")),
+ ),
+ makeDebugAPIHandler(am),
+ )
+}
+
+// --- library_info ---
+
+// libraryInfoResponse is the JSON structure returned by library_info.
+type libraryInfoResponse struct {
+ APIs []libraryInfoAPI `json:"apis"`
+ TotalAPIs int `json:"total_apis"`
+ TotalTools int `json:"total_tools"`
+ Version string `json:"version"`
+}
+
+type libraryInfoAPI struct {
+ Slug string `json:"slug"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ToolCount int `json:"tool_count"`
+ PublicToolCount int `json:"public_tool_count"`
+ AuthType string `json:"auth_type"`
+ MCPReady string `json:"mcp_ready"`
+ AuthConfigured bool `json:"auth_configured"`
+ Activated bool `json:"activated"`
+ UpgradeAvailable bool `json:"upgrade_available"`
+ NovelFeatures []string `json:"novel_features,omitempty"`
+}
+
+func makeLibraryInfoHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ entries := am.AllManifests()
+
+ resp := libraryInfoResponse{
+ APIs: make([]libraryInfoAPI, 0, len(entries)),
+ Version: version.Get(),
+ }
+
+ totalTools := 0
+ for _, entry := range entries {
+ m := entry.Manifest
+ toolCount := len(m.Tools)
+ publicToolCount := countPublicTools(m)
+ totalTools += toolCount
+
+ api := libraryInfoAPI{
+ Slug: entry.Slug,
+ Name: m.APIName,
+ Description: SanitizeText(m.Description, 200),
+ ToolCount: toolCount,
+ PublicToolCount: publicToolCount,
+ AuthType: m.Auth.Type,
+ MCPReady: m.MCPReady,
+ AuthConfigured: hasAuthConfigured(m),
+ Activated: am.IsActivated(entry.Slug),
+ UpgradeAvailable: checkUpgradeAvailable(entry.Slug),
+ }
+
+ // Include novel features if present (from manifest description, not a separate field).
+ // The ToolsManifest doesn't carry novel features directly, but we can note
+ // their existence through the registry. For now, this field is omitted.
+
+ resp.APIs = append(resp.APIs, api)
+ }
+
+ resp.TotalAPIs = len(entries)
+ resp.TotalTools = totalTools
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error serializing library info: %v", err)), nil
+ }
+
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+// countPublicTools counts tools that have NoAuth=true.
+func countPublicTools(m *ToolsManifest) int {
+ count := 0
+ for _, t := range m.Tools {
+ if t.NoAuth {
+ count++
+ }
+ }
+ // If auth type is "none", all tools are public.
+ if m.Auth.Type == "" || m.Auth.Type == "none" {
+ return len(m.Tools)
+ }
+ return count
+}
+
+// checkUpgradeAvailable checks if a local per-API MCP binary exists,
+// indicating the user could use a dedicated MCP server with full features.
+func checkUpgradeAvailable(slug string) bool {
+ libraryRoot := pipeline.PublishedLibraryRoot()
+
+ // Check both directory layouts:
+ // 1. ~/printing-press/library/{slug}-pp-cli/cmd/{slug}-pp-mcp/
+ // 2. ~/printing-press/library/{slug}/cmd/{slug}-pp-mcp/
+ mcpBinary := naming.MCP(slug)
+
+ paths := []string{
+ filepath.Join(libraryRoot, naming.CLI(slug), "cmd", mcpBinary),
+ filepath.Join(libraryRoot, slug, "cmd", mcpBinary),
+ }
+
+ for _, p := range paths {
+ if info, err := os.Stat(p); err == nil && info.IsDir() {
+ return true
+ }
+ }
+ return false
+}
+
+// --- setup_guide ---
+
+func makeSetupGuideHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ slug, err := getStringArg(req, "api_slug")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ entry := am.GetManifest(slug)
+ if entry == nil {
+ return mcp.NewToolResultError(fmt.Sprintf("API not found: %q", slug)), nil
+ }
+
+ m := entry.Manifest
+
+ // No auth API.
+ if m.Auth.Type == "" || m.Auth.Type == "none" {
+ return mcp.NewToolResultText(fmt.Sprintf(
+ "No authentication required for %s — all endpoints are publicly accessible.\n\n"+
+ "Activate with: activate_api(%q)", m.APIName, slug)), nil
+ }
+
+ var sb strings.Builder
+ fmt.Fprintf(&sb, "# Setup Guide for %s\n\n", m.APIName)
+ fmt.Fprintf(&sb, "**Auth type:** %s\n\n", m.Auth.Type)
+
+ // Env vars.
+ if len(m.Auth.EnvVars) > 0 {
+ sb.WriteString("**Required environment variables:**\n")
+ for _, envVar := range m.Auth.EnvVars {
+ fmt.Fprintf(&sb, "- `%s`\n", envVar)
+ }
+ sb.WriteString("\n")
+ }
+
+ // Key URL.
+ if m.Auth.KeyURL != "" {
+ fmt.Fprintf(&sb, "**Get your API key:** %s\n\n", m.Auth.KeyURL)
+ }
+
+ // Example claude mcp add command.
+ if len(m.Auth.EnvVars) > 0 {
+ sb.WriteString("**Example setup command:**\n```\nclaude mcp add printing-press")
+ for _, envVar := range m.Auth.EnvVars {
+ fmt.Fprintf(&sb, " --env %s=<your-key>", envVar)
+ }
+ sb.WriteString(" -- printing-press-mcp\n```\n\n")
+ }
+
+ fmt.Fprintf(&sb, "Once configured, activate with: activate_api(%q)", slug)
+
+ return mcp.NewToolResultText(sb.String()), nil
+ }
+}
+
+// --- activate_api ---
+
+func makeActivateAPIHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ slug, err := getStringArg(req, "api_slug")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ count, err := am.Activate(slug)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Failed to activate API: %v", err)), nil
+ }
+
+ // Get example tool names for confirmation message.
+ toolNames := am.toolNamesForSlug(slug)
+ examples := ""
+ if len(toolNames) > 0 {
+ limit := 3
+ if len(toolNames) < limit {
+ limit = len(toolNames)
+ }
+ examples = "\n\nExample tools:\n"
+ for _, name := range toolNames[:limit] {
+ examples += fmt.Sprintf("- %s\n", name)
+ }
+ if len(toolNames) > 3 {
+ examples += fmt.Sprintf("- ... and %d more\n", len(toolNames)-3)
+ }
+ }
+
+ return mcp.NewToolResultText(fmt.Sprintf(
+ "Activated %q — %d tools registered.%s", slug, count, examples)), nil
+ }
+}
+
+// --- deactivate_api ---
+
+func makeDeactivateAPIHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ slug, err := getStringArg(req, "api_slug")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ if err := am.Deactivate(slug); err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Failed to deactivate API: %v", err)), nil
+ }
+
+ return mcp.NewToolResultText(fmt.Sprintf("Deactivated %q — tools removed.", slug)), nil
+ }
+}
+
+// --- search_tools ---
+
+type searchToolsResponse struct {
+ Results []SearchResult `json:"results"`
+ Count int `json:"count"`
+}
+
+func makeSearchToolsHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ query, err := getStringArg(req, "query")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ results := am.SearchTools(query)
+
+ resp := searchToolsResponse{
+ Results: results,
+ Count: len(results),
+ }
+
+ if resp.Results == nil {
+ resp.Results = make([]SearchResult, 0)
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error serializing search results: %v", err)), nil
+ }
+
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+// --- about ---
+
+type aboutResponse struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ TotalAPIs int `json:"total_apis"`
+ TotalTools int `json:"total_tools"`
+}
+
+func makeAboutHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ entries := am.AllManifests()
+
+ totalTools := 0
+ for _, entry := range entries {
+ totalTools += len(entry.Manifest.Tools)
+ }
+
+ resp := aboutResponse{
+ Name: "printing-press-mcp",
+ Version: version.Get(),
+ TotalAPIs: len(entries),
+ TotalTools: totalTools,
+ }
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error serializing about info: %v", err)), nil
+ }
+
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+// --- debug_api ---
+
+type debugAPIResponse struct {
+ Slug string `json:"slug"`
+ APIName string `json:"api_name"`
+ BaseURL string `json:"base_url"`
+ AuthType string `json:"auth_type"`
+ AuthConfigured bool `json:"auth_configured"`
+ Activated bool `json:"activated"`
+ ToolCount int `json:"tool_count"`
+ HealthCheck *healthCheckResult `json:"health_check,omitempty"`
+}
+
+type healthCheckResult struct {
+ StatusCode int `json:"status_code"`
+ Status string `json:"status"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+func makeDebugAPIHandler(am *ActivationManager) server.ToolHandlerFunc {
+ return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ slug, err := getStringArg(req, "api_slug")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ entry := am.GetManifest(slug)
+ if entry == nil {
+ return mcp.NewToolResultError(fmt.Sprintf("API not found: %q. Call library_info to see available APIs.", slug)), nil
+ }
+
+ m := entry.Manifest
+
+ // Check auth status.
+ authConfigured := false
+ if m.Auth.Type == "" || m.Auth.Type == "none" {
+ authConfigured = true
+ } else {
+ for _, envVar := range m.Auth.EnvVars {
+ if os.Getenv(envVar) != "" {
+ authConfigured = true
+ break
+ }
+ }
+ }
+
+ resp := debugAPIResponse{
+ Slug: slug,
+ APIName: m.APIName,
+ BaseURL: m.BaseURL,
+ AuthType: m.Auth.Type,
+ AuthConfigured: authConfigured,
+ Activated: am.IsActivated(slug),
+ ToolCount: len(m.Tools),
+ }
+
+ // Perform a lightweight health check (GET to base URL).
+ client := SafeHTTPClient(10 * time.Second)
+ httpReq, reqErr := http.NewRequestWithContext(ctx, "GET", m.BaseURL, nil)
+ if reqErr != nil {
+ resp.HealthCheck = &healthCheckResult{
+ Error: fmt.Sprintf("Could not create request: %v", reqErr),
+ }
+ } else {
+ httpReq.Header.Set("User-Agent", "printing-press-mcp/debug")
+ httpResp, doErr := client.Do(httpReq)
+ if doErr != nil {
+ resp.HealthCheck = &healthCheckResult{
+ Error: fmt.Sprintf("Connection failed: %v", doErr),
+ }
+ } else {
+ _ = httpResp.Body.Close()
+ headers := make(map[string]string)
+ for _, key := range []string{"Content-Type", "Server", "X-RateLimit-Limit", "X-RateLimit-Remaining"} {
+ if v := httpResp.Header.Get(key); v != "" {
+ headers[key] = v
+ }
+ }
+ resp.HealthCheck = &healthCheckResult{
+ StatusCode: httpResp.StatusCode,
+ Status: httpResp.Status,
+ Headers: headers,
+ }
+ }
+ }
+
+ data, jsonErr := json.MarshalIndent(resp, "", " ")
+ if jsonErr != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("Error serializing debug info: %v", jsonErr)), nil
+ }
+ return mcp.NewToolResultText(string(data)), nil
+ }
+}
+
+// --- helpers ---
+
+// getStringArg extracts a required string argument from an MCP request.
+func getStringArg(req mcp.CallToolRequest, name string) (string, error) {
+ args := req.GetArguments()
+ val, ok := args[name]
+ if !ok || val == nil {
+ return "", fmt.Errorf("missing required argument: %q", name)
+ }
+ s, ok := val.(string)
+ if !ok {
+ return "", fmt.Errorf("argument %q must be a string", name)
+ }
+ if s == "" {
+ return "", fmt.Errorf("argument %q must not be empty", name)
+ }
+ return s, nil
+}
diff --git a/internal/megamcp/metatools_test.go b/internal/megamcp/metatools_test.go
new file mode 100644
index 00000000..76209dbf
--- /dev/null
+++ b/internal/megamcp/metatools_test.go
@@ -0,0 +1,524 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// newMetaToolsServer creates a server with meta-tools registered for testing.
+func newMetaToolsServer() (*server.MCPServer, *ActivationManager) {
+ s := newTestServer()
+ entries := newTestAPIEntries()
+ am := NewActivationManager(s, entries)
+ RegisterMetaTools(s, am)
+ return s, am
+}
+
+// callMetaTool invokes a registered meta-tool by name with given arguments.
+func callMetaTool(t *testing.T, s *server.MCPServer, toolName string, args map[string]any) *mcp.CallToolResult {
+ t.Helper()
+ tool := s.GetTool(toolName)
+ require.NotNil(t, tool, "tool %q should be registered", toolName)
+ result, err := tool.Handler(t.Context(), makeToolRequest(args))
+ require.NoError(t, err)
+ return result
+}
+
+func TestRegisterMetaTools_AllRegistered(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ tools := s.ListTools()
+ expected := []string{"library_info", "setup_guide", "activate_api", "deactivate_api", "search_tools", "about", "debug_api"}
+ for _, name := range expected {
+ assert.NotNil(t, tools[name], "meta-tool %q should be registered", name)
+ }
+}
+
+// --- library_info ---
+
+func TestLibraryInfo_ReturnsAllAPIs(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "library_info", nil)
+ assert.False(t, result.IsError)
+
+ var resp libraryInfoResponse
+ text := extractResultText(result)
+ require.NoError(t, json.Unmarshal([]byte(text), &resp))
+
+ assert.Equal(t, 3, resp.TotalAPIs)
+ assert.Equal(t, 6, resp.TotalTools) // 3 ESPN + 2 Dub + 1 Public
+ assert.NotEmpty(t, resp.Version)
+ assert.Len(t, resp.APIs, 3)
+}
+
+func TestLibraryInfo_AuthConfiguredReflectsEnvVars(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ // Without env var set.
+ result := callMetaTool(t, s, "library_info", nil)
+ var resp libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ // Find ESPN entry — auth should not be configured.
+ for _, api := range resp.APIs {
+ if api.Slug == "espn" {
+ assert.False(t, api.AuthConfigured, "ESPN auth should not be configured without env var")
+ }
+ if api.Slug == "public-api" {
+ assert.True(t, api.AuthConfigured, "no-auth API should report auth as configured")
+ }
+ }
+
+ // Now set the env var.
+ t.Setenv("ESPN_KEY", "test-key")
+ result2 := callMetaTool(t, s, "library_info", nil)
+ var resp2 libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result2)), &resp2))
+
+ for _, api := range resp2.APIs {
+ if api.Slug == "espn" {
+ assert.True(t, api.AuthConfigured, "ESPN auth should be configured with env var set")
+ }
+ }
+}
+
+func TestLibraryInfo_ActivatedStatus(t *testing.T) {
+ s, am := newMetaToolsServer()
+
+ // Before activation.
+ result := callMetaTool(t, s, "library_info", nil)
+ var resp libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ for _, api := range resp.APIs {
+ assert.False(t, api.Activated, "no API should be activated initially")
+ }
+
+ // Activate ESPN.
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+
+ result2 := callMetaTool(t, s, "library_info", nil)
+ var resp2 libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result2)), &resp2))
+
+ for _, api := range resp2.APIs {
+ if api.Slug == "espn" {
+ assert.True(t, api.Activated, "ESPN should be activated")
+ } else {
+ assert.False(t, api.Activated)
+ }
+ }
+}
+
+func TestLibraryInfo_NoAPIsLoaded(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, nil)
+ RegisterMetaTools(s, am)
+
+ result := callMetaTool(t, s, "library_info", nil)
+ assert.False(t, result.IsError)
+
+ var resp libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ assert.Equal(t, 0, resp.TotalAPIs)
+ assert.Equal(t, 0, resp.TotalTools)
+ assert.Empty(t, resp.APIs)
+}
+
+func TestLibraryInfo_PublicToolCount(t *testing.T) {
+ s := newTestServer()
+ entries := []*APIEntry{
+ {
+ Slug: "mixed",
+ NormalizedPrefix: "mixed",
+ Manifest: &ToolsManifest{
+ APIName: "Mixed",
+ Auth: ManifestAuth{Type: "api_key", EnvVars: []string{"MIX_KEY"}},
+ Tools: []ManifestTool{
+ {Name: "public_get", NoAuth: true},
+ {Name: "private_get", NoAuth: false},
+ {Name: "also_private", NoAuth: false},
+ },
+ },
+ },
+ }
+ am := NewActivationManager(s, entries)
+ RegisterMetaTools(s, am)
+
+ result := callMetaTool(t, s, "library_info", nil)
+ var resp libraryInfoResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ require.Len(t, resp.APIs, 1)
+ assert.Equal(t, 3, resp.APIs[0].ToolCount)
+ assert.Equal(t, 1, resp.APIs[0].PublicToolCount)
+}
+
+// --- setup_guide ---
+
+func TestSetupGuide_APIKeyAuth(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "setup_guide", map[string]any{"api_slug": "espn"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "ESPN")
+ assert.Contains(t, text, "ESPN_KEY")
+ assert.Contains(t, text, "api_key")
+ assert.Contains(t, text, "claude mcp add")
+}
+
+func TestSetupGuide_BearerTokenAuth(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "setup_guide", map[string]any{"api_slug": "dub"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "DUB_TOKEN")
+ assert.Contains(t, text, "bearer_token")
+}
+
+func TestSetupGuide_NoAuthAPI(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "setup_guide", map[string]any{"api_slug": "public-api"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "No authentication required")
+}
+
+func TestSetupGuide_UnknownSlug(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "setup_guide", map[string]any{"api_slug": "nonexistent"})
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "API not found")
+}
+
+func TestSetupGuide_MissingArg(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "setup_guide", nil)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "missing required argument")
+}
+
+func TestSetupGuide_WithKeyURL(t *testing.T) {
+ s := newTestServer()
+ entries := []*APIEntry{
+ {
+ Slug: "withurl",
+ NormalizedPrefix: "withurl",
+ Manifest: &ToolsManifest{
+ APIName: "WithURL",
+ Auth: ManifestAuth{
+ Type: "api_key",
+ EnvVars: []string{"MY_KEY"},
+ KeyURL: "https://example.com/settings/keys",
+ },
+ Tools: []ManifestTool{
+ {Name: "data_get", Method: "GET", Path: "/data"},
+ },
+ },
+ },
+ }
+ am := NewActivationManager(s, entries)
+ RegisterMetaTools(s, am)
+
+ result := callMetaTool(t, s, "setup_guide", map[string]any{"api_slug": "withurl"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "https://example.com/settings/keys")
+}
+
+// --- activate_api ---
+
+func TestActivateAPI_RegistersTools(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "activate_api", map[string]any{"api_slug": "espn"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "Activated")
+ assert.Contains(t, text, "3 tools registered")
+
+ // Verify tools registered on server.
+ tools := s.ListTools()
+ assert.NotNil(t, tools["espn__scores_get"])
+}
+
+func TestActivateAPI_Idempotent(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result1 := callMetaTool(t, s, "activate_api", map[string]any{"api_slug": "espn"})
+ assert.False(t, result1.IsError)
+
+ result2 := callMetaTool(t, s, "activate_api", map[string]any{"api_slug": "espn"})
+ assert.False(t, result2.IsError)
+
+ // Should still have the same tool count.
+ assertResultContains(t, result2, "3 tools registered")
+}
+
+func TestActivateAPI_UnknownSlug(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "activate_api", map[string]any{"api_slug": "nonexistent"})
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "API not found")
+}
+
+func TestActivateAPI_MissingArg(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "activate_api", nil)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "missing required argument")
+}
+
+func TestActivateAPI_ShowsExampleTools(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "activate_api", map[string]any{"api_slug": "espn"})
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "Example tools")
+ assert.Contains(t, text, "espn__")
+}
+
+// --- deactivate_api ---
+
+func TestDeactivateAPI_ReplacesWithStubs(t *testing.T) {
+ s, am := newMetaToolsServer()
+
+ _, err := am.Activate("espn")
+ require.NoError(t, err)
+
+ result := callMetaTool(t, s, "deactivate_api", map[string]any{"api_slug": "espn"})
+ assert.False(t, result.IsError)
+ assertResultContains(t, result, "Deactivated")
+
+ // After deactivation, tools should still be registered (as stubs).
+ tools := s.ListTools()
+ assert.NotNil(t, tools["espn__scores_get"], "tool should exist as a stub after deactivation")
+ assert.False(t, am.IsActivated("espn"))
+}
+
+func TestDeactivateAPI_NotActivated(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "deactivate_api", map[string]any{"api_slug": "espn"})
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "not currently activated")
+}
+
+func TestDeactivateAPI_UnknownSlug(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "deactivate_api", map[string]any{"api_slug": "nonexistent"})
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "API not found")
+}
+
+// --- search_tools ---
+
+func TestSearchTools_FindsMatches(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "search_tools", map[string]any{"query": "scores"})
+ assert.False(t, result.IsError)
+
+ var resp searchToolsResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ assert.Equal(t, 1, resp.Count)
+ assert.Equal(t, "espn", resp.Results[0].APISlug)
+ assert.Equal(t, "espn__scores_get", resp.Results[0].ToolName)
+}
+
+func TestSearchTools_EmptyResults(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "search_tools", map[string]any{"query": "pizza"})
+ assert.False(t, result.IsError)
+
+ var resp searchToolsResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ assert.Equal(t, 0, resp.Count)
+ assert.Empty(t, resp.Results)
+}
+
+func TestSearchTools_SearchesAcrossUnactivatedAPIs(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ // Neither ESPN nor Dub are activated, but search should still find tools.
+ result := callMetaTool(t, s, "search_tools", map[string]any{"query": "list"})
+ assert.False(t, result.IsError)
+
+ var resp searchToolsResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ // Should match: espn__teams_list, dub__links_list, public_api__entries_list
+ assert.GreaterOrEqual(t, resp.Count, 3)
+}
+
+func TestSearchTools_MissingArg(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "search_tools", nil)
+ assert.True(t, result.IsError)
+ assertResultContains(t, result, "missing required argument")
+}
+
+// --- about ---
+
+func TestAbout_ReturnsVersionAndCounts(t *testing.T) {
+ s, _ := newMetaToolsServer()
+
+ result := callMetaTool(t, s, "about", nil)
+ assert.False(t, result.IsError)
+
+ var resp aboutResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ assert.Equal(t, "printing-press-mcp", resp.Name)
+ assert.NotEmpty(t, resp.Version)
+ assert.Equal(t, 3, resp.TotalAPIs)
+ assert.Equal(t, 6, resp.TotalTools) // 3 + 2 + 1
+}
+
+func TestAbout_NoAPIs(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, nil)
+ RegisterMetaTools(s, am)
+
+ result := callMetaTool(t, s, "about", nil)
+ assert.False(t, result.IsError)
+
+ var resp aboutResponse
+ require.NoError(t, json.Unmarshal([]byte(extractResultText(result)), &resp))
+
+ assert.Equal(t, 0, resp.TotalAPIs)
+ assert.Equal(t, 0, resp.TotalTools)
+}
+
+// --- getStringArg ---
+
+func TestGetStringArg_Valid(t *testing.T) {
+ req := makeToolRequest(map[string]any{"api_slug": "espn"})
+ val, err := getStringArg(req, "api_slug")
+ require.NoError(t, err)
+ assert.Equal(t, "espn", val)
+}
+
+func TestGetStringArg_Missing(t *testing.T) {
+ req := makeToolRequest(nil)
+ _, err := getStringArg(req, "api_slug")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "missing required argument")
+}
+
+func TestGetStringArg_Empty(t *testing.T) {
+ req := makeToolRequest(map[string]any{"api_slug": ""})
+ _, err := getStringArg(req, "api_slug")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "must not be empty")
+}
+
+func TestGetStringArg_WrongType(t *testing.T) {
+ req := makeToolRequest(map[string]any{"api_slug": 42})
+ _, err := getStringArg(req, "api_slug")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "must be a string")
+}
+
+// --- countPublicTools ---
+
+func TestCountPublicTools_MixedAuth(t *testing.T) {
+ m := &ToolsManifest{
+ Auth: ManifestAuth{Type: "api_key"},
+ Tools: []ManifestTool{
+ {Name: "public", NoAuth: true},
+ {Name: "private1", NoAuth: false},
+ {Name: "private2", NoAuth: false},
+ },
+ }
+ assert.Equal(t, 1, countPublicTools(m))
+}
+
+func TestCountPublicTools_NoAuth(t *testing.T) {
+ m := &ToolsManifest{
+ Auth: ManifestAuth{Type: "none"},
+ Tools: []ManifestTool{
+ {Name: "a"},
+ {Name: "b"},
+ },
+ }
+ // All tools are public when auth type is "none".
+ assert.Equal(t, 2, countPublicTools(m))
+}
+
+func TestCountPublicTools_EmptyAuthType(t *testing.T) {
+ m := &ToolsManifest{
+ Auth: ManifestAuth{Type: ""},
+ Tools: []ManifestTool{
+ {Name: "a"},
+ },
+ }
+ assert.Equal(t, 1, countPublicTools(m))
+}
+
+func TestDebugAPI_ValidAPI(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ handler := makeDebugAPIHandler(am)
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{"api_slug": "espn"}))
+ require.NoError(t, err)
+ assert.False(t, result.IsError)
+
+ text := extractResultText(result)
+ assert.Contains(t, text, "espn")
+ assert.Contains(t, text, "base_url")
+ assert.Contains(t, text, "auth_configured")
+ assert.Contains(t, text, "health_check")
+}
+
+func TestDebugAPI_UnknownSlug(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ handler := makeDebugAPIHandler(am)
+ result, err := handler(t.Context(), makeToolRequest(map[string]any{"api_slug": "nonexistent"}))
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ text := extractResultText(result)
+ assert.Contains(t, text, "API not found")
+}
+
+func TestDebugAPI_MissingArg(t *testing.T) {
+ s := newTestServer()
+ am := NewActivationManager(s, newTestAPIEntries())
+
+ handler := makeDebugAPIHandler(am)
+ result, err := handler(t.Context(), makeToolRequest(nil))
+ require.NoError(t, err)
+ assert.True(t, result.IsError)
+ text := extractResultText(result)
+ assert.Contains(t, text, "missing required")
+}
diff --git a/internal/megamcp/registry.go b/internal/megamcp/registry.go
new file mode 100644
index 00000000..07a69586
--- /dev/null
+++ b/internal/megamcp/registry.go
@@ -0,0 +1,39 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// DefaultBaseURL is the default GitHub raw content URL for the public library repo.
+const DefaultBaseURL = "https://raw.githubusercontent.com/mvanhorn/printing-press-library/main"
+
+// FetchRegistry fetches registry.json from baseURL and parses it into a Registry.
+// baseURL is injectable for testing (use httptest.NewServer).
+func FetchRegistry(baseURL string) (*Registry, error) {
+ registryURL := baseURL + "/registry.json"
+
+ resp, err := http.Get(registryURL)
+ if err != nil {
+ return nil, fmt.Errorf("fetching registry: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetching registry: HTTP %d", resp.StatusCode)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // 10MB limit
+ if err != nil {
+ return nil, fmt.Errorf("reading registry body: %w", err)
+ }
+
+ var registry Registry
+ if err := json.Unmarshal(body, ®istry); err != nil {
+ return nil, fmt.Errorf("parsing registry JSON: %w", err)
+ }
+
+ return ®istry, nil
+}
diff --git a/internal/megamcp/registry_test.go b/internal/megamcp/registry_test.go
new file mode 100644
index 00000000..83ddf77b
--- /dev/null
+++ b/internal/megamcp/registry_test.go
@@ -0,0 +1,98 @@
+package megamcp
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFetchRegistryHappyPath(t *testing.T) {
+ registry := Registry{
+ SchemaVersion: 1,
+ Entries: []RegistryEntry{
+ {
+ Name: "espn-pp-cli",
+ Category: "media-and-entertainment",
+ API: "espn",
+ Description: "ESPN sports data",
+ Path: "library/media-and-entertainment/espn-pp-cli",
+ MCP: RegistryMCP{
+ Binary: "espn-pp-mcp",
+ Transport: "stdio",
+ ToolCount: 3,
+ PublicToolCount: 3,
+ AuthType: "none",
+ MCPReady: "full",
+ ManifestChecksum: "sha256:abc123",
+ SpecFormat: "openapi3",
+ ManifestURL: "library/media-and-entertainment/espn-pp-cli/tools-manifest.json",
+ },
+ },
+ {
+ Name: "dub-pp-cli",
+ Category: "developer-tools",
+ API: "dub",
+ Description: "Dub link management",
+ Path: "library/developer-tools/dub-pp-cli",
+ MCP: RegistryMCP{
+ Binary: "dub-pp-mcp",
+ Transport: "stdio",
+ ToolCount: 10,
+ PublicToolCount: 10,
+ AuthType: "api_key",
+ EnvVars: []string{"DUB_TOKEN"},
+ MCPReady: "full",
+ ManifestChecksum: "sha256:def456",
+ SpecFormat: "openapi3",
+ ManifestURL: "library/developer-tools/dub-pp-cli/tools-manifest.json",
+ },
+ },
+ },
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/registry.json", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(registry)
+ }))
+ defer server.Close()
+
+ result, err := FetchRegistry(server.URL)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, 1, result.SchemaVersion)
+ assert.Len(t, result.Entries, 2)
+ assert.Equal(t, "espn", result.Entries[0].API)
+ assert.Equal(t, "dub", result.Entries[1].API)
+ assert.Equal(t, "sha256:abc123", result.Entries[0].MCP.ManifestChecksum)
+ assert.Equal(t, []string{"DUB_TOKEN"}, result.Entries[1].MCP.EnvVars)
+}
+
+func TestFetchRegistry404(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ result, err := FetchRegistry(server.URL)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "HTTP 404")
+}
+
+func TestFetchRegistryInvalidJSON(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte("{invalid json"))
+ }))
+ defer server.Close()
+
+ result, err := FetchRegistry(server.URL)
+ require.Error(t, err)
+ assert.Nil(t, result)
+ assert.Contains(t, err.Error(), "parsing registry JSON")
+}
diff --git a/internal/megamcp/security.go b/internal/megamcp/security.go
new file mode 100644
index 00000000..c77c147a
--- /dev/null
+++ b/internal/megamcp/security.go
@@ -0,0 +1,194 @@
+package megamcp
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "time"
+ "unicode"
+)
+
+// ValidateBaseURL rejects non-HTTPS URLs and literal private/loopback IPs.
+// Does NOT perform DNS resolution — that check happens at request time via
+// SafeDialer to prevent DNS rebinding attacks. This function validates the
+// URL format and catches obviously unsafe literal IP addresses.
+func ValidateBaseURL(rawURL string) error {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("invalid URL: %w", err)
+ }
+
+ if parsed.Scheme != "https" {
+ return fmt.Errorf("URL must use HTTPS, got %q", parsed.Scheme)
+ }
+
+ host := parsed.Hostname()
+ if host == "" {
+ return fmt.Errorf("URL has no hostname")
+ }
+
+ // If the host is a literal IP address, check it directly.
+ // Hostname-based URLs are checked at request time via SafeDialer.
+ if ip := net.ParseIP(host); ip != nil {
+ if err := checkIP(ip); err != nil {
+ return fmt.Errorf("IP address %s: %w", host, err)
+ }
+ }
+
+ return nil
+}
+
+// checkIP rejects private, loopback, link-local, and metadata IPs.
+func checkIP(ip net.IP) error {
+ if ip.IsLoopback() {
+ return fmt.Errorf("loopback address rejected")
+ }
+ if ip.IsPrivate() {
+ return fmt.Errorf("private IP address rejected")
+ }
+ if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
+ return fmt.Errorf("link-local address rejected")
+ }
+ // Cloud metadata endpoint (169.254.169.254).
+ if ip.Equal(net.ParseIP("169.254.169.254")) {
+ return fmt.Errorf("cloud metadata address rejected")
+ }
+ return nil
+}
+
+// SafeHTTPClient returns an http.Client with a custom dialer that rejects
+// connections to private, loopback, link-local, and cloud metadata IPs.
+// This is the runtime SSRF guard that ValidateBaseURL defers DNS checks to.
+func SafeHTTPClient(timeout time.Duration) *http.Client {
+ dialer := &safeDialer{
+ inner: &net.Dialer{Timeout: 10 * time.Second},
+ }
+ transport := &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ DialContext: dialer.DialContext,
+ }
+ return &http.Client{
+ Timeout: timeout,
+ Transport: transport,
+ }
+}
+
+// safeDialer wraps a net.Dialer and checks resolved IPs before connecting.
+type safeDialer struct {
+ inner *net.Dialer
+}
+
+// DialContext resolves the hostname, validates all IPs, then connects.
+func (d *safeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid address %q: %w", addr, err)
+ }
+
+ // Resolve hostname to IPs.
+ ips, err := net.DefaultResolver.LookupHost(ctx, host)
+ if err != nil {
+ return nil, fmt.Errorf("cannot resolve %q: %w", host, err)
+ }
+
+ // Check every resolved IP.
+ for _, ipStr := range ips {
+ ip := net.ParseIP(ipStr)
+ if ip == nil {
+ continue
+ }
+ if err := checkIP(ip); err != nil {
+ return nil, fmt.Errorf("hostname %q resolves to %s: %w", host, ipStr, err)
+ }
+ }
+
+ // All IPs are safe — connect to the first one.
+ safeAddr := net.JoinHostPort(ips[0], port)
+ return d.inner.DialContext(ctx, network, safeAddr)
+}
+
+// SanitizeText strips control characters (except newline and tab),
+// and truncates to maxLen.
+func SanitizeText(s string, maxLen int) string {
+ var b strings.Builder
+ for _, r := range s {
+ if r == '\n' || r == '\t' {
+ b.WriteRune(r)
+ continue
+ }
+ if unicode.IsControl(r) {
+ continue
+ }
+ b.WriteRune(r)
+ }
+ result := b.String()
+ if maxLen > 0 && len(result) > maxLen {
+ result = result[:maxLen]
+ }
+ return result
+}
+
+// VerifyChecksum compares the SHA-256 hash of data against an expected
+// checksum string in the format "sha256:<hex>".
+func VerifyChecksum(data []byte, expected string) error {
+ if expected == "" {
+ return fmt.Errorf("expected checksum is empty")
+ }
+ if !strings.HasPrefix(expected, "sha256:") {
+ return fmt.Errorf("unsupported checksum format: %q (expected sha256:<hex>)", expected)
+ }
+ expectedHex := strings.TrimPrefix(expected, "sha256:")
+ actual := sha256.Sum256(data)
+ actualHex := hex.EncodeToString(actual[:])
+ if actualHex != expectedHex {
+ return fmt.Errorf("checksum mismatch: expected sha256:%s, got sha256:%s", expectedHex, actualHex)
+ }
+ return nil
+}
+
+// ComputeChecksum returns the SHA-256 checksum of data in "sha256:<hex>" format.
+func ComputeChecksum(data []byte) string {
+ h := sha256.Sum256(data)
+ return "sha256:" + hex.EncodeToString(h[:])
+}
+
+// ValidateSlug rejects slugs that contain path traversal characters or are empty.
+func ValidateSlug(slug string) error {
+ if slug == "" {
+ return fmt.Errorf("slug is empty")
+ }
+ if strings.Contains(slug, "..") {
+ return fmt.Errorf("slug contains path traversal: %q", slug)
+ }
+ if strings.Contains(slug, "/") {
+ return fmt.Errorf("slug contains forward slash: %q", slug)
+ }
+ if strings.Contains(slug, "\\") {
+ return fmt.Errorf("slug contains backslash: %q", slug)
+ }
+ return nil
+}
+
+// ValidateCachePath verifies that the resolved path is under cacheRoot.
+// Belt-and-suspenders defense against path traversal.
+func ValidateCachePath(path, cacheRoot string) error {
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return fmt.Errorf("cannot resolve path %q: %w", path, err)
+ }
+ absRoot, err := filepath.Abs(cacheRoot)
+ if err != nil {
+ return fmt.Errorf("cannot resolve cache root %q: %w", cacheRoot, err)
+ }
+ // Ensure the path is under the root with a trailing separator.
+ if !strings.HasPrefix(absPath, absRoot+string(filepath.Separator)) && absPath != absRoot {
+ return fmt.Errorf("path %q escapes cache root %q", absPath, absRoot)
+ }
+ return nil
+}
diff --git a/internal/megamcp/security_test.go b/internal/megamcp/security_test.go
new file mode 100644
index 00000000..83cb3c45
--- /dev/null
+++ b/internal/megamcp/security_test.go
@@ -0,0 +1,330 @@
+package megamcp
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "net"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateBaseURL(t *testing.T) {
+ tests := []struct {
+ name string
+ url string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "HTTPS passes",
+ url: "https://raw.githubusercontent.com/mvanhorn/printing-press-library/main",
+ wantErr: false,
+ },
+ {
+ name: "HTTP rejected",
+ url: "http://example.com",
+ wantErr: true,
+ errMsg: "must use HTTPS",
+ },
+ {
+ name: "empty scheme rejected",
+ url: "://example.com",
+ wantErr: true,
+ errMsg: "invalid URL",
+ },
+ {
+ name: "loopback rejected",
+ url: "https://127.0.0.1/test",
+ wantErr: true,
+ errMsg: "loopback",
+ },
+ {
+ name: "localhost hostname passes format check (DNS check at request time)",
+ url: "https://localhost/test",
+ wantErr: false,
+ },
+ {
+ name: "private IP 10.x rejected",
+ url: "https://10.0.0.1/test",
+ wantErr: true,
+ errMsg: "private",
+ },
+ {
+ name: "private IP 192.168.x rejected",
+ url: "https://192.168.1.1/test",
+ wantErr: true,
+ errMsg: "private",
+ },
+ {
+ name: "no hostname rejected",
+ url: "https:///path",
+ wantErr: true,
+ errMsg: "no hostname",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateBaseURL(tt.url)
+ if tt.wantErr {
+ require.Error(t, err)
+ if tt.errMsg != "" {
+ assert.Contains(t, err.Error(), tt.errMsg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestSafeHTTPClient(t *testing.T) {
+ // SafeHTTPClient should return a non-nil client with a custom transport.
+ client := SafeHTTPClient(30 * time.Second)
+ require.NotNil(t, client)
+ assert.Equal(t, 30*time.Second, client.Timeout)
+ assert.NotNil(t, client.Transport, "should have custom transport with safe dialer")
+}
+
+func TestSafeDialer_RejectsLoopback(t *testing.T) {
+ d := &safeDialer{inner: &net.Dialer{Timeout: 5 * time.Second}}
+ ctx := context.Background()
+
+ // Connecting to 127.0.0.1 should be rejected.
+ _, err := d.DialContext(ctx, "tcp", "127.0.0.1:443")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "loopback")
+}
+
+func TestSanitizeText(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ maxLen int
+ want string
+ }{
+ {
+ name: "normal text preserved",
+ input: "Hello, world!",
+ maxLen: 100,
+ want: "Hello, world!",
+ },
+ {
+ name: "control chars stripped",
+ input: "Hello\x00World\x01Test\x1F",
+ maxLen: 100,
+ want: "HelloWorldTest",
+ },
+ {
+ name: "newline preserved",
+ input: "line1\nline2",
+ maxLen: 100,
+ want: "line1\nline2",
+ },
+ {
+ name: "tab preserved",
+ input: "col1\tcol2",
+ maxLen: 100,
+ want: "col1\tcol2",
+ },
+ {
+ name: "length limited",
+ input: "abcdefghij",
+ maxLen: 5,
+ want: "abcde",
+ },
+ {
+ name: "zero maxLen means no limit",
+ input: "abcdefghij",
+ maxLen: 0,
+ want: "abcdefghij",
+ },
+ {
+ name: "empty string",
+ input: "",
+ maxLen: 100,
+ want: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SanitizeText(tt.input, tt.maxLen)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestVerifyChecksum(t *testing.T) {
+ data := []byte("hello world")
+ h := sha256.Sum256(data)
+ validChecksum := "sha256:" + hex.EncodeToString(h[:])
+
+ tests := []struct {
+ name string
+ data []byte
+ expected string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "matching hash passes",
+ data: data,
+ expected: validChecksum,
+ wantErr: false,
+ },
+ {
+ name: "mismatched hash fails",
+ data: []byte("different data"),
+ expected: validChecksum,
+ wantErr: true,
+ errMsg: "checksum mismatch",
+ },
+ {
+ name: "empty expected fails",
+ data: data,
+ expected: "",
+ wantErr: true,
+ errMsg: "empty",
+ },
+ {
+ name: "wrong format fails",
+ data: data,
+ expected: "md5:abc123",
+ wantErr: true,
+ errMsg: "unsupported checksum format",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := VerifyChecksum(tt.data, tt.expected)
+ if tt.wantErr {
+ require.Error(t, err)
+ if tt.errMsg != "" {
+ assert.Contains(t, err.Error(), tt.errMsg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestComputeChecksum(t *testing.T) {
+ data := []byte("hello world")
+ checksum := ComputeChecksum(data)
+ assert.True(t, len(checksum) > 7, "checksum should have sha256: prefix plus hex")
+ assert.NoError(t, VerifyChecksum(data, checksum), "ComputeChecksum output should verify")
+}
+
+func TestValidateSlug(t *testing.T) {
+ tests := []struct {
+ name string
+ slug string
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid simple slug",
+ slug: "espn",
+ wantErr: false,
+ },
+ {
+ name: "valid hyphenated slug",
+ slug: "steam-web",
+ wantErr: false,
+ },
+ {
+ name: "path traversal rejected",
+ slug: "../etc",
+ wantErr: true,
+ errMsg: "path traversal",
+ },
+ {
+ name: "double dot in middle rejected",
+ slug: "foo..bar",
+ wantErr: true,
+ errMsg: "path traversal",
+ },
+ {
+ name: "forward slash rejected",
+ slug: "foo/bar",
+ wantErr: true,
+ errMsg: "forward slash",
+ },
+ {
+ name: "backslash rejected",
+ slug: "foo\\bar",
+ wantErr: true,
+ errMsg: "backslash",
+ },
+ {
+ name: "empty slug rejected",
+ slug: "",
+ wantErr: true,
+ errMsg: "empty",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateSlug(tt.slug)
+ if tt.wantErr {
+ require.Error(t, err)
+ if tt.errMsg != "" {
+ assert.Contains(t, err.Error(), tt.errMsg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestValidateCachePath(t *testing.T) {
+ root := t.TempDir()
+
+ tests := []struct {
+ name string
+ path string
+ root string
+ wantErr bool
+ }{
+ {
+ name: "path under root passes",
+ path: filepath.Join(root, "manifests", "espn", "tools-manifest.json"),
+ root: root,
+ wantErr: false,
+ },
+ {
+ name: "escape attempt rejected",
+ path: filepath.Join(root, "..", "outside"),
+ root: root,
+ wantErr: true,
+ },
+ {
+ name: "exact root passes",
+ path: root,
+ root: root,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateCachePath(tt.path, tt.root)
+ if tt.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "escapes cache root")
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/internal/megamcp/types.go b/internal/megamcp/types.go
new file mode 100644
index 00000000..3ad86f3a
--- /dev/null
+++ b/internal/megamcp/types.go
@@ -0,0 +1,52 @@
+package megamcp
+
+import (
+ "github.com/mvanhorn/cli-printing-press/internal/pipeline"
+)
+
+// Re-export manifest types from pipeline to avoid duplication.
+// These were defined in internal/pipeline/toolsmanifest.go (Unit 1).
+type ToolsManifest = pipeline.ToolsManifest
+type ManifestTool = pipeline.ManifestTool
+type ManifestParam = pipeline.ManifestParam
+type ManifestAuth = pipeline.ManifestAuth
+type ManifestHeader = pipeline.ManifestHeader
+
+// RegistryEntry matches the schema of a single entry in registry.json
+// from the public library repo.
+type RegistryEntry struct {
+ Name string `json:"name"`
+ Category string `json:"category"`
+ API string `json:"api"`
+ Description string `json:"description"`
+ Path string `json:"path"`
+ MCP RegistryMCP `json:"mcp"`
+}
+
+// RegistryMCP holds MCP-specific metadata within a registry entry.
+type RegistryMCP struct {
+ Binary string `json:"binary"`
+ Transport string `json:"transport"`
+ ToolCount int `json:"tool_count"`
+ PublicToolCount int `json:"public_tool_count"`
+ AuthType string `json:"auth_type"`
+ EnvVars []string `json:"env_vars"`
+ MCPReady string `json:"mcp_ready"`
+ ManifestChecksum string `json:"manifest_checksum"`
+ SpecFormat string `json:"spec_format"`
+ ManifestURL string `json:"manifest_url"`
+}
+
+// Registry represents the top-level registry.json structure.
+type Registry struct {
+ SchemaVersion int `json:"schema_version"`
+ Entries []RegistryEntry `json:"entries"`
+}
+
+// APIEntry holds aggregated runtime state for a loaded API.
+type APIEntry struct {
+ Slug string
+ Dir string
+ Manifest *ToolsManifest
+ NormalizedPrefix string
+}
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index addb794a..2509c64a 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -246,6 +246,14 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
if parseErr == nil {
populateMCPMetadata(&m, parsed)
}
+
+ // Generate tools-manifest.json for the mega MCP server.
+ // Non-blocking: log warning on error but don't fail the publish.
+ if parsed != nil {
+ if tmErr := WriteToolsManifest(dir, parsed); tmErr != nil {
+ fmt.Fprintf(os.Stderr, "warning: could not write tools manifest: %v\n", tmErr)
+ }
+ }
}
// Look up catalog entry by API name; empty string if not found.
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
new file mode 100644
index 00000000..fa9d8373
--- /dev/null
+++ b/internal/pipeline/toolsmanifest.go
@@ -0,0 +1,353 @@
+package pipeline
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "unicode"
+
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+// ToolsManifestFilename is the name of the tools manifest file written to each
+// published CLI directory for use by the mega MCP server.
+const ToolsManifestFilename = "tools-manifest.json"
+
+// ToolsManifest describes every MCP tool for an API, along with API-level
+// metadata needed by the mega MCP to register and execute tools without
+// runtime spec parsing. These types will move to internal/megamcp/types.go
+// in Unit 3.
+type ToolsManifest struct {
+ APIName string `json:"api_name"`
+ BaseURL string `json:"base_url"`
+ Description string `json:"description"`
+ MCPReady string `json:"mcp_ready"`
+ Auth ManifestAuth `json:"auth"`
+ RequiredHeaders []ManifestHeader `json:"required_headers"`
+ Tools []ManifestTool `json:"tools"`
+}
+
+// ManifestAuth captures the auth configuration needed to make authenticated
+// API requests at runtime.
+type ManifestAuth struct {
+ Type string `json:"type"`
+ Header string `json:"header,omitempty"`
+ Format string `json:"format,omitempty"`
+ In string `json:"in,omitempty"`
+ EnvVars []string `json:"env_vars,omitempty"`
+ KeyURL string `json:"key_url,omitempty"`
+}
+
+// ManifestTool describes a single MCP tool derived from an API endpoint.
+type ManifestTool struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ NoAuth bool `json:"no_auth,omitempty"`
+ Params []ManifestParam `json:"params"`
+ HeaderOverrides []ManifestHeader `json:"header_overrides,omitempty"`
+}
+
+// ManifestParam describes a tool parameter with an explicit location
+// (path, query, or body).
+type ManifestParam struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Location string `json:"location"`
+ Description string `json:"description,omitempty"`
+ Required bool `json:"required,omitempty"`
+}
+
+// ManifestHeader represents a header name/value pair used for both
+// API-level required headers and per-tool header overrides.
+type ManifestHeader struct {
+ Name string `json:"name"`
+ Value string `json:"value"`
+}
+
+// WriteToolsManifest generates a tools-manifest.json from a parsed API spec.
+// It iterates Resources/SubResources/Endpoints in sorted key order (matching
+// the MCP template's RegisterTools pattern) and writes deterministic JSON.
+func WriteToolsManifest(dir string, parsed *spec.APISpec) error {
+ if parsed == nil {
+ return fmt.Errorf("parsed spec is nil")
+ }
+
+ total, public := parsed.CountMCPTools()
+ mcpReady := computeMCPReady(parsed.Auth.Type, public)
+
+ // For cookie/composed auth, only include NoAuth endpoints.
+ cookieOrComposed := parsed.Auth.Type == "cookie" || parsed.Auth.Type == "composed"
+
+ manifest := ToolsManifest{
+ APIName: parsed.Name,
+ BaseURL: parsed.BaseURL,
+ Description: parsed.Description,
+ MCPReady: mcpReady,
+ Auth: ManifestAuth{
+ Type: parsed.Auth.Type,
+ Header: parsed.Auth.Header,
+ Format: normalizeAuthFormat(parsed.Auth.Format, parsed.Auth.EnvVars),
+ In: parsed.Auth.In,
+ EnvVars: parsed.Auth.EnvVars,
+ KeyURL: parsed.Auth.KeyURL,
+ },
+ RequiredHeaders: make([]ManifestHeader, 0, len(parsed.RequiredHeaders)),
+ Tools: make([]ManifestTool, 0),
+ }
+
+ for _, rh := range parsed.RequiredHeaders {
+ manifest.RequiredHeaders = append(manifest.RequiredHeaders, ManifestHeader{
+ Name: rh.Name,
+ Value: rh.Value,
+ })
+ }
+
+ // Iterate resources in sorted order for deterministic output.
+ resourceNames := sortedResourceKeys(parsed.Resources)
+ for _, rName := range resourceNames {
+ resource := parsed.Resources[rName]
+
+ // Top-level endpoints
+ endpointNames := sortedEndpointKeys(resource.Endpoints)
+ for _, eName := range endpointNames {
+ endpoint := resource.Endpoints[eName]
+ if cookieOrComposed && !endpoint.NoAuth {
+ continue
+ }
+ toolName := toolSnake(rName) + "_" + toolSnake(eName)
+ desc := mcpDescriptionForManifest(endpoint.Description, endpoint.NoAuth, parsed.Auth.Type, public, total)
+ tool := buildManifestTool(toolName, desc, endpoint)
+ manifest.Tools = append(manifest.Tools, tool)
+ }
+
+ // Sub-resources
+ subNames := sortedResourceKeys(resource.SubResources)
+ for _, subName := range subNames {
+ subResource := resource.SubResources[subName]
+ subEndpointNames := sortedEndpointKeys(subResource.Endpoints)
+ for _, eName := range subEndpointNames {
+ endpoint := subResource.Endpoints[eName]
+ if cookieOrComposed && !endpoint.NoAuth {
+ continue
+ }
+ toolName := toolSnake(rName) + "_" + toolSnake(subName) + "_" + toolSnake(eName)
+ desc := mcpDescriptionForManifest(endpoint.Description, endpoint.NoAuth, parsed.Auth.Type, public, total)
+ tool := buildManifestTool(toolName, desc, endpoint)
+ manifest.Tools = append(manifest.Tools, tool)
+ }
+ }
+ }
+
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshaling tools manifest: %w", err)
+ }
+ data = append(data, '\n')
+
+ if err := os.WriteFile(filepath.Join(dir, ToolsManifestFilename), data, 0o644); err != nil {
+ return fmt.Errorf("writing tools manifest: %w", err)
+ }
+ return nil
+}
+
+// buildManifestTool creates a ManifestTool from an endpoint, classifying
+// each parameter's location.
+func buildManifestTool(name, description string, ep spec.Endpoint) ManifestTool {
+ tool := ManifestTool{
+ Name: name,
+ Description: description,
+ Method: strings.ToUpper(ep.Method),
+ Path: ep.Path,
+ NoAuth: ep.NoAuth,
+ Params: make([]ManifestParam, 0, len(ep.Params)+len(ep.Body)),
+ }
+
+ // Regular params: positional → path, others → query.
+ for _, p := range ep.Params {
+ loc := "query"
+ if p.Positional {
+ loc = "path"
+ }
+ tool.Params = append(tool.Params, ManifestParam{
+ Name: p.Name,
+ Type: normalizeParamType(p.Type),
+ Location: loc,
+ Description: p.Description,
+ Required: p.Required,
+ })
+ }
+
+ // Body params → body.
+ for _, p := range ep.Body {
+ tool.Params = append(tool.Params, ManifestParam{
+ Name: p.Name,
+ Type: normalizeParamType(p.Type),
+ Location: "body",
+ Description: p.Description,
+ Required: p.Required,
+ })
+ }
+
+ // Per-endpoint header overrides.
+ if len(ep.HeaderOverrides) > 0 {
+ tool.HeaderOverrides = make([]ManifestHeader, 0, len(ep.HeaderOverrides))
+ for _, ho := range ep.HeaderOverrides {
+ tool.HeaderOverrides = append(tool.HeaderOverrides, ManifestHeader{
+ Name: ho.Name,
+ Value: ho.Value,
+ })
+ }
+ }
+
+ return tool
+}
+
+// normalizeAuthFormat rewrites the auth format string so that derived
+// placeholders (like {token} from DUB_TOKEN) become the actual env var
+// name ({DUB_TOKEN}). This way the mega MCP's runtime expansion only needs
+// to handle env var names, not the derived semantic aliases that the
+// generated config template uses.
+func normalizeAuthFormat(format string, envVars []string) string {
+ if format == "" || len(envVars) == 0 {
+ return format
+ }
+ result := format
+ for _, envVar := range envVars {
+ derived := envVarPlaceholder(envVar)
+ if derived != strings.ToLower(envVar) {
+ // Replace the derived placeholder with the env var name.
+ result = strings.ReplaceAll(result, "{"+derived+"}", "{"+envVar+"}")
+ }
+ }
+ // Also replace common semantic aliases with the first env var.
+ first := envVars[0]
+ for _, alias := range []string{"token", "access_token", "api_key"} {
+ // Only replace if it's not already the env var name.
+ if alias != strings.ToLower(first) {
+ result = strings.ReplaceAll(result, "{"+alias+"}", "{"+first+"}")
+ }
+ }
+ return result
+}
+
+// envVarPlaceholder derives the placeholder name from an env var.
+// DUB_TOKEN -> token, STYTCH_PROJECT_ID -> project_id.
+// Mirrors the logic in internal/generator/generator.go:1331.
+func envVarPlaceholder(envVar string) string {
+ parts := strings.Split(envVar, "_")
+ if len(parts) <= 1 {
+ return strings.ToLower(envVar)
+ }
+ lower := make([]string, 0, len(parts)-1)
+ for _, p := range parts[1:] {
+ lower = append(lower, strings.ToLower(p))
+ }
+ return strings.Join(lower, "_")
+}
+
+// normalizeParamType ensures a consistent type string. Empty types default
+// to "string".
+func normalizeParamType(t string) string {
+ if t == "" {
+ return "string"
+ }
+ return t
+}
+
+// toolSnake converts CamelCase to snake_case for tool name segments.
+// This is a copy of toSnake from internal/generator/generator.go:794.
+// It does NOT convert hyphens, matching the MCP template's {{snake}} function.
+func toolSnake(s string) string {
+ var result strings.Builder
+ for i, r := range s {
+ if unicode.IsUpper(r) && i > 0 {
+ result.WriteRune('_')
+ }
+ result.WriteRune(unicode.ToLower(r))
+ }
+ return result.String()
+}
+
+// mcpDescriptionForManifest replicates the mcpDescription template function
+// from internal/generator/generator.go:1078 — adds minority-side annotations
+// for mixed auth/no-auth APIs.
+func mcpDescriptionForManifest(desc string, noAuth bool, authType string, publicCount, totalCount int) string {
+ authCount := totalCount - publicCount
+ mixed := publicCount > 0 && authCount > 0
+
+ if mixed {
+ if noAuth && publicCount < authCount {
+ desc = desc + " (public)"
+ } else if !noAuth && authCount < publicCount {
+ switch authType {
+ case "api_key":
+ desc = desc + " (requires API key)"
+ case "cookie", "composed":
+ desc = desc + " (requires browser login)"
+ case "oauth2", "bearer_token":
+ desc = desc + " (requires auth)"
+ default:
+ desc = desc + " (requires auth)"
+ }
+ }
+ }
+
+ return onelineForManifest(desc)
+}
+
+// onelineForManifest replicates the oneline template function from
+// internal/generator/generator.go:1051.
+func onelineForManifest(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", " ")
+ s = strings.ReplaceAll(s, "\n", " ")
+ s = strings.ReplaceAll(s, "\r", " ")
+ s = strings.ReplaceAll(s, `"`, `'`)
+ s = strings.ReplaceAll(s, "\\", "")
+ for strings.Contains(s, " ") {
+ s = strings.ReplaceAll(s, " ", " ")
+ }
+ s = strings.TrimSpace(s)
+ if len(s) > 120 {
+ cut := s[:117]
+ if idx := strings.LastIndex(cut, " "); idx > 60 {
+ s = cut[:idx] + "..."
+ } else {
+ s = cut + "..."
+ }
+ }
+ return s
+}
+
+// sortedResourceKeys returns sorted keys from a map[string]spec.Resource.
+func sortedResourceKeys(m map[string]spec.Resource) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// sortedEndpointKeys returns sorted keys from a map[string]spec.Endpoint.
+func sortedEndpointKeys(m map[string]spec.Endpoint) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// ComputeToolsManifestChecksum returns the SHA-256 checksum of manifest data
+// in "sha256:<hex>" format, matching the format used in registry.json.
+func ComputeToolsManifestChecksum(data []byte) string {
+ h := sha256.Sum256(data)
+ return "sha256:" + hex.EncodeToString(h[:])
+}
diff --git a/internal/pipeline/toolsmanifest_test.go b/internal/pipeline/toolsmanifest_test.go
new file mode 100644
index 00000000..edbe0373
--- /dev/null
+++ b/internal/pipeline/toolsmanifest_test.go
@@ -0,0 +1,929 @@
+package pipeline
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWriteToolsManifest_MultipleResources(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "petstore",
+ Description: "A sample pet store API",
+ BaseURL: "https://petstore.example.com/v3",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {PETSTORE_KEY}",
+ In: "header",
+ EnvVars: []string{"PETSTORE_KEY"},
+ KeyURL: "https://petstore.example.com/keys",
+ },
+ RequiredHeaders: []spec.RequiredHeader{
+ {Name: "X-Api-Version", Value: "2024-01-01"},
+ },
+ Resources: map[string]spec.Resource{
+ "Pets": {
+ Description: "Pet operations",
+ Endpoints: map[string]spec.Endpoint{
+ "List": {
+ Method: "GET",
+ Path: "/pets",
+ Description: "List all pets",
+ Params: []spec.Param{
+ {Name: "limit", Type: "integer", Required: false, Description: "Max items to return"},
+ {Name: "status", Type: "string", Required: false, Description: "Filter by status"},
+ },
+ },
+ "Get": {
+ Method: "GET",
+ Path: "/pets/{petId}",
+ Description: "Get a pet by ID",
+ Params: []spec.Param{
+ {Name: "petId", Type: "string", Required: true, Positional: true, Description: "The pet ID"},
+ },
+ },
+ "Create": {
+ Method: "POST",
+ Path: "/pets",
+ Description: "Create a new pet",
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Required: true, Description: "Pet name"},
+ {Name: "tag", Type: "string", Required: false, Description: "Pet tag"},
+ },
+ },
+ },
+ },
+ "Store": {
+ Description: "Store operations",
+ Endpoints: map[string]spec.Endpoint{
+ "GetInventory": {
+ Method: "GET",
+ Path: "/store/inventory",
+ Description: "Returns pet inventories by status",
+ },
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ // API-level metadata
+ assert.Equal(t, "petstore", got.APIName)
+ assert.Equal(t, "https://petstore.example.com/v3", got.BaseURL)
+ assert.Equal(t, "A sample pet store API", got.Description)
+ assert.Equal(t, "full", got.MCPReady)
+
+ // Auth
+ assert.Equal(t, "api_key", got.Auth.Type)
+ assert.Equal(t, "Authorization", got.Auth.Header)
+ assert.Equal(t, "Bearer {PETSTORE_KEY}", got.Auth.Format)
+ assert.Equal(t, "header", got.Auth.In)
+ assert.Equal(t, []string{"PETSTORE_KEY"}, got.Auth.EnvVars)
+ assert.Equal(t, "https://petstore.example.com/keys", got.Auth.KeyURL)
+
+ // Required headers
+ require.Len(t, got.RequiredHeaders, 1)
+ assert.Equal(t, "X-Api-Version", got.RequiredHeaders[0].Name)
+ assert.Equal(t, "2024-01-01", got.RequiredHeaders[0].Value)
+
+ // Tools: 4 total (3 from Pets + 1 from Store), sorted by resource then endpoint
+ require.Len(t, got.Tools, 4)
+
+ // Pets comes before Store alphabetically
+ assert.Equal(t, "pets_create", got.Tools[0].Name)
+ assert.Equal(t, "POST", got.Tools[0].Method)
+ assert.Equal(t, "/pets", got.Tools[0].Path)
+
+ assert.Equal(t, "pets_get", got.Tools[1].Name)
+ assert.Equal(t, "GET", got.Tools[1].Method)
+ assert.Equal(t, "/pets/{petId}", got.Tools[1].Path)
+
+ assert.Equal(t, "pets_list", got.Tools[2].Name)
+ assert.Equal(t, "GET", got.Tools[2].Method)
+ assert.Equal(t, "/pets", got.Tools[2].Path)
+
+ assert.Equal(t, "store_get_inventory", got.Tools[3].Name)
+ assert.Equal(t, "GET", got.Tools[3].Method)
+ assert.Equal(t, "/store/inventory", got.Tools[3].Path)
+}
+
+func TestWriteToolsManifest_SubResources(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "guild-api",
+ BaseURL: "https://api.guild.com",
+ Auth: spec.AuthConfig{Type: "bearer_token", EnvVars: []string{"GUILD_TOKEN"}},
+ Resources: map[string]spec.Resource{
+ "Guild": {
+ Endpoints: map[string]spec.Endpoint{
+ "Get": {Method: "GET", Path: "/guilds/{guildId}", Description: "Get a guild",
+ Params: []spec.Param{
+ {Name: "guildId", Type: "string", Required: true, Positional: true},
+ }},
+ },
+ SubResources: map[string]spec.Resource{
+ "Members": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/guilds/{guildId}/members", Description: "List guild members",
+ Params: []spec.Param{
+ {Name: "guildId", Type: "string", Required: true, Positional: true},
+ {Name: "limit", Type: "integer", Required: false},
+ }},
+ "Get": {Method: "GET", Path: "/guilds/{guildId}/members/{userId}", Description: "Get a guild member",
+ Params: []spec.Param{
+ {Name: "guildId", Type: "string", Required: true, Positional: true},
+ {Name: "userId", Type: "string", Required: true, Positional: true},
+ }},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 3)
+
+ // Top-level endpoint
+ assert.Equal(t, "guild_get", got.Tools[0].Name)
+
+ // Sub-resource endpoints: three-segment names
+ assert.Equal(t, "guild_members_get", got.Tools[1].Name)
+ assert.Equal(t, "guild_members_list", got.Tools[2].Name)
+}
+
+func TestWriteToolsManifest_ParamLocationClassification(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "test-api",
+ BaseURL: "https://api.test.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "Update": {
+ Method: "PUT",
+ Path: "/items/{itemId}",
+ Params: []spec.Param{
+ {Name: "itemId", Type: "string", Required: true, Positional: true, Description: "Item identifier"},
+ {Name: "filter", Type: "string", Required: false, Description: "Optional filter"},
+ },
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Required: true, Description: "Item name"},
+ {Name: "tags", Type: "string", Required: false, Description: "Item tags"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 1)
+ tool := got.Tools[0]
+ require.Len(t, tool.Params, 4)
+
+ // Positional → path
+ assert.Equal(t, "itemId", tool.Params[0].Name)
+ assert.Equal(t, "path", tool.Params[0].Location)
+ assert.True(t, tool.Params[0].Required)
+
+ // Non-positional regular param → query
+ assert.Equal(t, "filter", tool.Params[1].Name)
+ assert.Equal(t, "query", tool.Params[1].Location)
+ assert.False(t, tool.Params[1].Required)
+
+ // Body params → body
+ assert.Equal(t, "name", tool.Params[2].Name)
+ assert.Equal(t, "body", tool.Params[2].Location)
+ assert.True(t, tool.Params[2].Required)
+
+ assert.Equal(t, "tags", tool.Params[3].Name)
+ assert.Equal(t, "body", tool.Params[3].Location)
+ assert.False(t, tool.Params[3].Required)
+}
+
+func TestWriteToolsManifest_AuthConfigRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "auth-test",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "X-API-Key",
+ Format: "{MY_KEY}",
+ In: "header",
+ EnvVars: []string{"MY_KEY", "MY_BACKUP_KEY"},
+ KeyURL: "https://example.com/api-keys",
+ },
+ Resources: map[string]spec.Resource{
+ "Things": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/things", Description: "List things"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ assert.Equal(t, "api_key", got.Auth.Type)
+ assert.Equal(t, "X-API-Key", got.Auth.Header)
+ assert.Equal(t, "{MY_KEY}", got.Auth.Format)
+ assert.Equal(t, "header", got.Auth.In)
+ assert.Equal(t, []string{"MY_KEY", "MY_BACKUP_KEY"}, got.Auth.EnvVars)
+ assert.Equal(t, "https://example.com/api-keys", got.Auth.KeyURL)
+}
+
+func TestWriteToolsManifest_NoAuthEndpointsFlagged(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "mixed-auth",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "bearer_token", EnvVars: []string{"TOKEN"}},
+ Resources: map[string]spec.Resource{
+ "Data": {
+ Endpoints: map[string]spec.Endpoint{
+ "PublicList": {Method: "GET", Path: "/data", Description: "Public data", NoAuth: true},
+ "PrivateGet": {Method: "GET", Path: "/data/{id}", Description: "Private data",
+ Params: []spec.Param{{Name: "id", Type: "string", Positional: true, Required: true}}},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 2)
+ // Sorted: PrivateGet before PublicList
+ assert.Equal(t, "data_private_get", got.Tools[0].Name)
+ assert.False(t, got.Tools[0].NoAuth)
+
+ assert.Equal(t, "data_public_list", got.Tools[1].Name)
+ assert.True(t, got.Tools[1].NoAuth)
+}
+
+func TestWriteToolsManifest_CookieAuthOnlyNoAuthEndpoints(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "cookie-api",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "cookie", EnvVars: []string{"COOKIE"}},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "PublicList": {Method: "GET", Path: "/items", Description: "Public items", NoAuth: true},
+ "PrivateGet": {Method: "GET", Path: "/items/{id}", Description: "Private item"},
+ "PublicCount": {Method: "GET", Path: "/items/count", Description: "Public item count", NoAuth: true},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ // Only NoAuth endpoints should be included for cookie auth
+ require.Len(t, got.Tools, 2)
+ assert.Equal(t, "items_public_count", got.Tools[0].Name)
+ assert.Equal(t, "items_public_list", got.Tools[1].Name)
+ assert.Equal(t, "partial", got.MCPReady)
+}
+
+func TestWriteToolsManifest_ComposedAuthOnlyNoAuthEndpoints(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "composed-api",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "composed", EnvVars: []string{"AUTH_TOKEN"}},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "PublicList": {Method: "GET", Path: "/items", Description: "Public items", NoAuth: true},
+ "PrivateGet": {Method: "GET", Path: "/items/{id}", Description: "Private item"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 1)
+ assert.Equal(t, "items_public_list", got.Tools[0].Name)
+}
+
+func TestWriteToolsManifest_EmptyDescription(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "no-desc",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ assert.Equal(t, "", got.Description)
+ require.Len(t, got.Tools, 1)
+ assert.Equal(t, "", got.Tools[0].Description)
+}
+
+func TestWriteToolsManifest_RoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "roundtrip-api",
+ Description: "A test API for round-trip verification",
+ BaseURL: "https://api.roundtrip.com",
+ Auth: spec.AuthConfig{
+ Type: "bearer_token",
+ Header: "Authorization",
+ Format: "Bearer {RT_TOKEN}",
+ In: "header",
+ EnvVars: []string{"RT_TOKEN"},
+ KeyURL: "https://roundtrip.com/keys",
+ },
+ RequiredHeaders: []spec.RequiredHeader{
+ {Name: "X-Version", Value: "2"},
+ },
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/items", Description: "List items", NoAuth: true,
+ Params: []spec.Param{
+ {Name: "limit", Type: "integer", Required: false, Description: "Max results"},
+ }},
+ "Get": {Method: "GET", Path: "/items/{id}", Description: "Get an item",
+ Params: []spec.Param{
+ {Name: "id", Type: "string", Required: true, Positional: true, Description: "Item ID"},
+ }},
+ "Create": {Method: "POST", Path: "/items", Description: "Create item",
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Required: true, Description: "Item name"},
+ },
+ HeaderOverrides: []spec.RequiredHeader{
+ {Name: "Content-Type", Value: "application/json"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ // Read back
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ // Verify all API-level fields
+ assert.Equal(t, "roundtrip-api", got.APIName)
+ assert.Equal(t, "https://api.roundtrip.com", got.BaseURL)
+ assert.Equal(t, "A test API for round-trip verification", got.Description)
+ assert.Equal(t, "full", got.MCPReady) // bearer_token → full
+ assert.Equal(t, "bearer_token", got.Auth.Type)
+ assert.Equal(t, "Authorization", got.Auth.Header)
+ assert.Equal(t, "Bearer {RT_TOKEN}", got.Auth.Format)
+ assert.Equal(t, "header", got.Auth.In)
+ assert.Equal(t, []string{"RT_TOKEN"}, got.Auth.EnvVars)
+ assert.Equal(t, "https://roundtrip.com/keys", got.Auth.KeyURL)
+ require.Len(t, got.RequiredHeaders, 1)
+ assert.Equal(t, "X-Version", got.RequiredHeaders[0].Name)
+ assert.Equal(t, "2", got.RequiredHeaders[0].Value)
+
+ // Verify tools
+ require.Len(t, got.Tools, 3)
+
+ // items_create
+ assert.Equal(t, "items_create", got.Tools[0].Name)
+ assert.Equal(t, "POST", got.Tools[0].Method)
+ assert.Equal(t, "/items", got.Tools[0].Path)
+ assert.False(t, got.Tools[0].NoAuth)
+ require.Len(t, got.Tools[0].Params, 1)
+ assert.Equal(t, "body", got.Tools[0].Params[0].Location)
+ require.Len(t, got.Tools[0].HeaderOverrides, 1)
+ assert.Equal(t, "Content-Type", got.Tools[0].HeaderOverrides[0].Name)
+
+ // items_get
+ assert.Equal(t, "items_get", got.Tools[1].Name)
+ assert.Equal(t, "GET", got.Tools[1].Method)
+ assert.Equal(t, "/items/{id}", got.Tools[1].Path)
+ require.Len(t, got.Tools[1].Params, 1)
+ assert.Equal(t, "path", got.Tools[1].Params[0].Location)
+
+ // items_list
+ assert.Equal(t, "items_list", got.Tools[2].Name)
+ assert.True(t, got.Tools[2].NoAuth)
+ require.Len(t, got.Tools[2].Params, 1)
+ assert.Equal(t, "query", got.Tools[2].Params[0].Location)
+}
+
+func TestWriteToolsManifest_DeterministicJSON(t *testing.T) {
+ parsed := &spec.APISpec{
+ Name: "deterministic",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Zebra": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/zebras", Description: "List zebras"},
+ },
+ },
+ "Apple": {
+ Endpoints: map[string]spec.Endpoint{
+ "Get": {Method: "GET", Path: "/apples/{id}", Description: "Get apple"},
+ "List": {Method: "GET", Path: "/apples", Description: "List apples"},
+ },
+ },
+ },
+ }
+
+ // Write twice to different dirs
+ dir1 := t.TempDir()
+ dir2 := t.TempDir()
+
+ require.NoError(t, WriteToolsManifest(dir1, parsed))
+ require.NoError(t, WriteToolsManifest(dir2, parsed))
+
+ data1, err := os.ReadFile(filepath.Join(dir1, ToolsManifestFilename))
+ require.NoError(t, err)
+ data2, err := os.ReadFile(filepath.Join(dir2, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ // Byte-identical output
+ assert.Equal(t, string(data1), string(data2))
+
+ // Verify ordering: Apple before Zebra
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data1, &got))
+ require.Len(t, got.Tools, 3)
+ assert.Equal(t, "apple_get", got.Tools[0].Name)
+ assert.Equal(t, "apple_list", got.Tools[1].Name)
+ assert.Equal(t, "zebra_list", got.Tools[2].Name)
+}
+
+func TestWriteToolsManifest_NilSpec(t *testing.T) {
+ dir := t.TempDir()
+ err := WriteToolsManifest(dir, nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "nil")
+}
+
+func TestWriteToolsManifest_NonexistentDir(t *testing.T) {
+ parsed := &spec.APISpec{
+ Name: "test",
+ BaseURL: "https://api.test.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "X": {Endpoints: map[string]spec.Endpoint{
+ "Y": {Method: "GET", Path: "/x"},
+ }},
+ },
+ }
+ err := WriteToolsManifest("/nonexistent/path/does/not/exist", parsed)
+ assert.Error(t, err)
+}
+
+func TestWriteToolsManifest_NoAuthType(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "no-auth-api",
+ BaseURL: "https://api.public.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Data": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/data", Description: "Public data"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ assert.Equal(t, "none", got.Auth.Type)
+ assert.Empty(t, got.Auth.Header)
+ assert.Empty(t, got.Auth.EnvVars)
+ assert.Equal(t, "full", got.MCPReady)
+}
+
+func TestWriteToolsManifest_RequiredHeadersIncluded(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "headers-api",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "api_key", EnvVars: []string{"KEY"}},
+ RequiredHeaders: []spec.RequiredHeader{
+ {Name: "X-Version", Value: "3"},
+ {Name: "X-Client", Value: "printing-press"},
+ },
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/items"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.RequiredHeaders, 2)
+ assert.Equal(t, "X-Version", got.RequiredHeaders[0].Name)
+ assert.Equal(t, "3", got.RequiredHeaders[0].Value)
+ assert.Equal(t, "X-Client", got.RequiredHeaders[1].Name)
+ assert.Equal(t, "printing-press", got.RequiredHeaders[1].Value)
+}
+
+func TestWriteToolsManifest_HeaderOverrides(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "overrides-api",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "Upload": {
+ Method: "POST", Path: "/items/upload", Description: "Upload an item",
+ HeaderOverrides: []spec.RequiredHeader{
+ {Name: "Content-Type", Value: "multipart/form-data"},
+ },
+ },
+ "List": {
+ Method: "GET", Path: "/items", Description: "List items",
+ // No header overrides
+ },
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 2)
+ // List (no overrides)
+ assert.Equal(t, "items_list", got.Tools[0].Name)
+ assert.Nil(t, got.Tools[0].HeaderOverrides)
+ // Upload (with overrides)
+ assert.Equal(t, "items_upload", got.Tools[1].Name)
+ require.Len(t, got.Tools[1].HeaderOverrides, 1)
+ assert.Equal(t, "Content-Type", got.Tools[1].HeaderOverrides[0].Name)
+ assert.Equal(t, "multipart/form-data", got.Tools[1].HeaderOverrides[0].Value)
+}
+
+func TestWriteToolsManifest_MCPDescriptionAnnotations(t *testing.T) {
+ dir := t.TempDir()
+ // 2 public, 5 auth-required → public is minority → public gets "(public)" annotation
+ parsed := &spec.APISpec{
+ Name: "mixed-api",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "api_key", EnvVars: []string{"KEY"}},
+ Resources: map[string]spec.Resource{
+ "A": {
+ Endpoints: map[string]spec.Endpoint{
+ "E1": {Method: "GET", Path: "/a/1", Description: "Public endpoint 1", NoAuth: true},
+ "E2": {Method: "GET", Path: "/a/2", Description: "Public endpoint 2", NoAuth: true},
+ "E3": {Method: "GET", Path: "/a/3", Description: "Private endpoint 1"},
+ "E4": {Method: "GET", Path: "/a/4", Description: "Private endpoint 2"},
+ "E5": {Method: "GET", Path: "/a/5", Description: "Private endpoint 3"},
+ "E6": {Method: "GET", Path: "/a/6", Description: "Private endpoint 4"},
+ "E7": {Method: "GET", Path: "/a/7", Description: "Private endpoint 5"},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ // Find the public endpoints — they should have "(public)" annotation
+ for _, tool := range got.Tools {
+ if tool.NoAuth {
+ assert.Contains(t, tool.Description, "(public)", "public minority endpoints should be annotated")
+ }
+ }
+}
+
+func TestWriteToolsManifest_EmptyParamType(t *testing.T) {
+ dir := t.TempDir()
+ parsed := &spec.APISpec{
+ Name: "empty-type",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Resources: map[string]spec.Resource{
+ "Items": {
+ Endpoints: map[string]spec.Endpoint{
+ "List": {Method: "GET", Path: "/items",
+ Params: []spec.Param{
+ {Name: "filter", Type: "", Required: false}, // empty type
+ }},
+ },
+ },
+ },
+ }
+
+ err := WriteToolsManifest(dir, parsed)
+ require.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(dir, ToolsManifestFilename))
+ require.NoError(t, err)
+
+ var got ToolsManifest
+ require.NoError(t, json.Unmarshal(data, &got))
+
+ require.Len(t, got.Tools, 1)
+ require.Len(t, got.Tools[0].Params, 1)
+ assert.Equal(t, "string", got.Tools[0].Params[0].Type, "empty type should default to string")
+}
+
+func TestToolSnake(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"Pets", "pets"},
+ {"GetInventory", "get_inventory"},
+ {"List", "list"},
+ {"PublicList", "public_list"},
+ {"APIKeys", "a_p_i_keys"}, // mirrors toSnake behavior for consecutive caps
+ {"simple", "simple"},
+ {"already_snake", "already_snake"},
+ {"with-hyphen", "with-hyphen"}, // does NOT convert hyphens
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.want, toolSnake(tt.input))
+ })
+ }
+}
+
+func TestMcpDescriptionForManifest(t *testing.T) {
+ tests := []struct {
+ name string
+ desc string
+ noAuth bool
+ authType string
+ publicCount int
+ totalCount int
+ wantSuffix string
+ }{
+ {
+ name: "public minority gets annotated",
+ desc: "List items",
+ noAuth: true,
+ authType: "api_key",
+ publicCount: 2,
+ totalCount: 10,
+ wantSuffix: "(public)",
+ },
+ {
+ name: "auth minority gets annotated with api_key",
+ desc: "Create item",
+ noAuth: false,
+ authType: "api_key",
+ publicCount: 8,
+ totalCount: 10,
+ wantSuffix: "(requires API key)",
+ },
+ {
+ name: "auth minority gets annotated with cookie",
+ desc: "Create item",
+ noAuth: false,
+ authType: "cookie",
+ publicCount: 8,
+ totalCount: 10,
+ wantSuffix: "(requires browser login)",
+ },
+ {
+ name: "no annotation when all auth",
+ desc: "Create item",
+ noAuth: false,
+ authType: "api_key",
+ publicCount: 0,
+ totalCount: 10,
+ wantSuffix: "",
+ },
+ {
+ name: "no annotation when all public",
+ desc: "List items",
+ noAuth: true,
+ authType: "api_key",
+ publicCount: 10,
+ totalCount: 10,
+ wantSuffix: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := mcpDescriptionForManifest(tt.desc, tt.noAuth, tt.authType, tt.publicCount, tt.totalCount)
+ if tt.wantSuffix != "" {
+ assert.Contains(t, result, tt.wantSuffix)
+ } else {
+ assert.NotContains(t, result, "(public)")
+ assert.NotContains(t, result, "(requires")
+ }
+ })
+ }
+}
+
+func TestNormalizeAuthFormat(t *testing.T) {
+ tests := []struct {
+ name string
+ format string
+ envVars []string
+ want string
+ }{
+ {
+ name: "already uses env var name",
+ format: "Bearer {DUB_TOKEN}",
+ envVars: []string{"DUB_TOKEN"},
+ want: "Bearer {DUB_TOKEN}",
+ },
+ {
+ name: "derived placeholder replaced with env var",
+ format: "Bearer {token}",
+ envVars: []string{"DUB_TOKEN"},
+ want: "Bearer {DUB_TOKEN}",
+ },
+ {
+ name: "semantic access_token replaced",
+ format: "Bearer {access_token}",
+ envVars: []string{"GITHUB_TOKEN"},
+ want: "Bearer {GITHUB_TOKEN}",
+ },
+ {
+ name: "multi-part derived placeholder",
+ format: "Basic {project_id}:{secret}",
+ envVars: []string{"STYTCH_PROJECT_ID", "STYTCH_SECRET"},
+ want: "Basic {STYTCH_PROJECT_ID}:{STYTCH_SECRET}",
+ },
+ {
+ name: "empty format stays empty",
+ format: "",
+ envVars: []string{"TOKEN"},
+ want: "",
+ },
+ {
+ name: "no env vars stays unchanged",
+ format: "Bearer {token}",
+ envVars: nil,
+ want: "Bearer {token}",
+ },
+ {
+ name: "api_key semantic alias replaced",
+ format: "ApiKey {api_key}",
+ envVars: []string{"STEAM_WEB_API_KEY"},
+ want: "ApiKey {STEAM_WEB_API_KEY}",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := normalizeAuthFormat(tt.format, tt.envVars)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestEnvVarPlaceholder(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"DUB_TOKEN", "token"},
+ {"STYTCH_PROJECT_ID", "project_id"},
+ {"STEAM_WEB_API_KEY", "web_api_key"},
+ {"TOKEN", "token"},
+ {"GITHUB_TOKEN", "token"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.want, envVarPlaceholder(tt.input))
+ })
+ }
+}
+
+func TestOnelineForManifest(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"newlines collapsed", "line one\nline two", "line one line two"},
+ {"double spaces collapsed", "too many spaces", "too many spaces"},
+ {"quotes replaced", `say "hello"`, "say 'hello'"},
+ {"long string truncated", string(make([]byte, 200)), ""},
+ {"trimmed", " spaces ", "spaces"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := onelineForManifest(tt.input)
+ if tt.name == "long string truncated" {
+ assert.LessOrEqual(t, len(result), 120)
+ } else {
+ assert.Equal(t, tt.want, result)
+ }
+ })
+ }
+}
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index b34faea3..ca445d97 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -580,7 +580,10 @@ The registry file has this structure:
"public_tool_count": 7,
"auth_type": "<api_key|none|bearer_token|cookie|composed>",
"env_vars": ["<ENV_VAR_NAME>"],
- "mcp_ready": "<full|partial|cli-only>"
+ "mcp_ready": "<full|partial|cli-only>",
+ "manifest_checksum": "<sha256:hex of tools-manifest.json>",
+ "spec_format": "<openapi3|graphql|internal>",
+ "manifest_url": "library/<category>/<cli-name>/tools-manifest.json"
}
}
]
@@ -597,6 +600,9 @@ Read `$PUBLISH_REPO_DIR/registry.json`, parse the `entries` array (not the top-l
- `auth_type`: from manifest `auth_type`
- `env_vars`: from manifest `auth_env_vars`
- `mcp_ready`: from manifest `mcp_ready`
+- `manifest_checksum`: SHA-256 hash of the `tools-manifest.json` file in the CLI directory (format: `sha256:<hex>`). Compute with: `sha256sum tools-manifest.json | awk '{print "sha256:" $1}'`. If `tools-manifest.json` does not exist, omit this field.
+- `spec_format`: from manifest `spec_format` (e.g., `openapi3`, `graphql`, `internal`). Omit if empty.
+- `manifest_url`: derived from the entry's `path` field: `<path>/tools-manifest.json`. Omit if `tools-manifest.json` does not exist.
If the manifest has no MCP fields (empty `mcp_binary`), omit the `mcp` block entirely.
diff --git a/smithery.yaml b/smithery.yaml
new file mode 100644
index 00000000..0a4e3c44
--- /dev/null
+++ b/smithery.yaml
@@ -0,0 +1,8 @@
+name: printing-press-mcp
+description: >
+ Access 270+ API tools across 6 APIs through one MCP server.
+ Sports scores (ESPN), link management (Dub), pizza ordering (Pagliacci),
+ game stats (Steam), and more. Zero config for public APIs — set env vars
+ for authenticated APIs. Discover APIs with library_info, activate on demand.
+startCommand:
+ command: printing-press-mcp
← 3393ada0 fix(cli): sync version files to 1.1.0 and fix release-please
·
back to Cli Printing Press
·
chore(main): release 1.2.0 (#144) 2e3b7860 →