From 0b922afe931845c6ffb33a8601070c2b9141449a Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Fri, 7 Aug 2026 12:41:59 -0400 Subject: [PATCH] feat(cli): start managed MCP with local editor --- README.md | 9 +- SETUP.md | 5 +- bun.lock | 13 +- packages/cli/README.md | 58 ++-- packages/cli/package.json | 9 +- packages/cli/scripts/smoke-packed-runtime.ts | 36 +++ packages/cli/scripts/stage-runtime.ts | 34 +++ packages/cli/src/bin/pascal.ts | 240 +++++++++++++++-- packages/cli/src/cli.test.ts | 25 ++ packages/cli/src/diagnostics.ts | 23 +- packages/cli/src/editor-process.ts | 252 +++++++++++++++--- packages/cli/src/index.ts | 1 + packages/cli/src/mcp-connector.ts | 61 +++++ packages/cli/src/paths.ts | 2 + packages/cli/src/projects.test.ts | 42 +++ packages/cli/src/projects.ts | 87 ++++++ packages/cli/src/runtime.test.ts | 65 +++++ packages/cli/src/runtime.ts | 19 +- packages/mcp/README.md | 95 ++++--- packages/mcp/src/bin/pascal-mcp.ts | 29 +- packages/mcp/src/index.test.ts | 3 +- packages/mcp/src/index.ts | 3 +- packages/mcp/src/server.ts | 5 +- .../mcp/src/storage/sqlite-scene-store.ts | 3 +- packages/mcp/src/transports/http.test.ts | 52 +++- packages/mcp/src/transports/http.ts | 63 ++++- packages/mcp/src/version.ts | 9 + 27 files changed, 1072 insertions(+), 171 deletions(-) create mode 100644 packages/cli/src/mcp-connector.ts create mode 100644 packages/cli/src/projects.test.ts create mode 100644 packages/cli/src/projects.ts create mode 100644 packages/mcp/src/version.ts diff --git a/README.md b/README.md index 89e647f5e..79371cef3 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ cloning this repository: npx @pascal-app/cli editor ``` -The CLI starts the editor in the background at `http://pascal.localhost:` and -keeps projects in `~/.pascal/data/pascal.db`. See [Run Pascal -locally](https://editor.pascal.app/docs/developers/local-editor) for pnpm/Bun commands, -lifecycle management, updates, storage paths, and troubleshooting. +The CLI starts the editor and an authenticated MCP service in the background, selects +collision-free loopback ports, and keeps projects in `~/.pascal/data/pascal.db`. Configure +an agent to launch `pascal mcp connect`. See [Run Pascal locally](https://editor.pascal.app/docs/developers/local-editor) +for pnpm/Bun commands, project management, MCP setup, updates, storage paths, and +troubleshooting. ## Using Published Packages diff --git a/SETUP.md b/SETUP.md index c2b338721..80c33bc02 100644 --- a/SETUP.md +++ b/SETUP.md @@ -58,7 +58,10 @@ background, and open it in the browser without a repository checkout: npx @pascal-app/cli editor ``` -Use `npx @pascal-app/cli doctor` to check the runtime, storage, and process state. Saved +The command starts the editor and its authenticated local MCP service together. Configure +an agent to launch `pascal mcp connect`; for example, run `pascal mcp setup codex`. + +Use `npx @pascal-app/cli doctor` to check the runtime, storage, editor, and MCP state. Saved scenes live in `~/.pascal/data/pascal.db` independently from installed runtime versions. The CLI retains old runtime versions for rollback and warns after more than three have accumulated. It also replaces a damaged copy of its bundled runtime on the next start; diff --git a/bun.lock b/bun.lock index 472c7596f..f32d84c14 100644 --- a/bun.lock +++ b/bun.lock @@ -99,9 +99,12 @@ }, "packages/cli": { "name": "@pascal-app/cli", - "version": "0.1.0", + "version": "0.1.4", "bin": { - "pascal": "./dist/bin/pascal.js", + "pascal": "dist/bin/pascal.js", + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", }, "devDependencies": { "@pascal/typescript-config": "*", @@ -1432,7 +1435,7 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -2052,6 +2055,8 @@ "postcss/nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], + "promise-worker-transferable/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + "react-doctor/agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], "react-doctor/eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], @@ -2060,8 +2065,6 @@ "react-scan/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "three-stdlib/fflate": ["fflate@0.6.10", "", {}, "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg=="], diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a1c841b9..83d4f9de7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,10 +17,10 @@ for `status`, `logs`, `stop`, and future sessions without another setup step. If global installation is unavailable because of local npm permissions, the editor remains running and the CLI shows the equivalent `npx` commands plus the manual install command. -The first run walks through local storage, runtime installation, automatic port -selection, process startup, and a health check with live terminal feedback. It then -opens `http://pascal.localhost:`. Your projects are stored separately from the -runtime, so updating the CLI does not replace your work. +The first run walks through local storage, runtime installation, automatic editor and +MCP port selection, process startup, and both health checks with live terminal feedback. +It then opens `http://pascal.localhost:`. Your projects are stored separately from +the runtime, so updating the CLI does not replace your work. ## Why use the CLI? @@ -28,6 +28,7 @@ runtime, so updating the CLI does not replace your work. - Keep projects on your machine in a local SQLite database. - Start and stop the editor independently from your terminal session. - Inspect health, logs, versions, storage, and project state from scripts or agents. +- Connect Codex, Claude Code, Cursor, or another MCP client to the same local projects. - Update through a health-checked activation that rolls back if the new runtime fails. ## Requirements @@ -83,16 +84,22 @@ npx @pascal-app/cli editor --foreground --no-open | --- | --- | | `pascal editor` | Install if needed, ensure the editor is running, and open it. | | `pascal start` | Ensure the editor is running without opening a browser. | -| `pascal stop [--force]` | Stop the managed process; `--force` is a guarded recovery path. | -| `pascal restart` | Restart the editor with its current configuration. | -| `pascal status [--json]` | Show health, version, PID, URL, and runtime metadata. | -| `pascal open` | Open the running editor in your default browser. | +| `pascal stop [--force]` | Stop the managed editor and MCP processes; `--force` is a guarded recovery path. | +| `pascal restart` | Restart the editor and MCP service with their current configuration. | +| `pascal status [--json]` | Show editor and MCP health, version, PIDs, ports, URL, and runtime metadata. | +| `pascal open [project]` | Start Pascal if needed, then open the editor or a project by ID, ID prefix, or unique name. | +| `pascal resume [project]` | Open the latest project, or a selected project. | +| `pascal projects [--json]` | List local projects. | | `pascal logs [--follow]` | Read or follow the managed editor log. | | `pascal update [--version ]` | Health-check and activate a published runtime. | | `pascal doctor [--json]` | Diagnose Node.js, storage, runtime, process, and plugin state. | | `pascal info [--json]` | Print platform, paths, runtime, and plugin context. | -| `pascal project list [--json]` | List projects in the running local editor. | -| `pascal project open ` | Open a local project in your browser. | +| `pascal project list [--json]` | Explicit form of `pascal projects`. | +| `pascal project open ` | Explicit form of `pascal open `. | +| `pascal mcp connect` | Stable local connector for MCP clients; discovers the dynamic managed service. | +| `pascal mcp status [--json]` | Show managed MCP health. | +| `pascal mcp config [--json]` | Print generic MCP client configuration. | +| `pascal mcp setup ` | Configure an installed client without overwriting existing entries. | | `pascal plugin list [--json]` | Inspect the reserved managed-plugin lock. | When you do not install globally, prefix commands with a runner—for example, @@ -100,15 +107,17 @@ When you do not install globally, prefix commands with a runner—for example, ## Local data and security -Pascal binds only to `127.0.0.1` and uses the reserved `.localhost` hostname. The -initial CLI does not expose an unauthenticated editor to your network. +Pascal binds the editor and MCP service only to `127.0.0.1` and uses the reserved +`.localhost` hostname. MCP requires a random token stored in Pascal's private runtime +directory; client configuration never contains that token. ```text ~/.pascal/ runtime// installed editor runtimes data/pascal.db projects and scenes logs/editor.log detached editor output - run/editor.json managed process identity + run/editor.json managed editor and MCP process identity + run/mcp-token private local MCP token plugins/ reserved verified-plugin storage pascal.plugins.lock reserved managed-plugin lock ``` @@ -118,17 +127,30 @@ The CLI does not include a command that deletes project data. Updates retain the previous runtime for rollback, and `pascal doctor` warns when more than three versions have accumulated. -## Plugins and AI agents +## Local AI agents + +The MCP server starts automatically with `pascal editor`. Add the stable connector to +your client once: + +```bash +pascal mcp setup codex +pascal mcp setup claude +``` + +Or use `pascal mcp config` for JSON-based clients. The connector also starts Pascal +when an agent connects while it is stopped. Ask the agent to read +`pascal://agent-guide`, list or load a scene, edit it, and return the `editorUrl`. + +## Plugins The current CLI manages the local editor runtime; it does not yet download plugin code from GitHub or npm. Follow the [plugin authoring guide](https://editor.pascal.app/docs/developers/plugins) and the standalone [Nature plugin](https://github.com/pascalorg/plugin-trees) when building an extension today. -Pascal also exposes a hosted Model Context Protocol endpoint for Claude Code, Codex, -Cursor, OpenClaw, and other MCP clients. See [Connect an AI agent](https://editor.pascal.app/docs/developers/mcp) -for the hosted setup and the relationship between hosted projects, the local editor, -and `@pascal-app/mcp`. +Pascal also exposes a hosted Model Context Protocol endpoint for projects in a Pascal +account. See [Connect an AI agent](https://editor.pascal.app/docs/developers/mcp) for +the local and hosted workflows and the standalone `@pascal-app/mcp` package. ## Documentation and support diff --git a/packages/cli/package.json b/packages/cli/package.json index 146f880fe..66fed4f68 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@pascal-app/cli", "version": "0.1.4", - "description": "Run and manage the open-source Pascal 3D building editor locally from your terminal", + "description": "Run the open-source Pascal 3D editor, local projects, and MCP agent tools from your terminal", "type": "module", "bin": { "pascal": "dist/bin/pascal.js" @@ -34,6 +34,9 @@ "@types/node": "^22.19.20", "typescript": "6.0.3" }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0" + }, "engines": { "node": ">=22.13.0" }, @@ -47,7 +50,9 @@ "cad", "bim", "local-first", - "cli" + "cli", + "mcp", + "ai-agents" ], "repository": { "type": "git", diff --git a/packages/cli/scripts/smoke-packed-runtime.ts b/packages/cli/scripts/smoke-packed-runtime.ts index 3062c1185..763fd39ff 100644 --- a/packages/cli/scripts/smoke-packed-runtime.ts +++ b/packages/cli/scripts/smoke-packed-runtime.ts @@ -4,6 +4,8 @@ import http from 'node:http' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' const packageDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const smokeRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-smoke-')) @@ -84,6 +86,40 @@ try { undefined, smokeEnvironment, ) + const mcpTransport = new StdioClientTransport({ + command: process.execPath, + args: [smokeExecutable, 'mcp', 'connect'], + env: smokeEnvironment as Record, + stderr: 'pipe', + }) + const mcpClient = new Client({ name: 'pascal-cli-smoke', version: '0.0.0' }) + try { + await mcpClient.connect(mcpTransport) + const tools = await mcpClient.listTools() + if (!tools.tools.some((tool) => tool.name === 'save_scene')) { + throw new Error('managed MCP did not expose save_scene') + } + const saved = await mcpClient.callTool({ + name: 'save_scene', + arguments: { id: 'smoke-project', name: 'Smoke project' }, + }) + if (saved.isError) throw new Error(`managed MCP save_scene failed: ${JSON.stringify(saved)}`) + } finally { + await mcpClient.close() + } + const resumed = JSON.parse( + ( + await run( + process.execPath, + [smokeExecutable, 'resume', 'Smoke project', '--json'], + undefined, + smokeEnvironment, + ) + ).stdout, + ) as { project: { id: string }; url: string } + if (resumed.project.id !== 'smoke-project' || !resumed.url.endsWith('/scene/smoke-project')) { + throw new Error('CLI project resume did not resolve the MCP-saved project') + } await run(process.execPath, [smokeExecutable, 'doctor', '--json'], undefined, smokeEnvironment) await run(process.execPath, [smokeExecutable, 'stop', '--json'], undefined, smokeEnvironment) smokeExecutable = null diff --git a/packages/cli/scripts/stage-runtime.ts b/packages/cli/scripts/stage-runtime.ts index 5f879b73b..69e27591d 100644 --- a/packages/cli/scripts/stage-runtime.ts +++ b/packages/cli/scripts/stage-runtime.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process' import { chmod, cp, mkdir, readdir, readFile, realpath, rm, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -30,6 +31,7 @@ await cp( path.join(outputDirectory, 'apps/editor/.next/static'), { recursive: true, force: true }, ) +await bundleMcpServer(outputDirectory, packageJson.version) await removeUnusedSharp(outputDirectory) await flattenBunNodeModules(outputDirectory) @@ -47,7 +49,9 @@ await writeFile( schemaVersion: 1, version: packageJson.version, entrypoint: 'apps/editor/server.js', + mcpEntrypoint: 'services/pascal-mcp.mjs', healthPath: '/api/health', + mcpHealthPath: '/health', }, null, 2, @@ -56,6 +60,36 @@ await writeFile( console.log(`Staged Pascal editor runtime ${packageJson.version} at ${outputDirectory}`) +async function bundleMcpServer(runtimeDirectory: string, version: string): Promise { + const output = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') + await mkdir(path.dirname(output), { recursive: true }) + const child = spawn( + process.execPath, + [ + 'build', + path.join(repositoryRoot, 'packages/mcp/src/bin/pascal-mcp.ts'), + '--outfile', + output, + '--target', + 'node', + '--format', + 'esm', + '--define', + `process.env.PASCAL_MCP_VERSION=${JSON.stringify(version)}`, + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ) + const stderr: Buffer[] = [] + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)) + const exitCode = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code) => resolve(code ?? 1)) + }) + if (exitCode !== 0) { + throw new Error(`Unable to bundle the Pascal MCP server: ${Buffer.concat(stderr).toString()}`) + } +} + async function assertFile(filePath: string): Promise { try { await readFile(filePath) diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index a81be7ede..be1649c03 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -16,7 +16,9 @@ import { } from '../editor-process.js' import { CliError, toCliError } from '../errors.js' import { readJsonFile } from '../json-files.js' +import { connectManagedMcp } from '../mcp-connector.js' import { resolvePascalPaths } from '../paths.js' +import { listLocalProjects, projectUrl, resolveLocalProject } from '../projects.js' import { installBundledRuntime } from '../runtime.js' import { TerminalProgress } from '../terminal-progress.js' import { version } from '../version.js' @@ -37,16 +39,38 @@ ENABLE THE SHORT GLOBAL COMMAND: USAGE: pascal editor [--foreground] [--no-open] [--port ] pascal start [--foreground] [--port ] - pascal stop | restart | status | open + pascal stop | restart | status + pascal open [project] + pascal resume [project] + pascal projects [--json] pascal logs [--follow] [--lines ] pascal update [--version ] pascal doctor [--json] pascal info [--json] pascal project list [--json] - pascal project open + pascal project open + pascal project resume [id-or-name] + pascal mcp connect | status | config | setup pascal plugin list [--json] -Run "pascal --help" for the command reference. +Documentation: https://editor.pascal.app/docs/developers/local-editor +` + +const MCP_HELP = `Pascal MCP — connect AI agents to local projects + +The authenticated MCP service starts and stops with the Pascal editor. + +USAGE: + pascal mcp status [--json] Check the managed MCP service + pascal mcp setup codex Configure Codex CLI + pascal mcp setup claude Configure Claude Code + pascal mcp config [--json] Print generic MCP client JSON + pascal mcp connect Start the stdio client connector + +MCP clients should run "pascal mcp connect"; the connector discovers the +dynamic loopback port without exposing Pascal's private local token. + +Documentation: https://editor.pascal.app/docs/developers/mcp ` const paths = resolvePascalPaths() @@ -55,7 +79,9 @@ async function main(): Promise { const [command = 'help', ...args] = process.argv.slice(2) if (command === '--version' || command === '-v') return print(version) if (command === '--help' || command === '-h' || command === 'help') return print(HELP) - if (args.includes('--help') || args.includes('-h')) return print(HELP) + if (args.includes('--help') || args.includes('-h')) { + return print(command === 'mcp' ? MCP_HELP : HELP) + } switch (command) { case 'editor': @@ -70,6 +96,10 @@ async function main(): Promise { return runStatus(args) case 'open': return runOpen(args) + case 'resume': + return runProjectOpen(args, true) + case 'projects': + return runProject(['list', ...args]) case 'logs': return runLogs(args) case 'doctor': @@ -82,6 +112,8 @@ async function main(): Promise { return runProject(args) case 'plugin': return runPlugin(args) + case 'mcp': + return runMcp(args) case '_install-runtime': return output(true, await installBundledRuntime(paths, undefined, { activate: false }), '') default: @@ -143,12 +175,18 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { result.alreadyRunning ? `Pascal is already running at ${result.state.url}` : `Pascal is ready at ${result.state.url}`, + `MCP is ready on port ${result.state.mcp?.port}`, `Projects stay in ${paths.data}`, '', `Manage it with ${useShortCommand ? 'pascal' : 'npx'}:`, ` ${commandPrefix} status Check the local editor`, + ` ${commandPrefix} projects List local projects`, + ` ${commandPrefix} resume Resume your latest project`, ` ${commandPrefix} logs --follow Follow editor logs`, ` ${commandPrefix} stop Stop the background process`, + ...(useShortCommand + ? ['', 'Connect an AI agent:', ` ${commandPrefix} mcp setup codex`] + : []), ...(useShortCommand ? [] : [ @@ -162,6 +200,7 @@ async function runStart(args: string[], shouldOpen: boolean): Promise { const exitCode = await new Promise((resolve) => result.child?.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))), ) + await stopEditor(paths, { force: true }).catch(() => undefined) process.exitCode = exitCode } } @@ -196,8 +235,17 @@ function reportStartProgress(progress: TerminalProgress, event: EditorStartProgr case 'health-checking': progress.update('Checking that the editor is ready') return + case 'mcp-port-ready': + progress.succeed(`MCP port ${event.port} selected automatically`) + return + case 'mcp-starting': + progress.start('Starting Pascal MCP') + return + case 'mcp-health-checking': + progress.update('Checking that MCP is ready') + return case 'ready': - progress.succeed('Pascal Editor is ready') + progress.succeed('Pascal Editor and MCP are ready') return case 'already-running': progress.succeed(`Pascal is already running on port ${event.port}`) @@ -230,7 +278,10 @@ async function runStatus(args: string[]): Promise { json, status, status.healthy - ? `Pascal ${status.state?.version} is running at ${status.state?.url}` + ? [ + `Pascal ${status.state?.version} is running at ${status.state?.url}`, + `MCP is ready on port ${status.state?.mcp?.port}`, + ].join('\n') : status.running ? 'Pascal has a running but unhealthy process.' : status.installed @@ -241,12 +292,19 @@ async function runStatus(args: string[]): Promise { } async function runOpen(args: string[]): Promise { - const json = booleanOption(args, 'json') - const status = await getEditorStatus(paths) - if (!status.healthy || !status.state) - throw new CliError('editor_stopped', 'Pascal is not running.') + const { values, positionals } = parseArgs({ + args, + strict: true, + allowPositionals: true, + options: { json: { type: 'boolean', default: false } }, + }) + if (positionals.length > 1) { + throw new CliError('invalid_option', 'Use "pascal open [project]".', undefined, 2) + } + if (positionals[0]) return runProjectOpen(args, false) + const status = await ensureRunningEditor() openBrowser(status.state.url) - output(json, { url: status.state.url }, status.state.url) + output(values.json, { url: status.state.url }, status.state.url) } async function runLogs(args: string[]): Promise { @@ -294,6 +352,7 @@ async function runInfo(args: string[]): Promise { `Home: ${paths.root}`, `Runtime: ${info.editor.runtime?.version ?? 'not installed'}`, `Editor: ${info.editor.healthy ? info.editor.state?.url : 'stopped'}`, + `MCP: ${info.editor.components.mcp.healthy ? `ready on port ${info.editor.state?.mcp?.port}` : 'stopped'}`, `Plugins: ${info.plugins.length}`, ].join('\n'), ) @@ -372,38 +431,146 @@ async function runProject(args: string[]): Promise { const [subcommand, ...rest] = args if (subcommand === 'list') { const json = booleanOption(rest, 'json') - const status = await requireRunningEditor() - const response = await fetch(`http://127.0.0.1:${status.state.port}/api/scenes`) - if (!response.ok) - throw new CliError('project_list_failed', `Scene API returned ${response.status}.`) - const body = (await response.json()) as { scenes?: Array<{ id: string; name: string }> } + const status = await ensureRunningEditor() + const projects = await listLocalProjects(status.state) output( json, - body, - body.scenes?.length - ? body.scenes.map((scene) => `${scene.id}\t${scene.name}`).join('\n') + { projects }, + projects.length + ? projects + .map( + (project) => + `${project.id}\t${project.name}\t${new Date(project.updatedAt).toLocaleString()}`, + ) + .join('\n') : 'No projects yet.', ) return } if (subcommand === 'open') { + return runProjectOpen(rest, false) + } + if (subcommand === 'resume') { + return runProjectOpen(rest, true) + } + throw new CliError( + 'unknown_command', + 'Use "pascal project list", "pascal project open ", or "pascal project resume".', + undefined, + 2, + ) +} + +async function runProjectOpen(args: string[], latestWhenMissing: boolean): Promise { + const { values, positionals } = parseArgs({ + args, + strict: true, + allowPositionals: true, + options: { json: { type: 'boolean', default: false } }, + }) + if (positionals.length > 1 || (!latestWhenMissing && positionals.length !== 1)) { + throw new CliError( + 'invalid_option', + latestWhenMissing ? 'Use "pascal resume [project]".' : 'Use "pascal open ".', + undefined, + 2, + ) + } + const status = await ensureRunningEditor() + const projects = await listLocalProjects(status.state) + const project = resolveLocalProject(projects, positionals[0]) + const url = projectUrl(status.state, project) + openBrowser(url) + output(values.json, { project, url }, `${project.name}\n${url}`) +} + +async function runMcp(args: string[]): Promise { + const [subcommand, ...rest] = args + if (subcommand === 'connect') { + if (rest.length > 0) { + throw new CliError('invalid_option', 'Use "pascal mcp connect".', undefined, 2) + } + await connectManagedMcp(paths) + return + } + if (subcommand === 'status') { + const json = booleanOption(rest, 'json') + const status = await getEditorStatus(paths) + const result = { + running: status.components.mcp.running, + healthy: status.components.mcp.healthy, + port: status.state?.mcp?.port ?? null, + } + output( + json, + result, + result.healthy + ? `Pascal MCP is ready on port ${result.port}.` + : result.running + ? 'Pascal MCP is running but unhealthy.' + : 'Pascal MCP is stopped.', + ) + if (result.running && !result.healthy) process.exitCode = 1 + return + } + if (subcommand === 'config') { + const json = booleanOption(rest, 'json') + const config = { command: 'pascal', args: ['mcp', 'connect'] } + const document = { mcpServers: { pascal: config } } + output(json, document, JSON.stringify(document, null, 2)) + return + } + if (subcommand === 'setup') { const { values, positionals } = parseArgs({ args: rest, strict: true, allowPositionals: true, options: { json: { type: 'boolean', default: false } }, }) - if (positionals.length !== 1) { - throw new CliError('invalid_option', 'Use "pascal project open ".', undefined, 2) + const client = positionals[0] + if (positionals.length !== 1 || (client !== 'codex' && client !== 'claude')) { + throw new CliError( + 'invalid_option', + 'Use "pascal mcp setup codex" or "pascal mcp setup claude".', + undefined, + 2, + ) } - const status = await requireRunningEditor() - const url = `${status.state.url}/scene/${encodeURIComponent(positionals[0]!)}` - openBrowser(url) - return output(values.json, { url }, url) + await ensureShortCommandAvailable() + const command = client === 'codex' ? 'codex' : 'claude' + const commandArgs = + client === 'codex' + ? ['mcp', 'add', 'pascal', '--', 'pascal', 'mcp', 'connect'] + : ['mcp', 'add', '--scope', 'user', 'pascal', '--', 'pascal', 'mcp', 'connect'] + let result: Awaited> + try { + result = await spawnAndCapture(command, commandArgs) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new CliError( + 'mcp_client_unavailable', + `${client === 'codex' ? 'Codex' : 'Claude Code'} is not installed or is not on PATH.`, + ) + } + throw error + } + if (result.exitCode !== 0) { + throw new CliError( + 'mcp_setup_failed', + `Unable to configure ${client}. It may already have a Pascal MCP entry.`, + { stderr: result.stderr.trim() || undefined, stdout: result.stdout.trim() || undefined }, + ) + } + output( + values.json, + { client, configured: true, command: 'pascal', args: ['mcp', 'connect'] }, + `${client === 'codex' ? 'Codex' : 'Claude Code'} now uses the managed Pascal MCP service. Start a new agent session to connect.`, + ) + return } throw new CliError( 'unknown_command', - 'Use "pascal project list" or "pascal project open ".', + 'Use "pascal mcp connect", "pascal mcp status", "pascal mcp config", or "pascal mcp setup ".', undefined, 2, ) @@ -438,10 +605,14 @@ async function runPlugin(args: string[]): Promise { ) } -async function requireRunningEditor() { +async function ensureRunningEditor() { const status = await getEditorStatus(paths) - if (!status.healthy || !status.state) throw new CliError('editor_stopped', 'Start Pascal first.') - return { ...status, state: status.state } + if (status.healthy && status.state) return { ...status, state: status.state } + const started = await startEditor({ paths }) + return { + ...(await getEditorStatus(paths)), + state: started.state, + } } function booleanOption(args: string[], name: string): boolean { @@ -473,6 +644,17 @@ function print(value: string): void { process.stdout.write(value.endsWith('\n') ? value : `${value}\n`) } +async function ensureShortCommandAvailable(): Promise { + try { + const result = await spawnAndCapture('pascal', ['--version']) + if (result.exitCode === 0 && result.stdout === version) return + } catch {} + throw new CliError( + 'pascal_command_unavailable', + `The matching Pascal CLI ${version} is required in MCP client configuration. Run "npm install --global @pascal-app/cli@${version}" and try again.`, + ) +} + async function spawnAndCapture( command: string, args: string[], diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 69acc999f..af6101727 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -19,6 +19,15 @@ describe('command parsing', () => { expect(result.stdout).toContain('npm install --global @pascal-app/cli') }) + test('shows focused help for MCP commands', async () => { + const result = await runCli('mcp', '--help') + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('pascal mcp setup codex') + expect(result.stdout).toContain('dynamic loopback port') + expect(result.stdout).not.toContain('pascal plugin list') + }) + test('rejects a partially numeric port', async () => { const result = await runCli('editor', '--port', '3000junk', '--no-open', '--json') @@ -47,6 +56,22 @@ describe('command parsing', () => { expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' }) }) + test('prints stable local MCP client configuration', async () => { + const result = await runCli('mcp', 'config', '--json') + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + mcpServers: { pascal: { command: 'pascal', args: ['mcp', 'connect'] } }, + }) + }) + + test('rejects unsupported automatic MCP client setup', async () => { + const result = await runCli('mcp', 'setup', 'cursor', '--json') + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' }) + }) + test('reports a malformed plugin lock as managed-state corruption', async () => { await mkdir(testHome, { recursive: true }) await writeFile(path.join(testHome, 'pascal.plugins.lock'), '{"schemaVersion":1}') diff --git a/packages/cli/src/diagnostics.ts b/packages/cli/src/diagnostics.ts index a1b498e9b..b499341b1 100644 --- a/packages/cli/src/diagnostics.ts +++ b/packages/cli/src/diagnostics.ts @@ -56,13 +56,30 @@ export async function runDoctor(paths: PascalPaths): Promise }) checks.push({ id: 'editor', - status: status.healthy ? 'pass' : status.running ? 'fail' : 'warn', - message: status.healthy + status: status.components.editor.healthy + ? 'pass' + : status.components.editor.running + ? 'fail' + : 'warn', + message: status.components.editor.healthy ? `Healthy at ${status.state?.url}` - : status.running + : status.components.editor.running ? 'A recorded editor process is running but unhealthy.' : 'The editor is stopped.', }) + checks.push({ + id: 'mcp', + status: status.components.mcp.healthy + ? 'pass' + : status.components.mcp.running + ? 'fail' + : 'warn', + message: status.components.mcp.healthy + ? `MCP is healthy on loopback port ${status.state?.mcp?.port}.` + : status.components.mcp.running + ? 'The managed MCP process is running but unhealthy.' + : 'MCP is stopped with the editor.', + }) const runtimeVersions = (await readdir(paths.runtime, { withFileTypes: true })) .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry) => entry.name) diff --git a/packages/cli/src/editor-process.ts b/packages/cli/src/editor-process.ts index bf861e3f0..801710531 100644 --- a/packages/cli/src/editor-process.ts +++ b/packages/cli/src/editor-process.ts @@ -1,7 +1,7 @@ import { type ChildProcess, execFile, spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { closeSync, openSync } from 'node:fs' -import { mkdir, open, rename, rm, stat } from 'node:fs/promises' +import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' import net from 'node:net' import path from 'node:path' import { CliError } from './errors.js' @@ -26,6 +26,14 @@ export interface EditorState { instanceId: string runtimeDirectory: string startedAt: string + mcp?: McpState +} + +export interface McpState { + pid: number + port: number + host: '127.0.0.1' + url: string } export interface EditorStatus { @@ -34,6 +42,10 @@ export interface EditorStatus { healthy: boolean state: EditorState | null runtime: ActiveRuntime | null + components: { + editor: { running: boolean; healthy: boolean } + mcp: { running: boolean; healthy: boolean } + } } export interface StartEditorOptions { @@ -51,6 +63,9 @@ export type EditorStartProgress = | { step: 'port-ready'; port: number; preferredPort: number } | { step: 'process-starting'; port: number } | { step: 'health-checking'; port: number } + | { step: 'mcp-port-ready'; port: number } + | { step: 'mcp-starting'; port: number } + | { step: 'mcp-health-checking'; port: number } | { step: 'ready'; port: number } | { step: 'already-running'; port: number } @@ -83,11 +98,35 @@ export async function getEditorStatus(paths: PascalPaths): Promise readJsonFile(paths.state), ]) if (state?.schemaVersion !== 1 || typeof state.pid !== 'number') { - return { installed: Boolean(runtime), running: false, healthy: false, state: null, runtime } + return { + installed: Boolean(runtime), + running: false, + healthy: false, + state: null, + runtime, + components: { + editor: { running: false, healthy: false }, + mcp: { running: false, healthy: false }, + }, + } + } + const editorRunning = isProcessRunning(state.pid) + const mcpRunning = Boolean(state.mcp && isProcessRunning(state.mcp.pid)) + const [editorHealthy, mcpHealthy] = await Promise.all([ + editorRunning ? checkHealth(state) : false, + mcpRunning ? checkMcpHealth(paths, state) : false, + ]) + return { + installed: Boolean(runtime), + running: editorRunning || mcpRunning, + healthy: editorHealthy && mcpHealthy, + state, + runtime, + components: { + editor: { running: editorRunning, healthy: editorHealthy }, + mcp: { running: mcpRunning, healthy: mcpHealthy }, + }, } - const running = isProcessRunning(state.pid) - const healthy = running && (await checkHealth(state)) - return { installed: Boolean(runtime), running, healthy, state, runtime } } export async function startEditor(options: StartEditorOptions): Promise { @@ -115,12 +154,16 @@ async function startEditorUnlocked(options: StartEditorOptions): Promise { const state = await readJsonFile(paths.state) - if (!state || !isProcessRunning(state.pid)) { + const editorRunning = Boolean(state && isProcessRunning(state.pid)) + const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) + if (!state || (!editorRunning && !mcpRunning)) { await rm(paths.state, { force: true }) + await rm(paths.mcpToken, { force: true }) return false } - if (!(await checkHealth(state))) { - if (options.force && (await matchesRecordedEditorProcess(paths, state))) { - await terminateProcess(state.pid) - await rm(paths.state, { force: true }) - return true - } + const [editorHealthy, mcpHealthy] = await Promise.all([ + editorRunning ? checkHealth(state) : true, + mcpRunning ? checkMcpHealth(paths, state) : true, + ]) + const editorIdentified = + editorHealthy || + (options.force && editorRunning && (await matchesRecordedEditorProcess(paths, state))) + const mcpIdentified = + mcpHealthy || (options.force && mcpRunning && (await matchesRecordedMcpProcess(paths, state))) + if (!editorIdentified || !mcpIdentified) { throw new CliError( 'state_conflict', options.force - ? `Refusing to stop process ${state.pid} because neither its health identity nor its operating-system command matches the recorded Pascal editor.` - : `Refusing to stop process ${state.pid} because its health identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if it is the recorded editor.`, + ? 'Refusing to stop a process whose health identity and operating-system command do not match the recorded Pascal runtime.' + : 'A Pascal process identity is unavailable. Inspect "pascal status --json", then use "pascal stop --force" only if the recorded commands are trusted.', ) } - await terminateProcess(state.pid) + if (mcpRunning && state.mcp) await terminateProcess(state.mcp.pid) + if (editorRunning) await terminateProcess(state.pid) await rm(paths.state, { force: true }) + await rm(paths.mcpToken, { force: true }) return true } @@ -250,22 +348,28 @@ export async function activateEditorRuntime( let previousStatus: EditorStatus if (previousRuntimeWasInvalid) { const state = await readJsonFile(paths.state) - const running = Boolean(state && isProcessRunning(state.pid)) - const healthy = Boolean(state && running && (await checkHealth(state))) + const editorRunning = Boolean(state && isProcessRunning(state.pid)) + const mcpRunning = Boolean(state?.mcp && isProcessRunning(state.mcp.pid)) + const editorHealthy = Boolean(state && editorRunning && (await checkHealth(state))) + const mcpHealthy = Boolean(state && mcpRunning && (await checkMcpHealth(paths, state))) previousStatus = { installed: false, - running, - healthy, + running: editorRunning || mcpRunning, + healthy: editorHealthy && mcpHealthy, state: state ?? null, runtime: null, + components: { + editor: { running: editorRunning, healthy: editorHealthy }, + mcp: { running: mcpRunning, healthy: mcpHealthy }, + }, } } else { previousStatus = await getEditorStatus(paths) } - if (previousStatus.running && !previousStatus.healthy) { + if (previousStatus.running && !statusComponentsAreIdentified(previousStatus)) { throw new CliError( 'state_conflict', - 'The recorded editor process is running but unhealthy. Recover or stop it before updating.', + 'A recorded Pascal process is running but its identity could not be verified. Recover or stop it before updating.', ) } if ( @@ -275,7 +379,7 @@ export async function activateEditorRuntime( return { runtime: previousRuntime, restarted: false } } - const wasRunning = previousStatus.healthy + const wasRunning = previousStatus.running const previousPort = previousStatus.state?.port if (wasRunning) await stopEditorUnlocked(paths) @@ -425,6 +529,69 @@ export async function waitForHealth(state: EditorState, timeoutMs: number): Prom throw new CliError('health_timeout', `Pascal did not become healthy within ${timeoutMs}ms.`) } +export async function checkMcpHealth(paths: PascalPaths, state: EditorState): Promise { + return (await probeMcpHealth(paths, state)) === 'healthy' +} + +async function probeMcpHealth( + paths: PascalPaths, + state: EditorState, +): Promise<'healthy' | 'foreign' | 'unreachable'> { + if (!state.mcp) return 'unreachable' + let token: string + try { + token = (await readFile(paths.mcpToken, 'utf8')).trim() + } catch { + return 'unreachable' + } + if (!token) return 'unreachable' + try { + const response = await fetch(`http://127.0.0.1:${state.mcp.port}/health`, { + headers: { authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(1_000), + }) + if (!response.ok) return 'foreign' + const body = (await response.json()) as { + status?: string + app?: string + version?: string + instanceId?: string + } + return body.status === 'ok' && + body.app === 'mcp' && + body.version === state.version && + body.instanceId === state.instanceId + ? 'healthy' + : 'foreign' + } catch { + return 'unreachable' + } +} + +async function waitForMcpHealth( + paths: PascalPaths, + state: EditorState, + timeoutMs: number, +): Promise { + if (!state.mcp) throw new CliError('start_failed', 'Pascal MCP state was not created.') + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const health = await probeMcpHealth(paths, state) + if (health === 'healthy') return + if (health === 'foreign') { + throw new CliError( + 'port_conflict', + `Port ${state.mcp.port} is responding as another application. Run Pascal again to choose another port.`, + ) + } + if (!isProcessRunning(state.mcp.pid)) { + throw new CliError('start_failed', 'Pascal MCP exited before becoming healthy.') + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new CliError('health_timeout', `Pascal MCP did not become healthy within ${timeoutMs}ms.`) +} + export function isProcessRunning(pid: number): boolean { if (!Number.isSafeInteger(pid) || pid <= 0) return false try { @@ -532,12 +699,30 @@ async function matchesRecordedEditorProcess( } catch { expectedEntrypoint = path.join(runtimeDirectory, 'apps/editor/server.js') } - const command = await new Promise((resolve) => { - execFile('ps', ['-ww', '-p', String(state.pid), '-o', 'command='], (error, stdout) => { + const command = await processCommand(state.pid) + return command.includes(expectedEntrypoint) +} + +async function matchesRecordedMcpProcess(paths: PascalPaths, state: EditorState): Promise { + if (process.platform === 'win32' || !state.mcp) return false + const runtimeDirectory = path.resolve(state.runtimeDirectory) + if (!runtimeDirectory.startsWith(`${path.resolve(paths.runtime)}${path.sep}`)) return false + let expectedEntrypoint: string + try { + const manifest = await readRuntimeManifest(runtimeDirectory) + expectedEntrypoint = path.resolve(runtimeDirectory, manifest.mcpEntrypoint) + } catch { + expectedEntrypoint = path.join(runtimeDirectory, 'services/pascal-mcp.mjs') + } + return (await processCommand(state.mcp.pid)).includes(expectedEntrypoint) +} + +async function processCommand(pid: number): Promise { + return new Promise((resolve) => { + execFile('ps', ['-ww', '-p', String(pid), '-o', 'command='], (error, stdout) => { resolve(error ? '' : stdout.trim()) }) }) - return command.includes(expectedEntrypoint) } async function rotateEditorLog(filePath: string): Promise { @@ -554,3 +739,10 @@ async function rotateEditorLog(filePath: string): Promise { function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } + +function statusComponentsAreIdentified(status: EditorStatus): boolean { + return ( + (!status.components.editor.running || status.components.editor.healthy) && + (!status.components.mcp.running || status.components.mcp.healthy) + ) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fc8c82355..6134f0db6 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ export { type EditorStatus, ensurePascalDirectories, getEditorStatus, + type McpState, type RuntimeActivationResult, restartEditor, type StopEditorOptions, diff --git a/packages/cli/src/mcp-connector.ts b/packages/cli/src/mcp-connector.ts new file mode 100644 index 000000000..21688e0d7 --- /dev/null +++ b/packages/cli/src/mcp-connector.ts @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' +import { getEditorStatus, startEditor } from './editor-process.js' +import { CliError } from './errors.js' +import type { PascalPaths } from './paths.js' + +export async function connectManagedMcp(paths: PascalPaths): Promise { + let status = await getEditorStatus(paths) + if (!status.healthy) { + await startEditor({ paths }) + status = await getEditorStatus(paths) + } + if (!(status.healthy && status.state?.mcp)) { + throw new CliError('mcp_unavailable', 'Pascal MCP is not healthy. Run "pascal doctor".') + } + + const token = (await readFile(paths.mcpToken, 'utf8')).trim() + if (!token) throw new CliError('mcp_unavailable', 'Pascal MCP credentials are missing.') + + const remote = new StreamableHTTPClientTransport(new URL(status.state.mcp.url), { + requestInit: { headers: { authorization: `Bearer ${token}` } }, + }) + const stdio = new StdioServerTransport() + let initializeRequestId: string | number | null = null + + stdio.onmessage = (message) => { + if ('method' in message && message.method === 'initialize' && 'id' in message) { + initializeRequestId = message.id + } + remote.send(message).catch(reportConnectorError) + } + stdio.onerror = reportConnectorError + remote.onmessage = (message) => { + applyProtocolVersion(remote, message, initializeRequestId) + stdio.send(message).catch(reportConnectorError) + } + remote.onerror = reportConnectorError + + await remote.start() + await stdio.start() +} + +function applyProtocolVersion( + transport: StreamableHTTPClientTransport, + message: JSONRPCMessage, + initializeRequestId: string | number | null, +): void { + if (!(initializeRequestId !== null && 'id' in message && message.id === initializeRequestId)) { + return + } + if (!('result' in message) || typeof message.result !== 'object' || message.result === null) + return + const protocolVersion = (message.result as { protocolVersion?: unknown }).protocolVersion + if (typeof protocolVersion === 'string') transport.setProtocolVersion(protocolVersion) +} + +function reportConnectorError(error: Error): void { + process.stderr.write(`[pascal-mcp] ${error.message}\n`) +} diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts index de7414e72..e07529fc9 100644 --- a/packages/cli/src/paths.ts +++ b/packages/cli/src/paths.ts @@ -13,6 +13,7 @@ export interface PascalPaths { pluginLock: string database: string editorLog: string + mcpToken: string } export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env): PascalPaths { @@ -29,5 +30,6 @@ export function resolvePascalPaths(environment: NodeJS.ProcessEnv = process.env) pluginLock: path.join(root, 'pascal.plugins.lock'), database: path.join(root, 'data/pascal.db'), editorLog: path.join(root, 'logs/editor.log'), + mcpToken: path.join(root, 'run/mcp-token'), } } diff --git a/packages/cli/src/projects.test.ts b/packages/cli/src/projects.test.ts new file mode 100644 index 000000000..b786e5f0d --- /dev/null +++ b/packages/cli/src/projects.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { type LocalProject, resolveLocalProject } from './projects.js' + +const projects: LocalProject[] = [ + { + id: 'kitchen-2026', + name: 'Kitchen renovation', + updatedAt: '2026-08-07T16:00:00.000Z', + version: 3, + nodeCount: 20, + }, + { + id: 'garden-room', + name: 'Garden room', + updatedAt: '2026-08-06T16:00:00.000Z', + version: 1, + nodeCount: 8, + }, +] + +describe('local project selection', () => { + test('resumes the newest project when no selector is given', () => { + expect(resolveLocalProject(projects)).toBe(projects[0]) + }) + + test('matches an exact id, a unique prefix, or a case-insensitive name', () => { + expect(resolveLocalProject(projects, 'garden-room')).toBe(projects[1]) + expect(resolveLocalProject(projects, 'kitchen')).toBe(projects[0]) + expect(resolveLocalProject(projects, 'GARDEN ROOM')).toBe(projects[1]) + }) + + test('never guesses when a selector is ambiguous', () => { + const ambiguous = [...projects, { ...projects[1]!, id: 'garden-suite', name: 'Garden room' }] + expect(() => resolveLocalProject(ambiguous, 'garden')).toThrow(/More than one/) + expect(() => resolveLocalProject(ambiguous, 'Garden room')).toThrow(/More than one/) + }) + + test('returns an actionable error when no project matches', () => { + expect(() => resolveLocalProject(projects, 'missing')).toThrow(/No local project matches/) + expect(() => resolveLocalProject([], undefined)).toThrow(/No local projects exist/) + }) +}) diff --git a/packages/cli/src/projects.ts b/packages/cli/src/projects.ts new file mode 100644 index 000000000..605e26a11 --- /dev/null +++ b/packages/cli/src/projects.ts @@ -0,0 +1,87 @@ +import type { EditorState } from './editor-process.js' +import { CliError } from './errors.js' + +export interface LocalProject { + id: string + name: string + updatedAt: string + version: number + nodeCount: number +} + +export async function listLocalProjects(state: EditorState): Promise { + const response = await fetch(`http://127.0.0.1:${state.port}/api/scenes?limit=500`, { + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok) { + throw new CliError('project_list_failed', `Scene API returned ${response.status}.`) + } + const body = (await response.json()) as { scenes?: unknown } + if (!Array.isArray(body.scenes)) { + throw new CliError('project_list_failed', 'Scene API returned an invalid project list.') + } + return body.scenes.map(parseProject) +} + +export function resolveLocalProject(projects: LocalProject[], selector?: string): LocalProject { + if (!selector) { + const latest = projects[0] + if (!latest) throw new CliError('project_not_found', 'No local projects exist yet.') + return latest + } + + const query = selector.trim() + const exactId = projects.find((project) => project.id === query) + if (exactId) return exactId + + const normalized = query.toLowerCase() + const exactNames = projects.filter((project) => project.name.toLowerCase() === normalized) + if (exactNames.length === 1) return exactNames[0]! + if (exactNames.length > 1) throw ambiguousProject(selector, exactNames) + + const idPrefixes = projects.filter((project) => project.id.startsWith(query)) + if (idPrefixes.length === 1) return idPrefixes[0]! + if (idPrefixes.length > 1) throw ambiguousProject(selector, idPrefixes) + + throw new CliError('project_not_found', `No local project matches "${selector}".`, { + selector, + }) +} + +export function projectUrl(state: EditorState, project: LocalProject): string { + return `${state.url}/scene/${encodeURIComponent(project.id)}` +} + +function parseProject(value: unknown): LocalProject { + if (!(typeof value === 'object' && value !== null)) { + throw new CliError('project_list_failed', 'Scene API returned invalid project metadata.') + } + const project = value as Record + if ( + typeof project.id !== 'string' || + typeof project.name !== 'string' || + typeof project.updatedAt !== 'string' || + typeof project.version !== 'number' || + typeof project.nodeCount !== 'number' + ) { + throw new CliError('project_list_failed', 'Scene API returned invalid project metadata.') + } + return { + id: project.id, + name: project.name, + updatedAt: project.updatedAt, + version: project.version, + nodeCount: project.nodeCount, + } +} + +function ambiguousProject(selector: string, matches: LocalProject[]): CliError { + return new CliError( + 'project_ambiguous', + `More than one local project matches "${selector}". Use one of these IDs: ${matches.map(({ id }) => id).join(', ')}.`, + { + selector, + matches: matches.map(({ id, name }) => ({ id, name })), + }, + ) +} diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts index 7f506142f..2fe7c2121 100644 --- a/packages/cli/src/runtime.test.ts +++ b/packages/cli/src/runtime.test.ts @@ -246,6 +246,28 @@ describe('managed runtime', () => { await stopEditor(paths) }) + test('upgrades a running editor state that predates managed MCP', async () => { + const root = await temporaryRoot() + const firstSource = await fakeRuntime(root, '1.2.3') + const secondSource = await fakeRuntime(root, '2.0.0') + const paths = resolvePascalPaths({ PASCAL_HOME: path.join(root, 'home') }) + const started = await startEditor({ paths, sourceDirectory: firstSource }) + const oldMcpPid = started.state.mcp?.pid + if (!oldMcpPid) throw new Error('test MCP did not start') + process.kill(oldMcpPid, 'SIGTERM') + await waitUntilStopped(oldMcpPid) + const legacyState = { ...started.state, mcp: undefined } + await writeFile(paths.state, `${JSON.stringify(legacyState, null, 2)}\n`) + await rm(paths.mcpToken, { force: true }) + const candidate = await installBundledRuntime(paths, secondSource, { activate: false }) + + const result = await activateEditorRuntime(paths, candidate) + + expect(result.restarted).toBe(true) + expect((await getEditorStatus(paths)).healthy).toBe(true) + await stopEditor(paths) + }) + test('health-checks an update without leaving a stopped editor running', async () => { const root = await temporaryRoot() const firstSource = await fakeRuntime(root, '1.2.3') @@ -271,17 +293,34 @@ async function temporaryRoot(): Promise { return root } +async function waitUntilStopped(pid: number): Promise { + const deadline = Date.now() + 2_000 + while (Date.now() < deadline) { + try { + process.kill(pid, 0) + } catch { + return + } + await Bun.sleep(20) + } + throw new Error(`process ${pid} did not stop`) +} + async function fakeRuntime(root: string, version: string, healthy = true): Promise { const runtime = path.join(root, `source-${version}`) const app = path.join(runtime, 'apps/editor') + const services = path.join(runtime, 'services') await mkdir(app, { recursive: true }) + await mkdir(services, { recursive: true }) await writeFile( path.join(runtime, 'runtime-manifest.json'), JSON.stringify({ schemaVersion: 1, version, entrypoint: 'apps/editor/server.js', + mcpEntrypoint: 'services/pascal-mcp.mjs', healthPath: '/api/health', + mcpHealthPath: '/health', }), ) await writeFile( @@ -307,5 +346,31 @@ process.on('SIGTERM', () => server.close(() => process.exit(0))) ` : 'process.exit(1)\n', ) + await writeFile( + path.join(services, 'pascal-mcp.mjs'), + `import http from 'node:http' +const token = process.env.PASCAL_MCP_HTTP_TOKEN +const server = http.createServer((request, response) => { + if (request.headers.authorization !== \`Bearer \${token}\`) { + response.writeHead(401).end() + return + } + response.setHeader('content-type', 'application/json') + if (request.url === '/health') { + response.end(JSON.stringify({ + status: 'ok', + app: 'mcp', + version: process.env.PASCAL_RUNTIME_VERSION, + instanceId: process.env.PASCAL_INSTANCE_ID, + })) + return + } + response.writeHead(404).end('{}') +}) +const portIndex = process.argv.indexOf('--port') +server.listen(Number(process.argv[portIndex + 1]), '127.0.0.1') +process.on('SIGTERM', () => server.close(() => process.exit(0))) +`, + ) return runtime } diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts index 34b7e7ae7..004d48ad5 100644 --- a/packages/cli/src/runtime.ts +++ b/packages/cli/src/runtime.ts @@ -10,7 +10,9 @@ export interface RuntimeManifest { schemaVersion: 1 version: string entrypoint: string + mcpEntrypoint: string healthPath: string + mcpHealthPath: string } export interface ActiveRuntime { @@ -42,7 +44,9 @@ export async function readRuntimeManifest(directory: string): Promise { process.exit(0) } - const bridge = new SceneBridge() - if (values.scene) { - const raw = readFileSync(values.scene, 'utf8') - bridge.loadJSON(raw) - } else { - bridge.loadDefault() - } - const store = await createSceneStore() - const server = createPascalMcpServer({ bridge, store }) + const createServer = () => { + const bridge = new SceneBridge() + if (values.scene) bridge.loadJSON(readFileSync(values.scene, 'utf8')) + else bridge.loadDefault() + return createPascalMcpServer({ bridge, store }) + } if (values.http) { const portNum = Number.parseInt(values.port ?? '3917', 10) if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65_535) { throw new Error(`invalid --port value: ${values.port}`) } - const handle = await connectHttp(server, portNum, { + const handle = await connectHttp(createServer, portNum, { host: values.host, authToken: values['auth-token'], allowedOrigins: values['cors-origin'], + ...(process.env.PASCAL_INSTANCE_ID + ? { + health: { + version: process.env.PASCAL_RUNTIME_VERSION ?? version, + instanceId: process.env.PASCAL_INSTANCE_ID, + }, + } + : {}), }) console.error(`[pascal-mcp] HTTP server listening on ${handle.host}:${handle.port}`) const shutdown = async () => { @@ -86,7 +91,7 @@ async function main(): Promise { process.on('SIGTERM', shutdown) } else { // --stdio is the default when no transport flag is passed. - await connectStdio(server) + await connectStdio(createServer()) console.error('[pascal-mcp] stdio server running') } } diff --git a/packages/mcp/src/index.test.ts b/packages/mcp/src/index.test.ts index 16486af3e..e73042917 100644 --- a/packages/mcp/src/index.test.ts +++ b/packages/mcp/src/index.test.ts @@ -1,8 +1,9 @@ import { expect, test } from 'bun:test' +import packageJson from '../package.json' test('version module loads', async () => { const mod = await import('./index') - expect(mod.version).toBe('0.1.0') + expect(mod.version).toBe(packageJson.version) }) test('createPascalMcpServer is a function', async () => { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index c51591ca2..da2d734f6 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,5 +1,4 @@ export { SceneBridge } from './bridge/scene-bridge' export { createSceneOperations, type SceneOperations } from './operations' export { type CreatePascalMcpServerOptions, createPascalMcpServer } from './server' - -export const version = '0.1.0' +export { version } from './version' diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 0ffedfd26..3cbda9ad8 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -6,6 +6,7 @@ import { registerResources } from './resources' import type { SceneStore } from './storage/types' import { registerTools } from './tools' import { registerVisionTools } from './tools/vision' +import { version } from './version' export type CreatePascalMcpServerOptions = { bridge: SceneBridge @@ -18,8 +19,8 @@ export type CreatePascalMcpServerOptions = { export function createPascalMcpServer(opts: CreatePascalMcpServerOptions): McpServer { const server = new McpServer({ - name: opts.name ?? 'pascal-mcp', - version: opts.version ?? '0.1.0', + name: opts.name ?? 'pascal-mcp-server', + version: opts.version ?? version, }) const operations = opts.operations ?? createSceneOperations({ bridge: opts.bridge, store: opts.store }) diff --git a/packages/mcp/src/storage/sqlite-scene-store.ts b/packages/mcp/src/storage/sqlite-scene-store.ts index 566ba1419..53d1016ac 100644 --- a/packages/mcp/src/storage/sqlite-scene-store.ts +++ b/packages/mcp/src/storage/sqlite-scene-store.ts @@ -156,7 +156,8 @@ function rowToMeta(row: SceneRow): SceneMeta { } function editorUrlForScene(id: string): string { - return `/editor/${id}` + const origin = process.env.PASCAL_EDITOR_ORIGIN?.replace(/\/$/, '') + return origin ? `${origin}/scene/${encodeURIComponent(id)}` : `/editor/${id}` } function hashGraphJson(graphJson: string): string { diff --git a/packages/mcp/src/transports/http.test.ts b/packages/mcp/src/transports/http.test.ts index 782ac8bfe..7b395eb14 100644 --- a/packages/mcp/src/transports/http.test.ts +++ b/packages/mcp/src/transports/http.test.ts @@ -25,7 +25,7 @@ afterEach(async () => { test('connectHttp listens on the given port and accepts MCP traffic', async () => { // Port 0 → OS assigns an ephemeral port. - handle = await connectHttp(server, 0) + handle = await connectHttp(() => server, 0) expect(handle.port).toBeGreaterThan(0) const url = new URL(`http://127.0.0.1:${handle.port}/mcp`) @@ -42,7 +42,7 @@ test('connectHttp listens on the given port and accepts MCP traffic', async () = }) test('connectHttp close() stops the server', async () => { - handle = await connectHttp(server, 0) + handle = await connectHttp(() => server, 0) const port = handle.port await handle.close() handle = null @@ -64,13 +64,13 @@ test('connectHttp close() stops the server', async () => { }) test('connectHttp requires auth when binding a non-loopback host', async () => { - await expect(connectHttp(server, 0, { host: '0.0.0.0' })).rejects.toThrow( + await expect(connectHttp(() => server, 0, { host: '0.0.0.0' })).rejects.toThrow( /requires PASCAL_MCP_HTTP_TOKEN/, ) }) test('connectHttp rejects unauthenticated requests when a token is configured', async () => { - handle = await connectHttp(server, 0, { authToken: 'secret' }) + handle = await connectHttp(() => server, 0, { authToken: 'secret' }) const response = await fetch(`http://127.0.0.1:${handle.port}/mcp`, { method: 'POST', @@ -82,7 +82,7 @@ test('connectHttp rejects unauthenticated requests when a token is configured', }) test('connectHttp handles allowed CORS preflight', async () => { - handle = await connectHttp(server, 0, { + handle = await connectHttp(() => server, 0, { authToken: 'secret', allowedOrigins: ['https://app.example'], }) @@ -98,3 +98,45 @@ test('connectHttp handles allowed CORS preflight', async () => { expect(response.status).toBe(204) expect(response.headers.get('access-control-allow-origin')).toBe('https://app.example') }) + +test('connectHttp serves authenticated supervisor health', async () => { + handle = await connectHttp(() => server, 0, { + authToken: 'secret', + health: { version: '1.2.3', instanceId: 'instance-1' }, + }) + + const response = await fetch(`http://127.0.0.1:${handle.port}/health`, { + headers: { authorization: 'Bearer secret' }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + status: 'ok', + app: 'mcp', + version: '1.2.3', + instanceId: 'instance-1', + }) +}) + +test('connectHttp isolates simultaneous client sessions', async () => { + handle = await connectHttp(() => { + const sessionBridge = new SceneBridge() + sessionBridge.loadDefault() + return createPascalMcpServer({ bridge: sessionBridge }) + }, 0) + const url = new URL(`http://127.0.0.1:${handle.port}/mcp`) + const first = new Client({ name: 'first-client', version: '0.0.0' }) + const second = new Client({ name: 'second-client', version: '0.0.0' }) + + try { + await Promise.all([ + first.connect(new StreamableHTTPClientTransport(url)), + second.connect(new StreamableHTTPClientTransport(url)), + ]) + const [firstTools, secondTools] = await Promise.all([first.listTools(), second.listTools()]) + expect(firstTools.tools.length).toBeGreaterThan(0) + expect(secondTools.tools.length).toBe(firstTools.tools.length) + } finally { + await Promise.all([first.close(), second.close()]) + } +}) diff --git a/packages/mcp/src/transports/http.ts b/packages/mcp/src/transports/http.ts index 2ffc807c9..b1321e046 100644 --- a/packages/mcp/src/transports/http.ts +++ b/packages/mcp/src/transports/http.ts @@ -31,6 +31,8 @@ export type HttpTransportOptions = { allowedOrigins?: string[] /** Per-client request cap per minute. Set <= 0 to disable. */ rateLimitPerMinute?: number + /** Authenticated identity returned from GET /health for local supervisors. */ + health?: { version: string; instanceId: string } } /** @@ -46,7 +48,7 @@ export type HttpTransportOptions = { * configure an auth token. */ export async function connectHttp( - server: McpServer, + createMcpServer: () => McpServer, port: number, options: HttpTransportOptions = {}, ): Promise { @@ -63,14 +65,11 @@ export async function connectHttp( rateLimitPerMinute: options.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE, }) - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }) - await server.connect(transport) + const transports = new Map() const httpServer = createServer((req, res) => { if (!guard(req, res)) return - transport.handleRequest(req, res).catch((err) => { + handleRequest(req, res).catch((err) => { // Log to stderr; never touch stdout (stdio transport uses it). console.error('[pascal-mcp] http transport error', err) if (!res.writableEnded) { @@ -83,6 +82,51 @@ export async function connectHttp( }) }) + const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise => { + const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '/' + if (pathname === '/health') { + if (!options.health) return sendJson(res, 404, { error: 'not_found' }) + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET') + return sendJson(res, 405, { error: 'method_not_allowed' }) + } + return sendJson(res, 200, { + status: 'ok', + app: 'mcp', + version: options.health.version, + instanceId: options.health.instanceId, + }) + } + + const sessionId = headerValue(req.headers['mcp-session-id']) + let transport = sessionId ? transports.get(sessionId) : undefined + if (!transport && req.method === 'POST' && !sessionId) { + let createdTransport: StreamableHTTPServerTransport + createdTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + transports.set(id, createdTransport) + }, + }) + createdTransport.onclose = () => { + const id = createdTransport.sessionId + if (id) transports.delete(id) + } + await createMcpServer().connect(createdTransport) + transport = createdTransport + } + + if (!transport) { + if (req.method === 'GET' && !sessionId) { + res.setHeader('Allow', 'POST') + return sendJson(res, 405, { error: 'session_required' }) + } + return sendJson(res, 400, { error: 'invalid_session' }) + } + await transport.handleRequest(req, res) + if (!transport.sessionId) await transport.close() + } + await new Promise((resolve, reject) => { const onError = (err: Error) => { httpServer.off('listening', onListening) @@ -104,13 +148,14 @@ export async function connectHttp( host, port: boundPort, close: async () => { + await Promise.all([...transports.values()].map((transport) => transport.close())) + transports.clear() await new Promise((resolve, reject) => { httpServer.close((err) => { if (err) reject(err) else resolve() }) }) - await transport.close() }, } } @@ -142,7 +187,7 @@ function createHttpGuard(options: { } const pathname = req.url ? new URL(req.url, 'http://localhost').pathname : '/' - if (pathname !== '/mcp') { + if (pathname !== '/mcp' && pathname !== '/health') { sendJson(res, 404, { error: 'not_found' }) return false } @@ -155,7 +200,7 @@ function createHttpGuard(options: { } } - if (options.rateLimitPerMinute > 0) { + if (pathname === '/mcp' && options.rateLimitPerMinute > 0) { const now = Date.now() const key = req.socket.remoteAddress ?? 'unknown' const bucket = buckets.get(key) diff --git a/packages/mcp/src/version.ts b/packages/mcp/src/version.ts new file mode 100644 index 000000000..0dc7a41e6 --- /dev/null +++ b/packages/mcp/src/version.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs' + +export const version = + process.env.PASCAL_MCP_VERSION ?? + ( + JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + version: string + } + ).version