212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { experimental_createMCPClient, type experimental_MCPClient } from '@ai-sdk/mcp'
|
|
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'
|
|
import type { ModelMessage, ToolSet } from 'ai'
|
|
|
|
const BLOCKED_TOOL_NAMES = new Set([
|
|
'create_project',
|
|
'delete_scene',
|
|
'generate_variants',
|
|
'get_project_status',
|
|
'list_scenes',
|
|
'load_scene',
|
|
'rename_scene',
|
|
])
|
|
|
|
type RequiredTool = ToolSet[string] & {
|
|
execute: NonNullable<ToolSet[string]['execute']>
|
|
}
|
|
|
|
export interface PascalAgentMcpSession {
|
|
close: () => Promise<void>
|
|
saveDraft: (abortSignal?: AbortSignal) => Promise<void>
|
|
sceneName: string
|
|
tools: ToolSet
|
|
}
|
|
|
|
function stringEnvironment(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
)
|
|
}
|
|
|
|
function resolveMcpEntry(env: NodeJS.ProcessEnv): string {
|
|
const configured = env.PASCAL_MCP_ENTRY?.trim()
|
|
if (configured) return path.resolve(configured)
|
|
|
|
const candidates = [
|
|
path.resolve(process.cwd(), '../../packages/mcp/dist/bin/pascal-mcp.js'),
|
|
path.resolve(process.cwd(), 'packages/mcp/dist/bin/pascal-mcp.js'),
|
|
path.resolve(process.cwd(), '../packages/mcp/dist/bin/pascal-mcp.js'),
|
|
]
|
|
const match = candidates.find((candidate) => existsSync(candidate))
|
|
if (!match) {
|
|
throw new Error(
|
|
'Pascal MCP executable was not found. Build @pascal-app/mcp or set PASCAL_MCP_ENTRY.',
|
|
)
|
|
}
|
|
return match
|
|
}
|
|
|
|
function resolveMcpCommand(env: NodeJS.ProcessEnv): string {
|
|
const configured = env.PASCAL_MCP_COMMAND?.trim()
|
|
if (configured) return configured
|
|
return process.versions.bun ? process.execPath : 'bun'
|
|
}
|
|
|
|
function toolExecutionOptions(abortSignal?: AbortSignal) {
|
|
return {
|
|
abortSignal,
|
|
messages: [] as ModelMessage[],
|
|
toolCallId: `pascal-agent-${crypto.randomUUID()}`,
|
|
}
|
|
}
|
|
|
|
function requireTool(tools: ToolSet, name: string): RequiredTool {
|
|
const candidate = tools[name]
|
|
if (!candidate?.execute) {
|
|
throw new Error(`Pascal MCP tool is unavailable: ${name}`)
|
|
}
|
|
return candidate as RequiredTool
|
|
}
|
|
|
|
function withStepTimeout(tool: RequiredTool, timeoutMs: number): RequiredTool {
|
|
return {
|
|
...tool,
|
|
execute: async (input, options) => {
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs)
|
|
const abortSignal = options.abortSignal
|
|
? AbortSignal.any([options.abortSignal, timeoutSignal])
|
|
: timeoutSignal
|
|
return tool.execute(input, { ...options, abortSignal })
|
|
},
|
|
}
|
|
}
|
|
|
|
export function isPascalAgentToolAllowed(name: string): boolean {
|
|
return !BLOCKED_TOOL_NAMES.has(name)
|
|
}
|
|
|
|
function readSceneName(result: unknown, fallback: string): string {
|
|
const structured = (result as { structuredContent?: { name?: unknown } })?.structuredContent
|
|
return typeof structured?.name === 'string' && structured.name.trim()
|
|
? structured.name.trim()
|
|
: fallback
|
|
}
|
|
|
|
function assertToolSucceeded(result: unknown, operation: string): void {
|
|
if ((result as { isError?: unknown })?.isError === true) {
|
|
const content = (result as { content?: unknown })?.content
|
|
const detail = Array.isArray(content)
|
|
? content
|
|
.filter((item): item is { text: string; type: 'text' } =>
|
|
Boolean(
|
|
item &&
|
|
typeof item === 'object' &&
|
|
(item as { type?: unknown }).type === 'text' &&
|
|
typeof (item as { text?: unknown }).text === 'string',
|
|
),
|
|
)
|
|
.map((item) => item.text)
|
|
.join(' ')
|
|
.slice(0, 1000)
|
|
: ''
|
|
throw new Error(`Pascal MCP ${operation} failed${detail ? `: ${detail}` : '.'}`)
|
|
}
|
|
}
|
|
|
|
export async function createPascalAgentMcpSession(params: {
|
|
abortSignal?: AbortSignal
|
|
env?: NodeJS.ProcessEnv
|
|
sceneId: string
|
|
stepTimeoutMs: number
|
|
}): Promise<PascalAgentMcpSession> {
|
|
const env = params.env ?? process.env
|
|
const entry = resolveMcpEntry(env)
|
|
const transport = new Experimental_StdioMCPTransport({
|
|
args: [entry, '--stdio'],
|
|
command: resolveMcpCommand(env),
|
|
cwd: path.resolve(path.dirname(entry), '../../../..'),
|
|
env: stringEnvironment(env),
|
|
stderr: 'inherit',
|
|
})
|
|
|
|
let client: experimental_MCPClient | null = null
|
|
try {
|
|
client = await experimental_createMCPClient({
|
|
name: 'pascal-editor-agent',
|
|
transport,
|
|
version: '0.1.0',
|
|
})
|
|
const allTools = (await client.tools()) as ToolSet
|
|
const loadScene = requireTool(allTools, 'load_scene')
|
|
const loadResult = await loadScene.execute(
|
|
{ id: params.sceneId },
|
|
toolExecutionOptions(params.abortSignal),
|
|
)
|
|
assertToolSucceeded(loadResult, 'load_scene')
|
|
const sceneName = readSceneName(loadResult, params.sceneId)
|
|
const saveScene = withStepTimeout(requireTool(allTools, 'save_scene'), params.stepTimeoutMs)
|
|
|
|
const scopedSaveScene: RequiredTool = {
|
|
...saveScene,
|
|
execute: async (input, options) => {
|
|
const candidate = input && typeof input === 'object' ? input : {}
|
|
const result = await saveScene.execute(
|
|
{
|
|
...candidate,
|
|
expectedVersion: undefined,
|
|
graph: undefined,
|
|
id: params.sceneId,
|
|
includeCurrentScene: true,
|
|
name: sceneName,
|
|
projectId: undefined,
|
|
},
|
|
options,
|
|
)
|
|
return result
|
|
},
|
|
}
|
|
|
|
const tools = Object.fromEntries(
|
|
Object.entries(allTools)
|
|
.filter(([name]) => isPascalAgentToolAllowed(name))
|
|
.map(([name, tool]) => [
|
|
name,
|
|
name === 'save_scene'
|
|
? scopedSaveScene
|
|
: withStepTimeout(tool as RequiredTool, params.stepTimeoutMs),
|
|
]),
|
|
) as ToolSet
|
|
|
|
let closed = false
|
|
const close = async () => {
|
|
if (closed) return
|
|
closed = true
|
|
await client?.close()
|
|
}
|
|
|
|
return {
|
|
close,
|
|
sceneName,
|
|
tools,
|
|
saveDraft: async (abortSignal) => {
|
|
const result = await scopedSaveScene.execute(
|
|
{
|
|
id: params.sceneId,
|
|
includeCurrentScene: true,
|
|
name: sceneName,
|
|
saveMode: 'draft',
|
|
},
|
|
toolExecutionOptions(abortSignal),
|
|
)
|
|
assertToolSucceeded(result, 'save_scene')
|
|
},
|
|
}
|
|
} catch (error) {
|
|
await client?.close().catch(() => undefined)
|
|
throw error
|
|
}
|
|
}
|