From 725421b56840fdfae2e9bf9b0dac709e457ecb2d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 02:04:07 +0200 Subject: [PATCH] Add read-only MCP project session discovery --- README.md | 9 ++- src/commands/mcp/serve.ts | 3 +- src/mcp/server.ts | 51 +++++++++++++-- src/mcp/session-discovery.ts | 61 +++++++++++++++++ test/mcp/server.test.ts | 102 ++++++++++++++++++++++++++++- test/mcp/session-discovery.test.ts | 85 ++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 10 deletions(-) create mode 100644 src/mcp/session-discovery.ts create mode 100644 test/mcp/session-discovery.test.ts diff --git a/README.md b/README.md index d41c1083..e2755db0 100644 --- a/README.md +++ b/README.md @@ -1793,7 +1793,7 @@ test the end of a restriction. `--from-session OTHER_TEST_NUMBER` sends a real s another Test number in the project instead, producing an ordinary `message.received` event you can reply to. Use `--json` for one JSON document. -## MCP listener controls +## Local MCP tools The package includes a standards-based stdio MCP server for Codex and Claude: @@ -1811,6 +1811,13 @@ They do not create detached daemons. MCP responses contain listener IDs, status, and safe counters, never primary credentials, listener credentials, forwarding secrets, or unrestricted payloads. +`polymorfa_session_list` is a read-only discovery tool for one explicit project +UUID. It uses the selected CLI organization API key with `sessions:read` and +returns session ID, name, stored status, and test-mode flag. It omits phone +numbers, credentials, and message data. The tool does not probe the live device. +Project tokens and Messaging client tokens cannot use this tool. A failed read +returns a generic error without exposing the credential or API response body. + ## Agent skills The npm package includes two portable skills under `skills/`: diff --git a/src/commands/mcp/serve.ts b/src/commands/mcp/serve.ts index ff92bd7a..f3e89883 100644 --- a/src/commands/mcp/serve.ts +++ b/src/commands/mcp/serve.ts @@ -3,8 +3,7 @@ import {createListenerManager} from '../../mcp/factory.js' import {serveMcp} from '../../mcp/server.js' export default class McpServe extends BaseCommand { - static override description = - 'Serve Polymorfa listener controls over MCP stdio.' + static override description = 'Serve local Polymorfa tools over MCP stdio.' async run(): Promise { await this.parse(McpServe) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 47027997..c1ebd143 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,16 +7,21 @@ import {z} from 'zod' import {LISTENER_PROJECT_ID_PATTERN} from '../listener/protocol.js' import type {ListenerManager} from './listener-manager.js' +import {listMcpSessions, type McpSessionList} from './session-discovery.js' export const MCP_TOOL_NAMES = [ 'polymorfa_listen_start', 'polymorfa_listen_status', 'polymorfa_listen_stop', + 'polymorfa_session_list', ] as const +export type McpSessionLister = (projectId: string) => Promise + export function createMcpServer( manager: ListenerManager, version: string, + sessionLister: McpSessionLister = listMcpSessions, ): McpServer { const server = new McpServer({name: '@polymorfa/cli', version}) server.registerTool( @@ -77,6 +82,38 @@ export function createMcpServer( }, async ({listenerId}) => result(await manager.stop(listenerId)), ) + server.registerTool( + 'polymorfa_session_list', + { + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + }, + description: + 'List stored session summaries in one project. Requires a CLI organization API key with sessions:read.', + inputSchema: z.object({ + projectId: z.string().regex(LISTENER_PROJECT_ID_PATTERN), + }), + title: 'List Polymorfa project sessions', + }, + async ({projectId}) => { + try { + return result(await sessionLister(projectId)) + } catch { + return { + content: [ + { + text: 'Could not list project sessions with the selected CLI organization API key.', + type: 'text' as const, + }, + ], + isError: true, + } + } + }, + ) return server } @@ -84,13 +121,17 @@ export function serveMcp( manager: ListenerManager, version: string, transport?: StdioServerTransport, + sessionLister?: McpSessionLister, ): {close(): Promise} { - const handle = serveStdio(() => createMcpServer(manager, version), { - onerror(error) { - process.stderr.write(`MCP error: ${error.message}\n`) + const handle = serveStdio( + () => createMcpServer(manager, version, sessionLister), + { + onerror(error) { + process.stderr.write(`MCP error: ${error.message}\n`) + }, + ...(transport === undefined ? {} : {transport}), }, - ...(transport === undefined ? {} : {transport}), - }) + ) return { async close(): Promise { await manager.close() diff --git a/src/mcp/session-discovery.ts b/src/mcp/session-discovery.ts new file mode 100644 index 00000000..b6feee06 --- /dev/null +++ b/src/mcp/session-discovery.ts @@ -0,0 +1,61 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import {PROJECT_UUID} from '../config/project-link.js' +import {resolveCommandEnvironment} from '../context/environment.js' +import {createPlatformClient} from '../sdk/client.js' +import {listManagedSessions} from '../services/platform-management.js' + +export interface McpSessionSummary { + readonly sessionId: string + readonly name: string + readonly status: string + readonly testMode: boolean +} + +export interface McpSessionList { + readonly sessions: readonly McpSessionSummary[] + readonly status: number + readonly requestId?: string +} + +/** Read one project's session metadata without exposing phone or credentials. */ +export async function listMcpSessions( + projectId: string, +): Promise { + if (!PROJECT_UUID.test(projectId)) { + throw new PolymorfaConfigurationError( + 'A project UUID is required.', + 'projectId', + ) + } + if ( + process.env.POLYMORFA_PROJECT_TOKEN?.trim() || + (process.env.POLYMORFA_API_KEY?.trim() && + process.env.POLYMORFA_CLIENT_TOKEN?.trim()) + ) { + throw new PolymorfaConfigurationError( + 'Select only one organization API key for MCP session discovery.', + 'credential', + ) + } + const environment = await resolveCommandEnvironment({ + env: process.env, + surface: 'platform', + }) + const response = await listManagedSessions( + createPlatformClient(environment), + projectId, + ) + return { + sessions: response.data.map(({sessionId, name, status, testMode}) => ({ + sessionId, + name, + status, + testMode, + })), + status: response.metadata.status, + ...(response.metadata.requestId === undefined + ? {} + : {requestId: response.metadata.requestId}), + } +} diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts index 38c6e8a4..17dc44ed 100644 --- a/test/mcp/server.test.ts +++ b/test/mcp/server.test.ts @@ -11,14 +11,16 @@ describe('MCP stdio server', () => { afterEach(async () => close?.()) - test('negotiates and lists the three in-process listener tools', async () => { + test('negotiates listener controls and project-scoped session discovery', async () => { const input = new PassThrough() const output = new PassThrough() const transport = new StdioServerTransport(input, output) const manager = new ListenerManager( async () => new Promise(() => {}), ) - const handle = serveMcp(manager, '0.1.0-test', transport) + const handle = serveMcp(manager, '0.1.0-test', transport, () => + Promise.resolve({sessions: [], status: 200}), + ) close = () => handle.close() const messages = collectMessages(output) @@ -56,6 +58,7 @@ describe('MCP stdio server', () => { 'polymorfa_listen_start', 'polymorfa_listen_status', 'polymorfa_listen_stop', + 'polymorfa_session_list', ]) const start = ( messages[1] as { @@ -73,6 +76,101 @@ describe('MCP stdio server', () => { expect(pattern.test('11111111-1111-4111-8111-111111111111')).toBe(true) expect(pattern.test('project_123')).toBe(false) expect(pattern.test('11111111-1111-4111-8111-11111111111G')).toBe(false) + const sessions = ( + messages[1] as { + result: { + tools: Array<{ + name: string + annotations?: {readOnlyHint?: boolean} + inputSchema: {properties: Record} + }> + } + } + ).result.tools.find(({name}) => name === 'polymorfa_session_list') + expect(sessions?.annotations?.readOnlyHint).toBe(true) + expect( + new RegExp( + sessions?.inputSchema.properties.projectId?.pattern ?? '', + ).test('project_123'), + ).toBe(false) + }) + + test('returns only curated session fields and hides failures', async () => { + const input = new PassThrough() + const output = new PassThrough() + const transport = new StdioServerTransport(input, output) + const manager = new ListenerManager( + async () => new Promise(() => {}), + ) + const projectId = '11111111-1111-4111-8111-111111111111' + let requests = 0 + const handle = serveMcp(manager, '0.1.0-test', transport, (project) => { + expect(project).toBe(projectId) + if (requests++ > 0) { + return Promise.reject(new Error('Bearer pmfa_secret_value')) + } + return Promise.resolve({ + sessions: [ + { + sessionId: 'session_1', + name: 'Support', + status: 'CONNECTED', + testMode: false, + }, + ], + status: 200, + }) + }) + close = () => handle.close() + + const messages = collectMessages(output) + input.write( + `${JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: {name: 'test', version: '1.0.0'}, + protocolVersion: '2025-06-18', + }, + })}\n`, + ) + await waitFor(messages, 1) + input.write( + `${JSON.stringify({jsonrpc: '2.0', method: 'notifications/initialized'})}\n`, + ) + input.write( + `${JSON.stringify({ + id: 2, + jsonrpc: '2.0', + method: 'tools/call', + params: {name: 'polymorfa_session_list', arguments: {projectId}}, + })}\n`, + ) + await waitFor(messages, 2) + expect(messages[1]).toMatchObject({ + id: 2, + result: { + structuredContent: { + sessions: [{sessionId: 'session_1', status: 'CONNECTED'}], + status: 200, + }, + }, + }) + expect(JSON.stringify(messages[1])).not.toContain('phone') + + input.write( + `${JSON.stringify({ + id: 3, + jsonrpc: '2.0', + method: 'tools/call', + params: {name: 'polymorfa_session_list', arguments: {projectId}}, + })}\n`, + ) + await waitFor(messages, 3) + expect(messages[2]).toMatchObject({id: 3, result: {isError: true}}) + expect(JSON.stringify(messages[2])).not.toContain('pmfa_secret_value') }) }) diff --git a/test/mcp/session-discovery.test.ts b/test/mcp/session-discovery.test.ts new file mode 100644 index 00000000..0c7dcc87 --- /dev/null +++ b/test/mcp/session-discovery.test.ts @@ -0,0 +1,85 @@ +import {afterEach, describe, expect, test} from 'vitest' + +import {listMcpSessions} from '../../src/mcp/session-discovery.js' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const PROJECT_ID = '11111111-1111-4111-8111-111111111111' +const API_KEY = `pmfa_${'A'.repeat(72)}` + +describe('MCP session discovery', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('reads one project and returns only non-secret session fields', async () => { + server = await startHttpServer({ + body: { + data: [ + { + projectId: PROJECT_ID, + sessionId: 'session_1', + name: 'Support', + status: 'CONNECTED', + testMode: false, + phone: '+49123456789', + token: 'secret-value', + }, + ], + }, + headers: {'request-id': 'req_sessions'}, + }) + + const result = await withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => listMcpSessions(PROJECT_ID), + ) + + expect(result).toEqual({ + sessions: [ + { + sessionId: 'session_1', + name: 'Support', + status: 'CONNECTED', + testMode: false, + }, + ], + status: 200, + requestId: 'req_sessions', + }) + expect(JSON.stringify(result)).not.toMatch( + /phone|secret-value|\+49123456789/, + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/sessions?projectId=${PROJECT_ID}`, + ]) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + }) + + test('rejects a project token rather than falling back to an active profile', async () => { + server = await startHttpServer({body: {data: []}}) + await expect( + withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(93)}A`, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => listMcpSessions(PROJECT_ID), + ), + ).rejects.toThrow('Select only one organization API key') + expect(server.requests).toHaveLength(0) + }) +})