Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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/`:
Expand Down
3 changes: 1 addition & 2 deletions src/commands/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await this.parse(McpServe)
Expand Down
51 changes: 46 additions & 5 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpSessionList>

export function createMcpServer(
manager: ListenerManager,
version: string,
sessionLister: McpSessionLister = listMcpSessions,
): McpServer {
const server = new McpServer({name: '@polymorfa/cli', version})
server.registerTool(
Expand Down Expand Up @@ -77,20 +82,56 @@ 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
}

export function serveMcp(
manager: ListenerManager,
version: string,
transport?: StdioServerTransport,
sessionLister?: McpSessionLister,
): {close(): Promise<void>} {
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<void> {
await manager.close()
Expand Down
61 changes: 61 additions & 0 deletions src/mcp/session-discovery.ts
Original file line number Diff line number Diff line change
@@ -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<McpSessionList> {
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}),
}
}
102 changes: 100 additions & 2 deletions test/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => {}),
)
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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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<string, {pattern?: string}>}
}>
}
}
).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<never>(() => {}),
)
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')
})
})

Expand Down
85 changes: 85 additions & 0 deletions test/mcp/session-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})