Skip to content

Commit 67408e2

Browse files
lane711claude
andauthored
feat(mcp): MCP Server plugin — expose content as AI agent tools (#784) (#991)
* feat(mcp): MCP Server plugin for SonicJS (#784) Implements a Model Context Protocol server as a first-class SonicJS plugin, exposing content collections as JSON-RPC 2.0 tools for AI agents (Claude Code, Cursor, etc). - POST /mcp endpoint — initialize, tools/list, tools/call, resources/* - Auth via existing API Keys plugin (Bearer sk_… header) - Per-collection tools: list_, get_, create_, update_, publish_, delete_ - Auto-populates `user` type fields (e.g. author) with API key owner - GET /admin/mcp dashboard — endpoint URL, exposed collections, Claude Code / Cursor integration snippets - Sidebar "Author" field resolves created_by UUID to email in all content list and edit views - Mounted at /mcp (not /api/mcp) to avoid /api/:collection catch-all - E2E spec: tests/e2e/93-mcp-admin.spec.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mcp): address code review findings (XSS, D1 limit, slug miss, TOCTOU, N+1) - R8 violation: add escapeHtml to Author sidebar in admin-content-form - D1 100-param limit: chunk auth_user IN-clause lookup into 99-param batches - execGet slug: replace O(N) list scan with direct getBySlug() repo query - execDelete TOCTOU: reuse existence-check result instead of re-fetching - execListCollections: remove N serial findById DB calls; types already resolved active by resolveMcpConfig — now a sync O(1) map Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mcp): enforce redactFields on write, slug draft fallback, shared phase flags Fresh-eyes review follow-ups on the MCP plugin: - redactFields now stripped from create/update input, not just read output — a client can no longer populate a field it can never read back. - getBySlug falls back to the current draft so a never-published doc is reachable by slug (parity with getById); published still preferred. - PHASE_FLAGS hoisted to a single exported const in tools/registry so the admin dashboard tool list can't drift from what the endpoint serves. - execList documents its post-ACL truncation tradeoff. - Corrected stale /api/mcp endpoint comments (actual mount is /mcp). - Integration tests for slug-draft resolution + redact-on-write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): live JSON-RPC endpoint E2E — key mint + slug-draft resolution Drives POST /mcp the way an agent does: mints a one-time API key via the api-keys admin route, then Bearer-auths JSON-RPC. Pins the review fix that get_* by slug resolves a never-published draft. Covers unauthenticated rejection, initialize handshake, and tools/list advertising. Verified locally against a running dev server (4/4). CI runs it on PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(deps): resync package-lock with npm 10 for CI npm ci The lockfile was authored by npm 11 (node 24), which resolves optional platform deps differently than the CI matrix (node 20/22 → npm 10). npm ci on CI rejected it as out of sync (missing better-sqlite3@12.11.1, @emnapi/*, esbuild@0.28.1). Regenerated under node 22 / npm 10 so the lock matches what CI installs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 633023e commit 67408e2

30 files changed

Lines changed: 2513 additions & 259 deletions

docs/ai/plans/mcp-plugin-plan.md

Lines changed: 246 additions & 211 deletions
Large diffs are not rendered by default.

my-sonicjs-app/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
createSonicJSApp,
1414
emailReconciliationPlugin,
1515
getHookSystem,
16+
mcpPlugin,
1617
redirectPlugin,
1718
registerCollections,
1819
} from '@sonicjs-cms/core';
@@ -34,7 +35,7 @@ const config: SonicJSConfig = {
3435
plugins: {
3536
// Add plugins to this array to activate them. Each plugin's register()
3637
// runs synchronously at startup; onBoot() runs async on first request.
37-
register: [redirectPlugin, examplePlugin],
38+
register: [redirectPlugin, examplePlugin, mcpPlugin()],
3839
disableAll: false,
3940
},
4041
};

package-lock.json

Lines changed: 122 additions & 40 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/db/migrations-bundle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* AUTO-GENERATED FILE - DO NOT EDIT
33
* Generated by: scripts/generate-migrations.ts
4-
* Generated at: 2026-07-02T22:07:06.083Z
4+
* Generated at: 2026-07-02T23:45:16.345Z
55
*
66
* This file contains all migration SQL bundled for use in Cloudflare Workers
77
* where filesystem access is not available at runtime.

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,10 @@ export { OAuthService, BUILT_IN_PROVIDERS } from './plugins/core-plugins/oauth-p
373373
export { userProfilesPlugin, createUserProfilesPlugin, defineUserProfile, getUserProfileConfig } from './plugins/core-plugins/user-profiles'
374374
export type { ProfileFieldDefinition, UserProfileConfig } from './plugins/core-plugins/user-profiles'
375375

376+
// MCP Server Plugin (opt-in)
377+
export { mcpPlugin, createMcpPlugin } from './plugins/core-plugins/mcp-plugin'
378+
export type { McpConfigInput, McpConfig } from './plugins/core-plugins/mcp-plugin'
379+
376380
// ============================================================================
377381
// Version
378382
// ============================================================================

packages/core/src/plugins/core-plugins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export { stripePlugin, createStripePlugin, SubscriptionService, StripeAPI, requi
4343
export { dashboardPlugin, createDashboardPlugin } from './dashboard-plugin'
4444
export { multiTenantPlugin, createMultiTenantPlugin, TenantService } from './multi-tenant-plugin'
4545
export { versioningPlugin, createVersioningPlugin } from './versioning-plugin'
46+
export { mcpPlugin, createMcpPlugin } from './mcp-plugin'
47+
export type { McpConfigInput, McpConfig } from './mcp-plugin'
4648
export { menuPlugin, createMenuPlugin } from './menu-plugin'
4749

4850
// Core plugins list - now imported from auto-generated registry
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { resolveMcpConfig } from '../config'
3+
import type { CollectionRecord } from '../../../../services/collection-registry'
4+
5+
function col(name: string, displayName?: string): CollectionRecord {
6+
return {
7+
id: name,
8+
name,
9+
displayName: displayName ?? name,
10+
schema: { type: 'object', properties: {} },
11+
} as CollectionRecord
12+
}
13+
14+
const ALL = [col('posts', 'Posts'), col('pages', 'Pages'), col('products', 'Products')]
15+
16+
describe('resolveMcpConfig', () => {
17+
it('exposes every active collection by default with read+write', () => {
18+
const r = resolveMcpConfig({}, ALL)
19+
expect(r.types.map((t) => t.typeId)).toEqual(['posts', 'pages', 'products'])
20+
expect(r.types.every((t) => t.read && t.write)).toBe(true)
21+
expect(r.listLimit).toBe(50)
22+
expect(r.redactFields).toEqual([])
23+
})
24+
25+
it('applies the expose allowlist', () => {
26+
const r = resolveMcpConfig({ expose: ['posts'] }, ALL)
27+
expect(r.types.map((t) => t.typeId)).toEqual(['posts'])
28+
})
29+
30+
it('applies per-type read/write overrides', () => {
31+
const r = resolveMcpConfig({ types: { pages: { read: true, write: false } } }, ALL)
32+
const pages = r.types.find((t) => t.typeId === 'pages')!
33+
expect(pages.write).toBe(false)
34+
expect(pages.read).toBe(true)
35+
})
36+
37+
it('carries redactFields and listLimit through', () => {
38+
const r = resolveMcpConfig({ redactFields: ['a', 'b'], listLimit: 10 }, ALL)
39+
expect(r.redactFields).toEqual(['a', 'b'])
40+
expect(r.listLimit).toBe(10)
41+
})
42+
43+
it('resolves display names from the registry', () => {
44+
const r = resolveMcpConfig({ expose: ['products'] }, ALL)
45+
expect(r.types[0]!.displayName).toBe('Products')
46+
})
47+
48+
it('caps listLimit at 200', () => {
49+
expect(() => resolveMcpConfig({ listLimit: 999 }, ALL)).toThrow()
50+
})
51+
})
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { fieldToJsonSchema, collectionToJsonSchema } from '../schema/field-to-jsonschema'
3+
import type { CollectionSchema } from '../../../../types/collection-config'
4+
5+
describe('fieldToJsonSchema', () => {
6+
it('maps string-like types to string with constraints', () => {
7+
expect(fieldToJsonSchema({ type: 'string', minLength: 2, maxLength: 5, pattern: '^a' })).toEqual({
8+
type: 'string',
9+
minLength: 2,
10+
maxLength: 5,
11+
pattern: '^a',
12+
})
13+
expect(fieldToJsonSchema({ type: 'slug' })).toEqual({ type: 'string' })
14+
expect(fieldToJsonSchema({ type: 'richtext' })).toEqual({ type: 'string' })
15+
expect(fieldToJsonSchema({ type: 'email' })).toEqual({ type: 'string' })
16+
})
17+
18+
it('maps number with min/max', () => {
19+
expect(fieldToJsonSchema({ type: 'number', min: 1, max: 10 })).toEqual({
20+
type: 'number',
21+
minimum: 1,
22+
maximum: 10,
23+
})
24+
})
25+
26+
it('maps boolean and checkbox', () => {
27+
expect(fieldToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
28+
expect(fieldToJsonSchema({ type: 'checkbox' })).toEqual({ type: 'boolean' })
29+
})
30+
31+
it('maps date/datetime to date-time string', () => {
32+
expect(fieldToJsonSchema({ type: 'datetime' })).toEqual({ type: 'string', format: 'date-time' })
33+
})
34+
35+
it('maps select/radio to enum string', () => {
36+
expect(fieldToJsonSchema({ type: 'select', enum: ['a', 'b'] })).toEqual({
37+
type: 'string',
38+
enum: ['a', 'b'],
39+
})
40+
})
41+
42+
it('maps multiselect to array of enum strings', () => {
43+
expect(fieldToJsonSchema({ type: 'multiselect', enum: ['a', 'b'] })).toEqual({
44+
type: 'array',
45+
items: { type: 'string', enum: ['a', 'b'] },
46+
})
47+
})
48+
49+
it('maps array with nested items', () => {
50+
expect(fieldToJsonSchema({ type: 'array', items: { type: 'number' } })).toEqual({
51+
type: 'array',
52+
items: { type: 'number' },
53+
})
54+
})
55+
56+
it('maps object with nested properties', () => {
57+
expect(fieldToJsonSchema({ type: 'object', properties: { a: { type: 'string' } } })).toEqual({
58+
type: 'object',
59+
properties: { a: { type: 'string' } },
60+
})
61+
})
62+
63+
it('carries title and description through', () => {
64+
expect(fieldToJsonSchema({ type: 'string', title: 'Name', description: 'Full name' })).toEqual({
65+
type: 'string',
66+
title: 'Name',
67+
description: 'Full name',
68+
})
69+
})
70+
})
71+
72+
describe('collectionToJsonSchema', () => {
73+
const schema: CollectionSchema = {
74+
type: 'object',
75+
properties: {
76+
id: { type: 'string' },
77+
created_at: { type: 'datetime' },
78+
title: { type: 'string', required: true },
79+
body: { type: 'richtext' },
80+
secret: { type: 'string' },
81+
},
82+
required: ['title'],
83+
}
84+
85+
it('produces an object schema with all properties by default', () => {
86+
const out = collectionToJsonSchema(schema)
87+
expect(out.type).toBe('object')
88+
expect(Object.keys(out.properties as object)).toEqual(['id', 'created_at', 'title', 'body', 'secret'])
89+
expect(out.required).toEqual(['title'])
90+
})
91+
92+
it('strips system fields when forWrite', () => {
93+
const out = collectionToJsonSchema(schema, { forWrite: true })
94+
const keys = Object.keys(out.properties as object)
95+
expect(keys).not.toContain('id')
96+
expect(keys).not.toContain('created_at')
97+
expect(keys).toContain('title')
98+
})
99+
100+
it('omits redacted fields', () => {
101+
const out = collectionToJsonSchema(schema, { redactFields: ['secret'] })
102+
expect(Object.keys(out.properties as object)).not.toContain('secret')
103+
})
104+
105+
it('unions field-level and schema-level required', () => {
106+
const out = collectionToJsonSchema({
107+
type: 'object',
108+
properties: { a: { type: 'string', required: true }, b: { type: 'string' } },
109+
required: ['b'],
110+
})
111+
expect((out.required as string[]).sort()).toEqual(['a', 'b'])
112+
})
113+
114+
it('omits required key entirely when nothing is required', () => {
115+
const out = collectionToJsonSchema({ type: 'object', properties: { a: { type: 'string' } } })
116+
expect('required' in out).toBe(false)
117+
})
118+
})
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { parseJsonRpc, rpcResult, rpcError, JSON_RPC } from '../jsonrpc'
3+
4+
describe('parseJsonRpc', () => {
5+
it('accepts a well-formed request and defaults params to {}', () => {
6+
const r = parseJsonRpc({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
7+
expect('error' in r).toBe(false)
8+
if (!('error' in r)) {
9+
expect(r.method).toBe('tools/list')
10+
expect(r.id).toBe(1)
11+
expect(r.params).toEqual({})
12+
}
13+
})
14+
15+
it('preserves provided params', () => {
16+
const r = parseJsonRpc({ jsonrpc: '2.0', id: 'a', method: 'tools/call', params: { name: 'x' } })
17+
if (!('error' in r)) expect(r.params).toEqual({ name: 'x' })
18+
})
19+
20+
it('normalizes a missing id to null', () => {
21+
const r = parseJsonRpc({ jsonrpc: '2.0', method: 'initialize' })
22+
if (!('error' in r)) expect(r.id).toBeNull()
23+
})
24+
25+
it('rejects a non-object body', () => {
26+
const r = parseJsonRpc('nope')
27+
expect('error' in r && r.error.code).toBe(JSON_RPC.INVALID_REQUEST)
28+
})
29+
30+
it('rejects an array body', () => {
31+
const r = parseJsonRpc([{ jsonrpc: '2.0', method: 'x' }])
32+
expect('error' in r && r.error.code).toBe(JSON_RPC.INVALID_REQUEST)
33+
})
34+
35+
it('rejects a wrong jsonrpc version', () => {
36+
const r = parseJsonRpc({ jsonrpc: '1.0', method: 'x' })
37+
expect('error' in r && r.error.code).toBe(JSON_RPC.INVALID_REQUEST)
38+
})
39+
40+
it('rejects a missing/empty method', () => {
41+
expect('error' in parseJsonRpc({ jsonrpc: '2.0', id: 1 })).toBe(true)
42+
expect('error' in parseJsonRpc({ jsonrpc: '2.0', id: 1, method: '' })).toBe(true)
43+
})
44+
})
45+
46+
describe('rpc envelopes', () => {
47+
it('builds a success envelope', () => {
48+
expect(rpcResult(1, { ok: true })).toEqual({ jsonrpc: '2.0', id: 1, result: { ok: true } })
49+
})
50+
51+
it('coerces undefined id to null', () => {
52+
expect(rpcResult(undefined as any, {}).id).toBeNull()
53+
expect(rpcError(undefined as any, JSON_RPC.INTERNAL_ERROR, 'x').id).toBeNull()
54+
})
55+
56+
it('builds an error envelope and only includes data when provided', () => {
57+
expect(rpcError(2, JSON_RPC.METHOD_NOT_FOUND, 'nope')).toEqual({
58+
jsonrpc: '2.0',
59+
id: 2,
60+
error: { code: JSON_RPC.METHOD_NOT_FOUND, message: 'nope' },
61+
})
62+
expect(rpcError(2, JSON_RPC.INVALID_PARAMS, 'bad', { field: 'x' }).error.data).toEqual({ field: 'x' })
63+
})
64+
})

0 commit comments

Comments
 (0)