Skip to content

Decide agent access per endpoint, not per HTTP method - #96

Merged
MathiasWP merged 2 commits into
mainfrom
share-with-agents-method-gating
Sep 4, 2026
Merged

Decide agent access per endpoint, not per HTTP method#96
MathiasWP merged 2 commits into
mainfrom
share-with-agents-method-gating

Conversation

@MathiasWP

Copy link
Copy Markdown
Owner

Why

The switch beside Share with agents assumes GET means read and POST means write. For an API where every operation is a POST that switch is useless: off, and the collection can't be used; on, and there is no guard left at all.

What

A collection can carry an access policy — a jq filter answering "allow", "ask" or "deny" per endpoint:

if   .meta["x-kind"] == "query"   then "allow"
elif .meta["x-kind"] == "command" then "ask"
else "deny" end

Every scalar x- extension on an OpenAPI operation now comes through the loader into meta, so a rule reads the API's own vocabulary. Fiber knows nothing about x-kind, or any other key — the filter is the single place meaning is attached, and it is one a person can read and change.

jq for the reasons loaders already use it: it cannot do anything but transform, it re-runs instantly against the real endpoint list, and a second matching language invented here would buy nothing.

"ask" goes through MCP elicitation, to the client the user is already sitting in when an agent is working. Only accept sends. Ten minutes, then refused. At most 8 prompts wait at once.

Everything fails closed. A filter that doesn't compile, throws, fans out, or answers something other than the three words denies the whole collection and says why. A path the catalogue doesn't list is decided with no metadata, so it cannot inherit a listed endpoint's permission — POST /orders/42/refund is not POST /orders/{id}.

Nothing migrates. An empty policy keeps the old switch exactly. A policy replaces it outright while set, GET included, so a read that returns the whole customer table can say so.

The one that took a spike to find

Headless Claude Code declares "elicitation": {} at initialize and then answers cancel in ~5ms without showing anyone anything. Safe — it cancels rather than accepts — but it is not an approval, and reporting it as "denied" would send an agent looking for another way round. A cancel too fast to have been read now says nobody was asked, and names the client that did it.

Verification

Beyond 160 Rust tests and 338 e2e, the gate was driven end to end against the built fiber mcp binary with a small MCP client that answers elicitations on command:

GET  /ping             -> deny     (typed request: no metadata)
POST /customers/search -> allow    → sent
POST /orders           -> ask      → elicitation raised; accept sends, cancel refuses
POST /events           -> deny

Notes

  • The editor's preview and the server's answer are one shared function over one shared catalogue, so they cannot disagree about an endpoint.
  • Fiber asking for approval in its own window — for clients that cannot prompt — is deliberately left out of this PR.
  • Hand-written requests carry no metadata, so with a policy set they land in its else. The shipped By x-kind template gives them their own branch; worth a look when reviewing that default.

The switch beside "share with agents" assumes GET means read and POST
means write. For an API where every operation is a POST it is useless:
off, and the collection can't be used; on, and there is no guard left.

A collection can now carry an access policy instead — a jq filter
answering "allow", "ask" or "deny" per endpoint, run against what the
manifest already publishes. Every scalar `x-` extension on an OpenAPI
operation comes through the loader into `meta`, so a rule reads the
API's own vocabulary; Fiber knows nothing about `x-kind` or any other
key, and the filter is the one place meaning is attached.

"ask" puts the call in front of a person through MCP elicitation, in
the client they are already sitting in, and sends it only on accept.
Ten minutes, then it is refused. A client with no way to prompt often
declares the capability and cancels instantly anyway, so a cancel that
arrives too fast to have been read says nobody was asked rather than
"denied" — the difference decides whether an agent retries elsewhere
or gives up.

Everything fails closed. A filter that doesn't compile, throws, fans
out or answers something other than the three words denies the whole
collection, and a path the catalogue doesn't list is decided with no
metadata rather than inheriting a listed endpoint's permission.

The editor's preview and the server's answer are one shared function
over one shared catalogue, so the two cannot disagree about an
endpoint. Collections with no policy keep the old switch, untouched.
…hod-gating

# Conflicts:
#	src-tauri/src/mcp.rs
@MathiasWP
MathiasWP merged commit f5a32b5 into main Sep 4, 2026
4 checks passed
@MathiasWP
MathiasWP deleted the share-with-agents-method-gating branch September 4, 2026 13:05
@github-actions github-actions Bot mentioned this pull request Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant