import { assertControllerMcpWorkspaceRequest, assertControllerMcpWorkspaceResponse } from './classes.mcpvalidation.js'; import type { TControllerMcpWorkspaceMethod } from '../ts_interfaces/mcpworkspacerequests.js'; import * as plugins from './mcp.plugins.js'; import { controllerMaxDraftTextBytes, controllerMcpToolMaximumOutputBytes, controllerMcpBrowserOperationTimeoutMs, type IReq_ControllerMcpBrowserAction, type IControllerRuntimeId, type TControllerModelChoice, } from '../ts_interfaces/index.js'; import { ControllerMcpClient, ControllerMcpClientError, } from './classes.mcpclient.js'; export const aglMcpToolNames = [ 'controller_status', 'projects_list', 'sessions_list', 'session_read', 'session_send', 'session_scratchpad_read', 'session_scratchpad_update', 'context_resolve', 'project_add', 'models_list', 'session_create', 'session_model_set', 'session_rename', 'session_archive', 'session_stop', 'resources_list', 'resource_create', 'resource_rename', 'resource_attach', 'resource_detach', 'resource_start', 'resource_stop', 'resource_retire', 'browser_action', ] as const; interface IAglMcpToolResult { [key: string]: unknown; content: Array<{ type: 'text'; text: string; } | { type: 'image'; mimeType: 'image/jpeg' | 'image/png'; data: string }>; isError?: boolean; } export interface IAglMcpToolsOptions { controllerPort: number; callerCredential?: string; client?: Pick< ControllerMcpClient, | 'workspace' | 'status' | 'listProjects' | 'listSessions' | 'readSession' | 'sendSession' | 'readSessionScratchpad' | 'updateSessionScratchpad' >; } const maximumIdentifierBytes = 1024; const maximumModelPartBytes = 512; const maximumConcurrentToolCalls = 16; const boundedNonblankString = ( maximumBytesArg: number, descriptionArg: string, ): plugins.z.ZodString => plugins.z.string() .refine((valueArg) => valueArg.trim().length > 0, `${descriptionArg} must not be blank`) .refine( (valueArg) => Buffer.byteLength(valueArg, 'utf8') <= maximumBytesArg, `${descriptionArg} exceeds its UTF-8 byte limit`, ); const runtimeIdSchema = plugins.z.object({ harnessId: plugins.z.enum(['opencode', 'flex', 'codex']), nativeId: boundedNonblankString(maximumIdentifierBytes, 'sessionId.nativeId'), }).strict(); const openCodeModelSchema = plugins.z.object({ harnessId: plugins.z.literal('opencode'), providerID: boundedNonblankString(maximumModelPartBytes, 'model.providerID'), modelID: boundedNonblankString(maximumModelPartBytes, 'model.modelID'), variant: boundedNonblankString(maximumModelPartBytes, 'model.variant').optional(), }).strict(); const flexModelSchema = plugins.z.object({ harnessId: plugins.z.literal('flex'), providerID: boundedNonblankString(maximumModelPartBytes, 'model.providerID'), modelID: boundedNonblankString(maximumModelPartBytes, 'model.modelID'), variant: boundedNonblankString(maximumModelPartBytes, 'model.variant').optional(), }).strict(); const modelSchema = plugins.z.discriminatedUnion('harnessId', [ openCodeModelSchema, flexModelSchema, plugins.z.object({ harnessId: plugins.z.literal('codex'), providerID: plugins.z.literal('codex'), modelID: boundedNonblankString(maximumModelPartBytes, 'model.modelID'), variant: boundedNonblankString(maximumModelPartBytes, 'model.variant').optional(), }).strict(), ]); const workspaceId = boundedNonblankString(128, 'identifier'); const workspaceTitle = boundedNonblankString(800, 'title').max(200); const workspaceAccount = boundedNonblankString(maximumModelPartBytes, 'providerConnectionId'); const workspaceRevision = plugins.z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); const workspaceSessionTarget = { projectId: workspaceId, sessionId: runtimeIdSchema }; const workspaceResourceTarget = { projectId: workspaceId, resourceId: workspaceId }; const browserTab = { tabId: workspaceId.optional() }; const browserTimeout = plugins.z.number().int().min(100).max(controllerMcpBrowserOperationTimeoutMs).optional(); const browserActionSchema = plugins.z.discriminatedUnion('action', [ plugins.z.object({ action: plugins.z.literal('snapshot'), ...browserTab, maxCharacters: plugins.z.number().int().min(256).max(50_000).optional() }).strict(), plugins.z.object({ action: plugins.z.literal('navigate'), ...browserTab, url: boundedNonblankString(8192, 'url'), timeoutMs: browserTimeout }).strict(), plugins.z.object({ action: plugins.z.literal('click'), ...browserTab, selector: boundedNonblankString(4096, 'selector'), timeoutMs: browserTimeout }).strict(), plugins.z.object({ action: plugins.z.literal('fill'), ...browserTab, selector: boundedNonblankString(4096, 'selector'), text: plugins.z.string().max(100_000), timeoutMs: browserTimeout }).strict(), plugins.z.object({ action: plugins.z.literal('press'), ...browserTab, selector: boundedNonblankString(4096, 'selector'), key: boundedNonblankString(64, 'key'), timeoutMs: browserTimeout }).strict(), plugins.z.object({ action: plugins.z.literal('screenshot'), ...browserTab, format: plugins.z.enum(['jpeg', 'png']).optional(), quality: plugins.z.number().int().min(0).max(100).optional() }).strict(), ]); interface IWorkspaceToolDefinition { name: string; method: TControllerMcpWorkspaceMethod; description: string; inputSchema: plugins.z.ZodType; readOnly: boolean; destructive: boolean; } const workspaceToolDefinitions: IWorkspaceToolDefinition[] = [ { name: 'browser_action', method: 'agl.mcp.browser.action', description: "Inspect or control a browser attached to this chat. Attach the resource with resource_attach first and supply its current attachment revision. Another chat's attachment grants nothing: this chat needs its own. Human viewers stay connected. Actions have a 20-second deadline. Screenshots return an image up to 512 KiB; use JPEG with lower quality for larger pages. Never replay a mutation after an unknown outcome.", inputSchema: plugins.z.object({ ...workspaceResourceTarget, expectedAttachmentRevision: workspaceRevision, action: browserActionSchema }).strict(), readOnly: false, destructive: true }, { name: 'context_resolve', method: 'agl.mcp.context.resolve', description: "Resolve an absolute project directory and optionally an existing native task. Accepts a raw Codex thread ID only when it uniquely resolves; returns the qualified AGL session ID. Does not take over a running task.", inputSchema: plugins.z.object({ directory: boundedNonblankString(4096, 'directory'), sessionId: runtimeIdSchema.optional() }).strict(), readOnly: false, destructive: false }, { name: 'project_add', method: 'agl.mcp.project.create', description: "Register an existing project directory. Does not create directories.", inputSchema: plugins.z.object({ path: boundedNonblankString(4096, 'path') }).strict(), readOnly: false, destructive: false }, { name: 'models_list', method: 'agl.mcp.model.list', description: "List available models and reasoning variants using the configured project or task connection. Supply the task context for Codex remote models.", inputSchema: plugins.z.object({ projectId: workspaceId.optional(), sessionId: runtimeIdSchema.optional() }).strict(), readOnly: false, destructive: false }, { name: 'session_create', method: 'agl.mcp.session.create', description: "Create a task in an AGL project using its configured harness and optional model. Does not send a prompt.", inputSchema: plugins.z.object({ projectId: workspaceId, harnessId: plugins.z.enum(['opencode', 'flex', 'codex']), title: workspaceTitle.optional(), model: modelSchema.optional(), providerConnectionId: workspaceAccount.optional() }).strict(), readOnly: false, destructive: false }, { name: 'session_model_set', method: 'agl.mcp.session.model.update', description: "Set AGL's selected model and reasoning variant for subsequent task submissions, validated against the task's configured connection.", inputSchema: plugins.z.object({ ...workspaceSessionTarget, model: modelSchema, providerConnectionId: workspaceAccount.optional() }).strict(), readOnly: false, destructive: false }, { name: 'session_rename', method: 'agl.mcp.session.rename', description: "Rename the selected task.", inputSchema: plugins.z.object({ ...workspaceSessionTarget, title: workspaceTitle }).strict(), readOnly: false, destructive: false }, { name: 'session_archive', method: 'agl.mcp.session.archive', description: "Archive an idle task and remove it from the active sidebar. Active work must settle first.", inputSchema: plugins.z.object(workspaceSessionTarget).strict(), readOnly: false, destructive: true }, { name: 'session_stop', method: 'agl.mcp.session.abort', description: "Stop active and queued work for the selected task using the normal harness controls.", inputSchema: plugins.z.object(workspaceSessionTarget).strict(), readOnly: false, destructive: true }, { name: 'resources_list', method: 'agl.mcp.resource.list', description: "List the project resources attached to this chat plus the unattached ones, including their attachment revisions.", inputSchema: plugins.z.object({ projectId: workspaceId }).strict(), readOnly: true, destructive: false }, { name: 'resource_create', method: 'agl.mcp.resource.create', description: "Create a persistent browser or terminal resource in the selected project. Pass agent 'claude' on a terminal to run a controller-owned Claude chat as its root process.", inputSchema: plugins.z.object({ projectId: workspaceId, kind: plugins.z.enum(['browser', 'terminal']), title: workspaceTitle.optional(), agent: plugins.z.enum(['claude']).optional() }).strict(), readOnly: false, destructive: false }, { name: 'resource_rename', method: 'agl.mcp.resource.rename', description: "Rename a project resource.", inputSchema: plugins.z.object({ ...workspaceResourceTarget, title: workspaceTitle }).strict(), readOnly: false, destructive: false }, { name: 'resource_attach', method: 'agl.mcp.resource.attach', description: "Attach a resource to this chat, adding to whatever it is already attached to. A resource may serve several chats at once: a second chat attaching does not steal it from the first, both drive it, and each action is fenced by the attachment revision it observed. Idempotent when this chat is already attached. Requires the current resource attachment revision. Supply sessionId only from a shared harness runtime that owns several tasks; attaching to another chat is a workspace-UI operation.", inputSchema: plugins.z.object({ ...workspaceResourceTarget, sessionId: runtimeIdSchema.optional(), expectedAttachmentRevision: workspaceRevision }).strict(), readOnly: false, destructive: false }, { name: 'resource_detach', method: 'agl.mcp.resource.detach', description: "Remove this chat's own attachment to a resource, using its current attachment revision. Other chats attached to the same resource keep theirs, and the whole set is never cleared. Supply sessionId only from a shared harness runtime that owns several tasks, naming which of its own tasks to release.", inputSchema: plugins.z.object({ ...workspaceResourceTarget, sessionId: runtimeIdSchema.optional(), expectedAttachmentRevision: workspaceRevision }).strict(), readOnly: false, destructive: false }, { name: 'resource_start', method: 'agl.mcp.resource.start', description: "Start the selected browser or terminal resource.", inputSchema: plugins.z.object(workspaceResourceTarget).strict(), readOnly: false, destructive: false }, { name: 'resource_stop', method: 'agl.mcp.resource.stop', description: "Stop the selected resource while retaining its project registration.", inputSchema: plugins.z.object(workspaceResourceTarget).strict(), readOnly: false, destructive: true }, { name: 'resource_retire', method: 'agl.mcp.resource.retire', description: "Permanently retire the resource and remove its resource-owned runtime state.", inputSchema: plugins.z.object(workspaceResourceTarget).strict(), readOnly: false, destructive: true }, ]; const safeClientMessages: Record = { CONTROLLER_UNAVAILABLE: 'The requested AGL controller is unavailable.', FENCED: 'The AGL controller runtime identity could not be verified.', AUTHENTICATION_FAILED: 'The private AGL controller rejected authentication.', PROTOCOL_MISMATCH: 'The AGL controller and agl mcp versions are incompatible.', RESPONSE_LIMIT: 'The AGL controller response exceeded the private response limit.', CONCURRENT_CHANGE: 'The resource or scratchpad changed; read its latest revision before updating again.', PROJECT_NOT_FOUND: 'The project is unavailable. Use projects_list or register its directory with project_add.', SESSION_NOT_FOUND: 'The task is unavailable in this project. Use sessions_list to select an active task.', AMBIGUOUS_SESSION: 'The native ID exists on multiple connections. Use sessions_list and select its qualified AGL ID.', MODEL_NOT_FOUND: 'The selected model is unavailable. Use models_list for this task.', REQUEST_FAILED: 'The private AGL controller request failed.', OUTCOME_UNKNOWN: 'The operation outcome is unknown; inspect current state before acting again.', }; const serializeToolResult = ( payloadArg: Record, isErrorArg = false, ): IAglMcpToolResult => { const result: IAglMcpToolResult = { content: [{ type: 'text', text: JSON.stringify(payloadArg) }], ...(isErrorArg ? { isError: true } : {}), }; if (Buffer.byteLength(JSON.stringify(result), 'utf8') <= controllerMcpToolMaximumOutputBytes) { return result; } return { content: [{ type: 'text', text: JSON.stringify({ ok: false, error: { code: 'OUTPUT_LIMIT', message: 'The MCP tool result exceeded the output limit.', }, }), }], isError: true, }; }; const safeToolError = (errorArg: unknown): IAglMcpToolResult => { if (errorArg instanceof ControllerMcpClientError) { return serializeToolResult({ ok: false, error: { code: errorArg.code, message: safeClientMessages[errorArg.code], }, }, true); } return serializeToolResult({ ok: false, error: { code: 'REQUEST_FAILED', message: 'The AGL MCP tool request failed.', }, }, true); }; export class AglMcpTools { public readonly client: Pick< ControllerMcpClient, | 'workspace' | 'status' | 'listProjects' | 'listSessions' | 'readSession' | 'sendSession' | 'readSessionScratchpad' | 'updateSessionScratchpad' >; private registeredServer?: InstanceType; private registeredTools: plugins.RegisteredTool[] = []; private activeHandlerPromises = new Set>(); private closePromise?: Promise; private admissionClosed = false; private closed = false; constructor(optionsArg: IAglMcpToolsOptions) { this.client = optionsArg.client ?? new ControllerMcpClient({ controllerPort: optionsArg.controllerPort, ...(optionsArg.callerCredential === undefined ? {} : { callerCredential: optionsArg.callerCredential }), }); } public register(serverArg: InstanceType): this { if (this.registeredServer || this.admissionClosed || this.closed) { throw new Error('AGL MCP tool component instances are one-shot.'); } if (serverArg.isConnected()) { throw new Error('AGL MCP tools must be registered before the server connects.'); } this.registeredServer = serverArg; const registeredTools: plugins.RegisteredTool[] = []; try { registeredTools.push(serverArg.registerTool( 'controller_status', { title: 'Read AGL controller status', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, description: 'Read status from the verified private AGL controller generation.', inputSchema: plugins.z.object({}).strict(), }, async (_args, extra) => this.runToolCall( async () => this.client.status(extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'projects_list', { title: 'List AGL projects', annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, description: 'List projects visible to the running AGL controller.', inputSchema: plugins.z.object({}).strict(), }, async (_args, extra) => this.runToolCall( async () => this.client.listProjects(extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'sessions_list', { title: 'List project sessions', annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, description: 'Discover OpenCode, Flex and Codex tasks from the project folder and configured connections, including tasks created outside AGL.', inputSchema: plugins.z.object({ projectId: boundedNonblankString(maximumIdentifierBytes, 'projectId'), }).strict(), }, async (args, extra) => this.runToolCall( async () => this.client.listSessions({ projectId: args.projectId }, extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'session_read', { title: 'Read an AGL session', annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, description: 'Read the bounded current detail for one qualified AGL session.', inputSchema: plugins.z.object({ projectId: boundedNonblankString(maximumIdentifierBytes, 'projectId'), sessionId: runtimeIdSchema, }).strict(), }, async (args, extra) => this.runToolCall( async () => this.client.readSession({ projectId: args.projectId, sessionId: args.sessionId as IControllerRuntimeId, }, extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'session_send', { title: 'Send a prompt to an AGL session', annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, description: 'Directly submit one bounded prompt without reading or mutating the browser draft. ' + 'An OUTCOME_UNKNOWN result must not be retried automatically.', inputSchema: plugins.z.object({ projectId: boundedNonblankString(maximumIdentifierBytes, 'projectId'), sessionId: runtimeIdSchema, text: boundedNonblankString(controllerMaxDraftTextBytes, 'text'), model: modelSchema.optional(), providerConnectionId: boundedNonblankString( maximumModelPartBytes, 'providerConnectionId', ).optional(), }).strict(), }, async (args, extra) => this.runToolCall( async () => this.client.sendSession({ projectId: args.projectId, sessionId: args.sessionId as IControllerRuntimeId, text: args.text, ...(args.model === undefined ? {} : { model: args.model as TControllerModelChoice }), ...(args.providerConnectionId === undefined ? {} : { providerConnectionId: args.providerConnectionId }), }, extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'session_scratchpad_read', { title: 'Read an AGL session scratchpad', annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true }, description: 'Read the current scratchpad text and revision for one qualified session.', inputSchema: plugins.z.object({ projectId: boundedNonblankString(maximumIdentifierBytes, 'projectId'), sessionId: runtimeIdSchema, }).strict(), }, async (args, extra) => this.runToolCall( async () => this.client.readSessionScratchpad({ projectId: args.projectId, sessionId: args.sessionId as IControllerRuntimeId, }, extra.signal), ), )); registeredTools.push(serverArg.registerTool( 'session_scratchpad_update', { title: 'Update an AGL session scratchpad', annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false }, description: 'Replace the scratchpad only when expectedRevision still matches. ' + 'Read again after a CONCURRENT_CHANGE result.', inputSchema: plugins.z.object({ projectId: boundedNonblankString(maximumIdentifierBytes, 'projectId'), sessionId: runtimeIdSchema, text: plugins.z.string() .max(32_768, 'text exceeds its character limit') .refine( (valueArg) => Buffer.byteLength(valueArg, 'utf8') <= 128 * 1024, 'text exceeds its UTF-8 byte limit', ), expectedRevision: plugins.z.number() .int() .nonnegative() .max(Number.MAX_SAFE_INTEGER), }).strict(), }, async (args, extra) => this.runToolCall( async () => this.client.updateSessionScratchpad({ projectId: args.projectId, sessionId: args.sessionId as IControllerRuntimeId, text: args.text, expectedRevision: args.expectedRevision, }, extra.signal), ), )); for (const definition of workspaceToolDefinitions) { registeredTools.push(serverArg.registerTool( definition.name, { title: definition.name.replaceAll('_', ' '), description: definition.description, inputSchema: definition.inputSchema, annotations: { readOnlyHint: definition.readOnly, destructiveHint: definition.destructive, openWorldHint: true }, }, async (args, extra) => this.runToolCall(async () => this.client.workspace( definition.method, assertControllerMcpWorkspaceRequest(definition.method, args), extra.signal, ), definition.method === 'agl.mcp.browser.action' ? (responseArg) => { assertControllerMcpWorkspaceResponse('agl.mcp.browser.action', responseArg); const { result } = responseArg as IReq_ControllerMcpBrowserAction['response']; if (result.action !== 'screenshot') return serializeToolResult({ ok: true, data: responseArg }); return { content: [{ type: 'image', ...result.image }] }; } : undefined), )); } } catch (errorArg) { const failedTools: plugins.RegisteredTool[] = []; const rollbackErrors: unknown[] = []; for (const registeredTool of [...registeredTools].reverse()) { try { registeredTool.remove(); } catch (rollbackError) { failedTools.unshift(registeredTool); rollbackErrors.push(rollbackError); } } if (rollbackErrors.length > 0) { this.registeredTools = failedTools; this.admissionClosed = true; throw new AggregateError( [errorArg, ...rollbackErrors], 'AGL MCP tool registration and rollback failed.', ); } this.registeredServer = undefined; throw errorArg; } this.registeredTools = registeredTools; return this; } public beginShutdown(): void { this.admissionClosed = true; } public close(): Promise { this.beginShutdown(); if (this.closePromise) return this.closePromise; if (this.closed) return Promise.resolve(); let finalPromise!: Promise; const operation = Promise.resolve().then(async () => { await Promise.allSettled([...this.activeHandlerPromises]); if (this.registeredServer?.isConnected()) { throw new Error('Close the shared MCP server before closing AGL MCP tools.'); } const failedTools: plugins.RegisteredTool[] = []; const errors: unknown[] = []; for (const registeredTool of [...this.registeredTools].reverse()) { try { registeredTool.remove(); } catch (errorArg) { failedTools.unshift(registeredTool); errors.push(errorArg); } } this.registeredTools = failedTools; if (errors.length === 1) throw errors[0]; if (errors.length > 1) { throw new AggregateError(errors, 'AGL MCP tool cleanup failed.'); } this.registeredServer = undefined; this.closed = true; }); finalPromise = operation.finally(() => { if (this.closePromise === finalPromise) this.closePromise = undefined; }); this.closePromise = finalPromise; return finalPromise; } private runToolCall( operationArg: () => Promise, serializeArg = (responseArg: unknown): IAglMcpToolResult => serializeToolResult({ ok: true, data: responseArg }), ): Promise { if (this.admissionClosed || this.closed) { return Promise.resolve(serializeToolResult({ ok: false, error: { code: 'SHUTTING_DOWN', message: 'The AGL MCP tools are shutting down.', }, }, true)); } if (this.activeHandlerPromises.size >= maximumConcurrentToolCalls) { return Promise.resolve(serializeToolResult({ ok: false, error: { code: 'ADMISSION_LIMIT', message: 'The AGL MCP tool admission limit was reached.', }, }, true)); } let finalPromise!: Promise; const operation = Promise.resolve() .then(operationArg) .then(serializeArg) .catch((errorArg) => safeToolError(errorArg)); finalPromise = operation.finally(() => { this.activeHandlerPromises.delete(finalPromise); }); this.activeHandlerPromises.add(finalPromise); return finalPromise; } }