feat(plugins): allow explicit MCP tool declarations#2002
Conversation
🦋 Changeset detectedLatest commit: 8305932 The changes in this PR will be included in the next version bump. This PR includes changesets to release 20 packages
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 |
Scope checkThis PR changes 1,733 lines across 76 files. Large PRs are harder to review and more likely to be closed without review. 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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:
-
handlePluginMcpToolreuses the incomingContent-Lengthheader for a new, shorter request body. The internalRequestcopiesrequest.headersverbatim while replacing the body withJSON.stringify(input). The originalContent-Lengthcorresponds 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 dropcontent-length/content-encoding. -
The admin marketplace client treats an empty
mcpToolsarray as a consent-required error.getMcpConsentTools()returns[]when details are missing or tools are empty, and the callers checkif (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. Returnnullwhen there are no tools. -
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.
There was a problem hiding this comment.
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:
handlePluginMcpToolnow builds freshHeaders, deletescontent-lengthandcontent-encoding, and supplies the new JSON body—no stale length mismatch can truncate or hang the downstream request. A test inpackages/core/tests/integration/runtime/plugin-media-route.test.tslocks this in.getMcpConsentTools()inpackages/admin/src/lib/api/marketplace.tsnow returnsnullfor missing, empty, or malformed tool arrays, so an emptymcpTools: []no longer triggers a false consent dialog.parseMcpConsent()inpackages/admin/src/lib/api/registry.tshas the same fix.- 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). - The plugin-scoped token regex in
packages/auth/src/tokens.ts(/^[a-z][a-z0-9_-]*$/) aligns with EmDash'svalidatePluginIdentifiershape 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; |
There was a problem hiding this comment.
[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.
| 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, |
There was a problem hiding this comment.
[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:
| confirmCapabilities: true, | |
| : updateMarketplacePlugin(plugin.id, { | |
| confirmCapabilityChanges: true, | |
| confirmMcpTools: mcpUpdateTools.length > 0, | |
| }), |
There was a problem hiding this comment.
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
handlePluginMcpToolnow builds a freshRequestwithcontent-lengthandcontent-encodingdeleted, so the JSON tool body cannot be truncated by stale representation headers. A regression test locks this in.- Both
getMcpConsentTools()andparseMcpConsent()correctly returnnullfor missing, empty, or malformed MCP tool arrays, preventing emptymcpTools: []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:
adminno longer silently grants plugin-tool access;mcp:toolsandmcp:tools:<pluginId>are validated, scoped, and enforced inside the MCP server. - Consent/enablement: install/update require explicit consent; the per-plugin
mcpToolsEnabled/mcpToolsConsentcolumns are correctly migrated and cleared on update. - Runtime tool discovery and dispatch:
getPluginMcpTools,getEnabledPluginMcpTools,serializePluginMcpConsent, andhandlePluginMcpToolare 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.
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:toolsor a plugin-scopedmcp:tools:<pluginId>scope; historicaladmintokens 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
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
pnpm lintpnpm typecheckpnpm typecheck:demospnpm format:checkpnpm test: admin (1,100 tests), auth (57 tests), plugin CLI (394 tests), and core (4,715 tests) all pass.127.0.0.1:18788); the affected wall-time integration test passes when rerun in isolation.demos/simple: after enabling the smoke plugin, MCPtools/listadvertisedmcp-smoke__echowith its declared schema and annotations.