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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user