blob: 0c2ab6ba465c28a78de140eeb4ca1805e86d708e [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash } from 'node:crypto';
import { stableJsonStringify } from '@maka/core/canonical-json';
import { createCredentialMcpOAuthStorage, McpClientManager } from '@maka/mcp';
import { createFileCredentialStore } from '@maka/storage/credential-store';
import { createMcpConfigStore } from '@maka/storage/mcp-config-store';
import {
RuntimeHostOperationError,
type RuntimeHostConnectionAvailability,
type RuntimeHostReconnectingConnection,
} from '@maka/runtime-host/client';
import { createMcpCapabilityProvider } from './mcp-capability-provider.js';
import { McpCapabilityPublication } from './mcp-capability-publication.js';
/** One headless invocation owns one Session's MCP processes and publication. */
export class RuntimeHostRunMcp {
readonly #connection: RuntimeHostReconnectingConnection;
readonly #workspaceRoot: string;
readonly #manager: McpClientManager;
readonly #publication: McpCapabilityPublication;
readonly #unsubscribeManager: () => void;
readonly #unsubscribeConnection: () => void;
#availability: RuntimeHostConnectionAvailability | undefined;
#sessionId: string | undefined;
#sessionConfigurationId: string | undefined;
#enabledServerIds: readonly string[] = [];
#prepareTask: Promise<void> | undefined;
#closed = false;
#closeTask: Promise<void> | undefined;
constructor(workspaceRoot: string, connection: RuntimeHostReconnectingConnection) {
this.#workspaceRoot = workspaceRoot;
this.#connection = connection;
this.#manager = new McpClientManager({
clientName: 'maka-run',
excludedStdioEnvironmentKeys: ['MAKA_RUNTIME_HOST_ACCESS_CREDENTIAL'],
oauthStorage: createCredentialMcpOAuthStorage(createFileCredentialStore(workspaceRoot)),
});
this.#publication = new McpCapabilityPublication({
connectionIdentity: () =>
this.#availability?.kind === 'connected'
? `${this.#availability.hostEpoch}\0${this.#availability.connectionId}`
: undefined,
revision: () => this.#manager.toolSnapshot().revision,
createProvider: () =>
createMcpCapabilityProvider(this.#manager, {
admission: 'mcp',
onCurrentRegistrationRetired: () => this.#retire(),
}) ?? {
// Even an empty snapshot must replace a lost Session publication.
offers: () => [],
currentRegistrationRetired: () => this.#retire(),
},
replace: (provider) =>
this.#connection.replaceClientCapabilities(provider, {
sessionId: this.#sessionId!,
sessionConfigurationId: this.#sessionConfigurationId!,
}),
unregister: () =>
this.#connection.unregisterClientCapabilities({ sessionId: this.#sessionId! }),
onState: () => undefined,
});
this.#unsubscribeManager = this.#manager.onChange(() => {
if (this.#sessionId && !this.#closed) this.#publication.request();
});
this.#unsubscribeConnection = connection.subscribeConnectionAvailability((availability) => {
this.#availability = availability;
if (availability.kind !== 'connected') this.#publication.invalidate();
if (this.#sessionId && !this.#closed) this.#publication.request();
});
}
prepare(sessionId: string): Promise<void> {
if (this.#sessionId && this.#sessionId !== sessionId) {
return Promise.reject(new Error('MCP is already bound to another Session'));
}
this.#prepareTask ??= this.#prepare(sessionId);
return this.#prepareTask;
}
async #prepare(sessionId: string): Promise<void> {
if (this.#closed) throw new Error('MCP publication is closed');
try {
const config = await createMcpConfigStore(this.#workspaceRoot).get();
this.#enabledServerIds = Object.entries(config.mcpServers)
.filter(([, server]) => server.enabled !== false)
.map(([serverId]) => serverId);
try {
await this.#manager.sync(config);
} catch (error) {
throw new Error('Session MCP preparation failed; check the workspace MCP configuration', {
cause: error,
});
}
this.#assertServersConnected();
if (this.#closed) throw new Error('MCP publication is closed');
this.#sessionConfigurationId = `sha256:${createHash('sha256').update(stableJsonStringify(config)).digest('hex')}`;
this.#sessionId = sessionId;
await this.ready();
} catch (error) {
await this.close().catch(() => undefined);
throw error;
}
}
async ready(): Promise<void> {
if (this.#closed || !this.#sessionId) throw new Error('MCP publication is not prepared');
this.#assertServersConnected();
const state = await this.#publication.settle();
if (state !== 'published') {
if (
this.#publication.lastError instanceof RuntimeHostOperationError &&
this.#publication.lastError.code === 'session_binding_conflict'
) {
throw new Error(
'Session MCP configuration conflicts with its frozen tools; restore the same configuration before resuming',
{ cause: this.#publication.lastError },
);
}
throw new Error('Session MCP capability publication is unavailable', {
cause: this.#publication.lastError,
});
}
this.#assertServersConnected();
}
#assertServersConnected(): void {
const unavailable = this.#enabledServerIds.flatMap((serverId) => {
const state = this.#manager.status(serverId)?.state ?? 'missing';
return state === 'connected' ? [] : [`${serverId} (${state})`];
});
if (unavailable.length > 0) {
throw new Error(`Session MCP server(s) unavailable: ${unavailable.join(', ')}`);
}
}
get ownsServers(): boolean {
return this.#enabledServerIds.length > 0;
}
close(): Promise<void> {
this.#closeTask ??= this.#close(true);
return this.#closeTask;
}
#retire(): Promise<void> {
this.#closeTask ??= this.#close(false);
return this.#closeTask;
}
async #close(unregister: boolean): Promise<void> {
this.#closed = true;
this.#unsubscribeManager();
this.#unsubscribeConnection();
try {
await (unregister ? this.#publication.close() : this.#publication.retire());
} finally {
await this.#manager.close();
}
}
}