From 711e372ebd3ae50c99be2e5ca4e53512194c839b Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Thu, 20 Aug 2026 14:22:16 +0800 Subject: [PATCH 1/3] refactor(scene): support pluggable scene command backends --- src/core/scene/main-process/rpc.ts | 183 ++++++++++++++---- .../main-process/scene-command-backend.ts | 58 ++++++ .../main-process/scene-host-local-executor.ts | 49 +++++ src/core/scene/test/main-process-rpc.test.ts | 182 +++++++++++++++++ 4 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 src/core/scene/main-process/scene-command-backend.ts create mode 100644 src/core/scene/main-process/scene-host-local-executor.ts create mode 100644 src/core/scene/test/main-process-rpc.test.ts diff --git a/src/core/scene/main-process/rpc.ts b/src/core/scene/main-process/rpc.ts index a76bd0c0f..25c6104e0 100644 --- a/src/core/scene/main-process/rpc.ts +++ b/src/core/scene/main-process/rpc.ts @@ -1,57 +1,168 @@ +import type { ChildProcess } from 'child_process'; +import type { IPublicServiceManager } from '../scene-process'; import { ProcessRPC } from '../process-rpc'; -import { ChildProcess } from 'child_process'; -import { assetManager } from '../../assets'; -import scriptManager from '../../scripting'; -import { sceneConfigInstance } from '../scene-configs'; -import i18n from '../../base/i18n'; +import { + SceneCommandBackend, + SceneCommandRequestOptions, + WorkerSceneCommandBackend, +} from './scene-command-backend'; +import { SceneHostLocalExecutor } from './scene-host-local-executor'; -import type { IPublicServiceManager } from '../scene-process'; +type AnySceneMethod = (...args: any[]) => any; +type SceneServiceMethod< + K extends keyof IPublicServiceManager, + M extends keyof IPublicServiceManager[K], +> = Extract; export { ProcessRPC }; +export type { SceneCommandBackend, SceneCommandRequestOptions } from './scene-command-backend'; +export { WorkerSceneCommandBackend } from './scene-command-backend'; +export { SceneHostLocalExecutor } from './scene-host-local-executor'; +export type { SceneHostModules } from './scene-host-local-executor'; + +/** `Rpc.getInstance()` 对调用方公开的最小接口。 */ +export interface SceneRpcClient { + request( + module: K, + method: M, + ...rest: Parameters> extends [] + ? [args?: [], options?: SceneCommandRequestOptions] + : [args: Parameters>, options?: SceneCommandRequestOptions] + ): Promise>>>; + + notify( + module: K, + method: M, + args?: Parameters>, + ): void; -export class RpcProxy { - private rpcInstance: ProcessRPC | null = null; + executeLocal(module: string, method: string, args?: any[]): Promise; + isConnect(): boolean | undefined; +} + +export class RpcProxy implements SceneRpcClient { + private commandBackend: SceneCommandBackend | null = null; + private hostLocalExecutor: SceneHostLocalExecutor | null = null; - public getInstance() { - if (!this.rpcInstance) { + public getInstance(): SceneRpcClient { + if (!this.hostLocalExecutor) { throw new Error('[Node] Rpc instance is not started!'); } - return this.rpcInstance; + return this; } - public isConnect() { - return this.rpcInstance?.isConnect(); + public isConnect(): boolean | undefined { + return this.commandBackend?.isConnect?.(); } - async startup(prc?: ChildProcess | NodeJS.Process) { - // 在创建新实例前,先清理旧实例,防止内存泄漏 + /** + * 保持原有的启动约定: + * - 传入 process 时使用 `WorkerSceneCommandBackend` 连接 Scene Worker; + * - 未传入 process 时,仅初始化供 Scene Webview Runtime 使用的 `SceneHostLocalExecutor`。 + */ + public async startup(process?: ChildProcess | NodeJS.Process): Promise { this.dispose(); - this.rpcInstance = new ProcessRPC(); - if (prc) { - this.rpcInstance.attach(prc); + const hostLocalExecutor = this.ensureHostLocalExecutor(); + if (process) { + this.commandBackend = new WorkerSceneCommandBackend(process, hostLocalExecutor); } - this.rpcInstance.register({ - assetManager: assetManager, - programming: scriptManager, - sceneConfigInstance: sceneConfigInstance, - i18n: i18n, - }); - console.log(`[Node] Scene Process RPC ready ${prc ? '(Attached)' : '(Detached - Web Mode)'}`); + console.log(`[Node] Scene Process RPC ready ${process ? '(Attached)' : '(Detached - Web Mode)'}`); } /** - * 清理 RPC 实例 + * 安装宿主指定的 `SceneCommandBackend`,例如桥接 PinK Scene Webview Runtime 的 backend。 + * 安装前会释放当前 backend;新 backend 抛出的错误直接向上传播,不切换到其他 backend 重试。 */ - dispose(): void { - if (this.rpcInstance) { - console.log('[Node] Disposing RPC instance'); - try { - this.rpcInstance.dispose(); - } catch (error) { - console.warn('[Node] Error disposing RPC instance:', error); - } finally { - this.rpcInstance = null; - } + public installBackend(backend: SceneCommandBackend): void { + if (!backend || typeof backend.request !== 'function') { + throw new TypeError('[Node] Scene command backend must implement request()'); + } + if (backend === this.commandBackend) { + return; + } + + this.ensureHostLocalExecutor(); + this.disposeCommandBackend(); + this.commandBackend = backend; + console.log('[Node] Scene command backend installed'); + } + + public request( + module: K, + method: M, + ...rest: Parameters> extends [] + ? [args?: [], options?: SceneCommandRequestOptions] + : [args: Parameters>, options?: SceneCommandRequestOptions] + ): Promise>>> { + const backend = this.commandBackend; + if (!backend) { + return Promise.reject(new Error('RPC 尚未挂载进程且未安装 Scene command backend')); + } + + const [args, options] = rest; + return backend.request( + String(module), + String(method), + (args ?? []) as any[], + options, + ) as Promise>>>; + } + + public notify( + module: K, + method: M, + args?: Parameters>, + ): void { + const backend = this.commandBackend; + if (!backend) { + throw new Error('RPC 尚未挂载进程且未安装 Scene command backend'); + } + if (!backend.notify) { + throw new Error('[Node] Scene command backend does not support notify()'); + } + backend.notify(String(module), String(method), (args ?? []) as any[]); + } + + public executeLocal(module: string, method: string, args: any[] = []): Promise { + const executor = this.hostLocalExecutor; + if (!executor) { + return Promise.reject(new Error('[Node] Scene host local executor is not started!')); + } + return executor.executeLocal(module, method, args); + } + + /** 同时释放 `SceneCommandBackend` 和 `SceneHostLocalExecutor`。 */ + public dispose(): void { + if (!this.commandBackend && !this.hostLocalExecutor) { + return; + } + + console.log('[Node] Disposing RPC instance'); + this.disposeCommandBackend(); + try { + this.hostLocalExecutor?.dispose(); + } catch (error) { + console.warn('[Node] Error disposing Scene host local executor:', error); + } finally { + this.hostLocalExecutor = null; + } + } + + private ensureHostLocalExecutor(): SceneHostLocalExecutor { + this.hostLocalExecutor ??= new SceneHostLocalExecutor(); + return this.hostLocalExecutor; + } + + private disposeCommandBackend(): void { + const backend = this.commandBackend; + this.commandBackend = null; + if (!backend?.dispose) { + return; + } + try { + backend.dispose(); + } catch (error) { + console.warn('[Node] Error disposing Scene command backend:', error); } } } diff --git a/src/core/scene/main-process/scene-command-backend.ts b/src/core/scene/main-process/scene-command-backend.ts new file mode 100644 index 000000000..7f7a4d21b --- /dev/null +++ b/src/core/scene/main-process/scene-command-backend.ts @@ -0,0 +1,58 @@ +import type { ChildProcess } from 'child_process'; +import { ProcessRPC } from '../process-rpc'; +import type { IPublicServiceManager } from '../scene-process'; +import type { SceneHostLocalExecutor } from './scene-host-local-executor'; + +export interface SceneCommandRequestOptions { + timeout?: number; +} + +/** + * `SceneCommandBackend` 定义 Scene command 的发送方式。 + * 每次调用只以当前 backend 的结果为准;发生错误时不得切换到其他 backend 重试。 + */ +export interface SceneCommandBackend { + request( + module: string, + method: string, + args?: any[], + options?: SceneCommandRequestOptions, + ): Promise; + notify?(module: string, method: string, args?: any[]): void; + isConnect?(): boolean | undefined; + dispose?(): void; +} + +/** cocos-cli 独立运行时使用的默认 backend,负责连接 Scene Worker。 */ +export class WorkerSceneCommandBackend implements SceneCommandBackend { + private readonly rpc = new ProcessRPC(); + + constructor( + process: ChildProcess | NodeJS.Process, + hostLocalExecutor: SceneHostLocalExecutor, + ) { + this.rpc.attach(process); + hostLocalExecutor.registerWith(this.rpc); + } + + public request( + module: string, + method: string, + args: any[] = [], + options?: SceneCommandRequestOptions, + ): Promise { + return this.rpc.request(module as any, method as any, args as any, options); + } + + public notify(module: string, method: string, args: any[] = []): void { + this.rpc.notify(module as any, method as any, args as any); + } + + public isConnect(): boolean | undefined { + return this.rpc.isConnect(); + } + + public dispose(): void { + this.rpc.dispose(); + } +} diff --git a/src/core/scene/main-process/scene-host-local-executor.ts b/src/core/scene/main-process/scene-host-local-executor.ts new file mode 100644 index 000000000..e40695587 --- /dev/null +++ b/src/core/scene/main-process/scene-host-local-executor.ts @@ -0,0 +1,49 @@ +import { assetManager } from '../../assets'; +import scriptManager from '../../scripting'; +import i18n from '../../base/i18n'; +import { ProcessRPC } from '../process-rpc'; +import { sceneConfigInstance } from '../scene-configs'; + +export interface SceneHostModules { + assetManager: typeof assetManager; + programming: typeof scriptManager; + sceneConfigInstance: typeof sceneConfigInstance; + i18n: typeof i18n; +} + +/** + * `SceneHostLocalExecutor` 在 Scene Host Process 中处理 Scene Runtime 发起的反向 RPC; + * PinK 模式下,该 Process 为 cocosHost。 + * + * `SceneHostLocalExecutor` 不依赖具体 transport。Scene Webview Runtime 通过 HTTP + * `/rpc/:module/:method` 路由调用它,`WorkerSceneCommandBackend` 则将同一组 host modules + * 注册到与 Scene Worker 连接的 `ProcessRPC`。 + */ +export class SceneHostLocalExecutor { + private readonly rpc = new ProcessRPC(); + + constructor(private readonly modules: SceneHostModules = { + assetManager, + programming: scriptManager, + sceneConfigInstance, + i18n, + }) { + this.rpc.register(modules); + } + + public executeLocal(module: string, method: string, args: any[] = []): Promise { + return this.rpc.executeLocal(module as any, method as any, args); + } + + /** + * 将 host modules 注册到 Scene Worker 使用的 `ProcessRPC`,以处理 Scene Worker 发起的反向 RPC; + * `SceneHostLocalExecutor` 本身不绑定该 transport。 + */ + public registerWith(rpc: ProcessRPC): void { + rpc.register(this.modules); + } + + public dispose(): void { + this.rpc.dispose(); + } +} diff --git a/src/core/scene/test/main-process-rpc.test.ts b/src/core/scene/test/main-process-rpc.test.ts new file mode 100644 index 000000000..aea35c274 --- /dev/null +++ b/src/core/scene/test/main-process-rpc.test.ts @@ -0,0 +1,182 @@ +import { EventEmitter } from 'events'; + +const mockQueryAssetInfo = jest.fn((uuid: string) => ({ uuid })); +const mockProgrammingCall = jest.fn(async (value: string) => `programming:${value}`); +const mockConfigGet = jest.fn(async (key: string) => `config:${key}`); +const mockGetBundle = jest.fn(async () => ({ lang: 'en', data: {} })); + +jest.mock('../../assets', () => ({ + assetManager: { + queryAssetInfo: (uuid: string) => mockQueryAssetInfo(uuid), + }, +})); + +jest.mock('../../scripting', () => ({ + __esModule: true, + default: { + testCall: (value: string) => mockProgrammingCall(value), + }, +})); + +jest.mock('../scene-configs', () => ({ + sceneConfigInstance: { + get: (key: string) => mockConfigGet(key), + }, +})); + +jest.mock('../../base/i18n', () => ({ + __esModule: true, + default: { + getBundle: () => mockGetBundle(), + }, +})); + +import type { SceneCommandBackend } from '../main-process/rpc'; +import { RpcProxy } from '../main-process/rpc'; + +interface FakeProcess extends EventEmitter { + connected: boolean; + send: jest.Mock; +} + +function createFakeProcess(): FakeProcess { + const process = new EventEmitter() as FakeProcess; + process.connected = true; + process.send = jest.fn(); + return process; +} + +async function flushEvents(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('main-process Scene RPC backends', () => { + let rpc: RpcProxy; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + rpc = new RpcProxy(); + mockQueryAssetInfo.mockClear(); + mockProgrammingCall.mockClear(); + mockConfigGet.mockClear(); + mockGetBundle.mockClear(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + rpc.dispose(); + logSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('preserves getInstance startup and detached Webview reverse-call behavior', async () => { + expect(() => rpc.getInstance()).toThrow('[Node] Rpc instance is not started!'); + + await rpc.startup(); + const facade = rpc.getInstance(); + + await expect(facade.executeLocal('assetManager', 'queryAssetInfo', ['asset-uuid'])) + .resolves.toEqual({ uuid: 'asset-uuid' }); + await expect(facade.executeLocal('programming', 'testCall', ['value'])) + .resolves.toBe('programming:value'); + await expect(facade.executeLocal('sceneConfigInstance', 'get', ['camera'])) + .resolves.toBe('config:camera'); + await expect(facade.executeLocal('i18n', 'getBundle')) + .resolves.toEqual({ lang: 'en', data: {} }); + + expect(facade.isConnect()).toBeUndefined(); + await expect(facade.request('Editor', 'hasOpen')) + .rejects.toThrow('RPC 尚未挂载进程且未安装 Scene command backend'); + }); + + it('uses the Worker backend by default and registers host modules for reverse calls', async () => { + const workerProcess = createFakeProcess(); + workerProcess.send.mockImplementation((message: any) => { + if (message?.type === 'request') { + setImmediate(() => workerProcess.emit('message', { + id: message.id, + type: 'response', + result: true, + })); + } + }); + + await rpc.startup(workerProcess as any); + const facade = rpc.getInstance(); + + await expect(facade.request('Editor', 'hasOpen')).resolves.toBe(true); + expect(facade.isConnect()).toBe(true); + expect(workerProcess.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'request', + module: 'Editor', + method: 'hasOpen', + args: [], + })); + + workerProcess.emit('message', { + id: 91, + type: 'request', + module: 'assetManager', + method: 'queryAssetInfo', + args: ['reverse-call-uuid'], + }); + await flushEvents(); + + expect(mockQueryAssetInfo).toHaveBeenCalledWith('reverse-call-uuid'); + expect(workerProcess.send).toHaveBeenCalledWith({ + id: 91, + type: 'response', + result: { uuid: 'reverse-call-uuid' }, + }); + }); + + it('installs an explicit Pink backend and never falls back after its request fails', async () => { + const workerProcess = createFakeProcess(); + await rpc.startup(workerProcess as any); + const facade = rpc.getInstance(); + const pinkFailure = new Error('Pink runtime rejected the command'); + const pinkBackend: SceneCommandBackend = { + request: jest.fn().mockRejectedValue(pinkFailure), + isConnect: jest.fn(() => true), + dispose: jest.fn(), + }; + + workerProcess.send.mockClear(); + rpc.installBackend(pinkBackend); + + await expect(facade.request('Editor', 'hasOpen')).rejects.toBe(pinkFailure); + expect(pinkBackend.request).toHaveBeenCalledWith('Editor', 'hasOpen', [], undefined); + expect(workerProcess.send).not.toHaveBeenCalled(); + expect(workerProcess.listenerCount('message')).toBe(0); + }); + + it('can install a host backend before startup while keeping local execution available', async () => { + const pinkBackend: SceneCommandBackend = { + request: jest.fn(async (_module, _method, args) => args?.[0]), + dispose: jest.fn(), + }; + + rpc.installBackend(pinkBackend); + const facade = rpc.getInstance(); + + await expect(facade.request('Editor', 'open', [{ urlOrUUID: 'db://assets/main.scene' }])) + .resolves.toEqual({ urlOrUUID: 'db://assets/main.scene' }); + await expect(facade.executeLocal('assetManager', 'queryAssetInfo', ['local-uuid'])) + .resolves.toEqual({ uuid: 'local-uuid' }); + }); + + it('disposes an explicitly installed backend with the Rpc lifecycle', () => { + const pinkBackend: SceneCommandBackend = { + request: jest.fn(), + dispose: jest.fn(), + }; + rpc.installBackend(pinkBackend); + + rpc.dispose(); + + expect(pinkBackend.dispose).toHaveBeenCalledTimes(1); + expect(() => rpc.getInstance()).toThrow('[Node] Rpc instance is not started!'); + }); +}); From 2b3f513b838e6761043c647e3de3d6d878605c2f Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Thu, 20 Aug 2026 14:23:02 +0800 Subject: [PATCH 2/3] feat(scene): route Scene MCP commands to PinK runtimes --- .github/workflows/check-dts.yml | 8 +- .../__snapshots__/dts-snapshot.test.ts.snap | 62 ++++- src/lib/scene/pink-scene-command-backend.ts | 118 +++++++++ src/lib/scene/scene.ts | 55 +++++ src/mcp/mcp.middleware.ts | 115 +++++---- src/mcp/tool-call-context.ts | 143 +++++++++++ tests/lib-scene-command-backend.test.ts | 225 ++++++++++++++++++ .../mcp-middleware-tool-call-context.test.ts | 205 ++++++++++++++++ tests/mcp-tool-call-context.test.ts | 168 +++++++++++++ 9 files changed, 1043 insertions(+), 56 deletions(-) create mode 100644 src/lib/scene/pink-scene-command-backend.ts create mode 100644 src/mcp/tool-call-context.ts create mode 100644 tests/lib-scene-command-backend.test.ts create mode 100644 tests/mcp-middleware-tool-call-context.test.ts create mode 100644 tests/mcp-tool-call-context.test.ts diff --git a/.github/workflows/check-dts.yml b/.github/workflows/check-dts.yml index 65eed1dbd..c9a5bff07 100644 --- a/.github/workflows/check-dts.yml +++ b/.github/workflows/check-dts.yml @@ -71,15 +71,15 @@ jobs: local diff_output="$1" local noise_filter='grep -v "^exports\[" | grep -v "^$" | grep -v "^[[:space:]]*$" | grep -v "^\`;" | grep -v "^[[:space:]]*}[;,]\?$" | grep -v "^export { }$" | grep -v "^[[:space:]]*static readonly version" | grep -v "^export { .*_[0-9]\+ as "' - # Extract deleted lines (strip leading -, normalize quotes and Object casing) + # Extract deleted lines (strip leading -, normalize non-semantic syntax differences) local deleted deleted=$(echo "$diff_output" | grep '^-' | grep -v '^---' | sed 's/^-//' \ - | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g" | sort -u || true) + | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g; s/,[[:space:]]*$//" | sort -u || true) - # Extract added lines (strip leading +, normalize quotes and Object casing) + # Extract added lines (strip leading +, normalize non-semantic syntax differences) local added added=$(echo "$diff_output" | grep '^+' | grep -v '^+++' | sed 's/^+//' \ - | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g" | sort -u || true) + | eval "$noise_filter" | sed "s/\"/'/g; s/\bObject\b/object/g; s/,[[:space:]]*$//" | sort -u || true) # Lines in deleted but NOT in added = truly removed if [ -n "$deleted" ]; then diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index d2acb82e0..64f63ca52 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -8068,6 +8068,7 @@ export declare function get(key: string, scope?: ConfigurationScope): Promise export declare function get_2(): Promise; export declare function getConfig(useDefault?: boolean): Promise; export declare function getConfigPath(scope?: ConfigurationScope): Promise; +export declare function getCurrentMcpToolCallContext(): Readonly | undefined; export declare function getInfo(): Promise; export declare function getInfo_2(): Promise; export declare function getMetadata(): Promise; @@ -8514,6 +8515,8 @@ export declare function init_6(): Promise; export declare function init_7(projectPath: string): Promise; export declare function initEngine(enginePath: string, projectPath: string, serverURL?: string): Promise; export declare function initProgrammingFacet(): Promise; +export declare function installPinkSceneCommandBackend(invoke: PinkSceneCommandInvoker, completeOperation: PinkSceneOperationCompleter): PinkSceneCommandBackend; +export declare function installSceneCommandBackend(backend: SceneCommandBackend): void; export declare interface IPhysicsConfig { gravity: IVec3Like; allowSleep: boolean; @@ -8779,6 +8782,15 @@ export declare namespace Mcp { getStatus } } +export declare interface McpToolCallContext { + readonly operationId: string; + readonly routeToken?: string; + readonly origin: McpToolCallOrigin; + readonly lifecycleState: McpToolCallLifecycleState; + readonly hasDispatchedSceneRpc: boolean; +} +export declare type McpToolCallLifecycleState = 'running' | 'completing' | 'completed'; +export declare type McpToolCallOrigin = 'pink' | 'standalone'; export declare interface MeshClusterOptions { enable: boolean; coneCluster?: boolean; @@ -8891,6 +8903,31 @@ export declare interface ParticleAssetUserData { rotatePerSVar: number; spriteFrameUuid: string; } +export declare type PinkMcpToolCallContext = Readonly<{ + operationId: string; + origin: 'pink'; + routeToken: string; +}>; +export declare class PinkSceneCommandBackend implements SceneCommandBackend { + private readonly invoke; + private readonly completeOperation; + constructor(invoke: PinkSceneCommandInvoker, completeOperation: PinkSceneOperationCompleter); + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; +} +export declare class PinkSceneCommandContextError extends Error { + readonly code: PinkSceneCommandContextErrorCode; + readonly name = "PinkSceneCommandContextError"; + constructor(code: PinkSceneCommandContextErrorCode, message: string); +} +export declare type PinkSceneCommandContextErrorCode = 'MCP_CONTEXT_UNAVAILABLE' | 'MCP_CONTEXT_EXPIRED' | 'PINK_ORIGIN_REQUIRED' | 'PINK_ROUTE_TOKEN_REQUIRED'; +export declare interface PinkSceneCommandInvocation extends PinkMcpToolCallContext { + readonly module: string; + readonly method: string; + readonly args: any[]; + readonly options?: Readonly; +} +export declare type PinkSceneCommandInvoker = (invocation: PinkSceneCommandInvocation) => Promise; +export declare type PinkSceneOperationCompleter = (operation: PinkMcpToolCallContext) => Promise; export declare interface PluginScriptInfo { file: string; uuid: string; @@ -9054,9 +9091,32 @@ export declare function saveSerializedData(uuidOrUrlOrPath: string, patch: Seria export declare namespace Scene { export { init_6 as init, - startupWorker + startupWorker, + installSceneCommandBackend, + getCurrentMcpToolCallContext, + installPinkSceneCommandBackend, + SceneCommandBackend, + SceneCommandRequestOptions, + McpToolCallContext, + McpToolCallOrigin, + PinkSceneCommandBackend, + PinkSceneCommandContextError, + PinkMcpToolCallContext, + PinkSceneCommandContextErrorCode, + PinkSceneCommandInvocation, + PinkSceneCommandInvoker, + PinkSceneOperationCompleter } } +export declare interface SceneCommandBackend { + request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; + notify?(module: string, method: string, args?: any[]): void; + isConnect?(): boolean | undefined; + dispose?(): void; +} +export declare interface SceneCommandRequestOptions { + timeout?: number; +} export declare namespace Scripting { export { init_7 as init, diff --git a/src/lib/scene/pink-scene-command-backend.ts b/src/lib/scene/pink-scene-command-backend.ts new file mode 100644 index 000000000..85cc55622 --- /dev/null +++ b/src/lib/scene/pink-scene-command-backend.ts @@ -0,0 +1,118 @@ +import type { + SceneCommandBackend, + SceneCommandRequestOptions, +} from '../../core/scene/main-process/rpc'; +import { + currentMcpToolCallContext, + markMcpSceneRpcDispatched, + type McpToolCallContext, +} from '../../mcp/tool-call-context'; + +export type PinkMcpToolCallContext = Readonly<{ + operationId: string; + origin: 'pink'; + routeToken: string; +}>; + +export interface PinkSceneCommandInvocation extends PinkMcpToolCallContext { + readonly module: string; + readonly method: string; + readonly args: any[]; + readonly options?: Readonly; +} + +export type PinkSceneCommandInvoker = (invocation: PinkSceneCommandInvocation) => Promise; +export type PinkSceneOperationCompleter = (operation: PinkMcpToolCallContext) => Promise; + +export type PinkSceneCommandContextErrorCode = + | 'MCP_CONTEXT_UNAVAILABLE' + | 'MCP_CONTEXT_EXPIRED' + | 'PINK_ORIGIN_REQUIRED' + | 'PINK_ROUTE_TOKEN_REQUIRED'; + +/** 当调用无法安全绑定到 PinK 的 Scene Webview Runtime 时,在分发前抛出此错误。 */ +export class PinkSceneCommandContextError extends Error { + public override readonly name = 'PinkSceneCommandContextError'; + + constructor( + public readonly code: PinkSceneCommandContextErrorCode, + message: string, + ) { + super(message); + } +} + +/** + * PinK 宿主模式下的 `SceneCommandBackend`,用于将 Scene command 路由到与当前 + * MCP tool call 精确绑定的 Scene Webview Runtime。 + * + * 每次 request 都重新读取 MCP tool call context,而不是在构造时捕获, + * 以隔离并发 MCP tool call,并防止 route token 在所属 `AsyncLocalStorage` 作用域之外被复用。 + */ +export class PinkSceneCommandBackend implements SceneCommandBackend { + constructor( + private readonly invoke: PinkSceneCommandInvoker, + private readonly completeOperation: PinkSceneOperationCompleter, + ) { + if (typeof invoke !== 'function') { + throw new TypeError('Pink Scene command invoker must be a function'); + } + if (typeof completeOperation !== 'function') { + throw new TypeError('Pink Scene operation completer must be a function'); + } + } + + public async request( + module: string, + method: string, + args: any[] = [], + options?: SceneCommandRequestOptions, + ): Promise { + const context = requirePinkToolCallContext(currentMcpToolCallContext()); + markMcpSceneRpcDispatched(() => this.completeOperation({ + operationId: context.operationId, + origin: context.origin, + routeToken: context.routeToken, + })); + return this.invoke({ + operationId: context.operationId, + origin: context.origin, + routeToken: context.routeToken, + module, + method, + args, + ...(options !== undefined ? { options } : {}), + }); + } +} + +function requirePinkToolCallContext( + context: Readonly | undefined, +): PinkMcpToolCallContext { + if (!context) { + throw new PinkSceneCommandContextError( + 'MCP_CONTEXT_UNAVAILABLE', + 'Pink Scene commands require an active MCP tool-call context', + ); + } + if (context.lifecycleState !== 'running') { + throw new PinkSceneCommandContextError( + 'MCP_CONTEXT_EXPIRED', + 'Pink Scene commands reject a completing or completed MCP tool call', + ); + } + if (context.origin !== 'pink') { + throw new PinkSceneCommandContextError( + 'PINK_ORIGIN_REQUIRED', + 'Pink Scene commands reject standalone MCP calls', + ); + } + if (!context.routeToken || context.routeToken.trim().length === 0) { + throw new PinkSceneCommandContextError( + 'PINK_ROUTE_TOKEN_REQUIRED', + 'Pink Scene commands require a Scene capability', + ); + } + + return context as PinkMcpToolCallContext; +} diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index 338843cd3..add6ee16a 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -1,5 +1,36 @@ import { init as sceneInit } from '../../core/scene'; import { GlobalPaths } from '../../global'; +import { Rpc } from '../../core/scene/main-process/rpc'; +import type { SceneCommandBackend } from '../../core/scene/main-process/rpc'; +import { + currentMcpToolCallContext, + type McpToolCallContext, +} from '../../mcp/tool-call-context'; +import { + PinkSceneCommandBackend, + type PinkSceneCommandInvoker, + type PinkSceneOperationCompleter, +} from './pink-scene-command-backend'; + +export type { + SceneCommandBackend, + SceneCommandRequestOptions, +} from '../../core/scene/main-process/rpc'; +export type { + McpToolCallContext, + McpToolCallOrigin, +} from '../../mcp/tool-call-context'; +export { + PinkSceneCommandBackend, + PinkSceneCommandContextError, +} from './pink-scene-command-backend'; +export type { + PinkMcpToolCallContext, + PinkSceneCommandContextErrorCode, + PinkSceneCommandInvocation, + PinkSceneCommandInvoker, + PinkSceneOperationCompleter, +} from './pink-scene-command-backend'; /** * Initialize the scene module. @@ -18,3 +49,27 @@ export async function startupWorker(projectPath: string): Promise { const { sceneWorker } = await import('../../core/scene/main-process/scene-worker'); await sceneWorker.start(GlobalPaths.enginePath, projectPath); } + +/** + * 安装 `SceneCommandBackend`,并确保 `SceneHostLocalExecutor` 已初始化。 + */ +export function installSceneCommandBackend(backend: SceneCommandBackend): void { + Rpc.installBackend(backend); +} + +/** 返回当前异步作用域中 `McpToolCallContext` 的只读视图。 */ +export function getCurrentMcpToolCallContext(): Readonly | undefined { + return currentMcpToolCallContext(); +} + +/** + * 安装采用 fail-closed 策略的 `PinkSceneCommandBackend`,并返回该 backend。 + */ +export function installPinkSceneCommandBackend( + invoke: PinkSceneCommandInvoker, + completeOperation: PinkSceneOperationCompleter, +): PinkSceneCommandBackend { + const backend = new PinkSceneCommandBackend(invoke, completeOperation); + installSceneCommandBackend(backend); + return backend; +} diff --git a/src/mcp/mcp.middleware.ts b/src/mcp/mcp.middleware.ts index d06340ba7..0e18f312f 100644 --- a/src/mcp/mcp.middleware.ts +++ b/src/mcp/mcp.middleware.ts @@ -14,6 +14,10 @@ import stripAnsi from 'strip-ansi'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { assetManager } from '../core/assets'; +import { + completeMcpToolCallContext, + runWithMcpToolCallContext, +} from './tool-call-context'; export function isToolErrorCode(code: unknown): boolean { return typeof code === 'number' && code >= 500 && code < 600; @@ -146,60 +150,69 @@ export class McpMiddleware { toolName, meta.description || `Tool: ${toolName}`, inputSchemaFields, - async (args) => { - // args 已经是验证过的参数对象 (对于 builder-build.options 是 any) - try { - this.builderHook.onBeforeExecute(toolName, args); - // 这里的 prepareMethodArguments 主要是为了按顺序排列参数给 apply 使用 - // 注意:args 是对象,prepareMethodArguments 需要处理对象 - const methodArgs = this.prepareMethodArguments(meta, args, toolName); - const result = await this.callToolMethod(target, meta, methodArgs); - - const formattedResult = this.formatToolResult(meta, result); - - let structuredContent: any; - if (meta.returnSchema) { + async (args, extra) => { + return runWithMcpToolCallContext(extra.requestInfo?.headers, async () => { + try { + // 参数已经过 SDK 校验。`builder-build.options` 的类型为 `any`,而 `args` 是对象, + // 因此需要通过 `prepareMethodArguments` 按声明顺序生成供 `apply` 使用的实参数组。 + this.builderHook.onBeforeExecute(toolName, args); + const methodArgs = this.prepareMethodArguments(meta, args, toolName); + const result = await this.callToolMethod(target, meta, methodArgs); + + const formattedResult = this.formatToolResult(meta, result); + + let structuredContent: any; + if (meta.returnSchema) { + try { + const validatedResult = meta.returnSchema.parse(result); + structuredContent = { result: validatedResult }; + } catch { + structuredContent = { result: result }; + } + } else { + structuredContent = { result: result }; + } + console.debug(`call ${toolName} with args:${methodArgs.toString()} result: ${formattedResult}`); + return { + content: [{ type: 'text' as const, text: formattedResult }], + structuredContent: structuredContent, + isError: isToolErrorCode(result?.code) + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + + let detailedReason = `Tool execution failed (${toolName}): ${errorMessage}`; + if (errorStack && process.env.NODE_ENV === 'development') { + detailedReason += `\n\nStack trace:\n${errorStack}`; + } + detailedReason += `\n\nParameters passed:\n${JSON.stringify(args, null, 2)}`; + + console.error(`[MCP] ${detailedReason}`); + + const errorResult: { code: HttpStatusCode; data?: any; reason?: string } = { + code: HTTP_STATUS.INTERNAL_SERVER_ERROR, + data: undefined, + reason: detailedReason, + }; + + const formattedResult = JSON.stringify({ result: errorResult }, null, 2); + return { + content: [{ type: 'text' as const, text: formattedResult }], + structuredContent: { result: errorResult }, + isError: true + }; + } finally { try { - const validatedResult = meta.returnSchema.parse(result); - structuredContent = { result: validatedResult }; + await completeMcpToolCallContext(); } catch { - structuredContent = { result: result }; + // 不记录完成阶段抛出的具体错误或 request metadata,避免泄露敏感的 route token。 + // 完成阶段失败也不能覆盖 MCP tool call 自身的执行结果。 + console.error('[MCP] PinK Scene operation completion failed.'); } - } else { - structuredContent = { result: result }; } - console.debug(`call ${toolName} with args:${methodArgs.toString()} result: ${formattedResult}`); - return { - content: [{ type: 'text' as const, text: formattedResult }], - structuredContent: structuredContent, - isError: isToolErrorCode(result?.code) - }; - - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; - - let detailedReason = `Tool execution failed (${toolName}): ${errorMessage}`; - if (errorStack && process.env.NODE_ENV === 'development') { - detailedReason += `\n\nStack trace:\n${errorStack}`; - } - detailedReason += `\n\nParameters passed:\n${JSON.stringify(args, null, 2)}`; - - console.error(`[MCP] ${detailedReason}`); - - const errorResult: { code: HttpStatusCode; data?: any; reason?: string } = { - code: HTTP_STATUS.INTERNAL_SERVER_ERROR, - data: undefined, - reason: detailedReason, - }; - - const formattedResult = JSON.stringify({ result: errorResult }, null, 2); - return { - content: [{ type: 'text' as const, text: formattedResult }], - structuredContent: { result: errorResult }, - isError: true - }; - } + }); } ); } catch (error) { @@ -268,7 +281,7 @@ export class McpMiddleware { const validatedValue = param.schema.parse(numValue); methodArgs[param.index] = validatedValue; continue; - } catch (innerError) { + } catch { // 忽略内部错误,继续抛出原始错误 } } diff --git a/src/mcp/tool-call-context.ts b/src/mcp/tool-call-context.ts new file mode 100644 index 000000000..a5cbd1f8d --- /dev/null +++ b/src/mcp/tool-call-context.ts @@ -0,0 +1,143 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { randomUUID } from 'crypto'; + +export const PINK_SCENE_CAPABILITY_HEADER = 'X-Pink-Scene-Capability'; + +export type McpToolCallOrigin = 'pink' | 'standalone'; + +export type McpToolCallLifecycleState = 'running' | 'completing' | 'completed'; + +export interface McpToolCallContext { + readonly operationId: string; + readonly routeToken?: string; + readonly origin: McpToolCallOrigin; + readonly lifecycleState: McpToolCallLifecycleState; + readonly hasDispatchedSceneRpc: boolean; +} + +export type McpRequestHeaders = Readonly>; + +type PinkSceneOperationCompleter = () => Promise; + +interface MutableMcpToolCallContext { + publicContext: Readonly; + lifecycleState: McpToolCallLifecycleState; + hasDispatchedSceneRpc: boolean; + completePinkSceneOperation?: PinkSceneOperationCompleter; + completionPromise?: Promise; +} + +const toolCallContextStorage = new AsyncLocalStorage(); + +/** + * 返回当前异步作用域中的 MCP tool call context。 + */ +export function currentMcpToolCallContext(): Readonly | undefined { + return toolCallContextStorage.getStore()?.publicContext; +} + +/** + * 返回当前 MCP tool call context;若在 tool handler 之外调用则抛出错误。 + */ +export function requireMcpToolCallContext(): Readonly { + const context = currentMcpToolCallContext(); + if (!context) { + throw new Error('MCP tool-call context is unavailable outside a tool handler'); + } + return context; +} + +/** + * 在相互隔离的异步上下文中运行一次完整的 MCP tool call。 + */ +export function runWithMcpToolCallContext( + headers: McpRequestHeaders | undefined, + callback: () => T, +): T { + const routeToken = readHeader(headers, PINK_SCENE_CAPABILITY_HEADER); + const mutableContext = { + lifecycleState: 'running' as McpToolCallLifecycleState, + hasDispatchedSceneRpc: false, + } as MutableMcpToolCallContext; + const publicContext: Readonly = Object.freeze({ + operationId: randomUUID(), + ...(routeToken !== undefined ? { routeToken } : {}), + origin: routeToken === undefined ? 'standalone' : 'pink', + get lifecycleState() { + return mutableContext.lifecycleState; + }, + get hasDispatchedSceneRpc() { + return mutableContext.hasDispatchedSceneRpc; + }, + }); + mutableContext.publicContext = publicContext; + + return toolCallContextStorage.run(mutableContext, callback); +} + +/** + * 标记当前 PinK MCP tool call 已经分发 Scene RPC。 + * 每次分发都会调用;首次分发还会保存整个 tool call 结束时使用的完成回调。 + */ +export function markMcpSceneRpcDispatched(completeOperation: PinkSceneOperationCompleter): void { + const context = requireMutableContext(); + if (context.lifecycleState !== 'running') { + throw new Error(`MCP tool-call context is ${context.lifecycleState}`); + } + + context.hasDispatchedSceneRpc = true; + context.completePinkSceneOperation ??= completeOperation; +} + +/** + * 仅当当前 MCP tool call 已经分发 Scene RPC 时,才执行一次宿主完成回调。无论回调成功与否, + * 生命周期都会进入 `completed`,防止调用结束后逸出的异步任务继续分发 Scene RPC。 + */ +export async function completeMcpToolCallContext(): Promise { + const context = requireMutableContext(); + if (context.completionPromise) { + return context.completionPromise; + } + if (context.lifecycleState === 'completed') { + return; + } + + context.lifecycleState = 'completing'; + const completionPromise = (async () => { + try { + if (context.hasDispatchedSceneRpc) { + await context.completePinkSceneOperation?.(); + } + } finally { + context.lifecycleState = 'completed'; + context.completePinkSceneOperation = undefined; + } + })(); + context.completionPromise = completionPromise; + return completionPromise; +} + +function requireMutableContext(): MutableMcpToolCallContext { + const context = toolCallContextStorage.getStore(); + if (!context) { + throw new Error('MCP tool-call context is unavailable outside a tool handler'); + } + return context; +} + +function readHeader(headers: McpRequestHeaders | undefined, headerName: string): string | undefined { + if (!headers) { + return undefined; + } + + const normalizedHeaderName = headerName.toLowerCase(); + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() !== normalizedHeaderName) { + continue; + } + + return typeof value === 'string' ? value : value?.[0]; + } + + return undefined; +} diff --git a/tests/lib-scene-command-backend.test.ts b/tests/lib-scene-command-backend.test.ts new file mode 100644 index 000000000..d28c99292 --- /dev/null +++ b/tests/lib-scene-command-backend.test.ts @@ -0,0 +1,225 @@ +const mockInstallBackend = jest.fn(); + +jest.mock('../src/core/scene', () => ({ + init: jest.fn(), +})); + +jest.mock('../src/core/scene/main-process/rpc', () => ({ + Rpc: { + installBackend: (backend: unknown) => mockInstallBackend(backend), + }, +})); + +jest.mock('../src/global', () => ({ + GlobalPaths: { + enginePath: '/engine', + }, +})); + +import type { SceneCommandBackend } from '../src/lib/scene/scene'; +import { + getCurrentMcpToolCallContext, + installPinkSceneCommandBackend, + installSceneCommandBackend, + PinkSceneCommandBackend, +} from '../src/lib/scene/scene'; +import { + completeMcpToolCallContext, + requireMcpToolCallContext, + runWithMcpToolCallContext, +} from '../src/mcp/tool-call-context'; + +describe('Scene library command backend entry', () => { + beforeEach(() => { + mockInstallBackend.mockClear(); + }); + + it('forwards the typed backend to the core Rpc owner', () => { + const backend: SceneCommandBackend = { + request: jest.fn(), + }; + + installSceneCommandBackend(backend); + + expect(mockInstallBackend).toHaveBeenCalledWith(backend); + }); + + it('exports a read-only view of the current MCP context', () => { + expect(getCurrentMcpToolCallContext()).toBeUndefined(); + + const context = runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'scene-capability', + }, () => getCurrentMcpToolCallContext()); + + expect(context).toMatchObject({ + origin: 'pink', + routeToken: 'scene-capability', + }); + expect(Object.isFrozen(context)).toBe(true); + expect(getCurrentMcpToolCallContext()).toBeUndefined(); + }); + + it('constructs and installs the fail-closed Pink backend helper', () => { + const invoke = jest.fn(async () => true); + const completeOperation = jest.fn(async (_operation: unknown) => undefined); + + const backend = installPinkSceneCommandBackend(invoke, completeOperation); + + expect(backend).toBeInstanceOf(PinkSceneCommandBackend); + expect(mockInstallBackend).toHaveBeenCalledWith(backend); + }); +}); + +describe('PinkSceneCommandBackend', () => { + it('rejects calls made without an MCP context', async () => { + const invoke = jest.fn(); + const backend = new PinkSceneCommandBackend(invoke, jest.fn()); + + await expect(backend.request('Node', 'query', [])) + .rejects.toMatchObject({ + name: 'PinkSceneCommandContextError', + code: 'MCP_CONTEXT_UNAVAILABLE', + }); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('rejects standalone calls and missing or blank route tokens', async () => { + const invoke = jest.fn(); + const completeOperation = jest.fn(); + const backend = new PinkSceneCommandBackend(invoke, completeOperation); + + await expect(runWithMcpToolCallContext(undefined, () => ( + backend.request('Node', 'query', []) + ))).rejects.toMatchObject({ code: 'PINK_ORIGIN_REQUIRED' }); + + await expect(runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': '', + }, () => backend.request('Node', 'query', []))) + .rejects.toMatchObject({ code: 'PINK_ROUTE_TOKEN_REQUIRED' }); + + await expect(runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': ' ', + }, () => backend.request('Node', 'query', []))) + .rejects.toMatchObject({ code: 'PINK_ROUTE_TOKEN_REQUIRED' }); + + expect(invoke).not.toHaveBeenCalled(); + expect(completeOperation).not.toHaveBeenCalled(); + }); + + it('forwards the current Pink context and command as one invocation envelope', async () => { + const invoke = jest.fn(async () => 'runtime-result'); + const completeOperation = jest.fn(async () => undefined); + const backend = new PinkSceneCommandBackend(invoke, completeOperation); + + const result = await runWithMcpToolCallContext({ + 'x-pink-scene-capability': 'opaque-route-token', + }, async () => { + const context = getCurrentMcpToolCallContext(); + const value = await backend.request( + 'Component', + 'setProperty', + [{ path: 'Canvas/Label', value: 'Hello' }], + { timeout: 4321 }, + ); + await completeMcpToolCallContext(); + return { context, value }; + }); + + expect(result.value).toBe('runtime-result'); + expect(invoke).toHaveBeenCalledWith({ + operationId: result.context?.operationId, + origin: 'pink', + routeToken: 'opaque-route-token', + module: 'Component', + method: 'setProperty', + args: [{ path: 'Canvas/Label', value: 'Hello' }], + options: { timeout: 4321 }, + }); + expect(completeOperation).toHaveBeenCalledWith({ + operationId: result.context?.operationId, + origin: 'pink', + routeToken: 'opaque-route-token', + }); + }); + + it('reads ALS context independently for concurrent requests', async () => { + const invocations: Array<{ operationId: string; routeToken: string }> = []; + const invoke = jest.fn(async (invocation: { operationId: string; routeToken: string }) => { + invocations.push(invocation); + await Promise.resolve(); + return invocation.routeToken; + }); + const backend = new PinkSceneCommandBackend(invoke, jest.fn(async () => undefined)); + + const [first, second] = await Promise.all([ + runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'first-token', + }, () => backend.request('Node', 'query', [])), + runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'second-token', + }, () => backend.request('Node', 'query', [])), + ]); + + expect(new Set([first, second])).toEqual(new Set(['first-token', 'second-token'])); + expect(invocations.map(({ routeToken }) => routeToken)) + .toEqual(['first-token', 'second-token']); + expect(invocations[0].operationId).not.toBe(invocations[1].operationId); + }); + + it('preserves backend failures without retrying or wrapping them', async () => { + const backendFailure = new Error('Pink runtime is stale'); + const invoke = jest.fn().mockRejectedValue(backendFailure); + const completeOperation = jest.fn(async () => undefined); + const backend = new PinkSceneCommandBackend(invoke, completeOperation); + + await runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-route-token', + }, async () => { + await expect(backend.request('Editor', 'save', [])) + .rejects.toBe(backendFailure); + await completeMcpToolCallContext(); + }); + + expect(invoke).toHaveBeenCalledTimes(1); + expect(completeOperation).toHaveBeenCalledTimes(1); + }); + + it('completes one Pink operation after multiple Scene RPCs', async () => { + const invoke = jest.fn(async () => true); + const completeOperation = jest.fn(async (_operation: unknown) => undefined); + const backend = new PinkSceneCommandBackend(invoke, completeOperation); + + await runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-route-token', + }, async () => { + await backend.request('Node', 'query', []); + await backend.request('Component', 'queryAll', []); + await completeMcpToolCallContext(); + }); + + expect(invoke).toHaveBeenCalledTimes(2); + expect(completeOperation).toHaveBeenCalledTimes(1); + expect(completeOperation.mock.calls[0][0]).toMatchObject({ + origin: 'pink', + routeToken: 'opaque-route-token', + }); + }); + + it('rejects Scene RPCs after the tool context is completed', async () => { + const backend = new PinkSceneCommandBackend( + jest.fn(async () => true), + jest.fn(async () => undefined), + ); + + await runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-route-token', + }, async () => { + await backend.request('Node', 'query', []); + await completeMcpToolCallContext(); + + expect(requireMcpToolCallContext().lifecycleState).toBe('completed'); + await expect(backend.request('Node', 'query', [])) + .rejects.toMatchObject({ code: 'MCP_CONTEXT_EXPIRED' }); + }); + }); +}); diff --git a/tests/mcp-middleware-tool-call-context.test.ts b/tests/mcp-middleware-tool-call-context.test.ts new file mode 100644 index 000000000..e85aaca77 --- /dev/null +++ b/tests/mcp-middleware-tool-call-context.test.ts @@ -0,0 +1,205 @@ +const mockRegisteredTools = new Map Promise>(); +const mockToolExecution = jest.fn(); +const mockToolRegistry = new Map([ + ['context-probe', { + target: { + execute: (...args: unknown[]) => mockToolExecution(...args), + }, + meta: { + toolName: 'context-probe', + description: 'Context probe', + paramSchemas: [], + methodName: 'execute', + }, + }], +]); + +jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ + McpServer: jest.fn().mockImplementation(() => ({ + tool: jest.fn((name: string, _description: string, _schema: unknown, callback: any) => { + mockRegisteredTools.set(name, callback); + }), + resource: jest.fn(), + connect: jest.fn(), + server: { + setRequestHandler: jest.fn(), + }, + })), + ResourceTemplate: jest.fn(), +})); + +jest.mock('../src/api/decorator/decorator', () => ({ + toolRegistry: mockToolRegistry, +})); + +jest.mock('../src/mcp/resources', () => ({ + ResourceManager: jest.fn().mockImplementation(() => ({ + loadAllResources: jest.fn(() => []), + })), +})); + +jest.mock('../src/mcp/hooks/builder.hook', () => ({ + BuilderHook: jest.fn().mockImplementation(() => ({ + onBeforeExecute: jest.fn(), + onRegisterParam: jest.fn(), + onValidationFailed: jest.fn(), + })), +})); + +jest.mock('../src/core/assets', () => ({ + assetManager: { + queryAssetInfos: jest.fn(() => []), + }, +})); + +import { McpMiddleware } from '../src/mcp/mcp.middleware'; +import { + currentMcpToolCallContext, + markMcpSceneRpcDispatched, + requireMcpToolCallContext, +} from '../src/mcp/tool-call-context'; + +function requestExtra(headers?: Record): any { + return { + requestInfo: headers ? { headers } : undefined, + requestId: 1, + signal: new AbortController().signal, + sendNotification: jest.fn(), + sendRequest: jest.fn(), + }; +} + +describe('McpMiddleware tool-call context integration', () => { + let debugSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + mockRegisteredTools.clear(); + mockToolExecution.mockReset(); + debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + new McpMiddleware(); + }); + + afterEach(() => { + debugSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('covers the target method async lifecycle without logging the capability token', async () => { + const routeToken = 'secret-opaque-capability'; + const snapshots: ReturnType[] = []; + mockToolExecution.mockImplementation(async () => { + snapshots.push(requireMcpToolCallContext()); + await Promise.resolve(); + snapshots.push(requireMcpToolCallContext()); + return { code: 200 }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + expect(handler).toBeDefined(); + await handler!({}, requestExtra({ 'x-PINK-scene-CAPABILITY': routeToken })); + + expect(snapshots).toHaveLength(2); + expect(snapshots[0]).toBe(snapshots[1]); + expect(snapshots[0]).toMatchObject({ routeToken, origin: 'pink' }); + expect(snapshots[0].lifecycleState).toBe('completed'); + expect(snapshots[0].hasDispatchedSceneRpc).toBe(false); + expect(currentMcpToolCallContext()).toBeUndefined(); + + const logOutput = JSON.stringify([ + ...debugSpy.mock.calls, + ...errorSpy.mock.calls, + ]); + expect(logOutput).not.toContain(routeToken); + }); + + it('creates a standalone context when the SDK request has no Pink header', async () => { + let observedContext: ReturnType | undefined; + mockToolExecution.mockImplementation(async () => { + observedContext = requireMcpToolCallContext(); + return { code: 200 }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + await handler!({}, requestExtra()); + + expect(observedContext).toMatchObject({ origin: 'standalone' }); + expect(observedContext).not.toHaveProperty('routeToken'); + expect(observedContext?.lifecycleState).toBe('completed'); + expect(observedContext?.hasDispatchedSceneRpc).toBe(false); + }); + + it('awaits one completion after a successful multi-RPC Pink tool call', async () => { + let notifyCompletionStarted!: () => void; + let releaseCompletion!: () => void; + const completionStarted = new Promise(resolve => { + notifyCompletionStarted = resolve; + }); + const completionGate = new Promise(resolve => { + releaseCompletion = resolve; + }); + const completeOperation = jest.fn(async () => { + notifyCompletionStarted(); + await completionGate; + }); + let observedContext: ReturnType | undefined; + mockToolExecution.mockImplementation(async () => { + observedContext = requireMcpToolCallContext(); + markMcpSceneRpcDispatched(completeOperation); + await Promise.resolve(); + markMcpSceneRpcDispatched(completeOperation); + return { code: 200, data: 'ok' }; + }); + + const handler = mockRegisteredTools.get('context-probe'); + const handlerPromise = handler!({}, requestExtra({ + 'X-Pink-Scene-Capability': 'opaque-capability', + })) as Promise<{ isError: boolean }>; + let handlerSettled = false; + void handlerPromise.finally(() => { + handlerSettled = true; + }); + + await completionStarted; + await Promise.resolve(); + expect(handlerSettled).toBe(false); + releaseCompletion(); + const result = await handlerPromise; + + expect(result.isError).toBe(false); + expect(completeOperation).toHaveBeenCalledTimes(1); + expect(observedContext?.hasDispatchedSceneRpc).toBe(true); + expect(observedContext?.lifecycleState).toBe('completed'); + }); + + it('preserves the tool error when completion also fails and logs no completion detail', async () => { + const routeToken = 'secret-route-capability'; + const completionSecret = 'private-completion-failure'; + const completeOperation = jest.fn(async () => { + throw new Error(`${completionSecret}: ${routeToken}`); + }); + mockToolExecution.mockImplementation(async () => { + markMcpSceneRpcDispatched(completeOperation); + throw new Error('original-tool-failure'); + }); + + const handler = mockRegisteredTools.get('context-probe'); + const result = await handler!({}, requestExtra({ + 'X-Pink-Scene-Capability': routeToken, + })) as { + isError: boolean; + structuredContent: { result: { reason: string } }; + }; + + expect(result.isError).toBe(true); + expect(result.structuredContent.result.reason).toContain('original-tool-failure'); + expect(result.structuredContent.result.reason).not.toContain(completionSecret); + expect(completeOperation).toHaveBeenCalledTimes(1); + + const logOutput = JSON.stringify(errorSpy.mock.calls); + expect(logOutput).toContain('PinK Scene operation completion failed'); + expect(logOutput).not.toContain(completionSecret); + expect(logOutput).not.toContain(routeToken); + }); +}); diff --git a/tests/mcp-tool-call-context.test.ts b/tests/mcp-tool-call-context.test.ts new file mode 100644 index 000000000..343463c70 --- /dev/null +++ b/tests/mcp-tool-call-context.test.ts @@ -0,0 +1,168 @@ +import { + completeMcpToolCallContext, + currentMcpToolCallContext, + markMcpSceneRpcDispatched, + requireMcpToolCallContext, + runWithMcpToolCallContext, +} from '../src/mcp/tool-call-context'; + +interface Deferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('MCP tool-call context', () => { + it('exposes a frozen Pink context for the complete asynchronous callback lifecycle', async () => { + const result = await runWithMcpToolCallContext({ + 'x-PiNk-ScEnE-cApAbIlItY': 'opaque-capability', + }, async () => { + const beforeAwait = requireMcpToolCallContext(); + await Promise.resolve(); + const afterAwait = currentMcpToolCallContext(); + + return { beforeAwait, afterAwait }; + }); + + expect(result.beforeAwait).toBe(result.afterAwait); + expect(result.beforeAwait).toMatchObject({ + routeToken: 'opaque-capability', + origin: 'pink', + lifecycleState: 'running', + hasDispatchedSceneRpc: false, + }); + expect(result.beforeAwait.operationId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(Object.isFrozen(result.beforeAwait)).toBe(true); + expect(currentMcpToolCallContext()).toBeUndefined(); + }); + + it('keeps calls without a capability header compatible with standalone CLI usage', () => { + const context = runWithMcpToolCallContext(undefined, () => requireMcpToolCallContext()); + + expect(context).toMatchObject({ origin: 'standalone' }); + expect(context).not.toHaveProperty('routeToken'); + }); + + it('uses the first value when the SDK supplies a repeated header as an array', () => { + const context = runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': ['first-capability', 'second-capability'], + }, () => requireMcpToolCallContext()); + + expect(context.routeToken).toBe('first-capability'); + expect(context.origin).toBe('pink'); + }); + + it('isolates concurrent calls and assigns a unique operation id to each one', async () => { + const firstEntered = deferred(); + const secondEntered = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + + const firstCall = runWithMcpToolCallContext({ + 'x-pink-scene-capability': 'first-capability', + }, async () => { + const beforeAwait = requireMcpToolCallContext(); + firstEntered.resolve(); + await releaseFirst.promise; + return { beforeAwait, afterAwait: requireMcpToolCallContext() }; + }); + const secondCall = runWithMcpToolCallContext({ + 'X-PINK-SCENE-CAPABILITY': 'second-capability', + }, async () => { + const beforeAwait = requireMcpToolCallContext(); + secondEntered.resolve(); + await releaseSecond.promise; + return { beforeAwait, afterAwait: requireMcpToolCallContext() }; + }); + + await Promise.all([firstEntered.promise, secondEntered.promise]); + expect(currentMcpToolCallContext()).toBeUndefined(); + + releaseSecond.resolve(); + releaseFirst.resolve(); + const [first, second] = await Promise.all([firstCall, secondCall]); + + expect(first.beforeAwait).toBe(first.afterAwait); + expect(second.beforeAwait).toBe(second.afterAwait); + expect(first.beforeAwait.routeToken).toBe('first-capability'); + expect(second.beforeAwait.routeToken).toBe('second-capability'); + expect(first.beforeAwait.operationId).not.toBe(second.beforeAwait.operationId); + }); + + it('fails explicitly when a backend requires context outside a tool call', () => { + expect(currentMcpToolCallContext()).toBeUndefined(); + expect(() => requireMcpToolCallContext()).toThrow( + 'MCP tool-call context is unavailable outside a tool handler', + ); + }); + + it('completes a multi-RPC Pink operation exactly once', async () => { + const completeFirst = jest.fn(async () => undefined); + const completeSecond = jest.fn(async () => undefined); + let context!: ReturnType; + + await runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-capability', + }, async () => { + context = requireMcpToolCallContext(); + markMcpSceneRpcDispatched(completeFirst); + markMcpSceneRpcDispatched(completeSecond); + + expect(context.hasDispatchedSceneRpc).toBe(true); + await Promise.all([ + completeMcpToolCallContext(), + completeMcpToolCallContext(), + ]); + await completeMcpToolCallContext(); + }); + + expect(completeFirst).toHaveBeenCalledTimes(1); + expect(completeSecond).not.toHaveBeenCalled(); + expect(context.lifecycleState).toBe('completed'); + }); + + it('does not invoke Pink completion for no-Scene or standalone tool calls', async () => { + let pinkContext!: ReturnType; + let standaloneContext!: ReturnType; + + await runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-capability', + }, async () => { + pinkContext = requireMcpToolCallContext(); + await completeMcpToolCallContext(); + }); + await runWithMcpToolCallContext(undefined, async () => { + standaloneContext = requireMcpToolCallContext(); + await completeMcpToolCallContext(); + }); + + expect(pinkContext.hasDispatchedSceneRpc).toBe(false); + expect(pinkContext.lifecycleState).toBe('completed'); + expect(standaloneContext.hasDispatchedSceneRpc).toBe(false); + expect(standaloneContext.lifecycleState).toBe('completed'); + }); + + it('expires the context even when the Pink completion callback fails', async () => { + const completionFailure = new Error('must remain private'); + let context!: ReturnType; + + await expect(runWithMcpToolCallContext({ + 'X-Pink-Scene-Capability': 'opaque-capability', + }, async () => { + context = requireMcpToolCallContext(); + markMcpSceneRpcDispatched(async () => { throw completionFailure; }); + await completeMcpToolCallContext(); + })).rejects.toBe(completionFailure); + + expect(context.lifecycleState).toBe('completed'); + }); +}); From 8b8916aaa3114475aa7c380abe41d97d8a831ec5 Mon Sep 17 00:00:00 2001 From: zhouzz <275028888@qq.com> Date: Thu, 20 Aug 2026 14:23:12 +0800 Subject: [PATCH 3/3] fix(scene): create scene assets through Asset Manager --- src/api/scene/scene.ts | 26 ++-- .../component-prefab-ui-handling.test.ts | 3 + tests/scene-create-asset-backend.test.ts | 112 ++++++++++++++++++ 3 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 tests/scene-create-asset-backend.test.ts diff --git a/src/api/scene/scene.ts b/src/api/scene/scene.ts index 9ee39f740..3bf307b0c 100644 --- a/src/api/scene/scene.ts +++ b/src/api/scene/scene.ts @@ -18,11 +18,11 @@ import { } from './schema'; import { description, param, result, title, tool } from '../decorator/decorator.js'; import { COMMON_STATUS, CommonResultType, getCommonErrorStatus } from '../base/schema-base'; -import { Scene, TSceneTemplateType } from '../../core/scene'; +import { Scene } from '../../core/scene'; +import { assetManager } from '../../core/assets'; import { ComponentApi } from './component'; import { NodeApi } from './node'; import { PrefabApi } from './prefab'; -import { options } from '../../core/builder/platforms/android/i18n/en'; export class SceneApi { public component: ComponentApi; @@ -126,16 +126,22 @@ export class SceneApi { @result(SchemaCreateResult) async createScene(@param(SchemaCreateOptions) options: TCreateOptions): Promise> { try { - const data = await Scene.create({ - type: 'scene', - baseName: options.baseName, - targetDirectory: options.dbURL, - templateType: options.templateType as TSceneTemplateType, - }); - + const assetInfo = await assetManager.createAssetByType( + 'scene', + options.dbURL, + options.baseName, + { templateName: options.templateType ?? '2d' }, + ); + const data: TCreateResult = { + assetName: assetInfo.name, + assetUuid: assetInfo.uuid, + assetUrl: assetInfo.url, + assetType: assetInfo.type, + }; + return { code: COMMON_STATUS.SUCCESS, - data: data as TCreateResult, + data, }; } catch (e) { console.error(e); diff --git a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts index d97548a56..8e16f8284 100644 --- a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts +++ b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts @@ -36,6 +36,8 @@ class MockNode { } } +class MockScene extends MockNode {} + (global as any).cc = { js: { getClassById: mockGetClassById, @@ -63,6 +65,7 @@ jest.mock('cc', () => ({ MeshCollider: class MeshCollider {}, Node: MockNode, RigidBody: class RigidBody {}, + Scene: MockScene, UITransform: MockUITransform, js: { getClassById: mockGetClassById, diff --git a/tests/scene-create-asset-backend.test.ts b/tests/scene-create-asset-backend.test.ts new file mode 100644 index 000000000..6f02ef5fb --- /dev/null +++ b/tests/scene-create-asset-backend.test.ts @@ -0,0 +1,112 @@ +const mockCreateAssetByType = jest.fn(); +const mockSceneCreate = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/assets', () => ({ + assetManager: { + createAssetByType: (...args: unknown[]) => mockCreateAssetByType(...args), + }, +})); + +jest.mock('../src/core/scene', () => ({ + NodeType: { + EMPTY: 'Node', + SPRITE: 'Sprite', + }, + SCENE_TEMPLATE_TYPE: ['2d', '3d'], + Scene: { + create: (...args: unknown[]) => mockSceneCreate(...args), + }, +})); + +import { SceneApi } from '../src/api/scene/scene'; +import { COMMON_STATUS } from '../src/api/base/schema-base'; + +describe('scene-create AssetManager backend', () => { + beforeEach(() => { + mockCreateAssetByType.mockReset(); + mockSceneCreate.mockReset(); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates the scene through AssetManager and maps the legacy identifier result', async () => { + mockCreateAssetByType.mockResolvedValue({ + name: 'Main.scene', + uuid: 'scene-uuid', + url: 'db://assets/scenes/Main.scene', + type: 'cc.SceneAsset', + }); + + const result = await new SceneApi().createScene({ + baseName: 'Main', + dbURL: 'db://assets/scenes', + templateType: '3d', + }); + + expect(mockCreateAssetByType).toHaveBeenCalledWith( + 'scene', + 'db://assets/scenes', + 'Main', + { templateName: '3d' }, + ); + expect(result).toEqual({ + code: COMMON_STATUS.SUCCESS, + data: { + assetName: 'Main.scene', + assetUuid: 'scene-uuid', + assetUrl: 'db://assets/scenes/Main.scene', + assetType: 'cc.SceneAsset', + }, + }); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); + + it('uses the 2d template by default and still performs zero Scene RPCs', async () => { + mockCreateAssetByType.mockResolvedValue({ + name: 'Default.scene', + uuid: 'default-scene-uuid', + url: 'db://assets/Default.scene', + type: 'cc.SceneAsset', + }); + + const result = await new SceneApi().createScene({ + baseName: 'Default', + dbURL: 'db://assets', + }); + + expect(result.code).toBe(COMMON_STATUS.SUCCESS); + expect(mockCreateAssetByType).toHaveBeenCalledWith( + 'scene', + 'db://assets', + 'Default', + { templateName: '2d' }, + ); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); + + it('returns the existing scene-create failure contract without falling back to Scene RPC', async () => { + mockCreateAssetByType.mockRejectedValue(new Error('asset creation failed')); + + const result = await new SceneApi().createScene({ + baseName: 'Broken', + dbURL: 'db://assets', + }); + + expect(result).toEqual({ + code: COMMON_STATUS.FAIL, + reason: 'asset creation failed', + }); + expect(mockSceneCreate).not.toHaveBeenCalled(); + }); +});