@ssheleg/agent-stack 0.6.1 → 0.7.0

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.
@@ -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.