mindspace: close authority boundaries
This commit is contained in:
@@ -3,6 +3,16 @@ import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MCP_TOOL_METHODS = Object.freeze({
|
||||
create_dir: 'createDirectory',
|
||||
edit_file: 'editFile',
|
||||
generate_long_image: 'generateLongImage',
|
||||
list_dir: 'listDirectory',
|
||||
publish_page: 'publishPage',
|
||||
read_file: 'readFile',
|
||||
write_binary_file: 'writeBinaryFile',
|
||||
write_file: 'writeFile',
|
||||
});
|
||||
|
||||
async function loadContract(env = process.env) {
|
||||
const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source');
|
||||
@@ -11,19 +21,48 @@ async function loadContract(env = process.env) {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMcpScopedTokenVerifier(env = process.env) {
|
||||
const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source');
|
||||
return import(
|
||||
pathToFileURL(path.join(memindRoot, 'mindspace-mcp-scoped-token.mjs')).href
|
||||
);
|
||||
}
|
||||
|
||||
function parseBearerToken(headers) {
|
||||
const auth = String(headers.authorization ?? '');
|
||||
if (!auth.startsWith('Bearer ')) return '';
|
||||
return auth.slice('Bearer '.length).trim();
|
||||
}
|
||||
|
||||
async function readJsonBody(req) {
|
||||
async function readJsonBody(
|
||||
req,
|
||||
{ maxBytes = 32 * 1024 * 1024 } = {},
|
||||
) {
|
||||
const chunks = [];
|
||||
let receivedBytes = 0;
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
const buffer = Buffer.isBuffer(chunk)
|
||||
? chunk
|
||||
: Buffer.from(chunk);
|
||||
receivedBytes += buffer.length;
|
||||
if (receivedBytes > maxBytes) {
|
||||
throw Object.assign(
|
||||
new Error('MindSpace RPC request body is too large'),
|
||||
{ code: 'request_body_too_large' },
|
||||
);
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
const text = Buffer.concat(chunks).toString('utf8').trim();
|
||||
return text ? JSON.parse(text) : {};
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw Object.assign(
|
||||
new Error('MindSpace RPC request body must be valid JSON'),
|
||||
{ code: 'invalid_json_body' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function reviveRpcValue(value) {
|
||||
@@ -67,17 +106,39 @@ function resolveRpcErrorStatus(error) {
|
||||
case 'publication_owner_not_found':
|
||||
case 'page_not_found':
|
||||
case 'category_not_found':
|
||||
case 'static_page_not_found':
|
||||
return 404;
|
||||
case 'publication_login_required':
|
||||
return 401;
|
||||
case 'publication_password_required':
|
||||
case 'mcp_tool_forbidden':
|
||||
case 'mcp_scope_mismatch':
|
||||
return 403;
|
||||
case 'invalid_mcp_token':
|
||||
case 'expired_mcp_token':
|
||||
return 401;
|
||||
case 'invalid_input':
|
||||
case 'invalid_page_input':
|
||||
case 'invalid_page_path':
|
||||
case 'empty_page_content':
|
||||
case 'invalid_publish_input':
|
||||
case 'invalid_state_transition':
|
||||
case 'slug_conflict':
|
||||
case 'security_ack_required':
|
||||
case 'invalid_workspace_tool_input':
|
||||
case 'invalid_workspace_ref':
|
||||
case 'invalid_workspace_path':
|
||||
case 'invalid_workspace_package_scope':
|
||||
case 'invalid_workspace_user_scope':
|
||||
case 'invalid_workspace_page_path':
|
||||
case 'invalid_workspace_binary':
|
||||
case 'invalid_json_body':
|
||||
return 400;
|
||||
case 'workspace_entry_not_found':
|
||||
return 404;
|
||||
case 'workspace_file_too_large':
|
||||
case 'request_body_too_large':
|
||||
return 413;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
@@ -99,6 +160,35 @@ export async function createMindSpaceRpcRequestHandler({
|
||||
.trim()
|
||||
.replace(/\/+$/, '') || '/mindspace/v1/adapter';
|
||||
const authToken = String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim();
|
||||
const mcpOperationBasePath = String(
|
||||
serviceMeta.mcpOperationBasePath ??
|
||||
env.MINDSPACE_MCP_OPERATION_BASE_PATH ??
|
||||
'/mindspace/v1/mcp',
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '') ||
|
||||
'/mindspace/v1/mcp';
|
||||
const mcpTokenSecret = String(
|
||||
env.MINDSPACE_MCP_TOKEN_SECRET ?? '',
|
||||
).trim();
|
||||
if (
|
||||
mcpTokenSecret &&
|
||||
mcpTokenSecret.length < 16
|
||||
) {
|
||||
throw new Error(
|
||||
'MINDSPACE_MCP_TOKEN_SECRET must contain at least 16 characters',
|
||||
);
|
||||
}
|
||||
const mcpTokenVerifier = mcpTokenSecret
|
||||
? await loadMcpScopedTokenVerifier(env)
|
||||
: null;
|
||||
const mcpMaxBodyBytes = Math.max(
|
||||
1024,
|
||||
Number(
|
||||
env.MINDSPACE_MCP_MAX_BODY_BYTES ??
|
||||
12 * 1024 * 1024,
|
||||
) || 12 * 1024 * 1024,
|
||||
);
|
||||
|
||||
return async function handleMindSpaceRpc(req, res) {
|
||||
try {
|
||||
@@ -110,6 +200,9 @@ export async function createMindSpaceRpcRequestHandler({
|
||||
adapterKind: adapter.kind,
|
||||
implementationStatus: adapter.implementationStatus,
|
||||
operationBasePath,
|
||||
mcpOperationBasePath,
|
||||
mcpScopedToolsEnabled:
|
||||
Boolean(mcpTokenSecret),
|
||||
backgroundJobsManagedLocally: adapter.kind === 'local',
|
||||
});
|
||||
}
|
||||
@@ -119,6 +212,101 @@ export async function createMindSpaceRpcRequestHandler({
|
||||
bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS,
|
||||
});
|
||||
}
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname.startsWith(
|
||||
`${mcpOperationBasePath}/`,
|
||||
)
|
||||
) {
|
||||
if (!mcpTokenSecret) {
|
||||
return json(res, 503, {
|
||||
message:
|
||||
'MindSpace MCP scoped tokens are not configured',
|
||||
});
|
||||
}
|
||||
const segments = url.pathname
|
||||
.slice(
|
||||
mcpOperationBasePath.length + 1,
|
||||
)
|
||||
.split('/')
|
||||
.filter(Boolean);
|
||||
if (segments.length !== 1) {
|
||||
return json(res, 400, {
|
||||
message:
|
||||
'Expected /mindspace/v1/mcp/:tool',
|
||||
});
|
||||
}
|
||||
const tool = decodeURIComponent(
|
||||
segments[0],
|
||||
);
|
||||
const method = MCP_TOOL_METHODS[tool];
|
||||
if (
|
||||
!method ||
|
||||
typeof adapter?.workspaceToolService?.[
|
||||
method
|
||||
] !== 'function'
|
||||
) {
|
||||
return json(res, 404, {
|
||||
message: `Unknown MCP tool: ${tool}`,
|
||||
});
|
||||
}
|
||||
const claims =
|
||||
mcpTokenVerifier.verifyMindSpaceMcpScopedToken({
|
||||
token: parseBearerToken(
|
||||
req.headers,
|
||||
),
|
||||
secret: mcpTokenSecret,
|
||||
tool,
|
||||
});
|
||||
const body = await readJsonBody(req, {
|
||||
maxBytes: mcpMaxBodyBytes,
|
||||
});
|
||||
const suppliedInput =
|
||||
body?.arguments &&
|
||||
typeof body.arguments === 'object'
|
||||
? reviveRpcValue(body.arguments)
|
||||
: {};
|
||||
for (const [
|
||||
field,
|
||||
expected,
|
||||
] of Object.entries({
|
||||
userId: claims.userId,
|
||||
sessionId: claims.sessionId,
|
||||
packageId: claims.packageId,
|
||||
workspaceRef:
|
||||
claims.workspaceRef,
|
||||
})) {
|
||||
const supplied = String(
|
||||
suppliedInput?.[field] ?? '',
|
||||
).trim();
|
||||
if (
|
||||
supplied &&
|
||||
supplied !== expected
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`${field} does not match the scoped MCP token`,
|
||||
),
|
||||
{ code: 'mcp_scope_mismatch' },
|
||||
);
|
||||
}
|
||||
}
|
||||
const result =
|
||||
await adapter.workspaceToolService[
|
||||
method
|
||||
]({
|
||||
...suppliedInput,
|
||||
userId: claims.userId,
|
||||
sessionId: claims.sessionId,
|
||||
packageId: claims.packageId,
|
||||
workspaceRef:
|
||||
claims.workspaceRef,
|
||||
});
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
result,
|
||||
});
|
||||
}
|
||||
if (req.method !== 'POST' || !url.pathname.startsWith(`${operationBasePath}/`)) {
|
||||
return json(res, 404, { message: 'Not found' });
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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,
|
||||
@@ -15,6 +23,175 @@ function createStubAdapter() {
|
||||
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 }; },
|
||||
@@ -31,8 +208,12 @@ function createStubAdapter() {
|
||||
async createUpload() { return { ok: true }; },
|
||||
async deleteAsset() { return { ok: true }; },
|
||||
async listAssets() { return []; },
|
||||
async readAsset() { return { ok: true }; },
|
||||
async readPublicAsset() { return { ok: true }; },
|
||||
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 }; },
|
||||
@@ -148,6 +329,107 @@ test('health and contract endpoints are exposed without auth', async () => {
|
||||
assert.equal(health.body.ok, true);
|
||||
assert.equal(contract.statusCode, 200);
|
||||
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 () => {
|
||||
@@ -199,6 +481,39 @@ test('rpc invocation revives JSON-serialized buffers before dispatch', async ()
|
||||
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 () => {
|
||||
@@ -247,3 +562,315 @@ test('rpc maps publication_login_required to 401 with error code', async () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,7 +54,6 @@ async function loadMemindModules(memindRoot) {
|
||||
config,
|
||||
conversationMemory,
|
||||
localServerAdapter,
|
||||
publicHtmlHelpers,
|
||||
runtimeConfig,
|
||||
sessionSnapshot,
|
||||
userAuth,
|
||||
@@ -67,7 +66,6 @@ async function loadMemindModules(memindRoot) {
|
||||
importFromMemindRoot(memindRoot, 'mindspace-config.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'conversation-memory.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'mindspace-local-server-adapter.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'mindspace-conversation-package-public-html.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'mindspace-runtime-config.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'session-snapshot.mjs'),
|
||||
importFromMemindRoot(memindRoot, 'user-auth.mjs'),
|
||||
@@ -81,7 +79,6 @@ async function loadMemindModules(memindRoot) {
|
||||
...config,
|
||||
...conversationMemory,
|
||||
...localServerAdapter,
|
||||
...publicHtmlHelpers,
|
||||
...runtimeConfig,
|
||||
...sessionSnapshot,
|
||||
...userAuth,
|
||||
@@ -137,7 +134,6 @@ export async function bootstrapMindSpaceService({
|
||||
createSessionSnapshotService,
|
||||
createUserAuth,
|
||||
createExperienceService,
|
||||
registerPublicHtmlArtifactsForConversationPackage,
|
||||
resolveMindSpaceServerRuntimeOptions,
|
||||
startWorkspaceAssetSyncWatcher,
|
||||
startWorkspaceThumbnailWatcher,
|
||||
@@ -172,13 +168,6 @@ export async function bootstrapMindSpaceService({
|
||||
resolveUserIdForAgentSession: (sessionId) => resolveUserIdForAgentSession(pool, sessionId),
|
||||
resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId),
|
||||
resolveSessionSnapshot: (sessionId) => sessionSnapshotService.get(sessionId),
|
||||
registerPublicHtmlArtifactsForConversation: (payload) =>
|
||||
registerPublicHtmlArtifactsForConversationPackage({
|
||||
conversationPackageRegistry: adapter.conversationPackageRegistry,
|
||||
h5Root: standaloneDataRoot,
|
||||
env,
|
||||
...payload,
|
||||
}),
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user