agent-ecosystem/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts
2026-04-21 20:28:22 +03:00

421 lines
13 KiB
TypeScript

export const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
] as const;
export type RequiredAgentTeamsRuntimeTool = (typeof REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS)[number];
export interface OpenCodeToolListItem {
id: string;
description?: string;
parameters?: unknown;
}
export interface OpenCodeInfrastructureToolClient {
listExperimentalToolIds(): Promise<string[]>;
listExperimentalTools(input: {
providerId: string;
modelId: string;
}): Promise<OpenCodeToolListItem[]>;
}
export type OpenCodeMcpToolProofRoute = '/experimental/tool/ids' | '/experimental/tool' | null;
export interface OpenCodeMcpToolProof {
ok: boolean;
route: OpenCodeMcpToolProofRoute;
canonicalServerName: string;
canonicalExpectedIds: Record<string, string>;
observedTools: string[];
missingTools: string[];
matchedByRequiredTool: Record<string, string | null>;
aliasMatchedByRequiredTool: Record<string, string | null>;
diagnostics: string[];
}
export interface AppMcpRuntimeToolContract {
name: RequiredAgentTeamsRuntimeTool;
requiredInputFields: string[];
idempotencyField: string | null;
runScoped: boolean;
handlerKind: 'bootstrap' | 'delivery' | 'task_event' | 'heartbeat';
}
export interface AppMcpToolDefinition {
name: string;
inputSchema: unknown;
}
export interface AppMcpRuntimeToolPreflightResult {
ok: boolean;
observedToolNames: string[];
diagnostics: string[];
}
export interface RuntimeDeliverMessageSchemaDiagnostic {
severity: 'error';
message: string;
missingFields?: string[];
}
export const APP_MCP_RUNTIME_TOOL_CONTRACTS: AppMcpRuntimeToolContract[] = [
{
name: 'runtime_bootstrap_checkin',
requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'],
idempotencyField: null,
runScoped: true,
handlerKind: 'bootstrap',
},
{
name: 'runtime_deliver_message',
requiredInputFields: [
'idempotencyKey',
'runId',
'teamName',
'fromMemberName',
'runtimeSessionId',
'to',
'text',
],
idempotencyField: 'idempotencyKey',
runScoped: true,
handlerKind: 'delivery',
},
{
name: 'runtime_task_event',
requiredInputFields: ['idempotencyKey', 'runId', 'teamName', 'memberName', 'taskId', 'event'],
idempotencyField: 'idempotencyKey',
runScoped: true,
handlerKind: 'task_event',
},
{
name: 'runtime_heartbeat',
requiredInputFields: ['runId', 'teamName', 'memberName', 'runtimeSessionId'],
idempotencyField: null,
runScoped: true,
handlerKind: 'heartbeat',
},
];
export class OpenCodeMcpToolAvailabilityProbe {
constructor(private readonly client: OpenCodeInfrastructureToolClient) {}
async proveRequiredTools(input: {
serverName: string;
requiredTools: string[];
providerId: string;
modelId: string;
}): Promise<OpenCodeMcpToolProof> {
const idsProof = await this.tryToolIdsProof(input);
if (idsProof.ok) {
return idsProof;
}
const definitionsProof = await this.tryToolDefinitionsProof(input);
if (definitionsProof.ok) {
return definitionsProof;
}
return mergeFailedToolProofs({
serverName: input.serverName,
requiredTools: input.requiredTools,
idsProof,
definitionsProof,
});
}
private async tryToolIdsProof(input: {
serverName: string;
requiredTools: string[];
}): Promise<OpenCodeMcpToolProof> {
try {
const observedTools = await this.client.listExperimentalToolIds();
return matchRequiredOpenCodeTools({
route: '/experimental/tool/ids',
serverName: input.serverName,
requiredTools: input.requiredTools,
observedTools,
});
} catch (error) {
return failedToolProof({
route: '/experimental/tool/ids',
serverName: input.serverName,
requiredTools: input.requiredTools,
diagnostics: [`OpenCode /experimental/tool/ids unavailable - ${stringifyError(error)}`],
});
}
}
private async tryToolDefinitionsProof(input: {
serverName: string;
requiredTools: string[];
providerId: string;
modelId: string;
}): Promise<OpenCodeMcpToolProof> {
try {
const tools = await this.client.listExperimentalTools({
providerId: input.providerId,
modelId: input.modelId,
});
return matchRequiredOpenCodeTools({
route: '/experimental/tool',
serverName: input.serverName,
requiredTools: input.requiredTools,
observedTools: tools.map((tool) => tool.id),
});
} catch (error) {
return failedToolProof({
route: '/experimental/tool',
serverName: input.serverName,
requiredTools: input.requiredTools,
diagnostics: [`OpenCode /experimental/tool unavailable - ${stringifyError(error)}`],
});
}
}
}
export function sanitizeOpenCodeMcpToolPart(value: string): string {
const sanitized = value
.trim()
.replace(/[^a-zA-Z0-9_-]/g, '_')
.replace(/_+/g, '_');
return sanitized.length > 0 ? sanitized : 'unknown';
}
export function buildOpenCodeCanonicalMcpToolId(serverName: string, toolName: string): string {
return `${sanitizeOpenCodeMcpToolPart(serverName)}_${sanitizeOpenCodeMcpToolPart(toolName)}`;
}
export function buildOpenCodeToolIdCandidates(serverName: string, toolName: string): string[] {
const dashServerName = serverName.trim();
const underscoreServerName = sanitizeOpenCodeMcpToolPart(serverName);
const canonical = buildOpenCodeCanonicalMcpToolId(serverName, toolName);
return unique([
canonical,
toolName,
`${dashServerName}:${toolName}`,
`${underscoreServerName}:${toolName}`,
`${dashServerName}_${toolName}`,
`${underscoreServerName}_${toolName}`,
`mcp__${dashServerName}__${toolName}`,
`mcp__${underscoreServerName}__${toolName}`,
]);
}
export function matchRequiredOpenCodeTools(input: {
route: Exclude<OpenCodeMcpToolProofRoute, null>;
serverName: string;
requiredTools: string[];
observedTools: string[];
}): OpenCodeMcpToolProof {
const observed = new Set(input.observedTools);
const matchedByRequiredTool: Record<string, string | null> = {};
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
const missingTools: string[] = [];
const diagnostics: string[] = [];
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
for (const requiredTool of input.requiredTools) {
const canonical = canonicalExpectedIds[requiredTool];
const alias = buildOpenCodeToolIdCandidates(input.serverName, requiredTool).find(
(candidate) => candidate !== canonical && observed.has(candidate)
);
matchedByRequiredTool[requiredTool] = observed.has(canonical) ? canonical : null;
aliasMatchedByRequiredTool[requiredTool] = alias ?? null;
if (!observed.has(canonical)) {
missingTools.push(requiredTool);
diagnostics.push(
alias
? `OpenCode observed alias ${alias} but missing canonical app MCP tool id ${canonical}`
: `OpenCode missing canonical app MCP tool id ${canonical}`
);
}
}
return {
ok: missingTools.length === 0,
route: input.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: unique(input.observedTools).sort(),
missingTools,
matchedByRequiredTool,
aliasMatchedByRequiredTool,
diagnostics,
};
}
export function verifyAppMcpRuntimeToolContracts(
tools: AppMcpToolDefinition[]
): AppMcpRuntimeToolPreflightResult {
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const diagnostics: string[] = [];
for (const contract of APP_MCP_RUNTIME_TOOL_CONTRACTS) {
const tool = byName.get(contract.name);
if (!tool) {
diagnostics.push(`App MCP tool missing: ${contract.name}`);
continue;
}
const schema = asRecord(tool.inputSchema);
const properties = asRecord(schema?.properties);
const required = asStringArray(schema?.required);
for (const field of contract.requiredInputFields) {
if (!properties?.[field] || !required.includes(field)) {
diagnostics.push(`App MCP tool ${contract.name} missing required field ${field}`);
}
}
if (contract.idempotencyField && !required.includes(contract.idempotencyField)) {
diagnostics.push(
`App MCP tool ${contract.name} idempotency field ${contract.idempotencyField} is not required`
);
}
}
return {
ok: diagnostics.length === 0,
observedToolNames: tools.map((tool) => tool.name).sort(),
diagnostics,
};
}
export function assertRuntimeDeliverMessageSchema(
tools: OpenCodeToolListItem[],
serverName = 'agent-teams'
): RuntimeDeliverMessageSchemaDiagnostic[] {
const deliverToolIds = new Set(
buildOpenCodeToolIdCandidates(serverName, 'runtime_deliver_message')
);
const deliver = tools.find((tool) => deliverToolIds.has(tool.id));
if (!deliver) {
return [{ severity: 'error', message: 'runtime_deliver_message tool is absent' }];
}
const schema = asRecord(deliver.parameters);
const properties = asRecord(schema?.properties);
const required = asStringArray(schema?.required);
const requiredFields = [
'idempotencyKey',
'runId',
'teamName',
'fromMemberName',
'runtimeSessionId',
'to',
'text',
];
const missingFields = requiredFields.filter(
(field) => !properties?.[field] || !required.includes(field)
);
return missingFields.length === 0
? []
: [
{
severity: 'error',
message: `runtime_deliver_message schema missing required fields: ${missingFields.join(', ')}`,
missingFields,
},
];
}
function mergeFailedToolProofs(input: {
serverName: string;
requiredTools: string[];
idsProof: OpenCodeMcpToolProof;
definitionsProof: OpenCodeMcpToolProof;
}): OpenCodeMcpToolProof {
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
const matchedByRequiredTool: Record<string, string | null> = {};
const aliasMatchedByRequiredTool: Record<string, string | null> = {};
for (const tool of input.requiredTools) {
matchedByRequiredTool[tool] =
input.idsProof.matchedByRequiredTool[tool] ??
input.definitionsProof.matchedByRequiredTool[tool] ??
null;
aliasMatchedByRequiredTool[tool] =
input.idsProof.aliasMatchedByRequiredTool[tool] ??
input.definitionsProof.aliasMatchedByRequiredTool[tool] ??
null;
}
return {
ok: false,
route: input.definitionsProof.route ?? input.idsProof.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: unique([
...input.idsProof.observedTools,
...input.definitionsProof.observedTools,
]).sort(),
missingTools: unique([
...input.idsProof.missingTools,
...input.definitionsProof.missingTools,
]).sort(),
matchedByRequiredTool,
aliasMatchedByRequiredTool,
diagnostics: [
...input.idsProof.diagnostics,
...input.definitionsProof.diagnostics,
'OpenCode app-owned MCP server is connected but required runtime tools were not proven available',
],
};
}
function failedToolProof(input: {
route: Exclude<OpenCodeMcpToolProofRoute, null>;
serverName: string;
requiredTools: string[];
diagnostics: string[];
}): OpenCodeMcpToolProof {
const canonicalExpectedIds = buildCanonicalExpectedIds(input.serverName, input.requiredTools);
return {
ok: false,
route: input.route,
canonicalServerName: sanitizeOpenCodeMcpToolPart(input.serverName),
canonicalExpectedIds,
observedTools: [],
missingTools: [...input.requiredTools],
matchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])),
aliasMatchedByRequiredTool: Object.fromEntries(input.requiredTools.map((tool) => [tool, null])),
diagnostics: input.diagnostics,
};
}
function buildCanonicalExpectedIds(
serverName: string,
requiredTools: string[]
): Record<string, string> {
return Object.fromEntries(
requiredTools.map((tool) => [tool, buildOpenCodeCanonicalMcpToolId(serverName, tool)])
);
}
function unique<T>(items: T[]): T[] {
return [...new Set(items)];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is string => typeof item === 'string');
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}