openpencil/tests/engine/mcp/server/security.test.ts
Danila Poyarkov 9c9e4fae08 refactor(mcp): align transport domain structure
- Group server, stdio, and transport implementation by ownership

- Mirror those domains in engine tests and centralize shared MCP fixtures
2026-07-25 22:59:59 +03:00

449 lines
15 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { mkdir } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { SceneGraph } from '@open-pencil/scene-graph'
import { startServer, paramToZod } from '#mcp/server'
import type { DiscoveryInfo } from '#mcp/transport/discovery'
import {
connectMockBrowser,
waitForBrowserRegistration,
type HealthResponse
} from '#tests/helpers/mcp/server'
const isUnix = process.platform !== 'win32'
const SOCKET_DIR = join(tmpdir(), `openpencil-test-server-${process.pid}`)
const TEST_AUTH_TOKEN = 'test-auth-token'
const NO_AUTH_TOKEN = null as string | null
let testCounter = 0
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function testSocketPath(): string | null {
if (!isUnix) return null
return join(SOCKET_DIR, `mcp-test-${process.pid}-${++testCounter}.sock`)
}
// ---------------------------------------------------------------------------
// Auto-generated auth token
// ---------------------------------------------------------------------------
describe('MCP server auto-generated auth token', () => {
test('requires auth when authToken is omitted (auto-generated)', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
// Request without auth should be rejected
const response = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'no-auth-test', version: '0.0.0' }
}
})
})
expect(response.status).toBe(401)
// /health should show authRequired: true
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
const health = (await healthResp.json()) as HealthResponse
expect(health.authRequired).toBe(true)
} finally {
await handle.close()
}
})
})
// ---------------------------------------------------------------------------
// /rpc auth skip when authToken is null
// ---------------------------------------------------------------------------
describe('MCP server /rpc auth skip', () => {
test('/rpc skips auth when authToken is explicitly null', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: NO_AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
// /health should show authRequired: false
const healthResp = await fetch(`http://127.0.0.1:${httpPort}/health`)
const health = (await healthResp.json()) as HealthResponse
expect(health.authRequired).toBe(false)
// Connect a browser
const graph = new SceneGraph()
const browser = await connectMockBrowser(httpPort, graph)
await waitForBrowserRegistration(httpPort)
try {
// /rpc should succeed without auth when browser IS connected
const rpcResp = await fetch(`http://127.0.0.1:${httpPort}/rpc`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ command: 'tool', args: { name: 'get_current_page' } })
})
expect(rpcResp.status).toBe(200)
// /mcp should still work (no auth required)
const mcpResp = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'no-auth-rpc-test', version: '0.0.0' }
}
})
})
expect(mcpResp.status).toBe(200)
} finally {
browser.close()
}
} finally {
await handle.close()
}
})
})
// ---------------------------------------------------------------------------
// paramToZod coercion
// ---------------------------------------------------------------------------
describe('paramToZod coercion', () => {
test('number param accepts numeric strings', () => {
const schema = paramToZod({ type: 'number', description: 'x', required: true })
expect(schema.parse('42')).toBe(42)
expect(schema.parse(42)).toBe(42)
expect(schema.parse('3.14')).toBeCloseTo(3.14)
})
test('number param rejects non-numeric strings', () => {
const schema = paramToZod({ type: 'number', description: 'x', required: true })
expect(() => schema.parse('abc')).toThrow()
})
test('number param respects min/max after coercion', () => {
const schema = paramToZod({
type: 'number',
description: 'x',
required: true,
min: 0,
max: 100
})
expect(schema.parse('50')).toBe(50)
expect(() => schema.parse('200')).toThrow()
expect(() => schema.parse('-1')).toThrow()
})
})
// ---------------------------------------------------------------------------
// GAP-04: wrong auth token rejected
// ---------------------------------------------------------------------------
describe('MCP auth boundary', () => {
test('server rejects requests with an incorrect auth token', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: 'correct-token',
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
const r = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: {
Authorization: 'Bearer wrong-token',
'content-type': 'application/json'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'mcp.ping', params: {} })
})
expect(r.status).toBe(401)
} finally {
await handle.close()
}
})
test('server rejects requests with no Authorization header when auth is enabled', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: 'correct-token',
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
const r = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'mcp.ping', params: {} })
})
expect(r.status).toBe(401)
} finally {
await handle.close()
}
})
test('server accepts requests with authToken: null (auth disabled)', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: null,
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
try {
const healthResp = (await (
await fetch(`http://127.0.0.1:${httpPort}/health`)
).json()) as HealthResponse
expect(healthResp.authRequired).toBe(false)
const mcpResp = await fetch(`http://127.0.0.1:${httpPort}/mcp`, {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'auth-disabled-test', version: '0.0.0' }
}
})
})
expect(mcpResp.status).toBe(200)
} finally {
await handle.close()
}
})
})
// ---------------------------------------------------------------------------
// GAP-05: MCP session limits — MAX_MCP_SESSIONS=10
// ---------------------------------------------------------------------------
describe('MCP session limits', () => {
test('server returns 503 when MAX_MCP_SESSIONS is exceeded', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
const httpPort = handle.httpPort
if (!httpPort) {
await handle.close()
throw new Error('withTcp: true did not produce an HTTP port')
}
const sessions: Client[] = []
const openSession = async () => {
const c = new Client({ name: 'limit-client', version: '0.0.0' })
const t = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${httpPort}/mcp`), {
requestInit: { headers: { Authorization: `Bearer ${TEST_AUTH_TOKEN}` } }
})
await c.connect(t)
sessions.push(c)
return c
}
try {
// Open MAX_MCP_SESSIONS (10) sessions successfully
for (let i = 0; i < 10; i++) await openSession()
// The 11th must be rejected with 503. The MCP SDK wraps non-2xx
// responses in StreamableHTTPError, so we assert on the error text
// rather than on a successful return.
const overflow = new Client({ name: 'overflow', version: '0.0.0' })
const overflowTransport = new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${httpPort}/mcp`),
{ requestInit: { headers: { Authorization: `Bearer ${TEST_AUTH_TOKEN}` } } }
)
let caught: unknown = null
try {
await overflow.connect(overflowTransport)
await overflow.listTools()
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toContain('Too many active MCP sessions')
await overflow.close().catch(() => undefined)
} finally {
for (const s of sessions) await s.close().catch(() => undefined)
await handle.close()
}
}, 30000)
})
// ---------------------------------------------------------------------------
// GAP-03: PID recycling in discovery — stale PID should not be treated as live
// ---------------------------------------------------------------------------
describe('Discovery PID liveness', () => {
test('readDiscoveryFile does not treat a recycled PID as live', async () => {
if (isUnix) await mkdir(SOCKET_DIR, { recursive: true })
const { getDiscoveryPath } = await import('#mcp/transport/paths')
const handle = await startServer({
httpPort: 0,
withTcp: true,
socketPath: testSocketPath(),
authToken: TEST_AUTH_TOKEN,
enableEval: false,
mcpRoot: null
})
try {
const discoveryPath = await getDiscoveryPath()
const raw = (await Bun.file(discoveryPath).json()) as Pick<DiscoveryInfo, 'pid'>
expect(raw.pid).toBe(process.pid)
} finally {
await handle.close()
}
// After close, the discovery file should be gone — even if the PID
// is reused by a future process, the absence of the file is the
// primary signal that the server is no longer reachable.
const discoveryPath = await getDiscoveryPath()
expect(await Bun.file(discoveryPath).exists()).toBe(false)
})
test('readDiscoveryFile returns null for a discovery file with a dead PID', async () => {
const { getDiscoveryPath } = await import('#mcp/transport/paths')
const { readDiscoveryFile } = await import('#mcp/transport/discovery')
const discoveryPath = await getDiscoveryPath()
// Snapshot any existing discovery file so we can restore it after the test.
let originalContents: string | null = null
try {
originalContents = await Bun.file(discoveryPath).text()
} catch {
void 0 // File doesn't exist — nothing to save
}
// Spawn a child process, kill it, and use its PID — guaranteed dead.
// This avoids relying on a hardcoded magic number (like 4_194_304) that
// could theoretically be alive on systems with a high pid_max.
// Uses an inline Bun script instead of the `sleep` binary so the
// fixture is self-contained and portable (no external dependency).
const child = Bun.spawn({
cmd: [process.execPath, '-e', 'setTimeout(()=>{},999999)'],
detached: true
})
const deadPid = child.pid
child.kill()
await child.exited
const discoveryDir = dirname(discoveryPath)
await mkdir(discoveryDir, { recursive: true })
await Bun.write(
discoveryPath,
JSON.stringify({
pid: deadPid,
socketPath: '/tmp/nonexistent-mcp.sock',
httpPort: 9999,
authRequired: true,
authToken: 'dead-pid-test-token',
version: '0.1.0-test',
startedAt: new Date().toISOString()
})
)
try {
const result = await readDiscoveryFile()
expect(result).toBeNull()
} finally {
// Restore the original discovery file (or remove our seeded one)
if (originalContents !== null) {
await Bun.write(discoveryPath, originalContents)
} else {
try {
const { unlink } = await import('node:fs/promises')
await unlink(discoveryPath)
} catch {
void 0
}
}
}
})
})