Back to Browse

Manifold MCP Server

Developer ToolsLow Risk10.0MCP RegistryLocal
Free

Server data from the Official MCP Registry

MCP gateway aggregating MCP servers and OpenAPI/Swagger REST APIs behind one MCP endpoint

About

MCP gateway aggregating MCP servers and OpenAPI/Swagger REST APIs behind one MCP endpoint

Security Report

10.0
Low Risk10.0Low Risk

Valid MCP server (2 strong, 1 medium validity signals). No known CVEs in dependencies. Imported from the Official MCP Registry.

7 files analyzed · No issues found

Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.

Permissions Required

This plugin requests these system permissions. Most are normal for its category.

file_system

Check that this permission is expected for this type of plugin.

Shell Command Execution

Runs commands on your machine. Be cautious — only use if you trust this plugin.

What You'll Need

Set these up before or after installing:

Base64-encoded 32-byte AES-256 key used to encrypt stored tokens (generate with: openssl rand -base64 32)Required

Environment variable: ENCRYPT_KEY

Name of a backend server defined under mcpServers in config.yaml; forms the MCP endpoint path /mcp/{SERVER_NAME} (not read by the container itself)Optional

Environment variable: SERVER_NAME

Documentation

View on GitHub

From the project's GitHub README.

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

MCP Client
    │
    ▼
┌─────────────┐
│   Manifold  │   ← this server
└─────────────┘
    │       │
    ▼       ▼
External  OpenAPI / Swagger
MCP       REST API Server
Server

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)
  • MCP backend aggregation: Transparent reverse proxy to external MCP servers
  • 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)
  • Resource links: Stores binary content from tool 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 backed by Redis or SQLite
  • OpenTelemetry support: OTLP export of traces, metrics, and logs (metrics also support Prometheus-style pull)

Requirements

  • Go 1.26+
  • Redis or SQLite (for session management)

Installation

Download binary

Download the latest binary from Releases.

Build from source

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

Docker

docker pull ghcr.io/nonchan7720/manifold:latest

Usage

Start the gateway

# 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.

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.

# 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

openapi tools output:

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:

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 multipart/form-data or application/x-www-form-urlencoded property with format: binary 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). From a spec with one upload and one download operation:

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

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:

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

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}.

Connecting to an MCP backend

Expose an external MCP server through Manifold.

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

Connecting to an OpenAPI / Swagger backend

Automatically generate MCP tools from an OpenAPI specification.

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

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}

Configuration reference

gateway
FieldTypeDescription
portintListening port (default: 8081)
keystringTLS private key file path (optional)
certstringTLS certificate file path (optional)
encryptKeystringToken encryption key (required). Base64-encoded 32-byte AES-256 key. Generate with openssl rand -base64 32
specRefresh.intervaldurationInterval for re-fetching OpenAPI mode specs (e.g. 5m). Unset or 0 disables refreshing
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.

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.

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)
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 or URL of 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, required in OpenAPI mode (i.e. when spec or tools.file is set)
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
tools.filestringPath to a generated tools file (see mcpServers.<name>.tools). When set, the gateway starts from this file instead of fetching spec

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

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.

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.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:

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)
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
sqlite
FieldTypeDescription
pathstringDatabase file path (:memory: for in-memory)

Either redis or sqlite must be configured.

storage

Stores content included in OpenAPI/Swagger tool responses (images, binaries, etc.) in external storage and returns resource links (download URLs). 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)
storage:
  type: s3
  hostURL: https://manifold.example.com
  s3:
    bucket: my-bucket
    keyPrefix: manifold/media
fileFetch

When a URL is passed to a file input field of an OpenAPI/Swagger tool, Manifold downloads the file from that URL. As an SSRF countermeasure, connections to private/loopback/link-local IPs and the http:// scheme are rejected by default.

FieldTypeDescription
allowLocalboolAllow connections to private/loopback IPs and http:// (for testing with local stacks; default: false)
allowedHosts[]stringAllowlist of hosts (hostname, or host:port). Empty allows all hosts (private IP blocking still applies)
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:
  allowLocal: false
  maxSize: 524288000 # 500MiB
  # allowedHosts:
  #   - example.com
  #   - files.example.com:8443
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:

telemetry:
  trace:
    http:
      url: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT}
      headers: ${OTEL_EXPORTER_OTLP_HEADERS_JSON}
export OTEL_EXPORTER_OTLP_HEADERS_JSON='{"Authorization":"Basic xxxxx"}'
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

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.
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

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.

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:

      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.

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
    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.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>.headerstringInbound 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 / tool (the tools/call input), user / groups / tools (the tools/list input), and server / 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 / 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. The examples below use the default authz.input key names; every key is renameable (see the input table above):

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

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

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

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!