feat(mcp): add stdio + streamable HTTP transports, CLI, and smoke test
- connectStdio(server): wires the MCP server to StdioServerTransport. - connectHttp(server, port): wires the MCP server to StreamableHTTPServerTransport over node:http. Returns a handle with port and close(). - bin/pascal-mcp.ts: CLI entrypoint with --stdio (default), --http, --port, --scene, --help, --version. Loads node-shims before any core import. Logs to stderr (stdio transport uses stdout for JSON-RPC). - scripts/smoke.ts: end-to-end smoke — spawns the stdio bin, connects as an MCP Client, exercises get_scene, create_level, validate_scene, undo. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4dbfbb1e1a
commit
441e97b2b6
@@ -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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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 <n>] [--scene <path>]
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--stdio Use stdio transport (default)
|
||||||
|
--http Use Streamable HTTP transport
|
||||||
|
--port <n> HTTP port (default 3917)
|
||||||
|
--scene <path> Initial scene JSON to load
|
||||||
|
--version Print version
|
||||||
|
--help Print this help
|
||||||
|
`
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:<port>` (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<HttpTransportHandle> {
|
||||||
|
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<void>((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<void>((resolve, reject) => {
|
||||||
|
httpServer.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await transport.close()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
const transport = new StdioServerTransport()
|
||||||
|
await server.connect(transport)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user