diff --git a/packages/mcp/scripts/smoke.ts b/packages/mcp/scripts/smoke.ts new file mode 100644 index 00000000..0fb0a502 --- /dev/null +++ b/packages/mcp/scripts/smoke.ts @@ -0,0 +1,81 @@ +/** + * End-to-end smoke test for @pascal-app/mcp. + * + * Spawns the compiled stdio binary as a child process, connects as an MCP + * client, and exercises a handful of representative tools. This test requires + * the package to be built first (`bun run build`) — the compiled bin is what + * `package.json`'s `bin` entry ships to users. + * + * Run with: bun run scripts/smoke.ts + */ +import { existsSync } from 'node:fs' +import { dirname, resolve } 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 __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const BIN_PATH = resolve(__dirname, '../dist/bin/pascal-mcp.js') + +async function main(): Promise { + if (!existsSync(BIN_PATH)) { + console.error(`[smoke] bin not found at ${BIN_PATH}`) + console.error('[smoke] run `bun run build` first') + process.exit(1) + } + + const transport = new StdioClientTransport({ + command: process.execPath, + args: [BIN_PATH, '--stdio'], + stderr: 'inherit', + }) + const client = new Client({ name: 'pascal-mcp-smoke', version: '0.0.0' }) + + try { + await client.connect(transport) + + const tools = await client.listTools() + console.log(`[smoke] tools registered: ${tools.tools.length}`) + if (tools.tools.length === 0) { + throw new Error('no tools registered') + } + + const getScene = await client.callTool({ name: 'get_scene', arguments: {} }) + if (getScene.isError) { + throw new Error(`get_scene failed: ${JSON.stringify(getScene)}`) + } + console.log('[smoke] get_scene: OK') + + // create_level — buildingId may not match a real node depending on the + // default scene; we just verify the tool returns a structured response + // rather than crash. + const createLevel = await client.callTool({ + name: 'create_level', + arguments: { buildingId: 'tbd', elevation: 1, height: 3 }, + }) + console.log('[smoke] create_level:', createLevel.isError ? 'structured error (ok)' : 'OK') + + const validate = await client.callTool({ + name: 'validate_scene', + arguments: {}, + }) + console.log('[smoke] validate_scene:', validate.isError ? 'ERROR' : 'OK') + + const undone = await client.callTool({ name: 'undo', arguments: {} }) + console.log('[smoke] undo:', undone.isError ? 'ERROR' : 'OK') + + console.log('[smoke] passed') + } finally { + try { + await client.close() + } catch { + // client may already be closed; ignore. + } + } +} + +main().catch((err) => { + console.error('[smoke] failed:', err) + process.exit(1) +}) diff --git a/packages/mcp/src/bin/pascal-mcp.ts b/packages/mcp/src/bin/pascal-mcp.ts new file mode 100644 index 00000000..e1409d9f --- /dev/null +++ b/packages/mcp/src/bin/pascal-mcp.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Load shims FIRST so any subsequent core import sees the RAF polyfill. +import '../bridge/node-shims' + +import { readFileSync } from 'node:fs' +import { parseArgs } from 'node:util' +import { SceneBridge } from '../bridge/scene-bridge' +import { version } from '../index' +import { createPascalMcpServer } from '../server' +import { connectHttp } from '../transports/http' +import { connectStdio } from '../transports/stdio' + +const HELP = `pascal-mcp — MCP server for the Pascal editor + +USAGE: + pascal-mcp [--stdio | --http --port ] [--scene ] + +OPTIONS: + --stdio Use stdio transport (default) + --http Use Streamable HTTP transport + --port HTTP port (default 3917) + --scene Initial scene JSON to load + --version Print version + --help Print this help +` + +async function main(): Promise { + const { values } = parseArgs({ + options: { + stdio: { type: 'boolean', default: false }, + http: { type: 'boolean', default: false }, + port: { type: 'string', default: '3917' }, + scene: { type: 'string' }, + help: { type: 'boolean', default: false }, + version: { type: 'boolean', default: false }, + }, + }) + + if (values.help) { + console.log(HELP) + process.exit(0) + } + + if (values.version) { + console.log(version) + process.exit(0) + } + + const bridge = new SceneBridge() + if (values.scene) { + const raw = readFileSync(values.scene, 'utf8') + bridge.loadJSON(raw) + } else { + bridge.loadDefault() + } + + const server = createPascalMcpServer({ bridge }) + + if (values.http) { + const portNum = Number.parseInt(values.port ?? '3917', 10) + if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) { + throw new Error(`invalid --port value: ${values.port}`) + } + const handle = await connectHttp(server, portNum) + console.error(`[pascal-mcp] HTTP server listening on :${handle.port}`) + const shutdown = async () => { + try { + await handle.close() + } finally { + process.exit(0) + } + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + } else { + // --stdio is the default when no transport flag is passed. + await connectStdio(server) + console.error('[pascal-mcp] stdio server running') + } +} + +main().catch((err) => { + console.error('[pascal-mcp] fatal:', err instanceof Error ? (err.stack ?? err.message) : err) + process.exit(1) +}) diff --git a/packages/mcp/src/transports/http.test.ts b/packages/mcp/src/transports/http.test.ts new file mode 100644 index 00000000..69be6eb7 --- /dev/null +++ b/packages/mcp/src/transports/http.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { createPascalMcpServer } from '../server' +import { connectHttp, type HttpTransportHandle } from './http' + +let bridge: SceneBridge +let server: McpServer +let handle: HttpTransportHandle | null = null + +beforeEach(() => { + bridge = new SceneBridge() + bridge.loadDefault() + server = createPascalMcpServer({ bridge }) +}) + +afterEach(async () => { + if (handle) { + await handle.close() + handle = null + } +}) + +test('connectHttp listens on the given port and accepts MCP traffic', async () => { + // Port 0 → OS assigns an ephemeral port. + handle = await connectHttp(server, 0) + expect(handle.port).toBeGreaterThan(0) + + const url = new URL(`http://127.0.0.1:${handle.port}/mcp`) + const clientTransport = new StreamableHTTPClientTransport(url) + const client = new Client({ name: 'http-test-client', version: '0.0.0' }) + + try { + await client.connect(clientTransport) + const tools = await client.listTools() + expect(Array.isArray(tools.tools)).toBe(true) + } finally { + await client.close() + } +}) + +test('connectHttp close() stops the server', async () => { + handle = await connectHttp(server, 0) + const port = handle.port + await handle.close() + handle = null + + // A fresh fetch to the old port should fail (connection refused). + let didConnect = false + try { + await fetch(`http://127.0.0.1:${port}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + signal: AbortSignal.timeout(500), + }) + didConnect = true + } catch { + didConnect = false + } + expect(didConnect).toBe(false) +}) diff --git a/packages/mcp/src/transports/http.ts b/packages/mcp/src/transports/http.ts new file mode 100644 index 00000000..88b7b124 --- /dev/null +++ b/packages/mcp/src/transports/http.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto' +import { createServer } from 'node:http' +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' + +export type HttpTransportHandle = { + /** Port the server is actually listening on (useful when caller passed 0). */ + port: number + /** Gracefully close the HTTP server and the MCP transport. */ + close(): Promise +} + +/** + * Attach an `McpServer` to a Streamable HTTP transport bound to a local port. + * + * Uses the SDK's Node-flavored `StreamableHTTPServerTransport`, which accepts + * `IncomingMessage`/`ServerResponse` directly via `handleRequest(req, res)`. + * A new session ID is generated per connection (stateful mode). + * + * Listens on `0.0.0.0:` (pass `0` for an ephemeral port in tests). The + * returned handle exposes the actual bound port and a `close()` that stops + * the underlying Node HTTP server. + */ +export async function connectHttp(server: McpServer, port: number): Promise { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }) + await server.connect(transport) + + const httpServer = createServer((req, res) => { + transport.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) { + try { + res.writeHead(500).end() + } catch { + // Response may already be partially sent; nothing more we can do. + } + } + }) + }) + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + httpServer.off('listening', onListening) + reject(err) + } + const onListening = () => { + httpServer.off('error', onError) + resolve() + } + httpServer.once('error', onError) + httpServer.once('listening', onListening) + httpServer.listen(port) + }) + + const address = httpServer.address() + const boundPort = typeof address === 'object' && address !== null ? address.port : port + + return { + port: boundPort, + close: async () => { + await new Promise((resolve, reject) => { + httpServer.close((err) => { + if (err) reject(err) + else resolve() + }) + }) + await transport.close() + }, + } +} diff --git a/packages/mcp/src/transports/stdio.test.ts b/packages/mcp/src/transports/stdio.test.ts new file mode 100644 index 00000000..ae59ee85 --- /dev/null +++ b/packages/mcp/src/transports/stdio.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { SceneBridge } from '../bridge/scene-bridge' +import { createPascalMcpServer } from '../server' +import { connectStdio } from './stdio' + +let bridge: SceneBridge + +beforeEach(() => { + bridge = new SceneBridge() + bridge.loadDefault() +}) + +afterEach(() => { + // Fresh store per test — no global teardown needed. +}) + +test('connectStdio is an async function', () => { + expect(typeof connectStdio).toBe('function') + // Async functions report their constructor as AsyncFunction. + expect(connectStdio.constructor.name).toBe('AsyncFunction') +}) + +test('server+client over linked in-memory pair can list tools', async () => { + // Functional equivalence check: we can't attach the real stdio transport in + // a test (it hijacks process stdin/stdout), but we can still verify that + // `createPascalMcpServer` produces a server that exposes tools over any + // MCP transport. This catches regressions where tool registration fails + // silently during server construction. + const server = createPascalMcpServer({ bridge }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + + const client = new Client({ name: 'stdio-test-client', version: '0.0.0' }) + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]) + + try { + const tools = await client.listTools() + expect(Array.isArray(tools.tools)).toBe(true) + // Don't require a specific count — other agents own tool registration. + // Just assert the wiring carries protocol traffic end-to-end. + } finally { + await client.close() + await server.close() + } +}) diff --git a/packages/mcp/src/transports/stdio.ts b/packages/mcp/src/transports/stdio.ts new file mode 100644 index 00000000..8cbff8fa --- /dev/null +++ b/packages/mcp/src/transports/stdio.ts @@ -0,0 +1,18 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' + +/** + * Attach an `McpServer` to the stdio transport. + * + * The server takes ownership of stdin/stdout for JSON-RPC messaging. Callers + * must send any operator logging to stderr (not stdout) to avoid corrupting + * the protocol stream. + * + * Resolves once the transport is started. The underlying transport keeps the + * process alive as long as stdin is open, so callers typically just `await` + * this and then let the event loop run. + */ +export async function connectStdio(server: McpServer): Promise { + const transport = new StdioServerTransport() + await server.connect(transport) +}