Skip to content

fix: scope MCP and the ledger listing by token claims; MCP follows data auth - #1963

Merged
bplatz merged 6 commits into
mainfrom
fix/mcp-ledgers-auth
Sep 29, 2026
Merged

bplatz merged 6 commits into
mainfrom
fix/mcp-ledgers-auth

Conversation

@bplatz

@bplatz bplatz commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

MCP tools reached every ledger

The MCP endpoint admitted any token signed by a trusted issuer and then served every ledger:

  • get_data_model returned any ledger's schema, class counts and statistics with no scope or policy check.
  • sparql_query ran unrestricted whenever the token carried no identity.

Both tools now authorize the requested ledger against the token's fluree.ledger.read.all / fluree.ledger.read.ledgers claims (falling back to fluree.storage.*). These are the same claims, with the same parsing, the data API uses, and a bare mydb scope means mydb:main. A ledger the token doesn't cover gets the same "Ledger not found" as one that doesn't exist.

Each tool parses its ledger argument once, checks scope on the parsed id, and loads the address rebuilt from it, so the scope check and the load always see the same ledger. A @ time suffix is refused up front (sparql_query takes t for that), and get_data_model refuses a # graph selector.

MCP follows the server's data auth

Before, /mcp always required a signed token, even on a server whose /query was open. Trying MCP meant generating a key, trusting it and minting a token first. With the scoping above, a token without read claims would also have reached nothing.

Now, with data auth none (the default) and no MCP issuer configured, /mcp needs no token. Every ledger is readable, and a token sent anyway is ignored, as the data API ignores it. --mcp-enabled alone is enough to try MCP locally. Tokens are still required whenever an MCP issuer is configured (or the events-issuer fallback applies), whatever the data auth mode, and data auth optional/required still refuses to start without an MCP issuer.

Anonymous MCP queries skipped the ledger's policy defaults

An MCP query without an identity loaded the ledger through the graph builder, which applies no policy. A ledger configured f:defaultAllow false denied an anonymous /query but answered the same SPARQL over MCP. This was already reachable with a token carrying no sub or identity, and tokenless mode would have made it the default path. Identity-less sparql_query calls now get the ledger's configured policy defaults, like an anonymous /query.

get_data_model is not policy-filtered: it still reports schema and counts for any ledger the caller can address, as GET /info does. A test pins that. Follow-up: #1981 (whether either should be governed by policy).

GET /ledgers had no auth

It listed every ledger and graph source even with data auth required. It now follows data auth like /info and /exists:

  • A Bearer token is required when data auth is.
  • A request carrying a token sees only the ledgers and graph sources that token can read.
  • Unauthenticated listing on an open (None/Optional) server is unchanged.

/v1/fluree/events is not covered. It has its own events auth, and with data auth required and events auth off, /events?all=true still lists every ledger. The two stay separate because query peers subscribe to /events, and without events auth they do so with no token, so falling back to data auth would lock them out. Instead, the server logs a warning at startup in that configuration, and the configuration reference and MCP guide say to set both.

Setting-groups docs

The setting-groups page still said a request without policy inputs is never enforced and that f:defaultAllow only applies to requests carrying policy inputs. Both are corrected. (This PR also fixed the graph-scoped builder's governed-source fallback, but #1935 landed the same fix on main first, so after the rebase only the docs change remains.)

Behavior changes

  • MCP tokens need ledger claims. A deployment that relied on issuer trust alone must issue "fluree.ledger.read.all": true for an agent that should read every ledger. A token with no ledger claims now reaches none.
  • GET /ledgers requires a Bearer when data auth is required, and filters the listing to the token's scope. /events is configured separately (see above).
  • Under data auth optional, /mcp requires a token while an anonymous /query is still served.
  • An MCP ledger argument with a @ time suffix is refused rather than stripped before the scope check.
  • --mcp-enabled no longer needs an issuer on a server without data auth. Such a server now serves /mcp tokenless instead of refusing to start. Servers that configure an MCP issuer keep requiring tokens.

Docs

New guide, docs/ai/mcp-server.md: try MCP locally with one flag, connect common clients, see when a token is required, then harden step by step (server-wide data auth, per-agent ledger scopes, identity-based policy, production notes). The configuration, authentication and token references link to it.

Testing

  • End-to-end MCP test over the streamable HTTP transport (initialize, then tools/call): a scoped token reaches its ledger, is refused another, and a token from the same trusted issuer with no ledger claims is refused both, for both tools.
  • /ledgers: unauthenticated request → 401 under required data auth; a scoped token lists only its ledger.
  • Tokenless MCP: both tools reach every ledger with no token and with an ignored token; a configured MCP issuer still returns 401 without a token; unit tests cover the token-required matrix and startup validation.
  • Anonymous MCP vs /query on a ledger configured f:defaultAllow false: both return no rows; get_data_model still reports the ledger's counts, pinned (/info and MCP get_data_model describe a ledger whose policy defaults deny the caller #1981).
  • The MCP ledger argument (unit): urn:fluree:open#txn-meta is checked as open:main and loaded as open:main#txn-meta; open@t:1 and an empty name are refused before the scope check.
  • The startup warning's condition: data auth required with events auth none, and no other combination.
  • The documented quickstart and hardened setup were run against a real server via fluree server run, including the curl session snippet under bash and zsh; mdbook build docs succeeds.
  • Each test was checked to fail with its fix removed.
  • Server suite, affected API suites, workspace clippy (native and wasm32) and fmt are clean.

@bplatz bplatz added bug Something isn't working as expected breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section labels Sep 26, 2026
@bplatz
bplatz requested review from aaj3f and zonotope September 26, 2026 15:21
@bplatz
bplatz added this pull request to stack #1964 September 26, 2026 15:21
@bplatz bplatz changed the title fix: scope MCP tools and the ledger listing by token claims fix: scope MCP and the ledger listing by token claims; MCP follows data auth Sep 26, 2026

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bplatz this is nice and I almost said "obvious" next step to the various policy & auth-guard work other recent PRs have done across the repo, but I won't say "obvious" because, when reviewing those PRs, I didn't even think to consider if the MCP paths were covered by this. In any event, glad to have this. I think the only headline finding is that there are some items that slipped through the cracks re: ledger enumeration and data model (they also need their auth scoping re-applied). Details below:


This is a real hole closed properly, and I went at the authorization work hard and could not break it.** The thing I most wanted to be true is true: the MCP principal follows the calling request, not the session. I opened a session with a token scoped to open, then issued tools/call on that same session with a token scoped only to closed and asked for open — Ledger not found. A no-token call on the same authenticated session is a 401. Given that rmcp sessions live across requests, that was the failure mode I expected to find and it isn't there.

Two more things I checked rather than assumed. sparql_query cannot reach another ledger through FROM — FROM <closed>, FROM <closed:main>, FROM NAMED <closed:main>, FROM <fluree:closed:main> all hit cross_ledger_dataset_error, and GRAPH <closed:main> {…} returns nothing; a secret planted in closed never surfaced. That's structural (the tool builds a single-ledger GraphDb, which rejects dataset clauses) rather than incidental, which is the good kind. And no spelling gets past the gate: closed, closed:main, urn:fluree:closed:main, closed@t:1, closed:main@t:1, closed:main#g, CLOSED, "closed ", " closed" are all Ledger not found, while open, open:main and urn:fluree:open are all allowed. Returning the same answer for unauthorized and nonexistent is the right call and it holds.

Reusing read_scopes between the data API and MCP rather than inventing an MCP scope model is the design decision I'd most defend here — it's what makes "a bare mydb means mydb:main" true on both surfaces by construction instead of by discipline.

The one substantive note is that /ledgers isn't the only enumeration point. GET /v1/fluree/events?all=true builds its snapshot from the same all_records() / all_graph_source_records(), and it's gated by events_auth, which defaults to None and is untouched by --data-auth-mode required. On one server with data auth required I get a 401 from /ledgers and a 200 from /events?all=true listing every ledger with full nameservice records. Your PR doesn't introduce that, and filter_to_allowed is correct once events auth is on — but the Behavior-changes line "filters the listing to the token's scope" will read to an operator as a property of the deployment rather than of one route. Either have /events fall back to data auth the way /ledgers now does, or say in the hardening section that the two axes are independent and both need setting.

Smaller: get_data_model gets the scope check but no policy wrap, so on an f:defaultAllow false ledger an anonymous sparql_query correctly returns 0 rows while get_data_model still reports classes, properties and flake counts (fluree-db-server/src/mcp/tools.rs:307). The HTTP /info twin does exactly the same — I checked — so this is consistent rather than a regression, but the sentence "Identity-less MCP queries now get the ledger's configured policy defaults, like every other anonymous read" is broader than what shipped, and tokenless_mcp_applies_the_ledgers_policy_defaults only covers the query tool. And token_required treating Optional as token-required makes MCP stricter than /query on that server — right call, worth a row in the configuration table so the 401 isn't a surprise.

Last thing, no anchor for it: this sits on #1958, which I'm requesting changes on for two verified blocking findings, and #1958 is currently DIRTY against main. If those take a while, the MCP fix here is the most urgent thing in the stack — an unscoped /mcp on a deployed multi-tenant server is a live cross-tenant read — so it may be worth knowing what it would cost to rebase this onto main independently. It needs #1958's typed scopes (read_scopes returning HashSet<LedgerId>), so it isn't free, but it's worth pricing before assuming the stack order.

Adherence to repo commitments

  • Patterns / abstractions — ✔ Shares read_scopes with the data API rather than adding an MCP scope model, and routes the anonymous MCP read through the same wrap_policy_defaults every other anonymous read uses. No parallel construct.
  • Performance (speed first, memory second) — ✔ No performance-degradation risk. One LedgerRef::parse plus a HashSet<LedgerId> lookup per tool call, ahead of planning and executing a SPARQL query; one closure call per record on /ledgers. The added wrap_policy_defaults on the graph-source fallback is per-query setup, and it's the same work the from-driven builder already did.
  • Testing — ✔ ledger_scope_auth.rs is wired into grp_policy (tests/grp_policy.rs:5-6, with autotests = false), the MCP tests drive the real streamable-HTTP transport end to end rather than calling the handler directly, and the tokenless/token-required matrix is covered at both the unit and integration level. The gap is get_data_model under policy defaults (MEDIUM-2).
  • Conventions — ✔ Five commits, one per sub-fix, each self-describing; clippy/fmt green in CI; and unusually, the docs land with the code — a 277-line MCP guide plus a correction to setting-groups.md text that described the old, wrong policy behaviour.

///
/// Follows data auth like `/info` and `/exists`: a bearer is required when
/// data auth is, and a request carrying one sees only what its token can read.
pub async fn list_ledgers(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH-1 — /events?all=true is the same enumeration hole /ledgers just had, and it isn't closed

(in-diff range 578-591).

The PR's premise for this route is "It listed every ledger and graph source even with data auth required." That sentence is still true, verbatim, of GET /v1/fluree/events?all=true — build_initial_snapshot (routes/events.rs:344-362) walks the same all_records() and all_graph_source_records() and emits an ns-record event per ledger.

The reason it isn't covered is that /events is gated by events_auth, a separate config axis. EventsAuthMode defaults to None (config.rs:41-44) and --data-auth-mode required does not touch it, so the two ends up disagreeing on the same server. I ran both against one instance with data_auth_mode: Required:

GET /v1/fluree/ledgers            (no token)  -> 401 {"error":"Bearer token required"}
GET /v1/fluree/ledgers            (alpha tok) -> 200 [{"name":"alpha",...}]          # only alpha
GET /v1/fluree/events?all=true    (no token)  -> 200
event: ns-record  data: {"resource_id":"alpha:main","record":{...}}
event: ns-record  data: {"resource_id":"beta:main","record":{...}}

To be clear on attribution: this PR doesn't introduce it, and filter_to_allowed (events.rs:477-518) is correct on its own terms — it expands all=true down to the token's own set rather than passing it through, which is the right fail-closed shape once events auth is on. The problem is only that an operator who turns on data auth reasonably believes they have turned on ledger enumeration control, and the Behavior-changes section of this PR will reinforce that belief.

I'd rather see this folded in than tracked — either have /events consult data_auth when events_auth is None (which matches what this PR did for /ledgers), or, if the two axes are deliberately independent, say so in docs/ai/mcp-server.md's hardening section and in the Behavior-changes note, so "filters the listing to the token's scope" doesn't read as a property of the deployment.

Happy to be told the axes are deliberately separate — but then the doc line is the fix, and I don't think it should ship silent either way.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it shouldn't ship silent. I went with the doc route plus a warning, not the data-auth fallback, and it's in 022af76.

Why not the fallback: query peers subscribe to /events, and with events auth off they connect without a token (--peer-events-token is optional). Their tokens also carry events claims and are checked against the events issuers, not the data ones. So falling back to data auth would lock existing peers out of any server that has data auth required.

What's in instead:

  • The server logs a warning at startup when data auth is required and events auth is none, saying /events lists every ledger and its nameservice record to anyone. ServerConfig::events_open_under_data_auth has a unit test covering which combinations trigger it.
  • configuration.md says in both the data-auth and events-auth sections that the two are independent. The MCP guide's hardening section tells you to set --events-auth-mode required and to give query peers a --peer-events-token.
  • In the PR body, "filters the listing" is now scoped to /ledgers, and there's a paragraph on why /events stays separate.

Comment thread fluree-db-server/src/mcp/tools.rs Outdated
context: rmcp::service::RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let start = std::time::Instant::now();
if authorize(&context, &req.ledger).is_none() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM-2 — get_data_model gets the scope check but not the policy layer

(in-diff range 301-312).

authorize() runs and then ledger_info(&req.ledger).execute() goes straight to the ledger with no wrap_policy_defaults and no identity. So on a ledger configured f:defaultAllow false, using the PR's own fixture:

sparql_query   (anonymous) -> rowCount 0        # the fix in this PR, working
get_data_model (anonymous) -> "## Dataset Statistics — Classes: 0, Total instances: 0,
                             Properties: 2, Triples (flakes): 2"

Your code didn't introduce this and the HTTP twin does the same thing — GET /v1/fluree/info/gov on that same locked ledger returns the full property list (http://example.org/name), the named-graph inventory including #config and #txn-meta, the commit id and the nameservice record. So get_data_model is consistent with the existing surface, which is a reasonable place to land.

What I'd change is the claim rather than the code. "Identity-less MCP queries now get the ledger's configured policy defaults, like every other anonymous read" reads as covering the endpoint, and tokenless_mcp_applies_the_ledgers_policy_defaults only exercises sparql_query. Narrowing that sentence to the query tool, and adding a get_data_model case to the test asserting the current behaviour, would pin down what's intended so the next person doesn't have to work it out from the diff.

(If you think schema-and-counts should in fact be policy-gated, that's a bigger call than this PR and touches /info too — that's the one thing here I'd genuinely leave to a separate decision rather than fold in.)


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did it the way you suggested (022af76):

  • The PR body now says identity-less sparql_query calls get the policy defaults, and that get_data_model isn't policy-filtered, the same as GET /info.
  • tokenless_mcp_applies_the_ledgers_policy_defaults now also calls get_data_model on the deny-by-default ledger and pins what it returns today (Triples (flakes): 2).
  • Whether schema and counts should be policy-gated is filed as /info and MCP get_data_model describe a ledger whose policy defaults deny the caller #1981 (needs-decision), covering /info and get_data_model together. The test comment and the PR body point to it.

/// Whether `/mcp` requests must carry a token. Without data auth and without
/// any MCP issuer configured, `/mcp` is as open as `/query` on the same
/// server; configuring an issuer turns tokens on regardless of data auth.
pub fn token_required(&self, events_auth: &EventsAuthConfig) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM-3 — token_required makes MCP stricter than /query under Optional, which is right but undocumented

(in-diff range 263-293).

data_auth_mode != DataAuthMode::None puts Optional on the token-required side. So on a server running --data-auth-mode optional, an anonymous /query is served and an anonymous /mcp is a 401 — and the token that satisfies it then also needs ledger claims or it reaches nothing.

That's the safe direction and I'd have made the same call. But Optional exists precisely for the "accept tokens, don't require them" posture, and someone in that posture will hit an MCP 401 with no obvious cause. The docs/operations/configuration.md block added here covers none and "an MCP issuer is configured"; a row for optional would close it.

This is minor and non-blocking — but if you agree it's right, I'd rather see the line added now than lost in the backlog.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added (022af76). configuration.md now says that under optional an anonymous /query is served and an anonymous /mcp call is a 401. The MCP guide's token table row for optional/required says the same.

@@ -29,6 +29,20 @@ fn extract_principal(context: &rmcp::service::RequestContext<RoleServer>) -> Opt
.cloned()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT-4 — MCP accepts #fragment and @pin spellings the HTTP route rejects

(in-diff range 29-48).

authorize() runs the ledger through scope_id, which is LedgerRef::parse(raw)?.id — it strips urn:fluree:, a @pin and a #fragment before checking the scope. The tool then hands the raw string to the loader, which uses the stricter LedgerId::parse. So:

ledger "open@t:1" -> authorized, then "SPARQL query error: Invalid ledger id 'open@t:1':
                   ledger name cannot contain '@'"
ledger "open#g"   -> authorized, then "Query error: Unknown named graph '#g'"

versus POST /v1/fluree/query/gov%23config, which is a 500 at the route.

Nothing leaks — every closed* spelling I tried returned Ledger not found, so the gate is the conservative of the two parsers — and #1961 already tracks the db()/graph() half of this divergence. I mention it only because MCP is now a third parser pair in the same family, and it's the sort of thing that's cheap to make consistent while the area is open.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made consistent (022af76). Each tool now parses ledger once with LedgerRef, checks scope on that parsed id, and loads the address rebuilt from it, so the gate and the loader can't disagree.

  • A @ time suffix is refused before the scope check, with a message pointing at t.
  • get_data_model refuses a # graph selector.
  • urn:fluree: and #txn-meta still work for sparql_query, because the loader accepts them.

I didn't use the strict LedgerId::parse here, because it would have rejected those two spellings. Unit test: a_ledger_argument_is_parsed_once_for_scope_and_load.

@@ -17,9 +17,9 @@ When no config graph is present (or a setting group is absent), the system defau
| Transact constraints | Disabled — no uniqueness enforcement |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ NIT-5 — nice catch on the setting-groups.md correction

(in-diff range 17-25).

Not a finding — just want it on the record that changing "Policy … is switched on by the request, not by the ledger" to "by the ledger's configuration or by the request", and the f:defaultAllow row from "Only consulted for requests that carry policy inputs" to "Applies to requests with and without policy inputs", is the part of this PR most likely to save someone a bad afternoon. Documentation that described a security property incorrectly is worse than none, and this was that.


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. That correction is the one piece of 160f098ed that survived the rebase: #1935 had already landed the same graph-builder fix on main, so that commit is now docs-only.

Base automatically changed from fix/ledger-id-normalization to main September 29, 2026 01:34
MCP admitted any token from a trusted issuer and then served every
ledger: `get_data_model` returned any ledger's schema and statistics with
no scope or policy check, and `sparql_query` ran unrestricted when the
token carried no identity. Both tools now authorize the requested ledger
against the token's `fluree.ledger.read.*` claims (falling back to
`fluree.storage.*`), the same claims and parsing the data API uses; an
uncovered ledger gets the same answer as a missing one.

`GET /ledgers` had no auth and listed every ledger even when data auth
is required. It now follows data auth like `/info` and `/exists`: a
Bearer is required when data auth is, and a token sees only the ledgers
and graph sources it can read.

Tokens that relied on issuer trust alone for MCP need
`"fluree.ledger.read.all": true` to keep reading every ledger.
The setting-groups page still said a request with no policy inputs is
never enforced and that `f:defaultAllow` applies only to requests that
carry them. A ledger's configured policy defaults govern those requests
too: `f:defaultAllow false` denies them.
On a server with data auth `none` and no MCP issuer configured, `/mcp`
is now as open as `/query` next to it: no token needed, every ledger
readable, and a presented token ignored as the data API ignores it.
`--mcp-enabled` alone is enough to try MCP locally. Configuring an MCP
issuer (or the events-issuer fallback) still requires tokens whatever
the data auth mode, and data auth `optional`/`required` still requires
an MCP issuer at startup.

An MCP query without an identity also skipped the ledger's configured
policy defaults: it loaded the ledger through the graph builder, which
applies none, so a ledger configured `f:defaultAllow false` denied an
anonymous `/query` but answered the same SPARQL over MCP. That path was
already reachable with a token carrying no `sub` or identity, and
tokenless mode would have made it the default. Identity-less queries
now go through the same policy-defaults wrap as every other anonymous
read.
Covers trying `/mcp` locally with one flag, connecting common MCP
clients, when the endpoint requires a token, and hardening it step by
step: server-wide data auth, per-agent ledger scopes, identity-based
policy, and production notes. The configuration, authentication and
token references now describe the token requirement and link to it.
The startup error for MCP with data auth and no trusted issuer, and the
MCP guide, both pointed at `--mcp-auth-insecure`, which does not exist.
The flag is `--mcp-auth-insecure-accept-any-issuer`; only its env var is
the short `FLUREE_MCP_AUTH_INSECURE`, which the guide now also names.
- The MCP tools parse `ledger` once with `LedgerRef`, check scope on the
  parsed id and load the address rebuilt from it. A `@` time suffix is
  refused up front (sparql_query takes `t`), and get_data_model refuses a
  `#` graph selector, instead of passing the scope check and then failing
  in the loader.
- The server warns at startup when data auth is required and events auth
  is off: `/events?all=true` then lists every ledger to anyone. The two
  stay separate because query peers subscribe to `/events`, without a
  token when events auth is off.
- Docs: events auth is independent of data auth, and data auth `optional`
  makes `/mcp` stricter than `/query`.
- A test pins that get_data_model reports counts on a ledger whose policy
  defaults deny the caller, as `/info` does (#1981).
@bplatz
bplatz force-pushed the fix/mcp-ledgers-auth branch from 83658be to 022af76 Compare September 29, 2026 01:49
@bplatz

bplatz commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 022af76; each inline thread has its own reply. On your last point about the stack: #1958 has merged, so this is rebased onto main and the conflicts are gone.

One thing the rebase turned up: #1935 had already landed the same graph-scoped builder fix as this PR's 160f098ed, with a near-identical test. I kept main's version, so that commit is now just the setting-groups.md correction, and the PR body says so.

@bplatz
bplatz merged commit 07c1112 into main Sep 29, 2026
16 checks passed
@bplatz
bplatz deleted the fix/mcp-ledgers-auth branch September 29, 2026 01:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Backwards-incompatible change; drives the Breaking Changes release-notes section bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants