From 2a2d5be91fb5489abfe406bb4a7f6958cd20653c Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 26 Jul 2026 16:41:28 -0400 Subject: [PATCH 1/4] fix(console): complete production data transports --- apps/console/AGENTS.md | 12 +- apps/console/CLAUDE.md | 12 +- apps/console/package.json | 2 +- apps/console/scripts/railway-env.mjs | 12 ++ apps/console/scripts/railway-env.test.mjs | 25 +++ apps/console/scripts/start-railway.mjs | 3 + .../src/lib/server/consumer-graphql.test.ts | 29 ++++ .../src/lib/server/consumer-graphql.ts | 16 ++ apps/console/src/lib/server/filing-harness.ts | 16 +- .../src/lib/server/harness-mcp.test.ts | 158 ++++++++++++++++++ apps/console/src/lib/server/harness-mcp.ts | 126 +++++++++++++- .../src/lib/server/proactivity-harness.ts | 15 +- .../console/src/views/filing/filing-client.ts | 2 +- railway.console.toml | 8 +- 14 files changed, 390 insertions(+), 46 deletions(-) create mode 100644 apps/console/scripts/railway-env.mjs create mode 100644 apps/console/scripts/railway-env.test.mjs create mode 100644 apps/console/src/lib/server/consumer-graphql.test.ts create mode 100644 apps/console/src/lib/server/consumer-graphql.ts create mode 100644 apps/console/src/lib/server/harness-mcp.test.ts diff --git a/apps/console/AGENTS.md b/apps/console/AGENTS.md index b4cf97c9..0eaef35b 100644 --- a/apps/console/AGENTS.md +++ b/apps/console/AGENTS.md @@ -30,11 +30,13 @@ roots across tenants, and prevents symlink escapes. The legacy `COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development fallback only. Production must configure the Console list and tenant map. -Proactivity's GraphQL projection uses the CommonPlace API configured by -`CONSOLE_HARNESS_URL`. Its SSE feed is a separate RustyRed server endpoint: -set `THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered -`/v1/proactivity/stream` host. Do not fall back to `CONSOLE_HARNESS_URL`, -which would silently target the wrong deployment. +Proactivity and Filing use the CommonPlace consumer GraphQL schema configured by +`THEOREM_GRAPHQL_URL`. The value may be either the CommonPlace API origin or its +full `/graphql` endpoint. They never fall back to `CONSOLE_HARNESS_URL`, which +identifies the Harness MCP deployment and does not own those fields. +Proactivity's SSE feed is a separate RustyRed server endpoint: set +`THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered +`/v1/proactivity/stream` host. ## Composition doctrine diff --git a/apps/console/CLAUDE.md b/apps/console/CLAUDE.md index d97056c6..839543ef 100644 --- a/apps/console/CLAUDE.md +++ b/apps/console/CLAUDE.md @@ -30,11 +30,13 @@ roots across tenants, and prevents symlink escapes. The legacy `COMMONPLACE_WORKSPACE_ALLOWED_ROOTS` variable is a single-tenant development fallback only. Production must configure the Console list and tenant map. -Proactivity's GraphQL projection uses the CommonPlace API configured by -`CONSOLE_HARNESS_URL`. Its SSE feed is a separate RustyRed server endpoint: -set `THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered -`/v1/proactivity/stream` host. Do not fall back to `CONSOLE_HARNESS_URL`, -which would silently target the wrong deployment. +Proactivity and Filing use the CommonPlace consumer GraphQL schema configured by +`THEOREM_GRAPHQL_URL`. The value may be either the CommonPlace API origin or its +full `/graphql` endpoint. They never fall back to `CONSOLE_HARNESS_URL`, which +identifies the Harness MCP deployment and does not own those fields. +Proactivity's SSE feed is a separate RustyRed server endpoint: set +`THEOREM_PROACTIVITY_CHANGEFEED_URL` to the tenant-filtered +`/v1/proactivity/stream` host. ## Composition doctrine diff --git a/apps/console/package.json b/apps/console/package.json index cd3a766f..c28502a3 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -10,7 +10,7 @@ "start:railway": "node scripts/start-railway.mjs", "start": "next start", "lint": "eslint src scripts next.config.ts", - "test": "vitest run", + "test": "vitest run && node --test scripts/railway-env.test.mjs", "test:watch": "vitest", "gate:fence": "node scripts/check-import-fence.mjs", "gate:register": "node scripts/check-register-lint.mjs", diff --git a/apps/console/scripts/railway-env.mjs b/apps/console/scripts/railway-env.mjs new file mode 100644 index 00000000..cba6b03f --- /dev/null +++ b/apps/console/scripts/railway-env.mjs @@ -0,0 +1,12 @@ +// SOURCING: none. Railway startup configuration validation. + +/** Fail before the Console server starts when its production data plane is + * incomplete. Local development does not use the Railway launcher, so its + * localhost fallback remains available. */ +export function assertRailwayEnvironment(environment = process.env) { + if (!environment.CONSOLE_DATA_API_URL?.trim()) { + throw new Error( + 'CONSOLE_DATA_API_URL is required for CommonPlace Console Railway startup.', + ); + } +} diff --git a/apps/console/scripts/railway-env.test.mjs b/apps/console/scripts/railway-env.test.mjs new file mode 100644 index 00000000..ffe92b09 --- /dev/null +++ b/apps/console/scripts/railway-env.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { assertRailwayEnvironment } from './railway-env.mjs'; + +describe('assertRailwayEnvironment', () => { + it('requires the production data API URL', () => { + assert.throws( + () => assertRailwayEnvironment({}), + /CONSOLE_DATA_API_URL is required/, + ); + assert.throws( + () => assertRailwayEnvironment({ CONSOLE_DATA_API_URL: ' ' }), + /CONSOLE_DATA_API_URL is required/, + ); + }); + + it('accepts an explicit data API URL', () => { + assert.doesNotThrow(() => + assertRailwayEnvironment({ + CONSOLE_DATA_API_URL: 'http://commonplace-api.railway.internal:8080', + }), + ); + }); +}); diff --git a/apps/console/scripts/start-railway.mjs b/apps/console/scripts/start-railway.mjs index 818b681b..d0bd0b50 100644 --- a/apps/console/scripts/start-railway.mjs +++ b/apps/console/scripts/start-railway.mjs @@ -3,6 +3,9 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { assertRailwayEnvironment } from './railway-env.mjs'; + +assertRailwayEnvironment(); const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); diff --git a/apps/console/src/lib/server/consumer-graphql.test.ts b/apps/console/src/lib/server/consumer-graphql.test.ts new file mode 100644 index 00000000..9b8e242a --- /dev/null +++ b/apps/console/src/lib/server/consumer-graphql.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { consumerGraphqlUrl } from './consumer-graphql'; + +describe('consumerGraphqlUrl', () => { + it('uses the explicit CommonPlace consumer GraphQL endpoint', () => { + expect( + consumerGraphqlUrl({ + THEOREM_GRAPHQL_URL: ' https://commonplace.example/graphql/ ', + }), + ).toBe('https://commonplace.example/graphql'); + }); + + it('accepts the existing CommonPlace base URL contract', () => { + expect( + consumerGraphqlUrl({ + THEOREM_GRAPHQL_URL: 'https://commonplace.example/', + }), + ).toBe('https://commonplace.example/graphql'); + }); + + it('does not fall back to the Harness MCP host', () => { + expect( + consumerGraphqlUrl({ + CONSOLE_HARNESS_URL: 'https://api.theoremharness.com', + }), + ).toBeNull(); + }); +}); diff --git a/apps/console/src/lib/server/consumer-graphql.ts b/apps/console/src/lib/server/consumer-graphql.ts new file mode 100644 index 00000000..f3570c8d --- /dev/null +++ b/apps/console/src/lib/server/consumer-graphql.ts @@ -0,0 +1,16 @@ +// SOURCING: none. Server-only consumer GraphQL endpoint selection. + +/** + * Proactivity and Filing belong to the CommonPlace consumer schema. They must + * not fall back to CONSOLE_HARNESS_URL, which is the Harness MCP service and + * does not own their fields. + */ +export function consumerGraphqlUrl( + environment: Readonly> = process.env, +): string | null { + const configured = environment.THEOREM_GRAPHQL_URL?.trim(); + if (!configured) return null; + + const base = configured.replace(/\/+$/, ''); + return base.endsWith('/graphql') ? base : `${base}/graphql`; +} diff --git a/apps/console/src/lib/server/filing-harness.ts b/apps/console/src/lib/server/filing-harness.ts index 59062b36..73c9f294 100644 --- a/apps/console/src/lib/server/filing-harness.ts +++ b/apps/console/src/lib/server/filing-harness.ts @@ -1,7 +1,8 @@ // SOURCING: none. Server-only GraphQL adapter for the filing engine's Index // projection and its reversible corrections. It is the sole module that knows // the upstream credential and tenant headers, matching the shape -// proactivity-harness.ts established. +// proactivity-harness.ts established. Filing belongs to the CommonPlace +// consumer GraphQL schema, not the Harness MCP schema. import 'server-only'; @@ -13,6 +14,7 @@ import type { IndexCollection, UrgentEvent, } from '@/lib/filing/types'; +import { consumerGraphqlUrl } from '@/lib/server/consumer-graphql'; import { startHarnessRequestTimeout } from '@/lib/server/harness-timeout'; import { principalTenantHeaders, @@ -97,13 +99,6 @@ const EXPLAIN_QUERY = ` } `; -function graphqlUrl(): string | null { - const explicit = process.env.THEOREM_GRAPHQL_URL; - if (explicit) return explicit; - const base = process.env.CONSOLE_HARNESS_URL; - return base ? `${base.replace(/\/$/, '')}/graphql` : null; -} - async function executeGraphql( query: string, variables: Record = {}, @@ -115,7 +110,7 @@ async function executeGraphql( if (!resolution.ok) { return { ok: false, status: resolution.response.status, error: 'principal_resolution=unauthenticated' }; } - const endpoint = graphqlUrl(); + const endpoint = consumerGraphqlUrl(); if (!endpoint) return { ok: false, status: 404, error: 'filing_graphql_unconfigured' }; const timeout = startHarnessRequestTimeout(); try { @@ -124,9 +119,6 @@ async function executeGraphql( headers: { 'Content-Type': 'application/json', ...principalTenantHeaders(resolution.principal), - ...(process.env.CONSOLE_HARNESS_TOKEN - ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } - : {}), ...(process.env.THEOREM_API_KEY ? { 'x-api-key': process.env.THEOREM_API_KEY } : {}), }, body: JSON.stringify({ query, variables }), diff --git a/apps/console/src/lib/server/harness-mcp.test.ts b/apps/console/src/lib/server/harness-mcp.test.ts new file mode 100644 index 00000000..e2b59afa --- /dev/null +++ b/apps/console/src/lib/server/harness-mcp.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { principalTenantHeadersMock, resolveHarnessPrincipalMock } = vi.hoisted(() => ({ + principalTenantHeadersMock: vi.fn(), + resolveHarnessPrincipalMock: vi.fn(), +})); + +vi.mock('@/lib/server/harness-principal', () => ({ + principalTenantHeaders: principalTenantHeadersMock, + resolveHarnessPrincipal: resolveHarnessPrincipalMock, +})); + +import { callHarnessMcp } from './harness-mcp'; + +const principal = { + tenant: 'Travis-Gilbert', + githubLogin: 'Travis-Gilbert', + harnessIdentity: 'github:owner', +}; + +function sse(payload: Record, headers: Record = {}): Response { + return new Response(`:\n\ndata: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + ...headers, + }, + }); +} + +function multilineSse(payload: Record): Response { + const serialized = JSON.stringify(payload); + const splitAt = serialized.indexOf(',') + 1; + return new Response( + `:\n\ndata: ${serialized.slice(0, splitAt)}\ndata: ${serialized.slice(splitAt)}\n\n`, + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ); +} + +beforeEach(() => { + process.env.CONSOLE_HARNESS_URL = 'https://api.theoremharness.com'; + process.env.CONSOLE_HARNESS_TOKEN = 'test-harness-token'; + resolveHarnessPrincipalMock.mockResolvedValue({ ok: true, principal }); + principalTenantHeadersMock.mockReturnValue({ + 'x-theorem-tenant': principal.tenant, + 'x-theorem-principal': principal.harnessIdentity, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + delete process.env.CONSOLE_HARNESS_URL; + delete process.env.CONSOLE_HARNESS_TOKEN; +}); + +describe('callHarnessMcp', () => { + it('initializes a principal-bound MCP session before calling a tool', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(sse( + { + jsonrpc: '2.0', + id: 'initialize', + result: { + protocolVersion: '2025-06-18', + serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' }, + }, + }, + { 'MCP-Session-Id': 'session-1' }, + )) + .mockResolvedValueOnce(new Response(null, { status: 202 })) + .mockResolvedValueOnce(multilineSse({ + jsonrpc: '2.0', + id: 'graphql_query', + result: { + structuredContent: { + data: { observedModel: { eventCount: 0 } }, + }, + }, + })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await callHarnessMcp('graphql_query', { + query: 'query { observedModel(topicId: "topic") }', + }); + + expect(result).toEqual({ + ok: true, + data: { data: { observedModel: { eventCount: 0 } } }, + principal, + }); + expect(fetchMock).toHaveBeenCalledTimes(4); + + const initialize = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(initialize[1].headers).toMatchObject({ + Accept: 'application/json, text/event-stream', + Authorization: 'Bearer test-harness-token', + 'MCP-Protocol-Version': '2025-06-18', + }); + expect(JSON.parse(String(initialize[1].body))).toMatchObject({ + method: 'initialize', + params: { protocolVersion: '2025-06-18' }, + }); + + const ready = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(ready[1].headers).toMatchObject({ 'MCP-Session-Id': 'session-1' }); + expect(JSON.parse(String(ready[1].body))).toMatchObject({ + method: 'notifications/initialized', + }); + + const toolCall = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(toolCall[1].headers).toMatchObject({ 'MCP-Session-Id': 'session-1' }); + expect(JSON.parse(String(toolCall[1].body))).toMatchObject({ + method: 'tools/call', + params: { + name: 'graphql_query', + arguments: { + tenant: principal.tenant, + tenant_slug: principal.tenant, + actor: principal.harnessIdentity, + }, + }, + }); + + const close = fetchMock.mock.calls[3] as [string, RequestInit]; + expect(close[1]).toMatchObject({ + method: 'DELETE', + headers: expect.objectContaining({ 'MCP-Session-Id': 'session-1' }), + }); + expect(close[1].signal).not.toBe(toolCall[1].signal); + }); + + it('fails closed when initialization does not return a session id', async () => { + const fetchMock = vi.fn().mockResolvedValue(sse({ + jsonrpc: '2.0', + id: 'initialize', + result: { + protocolVersion: '2025-06-18', + serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' }, + }, + })); + vi.stubGlobal('fetch', fetchMock); + + const result = await callHarnessMcp('graphql_query', { query: 'query { status }' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + await expect(result.response.json()).resolves.toMatchObject({ + error: 'harness_mcp_initialization_failed', + }); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/console/src/lib/server/harness-mcp.ts b/apps/console/src/lib/server/harness-mcp.ts index 07d0d30d..889c3c2d 100644 --- a/apps/console/src/lib/server/harness-mcp.ts +++ b/apps/console/src/lib/server/harness-mcp.ts @@ -10,6 +10,9 @@ export type HarnessMcpResult = | { ok: true; data: Record; principal: HarnessPrincipal } | { ok: false; response: Response }; +const MCP_PROTOCOL_VERSION = '2025-06-18'; +const MCP_TEARDOWN_TIMEOUT_MS = 2_000; + export async function callHarnessMcp( name: string, argumentsValue: Record, @@ -25,16 +28,70 @@ export async function callHarnessMcp( } const endpoint = `${base.replace(/\/(?:mcp)?\/?$/, '')}/mcp`; const timeout = startHarnessRequestTimeout(); + const headers = { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, + ...principalTenantHeaders(resolution.principal), + ...(process.env.CONSOLE_HARNESS_TOKEN + ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } + : {}), + }; + let sessionId: string | null = null; try { + const initialized = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `initialize-${Date.now()}`, + method: 'initialize', + params: { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'commonplace-console', version: '1' }, + }, + }), + cache: 'no-store', + signal: timeout.signal, + }); + const initializePayload = await readMcpPayload(initialized); + sessionId = initialized.headers.get('mcp-session-id')?.trim() || null; + if (!initialized.ok || !sessionId || !record(initializePayload?.result)) { + return { + ok: false, + response: Response.json( + { error: 'harness_mcp_initialization_failed', status: initialized.status }, + { status: initialized.ok ? 502 : initialized.status }, + ), + }; + } + + const sessionHeaders = { ...headers, 'MCP-Session-Id': sessionId }; + const ready = await fetch(endpoint, { + method: 'POST', + headers: sessionHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }), + cache: 'no-store', + signal: timeout.signal, + }); + if (!ready.ok) { + return { + ok: false, + response: Response.json( + { error: 'harness_mcp_initialization_failed', status: ready.status }, + { status: ready.status }, + ), + }; + } + await ready.arrayBuffer(); + const upstream = await fetch(endpoint, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...principalTenantHeaders(resolution.principal), - ...(process.env.CONSOLE_HARNESS_TOKEN - ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } - : {}), - }, + headers: sessionHeaders, body: JSON.stringify({ jsonrpc: '2.0', id: `${name}-${Date.now()}`, @@ -47,7 +104,7 @@ export async function callHarnessMcp( cache: 'no-store', signal: timeout.signal, }); - const payload = (await upstream.json().catch(() => null)) as Record | null; + const payload = await readMcpPayload(upstream); if (!upstream.ok) { return { ok: false, @@ -84,10 +141,63 @@ export async function callHarnessMcp( ), }; } finally { + if (sessionId) { + await fetch(endpoint, { + method: 'DELETE', + headers: { + ...headers, + 'MCP-Session-Id': sessionId, + }, + cache: 'no-store', + signal: AbortSignal.timeout(MCP_TEARDOWN_TIMEOUT_MS), + }).catch(() => null); + } timeout.clear(); } } +async function readMcpPayload(response: Response): Promise | null> { + if (response.headers.get('content-type')?.includes('application/json')) { + return await response.json().catch(() => null) as Record | null; + } + const reader = response.body?.getReader(); + if (!reader) return null; + + const decoder = new TextDecoder(); + let buffer = ''; + let dataLines: string[] = []; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) return parseMcpEvent(dataLines); + buffer += decoder.decode(chunk.value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (line === '') { + if (dataLines.length === 0) continue; + const payload = parseMcpEvent(dataLines); + dataLines = []; + return payload; + } + if (!line.startsWith('data:')) continue; + dataLines.push(line.slice('data:'.length).replace(/^ /, '')); + } + } + } finally { + await reader.cancel().catch(() => undefined); + } +} + +function parseMcpEvent(dataLines: readonly string[]): Record | null { + if (dataLines.length === 0) return null; + try { + return record(JSON.parse(dataLines.join('\n'))); + } catch { + return null; + } +} + function normalizeResult(value: unknown): Record | null { const result = record(value); if (!result || result.isError === true) return null; diff --git a/apps/console/src/lib/server/proactivity-harness.ts b/apps/console/src/lib/server/proactivity-harness.ts index a90a0e82..506af258 100644 --- a/apps/console/src/lib/server/proactivity-harness.ts +++ b/apps/console/src/lib/server/proactivity-harness.ts @@ -1,4 +1,4 @@ -// SOURCING: none. Server-only GraphQL adapter for the harness's denormalized +// SOURCING: none. Server-only GraphQL adapter for the consumer schema's denormalized // proactivity projection and its named reversible mutations. It is the sole // module that knows the upstream credential and tenant headers. @@ -12,6 +12,7 @@ import type { ProactivityCompilationCandidate, ProactivityReceipt, } from '@/lib/proactivity/types'; +import { consumerGraphqlUrl } from '@/lib/server/consumer-graphql'; import { startHarnessRequestTimeout } from '@/lib/server/harness-timeout'; import { principalTenantHeaders, @@ -85,13 +86,6 @@ function deterministicProactivityGraphFixture(): ProactivityGraph { }; } -function graphqlUrl(): string | null { - const explicit = process.env.THEOREM_GRAPHQL_URL; - if (explicit) return explicit; - const base = process.env.CONSOLE_HARNESS_URL; - return base ? `${base.replace(/\/$/, '')}/graphql` : null; -} - function actionOperation(action: ProactivityAction): { readonly query: string; readonly variables: Record } { switch (action.kind) { case 'set-node-enabled': @@ -143,7 +137,7 @@ async function executeGraphql( if (!resolution.ok) { return { ok: false, status: resolution.response.status, error: 'principal_resolution=unauthenticated' }; } - const endpoint = graphqlUrl(); + const endpoint = consumerGraphqlUrl(); if (!endpoint) return { ok: false, status: 404, error: 'harness_graphql_unconfigured' }; const timeout = startHarnessRequestTimeout(); try { @@ -152,9 +146,6 @@ async function executeGraphql( headers: { 'Content-Type': 'application/json', ...principalTenantHeaders(resolution.principal), - ...(process.env.CONSOLE_HARNESS_TOKEN - ? { Authorization: `Bearer ${process.env.CONSOLE_HARNESS_TOKEN}` } - : {}), ...(process.env.THEOREM_API_KEY ? { 'x-api-key': process.env.THEOREM_API_KEY } : {}), }, body: JSON.stringify({ query, variables }), diff --git a/apps/console/src/views/filing/filing-client.ts b/apps/console/src/views/filing/filing-client.ts index 3fb9175c..1a08e61b 100644 --- a/apps/console/src/views/filing/filing-client.ts +++ b/apps/console/src/views/filing/filing-client.ts @@ -24,7 +24,7 @@ export type FilingFetchState = /** The capability an unconfigured or unreachable filing engine is missing. * Named, so the surface's unavailable state says the true thing rather than a * generic apology. */ -export const FILING_CAPABILITY = 'the filing engine (set CONSOLE_HARNESS_URL)'; +export const FILING_CAPABILITY = 'the filing engine (set THEOREM_GRAPHQL_URL)'; async function readJson(url: string): Promise> { try { diff --git a/railway.console.toml b/railway.console.toml index 1c05d455..33da2ee4 100644 --- a/railway.console.toml +++ b/railway.console.toml @@ -26,13 +26,17 @@ watchPatterns = [ # CONSOLE_MOBILE_API_KEY (optional x-api-key gate for native chat; use the # same value as the connected CommonPlace node's mobile API key) # CONSOLE_DATA_API_URL + CONSOLE_DATA_API_KEY (object seam; the record -# table renders its error state when unreachable, identity refused on 403) +# table renders its error state when unreachable, identity refused on 403; +# CONSOLE_DATA_API_URL is required by the Railway launcher) +# THEOREM_GRAPHQL_URL + THEOREM_API_KEY (CommonPlace consumer GraphQL; +# THEOREM_GRAPHQL_URL accepts either the API origin or `/graphql` endpoint, +# and THEOREM_API_KEY is sent only as the consumer `x-api-key`) # CONSOLE_HARNESS_URL + CONSOLE_HARNESS_TOKEN + CONSOLE_HARNESS_TENANT + # CONSOLE_HARNESS_ROOM (presence and runs; absent keeps presence hidden # and the Runs scope in its named unavailable state) # THEOREM_PROACTIVITY_CHANGEFEED_URL (required for the Console proactivity # overlay; points at RustyRed `/v1/proactivity/stream`, never the -# CommonPlace GraphQL host configured by `CONSOLE_HARNESS_URL`) +# CommonPlace GraphQL host configured by `THEOREM_GRAPHQL_URL`) [deploy] startCommand = "npm run console:start:railway" From 51ba11028bf0e35d68a53fcba7201ce231065289 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 26 Jul 2026 16:46:46 -0400 Subject: [PATCH 2/4] fix(console): guard server transport modules --- apps/console/src/lib/server/consumer-graphql.test.ts | 4 +++- apps/console/src/lib/server/consumer-graphql.ts | 2 ++ apps/console/src/lib/server/harness-mcp.test.ts | 1 + apps/console/src/lib/server/harness-mcp.ts | 2 ++ apps/console/src/lib/server/proactivity-harness.ts | 2 +- 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/console/src/lib/server/consumer-graphql.test.ts b/apps/console/src/lib/server/consumer-graphql.test.ts index 9b8e242a..d5c04c2d 100644 --- a/apps/console/src/lib/server/consumer-graphql.test.ts +++ b/apps/console/src/lib/server/consumer-graphql.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('server-only', () => ({})); import { consumerGraphqlUrl } from './consumer-graphql'; diff --git a/apps/console/src/lib/server/consumer-graphql.ts b/apps/console/src/lib/server/consumer-graphql.ts index f3570c8d..c3e33a8a 100644 --- a/apps/console/src/lib/server/consumer-graphql.ts +++ b/apps/console/src/lib/server/consumer-graphql.ts @@ -1,5 +1,7 @@ // SOURCING: none. Server-only consumer GraphQL endpoint selection. +import 'server-only'; + /** * Proactivity and Filing belong to the CommonPlace consumer schema. They must * not fall back to CONSOLE_HARNESS_URL, which is the Harness MCP service and diff --git a/apps/console/src/lib/server/harness-mcp.test.ts b/apps/console/src/lib/server/harness-mcp.test.ts index e2b59afa..86cae5a2 100644 --- a/apps/console/src/lib/server/harness-mcp.test.ts +++ b/apps/console/src/lib/server/harness-mcp.test.ts @@ -5,6 +5,7 @@ const { principalTenantHeadersMock, resolveHarnessPrincipalMock } = vi.hoisted(( resolveHarnessPrincipalMock: vi.fn(), })); +vi.mock('server-only', () => ({})); vi.mock('@/lib/server/harness-principal', () => ({ principalTenantHeaders: principalTenantHeadersMock, resolveHarnessPrincipal: resolveHarnessPrincipalMock, diff --git a/apps/console/src/lib/server/harness-mcp.ts b/apps/console/src/lib/server/harness-mcp.ts index 889c3c2d..77fb2646 100644 --- a/apps/console/src/lib/server/harness-mcp.ts +++ b/apps/console/src/lib/server/harness-mcp.ts @@ -1,3 +1,5 @@ +import 'server-only'; + import { principalTenantHeaders, resolveHarnessPrincipal, diff --git a/apps/console/src/lib/server/proactivity-harness.ts b/apps/console/src/lib/server/proactivity-harness.ts index 506af258..fde6c660 100644 --- a/apps/console/src/lib/server/proactivity-harness.ts +++ b/apps/console/src/lib/server/proactivity-harness.ts @@ -138,7 +138,7 @@ async function executeGraphql( return { ok: false, status: resolution.response.status, error: 'principal_resolution=unauthenticated' }; } const endpoint = consumerGraphqlUrl(); - if (!endpoint) return { ok: false, status: 404, error: 'harness_graphql_unconfigured' }; + if (!endpoint) return { ok: false, status: 404, error: 'proactivity_graphql_unconfigured' }; const timeout = startHarnessRequestTimeout(); try { const upstream = await fetch(endpoint, { From 51f8939aac410ed99096c85142e9a38b2077d7b5 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 26 Jul 2026 16:49:58 -0400 Subject: [PATCH 3/4] fix(console): correlate MCP stream responses --- .../src/lib/server/harness-mcp.test.ts | 53 +++++++++------ apps/console/src/lib/server/harness-mcp.ts | 67 ++++++++++--------- 2 files changed, 70 insertions(+), 50 deletions(-) diff --git a/apps/console/src/lib/server/harness-mcp.test.ts b/apps/console/src/lib/server/harness-mcp.test.ts index 86cae5a2..e4c008a9 100644 --- a/apps/console/src/lib/server/harness-mcp.test.ts +++ b/apps/console/src/lib/server/harness-mcp.test.ts @@ -29,11 +29,15 @@ function sse(payload: Record, headers: Record = }); } -function multilineSse(payload: Record): Response { +function notificationThenMultilineSse( + notification: Record, + payload: Record, +): Response { const serialized = JSON.stringify(payload); const splitAt = serialized.indexOf(',') + 1; return new Response( - `:\n\ndata: ${serialized.slice(0, splitAt)}\ndata: ${serialized.slice(splitAt)}\n\n`, + `data: ${JSON.stringify(notification)}\n\n` + + `:\n\ndata: ${serialized.slice(0, splitAt)}\ndata: ${serialized.slice(splitAt)}\n\n`, { status: 200, headers: { 'Content-Type': 'text/event-stream' }, @@ -42,8 +46,8 @@ function multilineSse(payload: Record): Response { } beforeEach(() => { - process.env.CONSOLE_HARNESS_URL = 'https://api.theoremharness.com'; - process.env.CONSOLE_HARNESS_TOKEN = 'test-harness-token'; + vi.stubEnv('CONSOLE_HARNESS_URL', 'https://api.theoremharness.com'); + vi.stubEnv('CONSOLE_HARNESS_TOKEN', 'test-harness-token'); resolveHarnessPrincipalMock.mockResolvedValue({ ok: true, principal }); principalTenantHeadersMock.mockReturnValue({ 'x-theorem-tenant': principal.tenant, @@ -53,35 +57,44 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); - delete process.env.CONSOLE_HARNESS_URL; - delete process.env.CONSOLE_HARNESS_TOKEN; }); describe('callHarnessMcp', () => { it('initializes a principal-bound MCP session before calling a tool', async () => { const fetchMock = vi.fn() - .mockResolvedValueOnce(sse( - { + .mockImplementationOnce((_input: string, init: RequestInit) => { + const id = JSON.parse(String(init.body)).id as string; + return Promise.resolve(sse({ jsonrpc: '2.0', - id: 'initialize', + id, result: { protocolVersion: '2025-06-18', serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' }, }, - }, - { 'MCP-Session-Id': 'session-1' }, - )) + }, { 'MCP-Session-Id': 'session-1' })); + }) .mockResolvedValueOnce(new Response(null, { status: 202 })) - .mockResolvedValueOnce(multilineSse({ - jsonrpc: '2.0', - id: 'graphql_query', - result: { - structuredContent: { - data: { observedModel: { eventCount: 0 } }, + .mockImplementationOnce((_input: string, init: RequestInit) => { + const id = JSON.parse(String(init.body)).id as string; + return Promise.resolve(notificationThenMultilineSse( + { + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'probe', progress: 0.5 }, }, - }, - })) + { + jsonrpc: '2.0', + id, + result: { + structuredContent: { + data: { observedModel: { eventCount: 0 } }, + }, + }, + }, + )); + }) .mockResolvedValueOnce(new Response(null, { status: 200 })); vi.stubGlobal('fetch', fetchMock); diff --git a/apps/console/src/lib/server/harness-mcp.ts b/apps/console/src/lib/server/harness-mcp.ts index 77fb2646..fdcc9030 100644 --- a/apps/console/src/lib/server/harness-mcp.ts +++ b/apps/console/src/lib/server/harness-mcp.ts @@ -1,5 +1,6 @@ import 'server-only'; +import { createParser } from 'eventsource-parser'; import { principalTenantHeaders, resolveHarnessPrincipal, @@ -14,6 +15,7 @@ export type HarnessMcpResult = const MCP_PROTOCOL_VERSION = '2025-06-18'; const MCP_TEARDOWN_TIMEOUT_MS = 2_000; +const MCP_SSE_MAX_BUFFER_SIZE = 1024 * 1024; export async function callHarnessMcp( name: string, @@ -41,12 +43,13 @@ export async function callHarnessMcp( }; let sessionId: string | null = null; try { + const initializeRequestId = `initialize-${Date.now()}`; const initialized = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', - id: `initialize-${Date.now()}`, + id: initializeRequestId, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, @@ -57,7 +60,7 @@ export async function callHarnessMcp( cache: 'no-store', signal: timeout.signal, }); - const initializePayload = await readMcpPayload(initialized); + const initializePayload = await readMcpPayload(initialized, initializeRequestId); sessionId = initialized.headers.get('mcp-session-id')?.trim() || null; if (!initialized.ok || !sessionId || !record(initializePayload?.result)) { return { @@ -91,12 +94,13 @@ export async function callHarnessMcp( } await ready.arrayBuffer(); + const toolRequestId = `${name}-${Date.now()}`; const upstream = await fetch(endpoint, { method: 'POST', headers: sessionHeaders, body: JSON.stringify({ jsonrpc: '2.0', - id: `${name}-${Date.now()}`, + id: toolRequestId, method: 'tools/call', params: { name, @@ -106,7 +110,7 @@ export async function callHarnessMcp( cache: 'no-store', signal: timeout.signal, }); - const payload = await readMcpPayload(upstream); + const payload = await readMcpPayload(upstream, toolRequestId); if (!upstream.ok) { return { ok: false, @@ -158,48 +162,51 @@ export async function callHarnessMcp( } } -async function readMcpPayload(response: Response): Promise | null> { +async function readMcpPayload( + response: Response, + expectedId: string, +): Promise | null> { if (response.headers.get('content-type')?.includes('application/json')) { - return await response.json().catch(() => null) as Record | null; + const payload = await response.json().catch(() => null) as Record | null; + return payload?.id === expectedId ? payload : null; } const reader = response.body?.getReader(); if (!reader) return null; const decoder = new TextDecoder(); - let buffer = ''; - let dataLines: string[] = []; + let matched: Record | null = null; + let parseFailed = false; + const parser = createParser({ + maxBufferSize: MCP_SSE_MAX_BUFFER_SIZE, + onEvent(event) { + if (matched) return; + try { + const payload = record(JSON.parse(event.data)); + if (payload?.id === expectedId) matched = payload; + } catch { + // A malformed or request-scoped event is not the matching response. + } + }, + onError() { + parseFailed = true; + }, + }); try { while (true) { const chunk = await reader.read(); - if (chunk.done) return parseMcpEvent(dataLines); - buffer += decoder.decode(chunk.value, { stream: true }); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() ?? ''; - for (const line of lines) { - if (line === '') { - if (dataLines.length === 0) continue; - const payload = parseMcpEvent(dataLines); - dataLines = []; - return payload; - } - if (!line.startsWith('data:')) continue; - dataLines.push(line.slice('data:'.length).replace(/^ /, '')); + if (chunk.done) { + parser.feed(decoder.decode()); + parser.reset({ consume: true }); + return matched; } + parser.feed(decoder.decode(chunk.value, { stream: true })); + if (matched || parseFailed) return matched; } } finally { await reader.cancel().catch(() => undefined); } } -function parseMcpEvent(dataLines: readonly string[]): Record | null { - if (dataLines.length === 0) return null; - try { - return record(JSON.parse(dataLines.join('\n'))); - } catch { - return null; - } -} - function normalizeResult(value: unknown): Record | null { const result = record(value); if (!result || result.isError === true) return null; From ceae0f9a62ec71c1363a6bbbf08e53f055381f8b Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Sun, 26 Jul 2026 17:01:31 -0400 Subject: [PATCH 4/4] fix(console): honor negotiated MCP protocol --- apps/console/src/lib/server/harness-mcp.test.ts | 17 +++++++++++++---- apps/console/src/lib/server/harness-mcp.ts | 16 ++++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/console/src/lib/server/harness-mcp.test.ts b/apps/console/src/lib/server/harness-mcp.test.ts index e4c008a9..f67d9459 100644 --- a/apps/console/src/lib/server/harness-mcp.test.ts +++ b/apps/console/src/lib/server/harness-mcp.test.ts @@ -70,7 +70,7 @@ describe('callHarnessMcp', () => { jsonrpc: '2.0', id, result: { - protocolVersion: '2025-06-18', + protocolVersion: '2025-03-26', serverInfo: { name: 'theorem-mcp-server', version: '0.1.0' }, }, }, { 'MCP-Session-Id': 'session-1' })); @@ -121,13 +121,19 @@ describe('callHarnessMcp', () => { }); const ready = fetchMock.mock.calls[1] as [string, RequestInit]; - expect(ready[1].headers).toMatchObject({ 'MCP-Session-Id': 'session-1' }); + expect(ready[1].headers).toMatchObject({ + 'MCP-Protocol-Version': '2025-03-26', + 'MCP-Session-Id': 'session-1', + }); expect(JSON.parse(String(ready[1].body))).toMatchObject({ method: 'notifications/initialized', }); const toolCall = fetchMock.mock.calls[2] as [string, RequestInit]; - expect(toolCall[1].headers).toMatchObject({ 'MCP-Session-Id': 'session-1' }); + expect(toolCall[1].headers).toMatchObject({ + 'MCP-Protocol-Version': '2025-03-26', + 'MCP-Session-Id': 'session-1', + }); expect(JSON.parse(String(toolCall[1].body))).toMatchObject({ method: 'tools/call', params: { @@ -143,7 +149,10 @@ describe('callHarnessMcp', () => { const close = fetchMock.mock.calls[3] as [string, RequestInit]; expect(close[1]).toMatchObject({ method: 'DELETE', - headers: expect.objectContaining({ 'MCP-Session-Id': 'session-1' }), + headers: expect.objectContaining({ + 'MCP-Protocol-Version': '2025-03-26', + 'MCP-Session-Id': 'session-1', + }), }); expect(close[1].signal).not.toBe(toolCall[1].signal); }); diff --git a/apps/console/src/lib/server/harness-mcp.ts b/apps/console/src/lib/server/harness-mcp.ts index fdcc9030..7c7328da 100644 --- a/apps/console/src/lib/server/harness-mcp.ts +++ b/apps/console/src/lib/server/harness-mcp.ts @@ -42,6 +42,7 @@ export async function callHarnessMcp( : {}), }; let sessionId: string | null = null; + let sessionProtocolVersion = MCP_PROTOCOL_VERSION; try { const initializeRequestId = `initialize-${Date.now()}`; const initialized = await fetch(endpoint, { @@ -61,8 +62,13 @@ export async function callHarnessMcp( signal: timeout.signal, }); const initializePayload = await readMcpPayload(initialized, initializeRequestId); + const initializeResult = record(initializePayload?.result); + const negotiatedProtocolVersion = + typeof initializeResult?.protocolVersion === 'string' + ? initializeResult.protocolVersion.trim() + : ''; sessionId = initialized.headers.get('mcp-session-id')?.trim() || null; - if (!initialized.ok || !sessionId || !record(initializePayload?.result)) { + if (!initialized.ok || !sessionId || !initializeResult || !negotiatedProtocolVersion) { return { ok: false, response: Response.json( @@ -71,8 +77,13 @@ export async function callHarnessMcp( ), }; } + sessionProtocolVersion = negotiatedProtocolVersion; - const sessionHeaders = { ...headers, 'MCP-Session-Id': sessionId }; + const sessionHeaders = { + ...headers, + 'MCP-Protocol-Version': sessionProtocolVersion, + 'MCP-Session-Id': sessionId, + }; const ready = await fetch(endpoint, { method: 'POST', headers: sessionHeaders, @@ -152,6 +163,7 @@ export async function callHarnessMcp( method: 'DELETE', headers: { ...headers, + 'MCP-Protocol-Version': sessionProtocolVersion, 'MCP-Session-Id': sessionId, }, cache: 'no-store',