Skip to content

Commit 82565fd

Browse files
jfallowsclaude
andauthored
feat(examples): mcp.proxy demonstrates url-mode elicitation aggregation (#1852)
Add the mcp.proxy example: Zilla aggregates an upstream "everything" MCP server with a "urlelicit" toolkit and relays SEP-1036 url-mode elicitation end to end. Includes the compose stack, zilla.yaml, a minimal url-mode elicitation MCP server and client over @modelcontextprotocol/sdk, a README, and a .github/test.sh smoke harness that asserts protocol negotiation and that url-mode elicitation is relayed end to end. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c6807a commit 82565fd

8 files changed

Lines changed: 1750 additions & 0 deletions

File tree

examples/mcp.proxy/.github/test.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/bin/sh
2+
# Drives the build-workflow verification for examples/mcp.proxy.
3+
#
4+
# Two assertions, both at the Zilla layer:
5+
# 1. a url-elicitation-capable client initializes and negotiates 2025-11-25
6+
# 2. a real MCP SDK client drives a url-mode elicitation round-trip end-to-end
7+
# through the gateway (elicitation/create mode:url + completion notification)
8+
#
9+
# Streamable HTTP responses arrive as Server-Sent Events; checks grep the
10+
# streamed body / client output rather than asserting exact-string equality.
11+
set -x
12+
13+
EXIT=0
14+
PORT="7114"
15+
INITIALIZE='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"elicitation":{"url":{}}},"clientInfo":{"name":"zilla-mcp-proxy-test","version":"0.0.1"}}}'
16+
17+
echo "# Testing mcp.proxy"
18+
echo "PORT=$PORT"
19+
20+
# WHEN: a url-elicitation-capable client initializes against the gateway
21+
# THEN: the gateway negotiates protocol version 2025-11-25 in the response
22+
INIT_BODY=$(curl -sS -N --max-time 10 \
23+
-X POST "http://localhost:$PORT/mcp" \
24+
-H "Content-Type: application/json" \
25+
-H "Accept: application/json, text/event-stream" \
26+
-d "$INITIALIZE")
27+
echo INIT_BODY="$INIT_BODY"
28+
if echo "$INIT_BODY" | grep -q '"protocolVersion":"2025-11-25"'; then
29+
echo ✅ initialize negotiated 2025-11-25
30+
else
31+
echo ❌ initialize did not negotiate 2025-11-25
32+
EXIT=1
33+
fi
34+
35+
# WHEN: a real MCP SDK client (method-first envelopes, elicitation.url capability)
36+
# calls the urlelicit toolkit's authorize tool through the gateway
37+
# THEN: Zilla relays the mode:url elicitation/create request and the subsequent
38+
# notifications/elicitation/complete back to the client
39+
ELICIT_OUT=$(docker compose run --rm --no-deps \
40+
-e MCP_URL="http://zilla:$PORT/mcp" \
41+
urlelicit-client 2>&1)
42+
echo "$ELICIT_OUT"
43+
if echo "$ELICIT_OUT" | grep -q 'OK url-mode elicitation relayed end-to-end'; then
44+
echo ✅ url-mode elicitation relayed end-to-end
45+
else
46+
echo ❌ url-mode elicitation not relayed end-to-end
47+
EXIT=1
48+
fi
49+
50+
exit $EXIT

examples/mcp.proxy/README.md

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# mcp.proxy
2+
3+
Aggregates multiple upstream MCP (Model Context Protocol) servers behind a single
4+
Streamable HTTP endpoint on port `7114`, with a shared in-memory cache for
5+
`tools` / `prompts` / `resources` listings.
6+
7+
```text
8+
┌──────────────────── Zilla ─────────────────────┐
9+
│ tcp(7114) → http → mcp(server) → mcp(proxy) │
10+
client ──────►│ │ │
11+
│ ┌──────────┴──────────┐ │
12+
│ ▼ ▼ │
13+
│ mcp(client) everything mcp(client) urlelicit
14+
│ │ │ │
15+
│ ▼ ▼ │
16+
│ http → http → │
17+
│ tcp tcp │
18+
└─────────────────┼─────────────────────┼───────┘
19+
▼ ▼
20+
everything:3001 urlelicit:3003
21+
(reference server) (url-mode elicitation)
22+
```
23+
24+
The proxy demonstrates, in one configuration:
25+
26+
- **Multi-toolkit aggregation**`routes[].when.toolkit` fans one Streamable HTTP
27+
endpoint into multiple upstream MCP servers.
28+
- **Shared cache**`options.cache` backs `tools` / `prompts` / `resources`
29+
listings with an in-memory store and a five-minute TTL.
30+
- **Protocol version negotiation** — Zilla offers MCP protocol `2025-11-25` and
31+
negotiates down per peer; the negotiated version is echoed on the `initialize`
32+
response and stamped on every upstream request.
33+
- **Form elicitation pass-through** — Zilla forwards MCP `elicitation/create`
34+
messages in both directions, so any upstream that elicits structured user input
35+
drives the flow through the gateway with no extra configuration. The
36+
`everything` reference server's `trigger-elicitation-request-async` tool
37+
exercises this directly.
38+
- **URL-mode elicitation pass-through (SEP-1036)** — when the client advertises
39+
the `elicitation.url` capability, Zilla advertises it to the upstream, so a
40+
url-mode-capable upstream can ask the user to complete a secure out-of-band
41+
interaction in their browser. Zilla relays the `mode:"url"` `elicitation/create`
42+
request and the `notifications/elicitation/complete` notification end-to-end.
43+
The `urlelicit` server's `authorize` tool exercises this directly.
44+
- **MCP metrics** — the `mcp(server)` binding records per-method counters and
45+
duration histograms (`mcp.initialize`, `mcp.tools.list`, `mcp.tools.call`,
46+
`mcp.prompts.*`, `mcp.resources.*`), dimensioned by `method`, `tool`, and
47+
`outcome`, exported over Prometheus on port `7190`.
48+
49+
## Requirements
50+
51+
- docker compose
52+
53+
## Setup
54+
55+
```bash
56+
docker compose up -d
57+
```
58+
59+
This starts Zilla plus two locally-reachable upstream MCP servers: a Node
60+
`everything` reference server on `:3001`, and a minimal `urlelicit` server on
61+
`:3003` that demonstrates url-mode elicitation.
62+
63+
## Verify
64+
65+
Run the automated smoke test that the build workflow uses:
66+
67+
```bash
68+
./.github/test.sh
69+
```
70+
71+
Or drive the gateway interactively with the MCP Inspector:
72+
73+
```bash
74+
npx @modelcontextprotocol/inspector http://localhost:7114/mcp
75+
```
76+
77+
### Initialize the MCP session
78+
79+
A client that supports url-mode elicitation advertises the `elicitation.url`
80+
capability and offers protocol `2025-11-25`; Zilla echoes the negotiated version:
81+
82+
```bash
83+
curl -N http://localhost:7114/mcp \
84+
-H "Content-Type: application/json" \
85+
-H "Accept: application/json, text/event-stream" \
86+
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"elicitation":{"url":{}}},"clientInfo":{"name":"curl","version":"0"}}}'
87+
```
88+
89+
The `result.protocolVersion` in the response is `2025-11-25`. Because the client
90+
advertised `elicitation.url`, Zilla advertises the same capability on its
91+
`initialize` request to each upstream.
92+
93+
### Trigger a form elicitation round-trip
94+
95+
Call the `everything` server's elicitation-demo tool through the gateway:
96+
97+
```bash
98+
curl -N http://localhost:7114/mcp \
99+
-H "Content-Type: application/json" \
100+
-H "Accept: application/json, text/event-stream" \
101+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"everything__trigger-elicitation-request-async"}}'
102+
```
103+
104+
`@modelcontextprotocol/server-everything` responds with an `elicitation/create`
105+
JSON-RPC request bound for the client. Zilla forwards it back through
106+
`mcp(client) → mcp(proxy) → mcp(server) → http(server)` without unwrapping.
107+
108+
### Trigger a url-mode elicitation round-trip
109+
110+
Use MCP Inspector (which knows how to handle `elicitation/create`) to call the
111+
`urlelicit` server's `authorize` tool:
112+
113+
```text
114+
urlelicit__authorize { "resource": "demo" }
115+
```
116+
117+
The `urlelicit` server replies with a `mode:"url"` `elicitation/create` request
118+
carrying a link for the user to open in their browser. Zilla relays it back to
119+
the client unchanged; once the out-of-band interaction completes, the server
120+
sends `notifications/elicitation/complete`, which Zilla also relays. URL-mode
121+
elicitation only flows when the client advertised `elicitation.url` at
122+
`initialize` — a form-only or older client never sees the url request.
123+
124+
### Observe the cache
125+
126+
Repeat a `tools/list` request within five minutes and tail Zilla's logs:
127+
128+
```bash
129+
docker compose logs -f zilla | grep mcp.proxy.cache
130+
```
131+
132+
The first call shows a cache miss; subsequent ones within `ttl` are served from
133+
memory.
134+
135+
### Observe MCP metrics
136+
137+
The `mcp(server)` binding is configured with `telemetry.metrics: [mcp.*]` and
138+
records each request as a counter plus a duration histogram, attributed by
139+
`method`, `tool`, and `outcome`. Scrape them from the Prometheus endpoint:
140+
141+
```bash
142+
curl -s http://localhost:7190/metrics | grep '^mcp_'
143+
```
144+
145+
After an `everything__echo` tool call, for example, you will see:
146+
147+
```text
148+
mcp_tools_call_total{method="tools.call",outcome="ok",tool="everything__echo"} 1
149+
```
150+
151+
## Teardown
152+
153+
```bash
154+
docker compose down -v
155+
```
156+
157+
## References
158+
159+
- [Zilla docs — `binding-mcp`](https://docs.aklivity.io/zilla/latest/reference/config/bindings/binding-mcp.html)
160+
- [MCP — Streamable HTTP transport](https://modelcontextprotocol.io/docs/concepts/transports)
161+
- [MCP — elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
162+
- [SEP-1036 — URL mode elicitation](https://modelcontextprotocol.io/seps/1036-url-mode-elicitation-for-secure-out-of-band-intera)

examples/mcp.proxy/compose.yaml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: ${NAMESPACE:-zilla-mcp-proxy}
2+
services:
3+
zilla:
4+
image: ghcr.io/aklivity/zilla:${ZILLA_VERSION:-latest}
5+
restart: unless-stopped
6+
hostname: zilla.examples.dev
7+
ports:
8+
- 7114:7114
9+
- 7190:7190
10+
# - 4444:4444
11+
healthcheck:
12+
interval: 5s
13+
timeout: 3s
14+
retries: 5
15+
test: ["CMD", "bash", "-c", "echo -n '' > /dev/tcp/127.0.0.1/7114"]
16+
environment:
17+
ZILLA_INCUBATOR_ENABLED: "true"
18+
# JAVA_OPTIONS: "-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=*:4444"
19+
volumes:
20+
- ./etc:/etc/zilla
21+
# - ~/docker-captures:/var/log/zilla-captures
22+
command: start -v -e
23+
depends_on:
24+
everything:
25+
condition: service_healthy
26+
urlelicit:
27+
condition: service_healthy
28+
29+
everything:
30+
image: node:20-alpine
31+
hostname: everything
32+
restart: unless-stopped
33+
command: ["npx", "-y", "@modelcontextprotocol/server-everything", "streamableHttp"]
34+
environment:
35+
PORT: "3001"
36+
healthcheck:
37+
interval: 5s
38+
timeout: 3s
39+
retries: 10
40+
test: ["CMD", "nc", "-z", "127.0.0.1", "3001"]
41+
42+
urlelicit:
43+
image: node:20-alpine
44+
hostname: urlelicit
45+
restart: unless-stopped
46+
working_dir: /app
47+
command: ["sh", "-c", "npm install --no-audit --no-fund --silent && node server.mjs"]
48+
ports:
49+
- 3003:3003
50+
environment:
51+
PORT: "3003"
52+
volumes:
53+
- ./url-elicit:/app
54+
healthcheck:
55+
interval: 5s
56+
timeout: 3s
57+
retries: 20
58+
test: ["CMD", "nc", "-z", "127.0.0.1", "3003"]
59+
60+
# Run on demand: `docker compose run --rm urlelicit-client`.
61+
# A distinct service so the one-off client container does not claim the
62+
# `urlelicit` network alias and round-robin Zilla's upstream resolution onto
63+
# itself (which would intermittently break the south connection to the server).
64+
urlelicit-client:
65+
image: node:20-alpine
66+
deploy:
67+
replicas: 0
68+
working_dir: /app
69+
environment:
70+
MCP_URL: "http://zilla:7114/mcp"
71+
volumes:
72+
- ./url-elicit:/app
73+
entrypoint: ["node", "/app/client.mjs"]
74+
75+
networks:
76+
default:
77+
driver: bridge

examples/mcp.proxy/etc/zilla.yaml

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
name: example
3+
stores:
4+
cache:
5+
type: memory
6+
bindings:
7+
north_tcp_server:
8+
type: tcp
9+
kind: server
10+
options:
11+
host: 0.0.0.0
12+
port:
13+
- 7114
14+
routes:
15+
- when:
16+
- port: 7114
17+
exit: north_http_server
18+
north_http_server:
19+
type: http
20+
kind: server
21+
options:
22+
access-control:
23+
policy: cross-origin
24+
routes:
25+
- when:
26+
- headers:
27+
:scheme: http
28+
:path: /mcp
29+
exit: north_mcp_server
30+
north_mcp_server:
31+
type: mcp
32+
kind: server
33+
exit: north_mcp_proxy
34+
telemetry:
35+
metrics:
36+
- mcp.*
37+
attributes:
38+
method: ${mcp.method}
39+
tool: ${mcp.tool}
40+
outcome: ${mcp.outcome}
41+
north_mcp_proxy:
42+
type: mcp
43+
kind: proxy
44+
options:
45+
cache:
46+
store: cache
47+
ttl: PT5M
48+
prompts:
49+
- name: gateway-rules
50+
description: Aggregator-level prompt advertised to every client
51+
routes:
52+
- exit: south_mcp_client_everything
53+
when:
54+
- toolkit: everything
55+
capability: [tools, prompts, resources]
56+
- exit: south_mcp_client_urlelicit
57+
when:
58+
- toolkit: urlelicit
59+
capability: [tools]
60+
south_mcp_client_everything:
61+
type: mcp
62+
kind: client
63+
routes:
64+
- exit: sys:http_client
65+
with:
66+
headers:
67+
:scheme: http
68+
:authority: everything:3001
69+
:path: /mcp
70+
south_mcp_client_urlelicit:
71+
type: mcp
72+
kind: client
73+
routes:
74+
- exit: sys:http_client
75+
with:
76+
headers:
77+
:scheme: http
78+
:authority: urlelicit:3003
79+
:path: /mcp
80+
telemetry:
81+
metrics:
82+
- mcp.initialize
83+
- mcp.initialize.duration
84+
- mcp.tools.list
85+
- mcp.tools.list.duration
86+
- mcp.tools.call
87+
- mcp.tools.call.duration
88+
- mcp.prompts.list
89+
- mcp.prompts.list.duration
90+
- mcp.prompts.get
91+
- mcp.prompts.get.duration
92+
- mcp.resources.list
93+
- mcp.resources.list.duration
94+
- mcp.resources.read
95+
- mcp.resources.read.duration
96+
exporters:
97+
stdout_logs_exporter:
98+
type: stdout
99+
prometheus_exporter:
100+
type: prometheus
101+
options:
102+
endpoints:
103+
- scheme: http
104+
port: 7190
105+
path: /metrics

0 commit comments

Comments
 (0)