Back to Browse

Manifold MCP Server

Developer ToolsLow Risk10.0MCP RegistryLocal
Free

Server data from the Official MCP Registry

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

About

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

Security Report

10.0
Low Risk10.0Low Risk

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

7 files analyzed · No issues found

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

Permissions Required

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

file_system

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

HTTP Network Access

Connects to external APIs or services over the internet.

What You'll Need

Set these up before or after installing:

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

Environment variable: ENCRYPT_KEY

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

Environment variable: SERVER_NAME

Documentation

View on GitHub

From the project's GitHub README.

Manifold

One interface. Many connections. Manifold.

CI Release License: MIT

English | 日本語

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

Why "Manifold"?

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

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

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

Architecture

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

Features

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

Requirements

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

Installation

Download binary

Download the latest binary from Releases.

Build from source

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

Docker

docker pull ghcr.io/nonchan7720/manifold:latest

Usage

Start the gateway

# Run the binary
manifold gateway

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

# Run from source
go run main.go gateway

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

Docker Compose (development)

Starts a development environment including Redis.

docker compose up -d

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

Inspect and generate MCP tools

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

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

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

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

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

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

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

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

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

Recommended workflow:

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

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

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

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

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

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

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

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

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

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

Breaking-change detection (openapi diff)

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

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

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

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

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

text output:

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

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

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

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

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

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

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

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

Configuration

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

Splitting the config across files (include)

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

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

Connecting to an MCP backend

Expose an external MCP server through Manifold.

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

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

sqlite:
  path: ./tmp/manifold.db

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

MCP Apps (apps)

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

Whether a caller supports MCP Apps decides what it gets:

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

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

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

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

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

Connecting to an A2A agent

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

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

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

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

Grouping servers into a service (service)

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

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

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

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

Connecting to an OpenAPI / Swagger backend

Automatically generate MCP tools from an OpenAPI specification.

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}

Prompts for an OpenAPI backend (prompts)

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

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

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

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

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

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

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

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

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

Audit log (audit)

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

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

{"time":"2026-10-03T05:00:00Z","level":"INFO","msg":"audit","event":"tool_call","server":"petstore","service":"petstore","tool":"get_pet","outcome":"success","duration_ms":42,"user":"alice","groups":"dev","token":"9f86d081884c"}

Documentation truncated — see the full README on GitHub.

Reviews

No reviews yet

Be the first to review this server!