Manifold README

The full upstream README, mirrored for reference. Install config, tool schemas, adoption signals and our own overview live on the Manifold listing page.

Manifold

One interface. Many connections. Manifold.

CI Release License: MIT

English | 日本語

Manifold is a gateway that acts as an MCP server while connecting to multiple external MCP servers and OpenAPI / Swagger-compliant REST APIs on the backend.

Why "Manifold"?

The name Manifold comes from an engine's intake manifold.

An intake manifold is the component that distributes air and fuel evenly and efficiently from a single inlet to multiple cylinders. We named this project Manifold because its structure is similar.

Engine manifoldThis project
Single inletRequests from MCP clients
Distribution / routingProtocol conversion / routing
To multiple cylindersTo multiple external MCP / REST APIs

Architecture

mermaid
flowchart TD
    client["MCP Client"] --> manifold["Manifold<br/>(this server)"]
    manifold --> mcp["External MCP Servers"]
    manifold --> rest["OpenAPI / Swagger<br/>REST API Servers"]
    manifold --> a2a["A2A Agents"]

Features

  • OpenAPI / Swagger → MCP conversion: Automatically generates MCP tools from OpenAPI 3.x / Swagger 2.x specifications
  • Static tool catalog: Inspect the MCP tools an OpenAPI spec would generate before starting the gateway (manifold openapi tools), and start from a committed, diffable generated file instead of fetching the spec at boot (manifold openapi generate, mcpServers.<name>.tools.file)
  • Breaking-change detection: Classify upstream spec changes as breaking or not with oasdiff, mapped to the affected MCP tools (manifold openapi diff, manifold openapi generate --check)
  • MCP backend aggregation: Transparent reverse proxy to external MCP servers, relaying their tools and prompts
  • Tool filtering and renaming: Expose only the tools you need, under the names and descriptions you choose (mcpServers.<name>.tools.include / exclude / overrides)
  • Audit log: Write one JSON line per tool call (audit)
  • A2A agents as MCP servers: Expose an A2A (Agent2Agent) agent's Agent Card skills as MCP tools, either served on their own (agents) or attached to a service (mcpServers.<name>.agents, skills exposed as <agent>__<skill> tools next to the service's own tools), with the caller's session id carried as the A2A contextId and the response context returned in _meta.a2a
  • Built-in OAuth 2.1 server: Authorization server with PKCE (S256) support. Downstream clients register through DCR (RFC 7591) or a client ID metadata document (CIMD), and can be mapped one-to-one onto upstream OAuth clients
  • Pluggable backend authentication: Choose one of static header (authValue) / OAuth 2.0 (oauth2) / API key Token Exchange (tokenExchange)
  • MCP Apps: Passes MCP Apps (io.modelcontextprotocol/ui) UIs of MCP backends through to hosts that support them — _meta.ui on tools, and the ui:// resources via resources/list / resources/read — and strips them for hosts that don't (mcpServers.<name>.apps)
  • Resource links: Stores binary content from tool responses (including format: binary fields inside JSON responses) in S3 and returns download URLs (resource links)
  • Lazy connection (stdio) / stateless connection (http): stdio backends connect on first request (no backend dependency at gateway startup); http backends open a fresh connection per request and never share a session across callers
  • Selectable storage: Session / token management in memory (default), Redis or SQLite
  • OpenTelemetry support: OTLP export of traces, metrics, and logs (metrics also support Prometheus-style pull)

Requirements

  • Nothing else for the prebuilt binary or Docker image. Go 1.26+ to build from source
  • Optional: Redis or SQLite, to keep sessions and OAuth tokens across restarts or share them between replicas. Without either, Manifold keeps them in memory

Installation

Download binary

Download the latest binary from Releases.

Build from source

bash
git clone https://github.com/nonchan7720/manifold.git
cd manifold
go build -o manifold .

Docker

Terminal
docker pull ghcr.io/nonchan7720/manifold:latest

Usage

Start the gateway

bash
# Run the binary
manifold gateway

# Specify a config file explicitly (-c / --config, config name without extension)
manifold gateway -c config

# Run from source
go run main.go gateway

# Docker (working directory is /home/nonroot)
docker run -p 9999:9999 \
  -v $(pwd)/config.yaml:/home/nonroot/config.yaml \
  ghcr.io/nonchan7720/manifold:latest

Docker Compose (development)

Starts a development environment including Redis.

Terminal
docker compose up -d

Ready-to-run configuration examples are available in the examples/ directory.

Inspect and generate MCP tools

For OpenAPI-mode servers (spec and/or tools.file configured), manifold openapi shows what the gateway would register, and can write it to a file the gateway starts from — without ever fetching the spec at boot.

bash
# Print the tools every OpenAPI-mode server would register (no gateway started)
manifold openapi tools -c config

# One server, with the full inputSchema
manifold openapi tools -c config --server petstore --json

# Write the generated tools file for every server that has tools.file configured
# (errors for a server that has no spec configured)
manifold openapi generate -c config

# CI: fail if the committed file doesn't match the live spec, without writing anything
manifold openapi generate -c config --check

# Show breaking changes between the committed file and the live spec (oasdiff)
manifold openapi diff -c config

openapi tools output:

text
SERVER    TOOL          OPERATION          DESCRIPTION
petstore  addpet        POST /pet          Add a new pet to the store.
petstore  getpetbyid    GET /pet/{petId}   Find pet by ID.

The generated file (tools.file) is YAML, with a diffable tools section followed by the resolved spec:

yaml
version: 1
generatedBy: manifold 1.12.0
source:
  spec: https://petstore3.swagger.io/api/v3/openapi.json
  sha256: "..."
  fetchedAt: "2026-09-04T00:00:00Z"
format: openapi3
tools:
  - name: getpetbyid
    operation: GET /pet/{petId}
    description: Find pet by ID.
    binaryResponse: false
    inputSchema: { ... }
spec: { ... }   # openapi3 document, external $refs internalized

Binary fields and responses

A format: binary property in a multipart/form-data, application/x-www-form-urlencoded or application/json request body (for Swagger 2, type: file form parameters and format: binary fields inside an in: body schema, including nested objects, arrays, $ref and allOf) is not exposed as a plain string. It becomes a oneOf that accepts either a string (base64 content or a URL to fetch the file from) or an object naming the source explicitly (url / base64 / text / content, plus optional filename and contentType), and carries _meta.manifold.file: true so clients can recognize it as a file input. An operation whose success response is binary (e.g. image/png, application/octet-stream) is marked binaryResponse: true; at runtime such responses are handled as binary content and, when storage is configured, returned as resource links (see storage). Only a successful (2xx) response whose actual Content-Type is not textual is treated as binary: error responses, 3xx responses and text/JSON/XML/YAML bodies (including +json / +xml types) are returned as-is even for a binaryResponse: true tool. From a spec with one upload and one download operation:

yaml
tools:
  - name: uploadfile
    operation: POST /files
    description: Upload a file
    binaryResponse: false
    inputSchema:
      properties:
        file:
          _meta:
            manifold:
              file: true
              fileInputHint: 'Provide the file content as a base64-encoded string, or as a URL (e.g. a presigned URL) to download the file from. For explicit control, an object may be passed instead with one of these keys: {url:"..."} ...'
          description: File to upload
          oneOf:
            - description: Base64-encoded file content, or a URL (e.g. a presigned URL) to download the file from.
              type: string
            - description: Explicit file source; provide exactly one of url/base64/text/content.
              properties:
                base64: { type: string, description: Base64-encoded file content. }
                url: { type: string, description: URL to download the file content from. }
                text: { type: string, description: Raw (non-base64-encoded) text file content. }
                content: { type: string, description: Legacy auto-detected base64 or URL content. }
                filename: { type: string, description: Filename to use for the upload. }
                contentType: { type: string, description: MIME content type to use for the upload. }
              type: object
        label:
          _meta: {}
          description: ""
          type: string
      required:
        - file
      type: object
  - name: downloadfile
    operation: GET /files/{fileId}/content
    description: Download a file
    binaryResponse: true
    inputSchema:
      properties:
        fileId:
          description: ""
          type: string
      required:
        - fileId
      type: object

In a JSON request body, a binary field's value is resolved the same way (base64, URL or explicit object) and sent to the upstream API as base64. Conversely, a format: binary field inside a 2xx JSON response (Swagger 2: format: binary or type: file; nested objects and arrays included, OpenAPI 3.1 contentMediaType used as the content type) is uploaded to storage when it is configured: the base64 value in the JSON is replaced with its download URL, and a resource link is added to the result for each uploaded field. Without storage, or for null / non-base64 values, the JSON is returned unchanged.

Recommended workflow:

  1. Add tools.file to the server's config (see mcpServers.<name>.tools) and run manifold openapi generate -c config.
  2. Commit the generated file. Its tools section makes upstream spec changes reviewable as a normal PR diff.
  3. Start the gateway (manifold gateway -c config) — it reads the tools from the file, with no network access to spec at startup.
  4. After the upstream spec changes, re-run manifold openapi generate -c config and commit the update. A stale file (spec changed but the file wasn't regenerated) fails gateway startup with an error telling you to regenerate.
  5. Add manifold openapi generate -c config --check as a CI step, so a PR that changes the upstream spec without regenerating the file fails before merge.

CI: --check only checks servers with tools.file configured — a server without one is skipped with a stderr note, and --server restricts the check to a single server. For each, it rebuilds the catalog from the live spec and compares it against the committed file: source.sha256 (the upstream spec's raw bytes), the tools section, and the embedded spec section (the internalized document the gateway actually runs from) — generatedBy and source.fetchedAt are not compared. It exits non-zero on any difference, including a spec change that leaves the tool list untouched, since the embedded spec also drives runtime request building. It never writes. Example GitHub Actions step:

yaml
- name: Check generated OpenAPI tools files are up to date
  run: manifold openapi generate -c config --check

When the embedded spec differs, --check also prints an oasdiff breaking-change summary for that server, listing each breaking change with the MCP tool it affects (non-breaking changes are only counted; see openapi diff below for the full list). Its exit status is unchanged — any drift still fails:

text
server "petstore": drift detected (generated/petstore.yaml)
  spec changed (sha256 a31896bb… → 3958dc03…)
  embedded spec differs from what the live spec produces
  compatibility: 2 breaking changes (2 errors, 0 warnings), 2 non-breaking
    error    GET /pet/{petId} (getpetbyid)  new-required-request-parameter: added the new required `query` request parameter `verbose`
    error    POST /pet/{petId}/uploadImage (uploadfile)  api-path-removed-without-deprecation: api path removed without deprecation
  + added: listpets (GET /pet)
  - removed: uploadfile (POST /pet/{petId}/uploadImage)
  ~ changed: getpetbyid (inputSchema)
  run "manifold openapi generate" to update

Tool annotations

Each OpenAPI / Swagger tool carries MCP tool annotations derived from its HTTP method:

MethodKindreadOnlyHintdestructiveHintidempotentHint
GET / HEAD / OPTIONS / TRACEreadOnlytruefalsetrue
POST / PUT / PATCH (and others)writefalsefalsefalse
DELETEdestructivefalsetruefalse

An operation can override them with the x-mcp.annotations extension. kind replaces the method-derived preset, and the individual hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) and title override it on top:

yaml
paths:
  /pets/search:
    post:
      operationId: searchPets
      x-mcp:
        annotations:
          kind: readOnly # readOnly | write | destructive
  /pets/{petId}:
    put:
      operationId: replacePet
      x-mcp:
        annotations:
          kind: destructive
          idempotentHint: true
          title: Replace a pet

An unknown kind or key, or a value of the wrong type, makes the whole x-mcp.annotations of that operation ignored with a warning log naming the operation; the tool keeps its method-derived annotations.

Breaking-change detection (openapi diff)

manifold openapi diff answers "is this upstream spec change safe for my MCP clients?". For every server with tools.file configured (others are skipped with a stderr note), it compares the spec embedded in the committed generated file (base) against what the live spec produces now (revision) using oasdiff's backward-compatibility checks, and reports every change with its level — error and warning are breaking, info is not — and the MCP tool whose operation it affects. It never writes.

FlagDefaultDescription
--server(all)Restrict to a single server
--fail-onERRExit non-zero if any change is at or above this level: ERR, WARN or INFO. "" or NONE never fails on changes (load errors still fail)
--formattexttext, json or markdown
--by-toolfalseGroup the report by MCP tool instead of listing changes flat (see below)
bash
# Fail only on errors (default)
manifold openapi diff -c config

# Fail on warnings too, as a Markdown table
manifold openapi diff -c config --fail-on WARN --format markdown

# Report only, never fail on changes
manifold openapi diff -c config --server petstore --format json --fail-on NONE

# Which tool calls break?
manifold openapi diff -c config --by-tool

text output:

text
petstore: 2 breaking changes (2 errors, 0 warnings), 2 non-breaking
  error    GET /pet/{petId} (getpetbyid)  new-required-request-parameter: added the new required `query` request parameter `verbose`
  error    POST /pet/{petId}/uploadImage (uploadfile)  api-path-removed-without-deprecation: api path removed without deprecation
  info     -  api-version-not-bumped: a breaking change was detected but the version is still `1.0.0`
  info     GET /pet (listpets)  endpoint-added: endpoint added

A server whose spec is unchanged prints petstore: no API changes. json output is an object keyed by server name, each with breaking / errors / warnings / infos counts and a changes array of {level, id, operation, message, tool} (operation and tool are omitted for a change outside paths, such as api-version-not-bumped). markdown renders a ### <server> section with a table per server, suitable for a PR comment or a job summary.

With --by-tool the same result is grouped by MCP tool, answering "which tool calls will break". Each affected tool is listed with its status and highest level, most severe first, and its changes indented below; unaffected tools are omitted and counted in the summary line. Changes tied to no tool (components, security, api-version-not-bumped, …) go last under (spec-wide):

text
petstore: 3 of 5 tools affected, 2 breaking changes (2 errors, 0 warnings), 2 non-breaking
  error    changed  getpetbyid (GET /pet/{petId})
    error    new-required-request-parameter: added the new required `query` request parameter `verbose`
  error    removed  uploadfile (POST /pet/{petId}/uploadImage)
    error    api-path-removed-without-deprecation: api path removed without deprecation
  info     added    listpets (GET /pet)
    info     endpoint-added: endpoint added
  (spec-wide)
    info     -  api-version-not-bumped: a breaking change was detected but the version is still `1.0.0`

A tool's status also reflects the generated tools themselves (the same comparison generate --check prints): removed — the tool is no longer generated, so every client still calling it breaks; it always counts as error, even if oasdiff reported nothing for it (e.g. an operationId rename); added — a new tool; changed — oasdiff reported a change on its operation, or its generated inputSchema changed. The latter case with no oasdiff change (e.g. a parameter description edit) is listed at level none with a note. json output becomes {affected, total, tools: [{name, operation, status, level, note?, changes: [{level, id, message}]}], specWide: [...]} per server, and markdown a single table per server with Tool | Operation | Status | Level | Rule | Message columns, one row per change. --fail-on still compares against the highest level, with a removed tool counted as error.

Run it in CI before regenerating, so a PR that pulls in a breaking upstream change is flagged with exactly which tools break:

yaml
- name: Check upstream OpenAPI changes for breaking changes
  run: manifold openapi diff -c config --format markdown >> "$GITHUB_STEP_SUMMARY"

With >> the step's exit status is still manifold's, so the job fails on an ERR-level change while the report lands in the job summary. Swagger 2.x specs are skipped, as with generate.

Configuration

Place a configuration file (config.yaml) in the current directory or in a config/ subdirectory. Configuration values support environment variable expansion in the form ${VAR} or ${VAR:-default}.

Splitting the config across files (include)

A top-level include list merges the mcpServers and agents sections from other YAML files into the config, similar to LiteLLM's include directive:

yaml
# config.yaml
include:
  - serviceA.yaml       # relative to this file (may only contain `mcpServers` / `agents`)
  - serviceB.yaml
  - services.d/*.yaml   # glob patterns are merged in lexical order
gateway:
  port: 9998
  encryptKey: ${ENCRYPT_KEY}
sqlite:
  path: ./tmp/manifold.db
yaml
# serviceA.yaml
mcpServers:
  notion:
    transport: http
    url: https://mcp.notion.com/mcp
    description: Notion MCP server
yaml
# serviceB.yaml
mcpServers:
  google:
    baseURL: https://www.googleapis.com/calendar/v3
    spec: https://example.com/calendar/openapi.yaml
    description: Google Calendar API
  • Included files may only contain the mcpServers and agents keys; any other key (including a nested include) is an error.
  • Included files are merged in list order, and the main config file is merged last, so its own values win. Servers with different names are combined; a server defined in several files has its settings merged.
  • Maps are merged recursively; lists and scalars are replaced as a whole.
  • Paths are relative to the main config file and may use ${VAR} expansion. A missing file is an error (a glob with no match is not).

Connecting to an MCP backend

Expose an external MCP server through Manifold.

yaml
gateway:
  port: 9999
  # openssl rand -base64 32
  encryptKey: ${ENCRYPT_KEY}

mcpServers:
  my-mcp-server:
    description: External MCP server
    transport: http
    url: http://localhost:8080/mcp

sqlite:
  path: ./tmp/manifold.db

The backend's tools (tools/list / tools/call) and prompts (prompts/list / prompts/get) are relayed as is. A backend that does not advertise the prompts capability returns an empty prompts/list, and prompts/get returns an unknown-prompt error. Since a prompt can tell the model how to use tools, authz applies to prompts with the same decisions as tools (see Decision contract); tools.include / exclude / overrides do not. notifications/prompts/list_changed from the backend is not relayed.

MCP Apps (apps)

An MCP backend (http / stdio) can serve MCP Apps: tools carrying _meta.ui.resourceUri, whose ui:// resource (text/html;profile=mcp-app) the host renders. Manifold forwards resources/list, resources/templates/list and resources/read to the backend, and advertises the resources capability on these endpoints.

Whether a caller supports MCP Apps decides what it gets:

  • Supporting hosts get the backend's tools/list as is, _meta.ui included.
  • Other hosts get tools/list without _meta.ui, and without the tools only the UI calls (_meta.ui.visibility without "model").

A host declares support in capabilities.extensions["io.modelcontextprotocol/ui"]. Manifold serves MCP over stateless HTTP, so it only sees that declaration when the host sends it with every request (protocol 2026-07-28 and later); what an older host declared in initialize is gone by its next request. Set apps: true to treat every host on the endpoint as supporting MCP Apps when its request declares nothing:

yaml
mcpServers:
  browser:
    description: Chrome DevTools with a live view
    transport: stdio
    command: browser-mcp
    apps: true

Toward the backend, an http backend is told the caller supports MCP Apps (in its initialize) only when Manifold treats the caller as supporting them. A stdio backend shares one session between every caller, so it is always told so; Manifold strips the UI for callers that don't support it. Tools with a UI should still return text content, for those callers.

Resources are not subject to tool authorization or tools.include / exclude; only the endpoint's authentication applies.

Connecting to an A2A agent

Expose an A2A (Agent2Agent) agent through Manifold. Each entry under agents is served at /mcp/<name> like an mcpServers entry (see agents.<name>).

yaml
agents:
  translator:
    url: https://translator.example.com   # Agent Card is fetched from <url>/.well-known/agent-card.json
    description: |
      Translation agent. Always pass the caller's session id as sessionId.
      On _meta.a2a.state = input-required, reply with the same sessionId and the returned taskId.
    skills: [translate, summarize]        # optional: expose only these skills, in this order
    timeout: 30s                          # optional: timeout of one message/send (default 60s)
    oauth2:
      clientID: ${TRANSLATOR_CLIENT_ID}
      clientSecret: ${TRANSLATOR_CLIENT_SECRET}
      authURL: https://auth.example.com/authorize
      tokenURL: https://auth.example.com/token
  • skills limits which Agent Card skills become tools. Only the listed skill IDs are exposed, in the listed order; an ID that the card does not have is skipped with a warning log (startup is not affected). When skills is unset, every skill in the card is exposed. A skill that is not exposed cannot be called either: tools/call returns the same unknown skill error as for a skill the card does not have.
  • Manifold resolves the Agent Card (v0.3 and v1.0 formats) from url (plus agentCardPath, default /.well-known/agent-card.json) and sends messages to the endpoint the card declares — url is never used as the message endpoint. The card is fetched at startup (a failure only logs a warning) and again on the first request if needed, then cached for the lifetime of the process.
  • Every exposed skill (all skills in the card unless skills is set) becomes one MCP tool named after the skill id. The tool description is description (the operator's instruction to the calling agent) followed by the skill's name, description, tags and examples from the card. /mcp/list?tools=true lists the skills.
  • tools/call arguments: sessionId (required — the calling agent's session id, forwarded as the A2A contextId), taskId (optional; continue a task, e.g. after input-required), and at least one of message (text), data (JSON object, sent as a data part) or files (each written like an OpenAPI file input — a base64 string / URL, or {url|base64|text, filename, contentType} — see Binary fields and responses). The message text is sent as-is; the chosen skill id is passed in the message metadata.skillId since A2A has no per-request skill selector.
  • Results: text and data parts become text content (data parts are also returned as structuredContent, which is always a JSON object: a single object part as is, an array or several parts as {"items": [...]}, a lone scalar only as text), file URLs become resource links, and file bytes are handled like OpenAPI binary responses (uploaded to storage and returned as a resource link when configured, inline otherwise). _meta.a2a carries protocolVersion, contextId, taskId, state (e.g. completed, input-required), messageId and the artifacts list. A task in failed / rejected state is an isError result.
  • Authentication (authValue / oauth2 / tokenExchange), headers and tool authorization work as for mcpServers; the policy input is server=<name>, service=<service.code, default name>, tool=<skill id>.
  • Streaming (message/stream), task polling and push notifications are not used; every call is a blocking message/send.

Attaching agents to a service

To hand a service's callers agents that belong to it, put them under agents of that mcpServers entry (any transport except reverse, including OpenAPI). The service is still served at /mcp/<name> and configured as before; its tools/list now returns the service's own tools plus one tool per exposed skill of each agent.

yaml
mcpServers:
  billing:                       # a service, configured as before
    transport: http
    url: https://billing.example.com/mcp
    description: Billing service
    agents:                      # A2A agents attached to this service
      translator:
        url: https://translator.example.com
        description: Use for translation.
        skills: [translate]      # optional
        timeout: 30s
      reviewer:
        url: https://reviewer.example.com
        description: Use for review.
  • Tools are named <agent>__<skill> (double underscore), e.g. translator__translate. tools/call on such a name is a message/send to that agent's skill, exactly like a top-level agent's skill tool; sessionId, taskId, message / data / files and the result format are the same as above.
  • Order: the service's own tools first, then the agents in name order, each agent's skills in card order (or in skills order when it is set). /mcp/list?tools=true returns the same list. A tools/call whose name does not start with an attached agent's <agent>__ goes to the service as before.
  • Name collisions: if an attached agent's tool name equals one of the service's own tools (e.g. the service has a tool translator__translate and the agent translator has a skill translate), the service's tool wins. It is listed once, tools/call reaches the service, and the agent's colliding skill is dropped from the list with a warning log. Rename the agent to resolve it.
  • An agent whose Agent Card cannot be fetched is skipped from tools/list (and /mcp/list?tools=true) with an error log; the service's own tools and the other agents are still returned, and the card is fetched again on the next request.
  • oauth2 is not available for these agents. The OAuth flow belongs to the server: the caller's per-server upstream token is what the round tripper forwards, so an agent's own oauth2 client settings would be silently ignored. Use authValue, tokenExchange or headers, or configure the agent under the top-level agents directive. See docs/design/service-agents.md for the rationale.
  • Not available on transport: reverse (those servers are resolved per user by the reverse gateway).
  • Tool authorization sees the server's name, its service code and the composed tool name: server=<server>, service=<the server's service.code>, tool=<agent>__<skill>. The same policy therefore governs the service's own tools and its agents' skills, and tools/list is filtered accordingly.

Grouping servers into a service (service)

A service often exposes several API sets — an MCP server, an OpenAPI spec, an A2A agent. service groups those mcpServers / agents entries under one service code, so tool authorization can grant a whole service instead of listing every top-level key:

yaml
mcpServers:
  billing-api:
    description: Billing REST API
    baseURL: https://billing.example.com/api
    spec: https://billing.example.com/openapi.yaml
    service:
      code: billing       # passed to the policy as input.service
      name: Billing       # display name for UIs (/mcp/list)
  billing-mcp:
    transport: http
    url: https://billing.example.com/mcp
    description: Billing MCP server
    service:
      code: billing       # name omitted: "Billing" from billing-api is used
agents:
  billing-assistant:
    url: https://billing-agent.example.com
    description: Use for billing questions.
    service:
      code: billing
  • service.code defaults to the entry's own name (its top-level key), so a config without service keeps one service per server. It follows the server-name character rules (alphanumerics, _ and -).
  • service.name is only for display and defaults to the code. Entries sharing a code must not set different names; an entry that omits it takes the name another entry of the same service set.
  • The URL path (/mcp/{name}), OAuth endpoints and everything else keyed by server name are unchanged; service only adds input.service to authz decisions and service to /mcp/list entries.
  • service on an agent under mcpServers.<name>.agents is ignored: its skills are tools of that server, so they belong to the server's service.

A policy that matches <service>/<tool> instead of <server>/<tool> (compare examples/opa/policy.rego) then grants billing/* across all three entries:

rego
allow if {
	some group in input.groups
	some pattern in data.policies[group].tools
	glob.match(pattern, ["/"], sprintf("%s/%s", [input.service, input.tool]))
}

Connecting to an OpenAPI / Swagger backend

Automatically generate MCP tools from an OpenAPI specification.

yaml
gateway:
  port: 9999
  encryptKey: ${ENCRYPT_KEY}

mcpServers:
  my-api:
    description: Sample REST API
    spec: https://example.com/api/openapi.json
    baseURL: https://example.com

OpenAPI backend with OAuth 2.0 authentication

yaml
gateway:
  port: 9999
  encryptKey: ${ENCRYPT_KEY}

mcpServers:
  my-api:
    description: OAuth-protected API
    spec: https://example.com/api/openapi.json
    baseURL: https://example.com
    oauth2:
      clientID: YOUR_CLIENT_ID
      clientSecret: YOUR_CLIENT_SECRET
      authURL: https://example.com/oauth/authorize
      tokenURL: https://example.com/oauth/token
      scopes:
        - read
        - write

redis:
  addrs:
    - "${REDIS_ADDRS:-localhost:6379}"
  db: ${REDIS_DB:-0}

Prompts for an OpenAPI backend (prompts)

An OpenAPI spec has no way to describe MCP prompts, so an OpenAPI-mode server can serve them from a separate prompts document, kept apart from spec. Set exactly one source under mcpServers.<name>.prompts:

  • url: the document is fetched from this absolute http(s) URL on every prompts/list and prompts/get, so the backend can change its prompts without a gateway release. The request carries the same authentication as tool calls (authValue / oauth2 / tokenExchange) and headers. A failed fetch returns an error to the caller.
  • file: the document is read at startup (an unreadable or invalid file fails startup) and re-read whenever it changes. The directory is watched, so a Kubernetes ConfigMap mounted as a volume, which is updated by swapping its ..data symlink, is picked up without a restart. A change to an invalid document is logged and the previous prompts keep being served.
yaml
mcpServers:
  petstore:
    description: Swagger Petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json
    prompts:
      file: /etc/manifold/prompts/petstore.yaml   # or url: https://petstore.example.com/mcp-prompts.yaml

The document is YAML or JSON, at most 1 MiB:

yaml
prompts:
  - name: summarize_pet
    title: Summarize a pet            # optional
    description: Summarize a pet's profile
    arguments:
      - name: petId
        description: ID of the pet
        required: true
      - name: tone
    messages:                         # role is user or assistant
      - role: user
        content: "Summarize the pet {{.petId}}.{{if .tone}} Tone: {{.tone}}.{{end}}"

content is a Go text/template that refers to the arguments by name ({{index . "pet-id"}} for names that contain -). A missing required argument is rejected with an invalid-params error, and an omitted optional argument expands to an empty string. authz applies to these prompts with the same decisions as tools (see Decision contract); tools.include / exclude / overrides do not.

Choosing which tools to expose (tools.include / exclude / overrides)

APIs generated from OpenAPI often have far more tools than an agent needs. tools.include / tools.exclude take path.Match glob patterns matched against the tool's original name; a tool is exposed when it matches an include pattern (or include is empty) and no exclude pattern. tools.overrides, keyed by the original name, renames a tool and/or replaces its description. This works for every kind of server (OpenAPI, MCP backends, A2A agents served on their own via agents, WebMCP).

On an OpenAPI / Swagger server, tools.exclude goes one step further: it is applied while the catalog is built, matched against each operation's operationId as well as the (lowercase) tool name derived from it, so "deletePet" and "deletepet" both work. An operation without an operationId is matched against the <method>_<path> id its tool name is derived from (e.g. get__pets_{id}), which is also its tool name. A matching operation is never turned into a tool — it is absent from tools/list, from manifold openapi tools and from the generated tools.file, and openapi diff / specRefresh have no tool to map its changes to. The spec itself is not altered, so changes to an excluded operation are still detected (see below). include and overrides still apply when the tool list is served.

yaml
mcpServers:
  petstore:
    description: Swagger Petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json
    tools:
      include: ["get*", "find*", "addpet"]
      exclude: ["*inventory*"]
      overrides:
        getpetbyid:
          name: get_pet
          description: Look up a single pet by its numeric ID.
        documents:          # any key works when `tool` names the original tool explicitly
          tool: listDocuments
          name: list_documents
  • A renamed tool is only callable under its new name. If the new name equals another tool's original name, the renamed tool wins and the other one is hidden.
  • Filtering happens before authz, so authz — and /mcp/list?tools=true — only see the exposed names. Write OPA policies against the exposed names.
  • A tool that is filtered out behaves exactly like a tool that doesn't exist (unknown tool).
  • With tools.file, exclude is applied by manifold openapi generate, and the gateway applies the same list when it rebuilds the catalog from the file at startup. Changing exclude therefore makes the committed file stale (generated tools are stale … (run "manifold openapi generate")): regenerate it.
  • Breaking changes openapi diff or specRefresh detects on an excluded operation are still reported and still count toward --fail-on / rejectOn, with no tool attributed to them (under (spec-wide) with --by-tool).
  • On a reverse (WebMCP) server the filter only applies to the tab's tools: create_pairing_code is registered by the gateway itself and is always exposed under that name, so users can still pair when include doesn't match it. Likewise the <agent>__<skill> tools of mcpServers.<name>.agents are never filtered or renamed: the filter only applies to the service's own tools, including a service tool whose name happens to start with <agent>__.

Audit log (audit)

yaml
audit:
  enabled: true
  output: /var/log/manifold/audit.jsonl   # stdout, stderr (default) or a file path
  includeArguments: false                 # arguments may contain personal or secret data

Every tools/call writes one JSON line, separate from the application log:

config.json
{"time":"2026-10-03T05:00:00Z","level":"INFO","msg":"audit","event":"tool_call","server":"petstore","service":"petstore","tool":"get_pet","outcome":"success","duration_ms":42,"user":"alice","groups":"dev","token":"9f86d081884c"}
  • outcome is success, tool_error (the tool returned an error result), denied (refused by authz) or error (unknown tool, backend failure, ...). error holds the message for the last two.
  • user / groups come from the authz.headers.userID / userGroups headers when present (even with authz disabled). token is the first 12 hex characters of the SHA-256 of the caller's bearer token — enough to correlate calls, without recording the token.
  • identity is the identityKey a reverse (WebMCP) server routed the call by (e.g. static under static pairing, or the resolved user under remote pairing). Those endpoints skip JWT validation, so user / groups / token are empty and this is the only caller identity recorded.

Redacting secrets (audit.redact)

Before a record is written, secrets are replaced with [REDACTED] in-process (no external call), in the recorded arguments and in error:

  • Key names: the value of any argument key — at any depth — whose name ends with one of password, passwd, secret, token, apikey, authorization, cookie, privatekey, credential, credentials is redacted, comparing case-insensitively and ignoring -, _ and . (so githubToken, client_secret and X-Api-Key match, max_tokens does not). redact.keys adds names to this list.
  • Patterns: redact.patterns are regular expressions (Go RE2); every match inside a string argument value or the error message is redacted.
yaml
audit:
  enabled: true
  includeArguments: true
  redact:
    keys: [ssn, cardNumber]
    patterns:
      - '[\w.+-]+@[\w-]+\.[\w.]+'  # e-mail addresses
      - 'AKIA[0-9A-Z]{16}'            # AWS access key IDs

Guardrail (guardrail)

A guardrail checks every tools/call on its way through the gateway, the way authz does: the arguments before they reach the backend (pre_call) and the text of the result before it reaches the caller (post_call). There is no standardized guardrail API, so Manifold speaks LiteLLM's Generic Guardrail API: any guardrail service built for LiteLLM works as is, and AWS Bedrock Guardrails is used through the bundled sidecar (below).

yaml
guardrail:
  enabled: true
  url: http://localhost:8090/beta/litellm_basic_guardrail_api
  headers:                    # optional, e.g. the guardrail's API key
    X-Api-Key: ${GUARDRAIL_API_KEY}
  timeout: 5s                 # per call (default 5s)
  mode: [pre_call, post_call] # default: both

Manifold POSTs {"texts": [...], "input_type": "request" | "response", ...} with additional_provider_specific_params carrying server, service and tool:

  • pre_call sends the arguments as one JSON text. GUARDRAIL_INTERVENED calls the backend with the rewritten arguments (which must still be a JSON object).
  • post_call sends every text content of the result, then structuredContent as JSON when present. GUARDRAIL_INTERVENED returns them rewritten; other content (images, resource links, ...) passes through unchecked.
  • BLOCKED rejects the call with blocked by guardrail: <blocked_reason>. On pre_call the backend is never called.
  • Fail closed: a guardrail that fails, answers non-200, or answers something invalid rejects the call with guardrail check failed, without passing the reason on (it is logged).

The guardrail is called synchronously, so its latency adds to every tools/call; mode limits it to the side you need. It runs inside authz (denied calls never reach it) and applies to every server, including reverse (WebMCP) ones. The audit log records the arguments as the caller sent them (with audit.redact applied).

AWS Bedrock Guardrails sidecar (manifold guardrail bedrock)

manifold guardrail bedrock serves the Generic Guardrail API and translates each request to Bedrock's ApplyGuardrail. Run it next to the gateway (same image) and point guardrail.url at it; it needs no Manifold config file. AWS credentials and the region are resolved the standard AWS SDK way (environment, shared config, IRSA, ...), and the role needs bedrock:ApplyGuardrail on the guardrail.

yaml
# Kubernetes: a sidecar container in the gateway Pod
- name: guardrail
  image: ghcr.io/nonchan7720/manifold:latest
  command: ["manifold", "guardrail", "bedrock", "--port", "8090"]
  env:
    - {name: BEDROCK_GUARDRAIL_ID, value: abc123xyz}   # or --guardrail-id
    - {name: BEDROCK_GUARDRAIL_VERSION, value: "1"}    # or --guardrail-version (e.g. DRAFT)
    - {name: AWS_REGION, value: ap-northeast-1}        # or --region

Each text is sent to ApplyGuardrail on its own (input_type: request → source INPUT, response → OUTPUT), so the answer's texts line up with the request's. A text whose sensitive-information filters ANONYMIZE comes back masked (GUARDRAIL_INTERVENED); if any policy BLOCKED it — or Bedrock intervened without returning a rewrite — the whole request is BLOCKED with Bedrock's actionReason. A Bedrock error is answered with 502, which the gateway turns into guardrail check failed. GET /healthz answers 200 for probes.

Configuration reference

gateway

FieldTypeDescription
portintListening port (default: 8081)
keystringTLS private key file path (optional)
certstringTLS certificate file path (optional)
encryptKeystringToken encryption key. Base64-encoded 32-byte AES-256 key. Generate with openssl rand -base64 32. Required with redis or sqlite; with the in-memory store a random key is generated at startup when unset
specRefresh.intervaldurationInterval for re-fetching OpenAPI mode specs (e.g. 5m). Unset or 0 disables refreshing
specRefresh.rejectOnstringReject a refreshed spec whose changes reach this level (ERR, WARN or INFO) and keep serving the current tools. Unset, "" or NONE never rejects (default). See Breaking changes during refresh
shutdown.drainDelaydurationAfter SIGTERM, /readyz turns 503 and the gateway keeps serving for this long before it stops accepting connections, so Kubernetes can remove the pod from the Service endpoints first. Set -1s (disabled) when a preStop sleep hook already does this (default: 5s; negative disables the wait)
shutdown.timeoutdurationHow long graceful shutdown waits for in-flight requests to finish (default: 30s)
timeouts.readHeaderdurationMax time to read request headers (default: 10s; negative = unlimited)
timeouts.idledurationMax idle time of a keep-alive connection (default: 120s; negative = unlimited)
timeouts.readdurationMax time to read a whole request including the body (default: unlimited). Leave unset: MCP Streamable HTTP / SSE and the edge WebSocket are long-lived
timeouts.writedurationMax time to write a response (default: unlimited). Leave unset for the same reason as timeouts.read
maxRequestBodySizeintMax request body size in bytes for /mcp/{server_name}; larger requests get 413 (default: 0 = unlimited). Tool arguments carrying base64 file content travel in this body, so if you use them set it to at least fileFetch.maxSize × 4/3 (base64 overhead) plus headroom

gateway.specRefresh

Periodically re-fetches the specs of OpenAPI mode servers (mcpServers.<name>.spec) and updates the MCP tool definitions without restarting Manifold. Added tools are registered, removed tools are unregistered, and connected clients are notified via notifications/tools/list_changed.

yaml
gateway:
  specRefresh:
    interval: 5m

Changes are detected by hashing the fetched spec document, so a change made only in an externally $ref-ed document leaves the hash unchanged and is not picked up. When a fetch or parse fails, the existing tool definitions are kept and the next interval retries.

Breaking changes during refresh

When a refresh fetches a spec that differs from the one currently served, Manifold diffs the two with the same oasdiff checks as openapi diff before swapping the tools:

  • Logs: a summary line with the server name and the number of error / warning / info changes — at WARN level if any change is breaking (error or warning), INFO otherwise — plus one WARN line per breaking change with its level, id, operation, affected tool and message.
  • Metrics: the OpenTelemetry counter manifold.openapi.spec_refresh.changes (attributes server, level = error / warning / info) is incremented once per detected change.
  • Rejection: with rejectOn set (gateway.specRefresh.rejectOn, or per server mcpServers.<name>.specRefreshRejectOn), a refreshed spec whose most severe change is at or above that level is not adopted. The previous spec and tools keep being served, an ERROR log lists the changes that caused the rejection, and manifold.openapi.spec_refresh.rejected (attributes server, level = the most severe change level) is incremented.
yaml
gateway:
  specRefresh:
    interval: 5m
    rejectOn: ERR   # keep the current tools if upstream introduces an error-level breaking change

A rejection lasts until upstream publishes a spec that passes the check against the spec still being served; re-fetching the same rejected spec is not re-diffed, logged or counted again. Restarting the gateway (including a config reload that restarts it) adopts whatever spec is fetched at boot, without any check. Detection is best-effort: if the diff itself fails, a warning is logged and the new spec is adopted as before. It is skipped when there is nothing to compare against — the first successful fetch after the spec failed at startup, or a Swagger 2.x spec.

mcpServers.<name>

Server names (<name>) are used in URL paths, so only alphanumerics, _, and - are allowed.

FieldTypeDescription
descriptionstringServer description (required; included in /mcp/list responses)
service.codestringService code grouping this entry with others (default: the server name). Passed to authz as input.service (see Grouping servers into a service)
service.namestringService display name for UIs (default: the service code). Returned by /mcp/list
transportstringTransport for MCP backends (http or stdio)
urlstringEndpoint for the HTTP transport
commandstringCommand for the stdio transport
args[]stringArguments for the stdio command
envmap[string]stringEnvironment variables for the stdio process
specstringPath, URL, or configmap://<namespace>/<name>/<key> reference to an OpenAPI/Swagger specification. Required for OpenAPI mode unless tools.file is set — the gateway never reads it then, but manifold openapi generate, --check, and openapi tools --from-spec need it
baseURLstringAPI base URL for OpenAPI mode. With spec, defaults to the spec's first servers entry (a relative one is resolved against the spec URL); config validation (and gateway startup) fails when that yields no absolute http(s) URL, so set baseURL explicitly when a local spec file has no servers or only relative ones such as /api/v1 (behavior change in 1.19). Required with tools.file alone
headersmap[string]stringExtra headers added to API requests
authValueobjectStatic authentication settings (header, prefix, value)
oauth2objectOAuth 2.0 settings (see below)
tokenExchangeobjectToken Exchange settings (see below)
specRefreshIntervaldurationPer-server override of gateway.specRefresh.interval. 0 disables refreshing for this server
specRefreshRejectOnstringPer-server override of gateway.specRefresh.rejectOn (ERR, WARN, INFO). NONE (or "") never rejects for this server
tools.filestringPath to a generated tools file (see mcpServers.<name>.tools). When set, the gateway starts from this file instead of fetching spec
tools.include / tools.exclude[]stringGlob patterns selecting the exposed tools. On an OpenAPI / Swagger server exclude also matches operationIds and keeps matching operations out of the catalog entirely (see Choosing which tools to expose)
tools.overridesmap[string]objectPer tool (original name): name, description, and tool to name the original tool explicitly. Keys keep the case written in the config file, so getPetById: matches the tool getPetById (keys differing only by case are rejected)
agentsmap[string]objectA2A agents attached to this service; their skills are added to its tools as <agent>__<skill>. Not for transport: reverse (see mcpServers.<name>.agents.<agent>)
appsboolTreat hosts that don't declare MCP Apps support per request as supporting it. http / stdio only (see MCP Apps)
prompts.url / prompts.filestringSource of the prompts document for an OpenAPI-mode server; exactly one (see Prompts for an OpenAPI backend)

authValue / oauth2 / tokenExchange are mutually exclusive; only one may be configured at a time.

spec from a ConfigMap

spec: configmap://<namespace>/<name>/<key> reads the spec from data[<key>] of a Kubernetes ConfigMap, fetched through the in-cluster Kubernetes API (client-go, in-cluster config — no separate kubeconfig setting). The gateway's ServiceAccount needs get RBAC permission on that ConfigMap:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: my-namespace
  name: manifold-spec-reader
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    resourceNames: ["my-specs"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: my-namespace
  name: manifold-spec-reader
subjects:
  - kind: ServiceAccount
    name: manifold
roleRef:
  kind: Role
  name: manifold-spec-reader
  apiGroup: rbac.authorization.k8s.io

If fetching or parsing spec fails at startup — whether it's a file, URL, or configmap:// reference — the affected server still starts, with zero tools and a warning log naming the server and the error, instead of failing the whole gateway. specRefreshInterval / gateway.specRefresh.interval keeps retrying on the usual schedule, and the tools appear once the spec becomes fetchable. This does not apply to tools.file: a stale or unreadable generated tools file still fails startup (see below).

mcpServers.<name>.tools

tools.file points at a generated tools file (written by manifold openapi generate, see Inspect and generate MCP tools). When it is set, the gateway does not fetch spec at startup or during specRefresh — it loads the tools and the (already-resolved) spec straight from the file, with no network access.

yaml
mcpServers:
  petstore:
    description: Swagger Petstore
    spec: https://petstore3.swagger.io/api/v3/openapi.json   # optional here — needed only for generate/--check/--from-spec
    baseURL: https://petstore3.swagger.io/api/v3
    tools:
      file: ./generated/petstore.yaml
  • baseURL is still required. spec is optional when tools.file is set — the gateway never reads it, but manifold openapi generate (and --check) need it to rebuild the file, so keep it in the config if you use those commands.
  • manifold openapi tools reads the generated file when tools.file is set. --from-spec reads the live spec instead, and errors if spec isn't configured.
  • At startup, Manifold rebuilds the tool catalog from the spec embedded in the file and compares it against the file's tools section. If they don't match (the file is out of date relative to its own embedded spec, or was hand-edited), startup fails, e.g. server "petstore": generated tools are stale: tool "addpet" description differs (run "manifold openapi generate").
  • tools.exclude is applied when the file is generated and again when the gateway rebuilds the catalog from it, so changing it also makes the file stale — regenerate after editing the list.
  • tools.file and a positive specRefreshInterval are mutually exclusive, and a server with tools.file is excluded from gateway.specRefresh — there is no live spec to refresh from.
  • tools.file must be a local path; a URL is rejected.
  • Phase 1 supports OpenAPI 3.x specs only. tools.file cannot be used with a Swagger 2.x spec.
  • The generated file embeds the full resolved spec, including any internal hostnames or example values it contains. Review it before committing to a public repository.

mcpServers.<name>.oauth2

FieldTypeDescription
clientIDstringClient ID of the shared upstream client (required whenever the effective unknownClient is default, see below)
clientSecretstringClient secret of the shared upstream client (same requirement as clientID)
authURLstringAuthorization endpoint (required; absolute URL)
tokenURLstringToken endpoint (required; absolute URL)
scopes[]stringScopes to request
clients[]objectMaps a downstream client_id to the upstream client used for it (see Downstream client registration)
unknownClientstringHow to treat a downstream client absent from clients: reject or default
authParamsmap[string]stringExtra query parameters added to the upstream authorization request

Each clients entry takes downstreamClientID, clientID and clientSecret. downstreamClientID is compared against the downstream client_id exactly, with no normalization, and must not be repeated. authParams may not set the parameters Manifold builds itself (client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method).

When unknownClient is omitted it is reject if clients is non-empty and default if clients is empty, so a configuration without clients keeps behaving as before. The shared clientID / clientSecret are required exactly when the effective value is default — including when you write unknownClient: default explicitly while mapping every client in clients. A missing shared client in that case fails at startup.

unknownClientclientsShared clientID / clientSecret
default (explicit)anyrequired
reject (explicit)anynot required
omittednon-emptynot required (effective reject)
omittedemptyrequired (effective default)

clients can also be supplied whole as a JSON array through a single environment variable, and authParams as a JSON object:

yaml
mcpServers:
  my-api:
    oauth2:
      clients: ${UPSTREAM_CLIENTS_JSON}

Note Parameter names in authParams written in the configuration file are lower-cased by the config loader, so use lower-case names (which is what OAuth 2.0 and OpenID Connect define). To keep a name's casing exactly, supply the whole map as JSON through an environment variable.

mcpServers.<name>.tokenExchange

Exchanges the API key received from the client for an OAuth token at the specified token exchange endpoint, and uses it for backend requests. Exchange results are cached, and rate limits (429) are respected.

FieldTypeDescription
urlstringAbsolute URL of the token exchange endpoint (required)

agents.<name>

A2A agents (see Connecting to an A2A agent). Names share the mcpServers namespace and follow the same character rules; a name used by both is a configuration error.

FieldTypeDescription
descriptionstringInstruction for the calling agent (required). Prepended to every skill's tool description and returned by /mcp/list
service.codestringService code, same as mcpServers.<name> (default: the agent name)
service.namestringService display name, same as mcpServers.<name> (default: the service code)
urlstringBase URL the Agent Card is resolved from (required). Messages go to the endpoint declared in the card
agentCardPathstringAgent Card path relative to url (default /.well-known/agent-card.json)
headersmap[string]stringExtra headers added to Agent Card and message requests
authValueobjectStatic authentication settings (header, prefix, value)
oauth2objectOAuth 2.0 settings (same as mcpServers.<name>.oauth2). Only message requests carry the caller's token; the Agent Card is fetched with headers / authValue only
tokenExchangeobjectToken Exchange settings (same as mcpServers.<name>.tokenExchange)
skills[]stringAgent Card skill IDs to expose as tools, in this order; IDs missing from the card are skipped with a warning. Unset exposes all skills
timeoutdurationTimeout of one message/send (default 60s)

authValue / oauth2 / tokenExchange are mutually exclusive.

mcpServers.<name>.agents.<agent>

A2A agents attached to a service (see Attaching agents to a service). <agent> follows the server-name character rules (alphanumerics, _ and -) and must not contain __, which separates the agent from the skill in tool names (<agent>__<skill>). Agent names are scoped to the service, so they may repeat across services and may equal a top-level name.

FieldTypeDescription
descriptionstringInstruction for the calling agent (required). Prepended to every skill's tool description and returned by /mcp/list
urlstringBase URL the Agent Card is resolved from (required). Messages go to the endpoint declared in the card
agentCardPathstringAgent Card path relative to url (default /.well-known/agent-card.json)
headersmap[string]stringExtra headers added to Agent Card and message requests
authValueobjectStatic authentication settings (header, prefix, value)
tokenExchangeobjectToken Exchange settings (same as mcpServers.<name>.tokenExchange)
skills[]stringAgent Card skill IDs to expose as tools, in this order; IDs missing from the card are skipped with a warning. Unset exposes all skills
timeoutdurationTimeout of one message/send (default 60s)

This is the same as agents.<name> minus oauth2, which is rejected here: the OAuth flow is per server, so use authValue, tokenExchange or headers, or a top-level agents entry. service is ignored here, since the agent's skills belong to the server's service. authValue / tokenExchange are mutually exclusive.

oauth.cimd

Accepts downstream clients that present an HTTPS client_id resolving to a client ID metadata document, instead of registering through DCR (see Downstream client registration). Disabled by default.

FieldTypeDescription
enabledboolEnable CIMD client registration (default: false)
allowedOrigins[]stringWhen non-empty, only client_id URLs on these origins are accepted. Applied before the document is fetched
cacheTTLdurationUpper bound on how long a resolved client is cached (default: 1h). A shorter Cache-Control: max-age wins
maxDocumentSizeintMaximum number of bytes read from the document (default: 65536)

redis

FieldTypeDescription
urlstringRedis URL (e.g. redis://user:pass@localhost:6379/0)
addrs[]stringList of host:port pairs (for Cluster/Sentinel)
userstringUsername
passwordstringPassword
dbintDatabase number
master_namestringSentinel master name
tlsboolEnable TLS
cluster_modeboolEnable Cluster mode
key_prefixstringPrefix prepended to every key stored in Redis (default: empty)

sqlite

FieldTypeDescription
pathstringDatabase file path (:memory: for in-memory)

memory

FieldTypeDescription
enabledboolKeep sessions and tokens in process memory, even when redis is configured

The store is chosen in this order: sqlite.path → memory.enabled → redis → in memory. The in-memory store loses sessions and OAuth tokens on restart and is not shared between replicas; use redis or sqlite for production.

storage

Stores content included in OpenAPI/Swagger tool responses (images, binaries, etc.) in external storage and returns resource links (download URLs). format: binary fields inside JSON responses are uploaded as well and replaced with their download URL (see Binary fields and responses). When unset, no storage is used.

FieldTypeDescription
typestringStorage type. Currently only s3 is supported
hostURLstringHost for download URLs (when set, content is served via Manifold's /media/download/{id})
s3.bucketstringS3 bucket name (required when type: s3)
s3.keyPrefixstringS3 object key prefix (required when type: s3)
yaml
storage:
  type: s3
  hostURL: https://manifold.example.com
  s3:
    bucket: my-bucket
    keyPrefix: manifold/media

audit

FieldTypeDescription
enabledboolWrite one JSON line per tools/call (see Audit log)
outputstringstdout, stderr (default) or a file path (appended to)
includeArgumentsboolAlso record the call's arguments (default: false)
redact.keys[]stringArgument key names whose values are redacted, in addition to the defaults (see Redacting secrets)
redact.patterns[]stringRegular expressions redacted from string argument values and the error message

guardrail

FieldTypeDescription
enabledboolCheck every tools/call with the guardrail (see Guardrail)
urlstringGeneric Guardrail API endpoint (required when enabled; absolute http(s) URL)
headersmap[string]stringHeaders added to every guardrail request
timeoutdurationTimeout of one guardrail call (default 5s)
mode[]stringpre_call and/or post_call (default: both)

fileFetch

When a URL is passed to a file input field of an OpenAPI/Swagger tool (or to an A2A files[] entry), Manifold downloads the file from that URL on the caller's behalf. As an SSRF countermeasure the download is checked at three layers:

  • Scheme: only https:// unless allowLocal is set.
  • Destination IP (checked on the resolved address of every connection, so DNS names resolving to internal addresses are covered):
    • loopback, link-local (including cloud metadata endpoints such as 169.254.169.254), unspecified, 0.0.0.0/8, multicast and broadcast addresses are always refused;
    • private ranges (RFC 1918, 100.64.0.0/10, fc00::/7) are refused by default, but allowed when the SKIP_SECURE_CLIENT=true environment variable is set. The official Docker image sets it, because inside Kubernetes signed URLs routinely point at in-cluster services (ClusterIP) and the ranges cannot practically be enumerated;
    • deniedCIDRs are refused regardless of SKIP_SECURE_CLIENT.
  • Host: allowedHosts (allowlist) and deniedHosts (denylist), checked before the request and on every redirect hop.

SKIP_SECURE_CLIENT also lifts every IP check for operator-configured upstream URLs (OAuth token/discovery endpoints). It never affects CIMD client_id URLs, which keep the full private-range block.

FieldTypeDescription
allowLocalboolAllow connections to any IP (including loopback) and http:// (for testing with local stacks; default: false)
allowedHosts[]stringAllowlist of hosts (hostname, or host:port). Empty allows all hosts (the IP checks still apply)
deniedHosts[]stringDenylist of hosts (hostname, or host:port), applied after allowedHosts and on every redirect
deniedCIDRs[]stringCIDR blocks the downloader must never connect to, even with SKIP_SECURE_CLIENT=true (e.g. an internal admin network)
maxSizeint64Maximum bytes for downloaded/base64/text content. 0 or unset defaults to 524288000 (500 MiB)

Each field can also be overridden via environment variables (FILEFETCH_MAXSIZE, FILEFETCH_ALLOWLOCAL, FILEFETCH_ALLOWEDHOSTS, FILEFETCH_DENIEDHOSTS, FILEFETCH_DENIEDCIDRS).

yaml
fileFetch:
  allowLocal: false
  maxSize: 524288000 # 500MiB
  # allowedHosts:
  #   - example.com
  #   - files.example.com:8443
  # deniedHosts:
  #   - vault.internal
  # deniedCIDRs:
  #   - 10.100.0.0/16

telemetry

Output settings for traces, metrics, and logs via OpenTelemetry.

FieldTypeDescription
serviceNamestringService name
environmentstringEnvironment name (deployment.environment attribute)
gzipCompressionboolGzip compression for OTLP export
traceobjectTrace settings (enabled, http, grpc)
metricsobjectMetrics settings (enabled, exporterType: push / pull, http, grpc)
logsobjectLog settings (enabled, http, grpc)

For the http / grpc exporters, specify addr (host:port) or url, plus an optional headers map of extra request headers (e.g. for a SaaS OTLP endpoint that requires an Authorization header). grpc also accepts insecure. With metrics.exporterType: pull, Prometheus-format metrics are exposed at the /metrics endpoint instead of OTLP push.

headers can also be supplied as a single environment variable holding a JSON object, instead of a nested YAML map — useful when the value (e.g. a bearer token) is injected at deploy time rather than checked into config.yaml:

yaml
telemetry:
  trace:
    http:
      url: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT}
      headers: ${OTEL_EXPORTER_OTLP_HEADERS_JSON}
server.ts
export OTEL_EXPORTER_OTLP_HEADERS_JSON='{"Authorization":"Basic xxxxx"}'
yaml
telemetry:
  serviceName: manifold
  trace:
    enabled: true
    grpc:
      addr: localhost:4317
      insecure: true
  metrics:
    enabled: true
    exporterType: push
    grpc:
      addr: localhost:4317
      insecure: true
  logs:
    enabled: true
    grpc:
      addr: localhost:4317
      insecure: true
Metrics for autoscaling

On top of the Go runtime metrics and the otelhttp request metrics (http_server_request_duration_seconds_*), the gateway emits (names as seen on the Prometheus /metrics endpoint; OTLP uses the dotted names):

MetricTypeDescription
http_server_active_requestsUpDownCounterIn-flight HTTP requests (http.server.active_requests). Long-lived SSE / WebSocket connections are counted while open; /healthz, /readyz and /metrics are not
manifold_tool_calls_totalcountertools/call requests, labelled server, tool, status (ok / error)
manifold_tool_call_duration_secondshistogramtools/call duration, same labels

The gateway is I/O-bound: most of its time is spent waiting on backends, so CPU and memory stay low while concurrency climbs, and CPU/memory-based HPA scaling lags behind real load. Scale on request rate or in-flight requests instead. For example, with KEDA and its Prometheus trigger:

yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: manifold
spec:
  scaleTargetRef:
    name: manifold
  minReplicaCount: 2
  maxReplicaCount: 20
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        # average in-flight requests per pod
        query: sum(http_server_active_requests{job="manifold"})
        threshold: "50"
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring.svc:9090
        # request rate across all pods
        query: sum(rate(http_server_request_duration_seconds_count{job="manifold"}[2m]))
        threshold: "100"

KEDA treats threshold as a per-replica target, so the desired replica count is the query value divided by it. Adjust the label selector (job) to your scrape config, and see "Running on Kubernetes / multiple replicas" below for what multiple replicas require.

Running on Kubernetes / multiple replicas

Checklist for running more than one replica (or rolling updates without dropped requests):

  • Use Redis. The memory and sqlite stores are per-process, so sessions, OAuth state and tokens are not shared between pods. They are for a single pod only. The gateway logs a warning at startup when it runs on the in-memory store.
  • Set gateway.encryptKey and share it across all pods (e.g. from a Secret). If it is unset, each pod with the in-memory store generates its own random key, and tokens issued by one pod cannot be read by another. With redis it is required.
  • WebMCP reverse gateway (transport: reverse) is single-replica only. The browser extension's WebSocket and the /mcp calls routed to it must land on the same process; run it as its own single-replica Deployment.
  • Probes: use /healthz for liveness and /readyz for readiness. /readyz returns 503 as soon as shutdown starts.
  • Graceful shutdown: use a native lifecycle.preStop.sleep hook (the image is distroless, so an exec hook running sleep does not work). Kubernetes removes a terminating pod from the Service endpoints regardless of readiness, and sends SIGTERM only after preStop returns, so the sleep gives the endpoints a chance to stop routing to the pod. Then disable the gateway's own wait with gateway.shutdown.drainDelay: -1s; on SIGTERM the gateway waits up to gateway.shutdown.timeout (default 30s) for in-flight requests. Set terminationGracePeriodSeconds to at least the preStop sleep + timeout plus a margin (e.g. 60). Without a preStop hook, leave drainDelay at its default (5s): the gateway fails /readyz and keeps serving for that long before it stops accepting connections.
  • Timeouts: gateway.timeouts.readHeader (10s) and idle (120s) have defaults. read / write are unlimited by default because MCP Streamable HTTP / SSE and the edge WebSocket are long-lived; setting them also cuts those connections. gateway.maxRequestBodySize is off by default; if you set it, keep it above fileFetch.maxSize × 4/3 when callers pass base64 file content in tool arguments.
  • Memory: set GOMEMLIMIT to about 80-90% of the container memory limit so the Go GC works harder before the pod is OOM-killed.
  • Autoscaling: scale on in-flight requests or request rate, not CPU (see Metrics for autoscaling).
yaml
spec:
  terminationGracePeriodSeconds: 60
  containers:
    - name: manifold
      lifecycle:
        preStop:
          sleep: { seconds: 10 } # with gateway.shutdown.drainDelay: -1s
      env:
        - name: GOMEMLIMIT
          value: 900MiB # container limit: 1Gi
      readinessProbe:
        httpGet: { path: /readyz, port: 8081 }
      livenessProbe:
        httpGet: { path: /healthz, port: 8081 }
      resources:
        limits:
          memory: 1Gi

Downstream client registration

Manifold acts as an OAuth 2.1 authorization server for the MCP clients in front of it, and as an OAuth client towards the backend it proxies. A downstream client becomes known to Manifold in one of two ways:

  • Dynamic client registration (RFC 7591) — the client posts its metadata to /{server_name}/auth/clients and receives a generated client_id. Always available.
  • Client ID metadata document (CIMD) — the client presents an HTTPS URL as its client_id, and Manifold fetches the metadata document from that URL. Enabled with oauth.cimd.enabled.
mermaid
flowchart LR
  C[MCP client] -->|client_id| L[Authorization endpoint]
  L --> R{Resolve client}
  R -->|registered via DCR| OK[Client registration]
  R -->|HTTPS URL and CIMD enabled| D[Fetch metadata document]
  D --> OK
  R -->|otherwise| E[401 invalid_client]
  OK --> U{Resolve upstream client}
  U -->|mapped in clients| A[Redirect to upstream authorization endpoint]
  U -->|unmapped and unknownClient is default| A
  U -->|unmapped and unknownClient is reject| E

Client ID metadata documents

yaml
oauth:
  cimd:
    enabled: true
    allowedOrigins:
      - https://client-a.example.com
    cacheTTL: 1h
    maxDocumentSize: 65536

When enabled, /.well-known/oauth-authorization-server/mcp/{server_name} advertises client_id_metadata_document_supported: true, and a client_id that is not a registered DCR client is treated as a document URL. It is accepted only when all of the following hold:

  • https scheme, a host name that is neither an IP literal nor localhost, a path other than /, and no fragment or userinfo
  • its origin is in allowedOrigins (when that list is non-empty)
  • the response is 200 with Content-Type: application/json, no larger than maxDocumentSize, and reached without following a redirect
  • the document's client_id equals the requested client_id byte for byte (no normalization)
  • redirect_uris is non-empty and every entry either uses https, or uses http with a loopback host (localhost, 127.0.0.1 or [::1]; any port)
  • token_endpoint_auth_method is absent or none (CIMD clients are public clients)
  • grant_types, when present, includes authorization_code

A resolved client is cached for the shorter of cacheTTL and the response's Cache-Control: max-age; no-store / no-cache disables caching. Anything else is rejected as invalid_client, with the reason recorded in the log only. A CIMD client is not bound to a single MCP server, so it must reach the authorization endpoint that carries a server name (/{server_name}/auth/login) rather than the /authorize alias.

private_key_jwt and jwks_uri are not supported.

Mapping downstream clients to upstream clients

Without a mapping, every downstream client shares one upstream client, so the upstream consent screen always shows Manifold. If the user already has an upstream session for that client, another downstream client can obtain an authorization code without the user consenting to it (confused deputy). Manifold has no consent page of its own; instead, each downstream client_id can be mapped to its own upstream client, so the upstream authorization server renders the consent screen under that client's registered name and tracks consent per client.

yaml
mcpServers:
  my-api:
    spec: https://example.com/api/openapi.json
    baseURL: https://example.com
    oauth2:
      authURL: https://example.com/oauth/authorize
      tokenURL: https://example.com/oauth/token
      scopes: [read, write]

      clients:
        - downstreamClientID: "https://client-a.example.com/oauth-client.json"
          clientID: client-a
          clientSecret: ${CLIENT_A_SECRET}
        - downstreamClientID: "https://client-b.example.com/.well-known/oauth-client"
          clientID: client-b
          clientSecret: ${CLIENT_B_SECRET}

      unknownClient: reject

clients is a list rather than a map keyed by the downstream client_id, because the configuration loader lower-cases map keys and splits them on . — neither of which a CIMD URL or a DCR-issued client_id survives. Keeping the value in downstreamClientID preserves it byte for byte.

The mapping doubles as a whitelist: with unknownClient: reject (the default once clients is set), a downstream client without a mapping is refused with invalid_client, and the rejected client_id, client name, and server name are logged for auditing. Manifold never skips the upstream redirect, so consent is always decided upstream.

Independently of clients and unknownClient, a client registered through DCR may only use the MCP server it registered with, while a CIMD client stays usable across servers (see docs/design/dcr-client-server-binding.md).

unknownClient: default keeps the previous behavior for unmapped clients, falling back to the shared clientID / clientSecret:

yaml
      unknownClient: default
      clientID: manifold
      clientSecret: ${MANIFOLD_SECRET}
      authParams:
        prompt: consent

Note that default cannot fully prevent the confused deputy problem — unmapped clients still appear upstream as Manifold. Adding prompt: consent through authParams mitigates it, but prompt is an OpenID Connect parameter and plain OAuth 2.0 authorization servers may ignore it. For production, prefer reject with an explicit clients whitelist.

Automatic OAuth 2.1 discovery (used for MCP backends without an oauth2 block) registers Manifold itself through DCR and always uses that single shared client; clients does not apply to it.

Tool authorization (OPA sidecar)

Manifold can enforce which server/tool pairs a caller may use on tools/call and tools/list, delegating each decision to an external OPA sidecar. Disabled by default (authz.enabled: false, preserving prior behavior); authentication, group resolution, and policy storage stay out of Manifold's scope — it trusts identity headers injected by an upstream layer and queries OPA for the decision.

yaml
authz:
  enabled: true
  opaURL: http://localhost:8181
  timeout: 3s
  decisionPath:
    list: /v1/data/mcp/authz/allowed_tools
    call: /v1/data/mcp/authz/allow
    catalog: /v1/data/mcp/authz/allow_catalog
  headers:
    userID: x-user-id
    userGroups: x-user-groups
  input:
    user: user
    groups: groups
    server: server
    service: service
    tool: tool
    tools: tools
    toolName: name
    fromHeaders:
      tenant:
        header: x-tenant-id
        required: true
FieldTypeDefaultDescription
enabledboolfalseEnables the authz middleware. Every other field below is only read when true
opaURLstringhttp://localhost:8181Base URL of the OPA sidecar (http or https)
timeoutduration3sPer-decision HTTP timeout
decisionPath.liststring/v1/data/mcp/authz/allowed_toolsOPA data path queried once per tools/list
decisionPath.callstring/v1/data/mcp/authz/allowOPA data path queried once per tools/call
decisionPath.catalogstring/v1/data/mcp/authz/allow_catalogOPA data path queried once per GET /mcp/list?tools=true (see "Tool catalog for policy authoring" below)
headers.userIDstringx-user-idInbound header carrying the caller's user ID
headers.userGroupsstringx-user-groupsInbound header carrying the caller's groups, comma-separated
headers.bypassstringx-authz-bypassInbound header that, set to the exact string true, disables authz enforcement for that one request (see "Disabling authorization per tenant" below)
input.userstringuserJSON key for the caller's user ID in every decision input
input.groupsstringgroupsJSON key for the caller's groups in every decision input
input.serverstringserverJSON key for the server name in the tools/call input and in each tools/list array element
input.servicestringserviceJSON key for the service code (service.code, or the server name when unset) in the tools/call input and in each tools/list array element
input.toolstringtoolJSON key for the tool name in the tools/call input
input.toolsstringtoolsJSON key for the tool array in the tools/list input
input.toolNamestringnameJSON key for the tool name in each tools/list array element
input.fromHeadersmap[string]object{}Maps a decision-input field name to the inbound HTTP header it is read from. Empty by default, adding nothing. See "Multi-tenant policy data" below
input.fromHeaders.<field>.headerstring—Inbound header carrying the field's value. Required, and must be a valid HTTP header field name
input.fromHeaders.<field>.requiredbooltrueWhen true (the default, including when the key is omitted), a missing or empty header denies the request. When false, the field is left out of the decision input instead
input.fromHeaders.<field>.typestringstringHow the raw header value becomes a JSON value: string, list, or number. Empty means string; anything else is rejected at startup

Manifold treats the headers.userID value as an opaque string: it doesn't interpret it, just passes it through as-is to the key authz.input.user names in the decision input (default user). In a multi-tenant deployment, use a format that includes the tenant (e.g. {tenant}:{user}) so policies can tell tenants apart — or use input.fromHeaders instead (see "Multi-tenant policy data" below), in which case headers.userID doesn't need to carry the tenant. headers.userGroups values should likewise be immutable opaque IDs (e.g. ULIDs) rather than display names, since display names can change.

input lets a policy author match an existing decision-input contract instead of renaming their policy to Manifold's defaults. Keys that appear together in the same input object must be pairwise distinct: user / groups / server / service / tool (the tools/call input), user / groups / tools (the tools/list input), and server / service / toolName (each tools/list array element) — startup validation rejects a collision within any of those groups. Every key must also be non-empty. input.fromHeaders field names must likewise be non-empty and must not collide with any of the (possibly renamed) top-level keys above — user / groups / server / service / tool / tools. The comparison is case-sensitive, since OPA input keys are: with the defaults in place, a field named User is accepted because input.user is a different key. toolName is not reserved: it only names a key inside the tools array elements, never a top-level one. The same header may be assigned to more than one field.

Prerequisites

Manifold trusts headers.userID / headers.userGroups — and, if configured, headers.bypass and every header named in input.fromHeaders — on every request without verifying them itself, the same caveat as the WebMCP reverse gateway's forwardAuth mode (see its Trust boundary section in docs/design/webmcp-reverse-gateway.md). Before enabling authz.enabled:

  • The fronting proxy must strip or overwrite any client-supplied headers of the same names, so a caller cannot forge its own identity
  • Direct access to Manifold bypassing that proxy must be blocked at the network layer (e.g. a Kubernetes NetworkPolicy)
  • headers.bypass is more sensitive than the identity headers: a caller that can set it to true disables authorization entirely for its own requests, regardless of identity or group membership. The fronting proxy must strip or overwrite it with the same rigor, and every network path that can reach Manifold without going through that proxy must be closed at the network layer — not merely authenticated separately

Decision contract

Manifold POSTs {"input": ...} to opaURL + decisionPath.call for every tools/call, to opaURL + decisionPath.list once per tools/list (batched across every tool, not queried per tool), and to opaURL + decisionPath.catalog for every GET /mcp/list?tools=true. Prompts go through the same two tool decisions with the same input, because a prompt can carry instructions on how to use tools: prompts/get queries decisionPath.call with the prompt name as tool, and prompts/list queries decisionPath.list with each prompt as an element of tools. The prompt name is passed with a prompt: prefix (the prompt summarize_pet is decided as prompt:summarize_pet) so a policy can tell a prompt from a tool of the same name; : is outside the characters MCP recommends for tool names. Grant prompts with <server>/prompt:<name> rules, e.g. petstore/prompt:* for every prompt of petstore. The examples below use the default authz.input key names; every key is renameable (see the input table above):

jsonc
// tools/call
{"input": {"user": "user-042", "groups": ["team-finance"], "server": "billing-svc", "service": "billing", "tool": "create_invoice"}}
// → {"result": true}

// tools/list
{"input": {"user": "user-042", "groups": ["team-finance"], "tools": [{"server": "billing-svc", "service": "billing", "name": "create_invoice"}, ...]}}
// → {"result": [{"server": "billing-svc", "service": "billing", "name": "create_invoice"}, ...]}

// GET /mcp/list?tools=true
{"input": {"user": "user-042", "groups": ["team-finance"]}}
// → {"result": true}

service is the server's service.code (the server name when unset, so it equals server for a config without service). A policy that matches on service instead of server grants every server and agent of a service at once — see Grouping servers into a service. The tools/list result is matched back to the request by server and name only, so a policy may return the input entries as-is or just {server, name}.

Note: prompts with an existing policy Tool decisions are unchanged, but prompts now reach the same two decisions, so an existing policy decides them too:

  • A rule that grants a whole server (e.g. petstore/*, since * matches prompt:summarize_pet) or ignores the name (e.g. "everything for this group") grants that server's prompts as well. A list of tool names (e.g. petstore/getpetbyid, */getpetbyid) grants none, so prompts stay hidden until a <server>/prompt:<name> rule allows them.
  • MCP backends now advertise prompts, so clients call prompts/list / prompts/get and OPA receives more queries (none when a backend has no prompts).
  • Prompt names appear as prompt:<name> in tool / tools[].name, including in OPA's decision log.

Manifold does not prescribe a shape for OPA's data document; policies are free to structure it however they like — see examples/opa/ for a working policy.rego and data.json (data.policies[<group id>].tools as a list of <server>/<tool> glob patterns, data.policies[<group id>].catalog as a boolean).

Multi-tenant policy data

input.fromHeaders maps a decision-input field name to an inbound HTTP header, so a value the upstream identity layer already knows (a tenant ID, a region) reaches the policy without being encoded into headers.userID. Every configured field is resolved for every decision kind (tools/call, tools/list, and GET /mcp/list?tools=true) and added as a top-level field alongside user / groups / etc.:

yaml
authz:
  input:
    fromHeaders:
      tenant:
        header: x-tenant-id
        required: true
      roles:
        header: x-roles
        required: false
        type: list
      seat_count:
        header: x-seat-count
        type: number
jsonc
// tools/call
{"input": {"user": "user-042", "groups": ["team-finance"], "server": "billing-svc", "tool": "create_invoice", "tenant": "acme", "roles": ["admin", "auditor"], "seat_count": 42}}

type controls the JSON type the raw header value becomes:

typeDecision input valueNotes
string (default)The raw header value, unmodified
listAn array of stringsSplit on ,, each element trimmed, blank elements dropped — the same rule headers.userGroups uses
numberA JSON numberThe raw digits are sent through unrounded. A value that isn't a number denies the request, whether the field is required or not

required defaults to true — omitting the key keeps the fail-closed behavior of the identity headers. With required: false, a missing or empty header (or a list with no non-blank element) leaves the field out of the decision input entirely rather than sending an empty value, so a policy should guard it:

rego
# input.roles is absent on requests that carried no x-roles header, so read
# it through a default instead of indexing it directly.
roles := object.get(input, "roles", [])

That tenant field lets data be organized per tenant instead of flat, so one bundle can serve every tenant without a naming convention baked into user:

rego
package mcp.authz

default allow := false

allow if {
	tenant_policies := data.tenants[input.tenant].policies
	some group in input.groups
	some pattern in tenant_policies[group].tools
	glob.match(pattern, ["/"], sprintf("%s/%s", [input.server, input.tool]))
}

This replaces the {tenant}:{user} convention described above for headers.userID — with input.fromHeaders resolving the tenant explicitly, headers.userID only needs to identify the user within that tenant.

Distributing per-tenant data

Manifold only knows opaURL and decisionPath.*; how policy and data reach the sidecar is OPA's concern (see "Operating recommendations" below for serving them as a bundle over HTTP). Once data is keyed by tenant, you can choose how finely to split it:

mermaid
flowchart LR
    M[Manifold] -->|"POST /v1/data/mcp/authz/allow<br/>input.tenant = acme"| O[OPA sidecar]
    O -.->|poll| B[(bundle service)]
    B -.->|"mcp-authz/policy.tar.gz<br/>roots: mcp/authz"| O
    B -.->|"tenants/acme/bundle.tar.gz<br/>roots: tenants/acme"| O
    B -.->|"tenants/globex/bundle.tar.gz<br/>roots: tenants/globex"| O

One OPA can load several bundles, each owning a disjoint subtree of data, so a tenant's policy data can be published and rolled back independently of every other tenant's. The OPA side of that looks like:

yaml
services:
  bundles:
    url: https://bundles.example.com
bundles:
  policy:
    service: bundles
    resource: mcp-authz/policy.tar.gz
  tenant-acme:
    service: bundles
    resource: tenants/acme/bundle.tar.gz
  tenant-globex:
    service: bundles
    resource: tenants/globex/bundle.tar.gz

Each bundle's .manifest declares the subtree it owns; the Rego above keeps reading data.tenants[input.tenant] unchanged.

jsonc
// mcp-authz/policy.tar.gz
{"revision": "2026-08-29-01", "roots": ["mcp/authz"]}
// tenants/acme/bundle.tar.gz
{"revision": "2026-08-29-01", "roots": ["tenants/acme"]}

Three constraints follow from how OPA merges bundles:

  • Roots must not overlap. OPA refuses to activate a bundle whose root conflicts with another's (["tenants"] alongside ["tenants/acme"], for example), so splitting means splitting every tenant, and shared data cannot live in the same subtree as tenant-specific data
  • Splitting is not isolation. Every bundle still lands in the one data tree of the one OPA process, so a policy that reads data.tenants.globex can. The tenant boundary is enforced by the policy indexing through input.tenant; bundle boundaries only scope updates and blast radius
  • Adding a tenant is an OPA config change. bundles: is static, so each new tenant needs the sidecar reconfigured. OPA's discovery feature can distribute the bundle list itself, at the cost of another moving part, and every bundle polls independently, so very large tenant counts do not scale gracefully this way

The alternative is to not share the sidecar at all: run one Manifold + OPA pair per tenant. Then the sidecar is the tenant, data needs no tenant level, and there is nothing for input.fromHeaders to resolve.

Deploymenttenant via input.fromHeaders
One Manifold + OPA serving several tenantsRequired — the decision input is the only thing that tells tenants apart
One Manifold + OPA pair per tenantNot needed — the sidecar implicitly identifies the tenant

Tool catalog for policy authoring

Writing a policy requires knowing every <server>/<tool> pair that exists, but tools/list only ever shows what the caller is already allowed to see. GET /mcp/list?tools=true returns the unfiltered catalog instead: when authz.enabled is false it's open to anyone, and when true it queries decisionPath.catalog the same way tools/call queries decisionPath.call — identified by headers.userID / headers.userGroups, and denying (403 {"error": "forbidden"}) on a missing identity, a policy deny, or a Decider error, without ever falling back to a static allowlist.

JSON Config
{
  "mcp": [
    {
      "name": "petstore",
      "description": "Swagger Petstore sample API",
      // service.code / service.name, defaulting to the server name.
      "service": {"code": "pets", "name": "Pet Store"},
      "tools": [
        {"name": "getpetbyid", "summary": "Find pet by ID.", "description": "Returns a single pet."}
      ]
    },
    // A WebMCP reverse server's tools only exist per-browser-connection, so
    // it reports "dynamic" instead of a tool list.
    {"name": "billing-svc", "description": "browser app", "service": {"code": "billing-svc", "name": "billing-svc"}, "dynamic": true},
    // A backend that failed to connect still lists (with "error" instead of
    // "tools") rather than dropping out of the response.
    {"name": "crm", "description": "CRM MCP backend", "service": {"code": "crm", "name": "crm"}, "error": "connect: dial tcp: connection refused"}
  ]
}

Disabling authorization per tenant

A fronting proxy that multiplexes several tenants behind one Manifold deployment can disable authz for a single request without flipping authz.enabled globally: set headers.bypass (default x-authz-bypass) to the exact string true. Any other value — True, 1, empty, or the header missing — goes through the normal authz checks (fail-closed).

When bypassed, for that request:

  • tools/call skips OPA and reaches the tool directly
  • tools/list returns the backend's full tool list, unfiltered
  • GET /mcp/list?tools=true returns 200 with the full catalog without querying decisionPath.catalog

This is equivalent to authz.enabled: false for that one request. Manifold logs decision: bypass (with server / method, no identity — none was resolved) so bypassed requests are distinguishable from allow / deny in an audit trail.

Fail-closed behavior

Every ambiguous or failing case denies the request rather than allowing it:

  • A missing or empty headers.userID / headers.userGroups denies without querying OPA
  • A missing or empty header for a required field configured in input.fromHeaders denies the same way, without querying OPA. required defaults to true; a field with required: false is omitted from the input instead of denying
  • An input.fromHeaders value that doesn't parse as its configured type (e.g. type: number on a non-numeric header) denies without querying OPA, regardless of required
  • A non-200 response, a response missing the expected result field, a timeout, or a connection failure to OPA all deny
  • tools/list filtering is a convenience — it hides tools the caller cannot use so they don't clutter a client's tool picker — but it is not the enforcement point. Enforcement happens on tools/call; a client that already knows a tool's name (e.g. from a stale list) is still denied there
  • A reverse (WebMCP) mcpServers entry always registers a create_pairing_code tool (see docs/design/webmcp-reverse-gateway.md), and authz.enabled covers it like any other tool. A group that should be able to pair with such a server needs <server>/create_pairing_code in its policy, or pairing itself is denied
  • This also holds one level down, inside OPA itself: if a bundle fetch fails, OPA keeps enforcing with the last bundle it activated — a bundle server outage stops policy updates, not decisions. But if OPA has never activated a bundle since startup (the bundle server was unreachable at boot, for example), data stays empty and every decision comes back false / [], which fail-closes the same way. Bundle fetch failures are still worth alerting on — see "Operating recommendations" below

Operating recommendations

  • Enable OPA's decision log for an audit trail of every allow / allowed_tools / allow_catalog query. Each event should carry the decision, the same fields Manifold sent in that decision's input, and the revision of the policy data that produced it — without a data revision there's no way to tell which policy version a given decision was made under. The input fields differ per decision kind (see "Decision contract" above); the names below are the authz.input defaults, each of which is renameable:

    DecisionQueryInput fields
    allowtools/calluser, groups, server, service, tool
    allowed_toolstools/listuser, groups, and a tools array of {server, service, name} entries
    allow_catalogGET /mcp/list?tools=trueuser, groups

    Every input.fromHeaders field that resolved is present in all three, at the top level. A field with required: false is absent from the input on requests whose header was missing or empty, so a decision log missing it is expected rather than a dropped field.

  • Distribute policy and data as an OPA bundle served over HTTP rather than mounting local files, so policy updates don't require restarting the sidecar. Bundle mode also stamps every decision log event with bundles.<name>.revision, which is where that revision comes from

  • Monitor OPA's bundle fetch status (see "Fail-closed behavior" above for what a failure does to enforcement): OPA's Health API (GET /health?bundles=true) reports unhealthy until every configured bundle has been activated at least once, so it doubles as a readiness probe. The status API and decision log also surface fetch failures

See examples/opa/ for a runnable OPA sidecar with sample policy and data.

HTTP endpoints

The HTTP endpoints exposed by Manifold.

MCP

MethodPathDescription
POST/mcp/{server_name}MCP requests (Streamable HTTP). {server_name} is an mcpServers or agents entry
GET/mcp/listList registered servers (names, descriptions and services). Add ?tools=true for the tool catalog (see "Tool catalog for policy authoring" above)

OAuth 2.1

MethodPathDescription
GET/.well-known/oauth-authorization-server/mcp/{server_name}Authorization Server metadata
GET/.well-known/oauth-protected-resource/mcp/{server_name}Protected Resource metadata
GET/{server_name}/auth/loginRedirect to the login page
GET/{server_name}/auth/callbackOAuth callback
POST/{server_name}/auth/tokenToken issuance
POST/{server_name}/auth/clientsDynamic client registration (RFC 7591)
GET/authorize, /callbackAliases without a server name
POST/token, /registerAliases without a server name

Other

MethodPathDescription
GET/media/download/{id}Download stored content (only when storage.hostURL is set)
GET/healthzLiveness probe. Always 200
GET/readyzReadiness probe. 200, or 503 once graceful shutdown has started
GET/metricsPrometheus metrics (only when telemetry.metrics.exporterType: pull)

Development

See CONTRIBUTING.md for how to set up a development environment and submit changes.

Test

The ConfigMap spec-loading tests use envtest, which needs a kube-apiserver/etcd binary set fetched via setup-envtest. Run mise install once (setup-envtest is declared in mise.toml). make test downloads the binaries before running the tests, and the tests find them in setup-envtest's default location without KUBEBUILDER_ASSETS. If you run go test directly, fetch them once first with setup-envtest use 1.36.2; the tests fail if they are missing.

bash
make test

End-to-end (Postman CLI)

make postman builds the gateway, starts OPA and a stub Petstore API, and runs the Postman collection in tests/postman/ that checks tool filtering, tool authorization and the audit log together. The Postman CLI comes from mise install (postman-cli in mise.toml). CI runs it on every pull request.

Lint

bash
make lint

Inspiration

This project is inspired by the Agent / MCP Gateway of LiteLLM.

Just as LiteLLM's MCP Gateway provides a unified access point to multiple MCP servers, Manifold aims to be a gateway that connects a single MCP interface to many MCP servers / REST APIs.

License

MIT License