From 0ccccde5efa5389678489a83dab23605810f3c74 Mon Sep 17 00:00:00 2001 From: Vamil Gandhi <13998000+vamgan@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:34:23 -0400 Subject: [PATCH] feat: add stdio runner and bridge sidecar --- README.md | 107 +++++++++++++++++++-- package-lock.json | 51 +++++----- package.json | 5 + src/__tests__/runner.test.ts | 122 ++++++++++++++++++++++++ src/cli.ts | 9 ++ src/index.ts | 1 + src/runner.ts | 178 +++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 8 files changed, 437 insertions(+), 37 deletions(-) create mode 100644 src/__tests__/runner.test.ts create mode 100644 src/cli.ts create mode 100644 src/runner.ts diff --git a/README.md b/README.md index c9dd504..04f77f8 100644 --- a/README.md +++ b/README.md @@ -36,12 +36,13 @@ That makes the MCP surface much higher-signal for copilots, IDE agents, and desk ## Current slice -This initial repo setup ships: +This repo currently ships: - `createAskableMcpSnapshot()` — serialize an `AskableContext` into an MCP-friendly session snapshot - `createAskableMcpBridge()` — send snapshot updates through an injected transport - `AskableMcpServer` — in-memory session/resource/tool state - `createAskableMcpSdkServer()` — MCP SDK adapter exposing askable resources and the `select_element` tool +- `askable-mcp` CLI — runnable stdio MCP server with a local bridge HTTP sidecar ## Install @@ -49,7 +50,99 @@ This initial repo setup ships: npm install @askable-ui/mcp @askable-ui/core ``` -## Example +## Run the local stdio server + +```bash +npm run build +npm run start +``` + +By default this starts: + +- an MCP stdio server on `stdin` / `stdout` for Claude Desktop, Cursor, or other process-spawned MCP clients +- a local bridge server on `http://127.0.0.1:4318` + +Available local bridge endpoints: + +- `POST /bridge` — ingest serialized snapshot messages from the browser/app bridge +- `GET /sessions` — inspect known askable sessions +- `GET /commands?sessionId=...` — drain queued `select_element` commands for a browser session +- `GET /health` — basic health check + +You can change the bridge listener with CLI flags: + +```bash +node dist/cli.js --host 127.0.0.1 --bridge-port 4318 +``` + +## Claude Desktop configuration + +Build the package, then point Claude Desktop at the stdio entrypoint: + +```json +{ + "mcpServers": { + "askable": { + "command": "node", + "args": [ + "/absolute/path/to/askable-mcp/dist/cli.js", + "--bridge-port", + "4318" + ] + } + } +} +``` + +After Claude Desktop launches the server, `ui_context`, `ui_history`, `ui_viewport`, and `select_element` become available once your app starts posting snapshots to the local bridge. + +## Browser / app bridge example + +```ts +import { createAskableContext } from '@askable-ui/core'; +import { createAskableMcpBridge } from '@askable-ui/mcp'; + +const ctx = createAskableContext({ viewport: true }); +const sessionId = 'dashboard-tab'; + +createAskableMcpBridge({ + ctx, + sessionId, + pageUrl: window.location.href, + pageTitle: document.title, + includeViewport: true, + transport: { + send(message) { + void fetch('http://127.0.0.1:4318/bridge', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: message, + }); + }, + }, +}); + +async function pollCommands() { + const response = await fetch( + `http://127.0.0.1:4318/commands?sessionId=${encodeURIComponent(sessionId)}` + ); + const payload = (await response.json()) as { + commands: Array<{ type: 'select_element'; elementId: string }>; + }; + + for (const command of payload.commands) { + if (command.type === 'select_element') { + console.log('focus element in app', command.elementId); + } + } +} + +setInterval(() => { + void pollCommands(); +}, 500); +``` + +## Library example ```ts import { createAskableContext } from '@askable-ui/core'; @@ -87,11 +180,11 @@ const mcpServer = createAskableMcpSdkServer(state, { ## Planned follow-up work -- stdio runner for Claude Desktop / Cursor local usage -- Streamable HTTP / WebSocket deployment story -- browser-side command handling for `select_element` -- multi-session routing and active-session selection -- setup docs for Claude Desktop and Cursor +- browser-side helpers for handling `select_element` automatically +- streamable HTTP deployment story +- multi-session routing and active-session selection UX +- richer Claude Desktop / Cursor setup docs +- optional auth / origin controls for the local bridge endpoint ## Development diff --git a/package-lock.json b/package-lock.json index cada459..ba231c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,11 @@ "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.3.6" }, + "bin": { + "askable-mcp": "dist/cli.js" + }, "devDependencies": { + "@types/node": "^24.5.2", "typescript": "^6.0.2", "vitest": "^4.1.2" } @@ -239,9 +243,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -259,9 +260,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -279,9 +277,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -299,9 +294,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -319,9 +311,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -339,9 +328,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -471,6 +457,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@vitest/expect": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", @@ -1430,9 +1426,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1454,9 +1447,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1478,9 +1468,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1502,9 +1489,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2195,6 +2179,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 6766121..386d4fe 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "askable-mcp": "./dist/cli.js" + }, "exports": { ".": { "import": "./dist/index.js", @@ -21,6 +24,7 @@ }, "scripts": { "build": "tsc", + "start": "node ./dist/cli.js", "test": "vitest run" }, "keywords": [ @@ -38,6 +42,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@types/node": "^24.5.2", "typescript": "^6.0.2", "vitest": "^4.1.2" } diff --git a/src/__tests__/runner.test.ts b/src/__tests__/runner.test.ts new file mode 100644 index 0000000..14cffae --- /dev/null +++ b/src/__tests__/runner.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import http from 'node:http'; +import { AskableMcpServer } from '../server.js'; +import { + createAskableMcpBridgeHttpHandler, + createInMemoryAskableMcpCommandQueue, + parseAskableMcpCliArgs, +} from '../runner.js'; + +const servers = new Set(); + +afterEach(async () => { + await Promise.all( + Array.from(servers).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }) + ) + ); + servers.clear(); +}); + +describe('askable-mcp runner helpers', () => { + it('parses CLI args for the stdio runner bridge server', () => { + expect(parseAskableMcpCliArgs(['--bridge-port', '4100', '--host', '0.0.0.0'])).toEqual({ + bridgePort: 4100, + host: '0.0.0.0', + }); + }); + + it('queues and drains select_element commands by session', () => { + const queue = createInMemoryAskableMcpCommandQueue(); + + queue.enqueue({ + type: 'select_element', + sessionId: 'session-a', + elementId: 'card:revenue', + requestedAt: 1, + }); + queue.enqueue({ + type: 'select_element', + sessionId: 'session-b', + elementId: 'chart:users', + requestedAt: 2, + }); + + expect(queue.drain('session-a')).toMatchObject([{ elementId: 'card:revenue' }]); + expect(queue.drain('session-a')).toEqual([]); + expect(queue.drain('session-b')).toMatchObject([{ elementId: 'chart:users' }]); + }); + + it('ingests bridge snapshots and exposes queued commands over HTTP', async () => { + const queue = createInMemoryAskableMcpCommandQueue(); + const serverState = new AskableMcpServer({ + onCommand(command) { + queue.enqueue(command); + }, + }); + + const server = http.createServer( + createAskableMcpBridgeHttpHandler({ + serverState, + commandQueue: queue, + }) + ); + servers.add(server); + + await new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', () => resolve()); + server.once('error', reject); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected TCP address from test server'); + } + + const baseUrl = `http://127.0.0.1:${address.port}`; + + const bridgeResponse = await fetch(`${baseUrl}/bridge`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'snapshot', + snapshot: { + sessionId: 'session-1', + updatedAt: 1, + pageTitle: 'Revenue dashboard', + currentFocus: null, + history: [], + viewport: [], + }, + }), + }); + + expect(bridgeResponse.status).toBe(202); + + const sessionsResponse = await fetch(`${baseUrl}/sessions`); + expect(sessionsResponse.status).toBe(200); + await expect(sessionsResponse.json()).resolves.toMatchObject({ + sessions: [{ sessionId: 'session-1', pageTitle: 'Revenue dashboard' }], + }); + + await serverState.callTool('select_element', { + sessionId: 'session-1', + elementId: 'card:revenue', + }); + + const commandsResponse = await fetch(`${baseUrl}/commands?sessionId=session-1`); + expect(commandsResponse.status).toBe(200); + await expect(commandsResponse.json()).resolves.toMatchObject({ + commands: [{ type: 'select_element', elementId: 'card:revenue' }], + }); + + const drainedResponse = await fetch(`${baseUrl}/commands?sessionId=session-1`); + await expect(drainedResponse.json()).resolves.toMatchObject({ commands: [] }); + }); +}); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..73d41bd --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { parseAskableMcpCliArgs, startAskableMcpStdioServer } from './runner.js'; + +try { + await startAskableMcpStdioServer(parseAskableMcpCliArgs(process.argv.slice(2))); +} catch (error) { + console.error('[askable-mcp] failed to start', error); + process.exitCode = 1; +} diff --git a/src/index.ts b/src/index.ts index 789c1ae..e30599e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { createAskableMcpBridge } from './bridge.js'; export { createAskableMcpSnapshot } from './snapshot.js'; +export { createAskableMcpBridgeHttpHandler, createInMemoryAskableMcpCommandQueue, parseAskableMcpCliArgs, startAskableMcpStdioServer } from './runner.js'; export { AskableMcpServer } from './server.js'; export { createAskableMcpSdkServer } from './sdk.js'; export type { diff --git a/src/runner.ts b/src/runner.ts new file mode 100644 index 0000000..481fc88 --- /dev/null +++ b/src/runner.ts @@ -0,0 +1,178 @@ +import http from 'node:http'; +import { URL } from 'node:url'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { AskableMcpServer } from './server.js'; +import { createAskableMcpSdkServer } from './sdk.js'; +import type { AskableMcpCommand } from './types.js'; + +export interface AskableMcpCliOptions { + bridgePort: number; + host: string; +} + +export interface AskableMcpCommandQueue { + enqueue(command: AskableMcpCommand): void; + drain(sessionId: string): AskableMcpCommand[]; +} + +export function parseAskableMcpCliArgs(argv: string[]): AskableMcpCliOptions { + const options: AskableMcpCliOptions = { + bridgePort: 4318, + host: '127.0.0.1', + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + const next = argv[index + 1]; + + if (token === '--bridge-port' && next) { + options.bridgePort = Number.parseInt(next, 10); + index += 1; + continue; + } + + if (token === '--host' && next) { + options.host = next; + index += 1; + } + } + + if (!Number.isInteger(options.bridgePort) || options.bridgePort <= 0) { + throw new Error(`Invalid --bridge-port value: ${options.bridgePort}`); + } + + return options; +} + +export function createInMemoryAskableMcpCommandQueue(): AskableMcpCommandQueue { + const commandsBySession = new Map(); + + return { + enqueue(command) { + const queue = commandsBySession.get(command.sessionId) ?? []; + queue.push(command); + commandsBySession.set(command.sessionId, queue); + }, + drain(sessionId) { + const queue = commandsBySession.get(sessionId) ?? []; + commandsBySession.delete(sessionId); + return queue; + }, + }; +} + +export function createAskableMcpBridgeHttpHandler(options: { + serverState: AskableMcpServer; + commandQueue: AskableMcpCommandQueue; + logger?: Pick; +}): http.RequestListener { + const { commandQueue, logger = console, serverState } = options; + + return async (request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + + const sendJson = (statusCode: number, payload: unknown) => { + response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify(payload)); + }; + + const readBody = async (): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); + }; + + try { + if (request.method === 'GET' && requestUrl.pathname === '/health') { + sendJson(200, { ok: true }); + return; + } + + if (request.method === 'GET' && requestUrl.pathname === '/sessions') { + sendJson(200, { sessions: serverState.listSessions() }); + return; + } + + if (request.method === 'GET' && requestUrl.pathname === '/commands') { + const sessionId = requestUrl.searchParams.get('sessionId'); + if (!sessionId) { + sendJson(400, { error: 'Missing required query parameter: sessionId' }); + return; + } + + sendJson(200, { commands: commandQueue.drain(sessionId) }); + return; + } + + if (request.method === 'POST' && requestUrl.pathname === '/bridge') { + const body = await readBody(); + serverState.ingestBridgeMessage(body); + sendJson(202, { ok: true }); + return; + } + + sendJson(404, { error: `Unknown route: ${request.method} ${requestUrl.pathname}` }); + } catch (error) { + logger.error('askable-mcp bridge error', error); + const message = error instanceof Error ? error.message : 'Unknown bridge error'; + sendJson(500, { error: message }); + } + }; +} + +export async function startAskableMcpStdioServer(options: AskableMcpCliOptions): Promise<{ + close(): Promise; + commandQueue: AskableMcpCommandQueue; + serverState: AskableMcpServer; +}> { + const commandQueue = createInMemoryAskableMcpCommandQueue(); + const serverState = new AskableMcpServer({ + onCommand(command) { + commandQueue.enqueue(command); + }, + }); + + const httpServer = http.createServer( + createAskableMcpBridgeHttpHandler({ + serverState, + commandQueue, + logger: console, + }) + ); + + await new Promise((resolve, reject) => { + httpServer.listen(options.bridgePort, options.host, () => resolve()); + httpServer.once('error', reject); + }); + + console.error( + `[askable-mcp] bridge listening on http://${options.host}:${options.bridgePort} ` + + '(POST /bridge, GET /sessions, GET /commands?sessionId=...)' + ); + + const mcpServer = createAskableMcpSdkServer(serverState, { + name: '@askable-ui/mcp', + version: '0.1.0', + }); + + const transport = new StdioServerTransport(); + await mcpServer.connect(transport); + + return { + commandQueue, + serverState, + async close() { + await Promise.all([ + mcpServer.close(), + new Promise((resolve, reject) => { + httpServer.close((error) => { + if (error) reject(error); + else resolve(); + }); + }), + ]); + }, + }; +} diff --git a/tsconfig.json b/tsconfig.json index fec1a6c..3ac733e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM"], + "types": ["node"], "strict": true, "declaration": true, "declarationMap": true,