fix(mcp): add shared operations and secure scene APIs
This commit is contained in:
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { registerDeleteScene } from './delete-scene'
|
||||
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
|
||||
|
||||
@@ -14,8 +19,9 @@ describe('delete_scene', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerDeleteScene(server, store)
|
||||
registerDeleteScene(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)])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
|
||||
export const deleteSceneInput = {
|
||||
@@ -12,7 +13,7 @@ export const deleteSceneOutput = {
|
||||
deleted: z.boolean(),
|
||||
}
|
||||
|
||||
export function registerDeleteScene(server: McpServer, store: SceneStore): void {
|
||||
export function registerDeleteScene(server: McpServer, operations: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'delete_scene',
|
||||
{
|
||||
@@ -24,7 +25,7 @@ export function registerDeleteScene(server: McpServer, store: SceneStore): void
|
||||
},
|
||||
async ({ id, expectedVersion }) => {
|
||||
try {
|
||||
const deleted = await store.delete(id, {
|
||||
const deleted = await operations.deleteStoredScene(id, {
|
||||
...(expectedVersion !== undefined ? { expectedVersion } : {}),
|
||||
})
|
||||
const payload = { deleted }
|
||||
|
||||
@@ -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 { registerDeleteScene } from './delete-scene'
|
||||
import { registerListScenes } from './list-scenes'
|
||||
import { registerLoadScene } from './load-scene'
|
||||
@@ -10,19 +9,15 @@ import { registerSaveScene } from './save-scene'
|
||||
/**
|
||||
* Register the scene-lifecycle MCP tools (`save_scene`, `load_scene`,
|
||||
* `list_scenes`, `delete_scene`, `rename_scene`) against the given server.
|
||||
* All tools operate against the supplied `SceneStore` so tests can inject an
|
||||
* in-memory implementation.
|
||||
* All tools operate against shared scene operations so MCP, REST, and future CLI
|
||||
* entry points share the same storage boundary.
|
||||
*/
|
||||
export function registerSceneLifecycleTools(
|
||||
server: McpServer,
|
||||
bridge: SceneBridge,
|
||||
store: SceneStore,
|
||||
): void {
|
||||
registerSaveScene(server, bridge, store)
|
||||
registerLoadScene(server, bridge, store)
|
||||
registerListScenes(server, store)
|
||||
registerDeleteScene(server, store)
|
||||
registerRenameScene(server, store)
|
||||
export function registerSceneLifecycleTools(server: McpServer, operations: SceneOperations): void {
|
||||
registerSaveScene(server, operations)
|
||||
registerLoadScene(server, operations)
|
||||
registerListScenes(server, operations)
|
||||
registerDeleteScene(server, operations)
|
||||
registerRenameScene(server, operations)
|
||||
}
|
||||
|
||||
export { deleteSceneInput, deleteSceneOutput, registerDeleteScene } from './delete-scene'
|
||||
|
||||
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { registerListScenes } from './list-scenes'
|
||||
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
|
||||
|
||||
@@ -14,8 +19,9 @@ describe('list_scenes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerListScenes(server, store)
|
||||
registerListScenes(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)])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneStore } from '../../storage/types'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
@@ -27,7 +27,7 @@ export const listScenesOutput = {
|
||||
),
|
||||
}
|
||||
|
||||
export function registerListScenes(server: McpServer, store: SceneStore): void {
|
||||
export function registerListScenes(server: McpServer, operations: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'list_scenes',
|
||||
{
|
||||
@@ -39,7 +39,7 @@ export function registerListScenes(server: McpServer, store: SceneStore): void {
|
||||
},
|
||||
async ({ projectId, limit }) => {
|
||||
try {
|
||||
const scenes = await store.list({
|
||||
const scenes = await operations.listScenes({
|
||||
...(projectId !== undefined ? { projectId } : {}),
|
||||
limit: limit ?? DEFAULT_LIMIT,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { registerLoadScene } from './load-scene'
|
||||
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
describe('load_scene', () => {
|
||||
let client: Client
|
||||
@@ -17,8 +22,9 @@ describe('load_scene', () => {
|
||||
bridge.setScene({}, [])
|
||||
bridge.loadDefault()
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ bridge, store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerLoadScene(server, bridge, store)
|
||||
registerLoadScene(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)])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import type { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import type { SceneStore } from '../../storage/types'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
|
||||
export const loadSceneInput = {
|
||||
@@ -21,7 +20,7 @@ export const loadSceneOutput = {
|
||||
nodeCount: z.number(),
|
||||
}
|
||||
|
||||
export function registerLoadScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
|
||||
export function registerLoadScene(server: McpServer, bridge: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'load_scene',
|
||||
{
|
||||
@@ -32,7 +31,7 @@ export function registerLoadScene(server: McpServer, bridge: SceneBridge, store:
|
||||
outputSchema: loadSceneOutput,
|
||||
},
|
||||
async ({ id }) => {
|
||||
const result = await store.load(id)
|
||||
const result = await bridge.loadStoredScene(id)
|
||||
if (!result) {
|
||||
throwMcpError(ErrorCode.InvalidParams, 'scene_not_found', { id })
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { registerRenameScene } from './rename-scene'
|
||||
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
const emptyGraph: SceneGraph = { nodes: {}, rootNodeIds: [] }
|
||||
|
||||
@@ -14,8 +19,9 @@ describe('rename_scene', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerRenameScene(server, store)
|
||||
registerRenameScene(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)])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
import { SceneNotFoundError, type SceneStore, SceneVersionConflictError } from '../../storage/types'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { SceneNotFoundError, SceneVersionConflictError } from '../../storage/types'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
|
||||
export const renameSceneInput = {
|
||||
@@ -22,7 +23,7 @@ export const renameSceneOutput = {
|
||||
nodeCount: z.number(),
|
||||
}
|
||||
|
||||
export function registerRenameScene(server: McpServer, store: SceneStore): void {
|
||||
export function registerRenameScene(server: McpServer, operations: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'rename_scene',
|
||||
{
|
||||
@@ -34,7 +35,7 @@ export function registerRenameScene(server: McpServer, store: SceneStore): void
|
||||
},
|
||||
async ({ id, newName, expectedVersion }) => {
|
||||
try {
|
||||
const meta = await store.rename(id, newName, {
|
||||
const meta = await operations.renameStoredScene(id, newName, {
|
||||
...(expectedVersion !== undefined ? { expectedVersion } : {}),
|
||||
})
|
||||
const payload = {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { registerSaveScene } from './save-scene'
|
||||
import { InMemorySceneStore, parseToolText, type StoredTextContent } from './test-utils'
|
||||
import {
|
||||
createTestSceneOperations,
|
||||
InMemorySceneStore,
|
||||
parseToolText,
|
||||
type StoredTextContent,
|
||||
} from './test-utils'
|
||||
|
||||
describe('save_scene', () => {
|
||||
let client: Client
|
||||
@@ -16,8 +21,9 @@ describe('save_scene', () => {
|
||||
bridge.setScene({}, [])
|
||||
bridge.loadDefault()
|
||||
store = new InMemorySceneStore()
|
||||
const { operations } = createTestSceneOperations({ bridge, store })
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' })
|
||||
registerSaveScene(server, bridge, store)
|
||||
registerSaveScene(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)])
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
||||
import { AnyNode } from '@pascal-app/core/schema'
|
||||
import { z } from 'zod'
|
||||
import type { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { type SceneStore, SceneVersionConflictError } from '../../storage/types'
|
||||
import type { SceneOperations } from '../../operations'
|
||||
import { SceneVersionConflictError } from '../../storage/types'
|
||||
import { ErrorCode, throwMcpError } from '../errors'
|
||||
import { appendLiveSceneEvent } from '../live-sync'
|
||||
|
||||
@@ -39,7 +39,7 @@ export const saveSceneOutput = {
|
||||
url: z.string(),
|
||||
}
|
||||
|
||||
export function registerSaveScene(server: McpServer, bridge: SceneBridge, store: SceneStore): void {
|
||||
export function registerSaveScene(server: McpServer, bridge: SceneOperations): void {
|
||||
server.registerTool(
|
||||
'save_scene',
|
||||
{
|
||||
@@ -56,12 +56,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
|
||||
if (!validation.valid) {
|
||||
throwMcpError(ErrorCode.InvalidRequest, 'scene_invalid', { errors: validation.errors })
|
||||
}
|
||||
const exported = bridge.exportJSON()
|
||||
sceneGraph = {
|
||||
nodes: exported.nodes,
|
||||
rootNodeIds: exported.rootNodeIds,
|
||||
collections: exported.collections as SceneGraph['collections'],
|
||||
}
|
||||
sceneGraph = bridge.exportSceneGraph()
|
||||
} else {
|
||||
if (!graph) {
|
||||
throwMcpError(
|
||||
@@ -96,7 +91,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await store.save({
|
||||
const meta = await bridge.saveScene({
|
||||
...(id !== undefined ? { id } : {}),
|
||||
name,
|
||||
...(projectId !== undefined ? { projectId } : {}),
|
||||
@@ -104,7 +99,7 @@ export function registerSaveScene(server: McpServer, bridge: SceneBridge, store:
|
||||
...(thumbnail !== undefined ? { thumbnailUrl: thumbnail } : {}),
|
||||
...(expectedVersion !== undefined ? { expectedVersion } : {}),
|
||||
})
|
||||
await appendLiveSceneEvent(store, meta.id, meta.version, 'save_scene', sceneGraph)
|
||||
await appendLiveSceneEvent(bridge, meta.id, meta.version, 'save_scene', sceneGraph)
|
||||
if (includeCurrentScene) {
|
||||
bridge.setActiveScene(meta)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SceneBridge } from '../../bridge/scene-bridge'
|
||||
import { createSceneOperations, type SceneOperations } from '../../operations'
|
||||
import {
|
||||
type SceneListOptions,
|
||||
type SceneMeta,
|
||||
@@ -15,6 +17,20 @@ export function parseToolText(content: StoredTextContent[]): Record<string, unkn
|
||||
return JSON.parse(content[0]!.text) as Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createTestSceneOperations(options?: {
|
||||
bridge?: SceneBridge
|
||||
store?: InMemorySceneStore
|
||||
}): {
|
||||
bridge: SceneBridge
|
||||
store: InMemorySceneStore
|
||||
operations: SceneOperations
|
||||
} {
|
||||
const bridge = options?.bridge ?? new SceneBridge()
|
||||
const store = options?.store ?? new InMemorySceneStore()
|
||||
const operations = createSceneOperations({ bridge, store })
|
||||
return { bridge, store, operations }
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory `SceneStore` for tests. Backed by a plain `Map` keyed by id.
|
||||
* Implements the full interface including optimistic concurrency via
|
||||
|
||||
Reference in New Issue
Block a user