manifold

command module
v1.14.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 4, 2026 License: MIT Imports: 4 Imported by: 0

README

Manifold

One interface. Many connections. Manifold.

CI Release

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 manifold This project
Single inlet Requests from MCP clients
Distribution / routing Protocol conversion / routing
To multiple cylinders To 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
  • 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 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
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
Field Type Description
port int Listening port (default: 8081)
key string TLS private key file path (optional)
cert string TLS certificate file path (optional)
encryptKey string Token encryption key (required). Base64-encoded 32-byte AES-256 key. Generate with openssl rand -base64 32
specRefresh.interval duration Interval 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.

Field Type Description
description string Server description (required; included in /mcp/list responses)
transport string Transport for MCP backends (http or stdio)
url string Endpoint for the HTTP transport
command string Command for the stdio transport
args []string Arguments for the stdio command
env map[string]string Environment variables for the stdio process
spec string Path or URL of an OpenAPI/Swagger specification
baseURL string API base URL in OpenAPI mode (required when spec is set)
headers map[string]string Extra headers added to API requests
authValue object Static authentication settings (header, prefix, value)
oauth2 object OAuth 2.0 settings (see below)
tokenExchange object Token Exchange settings (see below)
specRefreshInterval duration Per-server override of gateway.specRefresh.interval. 0 disables refreshing for this server
tools.file string Path 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   # still required — recorded as source.spec
    baseURL: https://petstore3.swagger.io/api/v3
    tools:
      file: ./generated/petstore.yaml
  • spec and baseURL are still required even when tools.file is set: spec is recorded in the file as its source, and baseURL is not derived from the generated spec.
  • 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
Field Type Description
clientID string Client ID (required)
clientSecret string Client secret (required)
authURL string Authorization endpoint (required; absolute URL)
tokenURL string Token endpoint (required; absolute URL)
scopes []string Scopes to request
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.

Field Type Description
url string Absolute URL of the token exchange endpoint (required)
redis
Field Type Description
url string Redis URL (e.g. redis://user:pass@localhost:6379/0)
addrs []string List of host:port pairs (for Cluster/Sentinel)
user string Username
password string Password
db int Database number
master_name string Sentinel master name
tls bool Enable TLS
cluster_mode bool Enable Cluster mode
sqlite
Field Type Description
path string Database 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.

Field Type Description
type string Storage type. Currently only s3 is supported
hostURL string Host for download URLs (when set, content is served via Manifold's /media/download/{id})
s3.bucket string S3 bucket name (required when type: s3)
s3.keyPrefix string S3 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.

Field Type Description
allowLocal bool Allow connections to private/loopback IPs and http:// (for testing with local stacks; default: false)
allowedHosts []string Allowlist of hosts (hostname, or host:port). Empty allows all hosts (private IP blocking still applies)
maxSize int64 Maximum 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.

Field Type Description
serviceName string Service name
environment string Environment name (deployment.environment attribute)
gzipCompression bool Gzip compression for OTLP export
trace object Trace settings (enabled, http, grpc)
metrics object Metrics settings (enabled, exporterType: push / pull, http, grpc)
logs object Log 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

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
Field Type Default Description
enabled bool false Enables the authz middleware. Every other field below is only read when true
opaURL string http://localhost:8181 Base URL of the OPA sidecar (http or https)
timeout duration 3s Per-decision HTTP timeout
decisionPath.list string /v1/data/mcp/authz/allowed_tools OPA data path queried once per tools/list
decisionPath.call string /v1/data/mcp/authz/allow OPA data path queried once per tools/call
decisionPath.catalog string /v1/data/mcp/authz/allow_catalog OPA data path queried once per GET /mcp/list?tools=true (see "Tool catalog for policy authoring" below)
headers.userID string x-user-id Inbound header carrying the caller's user ID
headers.userGroups string x-user-groups Inbound header carrying the caller's groups, comma-separated
headers.bypass string x-authz-bypass Inbound header that, set to the exact string true, disables authz enforcement for that one request (see "Disabling authorization per tenant" below)
input.user string user JSON key for the caller's user ID in every decision input
input.groups string groups JSON key for the caller's groups in every decision input
input.server string server JSON key for the server name in the tools/call input and in each tools/list array element
input.tool string tool JSON key for the tool name in the tools/call input
input.tools string tools JSON key for the tool array in the tools/list input
input.toolName string name JSON key for the tool name in each tools/list array element
input.fromHeaders map[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>.header string Inbound header carrying the field's value. Required, and must be a valid HTTP header field name
input.fromHeaders.<field>.required bool true When 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>.type string string How 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}

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

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

type Decision input value Notes
string (default) The raw header value, unmodified
list An array of strings Split on ,, each element trimmed, blank elements dropped — the same rule headers.userGroups uses
number A JSON number The 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:

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

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:

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:

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.

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

Deployment tenant via input.fromHeaders
One Manifold + OPA serving several tenants Required — the decision input is the only thing that tells tenants apart
One Manifold + OPA pair per tenant Not 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.

{
  "mcp": [
    {
      "name": "petstore",
      "description": "Swagger Petstore sample API",
      "tools": [
        {"name": "getpetbyid", "description": "Find pet by ID."}
      ]
    },
    // 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", "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", "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:

    Decision Query Input fields
    allow tools/call user, groups, server, tool
    allowed_tools tools/list user, groups, and a tools array of {server, name} entries
    allow_catalog GET /mcp/list?tools=true user, 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
Method Path Description
POST /mcp/{server_name} MCP requests (Streamable HTTP)
GET /mcp/list List registered servers (names and descriptions). Add ?tools=true for the tool catalog (see "Tool catalog for policy authoring" above)
OAuth 2.1
Method Path Description
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/login Redirect to the login page
GET /{server_name}/auth/callback OAuth callback
POST /{server_name}/auth/token Token issuance
POST /{server_name}/auth/clients Dynamic client registration (RFC 7591)
GET /authorize, /callback Aliases without a server name
POST /token, /register Aliases without a server name
Other
Method Path Description
GET /media/download/{id} Download stored content (only when storage.hostURL is set)
GET /metrics Prometheus metrics (only when telemetry.metrics.exporterType: pull)

Development

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

Test
make test
Lint
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

Documentation

The Go Gopher

There is no documentation for this package.

Directories

Path Synopsis
pkg
cmd
domain/edge
Package edge holds the domain model for the WebMCP reverse-connection gateway: the state that tracks which browser tab (per user, per origin) is currently reachable through an edge WebSocket connection.
Package edge holds the domain model for the WebMCP reverse-connection gateway: the state that tracks which browser tab (per user, per origin) is currently reachable through an edge WebSocket connection.
services/authz
Package authz decides whether a Principal may call a given MCP tool, delegating the decision to an OPA sidecar (see docs/design/opa-tool-authorization-plan.ja.md).
Package authz decides whether a Principal may call a given MCP tool, delegating the decision to an OPA sidecar (see docs/design/opa-tool-authorization-plan.ja.md).
services/edge
Package edge implements the WebMCP reverse-connection gateway services: the in-memory binding registry and the pairing/edge-token service.
Package edge implements the WebMCP reverse-connection gateway services: the in-memory binding registry and the pairing/edge-token service.
services/identity
Package identity resolves a domainedge.IdentityKey from an AI agent's inbound HTTP request, per the identities profile referenced by a reverse Server (see the "ユーザー識別(identity プロファイル)" section of docs/design/webmcp-reverse-gateway.ja.md, implemented in Phase 2a).
Package identity resolves a domainedge.IdentityKey from an AI agent's inbound HTTP request, per the identities profile referenced by a reverse Server (see the "ユーザー識別(identity プロファイル)" section of docs/design/webmcp-reverse-gateway.ja.md, implemented in Phase 2a).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL