Skip to content

feat(plugin-mcp): the endpoint knows which caller it is serving - #1850

Merged
mobeenabdullah merged 1 commit into
fix/mcp-endpoint-follow-upsfrom
feat/the-endpoint-knows-who-is-asking
Sep 13, 2026
Merged

mobeenabdullah merged 1 commit into
fix/mcp-endpoint-follow-upsfrom
feat/the-endpoint-knows-who-is-asking

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Phase 5.4 of the AI-native programme: the auth proof matrix, and the seam it proves something about.

Stacked on #1848. Its base is fix/mcp-endpoint-follow-ups, not main, because the integration lane this suite runs in arrives with that PR. Branching off main would have added vitest.integration.config.ts and the test:integration script a second time, which is an added-by-both conflict on exactly the files only these two branches touch.

Two consequences worth stating rather than discovering later:

  • ci.yml, integration.yml, secret-scan.yml and preview.yml are all declared pull_request: branches: [main], so none of them runs while the base is a feature branch. The green checks visible here are the pull_request_target pair and any path-filtered build, which is not a CI verdict. Retarget after fix(plugin-mcp): say what the package does, and test the endpoint as it is served #1848 merges, then push an empty commit, because retargeting alone emits edited and nothing subscribes to it.
  • Everything below was measured locally instead: full unit suite, the integration lane, typecheck, lint, the changeset check with a control, and four mutations.

What was wrong

A plugin route handler is called with (req, ctx). The endpoint declared only request, so the services facade and the authenticated user were discarded before the protocol layer could see them.

That costs nothing today, because the endpoint exposes no tools. It would cost a great deal at 5.5: the first tool would read and write with no user attached, and nothing about the endpoint's behaviour would look wrong.

What changed

The route context now travels as itself, scoped to the request. AsyncLocalStorage, for the reason auth/caller-scope.ts already gives in core: requests are served concurrently and a module variable hands one request's caller to another. The handler is built once on purpose, so the factory cannot close over a caller who did not exist when it was built.

A key's own grants are deliberately not carried here. They are already ambient: plugins/routes/dispatch.ts runs every handler inside runWithCallerScope, so a service call made anywhere in the request is judged on the grants stamped on the key rather than on the roles of whoever minted it. The library's own seam, authInfo, is an OAuth token with a flat scope list; flattening Nextly's model into it would answer the authorization question a second time, beside the routes the rest of the CMS is reached through, and the two would drift while both looked correct.

A request whose caller cannot be established is refused at construction rather than served a server built for nobody, so the guarantee is in place before the first tool depends on it.

The matrix

Driven through the real dispatcher with a real API key minted through apiKeyService, not a fixture standing in for authentication:

caller address answer
none foreign Host 401
unverifiable bearer allowed 401
real read-only key allowed 200
real read-only key foreign Host 403
real read-only key foreign Origin 403
real read-only key the site's own Origin 200
none foreign Host and Origin 401

The two 403 rows are the first time the address guard has been observable at all. #1837 recorded that an unauthenticated probe is answered before the guard runs, which is true and meant the guard had no test that reached it. A credential is what makes it reachable, and that is also the attack it exists for: a real session or key sent from a page that had no business sending it.

The last row pins the ordering as a case rather than a comment: both checks would refuse it, and the status a client sees says which one answered.

Evidence

  • unit 47 passed, integration 12 passed, typecheck and lint clean
  • check-changesets passes on the changeset, and a deliberately short control list fails it
  • four mutations, each restored and the tree diffed against the commit afterwards:
    • never opening the caller window: 6 unit and 2 integration red
    • the caller guard made fail-open: 3 unit red
    • the factory no longer asking who is calling: 1 unit red
    • the address guard removed: 5 unit and 2 integration red

The third of those survived the first round. Removing callerNow() from the factory broke nothing, because with the window always open the call has no observable effect, so the fail-closed precondition had mutation evidence and no standing test. buildServer is now exported for its own test, with a control proving the refusal is not blanket. Reported because a guard nobody has watched refuse is a guard nobody has seen work.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 10321ea6-6d12-4a8f-9374-b9990f0d3bcd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-13T10:26:31.839371Z edfccf9 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added type: docs Documentation only scope: plugin @nextlyhq/plugin-* packages labels Sep 13, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 2bf31468a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

A plugin route handler is called with the request AND the route context, and the
endpoint declared only the first. The services facade and the authenticated user
were discarded before the protocol layer could see them. That costs nothing
while the endpoint exposes no tools, and would cost a great deal the moment one
arrives: the tool would read and write with no user attached, and the defect
would be invisible because the endpoint answers correctly in every other way.

The context now travels as itself, scoped to the request with
`AsyncLocalStorage` for the reason `auth/caller-scope.ts` gives: requests are
served concurrently and a module variable hands one request's caller to another.
The handler is built once on purpose, so the factory cannot close over a caller
who did not exist when it was built, and the library's own seam for this is
`authInfo`, an OAuth token with a flat scope list.

A key's own grants are deliberately NOT carried here. They are already ambient:
the plugin dispatcher runs every handler inside `runWithCallerScope`, so a
service call made anywhere in the request is judged on the grants stamped on the
key rather than on the roles of whoever minted it. Flattening that into
`authInfo.scopes` would answer the authorization question a second time, beside
the routes the rest of the CMS is reached through, and the two would drift while
both looked correct.

A request whose caller cannot be established is refused at construction rather
than served a server built for nobody, so the guarantee is in place before the
first tool depends on it.

The auth proof matrix comes with it, driven through the real dispatcher with a
real API key rather than a fixture standing in for one: an unauthenticated
caller is refused, an unverifiable credential is refused, a real key is served,
and the address guard is shown refusing that same key on a foreign Host and a
foreign Origin while serving it on the configured site's own. Those last cases
are the first time the guard has been observable at all, because an
unauthenticated probe is answered before it ever runs.
@mobeenabdullah
mobeenabdullah force-pushed the feat/the-endpoint-knows-who-is-asking branch from 2bf3146 to edfccf9 Compare September 13, 2026 10:20
@mobeenabdullah
mobeenabdullah merged commit ace2964 into fix/mcp-endpoint-follow-ups Sep 13, 2026
5 checks passed
mobeenabdullah added a commit that referenced this pull request Sep 13, 2026
…it is served (#1848)

* fix(plugin-mcp): say what the package does, and test the endpoint as it is served

Follow-up to #1837, which merged before this round's findings were worked.

## Two summaries said the opposite of what the package does

The npm description and the root package catalogue both still called it a
placeholder serving no endpoint. That is what a reader discovering the package
through the registry, or an agent reading the indexed root README, was told
about a package that now serves one. Both say what it is.

## `path` refused nothing and promised too much

A value that cannot address a single endpoint is refused where it is written
rather than surfacing later as a 404 somebody has to explain: a missing leading
slash, a trailing one, a `:param` pattern, or the mount itself. A plain error,
not `NextlyError` — that type's public messages are canonical because they shape
an HTTP response, and this fires while the developer's own config is being
evaluated, where the message IS the fix.

What it deliberately does NOT do is check whether core already serves the path.
Core keeps no list of reserved prefixes on purpose: its own answer coming first
IS the mechanism that stops a plugin claiming `/collections`, so a list here
would be a second one, drifting behind every route core adds. The option's
documentation now states the consequence instead, including that the shadowing
is per method — a path core serves for `GET` and not `DELETE` would send half
this endpoint's traffic elsewhere.

## The tests could not see what the dispatcher does

They call the route handler directly, which is right for the handler and wrong
for the endpoint: core authenticates BEFORE the handler runs, so a caller with
no credential never reaches the address guard at all and is answered `401` —
where the transport specification names `403`.

The package gains an integration lane and a suite that goes through
`createDynamicHandlers`, the dispatcher that actually serves it, and pins that
`401`. Two controls sit beside it, because "the endpoint refuses" is otherwise
equally satisfied by there being no endpoint: an install without the plugin
answers `400`, and so does one with the plugin off.

The gap is recorded rather than closed. The request is still refused, and by the
check that does not depend on the attacker's cooperation; the guard covers what
authentication cannot, which is a request carrying a real credential from a page
that had no business sending it. Making the route public would put the guard
first and answer `403` — and would mean authenticating here instead, which is
the one thing this endpoint exists not to do.

## Also

A browser request from the configured site is now asserted to be SERVED. Every
Origin case was a refusal, so none of them could tell a guard that refuses the
wrong origin from one that refuses every origin — which is what an allowlist in
the wrong shape produces. A second case covers an operator configuring a full
origin rather than a hostname, since the check compares hostnames and an entry
carrying a scheme would match nothing.

* fix(plugin-mcp): apply the matcher's own capture rule to the endpoint path

The path check refused every `:`, while the matcher it validates for treats only
a segment that BEGINS with `:` as a capture. A validation stricter than its
matcher refuses paths that would have worked: `/mcp:v1` addresses exactly one
URL and was rejected as a pattern. The rule is now applied per segment, and the
dispatcher test mounts such a path and shows it answering at that address and at
no other, so the claim is about the matcher rather than about this package.

The path check had no test at all, which is why the disagreement could exist.
Both the accepted and the refused shapes are now covered, including that the
refusal happens while the config is evaluated rather than as a 404 to explain.

`mcpPlugin`'s documentation was separated from the function by a helper defined
between them, so the emitted `dist/index.d.ts` declared the package's only
public export with no description and an editor showed nothing on hover. The
helper now sits above that block.

* fix(plugin-mcp): publish the route grammar, and ask it instead of restating it

Whether a path names one address or a family of them is the matcher's question.
The endpoint's own check answered it separately, and a separate predicate that
agrees today is still separate: a change to what a capture looks like reaches
the matcher and leaves the plugin refusing paths that route, or accepting
patterns that do not name one address.

`routePathIsLiteral` is that classification, derived from the same `isCapture`
the matcher applies and published through `@nextlyhq/plugin-sdk`, which is the
surface a plugin author is meant to import from. `isCapture` itself stays
private: a caller given it has to know the rule is per SEGMENT, and splitting on
the wrong thing is the divergence this exists to prevent.

The core-compat range stays at a released version rather than the one this ships
in, because core validates it against its own version at boot and a floor naming
an unreleased version refuses the plugin inside this repository. What makes the
lower bound sufficient is the release train: every published package versions in
lockstep, so the core an install receives beside this plugin is built from the
same commit.

* feat(plugin-mcp): the endpoint knows which caller it is serving (#1850)

A plugin route handler is called with the request AND the route context, and the
endpoint declared only the first. The services facade and the authenticated user
were discarded before the protocol layer could see them. That costs nothing
while the endpoint exposes no tools, and would cost a great deal the moment one
arrives: the tool would read and write with no user attached, and the defect
would be invisible because the endpoint answers correctly in every other way.

The context now travels as itself, scoped to the request with
`AsyncLocalStorage` for the reason `auth/caller-scope.ts` gives: requests are
served concurrently and a module variable hands one request's caller to another.
The handler is built once on purpose, so the factory cannot close over a caller
who did not exist when it was built, and the library's own seam for this is
`authInfo`, an OAuth token with a flat scope list.

A key's own grants are deliberately NOT carried here. They are already ambient:
the plugin dispatcher runs every handler inside `runWithCallerScope`, so a
service call made anywhere in the request is judged on the grants stamped on the
key rather than on the roles of whoever minted it. Flattening that into
`authInfo.scopes` would answer the authorization question a second time, beside
the routes the rest of the CMS is reached through, and the two would drift while
both looked correct.

A request whose caller cannot be established is refused at construction rather
than served a server built for nobody, so the guarantee is in place before the
first tool depends on it.

The auth proof matrix comes with it, driven through the real dispatcher with a
real API key rather than a fixture standing in for one: an unauthenticated
caller is refused, an unverifiable credential is refused, a real key is served,
and the address guard is shown refusing that same key on a foreign Host and a
foreign Origin while serving it on the configured site's own. Those last cases
are the first time the guard has been observable at all, because an
unauthenticated probe is answered before it ever runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant