@ssheleg/agent-stack 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +88 -0
- package/README.md +29 -4
- package/package.json +2 -2
- package/plugins/agent-stack/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-stack/skills/agent-interop/SKILL.md +172 -0
- package/plugins/agent-stack/skills/agent-interop/references/a2a.md +209 -0
- package/plugins/agent-stack/skills/agent-interop/references/gateway.md +161 -0
- package/plugins/agent-stack/skills/agent-interop/references/mcp-scale.md +162 -0
- package/plugins/agent-stack/skills/agent-interop/references/mcp-ship.md +142 -0
- package/plugins/agent-stack/skills/agent-interop/references/mcp.md +241 -0
- package/plugins/agent-stack/skills/agent-interop/references/registry.md +181 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# The gateway layer — one controlled seam for agent traffic
|
|
2
|
+
|
|
3
|
+
**Load this when:** many clients meet many servers, tool access needs to differ per caller,
|
|
4
|
+
or somebody has asked who is allowed to call what and you cannot answer from one place.
|
|
5
|
+
|
|
6
|
+
**Spec pinned:** agentgateway `1.4.x` from `agentgateway.dev/docs/standalone/latest` · read 2026-08-13
|
|
7
|
+
|
|
8
|
+
Linux Foundation, Apache-2.0, written in Rust.
|
|
9
|
+
|
|
10
|
+
This file is **vendor-neutral first**: what a gateway for agent traffic must do, then
|
|
11
|
+
agentgateway as a named reference implementation. Swap the implementation and the first half
|
|
12
|
+
still holds.
|
|
13
|
+
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- Why an API gateway is not enough
|
|
17
|
+
- What a gateway must do
|
|
18
|
+
- Federation and the name-collision problem
|
|
19
|
+
- Where authorization belongs
|
|
20
|
+
- agentgateway, concretely
|
|
21
|
+
- Traps
|
|
22
|
+
|
|
23
|
+
## Why an API gateway is not enough
|
|
24
|
+
|
|
25
|
+
A conventional gateway assumes stateless request/response. Agent traffic breaks four of its
|
|
26
|
+
assumptions at once, and each break is a feature you would otherwise build by hand:
|
|
27
|
+
|
|
28
|
+
| Assumption | What agent traffic does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| one request, one upstream | **multiplexes** across several tool servers and merges the result into one surface |
|
|
31
|
+
| the server answers when asked | servers **initiate** events — SSE streams, change notifications |
|
|
32
|
+
| the protocol is fixed | MCP and A2A **revise**, so the hop has to negotiate versions rather than pass bytes |
|
|
33
|
+
| authorization is per route | authorization is **per tool and per client**, inside one route |
|
|
34
|
+
|
|
35
|
+
There is also a threat class with no HTTP analogue: **tool tampering, tool shadowing, and
|
|
36
|
+
rug-pull** — where a server's advertised tools change after approval, or one server's tool
|
|
37
|
+
name shadows another's. A proxy that only forwards cannot see any of it happen.
|
|
38
|
+
|
|
39
|
+
## What a gateway must do
|
|
40
|
+
|
|
41
|
+
Independent of product:
|
|
42
|
+
|
|
43
|
+
1. **Terminate and re-establish the protocol**, not tunnel it — otherwise none of the rest is
|
|
44
|
+
possible.
|
|
45
|
+
2. **Federate many servers behind one endpoint**, with a deliberate answer to name
|
|
46
|
+
collisions.
|
|
47
|
+
3. **Carry server-initiated events** across the hop, including reconnects, without silently
|
|
48
|
+
dropping subscriptions.
|
|
49
|
+
4. **Authorize per tool and per caller**, not per route.
|
|
50
|
+
5. **Pin and negotiate protocol revisions**, so one server upgrading does not break every
|
|
51
|
+
client at once.
|
|
52
|
+
6. **Detect change in the advertised surface.** A tool set that changes after approval is the
|
|
53
|
+
rug-pull; somebody has to notice.
|
|
54
|
+
7. **Emit one audit trail** across every hop, with the identity that made the call attached
|
|
55
|
+
to it.
|
|
56
|
+
8. **Route model traffic too**, where the same seam fronts LLM providers — otherwise you run
|
|
57
|
+
two proxies and two policies.
|
|
58
|
+
|
|
59
|
+
**The reason to want one at all:** without it, each of these lands in every client
|
|
60
|
+
separately, and the security answer becomes "it depends which client".
|
|
61
|
+
|
|
62
|
+
## Federation and the name-collision problem
|
|
63
|
+
|
|
64
|
+
Two servers, both exposing `search`. The model sees one name and the router picks by load
|
|
65
|
+
order — which is a coin flip that looks like a bug in the model.
|
|
66
|
+
|
|
67
|
+
The general answer is **namespacing at the federation point**, and the general trap is
|
|
68
|
+
**doing it inconsistently**, because tool names are what the model learned. A name that
|
|
69
|
+
changes between sessions invalidates every prompt, cache and eval that referenced it.
|
|
70
|
+
|
|
71
|
+
agentgateway's shape, as a concrete instance: multiple **targets** combine into one backend —
|
|
72
|
+
"Virtual MCP" — with tool names prefixed by target name (`time_get_current_time`,
|
|
73
|
+
`everything_echo`), controlled by `prefixMode`:
|
|
74
|
+
|
|
75
|
+
| `prefixMode` | Behaviour |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `conditional` *(default)* | prefix only when the backend has more than one target |
|
|
78
|
+
| `always` | prefix even with a single target |
|
|
79
|
+
| `never` | never prefix — names must already be unique, or initialization fails |
|
|
80
|
+
|
|
81
|
+
**`conditional` is a friendly default and a latent break.** Add a second target and every
|
|
82
|
+
tool name in the first one changes. If anything persists tool names — a prompt, a cached
|
|
83
|
+
plan, an eval fixture — choose `always` up front and pay the ugliness once.
|
|
84
|
+
|
|
85
|
+
```yaml
|
|
86
|
+
backends:
|
|
87
|
+
- mcp:
|
|
88
|
+
targets:
|
|
89
|
+
- name: time
|
|
90
|
+
stdio:
|
|
91
|
+
cmd: uvx
|
|
92
|
+
args: ["--with", "mcp<2", "mcp-server-time"]
|
|
93
|
+
- name: everything
|
|
94
|
+
stdio:
|
|
95
|
+
cmd: npx
|
|
96
|
+
args: ["@modelcontextprotocol/server-everything"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Where authorization belongs
|
|
100
|
+
|
|
101
|
+
**At the gateway, and again at each hop.** A gateway is the right place to express "this
|
|
102
|
+
client may call these tools" because it is the one place that sees every call. It is the
|
|
103
|
+
wrong place to make it the *only* check, because a server reachable by any other path is
|
|
104
|
+
unprotected.
|
|
105
|
+
|
|
106
|
+
Two rules that survive any product choice:
|
|
107
|
+
|
|
108
|
+
- **Policy per hop, not only at the entrance.** A chain of agents passes context onward, and
|
|
109
|
+
authority does not automatically travel with it.
|
|
110
|
+
- **The gateway sees the calls, not the intent.** It can enforce that a tool may be called;
|
|
111
|
+
it cannot tell whether the model was manipulated into calling it. Content inspection is
|
|
112
|
+
probabilistic — anything consequential needs a deterministic limit or a human. See
|
|
113
|
+
`agent-orchestrator/references/governance.md`.
|
|
114
|
+
|
|
115
|
+
## agentgateway, concretely
|
|
116
|
+
|
|
117
|
+
A unified HTTP and gRPC proxy written in Rust — a control plane and a data plane — under the
|
|
118
|
+
Linux Foundation, Apache-2.0. It covers three planes at once:
|
|
119
|
+
|
|
120
|
+
| Plane | What it does |
|
|
121
|
+
|---|---|
|
|
122
|
+
| **LLM providers** | OpenAI-compatible routing across 20+ providers (OpenAI, Anthropic, Bedrock, Azure, Gemini, Cohere, Ollama, …) |
|
|
123
|
+
| **MCP** | tool federation across many servers, per-client authorization |
|
|
124
|
+
| **A2A** | inter-agent calls, capability discovery |
|
|
125
|
+
|
|
126
|
+
**Local configuration — and read this before copying an example.** The current top-level keys
|
|
127
|
+
are `config`, `gateways`, `routes` / `tcpRoutes`, `llm`, `mcp`, `ui`, plus `services` /
|
|
128
|
+
`workloads` for advanced backends. **`binds` is the deprecated predecessor to `gateways`** —
|
|
129
|
+
and the project's own overview page still introduces it, which is exactly the kind of drift
|
|
130
|
+
this skill's rule zero exists for. A minimal MCP config:
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
mcp:
|
|
134
|
+
port: 3000
|
|
135
|
+
targets:
|
|
136
|
+
- name: everything
|
|
137
|
+
stdio:
|
|
138
|
+
cmd: npx
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Security policies** available as first-class config: JWT authentication, API-key
|
|
142
|
+
authentication, HTTP authorization, external authorization, and **MCP-specific
|
|
143
|
+
authorization** — the last being the per-tool control that a generic gateway lacks.
|
|
144
|
+
|
|
145
|
+
**On Kubernetes** it is conformant to the **Gateway API** — `HTTPRoute`, `GRPCRoute`,
|
|
146
|
+
`TCPRoute`, `TLSRoute` — so it uses standard resources rather than a proprietary CRD set.
|
|
147
|
+
That is the strongest single argument for it in a cluster that already runs Gateway API: the
|
|
148
|
+
routing objects are ones your platform team already reviews.
|
|
149
|
+
|
|
150
|
+
## Traps
|
|
151
|
+
|
|
152
|
+
- **Introducing a gateway and leaving the direct paths open.** The policy is then advisory.
|
|
153
|
+
- **`prefixMode: conditional` plus a second target**, silently renaming every tool.
|
|
154
|
+
- **Copying a config from a blog post.** `binds` versus `gateways` is a live example; the
|
|
155
|
+
config surface moves faster than the articles.
|
|
156
|
+
- **Treating the gateway as the security boundary.** It is *a* boundary. The sandbox, the
|
|
157
|
+
credentials and the per-hop policy are the others.
|
|
158
|
+
- **Federating servers you do not trust into one namespace.** Federation makes a hostile
|
|
159
|
+
server's tools look exactly as legitimate as everyone else's.
|
|
160
|
+
- **Forgetting the reconnect.** MCP notification delivery is best-effort; a hop in the middle
|
|
161
|
+
makes a dropped subscription more likely, not less. Poll as well.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# MCP at scale — when there are more tools than context
|
|
2
|
+
|
|
3
|
+
**Load this when:** the host connects to more than a handful of servers, tool definitions are
|
|
4
|
+
eating the context window, or chained tool calls are pushing large intermediate results
|
|
5
|
+
through the model.
|
|
6
|
+
|
|
7
|
+
**Spec pinned:** MCP `2026-07-28`, `docs/2026-07-28/develop/clients/client-best-practices` · read 2026-08-13
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
- The two costs, and which pattern fixes which
|
|
12
|
+
- Progressive tool discovery
|
|
13
|
+
- Dynamic server management
|
|
14
|
+
- The prompt-cache interaction nobody expects
|
|
15
|
+
- Programmatic tool calling (code mode)
|
|
16
|
+
- Security surface
|
|
17
|
+
- Traps
|
|
18
|
+
|
|
19
|
+
## The two costs, and which pattern fixes which
|
|
20
|
+
|
|
21
|
+
They are different problems and confusing them wastes a rewrite:
|
|
22
|
+
|
|
23
|
+
| Cost | Symptom | Pattern |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| **Definitions** — *when* tools enter context | the window is largely consumed before the user's message is read | **progressive discovery** |
|
|
26
|
+
| **Results** — *how* tools are invoked | every intermediate result flows through the model on the way to the next call | **programmatic tool calling** |
|
|
27
|
+
|
|
28
|
+
The published illustration is stark: loading everything upfront at ~150,000 tokens of
|
|
29
|
+
definitions against ~2,000 for discovering on demand. They compose — discovery narrows what
|
|
30
|
+
the model knows about, code mode narrows what it has to read.
|
|
31
|
+
|
|
32
|
+
## Progressive tool discovery
|
|
33
|
+
|
|
34
|
+
The naive host passes every connected server's `tools/list` output to the model at the start
|
|
35
|
+
of every conversation. For a handful of tools this is correct and you should not do anything
|
|
36
|
+
cleverer.
|
|
37
|
+
|
|
38
|
+
**The switch is a threshold, and the guidance is explicit:** express it as a percentage of
|
|
39
|
+
the context window — **1%–5%** — load definitions normally until it is reached, then switch.
|
|
40
|
+
|
|
41
|
+
Once switched, the host fetches definitions as usual but **defers injecting them**, exposing
|
|
42
|
+
a `search_tools` meta-tool instead. Three layers:
|
|
43
|
+
|
|
44
|
+
1. **Catalog** — `search_tools({query: "update salesforce record"})` returns names and
|
|
45
|
+
one-line descriptions only.
|
|
46
|
+
2. **Inspect** — `get_tool_details({name: "salesforce_updateRecord"})` returns the full
|
|
47
|
+
schema for that one tool.
|
|
48
|
+
3. **Execute** — the model calls it, having loaded only what it needed.
|
|
49
|
+
|
|
50
|
+
**Retrieval strategy**, and none is automatically right:
|
|
51
|
+
|
|
52
|
+
| Strategy | Good at | Cost |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| Keyword (BM25, regex) | descriptive names; simple, predictable | misses synonyms |
|
|
55
|
+
| Embedding | synonyms and semantic matches | index to build and maintain |
|
|
56
|
+
| Subagent (small fast model picks) | works very well | the most expensive |
|
|
57
|
+
| Hybrid | scoring across rankings, or per use case | complexity |
|
|
58
|
+
|
|
59
|
+
**Check the provider first.** OpenAI and Anthropic both ship built-in tool search; prefer the
|
|
60
|
+
platform's over a hand-rolled one unless you need access-control filtering or domain-specific
|
|
61
|
+
ranking in the retrieval itself.
|
|
62
|
+
|
|
63
|
+
**Implementation guidance worth obeying:** offer multiple detail levels (name-only,
|
|
64
|
+
name+description, full schema); cache definitions host-side so re-injection needs no round
|
|
65
|
+
trip; **re-index on `notifications/tools/list_changed`**; group tools by their source server
|
|
66
|
+
so the model can reason about related capability.
|
|
67
|
+
|
|
68
|
+
## Dynamic server management
|
|
69
|
+
|
|
70
|
+
The same idea one level up. Rather than connecting to everything at startup: keep a registry
|
|
71
|
+
of available servers with high-level descriptions, connect when the model asks for that
|
|
72
|
+
capability, and **disconnect when it is no longer relevant** to free context.
|
|
73
|
+
|
|
74
|
+
This suits general-purpose agents, where intent is unknown at the start. It also composes
|
|
75
|
+
with Agent Skills: a skill file can declare which servers it needs, and the host connects
|
|
76
|
+
them only when that skill is invoked.
|
|
77
|
+
|
|
78
|
+
## The prompt-cache interaction nobody expects
|
|
79
|
+
|
|
80
|
+
This is the part that turns a clever discovery implementation into a regression, and it is
|
|
81
|
+
easy to ship without noticing.
|
|
82
|
+
|
|
83
|
+
Most providers cache the prompt prefix — **including the `tools` array**. Adding or removing
|
|
84
|
+
a definition mid-conversation **invalidates that cache**, and the resulting miss can cost more
|
|
85
|
+
tokens than the definitions you so carefully removed.
|
|
86
|
+
|
|
87
|
+
Three mitigations, in the order they are usually right:
|
|
88
|
+
|
|
89
|
+
- **Append** newly discovered definitions after the cache breakpoint rather than re-sorting
|
|
90
|
+
the array.
|
|
91
|
+
- Or route every call through **one stable `call_tool({name, args})` meta-tool**, so the
|
|
92
|
+
array never changes at all.
|
|
93
|
+
- Treat **server disconnection as a conversation-boundary operation**, not a per-turn one.
|
|
94
|
+
|
|
95
|
+
**Measure this rather than reasoning about it.** Whether your discovery scheme is a net win
|
|
96
|
+
depends on your provider's caching and your conversation shape, and the loss is invisible in
|
|
97
|
+
a token count that only sums definitions.
|
|
98
|
+
|
|
99
|
+
## Programmatic tool calling (code mode)
|
|
100
|
+
|
|
101
|
+
Instead of the model calling tools, the model **writes code that calls tools**; the code runs
|
|
102
|
+
in a sandbox and only its output returns to the model.
|
|
103
|
+
|
|
104
|
+
**How it is wired:**
|
|
105
|
+
|
|
106
|
+
1. The host generates a typed API from each server's tool schemas. Where a tool declares
|
|
107
|
+
`outputSchema`, the generated return type is precise — which is the concrete reason to
|
|
108
|
+
provide one.
|
|
109
|
+
2. The model writes a script against those functions.
|
|
110
|
+
3. The sandbox executes it. Calls are **intercepted and routed back through the host broker**
|
|
111
|
+
to the right MCP server. Intermediate data flows server-to-server without entering
|
|
112
|
+
context. Only `console.log` output returns.
|
|
113
|
+
|
|
114
|
+
The canonical example: "find all error logs from the past hour and file a ticket for each
|
|
115
|
+
unique error." Direct calling pushes thousands of log entries through the model; code mode
|
|
116
|
+
filters and deduplicates inside the sandbox and returns one summary line.
|
|
117
|
+
|
|
118
|
+
**When `outputSchema` is missing**, prefer the simple path — accept a generic type and handle
|
|
119
|
+
it downstream; the real fix belongs to the server author. A typed extraction via a small
|
|
120
|
+
fast model is available for single-shot calls outside loops, but it adds latency, can
|
|
121
|
+
hallucinate or drop fields, and its result must be validated before use.
|
|
122
|
+
|
|
123
|
+
**Sandbox options**, listed as examples rather than endorsements — evaluate maturity
|
|
124
|
+
yourself: Deno or `isolated-vm` for JavaScript; Monty (experimental) for Python; pctx
|
|
125
|
+
(early-stage) for TypeScript; Wasmtime for anything compiled to Wasm. Whichever you pick, the
|
|
126
|
+
integration is the same: inject stubs, intercept over an in-process or stdio channel so
|
|
127
|
+
network permission can stay fully denied, dispatch as `tools/call`.
|
|
128
|
+
|
|
129
|
+
**Errors:** convert `isError: true` into a thrown exception in the generated wrappers so
|
|
130
|
+
model-authored code can `try`/`catch`. If an uncaught error kills the script, surface it as
|
|
131
|
+
the script's result so the model can self-correct — and note that the model is then
|
|
132
|
+
responsible for reporting side effects already committed.
|
|
133
|
+
|
|
134
|
+
## Security surface
|
|
135
|
+
|
|
136
|
+
Code mode adds a code-execution surface, and each of these is a real control rather than
|
|
137
|
+
advice:
|
|
138
|
+
|
|
139
|
+
- **Per-call authorization.** The broker is still the MCP host for specification purposes.
|
|
140
|
+
Approving the *script* does not approve every call it makes at runtime. Categorical grants
|
|
141
|
+
are fine ("allow `ticketing_createIssue` for this run"); evaluating each call against the
|
|
142
|
+
grant is not optional.
|
|
143
|
+
- **Cross-server data flow.** A result from server A is untrusted input to server B. Apply
|
|
144
|
+
the same review policy to brokered calls as to direct ones — **truncating output does not
|
|
145
|
+
prevent exfiltration**.
|
|
146
|
+
- **Network isolation.** The sandbox gets no direct network access; everything goes through
|
|
147
|
+
the broker.
|
|
148
|
+
- **No credential exposure.** Keys live with the host; generated code calls typed functions
|
|
149
|
+
and the host attaches auth when forwarding.
|
|
150
|
+
- **Resource limits.** Timeouts and memory caps, or one runaway script is an outage.
|
|
151
|
+
- **Output filtering.** Validate and truncate console output before it re-enters the model.
|
|
152
|
+
|
|
153
|
+
## Traps
|
|
154
|
+
|
|
155
|
+
- **Switching to discovery too early.** Below the threshold it is pure overhead plus a
|
|
156
|
+
failure mode: the model cannot use a tool it never found.
|
|
157
|
+
- **A search index that never re-indexes.** Without honoring `list_changed`, discovery
|
|
158
|
+
confidently returns tools that no longer exist.
|
|
159
|
+
- **Counting definition tokens and calling it a saving.** See the prompt-cache section.
|
|
160
|
+
- **A sandbox with network access.** It is then not a sandbox, it is a proxy for the model.
|
|
161
|
+
- **Assuming the sandbox bounds authorization.** It bounds *execution*. Authorization is the
|
|
162
|
+
broker's job, per call.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Shipping an MCP server — mount it, and debug the client that cannot reach it
|
|
2
|
+
|
|
3
|
+
**Load this when:** the server is written and now has to reach someone — mounting it inside
|
|
4
|
+
an existing web app, or fixing a client that will not connect.
|
|
5
|
+
|
|
6
|
+
**Spec pinned:** MCP `2026-07-28` transports; FastMCP/Starlette mounting shapes · read 2026-08-13
|
|
7
|
+
|
|
8
|
+
`mcp.md` is the protocol. This file starts where that one stops, and covers the part that
|
|
9
|
+
is not in any specification: where the endpoint actually lands, and why the client says 404.
|
|
10
|
+
For **publishing** to the registry, see `registry.md`. For designing the tool set in the
|
|
11
|
+
first place, Anthropic's `mcp-server-dev` plugin is built for it and this file does not
|
|
12
|
+
repeat it.
|
|
13
|
+
|
|
14
|
+
## Contents
|
|
15
|
+
|
|
16
|
+
- Mounting into an existing web app
|
|
17
|
+
- Auth middleware and a health endpoint
|
|
18
|
+
- Client configuration
|
|
19
|
+
- Debugging a client that will not connect
|
|
20
|
+
|
|
21
|
+
## Mounting into an existing web app
|
|
22
|
+
|
|
23
|
+
The common production shape: you already run a FastAPI/Starlette app, and the MCP server
|
|
24
|
+
should live at `/mcp` on the same host. It is also where the single most common bug lives.
|
|
25
|
+
|
|
26
|
+
**The double-path pitfall.** FastMCP defaults its internal `streamable_http_path` to
|
|
27
|
+
`/mcp/`. Mount that app at `/mcp` and the real endpoint becomes `/mcp/mcp` — so every
|
|
28
|
+
client pointed at `/mcp` gets 404, and the SSE fallback 404s too, which reads like the
|
|
29
|
+
server is down.
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
mcp = FastMCP(
|
|
33
|
+
"my_server",
|
|
34
|
+
streamable_http_path="/", # REQUIRED when mounting as a sub-app
|
|
35
|
+
stateless_http=True,
|
|
36
|
+
json_response=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
app.mount("/mcp", mcp.streamable_http_app())
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `streamable_http_path="/"` whenever the app is mounted rather than served standalone.
|
|
43
|
+
Standalone servers keep the default.
|
|
44
|
+
|
|
45
|
+
## Auth middleware and a health endpoint
|
|
46
|
+
|
|
47
|
+
**Wrap the mounted ASGI app rather than adding auth inside tool handlers.** The transport
|
|
48
|
+
handshake happens before any tool runs, so handler-level auth leaves the protocol surface
|
|
49
|
+
open — an unauthenticated caller can still enumerate what you expose.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
class MCPAuthMiddleware:
|
|
53
|
+
_PUBLIC_PATHS = {"/health"}
|
|
54
|
+
|
|
55
|
+
def __init__(self, app, *, store):
|
|
56
|
+
self._app, self._store = app, store
|
|
57
|
+
|
|
58
|
+
async def __call__(self, scope, receive, send):
|
|
59
|
+
if scope["type"] not in ("http", "websocket"):
|
|
60
|
+
return await self._app(scope, receive, send)
|
|
61
|
+
if scope.get("path", "") in self._PUBLIC_PATHS:
|
|
62
|
+
return await self._app(scope, receive, send)
|
|
63
|
+
|
|
64
|
+
headers = dict(scope.get("headers", []))
|
|
65
|
+
auth = headers.get(b"authorization", b"").decode()
|
|
66
|
+
if not auth.startswith("Bearer "):
|
|
67
|
+
return await self._send_401(send, "Missing Bearer token")
|
|
68
|
+
if not await self._store.validate_api_key(auth[7:]):
|
|
69
|
+
return await self._send_401(send, "Invalid API key")
|
|
70
|
+
return await self._app(scope, receive, send)
|
|
71
|
+
|
|
72
|
+
app.mount("/mcp", MCPAuthMiddleware(mcp.streamable_http_app(), store=key_store))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Keep a health endpoint **outside** the mount and exempt from auth. It is what tells "the app
|
|
76
|
+
is up, the MCP path is wrong" apart from "the app is down" — the two produce identical
|
|
77
|
+
client errors.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@app.get("/mcp/health")
|
|
81
|
+
async def mcp_health():
|
|
82
|
+
return {"status": "ok", "server": "my_server", "transport": "streamable-http"}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Client configuration
|
|
86
|
+
|
|
87
|
+
Remote (Streamable HTTP):
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"my-server": {
|
|
93
|
+
"url": "https://example.com/mcp",
|
|
94
|
+
"headers": { "Authorization": "Bearer ${MY_SERVER_KEY}" }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Local (stdio):
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"my-server": {
|
|
106
|
+
"command": "uv",
|
|
107
|
+
"args": ["--directory", "/path/to/project", "run", "python", "-m", "my_server"],
|
|
108
|
+
"env": { "API_KEY": "..." }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Ship both forms in your README.** A user who has to derive the config from prose files an
|
|
115
|
+
issue instead.
|
|
116
|
+
|
|
117
|
+
## Debugging a client that will not connect
|
|
118
|
+
|
|
119
|
+
**404 — by far the most common.** Symptom: `Error POSTing to endpoint: Not Found`, then the
|
|
120
|
+
SSE fallback 404s as well. Bisect the path directly:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
curl -X POST https://your-host/mcp/ -H 'Content-Type: application/json' -d '{}'
|
|
124
|
+
curl -X POST https://your-host/mcp -H 'Content-Type: application/json' -d '{}'
|
|
125
|
+
curl -X POST https://your-host/mcp/mcp -H 'Content-Type: application/json' -d '{}'
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
If `/mcp/mcp` answers — **even 401** — while `/mcp/` 404s, it is the double path. A 401 is a
|
|
129
|
+
*success* for this test: it proves routing reached the server.
|
|
130
|
+
|
|
131
|
+
**307.** Starlette redirects `/mcp` → `/mcp/`. Most clients follow it on POST; some do not,
|
|
132
|
+
and the failure looks like a hang. Either document the trailing slash or add an explicit
|
|
133
|
+
no-slash route.
|
|
134
|
+
|
|
135
|
+
**401.** Check the Bearer prefix and the key, then check that the client is actually sending
|
|
136
|
+
headers — several clients drop custom headers on the SSE fallback specifically, so the
|
|
137
|
+
initial POST authenticates and the stream does not.
|
|
138
|
+
|
|
139
|
+
**Version rejection.** Under `2026-07-28` a server that cannot speak the client's revision
|
|
140
|
+
answers `UnsupportedProtocolVersionError` listing what it does support. That is a readable
|
|
141
|
+
error, not a connection failure — read the list and retry on a common version rather than
|
|
142
|
+
treating it as the server being broken.
|