Files
memind/mindspace-service/mindspace-rpc-server.mjs

396 lines
11 KiB
JavaScript

import http from 'node:http';
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');
return import(
pathToFileURL(path.join(memindRoot, 'mindspace-server-adapter-contract.mjs')).href
);
}
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,
{ maxBytes = 32 * 1024 * 1024 } = {},
) {
const chunks = [];
let receivedBytes = 0;
for await (const chunk of req) {
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();
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) {
if (
value &&
typeof value === 'object' &&
value.type === 'Buffer' &&
Array.isArray(value.data)
) {
return Buffer.from(value.data);
}
if (Array.isArray(value)) return value.map((item) => reviveRpcValue(item));
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, reviveRpcValue(item)]),
);
}
return value;
}
function json(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function serializeRpcError(error) {
return {
message: error instanceof Error ? error.message : String(error),
code: error?.code ?? 'internal_error',
...(error?.details !== undefined ? { details: error.details } : {}),
};
}
function resolveRpcErrorStatus(error) {
switch (error?.code) {
case 'publication_not_found':
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;
}
}
export async function createMindSpaceRpcRequestHandler({
adapter,
env = process.env,
logger = console,
serviceMeta = {},
} = {}) {
const {
createMindSpaceServerAdapterContractPayload,
MINDSPACE_SERVER_ADAPTER_BINDINGS,
MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
} = await loadContract(env);
const operationBasePath = String(
serviceMeta.operationBasePath ?? env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter',
)
.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 {
const url = new URL(req.url, 'http://127.0.0.1');
if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, {
ok: true,
service: 'mindspace-service',
adapterKind: adapter.kind,
implementationStatus: adapter.implementationStatus,
contractVersion:
MINDSPACE_SERVER_ADAPTER_CONTRACT_VERSION,
buildId:
serviceMeta.buildId ??
env.MINDSPACE_SERVICE_BUILD_ID ??
null,
gitSha:
serviceMeta.gitSha ??
env.MINDSPACE_SERVICE_GIT_SHA ??
null,
operationBasePath,
mcpOperationBasePath,
mcpScopedToolsEnabled:
Boolean(mcpTokenSecret),
backgroundJobsManagedLocally: adapter.kind === 'local',
});
}
if (req.method === 'GET' && url.pathname === '/mindspace/v1/contract') {
return json(
res,
200,
createMindSpaceServerAdapterContractPayload({
adapter,
serviceMeta,
env,
}),
);
}
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' });
}
if (authToken && parseBearerToken(req.headers) !== authToken) {
return json(res, 401, { message: 'Unauthorized' });
}
const segments = url.pathname.slice(operationBasePath.length + 1).split('/').filter(Boolean);
if (segments.length !== 2) {
return json(res, 400, { message: 'Expected /:binding/:method' });
}
const [bindingKey, method] = segments.map(decodeURIComponent);
const service = adapter?.[bindingKey];
const allowedMethods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey];
if (!service || !allowedMethods) {
return json(res, 404, { message: `Unknown binding: ${bindingKey}` });
}
if (!allowedMethods.includes(method)) {
return json(res, 404, { message: `Unknown method: ${bindingKey}.${method}` });
}
const body = await readJsonBody(req);
const args = Array.isArray(body?.args) ? reviveRpcValue(body.args) : [];
const result = await service[method](...args);
return json(res, 200, result);
} catch (error) {
const statusCode = resolveRpcErrorStatus(error);
if (statusCode >= 500) {
logger.error?.('[MindSpace RPC Error]', error);
}
return json(res, statusCode, serializeRpcError(error));
}
};
}
export async function startMindSpaceRpcServer({
adapter,
env = process.env,
logger = console,
serviceMeta = {},
} = {}) {
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env,
logger,
serviceMeta,
});
const host = env.MINDSPACE_SERVICE_HOST ?? '127.0.0.1';
const port = Number(env.MINDSPACE_SERVICE_PORT ?? 8082);
const server = http.createServer((req, res) => {
void handler(req, res);
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, host, () => resolve());
});
return {
server,
host,
port,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => {
if (error) reject(error);
else resolve();
});
}),
};
}