Skip to content

Add plugin-defined MCP tools#988

Open
masonjames wants to merge 7 commits into
emdash-cms:mainfrom
masonjames:codex/plugin-mcp-tools
Open

Add plugin-defined MCP tools#988
masonjames wants to merge 7 commits into
emdash-cms:mainfrom
masonjames:codex/plugin-mcp-tools

Conversation

@masonjames
Copy link
Copy Markdown
Contributor

What does this PR do?

Adds the approved plugin-defined MCP tool surface from Discussion #850:

  • adds mcp:tools as an opt-in plugin capability
  • adds mcpTools metadata to plugin manifests, descriptors, bundle extraction, and runtime plugin wiring
  • registers enabled plugin MCP tools on the existing EmDash MCP endpoint
  • delegates MCP tool execution to the plugin route dispatcher so route validation/context/sandbox paths are reused
  • requires admin token scope plus ADMIN role for plugin MCP tool calls

Related discussion: #850

Type of change

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

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: OpenAI Codex (GPT-5), RepoPrompt agent_run Deep Plan/review (GPT-5.5)

Screenshots / test output

  • pnpm typecheck
  • pnpm lint (exits 0; reports 39 existing warnings, 0 errors)
  • pnpm test
  • git diff --check
  • pnpm --filter emdash test -- tests/integration/mcp/plugin-tools.test.ts tests/unit/plugins/adapt-sandbox-entry.test.ts tests/unit/plugins/manifest-schema.test.ts tests/unit/plugins/define-plugin.test.ts tests/unit/cli/bundle-utils.test.ts
  • pnpm --filter @emdash-cms/registry-cli test -- bundle-utils.test.ts

Adversarial review was run twice with RepoPrompt agent_run. The first pass found two issues around unloaded sandboxed tools and plugin ID tool-name collisions; both were fixed and the second pass found no blocking issues.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 11, 2026

🦋 Changeset detected

Latest commit: b0200a4

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

This PR includes changesets to release 15 packages
Name Type
emdash Patch
@emdash-cms/plugin-types Patch
@emdash-cms/registry-cli Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@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 683 lines across 26 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.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 11, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

npm i https://pkg.pr.new/emdash@988

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: b0200a4

@masonjames masonjames marked this pull request as ready for review May 11, 2026 13:57
@github-actions
Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Solid implementation overall — capability gating, hex-encoded tool names to avoid plugin-id collisions, admin scope + ADMIN role enforced before dispatch, and good test coverage (capability filter, sandbox-not-loaded guard, scope check, ID collision). The delegation to handlePluginApiRoute is a clean reuse strategy.

Findings are mostly DX/footgun concerns at the definePlugin boundary plus a couple of manifest cross-validation gaps. No correctness or auth bugs that block merge.

Notes:

  • Trusted (definePlugin) plugins skip several validations that sandboxed plugins get from pluginManifestSchema — tool-name shape, route existence, and capability declaration. Silent filtering vs. early errors.
  • getPluginRouteMeta falls back to { public: false } for any route on an existing sandboxed plugin, so MCP tools pointing at nonexistent sandboxed routes register fine and fail at call time. The catch-all route has the same behavior, so this matches existing semantics — flagging only as DX context.
  • One coverage gap: no test for requireRole(ADMIN) rejecting a non-admin user holding an admin-scoped token (the defense-in-depth branch).
  • Sandboxed plugin MCP tools have no input schema in ManifestMcpToolEntry, so they all fall back to z.record(z.string(), z.unknown()) — MCP clients lose introspection. Future work, but worth tracking.

Comment thread packages/core/src/plugins/define-plugin.ts
Comment thread packages/core/src/mcp/server.ts Outdated
Comment thread packages/core/src/plugins/manifest-schema.ts
Comment thread packages/core/tests/integration/mcp/plugin-tools.test.ts
Comment thread packages/core/src/mcp/server.ts
Comment thread packages/core/src/emdash-runtime.ts
@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 12, 2026

Review submitted successfully (review ID 4270626524) with 6 line-anchored comments and a summary body.

Key findings:

  • DX footguns at definePlugin: no validation of MCP tool name shape, route existence, or required mcp:tools capability — sandboxed plugins get all three from manifest schema, trusted plugins fail silently.
  • Manifest cross-validation gaps: mcpTools[i].route not cross-checked against routes; mcpTools non-empty not cross-checked against the capability declaration.
  • Test coverage gap: no test for the defense-in-depth requireRole(ADMIN) branch.
  • Schema introspection: sandboxed plugin tools fall back to z.record(string, unknown), losing client-side schema info.
  • Documentation suggestion: note that routeMeta.public is intentionally ignored in the MCP path.
  • Dedup gap: getPluginMcpTools doesn't dedup across the three sources; relies on registration-order semantics in createMcpServer.

No blocking issues — auth/correctness paths check out.

github run

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

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

Excellent work. One thing that would be good would be if there was a way we could provide a schema for tool inputs so we can validate and describe them. While we can't serialise a Zod schema directly, maybe a JSON schema would work?

Can you include docs in the PR too, as it will also make it easier to review.

Comment thread packages/core/src/mcp/server.ts Outdated
@masonjames
Copy link
Copy Markdown
Contributor Author

Excellent work. One thing that would be good would be if there was a way we could provide a schema for tool inputs so we can validate and describe them. While we can't serialise a Zod schema directly, maybe a JSON schema would work?

Can you include docs in the PR too, as it will also make it easier to review.

Agreed on both.

I’ll add docs in this PR, and I’ll look at adding a constrained inputSchema field to ManifestMcpToolEntry for client-facing tool introspection. I’m inclined to keep route handlers as the runtime validation source of truth for now, so plugin tools still execute through the existing route validation/context path, while JSON Schema gives MCP clients useful parameter descriptions.

I’ll keep the JSON Schema subset conservative: object root, properties, required, additionalProperties, scalar/array types, enum, description, default, and basic string/number/array constraints.

No $ref/recursive schemas in this pass unless you think we should make that part of the initial contract.

@masonjames
Copy link
Copy Markdown
Contributor Author

Done. I added docs for plugin MCP tools and added manifest-side inputSchema support using a constrained JSON Schema subset. Trusted plugins can still pass Zod via input; manifest/sandboxed plugins can now provide serializable JSON Schema, which is converted to Zod at MCP registration for client metadata and call validation.

Comment thread packages/core/src/mcp/json-schema.ts
Comment thread packages/core/src/plugins/types.ts
Comment thread packages/core/src/emdash-runtime.ts
Comment thread packages/core/tests/integration/mcp/plugin-tools.test.ts Outdated
@ascorbic
Copy link
Copy Markdown
Collaborator

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewing on top of the existing rounds (ask-bonk + ascorbic). Most of the substantive concerns from the earlier passes — capability cross-check, route cross-check, tool-name shape validation, JSON Schema introspection, dual-check coverage, admin-only intent comment, runtime dedup — have been addressed. A few remaining things worth a look, all DX/robustness rather than auth/correctness:

  • One bad inputSchema from a trusted plugin can take down the whole MCP endpoint because jsonSchemaObjectToZod is called inline during createMcpServer and has no isolation. Trusted plugins skip the Zod manifest validation that sandboxed plugins get, so the JSON Schema is unvalidated until z.fromJSONSchema throws.
  • pluginToolName collision detection looks for __ within a single segment, but two distinct valid scoped IDs can still collapse to the same generated name when a dash sits on either side of the slash boundary (@a-/b vs @a/-b both → a___b__foo). Narrow but real.
  • No dedicated unit test file for pluginToolName — the integration tests cover the happy paths but the ambiguous-segment / hash-fallback branches aren't directly exercised.

No blocking issues from me.

inputSchema:
tool.input ??
(tool.inputSchema
? jsonSchemaObjectToZod(tool.inputSchema)
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.

Robustness: one bad plugin inputSchema breaks the whole MCP endpoint.

jsonSchemaObjectToZod is called inline here, and z.fromJSONSchema throws on unsupported / malformed schemas. For sandboxed plugins this is fine — the manifest Zod schema (manifestJsonSchema) rejects bad shapes at parse time. For trusted plugins coming through definePlugin, there is no runtime JSON Schema validation: define-plugin.ts checks tool name shape, route existence, and capability, but never validates tool.inputSchema (the TS type is enforced at compile time only).

So a single trusted plugin author who hand-writes an inputSchema with, say, $ref, oneOf, or a misspelled type causes z.fromJSONSchema to throw during createMcpServer. The outer try/catch in api/mcp.ts then returns a JSON-RPC 500 for the whole endpoint — every other built-in tool (content, schema, media, etc.) becomes unreachable, not just the offending plugin tool.

Two cheap mitigations:

  1. Wrap the conversion and skip the tool on failure:
Suggested change
? jsonSchemaObjectToZod(tool.inputSchema)
inputSchema:
tool.input ??
(tool.inputSchema
? safeJsonSchemaObjectToZod(tool.inputSchema, tool.pluginId, tool.name)
: z.record(z.string(), z.unknown())),

where safeJsonSchemaObjectToZod try/catches and returns the permissive fallback (with a console.warn so the author has a signal). The route handler still validates server-side via PluginRouteHandler.invoke, so the security boundary is unchanged.

  1. Or validate inputSchema in definePlugin for trusted plugins (parse it with the same Zod schema used for manifests). That way bad schemas fail loudly at config time, like the other validations added in this PR.

Low-frequency in practice, but the blast radius (entire MCP server down) makes it worth handling explicitly.

.split(FORWARD_SLASH_PATTERN)
.map((segment) => segment.replace(PLUGIN_ID_DASH_PATTERN, "_"));
const readablePluginId = readableSegments.join("__");
if (readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) {
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.

Narrow collision case the __ check misses.

This catches consecutive __ within a single segment (e.g. foo--bar → segment foo__bar), but the segments are joined with __, so two distinct scoped IDs where a dash sits adjacent to the slash boundary still collapse to the same generated name:

  • @a-/b → segments ["a-", "b"]["a_", "b"]a___b
  • @a/-b → segments ["a", "-b"]["a", "_b"]a___b

Neither segment contains __ on its own, so the hash fallback is not triggered. Both plugins end up with a___b__<tool>, and the dedup in createMcpServer (registeredToolNames) silently drops the second one — meaning whichever plugin loaded first wins.

It requires fairly unusual plugin IDs (SIMPLE_ID/SCOPED_ID does allow leading/trailing dashes inside segments), so this is low-severity, but the existing check could be tightened to look at the joined readable id instead:

Suggested change
if (readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) {
const readablePluginId = readableSegments.join("__");
if (/_{3,}/.test(readablePluginId) || readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) {
return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`;
}

(Or just hash unconditionally for any plugin id whose readable form isn't reversibly recoverable.)

hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
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.

Worth a small dedicated unit test file (tests/unit/mcp/plugin-tool-name.test.ts) to lock the contract in directly. The integration test in plugin-tools.test.ts covers the happy paths and one of the ambiguous cases, but the hash-fallback branch, leading-@ stripping, and edge cases like @a-/b vs @a/-b aren't directly asserted — a future refactor could change the generation scheme without breaking any test.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted successfully (review ID 4303523122) with three line-anchored comments and a summary body.

Key findings (all non-blocking, DX/robustness rather than auth/correctness):

  • Trusted-plugin inputSchema blast radius: jsonSchemaObjectToZod is called inline in createMcpServer. Sandboxed plugins get manifest-level Zod validation; trusted plugins go straight through with no runtime check. A single malformed schema makes z.fromJSONSchema throw, which the outer try/catch in api/mcp.ts turns into a JSON-RPC 500 — taking down every other built-in MCP tool, not just the offending one. Suggested wrapping the conversion in a try/catch fallback, or validating inputSchema in definePlugin.
  • pluginToolName narrow collision: the __-in-segment check misses cases where a dash sits on either side of the slash boundary (@a-/b vs @a/-b both → a___b__foo). The runtime dedup silently drops the second registration. Tightening the regex to look at the joined readable id (or hashing unconditionally) closes it.
  • Unit-test coverage gap: pluginToolName has no dedicated unit tests — the hash-fallback and segment edge cases are only indirectly covered by the integration suite.

Prior reviewers' concerns (capability/route cross-validation, JSON Schema introspection, dual-check coverage, runtime dedup, admin-only intent comment, real-implementation testing) all look addressed.

github run

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.

2 participants