Putting a gateway in front of an MCP server gives you a policy enforcement point on one network path. It does not prove the upstream server cannot be reached another way. A test that stops at the gateway's decision log checks the gateway's claim against the gateway's own records: the request was denied, the log says so, test passed.
The question that matters is different: can a call the policy denies still cause a side effect on the upstream server? You answer that at the server and in the state it changes. This article proposes a test matrix built around that one assertion, with a harmless fixture server that keeps two records: one of every call its handlers receive, and one of the state it actually changed. The matrix covers discovery, invocation, a policy change between the two, an unapproved sensitive action, a direct call that skips the gateway, and a telemetry-only path that no gateway decides on.
Everything below is a proposed acceptance criterion for your own environment. The fixtures are fictional, nothing here was executed, and the expected outcomes describe what a sound setup should do, not what any particular implementation does.
Three trust boundaries and the path around the middle one
Draw the system as three boundaries, not one pipe.
MCP client (agent, IDE, script) boundary 1: can send any JSON-RPC it likes | | tools/list, tools/call v Gateway (policy enforcement point) boundary 2: decides on traffic it receives | | forwarded calls only v Upstream MCP server --> side effect boundary 3: where things actually happen ^ | direct path: same URL, same network position, no gateway | MCP client MCP client --> OTLP collector telemetry path: spans reported, nothing decided The client is untrusted. An agent's model chooses which tool to call, and a scripted client chooses whatever its author typed. The gateway is trusted to decide, but only for requests that reach it. The server, and the stores and services it writes to, are where a ticket gets created or a row gets deleted. Their records are the ones that show what happened.
Two arrows bypass the gateway's decision. The direct path reaches the server without it. The telemetry path reports activity and authorizes nothing. A test suite that only exercises the middle arrow tells you nothing about either.
tools/list is a catalog, not a permission
Listing and invoking are separate MCP methods. In the official TypeScript SDK, listTools returns the tools the server advertises and callTool invokes one by name with a plain arguments object. Nothing in callTool requires a prior listTools. The tool name is a string in the request:
{"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "write_marker", "arguments": {"nonce": "case2-8f3a"}}} A scripted client can send that for a tool it was never shown. So the authorization decision has to happen on tools/call, every time, whatever the list said.
Implementations make different choices about the list:
- Permit's MCP gateway documents that tool lists are not filtered: it returns all upstream tools whatever the agent's trust level, and enforces on the call, "so an agent can see a tool that it can't call."
- HashiCorp's Consul MCP Gateway, marked beta, exposes an authorized tool catalog and evaluates invocation independently. Its docs say discovery responses "are not credentials or durable authorization decisions" and that "policy can change between discovery and invocation."
- Worksi's MCP gateway filters
tools/listand checkstools/call(more on that below).
In our view, filtering the list narrows what a model is offered, which is useful. It is not the control. If your tests only confirm that a denied tool is missing from the list, you have tested the catalog.
Build a fixture that cannot hurt anything
Register a small MCP server behind the gateway exactly as you register real ones: same network segment, same registration flow, same credentials model. Give it three tools. Every tool takes a required nonce string, and every matrix invocation sends a fresh one:
-
read_note(nonce): returns a fixed string. No side effect. -
write_marker(nonce): the side-effect tool. It inserts one row, keyed by the nonce, into amarkerstable in a store the fixture owns. -
delete_marker(nonce, target): the "sensitive" tool, configured in your policy to need approval. Before each case, the harness seeds two marker rows,marker-Aandmarker-B, tagged with the case nonce. The tool deletes the row named bytarget.
The fixture keeps two separate records, and the gateway writes to neither:
- The entry ledger. The first thing each handler does, before any logic, is append a row: nonce, tool name, arguments hash, receive time, source address, and which authentication headers were present. This records that a call reached a handler. It does not record that anything changed.
- The marker state. The
markerstable itself. This is the committed effect: a row inserted bywrite_marker, or a seeded row missing afterdelete_marker.
Each assertion is two lookups by nonce. For an expected allow of write_marker: exactly one entry row, and exactly one marker row. More than one entry row means the handler was invoked more than once for the same request, which is worth investigating on its own: it may come from retries, and it may or may not have produced a duplicate effect, which the marker state tells you. For an expected deny: zero entry rows and unchanged marker state after a wait window you choose, because some paths are asynchronous.
Zero entry rows is bounded evidence. It says this fixture's handlers saw nothing for this nonce within your window. It is not a proof about every downstream system, which is why the fixture's effects stay inside a store you can inspect.
Row 5 needs two fixed payloads so the approval check is repeatable:
- Approved arguments:
{"nonce": "<case nonce>", "target": "marker-A"} - Mismatched arguments:
{"nonce": "<case nonce>", "target": "marker-B"}
The scripted client is a few lines with the SDK. It never calls listTools before the cases that test hidden names:
const nonce = `case3-${crypto.randomUUID()}`; const result = await client.callTool({ name: 'delete_marker', arguments: { nonce, target: 'marker-A' }, }); // Record what the client saw, then query the entry ledger and marker state for `nonce`. The SDK notes that a failed tool call is still a result with isError set, and only protocol-level failures throw. Your harness has to check both shapes, and neither replaces the two lookups.
The matrix
Each row is a proposed acceptance criterion. "Gateway evidence" is what the enforcement point should record. "Server evidence" is the independent check, and it decides pass or fail.
| # | Request path | Identity and policy state | Proposed expected decision | Gateway evidence | Independent server evidence |
|---|---|---|---|---|---|
| 1 | Client → gateway → server, tools/call read_note with a nonce | Identity A; read_note allowed | Allow | Allowed call recorded with identity, tool and time | Exactly one entry row for the nonce; marker state unchanged |
| 2 | tools/list, then tools/call write_marker by name | Identity A; write_marker denied | Deny | Denied call recorded; error returned to client | Zero entry rows; no marker row for the nonce |
| 3 | tools/call delete_marker with no prior list, name typed by a script | Identity A; tool hidden from A's list and denied | Deny | Denied call recorded | Zero entry rows; both seeded markers present |
| 4 | tools/list while allowed; policy changed to deny; then tools/call write_marker | Identity A; policy revision changes between list and call | Deny, decided at call time | Denied call recorded against the new policy revision | Zero entry rows; no marker row |
| 5 | tools/call delete_marker with the mismatched payload after an approval for the approved payload; then with no approval at all | Identity A; tool requires approval | Deny both, or hold until a person approves that exact call | Approval request or refusal recorded; the mismatched approval not accepted | Zero entry rows; both seeded markers present until an approval for that exact call exists |
| 6 | Client → server directly, same URL the gateway uses, from the agent's network position, with every credential the agent holds | Any identity | Refused by the network or by the server | Nothing, and that is expected: the gateway never saw it | Connection refused, a verified network drop, or a 401 or 403 logged by the server; zero entry rows; marker state unchanged |
| 7 | Observation-only control: client → a separate, isolated fixture instance that is deliberately reachable without the gateway; tools/call write_marker; client exports an OTLP span | Any identity the isolated fixture accepts | No gateway authorization decision; only the isolated server's own checks apply | None at the gateway; a span appears in your telemetry backend | One entry row and one marker row on the isolated fixture: a span exists, and no gateway decided anything |
Rows 2 and 3 are the core of the list-versus-call question. Row 3 matters most where the list is filtered, because filtering creates a false sense that hidden means unreachable.
Row 4 has a timing component. Record when the policy change was saved and when the call was sent, and repeat with shorter gaps. If decisions are cached, you want to know the window, not discover it in an incident.
Row 5 borrows a requirement HashiCorp states: an approval for one action "must not authorize a different tool, changed arguments, or a different destination". Test the mismatched payload explicitly. An approval bound only to the tool name refuses the unapproved call and accepts the mismatched one, and marker-B disappears.
Row 7 is a control, not a bypass test. Keep the isolated instance off the network segment where row 6 runs, so the two rows never contradict each other. Its job is to show what a span looks like when nothing authorized the call.
Two rows worth adding once these pass: the policy decision point unreachable (decide whether you want fail-closed, write it down, then test it), and an identity whose access was revoked mid-session.
Row 6 is the one a gateway cannot pass alone
A gateway decides on traffic it receives. If the agent can reach the upstream server's URL and holds a credential the server accepts, the gateway's policy does not apply to that request, however strict it is. Something else has to close the path, and you configure it separately:
- Network restriction. The upstream server accepts connections only from the gateway's egress. Test it from where the agent actually runs, not from your laptop.
- Upstream verification. The server rejects requests that cannot prove they came through the gateway. Permit documents one design: its gateway adds a short-lived signed JWT in an
X-Gateway-Authheader, with the canonical upstream URL as the audience, and "an upstream server that verifies the X-Gateway-Auth JWT can reject agents that connect to the upstream URL directly." The same page is explicit that verification is up to the upstream server: one that doesn't verify the header "ignores it and behaves as before." The header alone closes nothing. - Credential custody. If the client holds the upstream token, the client can use it directly. Permit's design stores upstream OAuth tokens server-side so that MCP clients never hold upstream tokens. The MCP security guidance forbids token passthrough and states that "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server", warning that clients using tokens directly against downstream APIs bypass controls that depend on the token's audience.
Run row 6 with every credential the agent process can read: environment variables, config files, cached tokens. If any of them produces an entry row, your gateway policy covers the gateway path and nothing more.
A span is a report, not a decision
If you add tracing without enforcement, a reported span is not evidence of an authorization check. An OTLP span is telemetry supplied by instrumentation. Receiving it does not authorize, block or prove completion of a tool call. Row 7 makes that visible: a span in the backend, a marker row on the isolated fixture, and no decision anywhere in between.
The inverse mistake is worse. No gateway decision for a call does not mean the call didn't happen. Row 6 produces exactly that picture when it fails: a clean gateway log and an entry row in the server's ledger. That is why the server evidence column exists, and why it decides pass or fail.
Keep authorization separate from outcome too. An allowed call that reached the server proves the policy permitted it and a handler received it. It does not prove the tool did the right thing. HashiCorp's docs state it: a successful HTTP or MCP response "establishes a protocol outcome, not completion of the user's workflow". Row 1 passing says nothing about whether the note the agent read was the one it needed.
Where Worksi fits, and where it doesn't
Worksi's MCP gateway at /mcp/servers/{slug} governs a registered upstream server: tools/list is filtered, tools/call is checked, and every call is recorded as a span. That is what we state, and it is narrower than the gateway column above. We have not run this matrix against Worksi. Our product materials do not establish that a denial is recorded against a specific policy revision, or that an approval is bound to exact arguments. Treat both as things to test, not things we claim.
Tool policies (tool_allowlist, tool_approval) start on the Scale plan, and approval policies are a Scale feature, per our pricing. The free Developer plan does not include them. A new account's first organization gets 3 days of Scale features with no card, so you can try the tool policies against a fixture before choosing a plan.
Worksi does not close the direct path in row 6. The upstream server's network rules and credential handling are yours to configure, and the matrix is how you find out whether you did. Worksi also accepts OpenTelemetry over OTLP, and that path is observation only: enforcement applies only to traffic through the gateway. Spans sent to Worksi over OTLP do not authorize or block the calls they describe. Read how the MCP gateway works.
When to run it again
Run the matrix whenever one of the boundaries moves: a new upstream server, a policy model change, a gateway upgrade, a new place where agents run, a change to how credentials reach the agent process. Keep the fixture server registered permanently next to the real ones, and rerun row 6 after every network change.
Originally published at blog.worksi.ai. Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

