898 lines
24 KiB
JavaScript
898 lines
24 KiB
JavaScript
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: '<html></html>',
|
|
relativePath: 'public/page.html',
|
|
};
|
|
},
|
|
async ensurePreviewThumbnail() {
|
|
return { ready: true };
|
|
},
|
|
async renderPreviewThumbnailSvg() {
|
|
return { svg: '<svg></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: '<html>unsafe</html>',
|
|
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: '<html></html>',
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(response.statusCode, 200);
|
|
assert.equal(response.body.ok, true);
|
|
assert.deepEqual(
|
|
adapter.calls.workspaceTools[0],
|
|
[
|
|
'writeFile',
|
|
{
|
|
path: 'public/page.html',
|
|
content: '<html></html>',
|
|
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: '<html></html>',
|
|
},
|
|
}),
|
|
});
|
|
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: '<html></html>',
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
|
|
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',
|
|
);
|
|
});
|