fix(mcp): add shared operations and secure scene APIs

This commit is contained in:
Aymeric Rabot
2026-04-27 17:10:25 -04:00
parent 058ee747c9
commit 8dfd1a9429
81 changed files with 1332 additions and 1786 deletions
@@ -2,9 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { cloneSceneGraph } from '@pascal-app/core/clone-scene-graph'
import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
import { z } from 'zod'
import type { SceneBridge } from '../../bridge/scene-bridge'
import { rehydrateSiteChildren } from '../../lib/rehydrate-site-children'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { isTemplateId, TEMPLATES, type TemplateId } from '../../templates'
import { ErrorCode, throwMcpError } from '../errors'
import { appendLiveSceneEvent } from '../live-sync'
@@ -22,9 +21,10 @@ export const createFromTemplateInput = {
.optional()
.describe('Optional display name for the saved scene. Defaults to the template name.'),
/**
* When a `SceneStore` is wired into the MCP server, set this flag to `true`
* to immediately save the instantiated template and return its `SceneMeta`.
* When `false` (default) the template is applied to the bridge only.
* When persistence operations are wired into the MCP server, set this flag to
* `true` to immediately save the instantiated template and return its
* `SceneMeta`. When `false` (default) the template is applied to the bridge
* only.
*/
save: z.boolean().default(false),
projectId: z.string().optional(),
@@ -54,17 +54,13 @@ export const createFromTemplateOutput = {
/**
* `create_from_template` — instantiate a seed template into the bridge, and
* optionally persist it via the attached `SceneStore`.
* optionally persist it via the attached scene operations.
*
* The source template is cloned with fresh ids (`cloneSceneGraph`) so the
* deterministic placeholders (`site_empty`, `wall_n`, …) don't collide
* across repeated calls or with other scenes.
*/
export function registerCreateFromTemplate(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerCreateFromTemplate(server: McpServer, bridge: SceneOperations): void {
server.registerTool(
'create_from_template',
{
@@ -112,7 +108,7 @@ export function registerCreateFromTemplate(
}
}
if (!store) {
if (!bridge.hasStore) {
// Graceful no-store mode: report that save was skipped rather than
// erroring — this makes the tool usable in headless bridge-only
// deployments (tests, smoke scripts) without crashing.
@@ -125,13 +121,13 @@ export function registerCreateFromTemplate(
}
try {
const meta = await store.save({
const meta = await bridge.saveScene({
name: name ?? entry.name,
...(projectId !== undefined ? { projectId } : {}),
graph: { nodes, rootNodeIds },
})
bridge.setActiveScene(meta)
await appendLiveSceneEvent(store, meta.id, meta.version, 'create_from_template', {
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'create_from_template', {
nodes,
rootNodeIds,
})
+5 -11
View File
@@ -1,6 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { SceneBridge } from '../../bridge/scene-bridge'
import type { SceneStore } from '../../storage/types'
import type { SceneOperations } from '../../operations'
import { registerCreateFromTemplate } from './create-from-template'
import { registerListTemplates } from './list-templates'
@@ -8,17 +7,12 @@ import { registerListTemplates } from './list-templates'
* Register the template MCP tools (`list_templates`, `create_from_template`)
* against the given server.
*
* `store` is optional: when omitted, `create_from_template` still applies the
* template to the bridge but skips the save step. This makes the tool safe
* to wire into headless bridge-only deployments.
* When persistence operations are unavailable, `create_from_template` still
* applies the template to the bridge but skips the save step.
*/
export function registerTemplateTools(
server: McpServer,
bridge: SceneBridge,
store?: SceneStore,
): void {
export function registerTemplateTools(server: McpServer, bridge: SceneOperations): void {
registerListTemplates(server)
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, bridge)
}
export {
@@ -3,6 +3,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { SceneBridge } from '../../bridge/scene-bridge'
import { createSceneOperations } from '../../operations'
import {
InMemorySceneStore,
parseToolText,
@@ -59,8 +60,9 @@ describe('create_from_template', () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
store = new InMemorySceneStore()
const operations = createSceneOperations({ bridge, store })
const server = new McpServer({ name: 'test', version: '0.0.0' })
registerCreateFromTemplate(server, bridge, store)
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])
@@ -138,9 +140,10 @@ describe('create_from_template without a store', () => {
beforeEach(async () => {
bridge = new SceneBridge()
bridge.setScene({}, [])
const operations = createSceneOperations({ bridge })
const server = new McpServer({ name: 'test', version: '0.0.0' })
// No store passed → save should be gracefully skipped.
registerCreateFromTemplate(server, bridge)
// No store in operations → save should be gracefully skipped.
registerCreateFromTemplate(server, operations)
const [srvT, cliT] = InMemoryTransport.createLinkedPair()
client = new Client({ name: 'test-client', version: '0.0.0' })
await Promise.all([server.connect(srvT), client.connect(cliT)])