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:
Adrian Perez
2026-04-18 17:51:27 +02:00
co-authored by Claude Opus 4.7
parent 4dbfbb1e1a
commit 441e97b2b6
6 changed files with 367 additions and 0 deletions
+64
View File
@@ -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)
})
+73
View File
@@ -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()
},
}
}
+46
View File
@@ -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()
}
})
+18
View File
@@ -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)
}