Of the 76 open code scanning alerts, 39 point at code that can change. svc-ai-mcp-adapter: - A path argument that is empty or made only of dots is refused. Percent-encoding keeps a separator inside its segment, but "." and ".." need none, so an upstream that resolves dot segments could answer from a path the contract does not name. - parse_duration splits on the units. The pattern it used before rescanned a long run of digits from every start position. Tests and the README generators: - Every Jinja environment built with autoescape=False and a "noqa: S701" uses select_autoescape now, as six test modules and utils/templating/jinja.py already did. It escapes templates named *.html, *.htm and *.xml, which none of these call sites loads. - Dead code goes: a doubled "rel" block in the email lint, an unread _SKIP_DIRS, and a module that _resolve returned to no caller. - Two "except ...: pass" blocks become contextlib.suppress and assertRaises. - Nine test modules import unittest.mock one way instead of two, and one lookup test imports its module once. Closes #807 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6.7 KiB
MCP Adapter
Description
A reusable Model Context Protocol adapter, instantiated once per provider application. Each instance fronts exactly one upstream, holds only that upstream's least-privileged credential, and exposes only the operations its checked-in contract names.
Overview
Several applications have a useful read API but no MCP server, and several projects ship an MCP server that authenticates to the application without authenticating the client calling it. Putting either behind this adapter gives the surface a bearer of its own, an exact tool allowlist, and enforced ceilings. The deployment renders one contract per instance, so the same image serves every provider without any of them sharing a credential or a trust boundary.
Cosmos
The diagram places MCP Adapter in the Infinito.Nexus cosmos: the components it deploys (capabilities), the central services it consumes (dependencies), and its outward reach (federation and bridged external networks).
flowchart LR
subgraph role [svc-ai-mcp-adapter]
svc_mcp_adapter["mcp-adapter"]
end
Solid 1:1 edges are fixed relationships; dashed 0..1 edges are conditional (enabled only in matching deployments); red 0..0 edges are turned off in this role. Node markers show the role's deploy modes (💻 host, 🐳 compose, 🐝 swarm); ❌ marks a service that is explicitly turned off, and ⚙️ an Ansible role dependency declared in meta/main.yml.
Features
- Client-facing authentication: The adapter issues its own bearer. The upstream credential authenticates the adapter to the provider and says nothing about who called the adapter, which is the gap the project-owned MCP sidecars leave open.
- Allowlist on
tools/call: Filtering onlytools/listwould leave every unlisted operation callable by name. - Read-only by default: A non-
GET/HEADoperation is refused unlessmutating_tools_enabledis explicitly true. - Path arguments stay in their segment: A value that fills a
{placeholder}of the contracted path is percent-encoded, and an empty value or one made only of dots is refused, so a call cannot resolve to a path the contract does not name. - Enforced ceilings: Request size, response size, timeout, concurrency, page size and result count come from the contract and are applied, not merely declared.
- Bounded ranges: A tool whose schema declares
start,endandstephas its point count checked againstresult_itemsand is refused with the smallest step that fits, so the upstream never scans a range whose result the adapter would discard. - Fail-closed on drift: A tool contract whose hash no longer matches its pinned
schema_sha256refuses to start. - Redacted audit trail: Every call emits provider, consumer, tool, credential subject, status, duration and correlation id, and never the arguments, the response body or the credential.
Contract
The deployment renders one ADAPTER_CONTRACT JSON document per instance:
{
"provider": "web-app-checkmk",
"upstream_url": "http://checkmk:5000",
"auth_subject": "service_account",
"mutating_tools_enabled": false,
"tools": {
"checkmk_list_hosts": {"method": "GET", "path": "/domain-types/host_config/collections/all"}
},
"limits": {
"request_bytes": 65536,
"response_bytes": 1048576,
"timeout_seconds": 15,
"concurrent_requests": 4,
"page_size": 100,
"result_items": 500,
"stream_seconds": 300
},
"schema_sha256": "sha256:…"
}
files/python/policy.py validates it at startup. A contract missing a limit, carrying an empty allowlist, naming a wildcard path or lacking a pinned schema hash is refused before the process serves anything.
Endpoints
| Path | Method | Purpose |
|---|---|---|
/mcp |
POST | JSON-RPC: initialize, tools/list, tools/call |
/health |
GET | liveness, names the provider it fronts |
A tools-only server never has to stream, so every response is a plain JSON body and no SSE session state exists to get wrong.
Upstream authentication
The adapter presents ADAPTER_UPSTREAM_KEY to the provider in the header named by ADAPTER_UPSTREAM_AUTH_HEADER (default Authorization), formatted by ADAPTER_UPSTREAM_AUTH_FORMAT (default Bearer {key}, where {key} is the credential).
| Provider style | Header | Format |
|---|---|---|
| RFC 6750 bearer | Authorization |
Bearer {key} |
| Pretix | Authorization |
Token {key} |
| Zammad | Authorization |
Token token={key} |
| Jellyfin | X-Emby-Token |
{key} |
An empty ADAPTER_UPSTREAM_KEY sends no header at all.
Image
Self-built and stdlib-only: the served surface is three JSON-RPC methods, and a dependency-free image has no transitive supply chain to audit per release. The container runs as a non-root user with a read-only root filesystem and all Linux capabilities dropped.
Quick Setup
Development
Clone, set up the workstation, and deploy MCP Adapter onto the local stack:
git clone https://github.com/infinito-nexus/core.git
cd core
make onboard
make compose-deploy mode=reinstall apps=svc-ai-mcp-adapter full_cycle=false
Production
Run the published image to provision the inventory and deploy MCP Adapter to a managed server (the mounted volume persists the inventory):
APP=svc-ai-mcp-adapter
HOST=<your-server>
DOMAIN=<your-domain>
TLS_MODE=self_signed
SSH_PUBLIC_KEY="<your-ssh-public-key>"
docker run --rm -it \
-v "$PWD/inventories:/etc/infinito.nexus/inventories" \
-e APP="$APP" -e HOST="$HOST" -e DOMAIN="$DOMAIN" -e TLS_MODE="$TLS_MODE" -e SSH_PUBLIC_KEY="$SSH_PUBLIC_KEY" \
ghcr.io/infinito-nexus/core/debian bash -c '
INVENTORY=/etc/infinito.nexus/inventories/production
infinito administration inventory provision "$INVENTORY" \
--inventory-file "$INVENTORY/devices.yml" \
--host "$HOST" \
--include "$APP" \
--vars "{\"TLS_MODE\": \"$TLS_MODE\", \"DOMAIN_PRIMARY\": \"$DOMAIN\", \"users\": {\"administrator\": {\"authorized_keys\": [\"$SSH_PUBLIC_KEY\"]}}}" &&
infinito administration deploy dedicated "$INVENTORY/devices.yml" \
--password-file "$INVENTORY/.password" \
--diff -vv'
Further resources
Credits
Implemented by Kevin Veen-Birkenbach. Part of the Infinito.Nexus Project and maintained by Kevin Veen-Birkenbach. Licensed under the Infinito.Nexus Community License (Non-Commercial).