Skip to content

feat(plugins): allow explicit MCP tool declarations#2002

Merged
ascorbic merged 6 commits into
emdash-cms:mainfrom
jcheese1:plugin-mcp-tools
Jul 21, 2026
Merged

feat(plugins): allow explicit MCP tool declarations#2002
ascorbic merged 6 commits into
emdash-cms:mainfrom
jcheese1:plugin-mcp-tools

Conversation

@jcheese1

Copy link
Copy Markdown
Contributor

What does this PR do?

Allows plugins to explicitly declare selected API routes as MCP tools, following the design approved in Discussion #1587.

Plugin authors can provide stable tool names, descriptions, input/output schemas, and MCP annotations. EmDash validates and bundles those declarations, exposes enabled tools through the existing MCP server with plugin-prefixed names, enforces each route's RBAC permission, and records invocation audit events.

Plugin MCP access is disabled by default and requires explicit administrator consent during install, update, or plugin management. Bearer tokens must also opt in with either mcp:tools or a plugin-scoped mcp:tools:<pluginId> scope; historical admin tokens do not silently gain access to plugin-provided tools.

This also adds authoring documentation, token-scope controls, migration coverage, and a small smoke-test plugin used by the site matrix.

Discussion: #1587

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: RFC: Let plugins explicitly declare MCP tools #1587

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: OpenAI Codex (GPT-5)

Screenshots / test output

  • pnpm lint
  • pnpm typecheck
  • pnpm typecheck:demos
  • pnpm format:check
  • Feature-relevant suites from pnpm test: admin (1,100 tests), auth (57 tests), plugin CLI (394 tests), and core (4,715 tests) all pass.
  • The aggregate test run encountered an unrelated parallel port collision in the untouched workerd package (127.0.0.1:18788); the affected wall-time integration test passes when rerun in isolation.
  • Live smoke check in demos/simple: after enabling the smoke plugin, MCP tools/list advertised mcp-smoke__echo with its declared schema and annotations.

@changeset-bot

changeset-bot Bot commented Jul 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8305932

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 20 packages
Name Type
emdash Minor
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/plugin-cli Minor
@emdash-cms/plugin-types Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/plugin-mcp-smoke Major
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/registry-verification Patch
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,733 lines across 76 files. Large PRs are harder to review and more likely to be closed without review.
This PR spans 5 different areas (area/core, area/admin, area/plugins, area/docs, area/auth). Consider breaking it into smaller, focused PRs.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jul 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@2002

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@2002

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@2002

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@2002

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@2002

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@2002

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@2002

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@2002

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@2002

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@2002

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@2002

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@2002

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@2002

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@2002

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@2002

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@2002

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@2002

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@2002

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@2002

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@2002

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@2002

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@2002

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@2002

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@2002

commit: 8305932

@github-actions

Copy link
Copy Markdown
Contributor

@swissky swissky added area/templates bot:review Trigger an emdashbot code review on this PR and removed area/templates labels Jul 15, 2026

@emdashbot emdashbot Bot 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.

This is a sound, well-scoped feature that matches the approved Discussion. It introduces explicit, opt-in plugin MCP tools with the right separation of concerns: plugin declarations at build time, deterministic <pluginId>__<tool> names, admin consent in a dedicated DB column, plugin-scoped bearer tokens, RBAC permission enforcement inside the MCP server, and audit logging. The changeset, migration (052), docs, admin UI strings (Lingui), RTL-aware Tailwind, and test coverage (unit + site-matrix smoke) are all in place.

I checked authorization flow, scope validation, the plugin route permission change, migration/schema consistency, API envelope shape, and admin UI conventions. I found three issues worth fixing before merge:

  1. handlePluginMcpTool reuses the incoming Content-Length header for a new, shorter request body. The internal Request copies request.headers verbatim while replacing the body with JSON.stringify(input). The original Content-Length corresponds to the much larger MCP JSON-RPC batch, so the downstream plugin route handler can see a truncated or hanging body depending on the runtime. Build fresh headers and drop content-length/content-encoding.

  2. The admin marketplace client treats an empty mcpTools array as a consent-required error. getMcpConsentTools() returns [] when details are missing or tools are empty, and the callers check if (mcpTools). An empty array is truthy, so a malformed/unrelated error response could surface as a consent dialog, and a genuinely empty consent response would loop because the follow-up request still doesn't confirm. Return null when there are no tools.

  3. The registry client helper has the same empty-array issue in parseMcpConsent().

I also left two minor suggestions: a stale comment in the plugin catch-all route that still refers to plugins:manage, and the plugin-scoped token regex, which accepts characters that are not valid EmDash plugin identifiers.

Comment thread packages/core/src/emdash-runtime.ts
Comment thread packages/admin/src/lib/api/marketplace.ts Outdated
Comment thread packages/admin/src/lib/api/registry.ts Outdated
Comment thread packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts Outdated
Comment thread packages/auth/src/tokens.ts Outdated
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 15, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review needs-rebase and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 15, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026

@emdashbot emdashbot Bot 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.

This remains a sound, well-scoped implementation of the approved Discussion #1587 feature. It preserves the right separation of concerns—explicit plugin MCP declarations, deterministic <pluginId>__<tool> names, administrator enablement via a dedicated DB column, plugin-scoped bearer tokens, RBAC permission enforcement inside the MCP server, and audit logging of invocations. The changeset, migration (052), docs, Lingui-wrapped admin UI strings, RTL-aware Tailwind classes, and test coverage are all present.

What changed since the prior review
All three previously reported needs_fixing issues are addressed:

  1. handlePluginMcpTool now builds fresh Headers, deletes content-length and content-encoding, and supplies the new JSON body—no stale length mismatch can truncate or hang the downstream request. A test in packages/core/tests/integration/runtime/plugin-media-route.test.ts locks this in.
  2. getMcpConsentTools() in packages/admin/src/lib/api/marketplace.ts now returns null for missing, empty, or malformed tool arrays, so an empty mcpTools: [] no longer triggers a false consent dialog.
  3. parseMcpConsent() in packages/admin/src/lib/api/registry.ts has the same fix.
  4. The plugin catch-all route comment no longer mis-describes method-based permission tiering; it correctly documents that every private invocation is gated on the route's declared permission (defaulting to plugins:manage).
  5. The plugin-scoped token regex in packages/auth/src/tokens.ts (/^[a-z][a-z0-9_-]*$/) aligns with EmDash's validatePluginIdentifier shape and has test coverage to reject plugin-id-invalid suffixes.

New issue found
The admin marketplace update client still uses a field name that the server no longer accepts, which means marketplace plugin updates that widened capabilities will fail/loop. Details are in the line-anchored findings below. This looks like a pre-existing contract skew that the PR's updated call site inherits, but it is real and user-visible in the new update flow.

Overall
Aside from the marketplace-update flag mismatch, the code is clean. The approach is correct, the security model is what was approved, and the prior blockers are resolved.

/** Update request body */
export interface UpdatePluginOpts {
/** User has confirmed new capabilities */
confirmCapabilities?: boolean;

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.

[needs fixing] UpdatePluginOpts declares confirmCapabilities, but the marketplace plugin update endpoint (packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts) and its handler (handleMarketplaceUpdate) expect confirmCapabilityChanges. Because the client body key never reaches the server, any marketplace plugin update that widens capabilities returns CAPABILITY_ESCALATION again, and the admin update flow cannot complete.

Rename the client option to match the server contract, and update the unit test in packages/admin/tests/lib/marketplace.test.ts that currently asserts the wrong body shape.

Suggested change
confirmCapabilities?: boolean;
export interface UpdatePluginOpts {
/** User has confirmed new capabilities */
confirmCapabilityChanges?: boolean;
confirmMcpTools?: boolean;
}

? updateRegistryPlugin(plugin.id, opts)
: updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }),
: updateMarketplacePlugin(plugin.id, {
confirmCapabilities: true,

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.

[needs fixing] This marketplace update call sends confirmCapabilities: true, but the server only reads confirmCapabilityChanges (see the updateBodySchema in packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts). As a result, the user's consent never registers and a marketplace update that adds capabilities will keep failing with CAPABILITY_ESCALATION.

Use the server's field name once UpdatePluginOpts is corrected:

Suggested change
confirmCapabilities: true,
: updateMarketplacePlugin(plugin.id, {
confirmCapabilityChanges: true,
confirmMcpTools: mcpUpdateTools.length > 0,
}),

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 20, 2026
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review cla: signed and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026

@emdashbot emdashbot Bot 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.

This is a sound, well-scoped implementation of the approved Discussion #1587 feature. The author has addressed every item from the previous review and the new revision is clean.

What changed / verified

  • handlePluginMcpTool now builds a fresh Request with content-length and content-encoding deleted, so the JSON tool body cannot be truncated by stale representation headers. A regression test locks this in.
  • Both getMcpConsentTools() and parseMcpConsent() correctly return null for missing, empty, or malformed MCP tool arrays, preventing empty mcpTools: [] from spuriously triggering a consent dialog.
  • The plugin catch-all route comment now accurately documents that every private invocation is gated on the route’s declared permission (defaulting to plugins:manage).
  • The plugin-scoped token regex /^[a-z][a-z0-9_-]*$/ matches EmDash’s plugin identifier rules and has test coverage.
  • The marketplace update contract mismatch is also fixed: the admin client now sends confirmCapabilityChanges, matching the server schema and handler.

What I checked in this pass

  • Auth model: admin no longer silently grants plugin-tool access; mcp:tools and mcp:tools:<pluginId> are validated, scoped, and enforced inside the MCP server.
  • Consent/enablement: install/update require explicit consent; the per-plugin mcpToolsEnabled / mcpToolsConsent columns are correctly migrated and cleared on update.
  • Runtime tool discovery and dispatch: getPluginMcpTools, getEnabledPluginMcpTools, serializePluginMcpConsent, and handlePluginMcpTool are consistent and audit every invocation.
  • Admin UI: new strings are Lingui-wrapped and the new Tailwind classes are logical/RTL-safe.
  • Manifest/build pipeline: manifest schema validates tool names, route references, and permissions; plugin-cli and core bundle extraction stay aligned.
  • Tests: the smoke test, unit tests for scopes/authorization/consent, and the header regression test all exercise observable behavior rather than implementation tautology.

No new bugs, regressions, or AGENTS.md violations were found in this revision. LGTM.

@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 21, 2026

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks!

@ascorbic
ascorbic merged commit e52dea9 into emdash-cms:main Jul 21, 2026
48 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants