# Manifold

**Category:** 💻 Developer Tools  
**Repository:** https://github.com/nonchan7720/manifold  
**Views:** 0  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/manifold

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

## Claude Desktop Quick Installation
Heuristic fallback — verify the package name and runner against the repository README before running it. Uses `npx` (confidence: low):

```json
"mcpServers": {
  "manifold": {
    "command": "npx",
    "args": ["-y","manifold"]
  }
}
```

## Documentation & README

# Manifold

**One interface. Many connections. Manifold.**

[![CI](https://github.com/nonchan7720/manifold/actions/workflows/ci.yaml/badge.svg)](https://github.com/nonchan7720/manifold/actions/workflows/ci.yaml)
[![Release](https://img.shields.io/github/v/release/nonchan7720/manifold)](https://github.com/nonchan7720/manifold/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

English | [日本語](https://github.com/nonchan7720/manifold/blob/HEAD/README_ja.md)

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

```text
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](https://github.com/nonchan7720/manifold/releases).

### Build from source

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

### Docker

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

```bash
docker compose up -d
```

Ready-to-run configuration examples are available in the [`examples/`](https://github.com/nonchan7720/manifold/blob/HEAD/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
```

`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 `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`](#storage)). 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
```

Recommended workflow:

1. Add `tools.file` to the server's config (see [`mcpServers.<name>.tools`](#mcpserversnametools)) 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
```

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

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

### 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}
```

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

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

#### `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. 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 |
| `baseURL`       | string            | API base URL, required in OpenAPI mode (i.e. when `spec` or `tools.file` 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`](#mcpserversnametools)). 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](#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.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 of the shared upstream client (**required** whenever the effective `unknownClient` is `default`, see below) |
| `clientSecret`  | string            | Client secret of the shared upstream client (same requirement as `clientID`)                    |
| `authURL`       | string            | Authorization endpoint (**required**; absolute URL)                                             |
| `tokenURL`      | string            | Token endpoint (**required**; absolute URL)                                                     |
| `scopes`        | []string          | Scopes to request                                                                               |
| `clients`       | []object          | Maps a downstream `client_id` to the upstream client used for it (see [Downstream client registration](#downstream-client-registration)) |
| `unknownClient` | string            | How to treat a downstream client absent from `clients`: `reject` or `default`                   |
| `authParams`    | map[string]string | Extra 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.

| `unknownClient` | `clients` | Shared `clientID` / `clientSecret` |
| --------------- | --------- | ---------------------------------- |
| `default` (explicit) | any       | required                      |
| `reject` (explicit)  | any       | not required                  |
| omitted         | non-empty | not required (effective `reject`)  |
| omitted         | empty     | required (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.

| Field | Type   | Description                                                |
| ----- | ------ | ---------------------------------------------------------- |
| `url` | string | Absolute 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](#downstream-client-registration)). Disabled by default.

| Field             | Type     | Description                                                                                          |
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `enabled`         | bool     | Enable CIMD client registration (default: `false`)                                                   |
| `allowedOrigins`  | []string | When non-empty, only `client_id` URLs on these origins are accepted. Applied before the document is fetched |
| `cacheTTL`        | duration | Upper bound on how long a resolved client is cached (default: `1h`). A shorter `Cache-Control: max-age` wins |
| `maxDocumentSize` | int      | Maximum number of bytes read from the document (default: `65536`)                                    |

#### `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`)                                            |

```yaml
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`).

```yaml
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`:

```yaml
telemetry:
  trace:
    http:
      url: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT}
      headers: ${OTEL_EXPORTER_OTLP_HEADERS_JSON}
```

```sh
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
```

## 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](https://www.openpolicyagent.org/) 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
    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](https://github.com/ulid/spec)) 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):

```jsonc
// 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/`](https://github.com/nonchan7720/manifold/blob/HEAD/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:

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

```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](https://www.openpolicyagent.org/docs/management-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.

```jsonc
{
  "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](https://www.openpolicyagent.org/docs/management-decision-logs) 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](https://www.openpolicyagent.org/docs/management-bundles) 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/`](https://github.com/nonchan7720/manifold/blob/HEAD/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](https://github.com/nonchan7720/manifold/blob/HEAD/CONTRIBUTING.md) for how to set up a development environment and submit changes.

### Test

```bash
make test
```

### Lint

```bash
make lint
```

## Inspiration

This project is inspired by the **Agent / MCP Gateway** of [LiteLLM](https://github.com/BerriAI/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

