Skip to content

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

Merged
mobeenabdullah merged 5 commits into
mainfrom
fix/mcp-endpoint-follow-ups
Sep 13, 2026
Merged

mobeenabdullah merged 5 commits into
mainfrom
fix/mcp-endpoint-follow-ups

Conversation

@mobeenabdullah

Copy link
Copy Markdown
Collaborator

Follow-up to #1837, which merged before this round's findings were worked. Four findings: three real and fixed, one I believe is wrong and I have the measurement.

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 it through the registry, or an agent reading the Context7-indexed root README, was told about a package that now serves one. Both corrected.

path refused nothing and promised too much

A value that cannot address a single endpoint is now refused where it is written — a missing leading slash, a trailing one, a :param pattern, or the mount itself — rather than surfacing later as a 404 somebody has to explain.

A plain Error, not NextlyError: that type's public messages are canonical by design because they shape an HTTP response (validation flattens to "Validation failed."), 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 comment says the ordering is the guard, "so there is no list of reserved prefixes to keep in step with the routes core adds later". A list here would be a second one, drifting behind every route core adds, which is the proxy derived-checks.md warns about. The option's documentation now states the consequence instead, including that the shadowing is per method.

The tests could not see what the dispatcher does — correct, and now they can

This criticism was right. The package gains an integration lane and a suite that goes through createDynamicHandlers, the dispatcher that actually serves the endpoint. It confirms the finding: an unauthenticated caller is answered 401 and the address guard never runs, because core authenticates before the handler.

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, and the reasoning is in the code. The request is still refused, by the check that does not depend on the attacker's cooperation. The guard covers what authentication cannot — a request carrying a real credential from a page that had no business sending it, which is the browser half of the attack. Making the route public would put the guard first and answer 403; it would also mean authenticating here instead, which is the one thing this endpoint exists not to do.

One finding I believe is wrong, measured

…1ADr says the origin allowlist must carry serialized origins and that passing hostnames rejects the configured site. Measured against the library:

allowlist Origin sent result
cms.example.com (what this does) https://cms.example.com allowed
cms.example.com https://evil.example.com refused 403
https://cms.example.com (what the finding suggests) https://cms.example.com refused 403

The helper compares hostnames and is port-agnostic, which is what its own documentation says and what localhostAllowedOrigins() returns. Adopting the suggested remedy would introduce the bug the finding describes.

Its second half was right and is fixed. 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. A browser request from the configured site is now asserted to be served, plus a case for an operator who configures a full origin rather than a hostname.

Gates

35 unit, 4 integration (new lane, wired into lane:test:integration:sqlite and verified by check:test-lanes, which refused until it was), types, lint, doc-samples, docs-claims, fallow zero introduced, changeset covers all 26 lockstep packages.

check:comments is red from main — a comment merged with #1834 names the pull request it arrived in. Not touched here to avoid a conflict: #1847 carries that one-line fix.

…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.
@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:37:36.845550Z ace2964 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.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 14 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: be5dae93-74ef-41ab-892e-ee3d0719f8ea

📥 Commits

Reviewing files that changed from the base of the PR and between d745705 and ace2964.

⛔ Files ignored due to path filters (3)
  • .changeset/an-endpoint-that-says-what-it-is.md is excluded by !.changeset/**
  • .changeset/the-endpoint-knows-who-is-asking.md is excluded by !.changeset/**
  • packages/plugin-sdk/src/__snapshots__/plugin-surface.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (18)
  • README.md
  • package.json
  • packages/nextly/src/index.ts
  • packages/nextly/src/plugins/routes/route-pattern.test.ts
  • packages/nextly/src/plugins/routes/route-pattern.ts
  • packages/plugin-mcp/README.md
  • packages/plugin-mcp/package.json
  • packages/plugin-mcp/src/__tests__/auth-matrix.integration.test.ts
  • packages/plugin-mcp/src/__tests__/endpoint-dispatch.integration.test.ts
  • packages/plugin-mcp/src/__tests__/plugin.test.ts
  • packages/plugin-mcp/src/plugin.ts
  • packages/plugin-mcp/src/transport/__tests__/caller.test.ts
  • packages/plugin-mcp/src/transport/__tests__/endpoint.test.ts
  • packages/plugin-mcp/src/transport/caller.ts
  • packages/plugin-mcp/src/transport/endpoint.ts
  • packages/plugin-mcp/vitest.config.ts
  • packages/plugin-mcp/vitest.integration.config.ts
  • packages/plugin-sdk/src/index.ts

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

Follow-up to #1837, which merged before these findings were worked. Three fixed, one refuted with a measurement.

The one to look at hardest is the dispatcher ordering. You were right that the handler-level tests could not see it, so the package has an integration lane now and a suite through createDynamicHandlers that confirms the 401. I have left the gap open rather than closed and said why in the code — moving the guard pre-auth means making the route public, which puts authentication back in this plugin. If you think that trade is wrong, this is the place to say so.

On the Origin allowlist: the helper compares hostnames, and passing full origins is what would reject the configured site. The table is in the description. Your point about the missing positive case was right and is fixed.

@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1cd437481a

ℹ️ 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".

Comment thread packages/plugin-mcp/src/plugin.ts Outdated
Comment thread packages/plugin-mcp/src/plugin.ts
@pkg-pr-new

pkg-pr-new Bot commented Sep 13, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@nextlyhq/adapter-drizzle

npm i https://pkg.pr.new/@nextlyhq/adapter-drizzle@3888fd1

@nextlyhq/adapter-mysql

npm i https://pkg.pr.new/@nextlyhq/adapter-mysql@3888fd1

@nextlyhq/adapter-postgres

npm i https://pkg.pr.new/@nextlyhq/adapter-postgres@3888fd1

@nextlyhq/adapter-sqlite

npm i https://pkg.pr.new/@nextlyhq/adapter-sqlite@3888fd1

@nextlyhq/admin

npm i https://pkg.pr.new/@nextlyhq/admin@3888fd1

@nextlyhq/admin-css

npm i https://pkg.pr.new/@nextlyhq/admin-css@3888fd1

@nextlyhq/blocks-engine

npm i https://pkg.pr.new/@nextlyhq/blocks-engine@3888fd1

@nextlyhq/blocks-react

npm i https://pkg.pr.new/@nextlyhq/blocks-react@3888fd1

@nextlyhq/builder

npm i https://pkg.pr.new/@nextlyhq/builder@3888fd1

create-nextly-app

npm i https://pkg.pr.new/create-nextly-app@3888fd1

@nextlyhq/eslint-plugin

npm i https://pkg.pr.new/@nextlyhq/eslint-plugin@3888fd1

nextly

npm i https://pkg.pr.new/nextly@3888fd1

@nextlyhq/plugin-form-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-form-builder@3888fd1

@nextlyhq/plugin-mcp

npm i https://pkg.pr.new/@nextlyhq/plugin-mcp@3888fd1

@nextlyhq/plugin-page-builder

npm i https://pkg.pr.new/@nextlyhq/plugin-page-builder@3888fd1

@nextlyhq/plugin-sdk

npm i https://pkg.pr.new/@nextlyhq/plugin-sdk@3888fd1

@nextlyhq/plugin-seo

npm i https://pkg.pr.new/@nextlyhq/plugin-seo@3888fd1

@nextlyhq/storage-s3

npm i https://pkg.pr.new/@nextlyhq/storage-s3@3888fd1

@nextlyhq/storage-uploadthing

npm i https://pkg.pr.new/@nextlyhq/storage-uploadthing@3888fd1

@nextlyhq/storage-vercel-blob

npm i https://pkg.pr.new/@nextlyhq/storage-vercel-blob@3888fd1

@nextlyhq/ui

npm i https://pkg.pr.new/@nextlyhq/ui@3888fd1

commit: 3888fd1

@github-actions github-actions Bot added type: docs Documentation only scope: plugin @nextlyhq/plugin-* packages dependencies Dependency updates (label applied by Dependabot) labels Sep 13, 2026
@github-actions

github-actions Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Whole-Repository Code Hygiene Summary

Full dead-code, duplication, and complexity report for the PR branch as it stands now. Playground is excluded. Quality gate enforcement on introduced issues is performed by the Changed files job.

🌿 Fallow

Warning

Review needed

⚠️ 73 code issues · ⚠️ 677 clone groups · ⚠️ 1035 health findings

See inline review comments for per-finding details.

Code issues (73)
Category Count
Unused files 2
Unused exports 5
Unused dependencies 19
Unused devDependencies 6
Unresolved imports 2
Unlisted dependencies 1
Circular dependencies 38
Duplication (677 groups · 28396 lines · 3.9%)
Locations Lines Tokens
schemas/_dialect-bundles/mysql.relations.ts:40-134
schemas/_dialect-bundles/postgres.relations.ts:40-134
schemas/_dialect-bundles/sqlite.relations.ts:40-134
95 593
cli/commands/db-sync-demote.ts:70-75
cli/commands/db-sync-promote.ts:38-43
cli/commands/dev-build.ts:100-105
cli/commands/dev-build.ts:179-184
cli/commands/dev-build.ts:299-304
cli/commands/dev-build.ts:411-416
cli/commands/dev-build.ts:552-557
cli/commands/dev-server.ts:575-580
cli/commands/dev-server.ts:840-845
cli/commands/dev-server.ts:1143-1148
cli/commands/migrate-field-groups.ts:110-115
6 70
entries/EntryList/EntryTableSkeleton.tsx:74-98
collection/components/CollectionTableSkeleton.tsx:94-118
field-group/components/FieldGroupTableSkeleton.tsx:90-114
plugins/components/PluginsTableSkeleton.tsx:86-110
singles/components/SinglesTableSkeleton.tsx:77-101
src/components/table-skeleton.tsx:100-124
25 89
collections/config/validate-config.ts:380-433
field-groups/config/validate-field-group.ts:185-238
singles/config/validate-single.ts:190-243
54 152
dispatcher/handlers/collection-dispatcher.ts:925-967
field-groups/services/field-group-table-provisioning.ts:186-236
singles/services/reconcile-single-companion.ts:110-160
51 149

… and 672 more groups.

Across 425 files.

Complexity (1035 functions above threshold)
File Function Severity Cyclomatic Cognitive CRAP Lines
singles/services/single-mutation-service.ts:966 <arrow> critical 246 ! 308 ! 13317.5 ! 1650
collections/services/collection-mutation-service.ts:6327 <arrow> critical 165 ! 153 ! 6045.6 ! 1260
src/init/reload-config.ts:1417 applyReload critical 143 ! 211 ! 4560 ! 1470
shared/lib/entry-validation.ts:245 validateFieldValue critical 109 ! 157 ! 2675.3 ! 432
dynamic-collections/services/dynamic-collection-schema-service.ts:941 generateAlterTableMigration critical 103 ! 220 ! 2394.5 ! 738

5110 files, 79164 functions analyzed (thresholds: cyclomatic > 20, cognitive > 15, CRAP >= 30)

Codebase health

Metric Value
Maintainability 91.7 / 100
Avg complexity 1.8

Tip

Run fallow fix --dry-run to preview auto-fixes.
Add /** @public */ above exports to preserve them.

… 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.
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9377508a7

ℹ️ 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".

Comment thread packages/plugin-mcp/src/plugin.ts Outdated
…tating 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.
@github-actions github-actions Bot added the scope: core nextly label Sep 13, 2026
@mobeenabdullah

Copy link
Copy Markdown
Collaborator Author

@codex review

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 merged commit 57cd97a into main Sep 13, 2026
16 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ace2964db9

ℹ️ 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".

@@ -0,0 +1,53 @@
---

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Consolidate the two changesets

This commit adds both the-endpoint-knows-who-is-asking.md and an-endpoint-that-says-what-it-is.md, each covering the complete lockstep package group. When the Version PR consumes this change, both bodies become separate release entries for the same PR; merge their contents into the single changeset required for each PR and remove the other file.

AGENTS.md reference: AGENTS.md:L327-L331

Useful? React with 👍 / 👎.

Comment on lines +195 to +198
it("serves it on the configured site's own Origin", async () => {
// The control on the case above, and the finding this programme refuted
// once already: an allowlist compared as serialized origins refuses the
// configured site's own browser clients. Hostnames are what it compares,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove review history from the code comment

This newly added comment explicitly refers to “the finding this programme refuted once already,” so its meaning depends on review history rather than documenting the hostname-comparison behavior in the code. Rephrase it as the invariant the test protects; repository conventions require code comments to describe only the code and never review findings or conversations.

AGENTS.md reference: AGENTS.md:L288-L290

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Dependency updates (label applied by Dependabot) scope: core nextly scope: plugin @nextlyhq/plugin-* packages type: docs Documentation only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant