blob: dae84fd6c41279e8a393903b7a011dbbebff4195 [file]
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import type {
McpBoundTool,
McpCallResult,
McpToolBinding,
McpToolDescriptor,
} from '@maka/core/mcp';
import { createCredentialMcpOAuthStorage, McpClientManager } from '@maka/mcp';
import { createFileCredentialStore, normalizeMcpConfig } from '@maka/storage';
import {
connectRemoteRuntimeHost,
loadOrCreateRuntimeHostClientInstanceId,
remoteRuntimeHostUnavailableError,
RuntimeHostPermanentReconnectError,
startRuntimeHostCapabilityProviderService,
type ClientCapabilityProvider,
type RuntimeHostConnection,
} from '@maka/runtime-host/client';
import {
CLIENT_CAPABILITY_MAX_TOOLS,
CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER,
decodeClientCapabilityReplaceInput,
INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
RUNTIME_HOST_COMPATIBILITY_EPOCH,
RUNTIME_HOST_PROTOCOL_VERSION,
type ClientCapabilityCallResult,
type ClientCapabilityOffer,
} from '@maka/runtime-host/protocol';
import { runRuntimeHostProcessLifecycle } from '@maka/runtime-host/server';
const DEFAULT_CREDENTIAL_ENV = 'MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL';
const CAPABILITY_VERSION = '0';
const MAX_MCP_CONFIG_BYTES = 1_048_576;
const MCP_RECONNECT_INTERVAL_MS = 5_000;
export interface RuntimeHostCapabilityProviderCliOptions {
readonly url: string;
readonly mcpConfigPath: string;
readonly expectedRootId: string;
readonly credentialEnv?: string;
readonly clientIdentityPath?: string;
readonly defaultClientIdentityRoot?: string;
}
export async function runRuntimeHostCapabilityProviderCli(
options: RuntimeHostCapabilityProviderCliOptions,
): Promise<number> {
const configPath = resolve(options.mcpConfigPath);
const identityPath = resolve(
options.clientIdentityPath ??
defaultProviderClientIdentityPath(options.url, configPath, options.defaultClientIdentityRoot),
);
const credentialEnv = options.credentialEnv ?? DEFAULT_CREDENTIAL_ENV;
const credential = process.env[credentialEnv];
if (!credential)
throw new Error(`Runtime Host access credential is missing from ${credentialEnv}`);
const configText = await readFile(configPath, 'utf8');
if (Buffer.byteLength(configText, 'utf8') > MAX_MCP_CONFIG_BYTES) {
throw new Error('MCP config exceeds 1 MiB');
}
const config = normalizeMcpConfig(JSON.parse(configText));
const clientInstanceId = await loadOrCreateRuntimeHostClientInstanceId(identityPath);
const manager = new McpClientManager({
clientName: 'maka-capability-provider',
excludedStdioEnvironmentKeys: [credentialEnv],
// Same credential store Desktop writes (credentials.json beside the
// config): without it this process is credential-blind — every remote
// OAuth server 401s forever while forgetServerCredentials reports
// success and erases nothing.
oauthStorage: createCredentialMcpOAuthStorage(createFileCredentialStore(dirname(configPath))),
});
await manager.sync(config);
let service: Awaited<ReturnType<typeof startRuntimeHostCapabilityProviderService>> | undefined;
let publishedRevision = manager.toolSnapshot().revision;
const disposeChanges = manager.onChange(() => {
const revision = manager.toolSnapshot().revision;
if (revision === publishedRevision) return;
publishedRevision = revision;
void service?.refresh().catch(reportRefreshFailure);
});
const reconnectTimer = setInterval(() => {
for (const status of manager.statuses()) {
if (status.state !== 'disconnected' && status.state !== 'error') continue;
void manager.connect(status.serverId).catch(() => undefined);
}
}, MCP_RECONNECT_INTERVAL_MS);
reconnectTimer.unref();
try {
service = await startRuntimeHostCapabilityProviderService({
connect: (signal) =>
connectRemoteCapabilityProvider({
url: options.url,
credential,
clientInstanceId,
signal,
expectedRootId: options.expectedRootId,
}),
createProvider: () => createMcpCapabilityProvider(manager),
onReconnectError: (error) => {
process.stderr.write(
`Runtime Host capability provider reconnect failed: ${error.message}\n`,
);
},
onPublicationError: reportRefreshFailure,
onFatalError: (error) => {
process.exitCode = 1;
process.stderr.write(`Runtime Host capability provider stopped: ${error.message}\n`);
},
});
await runRuntimeHostProcessLifecycle(service, {
onReady: () => {
process.stdout.write(
`Runtime Host capability provider is connected (${manager.toolSnapshot().tools.length} MCP tools)\n`,
);
},
});
return 0;
} finally {
clearInterval(reconnectTimer);
disposeChanges();
await service?.close().catch(() => undefined);
await manager.close();
}
}
function defaultProviderClientIdentityPath(
url: string,
configPath: string,
defaultClientIdentityRoot?: string,
): string {
const identity = createHash('sha256')
.update(`runtime-host-capability-provider\0${url}\0${configPath}`)
.digest('hex')
.slice(0, 24);
const identityRoot =
defaultClientIdentityRoot ?? join(homedir(), '.maka', 'runtime-host-capability-providers');
return join(identityRoot, `${identity}.json`);
}
async function connectRemoteCapabilityProvider(input: {
readonly url: string;
readonly credential: string;
readonly clientInstanceId: string;
readonly expectedRootId: string;
readonly signal: AbortSignal;
}): Promise<RuntimeHostConnection> {
input.signal.throwIfAborted();
const connected = await connectRemoteRuntimeHost({
url: input.url,
credential: input.credential,
protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION },
compositionId: INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID,
clientInstanceId: input.clientInstanceId,
expectedRootId: input.expectedRootId,
});
if (input.signal.aborted) {
if (connected.kind === 'connected') await connected.connection.close();
input.signal.throwIfAborted();
}
if (connected.kind === 'connected') return connected.connection;
if (connected.kind === 'incompatible') {
if (connected.handshake.compatibilityEpoch < RUNTIME_HOST_COMPATIBILITY_EPOCH) {
throw new RuntimeHostPermanentReconnectError(
'The remote Runtime Host is older than this capability provider. Upgrade or restart the Runtime Host, then reconnect.',
);
}
throw new RuntimeHostPermanentReconnectError(
`Runtime Host protocol is incompatible (Host ${connected.handshake.protocolMin}-${connected.handshake.protocolMax})`,
);
}
if (connected.kind === 'unavailable') {
throw remoteRuntimeHostUnavailableError('Runtime Host', connected.reason);
}
throw new Error('Runtime Host is draining');
}
export function createMcpCapabilityProvider(
manager: Pick<McpClientManager, 'toolSnapshot' | 'callTool'>,
): ClientCapabilityProvider | undefined {
const toolSnapshot = manager.toolSnapshot();
const tools = [...toolSnapshot.tools].sort(
(left, right) =>
left.descriptor.serverId.localeCompare(right.descriptor.serverId) ||
left.descriptor.name.localeCompare(right.descriptor.name),
);
const toolCount = tools.length;
if (toolCount === 0) return undefined;
if (toolCount > CLIENT_CAPABILITY_MAX_TOOLS) {
throw new Error(
`MCP capability provider exposes ${toolCount} tools; the limit is ${CLIENT_CAPABILITY_MAX_TOOLS}`,
);
}
const projectedIdentities = new Set<string>();
const projected: Array<{
readonly source: McpBoundTool;
readonly descriptor: ReturnType<typeof projectMcpTool>;
}> = [];
for (const source of tools) {
const descriptor = projectMcpTool(
source.descriptor,
capabilityEntityId(source.descriptor.serverId),
);
const identity = `${descriptor.serverId}\0${descriptor.name}`;
if (projectedIdentities.has(identity)) {
throw new Error('MCP tools collide after Client Capability identity normalization');
}
projectedIdentities.add(identity);
projected.push({ source, descriptor });
}
const bindings = new Map<string, McpToolBinding>();
const offers: ClientCapabilityOffer[] = [];
for (let offset = 0; offset < projected.length; offset += CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER) {
const chunk = projected.slice(offset, offset + CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER);
const offerId = mcpOfferId(chunk, offset / CLIENT_CAPABILITY_MAX_TOOLS_PER_OFFER);
const servers = new Set(chunk.map(({ source }) => source.descriptor.serverId));
offers.push({
offerId,
version: CAPABILITY_VERSION,
affinity: 'session',
hostPathAccess: 'none',
label:
servers.size === 1
? `MCP: ${chunk[0]?.source.descriptor.serverId ?? 'tools'}`.slice(0, 128)
: `MCP tools (${servers.size} servers)`,
description: 'Use tools provided by connected MCP servers.',
tools: chunk.map(({ descriptor }) => descriptor),
});
for (const { source, descriptor } of chunk) {
bindings.set(
capabilityBindingKey(offerId, descriptor.serverId, descriptor.name),
source.binding,
);
}
}
const canonical = decodeClientCapabilityReplaceInput({
registrationId: '00000000-0000-4000-8000-000000000000',
offers,
});
return {
offers: () => canonical.offers,
call: async (frame, options) => {
const binding = bindings.get(
capabilityBindingKey(frame.offerId, frame.serverId, frame.toolName),
);
if (!binding) throw new Error('MCP capability is not part of the published snapshot');
await options.accept();
return projectMcpResult(
await manager.callTool(binding, frame.arguments, { signal: options.signal }),
);
},
};
}
function projectMcpTool(tool: McpToolDescriptor, wireServerId: string) {
return {
serverId: wireServerId,
name: capabilityEntityId(tool.name),
...(tool.description ? { description: tool.description } : {}),
inputSchema: structuredClone(tool.inputSchema),
...(tool.annotations ? { annotations: { ...tool.annotations } } : {}),
};
}
function capabilityEntityId(value: string): string {
if (/^[A-Za-z0-9_-]{1,128}$/u.test(value)) return value;
const label = value.replace(/[^A-Za-z0-9_-]+/gu, '_').slice(0, 103) || 'mcp';
const digest = createHash('sha256').update(value).digest('hex').slice(0, 24);
return `${label}_${digest}`;
}
function projectMcpResult(result: McpCallResult): ClientCapabilityCallResult {
return {
content: result.content.map((block) => structuredClone(block)),
...(result.structuredContent === undefined
? {}
: { structuredContent: structuredClone(result.structuredContent) }),
};
}
function mcpOfferId(tools: readonly { readonly source: McpBoundTool }[], chunk: number): string {
const hash = createHash('sha256').update(`mcp-capability-offer-v0\0${chunk}`);
for (const { source } of tools)
hash
.update('\0')
.update(source.descriptor.serverId)
.update('\0')
.update(source.descriptor.name);
return `mcp_${hash.digest('hex').slice(0, 24)}_${chunk}`;
}
function capabilityBindingKey(offerId: string, serverId: string, toolName: string): string {
return `${offerId}\0${serverId}\0${toolName}`;
}
function reportRefreshFailure(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`Runtime Host capability publication refresh failed: ${message}\n`);
}