import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { mintMindSpaceMcpScopedToken, } from '../mindspace-mcp-scoped-token.mjs'; import { createMindSpaceRpcRequestHandler } from './mindspace-rpc-server.mjs'; function createStubAdapter() { const calls = { writeUploadContent: [], registerChatDocxArtifact: [], workspaceTools: [], }; return { calls, kind: 'local', implementationStatus: 'ready', serviceFacade: { async prepareConversationPackageRead() { return []; }, }, chatSaveService: { async createSharedHtml() { return { publicUrl: 'https://example.com/shared.html', filename: 'shared.html', }; }, async materializeWorkspaceHtml() { return { changed: false }; }, async readWorkspaceHtml() { return { html: '', relativePath: 'public/page.html', }; }, async ensurePreviewThumbnail() { return { ready: true }; }, async renderPreviewThumbnailSvg() { return { svg: '', contentType: 'image/svg+xml; charset=utf-8' }; }, }, conversationArtifactService: { async registerChatDocxArtifact(...args) { calls.registerChatDocxArtifact.push(args); return { id: 'docx-1' }; }, async registerPublicHtmlArtifacts() { return [{ artifactId: 'html-1' }]; }, async registerPublishedLongImageArtifact() { return { id: 'image-1' }; }, async registerWorkspaceFileArtifact() { return { id: 'file-1' }; }, }, publicFinishService: { async materializeSessionEvent() { return { materialized: [], skipped: [] }; }, async syncAfterFinish() { return { materialized: [], skipped: [], publicHtmlRelativePaths: [], }; }, async preparePageDataAfterFinish() { return { autoBind: { bound: [], skipped: [], errors: [], }, evaluation: { structuralPageData: false, relevantFiles: [], }, }; }, async prepareWechatPageDataDelivery() { return { outcome: { action: 'skip' }, deliveryArtifacts: [], }; }, async prepareWechatHtmlDelivery() { return { confirmedArtifacts: [], }; }, async ensureWechatFreshPageThumbnails() { return { ok: true }; }, }, workspacePublicationDeliveryService: { async listRecentlyModifiedPublicHtml() { return { relativePaths: [] }; }, async readOwnerPublicAsset() { return { action: 'not_found' }; }, async renderLongImage() { return { bodyBase64: '', servedName: 'page.long.png', }; }, async resolveWorkspaceRequest() { return { action: 'not_found' }; }, async validateRunDeliverables() { return { errors: [] }; }, }, workspacePageDeliveryService: { async syncAndDeliver() { return { sync: {} }; }, }, workspaceToolService: { async createDirectory() { calls.workspaceTools.push([ 'createDirectory', ...arguments, ]); return { relativePath: 'public/assets' }; }, async editFile() { calls.workspaceTools.push([ 'editFile', ...arguments, ]); return { relativePath: 'public/page.html' }; }, async generateLongImage() { calls.workspaceTools.push([ 'generateLongImage', ...arguments, ]); return { relativePath: 'public/page.long.png', }; }, async listDirectory() { calls.workspaceTools.push([ 'listDirectory', ...arguments, ]); return { entries: [] }; }, async publishPage() { calls.workspaceTools.push([ 'publishPage', ...arguments, ]); return { canonicalUrl: 'https://example.com/MindSpace/user-1/public/page.html', }; }, async readFile() { calls.workspaceTools.push([ 'readFile', ...arguments, ]); return { content: 'hello' }; }, async writeFile() { calls.workspaceTools.push([ 'writeFile', ...arguments, ]); return { relativePath: 'public/page.html' }; }, async writeBinaryFile() { calls.workspaceTools.push([ 'writeBinaryFile', ...arguments, ]); return { relativePath: 'public/report.docx', }; }, }, conversationPackageRegistry: { async ensurePackage() { return { ok: true }; }, async putObjectForSession() { return { ok: true }; }, async recordArtifact() { return { ok: true }; }, async readArtifactObject() { return { ok: true }; }, async readManifestForSession() { return { ok: true }; }, async writeManifestForSession() { return { ok: true }; }, }, assetService: { async cancelUpload() { return { ok: true }; }, async claimUploadArtifactsForConversation() { return []; }, async completeUpload() { return { ok: true }; }, async createChatAsset() { return { ok: true }; }, async createUpload() { return { ok: true }; }, async deleteAsset() { return { ok: true }; }, async listAssets() { return []; }, async readAssetContent() { return { ok: true, bodyBase64: '' }; }, async readPublicAssetContent() { return { ok: true, bodyBase64: '' }; }, async renderAssetPreview() { return { preview: true }; }, async renderAssetThumbnail() { return { thumbnail: true }; }, async syncWorkspaceAssets() { return { synced: true }; }, async writeUploadContent(...args) { calls.writeUploadContent.push(args); return { isBuffer: Buffer.isBuffer(args[2]), size: args[2]?.length ?? 0 }; }, async expireStaleUploads() { return { expired: 0 }; }, }, pageService: { async createFromChat() { return { ok: true }; }, async createPage() { return { ok: true }; }, async deletePage() { return { ok: true }; }, async findPageByRelativePath() { return null; }, async findPlazaContextByWorkspacePath() { return null; }, async findPageBySourceAsset() { return null; }, async getDeletePreview() { return { ok: true }; }, async getPage() { return { ok: true }; }, async listPages() { return []; }, async listVersions() { return []; }, async localizePrivateResources() { return { ok: true }; }, async redactPage() { return { ok: true }; }, async regenerateThumbnail() { return { ok: true }; }, async renderDraftPreview() { return { html: '' }; }, async renderPreview() { return { html: '' }; }, async renderThumbnail() { return { ok: true }; }, async rewriteHtmlDownloadLinksForPage() { return { ok: true }; }, async updatePage() { return { ok: true }; }, async uploadThumbnail() { return { ok: true }; }, }, pageLiveEditService: { async applyAgentPatch() { return { ok: true }; }, async bindSession() { return { ok: true }; }, async getRevisionSnapshot() { return { ok: true }; }, }, assetAgentService: { async applyAgentDelete() { return { ok: true }; }, }, publicationService: { async check() { return { ok: true }; }, async cleanupExpiredUnconfirmedPublications() { return { cleaned: 0 }; }, async getCurrent() { return null; }, async refreshOnlinePublicationHtml() { return null; }, async getPublicHomepage() { return null; }, async getStats() { return { ok: true }; }, async offline() { return { ok: true }; }, async publish() { return { ok: true }; }, async resolvePrivateLink() { return { ok: true }; }, async resolvePublic() { return { ok: true }; }, async updatePublicationStatus() { return { ok: true }; }, }, cleanupService: { async listCandidates() { return []; }, async runCleanup() { return { ok: true }; }, }, agentJobService: { async cancelJob() { return { ok: true }; }, async claimJob() { return { ok: true }; }, async claimNextJob() { return null; }, async completeJob() { return { ok: true }; }, async createJob() { return { ok: true }; }, async getAssetForJob() { return { ok: true }; }, async getJob() { return { ok: true }; }, async heartbeat() { return { ok: true }; }, async listJobs() { return []; }, async reapStaleJobs() { return 0; }, async retryJob() { return { ok: true }; }, }, }; } async function runRequest(handler, { method = 'GET', path = '/', headers = {}, body = '' } = {}) { const chunks = []; const req = { method, url: path, headers, async *[Symbol.asyncIterator]() { if (body) yield Buffer.from(body); }, }; const res = { statusCode: 0, headers: {}, writeHead(statusCode, responseHeaders) { this.statusCode = statusCode; this.headers = responseHeaders; }, end(chunk) { if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); }, }; await handler(req, res); return { statusCode: res.statusCode, headers: res.headers, body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null, }; } test('health and contract endpoints are exposed without auth', async () => { const handler = await createMindSpaceRpcRequestHandler({ adapter: createStubAdapter(), env: { MINDSPACE_MEMIND_ROOT: '..', }, serviceMeta: { buildId: 'mindspace-test-build', gitSha: 'abc123', builtAt: '2026-07-27T00:00:00.000Z', }, }); const health = await runRequest(handler, { path: '/health' }); const contract = await runRequest(handler, { path: '/mindspace/v1/contract' }); assert.equal(health.statusCode, 200); assert.equal(health.body.ok, true); assert.equal(health.body.contractVersion, 2); assert.equal(health.body.buildId, 'mindspace-test-build'); assert.equal(contract.statusCode, 200); assert.equal(contract.body.contractVersion, 2); assert.equal(contract.body.service, 'mindspace-service'); assert.equal(contract.body.buildId, 'mindspace-test-build'); assert.equal(contract.body.gitSha, 'abc123'); assert.ok( contract.body.requiredCapabilities.includes( 'workspace-publication-delivery-authority', ), ); assert.ok( contract.body.requiredBindings.workspaceToolService.includes( 'publishPage', ), ); assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview')); assert.ok( contract.body.bindings.chatSaveService.includes( 'materializeWorkspaceHtml', ), ); assert.ok( contract.body.bindings.conversationArtifactService .includes('registerPublicHtmlArtifacts'), ); assert.ok( contract.body.bindings.publicFinishService.includes( 'syncAfterFinish', ), ); assert.ok( contract.body.bindings.publicFinishService.includes( 'preparePageDataAfterFinish', ), ); assert.ok( contract.body.bindings.publicFinishService.includes( 'prepareWechatPageDataDelivery', ), ); assert.ok( contract.body.bindings.publicFinishService.includes( 'prepareWechatHtmlDelivery', ), ); assert.ok( contract.body.bindings.publicFinishService.includes( 'ensureWechatFreshPageThumbnails', ), ); assert.ok( contract.body.bindings .workspacePublicationDeliveryService .includes('resolveWorkspaceRequest'), ); assert.ok( contract.body.bindings .workspaceToolService .includes('writeBinaryFile'), ); assert.ok( contract.body.bindings .workspaceToolService .includes('generateLongImage'), ); }); test('RPC startup rejects an undersized MCP signing secret', async () => { await assert.rejects( createMindSpaceRpcRequestHandler({ adapter: createStubAdapter(), env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_MCP_TOKEN_SECRET: 'too-short', }, }), /at least 16 characters/, ); }); test('RPC startup does not require MCP token verifier when scoped MCP is disabled', async (t) => { const memindRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'mindspace-rpc-no-mcp-'), ); t.after(() => { fs.rmSync(memindRoot, { recursive: true, force: true, }); }); fs.copyFileSync( path.resolve( 'mindspace-server-adapter-contract.mjs', ), path.join( memindRoot, 'mindspace-server-adapter-contract.mjs', ), ); const handler = await createMindSpaceRpcRequestHandler({ adapter: createStubAdapter(), env: { MINDSPACE_MEMIND_ROOT: memindRoot, }, }); const health = await runRequest(handler, { method: 'GET', path: '/health', }); assert.equal(health.statusCode, 200); assert.equal( health.body.mcpScopedToolsEnabled, false, ); }); test('rpc invocation enforces auth token and dispatches args to adapter method', async () => { const handler = await createMindSpaceRpcRequestHandler({ adapter: createStubAdapter(), env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_REMOTE_AUTH_TOKEN: 'secret-token', }, }); const unauthorized = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/assetService/renderAssetPreview', body: JSON.stringify({ args: [{ assetId: 'a1' }] }), }); const authorized = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/assetService/renderAssetPreview', headers: { authorization: 'Bearer secret-token', }, body: JSON.stringify({ args: [{ assetId: 'a1' }] }), }); assert.equal(unauthorized.statusCode, 401); assert.equal(authorized.statusCode, 200); assert.deepEqual(authorized.body, { preview: true }); }); test('rpc invocation revives JSON-serialized buffers before dispatch', async () => { const adapter = createStubAdapter(); const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', }, }); const payload = Buffer.from('asset parity payload'); const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/assetService/writeUploadContent', body: JSON.stringify({ args: ['user-1', 'upload-1', payload] }), }); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { isBuffer: true, size: payload.length }); assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true); }); test('rpc revives generated artifact bodies before dispatching to MindSpace', async () => { const adapter = createStubAdapter(); const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', }, }); const payload = { userId: 'user-1', sessionId: 'session-1', messageId: 'message-1', filename: 'chat.docx', body: Buffer.from('docx payload'), }; const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/conversationArtifactService/registerChatDocxArtifact', body: JSON.stringify({ args: [payload] }), }); assert.equal(response.statusCode, 200); assert.deepEqual(response.body, { id: 'docx-1' }); assert.equal( Buffer.isBuffer( adapter.calls.registerChatDocxArtifact[0][0].body, ), true, ); }); test('rpc maps publication_not_found to 404 with error code', async () => { const adapter = createStubAdapter(); adapter.publicationService.resolvePublic = async () => { const error = new Error('公开页面不存在'); error.code = 'publication_not_found'; throw error; }; const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', }, }); const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/publicationService/resolvePublic', body: JSON.stringify({ args: ['john', 'missing-page', null, null, {}] }), }); assert.equal(response.statusCode, 404); assert.equal(response.body.code, 'publication_not_found'); assert.match(response.body.message, /公开页面不存在/); }); test('rpc maps publication_login_required to 401 with error code', async () => { const adapter = createStubAdapter(); adapter.publicationService.resolvePublic = async () => { const error = new Error('登录后才能访问此页面'); error.code = 'publication_login_required'; throw error; }; const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', }, }); const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/publicationService/resolvePublic', body: JSON.stringify({ args: ['john', 'login-page', null, null, {}] }), }); assert.equal(response.statusCode, 401); assert.equal(response.body.code, 'publication_login_required'); }); test('rpc maps chat save validation errors to 400 with error code', async () => { const adapter = createStubAdapter(); adapter.chatSaveService.materializeWorkspaceHtml = async () => { const error = new Error('无效的页面路径'); error.code = 'invalid_page_path'; throw error; }; const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', }, }); const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/adapter/chatSaveService/materializeWorkspaceHtml', body: JSON.stringify({ args: [{ userId: 'user-1', html: 'unsafe', relativePath: '../unsafe.html', }], }), }); assert.equal(response.statusCode, 400); assert.equal(response.body.code, 'invalid_page_path'); }); test('scoped MCP RPC injects token claims and dispatches only allowed workspace tools', async () => { const adapter = createStubAdapter(); const secret = 'mindspace-mcp-test-secret-1234'; const token = mintMindSpaceMcpScopedToken({ secret, userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', tools: ['write_file'], }); const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_MCP_TOKEN_SECRET: secret, }, }); const response = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/mcp/write_file', headers: { authorization: `Bearer ${token}`, }, body: JSON.stringify({ arguments: { path: 'public/page.html', content: '', }, }), }); assert.equal(response.statusCode, 200); assert.equal(response.body.ok, true); assert.deepEqual( adapter.calls.workspaceTools[0], [ 'writeFile', { path: 'public/page.html', content: '', userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', }, ], ); }); test('scoped MCP RPC dispatches binary writes and long-image generation to MindSpace', async () => { const adapter = createStubAdapter(); const secret = 'mindspace-mcp-test-secret-1234'; const token = mintMindSpaceMcpScopedToken({ secret, userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', tools: [ 'generate_long_image', 'write_binary_file', ], }); const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_MCP_TOKEN_SECRET: secret, }, }); const headers = { authorization: `Bearer ${token}`, }; const binary = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/mcp/write_binary_file', headers, body: JSON.stringify({ arguments: { path: 'public/report.docx', bodyBase64: Buffer.from('PK').toString( 'base64', ), }, }), }); const longImage = await runRequest( handler, { method: 'POST', path: '/mindspace/v1/mcp/generate_long_image', headers, body: JSON.stringify({ arguments: { html_path: 'public/page.html', output_path: 'public/page.long.png', }, }), }, ); assert.equal(binary.statusCode, 200); assert.equal(longImage.statusCode, 200); assert.deepEqual( adapter.calls.workspaceTools.map( ([method]) => method, ), [ 'writeBinaryFile', 'generateLongImage', ], ); assert.equal( adapter.calls.workspaceTools[0][1] .packageId, 'cp_session-1', ); }); test('scoped MCP RPC rejects tools and scope overrides outside the token', async () => { const adapter = createStubAdapter(); const secret = 'mindspace-mcp-test-secret-1234'; const readToken = mintMindSpaceMcpScopedToken({ secret, userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', tools: ['read_file'], }); const writeToken = mintMindSpaceMcpScopedToken({ secret, userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', tools: ['write_file'], }); const handler = await createMindSpaceRpcRequestHandler({ adapter, env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_MCP_TOKEN_SECRET: secret, }, }); const forbidden = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/mcp/write_file', headers: { authorization: `Bearer ${readToken}`, }, body: JSON.stringify({ arguments: { path: 'public/page.html', content: '', }, }), }); const scopeMismatch = await runRequest( handler, { method: 'POST', path: '/mindspace/v1/mcp/write_file', headers: { authorization: `Bearer ${writeToken}`, }, body: JSON.stringify({ arguments: { workspaceRef: 'mindspace://users/user-2/workspace', path: 'public/page.html', content: '', }, }), }, ); assert.equal(forbidden.statusCode, 403); assert.equal( forbidden.body.code, 'mcp_tool_forbidden', ); assert.equal(scopeMismatch.statusCode, 403); assert.equal( scopeMismatch.body.code, 'mcp_scope_mismatch', ); assert.equal( adapter.calls.workspaceTools.length, 0, ); }); test('scoped MCP RPC bounds request bodies and reports invalid JSON as client errors', async () => { const secret = 'mindspace-mcp-test-secret-1234'; const token = mintMindSpaceMcpScopedToken({ secret, userId: 'user-1', sessionId: 'session-1', packageId: 'cp_session-1', workspaceRef: 'mindspace://users/user-1/workspace', tools: ['write_file'], }); const handler = await createMindSpaceRpcRequestHandler({ adapter: createStubAdapter(), env: { MINDSPACE_MEMIND_ROOT: '..', MINDSPACE_MCP_TOKEN_SECRET: secret, MINDSPACE_MCP_MAX_BODY_BYTES: '1024', }, }); const headers = { authorization: `Bearer ${token}`, }; const tooLarge = await runRequest(handler, { method: 'POST', path: '/mindspace/v1/mcp/write_file', headers, body: JSON.stringify({ arguments: { path: 'oa/large.txt', content: 'x'.repeat(1200), }, }), }); const invalidJson = await runRequest( handler, { method: 'POST', path: '/mindspace/v1/mcp/write_file', headers, body: '{invalid', }, ); assert.equal(tooLarge.statusCode, 413); assert.equal( tooLarge.body.code, 'request_body_too_large', ); assert.equal(invalidJson.statusCode, 400); assert.equal( invalidJson.body.code, 'invalid_json_body', ); });