From 3e771908f71b83573c7866b506392cbadd7ad6a3 Mon Sep 17 00:00:00 2001 From: pegit-h <289013543+pegit-h@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:29:35 +0100 Subject: [PATCH 1/2] Allow MCP bridge to validate registered plugin nodes --- packages/mcp/src/bridge/scene-bridge.test.ts | 52 ++++++++++++++++++++ packages/mcp/src/bridge/scene-bridge.ts | 22 +++++++-- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts index 141f9e61f2..72d9ff0f06 100644 --- a/packages/mcp/src/bridge/scene-bridge.test.ts +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -1,13 +1,18 @@ import { beforeEach, describe, expect, test } from 'bun:test' +import { loadPlugin, nodeRegistry } from '@pascal-app/core' import { + BaseNode, BuildingNode, DoorNode, ItemNode, LevelNode, + nodeType, + objectId, SiteNode, WallNode, ZoneNode, } from '@pascal-app/core/schema' +import { z } from 'zod' import { SceneBridge } from './scene-bridge' function tick() { @@ -201,6 +206,53 @@ describe('SceneBridge', () => { }) describe('applyPatch', () => { + test('accepts registered plugin nodes in create patches and validation', async () => { + const restoreRegistry = nodeRegistry._snapshot() + const PluginProbe = BaseNode.extend({ + id: objectId('pluginprobe'), + type: nodeType('fixture:mcp-probe'), + value: z.number().default(1), + }) + + try { + await loadPlugin({ + id: 'fixture:mcp-probe-pack', + apiVersion: 1, + nodes: [ + { + kind: 'fixture:mcp-probe', + schemaVersion: 1, + schema: PluginProbe, + category: 'utility', + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + value: 1, + }), + capabilities: {}, + } as any, + ], + }) + + const level = bridge.findNodes({ type: 'level' })[0]! + const probe = PluginProbe.parse({ value: 7 }) + + const result = bridge.applyPatch([{ op: 'create', node: probe as any, parentId: level.id }]) + + expect(result.createdIds).toContain(probe.id) + expect(bridge.getNode(probe.id as any)).toMatchObject({ + type: 'fixture:mcp-probe', + value: 7, + parentId: level.id, + }) + expect(bridge.validateScene()).toEqual({ valid: true, errors: [], warnings: [] }) + } finally { + restoreRegistry() + } + }) + test('applies mixed create/update/delete atomically', async () => { const level = bridge.findNodes({ type: 'level' })[0]! const wallA = WallNode.parse({ start: [0, 0], end: [2, 0] }) diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index c134e31c28..03ca89b826 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -6,6 +6,7 @@ import { HIDDEN_SITE_NOTE, type NodeDeletionPlan, type NodeDeletionScene, + nodeRegistry, pauseSceneHistory, pauseSpaceDetection, planNodeDeletion, @@ -25,7 +26,6 @@ import { import type { AnyNode, Collection, CollectionId, SceneMaterialId } from '@pascal-app/core/schema' import { type AnyNodeId, - AnyNode as AnyNodeSchema, type AnyNodeType, generateId, parseNode, @@ -34,6 +34,22 @@ import { import type { SceneMeta } from '../storage/types' import { InMemoryAgentSession } from './agent-session' +type ParsedNode = ReturnType + +function parseRegisteredNode(node: unknown): ParsedNode { + const builtin = parseNode(node) + if (builtin.success) return builtin + if (!(node && typeof node === 'object' && !Array.isArray(node))) return builtin + + const type = (node as { type?: unknown }).type + if (typeof type !== 'string') return builtin + + const registered = nodeRegistry.get(type)?.schema.safeParse(node) + return registered?.success + ? ({ success: true, data: registered.data as AnyNode } as ParsedNode) + : builtin +} + export type ValidationError = { nodeId: string; path: string; message: string } export type ValidationResult = { valid: boolean @@ -487,7 +503,7 @@ export class SceneBridge { ) materialUpserts.set(parsed.data.id, parsed.data) } else if (p.op === 'create') { - const res = parseNode(p.node) + const res = parseRegisteredNode(p.node) if (!res.success) { throw new Error( `invalid patch: patches[${i}] create node failed schema: ${res.error.message}`, @@ -693,7 +709,7 @@ export class SceneBridge { if (node.type === 'site' && node.visible === false) { warnings.push({ nodeId: id, path: 'visible', message: HIDDEN_SITE_NOTE }) } - const res = AnyNodeSchema.safeParse(node) + const res = parseRegisteredNode(node) if (res.success) continue for (const issue of res.error.issues) { errors.push({ From 983766a89dbca2e1128d46e664e6a4e2dc10a458 Mon Sep 17 00:00:00 2001 From: Patrik Erblad Date: Fri, 6 Nov 2026 16:20:19 +0100 Subject: [PATCH 2/2] Preserve plugin schema validation errors --- packages/mcp/src/bridge/scene-bridge.test.ts | 23 ++++++++++++++++++++ packages/mcp/src/bridge/scene-bridge.ts | 6 ++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/mcp/src/bridge/scene-bridge.test.ts b/packages/mcp/src/bridge/scene-bridge.test.ts index 72d9ff0f06..3cb198882e 100644 --- a/packages/mcp/src/bridge/scene-bridge.test.ts +++ b/packages/mcp/src/bridge/scene-bridge.test.ts @@ -248,6 +248,29 @@ describe('SceneBridge', () => { parentId: level.id, }) expect(bridge.validateScene()).toEqual({ valid: true, errors: [], warnings: [] }) + + const invalidProbe = { + ...probe, + id: 'pluginprobe_invalid', + value: 'not-a-number', + } + expect(() => + bridge.applyPatch([{ op: 'create', node: invalidProbe as any, parentId: level.id }]), + ).toThrow(/value/) + + const nodes = bridge.getNodes() + bridge.setScene( + { + ...nodes, + [probe.id]: { ...nodes[probe.id]!, value: 'not-a-number' } as any, + }, + bridge.getRootNodeIds(), + ) + const invalidScene = bridge.validateScene() + expect(invalidScene.valid).toBe(false) + expect(invalidScene.errors).toContainEqual( + expect.objectContaining({ nodeId: probe.id, path: 'value' }), + ) } finally { restoreRegistry() } diff --git a/packages/mcp/src/bridge/scene-bridge.ts b/packages/mcp/src/bridge/scene-bridge.ts index 03ca89b826..ecc2b393f2 100644 --- a/packages/mcp/src/bridge/scene-bridge.ts +++ b/packages/mcp/src/bridge/scene-bridge.ts @@ -44,10 +44,8 @@ function parseRegisteredNode(node: unknown): ParsedNode { const type = (node as { type?: unknown }).type if (typeof type !== 'string') return builtin - const registered = nodeRegistry.get(type)?.schema.safeParse(node) - return registered?.success - ? ({ success: true, data: registered.data as AnyNode } as ParsedNode) - : builtin + const registered = nodeRegistry.get(type)?.schema + return registered ? (registered.safeParse(node) as ParsedNode) : builtin } export type ValidationError = { nodeId: string; path: string; message: string }