Intelligence

The Caller Tried to Restrict the MCP Tools. The Parser Turned the Bad Restriction Into No Restriction at All.

GitHub issue 939 against praxis-proxy/ai, opened 7 September 2026, labelled human reviewed and triage accepted for milestone v0.4.0, states that a malformed allowed_tools restriction on an MCP tool entry can reach AllowedTools::unrestricted, the same value the proxy returns when a caller supplies no restriction at all. This record independently confirmed the mechanism by extracting the exact, unmodified extract_allowed_tools, extract_from_filter_object and apply_allowed_tools_filter functions from the current default branch and running them directly against a deterministic local fixture, with no MCP server, production or otherwise, contacted at any point.

Event analysed: . This analysis was published on 7 September 2026.

When an OpenAI Responses API request asks Praxis Proxy to restrict an MCP server's tools with a malformed allowed_tools value, one that is present but is neither the documented array nor the documented filter object, does the restriction fail closed, or does it resolve to the same unrestricted authority as supplying no restriction at all?

It resolves to the same unrestricted authority as no restriction, and this record independently confirmed that by running the real extraction and filtering functions directly. GitHub issue 939 against praxis-proxy/ai, opened 7 September 2026 and carrying the labels human reviewed, triage accepted and area ai for milestone v0.4.0, states that extract_allowed_tools, in apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs, returns AllowedTools::unrestricted() both when the allowed_tools field is absent and, through its own final unconditional branch, whenever the field is present but matches neither the documented array form nor the documented filter object form, a bare string such as safe among them. This record read that exact function on the repository's current default branch, commit 84ddcc4ef0e33a6c6006a2d5264069fde96c5dfc, confirmed the same three way branch directly, and separately confirmed that extract_from_filter_object reads a filter object's own tool_names and read_only members through an optional chain that yields none for either member whenever its value is present but the wrong type, again indistinguishable in the parsed result from the member being absent. This record then copied those exact, unmodified functions into a standalone program and ran them against a deterministic local fixture, a read only tool named safe and a non read only tool named destructive, confirming that a bare string allowed_tools of safe resolves both tools as eligible, exactly as an absent allowed_tools field does, while the documented array form resolves only the one tool named. This record also read rewrite_request_body and confirmed a resolved MCP entry is always replaced in the outgoing request by concrete function tool definitions, so the caller's original, malformed field does not reach whatever schema check the inference backend might otherwise have applied to it. This record does not find a merged fix on the current default branch, and states that plainly rather than assuming the issue's own acceptance for a future milestone means the code has already changed.

Praxis Proxy sits between a caller of the OpenAI Responses API and one or more MCP servers, resolving each request's own MCP tool entries into the concrete function tool definitions the inference backend actually calls. A caller who wants to narrow what an MCP server may do inside one request sets that entry's own allowed_tools field, documented to accept either a plain array of tool names or a filter object carrying an optional tool_names array and an optional read_only flag. GitHub issue 939, opened against praxis-proxy/ai on 7 September 2026 and carrying the labels human reviewed, triage accepted and area ai against milestone v0.4.0, states that a value which is present but matches neither documented form does not raise an error and does not narrow anything: it reaches the exact same unrestricted result the resolver produces when the field is left out of the request entirely.

What this record independently confirmed by reading the exact source and then running it, rather than by reading a description of it: extract_allowed_tools, in apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs on the repository's current default branch, commit 84ddcc4ef0e33a6c6006a2d5264069fde96c5dfc, has exactly three branches. A missing field returns AllowedTools::unrestricted(). A JSON array returns a name list built from its string elements. A JSON object is handed to extract_from_filter_object. Anything else, a bare string, a number, a boolean, falls through all three checks to the function's own final line, which returns AllowedTools::unrestricted() again, the identical value the missing field case returns.

The exact branch a caller's mistake, or a client library's own serialization choice, can fall into

Reading apis/src/openai/responses/openai_mcp_tool_resolve/mod.rs directly shows the function's own shape plainly. It asks whether the entry carries an allowed_tools key at all; if not, it returns immediately. It asks whether the value is a JSON array; if so, it collects the array's string elements into a name list. It asks whether the value is a JSON object; if so, it delegates to extract_from_filter_object. There is no fourth branch that asks whether the value was one of the two documented shapes and, finding it was not, does anything other than fall to the function's own closing line, AllowedTools::unrestricted(). A caller who writes allowed_tools as the bare string safe, meaning to name one tool but forgetting the surrounding array brackets a slightly different client library or a slightly different mental model of the field might not add, receives every tool the server exposes, not the one tool the string named and not an error explaining why.

The same collapse recurs one level deeper, inside the documented filter object itself. extract_from_filter_object reads tool_names by asking whether the object has that key and whether its value is a JSON array, and reads read_only by asking whether the object has that key and whether its value is a JSON boolean; either check failing for either member, this record confirmed directly, produces none for that member, the same value the member being entirely absent produces. A filter object of the shape tool_names: "safe", written with the same array forgetting mistake one level in, or read_only: "true", written as a string rather than a boolean, is not rejected as an invalid filter object. It is read as a filter object that happens to specify neither a name restriction nor a read only restriction, which apply_allowed_tools_filter, this record separately confirmed, then treats as no restriction at all.

What this record ran, and what it found

This record copied extract_allowed_tools, extract_from_filter_object, extract_string_list, the AllowedTools struct and its unrestricted and as_names methods, and apply_allowed_tools_filter into a standalone program, unaltered from the current default branch source, and compiled and ran that program directly, with no MCP server, production or otherwise, contacted at any point. The fixture supplied a deterministic, local tools/list result: one tool named safe carrying readOnlyHint: true, and one tool named destructive carrying readOnlyHint: false. Against an entry with no allowed_tools field, the program confirmed both tools resolve as eligible. Against an entry with allowed_tools set to the documented array ["safe"], the program confirmed only the one named tool resolves. Against an entry with allowed_tools set to the bare string "safe", the program confirmed both tools resolve as eligible, an outcome identical to the absent case rather than to the one name array case, even though a caller wrote something rather than nothing. Against a filter object of tool_names: "safe" and, separately, read_only: "true", each with the value given as the wrong JSON type, the program confirmed both tools resolve as eligible in each case as well.

Why the caller's own mistake does not survive to be rejected later

This record separately read rewrite_request_body and rewrite_tools_array on the same source. A resolved MCP entry, whatever AllowedTools value it produced, is always removed from the outgoing tools array and replaced by the concrete function tool definitions that value's own resolved and filtered tool set produced, confirmed directly by this record's own reading of the comment stating the reason, that leaving an unresolved credentialed entry in place would leak its authorization or headers to the inference backend. That reasoning is sound for the credential it protects, and this record does not dispute it. What it also does, as a side effect this record did confirm directly rather than infer, is remove the caller's own original allowed_tools value, malformed or not, from the request the inference backend actually receives. A downstream schema at that backend which might otherwise reject a string where an array or object was expected never sees the string, because nothing shaped like allowed_tools remains in the request Praxis Proxy forwards; what remains is a set of function tool definitions computed from the unrestricted interpretation the malformed value already produced.

What the issue does not yet state, and what this record does not claim

This record does not claim a model has called, or would call, the destructive tool this record's own fixture named; what this record's own execution establishes is that the tool becomes eligible for selection, which is a distinct fact from an eventual call. This record does not claim every malformed JSON request becomes unrestricted; the scope confirmed here is the allowed_tools field specifically, parsed through the two branches and the one fallback this record read and ran directly. This record does not claim an absent allowed_tools field is itself a defect; the resolver's own documented behaviour for that case, exposing every tool, is a legitimate default this record does not dispute, and the gap this record does state is that a present but invalid restriction reaches that identical default rather than a distinct, rejected one. This record found no merged commit closing this gap on the repository's current default branch, and the exact commit the issue's own review cites, 3cfddbcbf4d6adcab02d8257bbdb272981c433c7, resolves to no such commit against the repository as read directly by this record, a discrepancy this record states as unresolved rather than as evidence either way about when the review actually occurred. Issue 939 remains open at the time of this record, accepted into a stated milestone rather than closed by a shipped fix.

The Authority Provenance ledger

Raw restriction supplied. A present, non empty allowed_tools value that is neither a JSON array nor a JSON object, such as the bare string safe, or a filter object carrying a tool_names or read_only member of the wrong JSON type.

Parser disposition. Not absent, and not valid against either documented shape, confirmed directly by this record's own reading of extract_allowed_tools's three branches and its own running of that exact function against each malformed shape.

Normalized authority. AllowedTools::unrestricted(), the identical value the same function returns for a genuinely absent field, confirmed directly by this record's own side by side run of both cases against the same fixture.

Discovered capability set. Whatever the MCP server's own tools/list returns; in this record's own fixture, one read only tool and one non read only tool.

Rewritten execution surface. Concrete OpenAI Responses API function tool definitions for every discovered tool, confirmed directly by this record's own reading of apply_allowed_tools_filter returning the fixture's full tool set unfiltered whenever the parsed AllowedTools carries no name list and no read only flag, exactly the value both the absent and the malformed cases produce.

Downstream validation. Not established either way for a specific backend by this record; what this record did confirm directly is that the caller's own original, malformed allowed_tools value does not reach that backend at all, because rewrite_request_body always removes the resolved MCP entry that carried it.

Final effective authority. Every tool the server exposes, selectable by the model, identically to what an entry with no allowed_tools field at all would produce, confirmed directly by this record's own fixture run.

Tracking state. Issue 939 open, human reviewed, triage accepted, targeted at milestone v0.4.0, with no merged fix found on the current default branch as of this record.

Exploitation. Unknown. Nothing available to this record states this has been exploited against a production Praxis Proxy deployment, and this record does not infer exploitation from the mechanism alone.

Provenance evidence quality. Strong for the mechanism itself: this record read extract_allowed_tools, extract_from_filter_object, apply_allowed_tools_filter, rewrite_request_body and rewrite_tools_array directly on the repository's current default branch, then copied those exact functions unaltered into a standalone program and ran them against a deterministic local fixture with no MCP server, live or otherwise, involved. Weaker for the issue's own cited review commit, 3cfddbcbf4d6adcab02d8257bbdb272981c433c7, which this record could not resolve against the repository's history through the GitHub interface, a gap stated here rather than filled by assumption.

Where this sits in the pattern

Postgres MCP Pro's own restricted mode already showed a working, correctly written check silently treating a syntax tree container it never opened as though it carried nothing requiring a check, rather than denying by default, and git-mcp-server's own validateGitArgs showed an executor level check that recognized a value it could not safely classify and chose, in its own words, to allow it anyway rather than reject it. Praxis Proxy's own extract_allowed_tools sits closer to the second of those than the first: nothing here is unreached or unopened, the function runs, inspects the value, and finds it does not match either documented shape. The gap is what happens next, a fallback branch built to handle absence pressed into service for invalidity as well, so that a caller's own present but malformed attempt to restrict something ends up indistinguishable, in the only place that matters, an eligible tool list, from a caller who never tried to restrict anything at all. This desk's own wider argument that execution authority has to be evaluated against what a system actually resolves an action to, not assumed from an earlier, adjacent field a request happened to carry, extends here to a restriction a caller wrote in good faith and a resolver silently declined to apply.

What this record does not establish

This record does not claim praxis-proxy/ai has been exploited, that every malformed JSON shape reaching the resolver produces the same collapse, that the model in any real deployment has selected or called a tool an operator meant to exclude, that a formally assigned CVE or advisory covers this issue, or that the exact commit the issue's own review names remains discoverable through means this record did not try. Where the evidence available to this record does not establish a fact, this record states it as unknown rather than inferring it from the mechanism's own shape.

Sources

This analysis interprets third-party reporting, research and announcements. Moona is not the original reporter of the underlying events.

[1]
Malformed MCP allowed_tools restrictions fail open to all resolved tools
praxis-proxy/ai (GitHub Issues) · 7 September 2026 · Primary source
[3]
praxis-proxy/ai repository (current default branch head)
praxis-proxy/ai (GitHub, repository) · Primary source

Protocol evidence

This record does not assess these architectures. The connection runs through the Risk Registry requirement each one bears on, and these published authority architectures are what the evidence says about that requirement.

Protocol evidence related through AEW-032 A malformed authority restriction is parsed the same as no restriction at all

  • Supports requirement

    Agent Control Standard (ACS)

    OWASP GenAI Security Project, originally Zenity

    Requirement The specification does not guarantee complete mediation of every consequential action a host can take

    ACS's own conformance document states directly that the specification does not guarantee complete mediation of every consequential action a host can take. Praxis Proxy's own resolver is concrete evidence for a narrower version of the same admission: a mediation point, extract_allowed_tools, is present, is reached on every request, and correctly recognizes both documented restriction shapes, yet still does not mediate a value that matches neither shape, because its own fallback branch for that case is the identical branch written for the restriction never having been attempted at all. This entry's own execution of the real function against a local fixture supports the requirement that a mediation point's own coverage be verified against every value a caller can supply, not only the shapes its own author documented.

    This record is the cited evidence for this relationship.

    View protocol evidence

  • Supports requirement

    Cedulon, an audit layer for agent to agent commerce

    E. C. Dogru, individual submission to the IETF

    Requirement The policy decision point fails closed

    Cedulon's own specification requires that a policy decision point resolve to deny when it is unreachable, uninitialized or failing, so a failure inside the check itself cannot quietly relax the limit it exists to enforce. Praxis Proxy's own extract_allowed_tools shows the same principle unmet one layer earlier, at parsing rather than at decision time: the function does not fail or become unreachable, it runs to completion and reaches a branch built for absence whenever it cannot classify the value it was given, confirmed directly by this entry's own reading and execution of the source. This is evidence for why a fail closed default belongs at every point an authority bearing value can go uninterpreted, parsing included, not only at the decision point the draft's own text names.

    This record is the cited evidence for this relationship.

    View protocol evidence

  • Supports requirement

    Verifiable Attenuated Delegation for AI Agent Chains (draft-asor-wimse-agent-delegation-chain)

    Rafael Asor, Attenu

    Requirement A verifier encountering an unrecognized constraint type must deny rather than treat it as unconstrained

    This draft's own verified requirement is that a verifier encountering an unrecognized constraint type must deny rather than treat it as unconstrained, so that a newer, unrecognized restriction is never silently read as no restriction at all. Praxis Proxy's own extract_allowed_tools shows the same failure at a different boundary, an OpenAI Responses API MCP tool entry's own allowed_tools field rather than a delegation token's own constraint array: a present value matching neither documented shape is not denied, it reaches the identical AllowedTools::unrestricted() the function returns for the field's own absence, confirmed directly by this entry's own reading and execution of the real, unmodified function against a deterministic local fixture. This is direct evidence for why the draft's own fail closed requirement matters as a general parsing discipline for any authority bearing field, not only for one draft's own constraint objects.

    This record is the cited evidence for this relationship.

    View protocol evidence

Related Intelligence

All Intelligence Records →