The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Manifold listing page.
One interface. Many connections. Manifold.
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.
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 |
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)authValue) / OAuth 2.0 (oauth2) / API key Token Exchange (tokenExchange)Download the latest binary from Releases.
Starts a development environment including Redis.
Ready-to-run configuration examples are available in the examples/ directory.
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.
openapi tools output:
The generated file (tools.file) is YAML, with a diffable tools section followed by the resolved spec:
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:
Recommended workflow:
tools.file to the server's config (see mcpServers.<name>.tools) and run manifold openapi generate -c config.tools section makes upstream spec changes reviewable as a normal PR diff.manifold gateway -c config) — it reads the tools from the file, with no network access to spec at startup.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.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:
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}.
Expose an external MCP server through Manifold.
Automatically generate MCP tools from an OpenAPI specification.
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.specRefreshPeriodically 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.
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). 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>.toolstools.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.
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.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.tools.file cannot be used with a Swagger 2.x spec.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) |
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:
Note Parameter names in
authParamswritten 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>.tokenExchangeExchanges 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.cimdAccepts downstream clients that present an HTTPS client_id resolving to a client ID metadata document, instead of registering through DCR (see Downstream client registration). Disabled by default.
| 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.
storageStores 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) |
fileFetchWhen 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).
telemetryOutput 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:
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:
/{server_name}/auth/clients and receives a generated client_id. Always available.client_id, and Manifold fetches the metadata document from that URL. Enabled with oauth.cimd.enabled.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 userinfoallowedOrigins (when that list is non-empty)200 with Content-Type: application/json, no larger than maxDocumentSize, and reached without following a redirectclient_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_codeA 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.
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.
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:
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.
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.
| 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.
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:
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 separatelyManifold 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):
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).
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.:
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:
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:
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.
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:
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:
Each bundle's .manifest declares the subtree it owns; the Rego above keeps reading data.tenants[input.tenant] unchanged.
Three constraints follow from how OPA merges bundles:
["tenants"] alongside ["tenants/acme"], for example), so splitting means splitting every tenant, and shared data cannot live in the same subtree as tenant-specific datadata 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 radiusbundles: 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 wayThe 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 |
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.
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 directlytools/list returns the backend's full tool list, unfilteredGET /mcp/list?tools=true returns 200 with the full catalog without querying decisionPath.catalogThis 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.
Every ambiguous or failing case denies the request rather than allowing it:
headers.userID / headers.userGroups denies without querying OPAinput.fromHeaders denies the same way, without querying OPA. required defaults to true; a field with required: false is omitted from the input instead of denyinginput.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 requiredresult field, a timeout, or a connection failure to OPA all denytools/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 theremcpServers 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 denieddata 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" belowEnable 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.
The HTTP endpoints exposed by Manifold.
| 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) |
| 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 |
| 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) |
See CONTRIBUTING.md for how to set up a development environment and submit changes.
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.
MIT License