281 lines
22 KiB
JavaScript
281 lines
22 KiB
JavaScript
/** External SDK consumer: imports NO server/store/runtime/database module. */
|
|
import assert from 'node:assert/strict'
|
|
import { randomUUID, createHash } from 'node:crypto'
|
|
import { Client, StreamableHTTPClientTransport, auth } from '@modelcontextprotocol/client'
|
|
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
|
|
const endpoint = new URL(process.env.OWN_MCP_ENDPOINT), origin = endpoint.origin
|
|
assert.equal(endpoint.hostname, '127.0.0.1'); assert.equal(process.env.OWN_EXTERNAL_CLIENT, 'true')
|
|
let phase = 'start', next = 0, maxResponseBytes = 0, stderrBytes = 0, bridgePid = null
|
|
const responseLimitBytes = 1024 * 1024
|
|
const pending = new Map(), cases = []
|
|
const activeClients = new Set()
|
|
let closing = false
|
|
async function closeClients() { await Promise.allSettled([...activeClients].map(client => client.close())) }
|
|
async function interrupted() { if (closing) return; closing = true; await closeClients(); process.disconnect?.(); process.exitCode = 1 }
|
|
process.on('SIGTERM', interrupted); process.on('SIGINT', interrupted); process.on('disconnect', interrupted)
|
|
process.on('message', m => { if (m?.kind === 'reply') { const p = pending.get(m.id); pending.delete(m.id); if (m.error) p?.reject(Error('Fixture control refused')); else p?.resolve(m.value) } })
|
|
const control = command => new Promise((resolve, reject) => { const id = ++next; pending.set(id, { resolve, reject }); process.send({ kind: 'control', command, id }) })
|
|
async function boundedFetch(input, init) {
|
|
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url)
|
|
assert.equal(url.origin, origin, 'External fixture refuses all nonlocal origins')
|
|
const response = await fetch(input, { ...init, redirect: 'manual', signal: init?.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000) })
|
|
const chunks = []; let count = 0
|
|
if (response.body) {
|
|
const reader = response.body.getReader()
|
|
try {
|
|
for (;;) { const item = await reader.read(); if (item.done) break; count += item.value.length; if (count >= responseLimitBytes) { await reader.cancel(); throw Error('Own external client response bound') }; chunks.push(item.value) }
|
|
} finally { reader.releaseLock() }
|
|
}
|
|
const bytes = Buffer.concat(chunks); maxResponseBytes = Math.max(maxResponseBytes, bytes.length)
|
|
return new Response(bytes.length ? bytes : null, { status: response.status, statusText: response.statusText, headers: response.headers })
|
|
}
|
|
function content(result) {
|
|
assert(!result.isError)
|
|
const text = result.content.find(c => c.type === 'text')?.text
|
|
const parsed = JSON.parse(text)
|
|
assert(!text.includes(process.env.OWN_SERVER_SECRET)); assert(Buffer.byteLength(text) < 65536)
|
|
return parsed
|
|
}
|
|
async function denied(work) { try { const r = await work(); assert.equal(r?.isError, true) } catch (e) { if (e?.code === 'ERR_ASSERTION') throw e; assert(!/timeout|timed out/i.test(String(e?.message)), 'A timeout is not authorization denial') } }
|
|
async function opened(mode, token, maxBufferSize = 131072) {
|
|
const client = new Client({ name: 'actual-external-process-' + mode, version: '1.0.0' })
|
|
activeClients.add(client)
|
|
let transport
|
|
if (mode === 'http') transport = new StreamableHTTPClientTransport(endpoint, { requestInit: { headers: { authorization: 'Bearer ' + token } }, fetch: boundedFetch })
|
|
else {
|
|
transport = new StdioClientTransport({ command: process.execPath, args: ['--import', import.meta.resolve('tsx'), process.env.OWN_BRIDGE_FILE],
|
|
cwd: process.cwd(), env: { MCP_URL: endpoint.href, MCP_TOKEN: token }, stderr: 'pipe', maxBufferSize })
|
|
transport.stderr?.on('data', chunk => { stderrBytes += chunk.length; const text = String(chunk); assert(!text.includes(token)); assert(stderrBytes < 1048576) })
|
|
const start = transport.start.bind(transport)
|
|
transport.start = async () => { await start(); bridgePid = transport.pid; assert(bridgePid && bridgePid !== process.pid && bridgePid !== Number(process.env.OWN_SERVER_PID)); process.send({ kind: 'bridge-pid', pid: bridgePid }) }
|
|
}
|
|
const connecting = client.connect(transport, { timeout: 10000 })
|
|
try { await connecting; return client }
|
|
catch (e) { await client.close().catch(() => {}); throw e }
|
|
}
|
|
async function scoped(client) {
|
|
const list = await client.listTools({}, { timeout: 5000 })
|
|
assert.deepEqual(list.tools.map(t => t.name), ['mm_servers_list'])
|
|
const result = content(await client.callTool({ name: 'mm_servers_list', arguments: { limit: 1, offset: 0 } }, undefined, { timeout: 5000 }))
|
|
assert.equal(result.items.length, 1); assert.equal(result.items[0].id, Number(process.env.OWN_ALLOWED_SERVER))
|
|
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: { serverId: Number(process.env.OWN_FORBIDDEN_SERVER) } }, undefined, { timeout: 5000 }))
|
|
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: { limit: 501 } }, undefined, { timeout: 5000 }))
|
|
await denied(() => client.callTool({ name: 'mm_servers_delete_plan', arguments: { serverId: Number(process.env.OWN_ALLOWED_SERVER) } }, undefined, { timeout: 5000 }))
|
|
}
|
|
try {
|
|
assert.notEqual(process.pid, Number(process.env.OWN_SERVER_PID))
|
|
for (const mode of ['http', 'stdio']) {
|
|
phase = mode + ':issue'; const initial = await control('new-connection'); let token = initial.token
|
|
phase = mode + ':initialize'; let client = await opened(mode, token)
|
|
try {
|
|
await scoped(client); cases.push(mode + ': separate SDK process initializes, filters tool/host scope, bounds page/result and redacts credentials')
|
|
phase = mode + ':policy-edit'
|
|
const editScope = async operationIds => {
|
|
const response = await boundedFetch(origin + '/api/mcp/connections/' + initial.connectionId, {
|
|
method: 'PUT', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Owned external SDK process', mode: 'observer', expiresAt: new Date(Date.now() + 600000).toISOString(),
|
|
permissions: ['mm:servers:read'], scope: { serverIds: [Number(process.env.OWN_ALLOWED_SERVER)], operationIds, allowGlobal: false }, limits: { callsPerMinute: 200 } }),
|
|
})
|
|
assert.equal(response.status, 200)
|
|
}
|
|
await editScope(['logs.query'])
|
|
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools.map(tool => tool.name), ['mm_logs_query'])
|
|
const allowedServer = Number(process.env.OWN_ALLOWED_SERVER), forbiddenServer = Number(process.env.OWN_FORBIDDEN_SERVER)
|
|
const queryLogs = arguments_ => client.callTool({ name: 'mm_logs_query', arguments: arguments_ }, undefined, { timeout: 5000 })
|
|
const filters = { q: 'external-sdk-log-', limit: 1 }
|
|
const first = content(await queryLogs(filters))
|
|
assert.ok(first.nextCursor)
|
|
const second = content(await queryLogs({ ...filters, cursor: first.nextCursor }))
|
|
assert.equal(second.nextCursor, null)
|
|
assert.deepEqual([first.entries, second.entries].map(entries => entries.map(({ serverId, message }) => ({ serverId, message }))), [
|
|
[{ serverId: allowedServer, message: 'external-sdk-log-1' }],
|
|
[{ serverId: allowedServer, message: 'external-sdk-log-2' }],
|
|
])
|
|
await denied(() => queryLogs({ ...filters, serverId: forbiddenServer }))
|
|
cases.push(mode + ': populated log search pages return only allowed server records and reject the forbidden server')
|
|
await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, { timeout: 5000 }))
|
|
await editScope([])
|
|
assert.deepEqual((await client.listTools({}, { timeout: 5000 })).tools, [])
|
|
await editScope(['servers.list'])
|
|
await scoped(client)
|
|
cases.push(mode + ': established client observes removed and restored tool scope through the web policy API')
|
|
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'failure') throw Error('Owned deliberate external client failure with bridge active')
|
|
if (mode === 'stdio' && process.env.OWN_EXTERNAL_CLEANUP_FAULT === 'timeout') { process.send({ kind: 'arm-cleanup-timeout' }); await new Promise(() => {}) }
|
|
phase = mode + ':result-bound'; await control('large-result')
|
|
try {
|
|
const bounded = content(await client.callTool({ name: 'mm_servers_list', arguments: { limit: 1 } }, undefined, { timeout: 5000 }))
|
|
assert.equal(bounded.items[0].comment.length, 16000); assert(!bounded.items[0].comment.includes('OWNED_TRUNCATION_TAIL'))
|
|
cases.push(mode + ': actual production public-string16k bound truncates owned40k comment through real SDK result')
|
|
} finally { await control('reset-result') }
|
|
phase = mode + ':rotate'; const rotated = await control('rotate'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
|
await client.close(); await assert.rejects(opened(mode, token)); token = rotated.token; client = await opened(mode, token)
|
|
content(await client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 })); cases.push(mode + ': old credential and reconnect refused after rotation; fresh transport reconnect succeeds')
|
|
phase = mode + ':pause'; await control('pause'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
|
await control('resume'); content(await client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 })); cases.push(mode + ': established client observes pause/resume on real requests')
|
|
phase = mode + ':revoke'; await control('revoke'); await denied(() => client.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
|
await client.close(); await assert.rejects(opened(mode, token)); cases.push(mode + ': permanent revocation blocks established and newly initialized external clients')
|
|
} finally { await client.close().catch(() => {}) }
|
|
}
|
|
phase = 'http:body-bound'; const bound = await control('new-connection')
|
|
const oversized = await fetch(endpoint, { method: 'POST', headers: { authorization: 'Bearer ' + bound.token, 'content-type': 'application/json', accept: 'application/json,text/event-stream', connection: 'close' },
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'mm_servers_list', arguments: { padding: 'x'.repeat(1048576) } } }), signal: AbortSignal.timeout(5000) })
|
|
assert.equal(oversized.status, 413); assert((await oversized.text()).length < 65536)
|
|
cases.push('actual HTTP request envelope over1MiB refused before tool execution; successful client results remain bounded')
|
|
|
|
phase = 'sdk:pkce'; const grant = await control('new-connection')
|
|
const metadata = { client_name: 'owned-external-sdk-pkce', redirect_uris: [origin + '/owned-callback'], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', scope: 'mcp' }
|
|
const registered = await boundedFetch(origin + '/api/mcp/oauth/clients', { method: 'POST', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' }, body: JSON.stringify({ name: metadata.client_name, redirectUris: metadata.redirect_uris }) })
|
|
assert.equal(registered.status, 200); const clientId = (await registered.json()).clientId
|
|
let tokens, verifier, authorizationCode, authorizationIssuer, discovery, consentAllowed = true
|
|
const state = randomUUID()
|
|
const provider = { redirectUrl: metadata.redirect_uris[0], clientMetadata: metadata, clientInformation: () => ({ client_id: clientId, token_endpoint_auth_method: 'none' }),
|
|
tokens: () => tokens, saveTokens: v => { tokens = v }, saveCodeVerifier: v => { verifier = v }, codeVerifier: () => verifier, state: () => state,
|
|
discoveryState: () => discovery, saveDiscoveryState: v => { discovery = v }, invalidateCredentials: scope => { if (scope === 'tokens') tokens = undefined },
|
|
redirectToAuthorization: async url => {
|
|
if (!consentAllowed) throw Error('Synthetic consent disabled after revocation')
|
|
assert.equal(url.origin, origin); assert.equal(url.searchParams.get('code_challenge_method'), 'S256')
|
|
assert.equal(url.searchParams.get('code_challenge'), createHash('sha256').update(verifier).digest('base64url'))
|
|
const response = await boundedFetch(url); assert.equal(response.status, 302)
|
|
const requestId = new URL(response.headers.get('location')).searchParams.get('oauthRequest'); assert(requestId)
|
|
const consent = await boundedFetch(origin + '/api/mcp/oauth/consent', { method: 'POST', headers: { authorization: 'Bearer ' + process.env.OWN_PORTAL_JWT, 'content-type': 'application/json' }, body: JSON.stringify({ requestId, connectionId: grant.connectionId, approve: true }) })
|
|
assert.equal(consent.status, 200); const callback = new URL((await consent.json()).redirectUrl)
|
|
assert.equal(callback.searchParams.get('state'), state); authorizationCode = callback.searchParams.get('code'); authorizationIssuer = callback.searchParams.get('iss')
|
|
} }
|
|
phase = 'sdk:pkce-redirect'; assert.equal(await auth(provider, { serverUrl: endpoint, scope: 'mcp', fetchFn: boundedFetch }), 'REDIRECT')
|
|
assert(authorizationCode && verifier); assert.equal(authorizationIssuer, origin)
|
|
phase = 'sdk:pkce-exchange'; assert.equal(await auth(provider, { serverUrl: endpoint, authorizationCode, iss: authorizationIssuer, fetchFn: boundedFetch }), 'AUTHORIZED')
|
|
assert(tokens?.access_token && tokens?.refresh_token)
|
|
const oauthClient = new Client({ name: 'external-sdk-real-pkce-mock-portal', version: '1.0.0' })
|
|
activeClients.add(oauthClient)
|
|
try {
|
|
phase = 'sdk:pkce-initialize'; await oauthClient.connect(new StreamableHTTPClientTransport(endpoint, { authProvider: provider, fetch: boundedFetch }), { timeout: 10000 })
|
|
content(await oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
|
cases.push('installed SDK auth() discovers actual OAuth metadata, produces PKCE/S256, exchanges one-use code and calls scoped tool; portal JWT/consent is synthetic')
|
|
phase = 'sdk:pkce-revoke'; const access = tokens.access_token, refresh = tokens.refresh_token
|
|
const revoked = await boundedFetch(origin + '/oauth/revoke', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: refresh, client_id: clientId }).toString() }); assert.equal(revoked.status, 200)
|
|
consentAllowed = false; await assert.rejects(opened('http', access))
|
|
await denied(() => oauthClient.callTool({ name: 'mm_servers_list', arguments: {} }, undefined, { timeout: 5000 }))
|
|
cases.push('actual OAuth family revocation refuses established and fresh external transport; no implicit new consent granted')
|
|
} finally { await oauthClient.close().catch(() => {}) }
|
|
phase = 'discovery:issue'
|
|
const catalogue = await control('discovery-connection')
|
|
const expectedNames = catalogue.names.toSorted()
|
|
assert(expectedNames.includes('mm_logs_follow'))
|
|
const discoveryByTransport = {}
|
|
for (const mode of ['http', 'stdio']) {
|
|
phase = `discovery:${mode}`
|
|
const client = await opened(mode, catalogue.token, responseLimitBytes)
|
|
try {
|
|
const names = [], cursors = new Set()
|
|
let cursor, largestPageBytes = 0, pages = 0
|
|
do {
|
|
const page = await client.listTools(cursor ? { cursor } : {}, { timeout: 10000 })
|
|
assert(page.tools.length > 0)
|
|
for (const tool of page.tools) {
|
|
assert.equal(tool.inputSchema.type, 'object')
|
|
names.push(tool.name)
|
|
}
|
|
const encoded = JSON.stringify(page)
|
|
assert(!encoded.includes(catalogue.token) && !encoded.includes(process.env.OWN_SERVER_SECRET))
|
|
largestPageBytes = Math.max(largestPageBytes, Buffer.byteLength(encoded))
|
|
assert(largestPageBytes < responseLimitBytes)
|
|
pages++
|
|
cursor = page.nextCursor
|
|
if (cursor) { assert(!cursors.has(cursor)); cursors.add(cursor) }
|
|
assert(names.length <= expectedNames.length)
|
|
} while (cursor)
|
|
assert.deepEqual(names.toSorted(), expectedNames)
|
|
discoveryByTransport[mode] = { tools: names.length, pages, largestPageBytes }
|
|
cases.push(mode + ': external SDK discovers every delegated tool exactly once with an object input schema')
|
|
} finally { await client.close().catch(() => {}) }
|
|
}
|
|
phase = 'discovery:revoke'
|
|
await control('revoke')
|
|
for (const [mode, arm] of [['http', 'arm-log-revocation'], ['stdio', 'arm-log-revocation-after-commit'], ['http', 'arm-log-follow-revocation']]) {
|
|
phase = mode + ':' + arm
|
|
const { token } = await control(arm)
|
|
const logClient = await opened(mode, token)
|
|
try {
|
|
const follow = arm === 'arm-log-follow-revocation'
|
|
const tool = follow ? 'mm_logs_follow' : 'mm_logs_query'
|
|
const filters = { q: follow ? 'external-sdk-live-event' : 'external-sdk-log-1', limit: 1 }
|
|
if (follow) {
|
|
Object.assign(filters, { serverId: Number(process.env.OWN_ALLOWED_SERVER), waitSeconds: 0 })
|
|
const start = content(await logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 5000 }))
|
|
assert.deepEqual(start.entries, [])
|
|
assert.equal(typeof start.cursor, 'string')
|
|
filters.cursor = start.cursor
|
|
filters.waitSeconds = 10
|
|
}
|
|
const query = logClient.callTool({ name: tool, arguments: filters }, undefined, { timeout: 10000 })
|
|
const checked = query.then(result => {
|
|
assert.equal(result.isError, true)
|
|
const wire = JSON.stringify(result)
|
|
assert.match(wire, /OWNER_CLAIMS_REVOKED|UNAUTHORIZED/)
|
|
assert.doesNotMatch(wire, /external-sdk-log-1|external-sdk-live-event|"entries"|"nextCursor"|"cursor"/)
|
|
})
|
|
const deliver = async () => {
|
|
if (follow) await control('publish-follow-fixture-row')
|
|
await control('deliver-log-revocation')
|
|
}
|
|
await Promise.all([checked, deliver()])
|
|
await assert.rejects(opened(mode, token))
|
|
cases.push(mode + ': signed portal revocation ' + (mode === 'stdio' ? 'after' : 'before') + ' audit commit denies ' + tool + ' result and reconnect')
|
|
} finally { await logClient.close().catch(() => {}) }
|
|
}
|
|
phase = 'portal:revoke-export-download'
|
|
const exportAccess = await control('new-artifact-connection')
|
|
const exportClient = await opened('http', exportAccess.token)
|
|
try {
|
|
const exported = content(await exportClient.callTool({ name: 'mm_logs_export', arguments: {
|
|
serverId: Number(process.env.OWN_ALLOWED_SERVER), q: 'external-sdk-log-1', format: 'json',
|
|
} }, undefined, { timeout: 5000 }))
|
|
assert.equal(exported.rows, 1)
|
|
assert.equal(exported.downloadPath, '/api/mcp/artifacts/' + exported.artifactId)
|
|
assert.doesNotMatch(JSON.stringify(exported), /external-sdk-log-1/)
|
|
const download = jwt => boundedFetch(origin + exported.downloadPath, { headers: { authorization: 'Bearer ' + jwt } })
|
|
const before = await download(exportAccess.ownerJwt)
|
|
assert.equal(before.status, 200)
|
|
assert.equal(before.headers.get('cache-control'), 'private, no-store')
|
|
const bytes = Buffer.from(await before.arrayBuffer())
|
|
assert.equal(createHash('sha256').update(bytes).digest('hex'), exported.sha256)
|
|
assert.match(bytes.toString('utf8'), /external-sdk-log-1/)
|
|
const fresh = await control('revoke-artifact-owner')
|
|
for (const jwt of [exportAccess.ownerJwt, fresh.ownerJwt]) {
|
|
const refused = await download(jwt)
|
|
assert.ok([401, 403].includes(refused.status))
|
|
assert.equal(refused.headers.has('content-disposition'), false)
|
|
assert.doesNotMatch(await refused.text(), /external-sdk-log-1/)
|
|
}
|
|
const management = await boundedFetch(origin + '/api/mcp/connections', { headers: { authorization: 'Bearer ' + fresh.ownerJwt } })
|
|
assert.equal(management.status, 200)
|
|
cases.push('portal owner revocation blocks an existing unexpired log artifact even with refreshed owner login')
|
|
} finally { await exportClient.close().catch(() => {}) }
|
|
phase = 'portal:revoke-approved-plan'
|
|
const planAccess = await control('new-revocable-plan-connection')
|
|
const planClient = await opened('http', planAccess.token)
|
|
try {
|
|
const plan = content(await planClient.callTool({ name: 'mm_logs_settings_update_plan', arguments: {
|
|
settings: { enabled: false },
|
|
} }, undefined, { timeout: 5000 }))
|
|
assert.equal(plan.state, 'awaiting-approval')
|
|
const approved = await boundedFetch(origin + '/api/mcp/plans/' + plan.planId + '/decision', {
|
|
method: 'POST', headers: { authorization: 'Bearer ' + planAccess.ownerJwt, 'content-type': 'application/json' },
|
|
body: JSON.stringify({ approve: true }),
|
|
})
|
|
assert.equal(approved.status, 200)
|
|
assert.deepEqual(await approved.json(), { ok: true })
|
|
await control('revoke-approved-plan-owner')
|
|
await denied(() => planClient.callTool({ name: 'mm_change_apply', arguments: {
|
|
planId: plan.planId, idempotencyKey: randomUUID(),
|
|
} }, undefined, { timeout: 5000 }))
|
|
cases.push('signed portal revocation invalidates the exact approved log settings plan before apply')
|
|
} finally { await planClient.close().catch(() => {}) }
|
|
process.send({ kind: 'result', result: { cases: cases.length, names: cases, externalClientPid: process.pid, serverPid: Number(process.env.OWN_SERVER_PID), bridgePid,
|
|
sdkOnlyExternalClient: true, maxObservedHttpResponseBytes: maxResponseBytes, clientResponseLimitBytes: responseLimitBytes, stdioSdkBufferLimitBytes: 131072,
|
|
discoveryByTransport, discoveryStdioSdkBufferLimitBytes: responseLimitBytes,
|
|
actualPublicStringLimitObserved: 16000, wholeGenericResultEnvelopeLimitAccepted: false,
|
|
syntheticPortalJwtAndConsent: true, liveAuthPortal: false, livePortalSender: false, endUserUi: false, wholeExternalDirectionComplete: false } })
|
|
} catch (error) { process.send?.({ kind: 'failure', phase, errorType: error?.name ?? 'Error', completedCases: cases }); process.exitCode = 1 }
|
|
finally { closing = true; await closeClients(); process.disconnect?.() }
|