From c432cfa9aba6ed36accc8199836556cecb760f05 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Fri, 23 Oct 2026 15:23:14 +0200 Subject: [PATCH 1/2] Snapshot the devtools 0.3.11 working tree Byte-identical copy of the uncommitted codex/agent-messaging working tree in the main checkout (package.json 0.3.11), taken on 2026-10-23 as the base for the shared project-tool registry. It is not new work: when that tree is committed upstream, the commits that follow rebase onto it. Co-Authored-By: Claude Fable 5.1 --- README.md | 28 ++ docs/agent-conversations.md | 55 ++++ docs/shared-desktop-transport.md | 23 ++ package-lock.json | 4 +- package.json | 2 +- src/index.ts | 13 + src/runner.ts | 2 +- src/shared/actions.ts | 18 +- src/shared/agentAssets.ts | 30 ++ src/shared/agentDelivery.ts | 304 +++++++++++++++++++++ src/shared/agentIcon.ts | 30 ++ src/shared/agents.ts | 25 +- src/shared/arguments.ts | 5 +- src/shared/deployments.ts | 192 +++++++++++++ src/shared/mcp.ts | 117 ++++++-- src/shared/mcpTransport.ts | 4 +- src/shared/models.ts | 5 +- src/shared/organizationProjectWorkspace.ts | 12 +- src/shared/organizationProjects.ts | 2 +- src/shared/session.ts | 52 +++- src/shared/sharedTransport.ts | 262 ++++++++++++++++++ src/shared/workingDirectory.ts | 9 + src/shared/workspaceSettings.ts | 42 +++ tests/agent-assets.test.cjs | 43 +++ tests/agent-delivery.test.cjs | 135 +++++++++ tests/agents.test.cjs | 19 ++ tests/deployments.test.cjs | 5 + tests/discovery.test.cjs | 18 ++ tests/mcp.test.cjs | 172 +++++++++++- tests/shared-transport.test.cjs | 115 ++++++++ tests/working-directory.test.cjs | 56 ++++ tests/workspace-settings.test.cjs | 10 + 32 files changed, 1743 insertions(+), 66 deletions(-) create mode 100644 docs/shared-desktop-transport.md create mode 100644 src/shared/agentAssets.ts create mode 100644 src/shared/agentDelivery.ts create mode 100644 src/shared/agentIcon.ts create mode 100644 src/shared/deployments.ts create mode 100644 src/shared/sharedTransport.ts create mode 100644 src/shared/workingDirectory.ts create mode 100644 src/shared/workspaceSettings.ts create mode 100644 tests/agent-assets.test.cjs create mode 100644 tests/agent-delivery.test.cjs create mode 100644 tests/deployments.test.cjs create mode 100644 tests/shared-transport.test.cjs create mode 100644 tests/working-directory.test.cjs create mode 100644 tests/workspace-settings.test.cjs diff --git a/README.md b/README.md index 8626093..c5291c8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,34 @@ The machine record is the SDKs' existing `MainSequenceCLI.auth` service, backend ## Project MCP client +In 0.3.6, remote tool failures use `MCP_TOOL_ERROR` and retain the sanitized MCP +`content`/`structuredContent` in `ActionResult.data`, separately from transport/API +errors. Hosts must preserve that result and any `effects` warning. Calls are never +automatically replayed. + +Since 0.3.7, a persistent client reuses verified release metadata, the release grant +and its tool catalog in memory for at most 60 seconds (never beyond credential +expiry). A prepared `mcp-call` sends only the business `tools/call` HTTP request. +`mcp-tools` explicitly refreshes schemas without repeating valid connection setup. +Cold setup verifies the account remotely at entry and completion; warm calls reread +the shared credential selection locally before and after execution. Logout, a +changed credential pair or saved endpoint, removal, expiry and transport failures +invalidate reuse. The server continues to enforce authorization for each call. +Tool results and credentials are never written to connection metadata; failed calls +are never replayed automatically, including after Stop. + +`createDevTools()` owns a cache by default. Hosts that create a new dispatcher per +request can pass the same opaque `createMcpConnectionCache()` handle as `mcpCache`; +devtools owns validation and expiry, while each request retains its own cancellation +signal. A session adapter without `selectionIdentity()` always uses fresh setup. +Separate CLI processes do not share this memory cache. + +Version 0.3.3 adds the public `AgentDelivery` service for durable host conversations. +It stages immutable requests, records native submission acknowledgements separately +from agent delivery, and reuses saved receipts on recovery. It uses the same session, +agent and readiness APIs as other consumers; no host UI code is included. See +[the delivery contract](docs/agent-conversations.md#durable-host-delivery-033). + The `mcp-discover`, `mcp-add`, `mcp-list`, `mcp-remove`, `mcp-tools` and `mcp-call` actions discover the platform's MCP directory, remember a selected release and invoke its tools through a local MCP client, using the existing developer session and short-lived release credentials. They are available from `ms-devtools` and from `createDevTools().dispatch()`; hosts may inject `mcpConnectionsPath`. Since 0.3.1, `mcp-add` reads the release's name, its repository's name and its repository branch from the release directory and stores them with the connection as `name`, `display_name` and `code_repository_branch_uid`, so `mcp-list` labels a saved connection without a backend request; a record saved by an earlier version is completed by the first listing that can read them, and a label that cannot be read is left out rather than making the MCP unavailable. Discovery rows carry `name` and `code_repository_branch_uid` as the platform directory reports them. The conversational `mainsequence-mcp` skill that lets Codex use them without `/ms` ships with the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin), together with [its user guide](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/conversational-mcp.md) and [ADR 0002](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/adr/0002-conversational-project-mcp-client.md). ## Shared discovery API (0.3.2) diff --git a/docs/agent-conversations.md b/docs/agent-conversations.md index a4588ce..3d5bc7d 100644 --- a/docs/agent-conversations.md +++ b/docs/agent-conversations.md @@ -78,6 +78,47 @@ sixth request argument accepts a per-request `timeoutMs` up to 300,000 milliseco Agent replies are external data. Consumers must not treat returned text or card descriptions as instructions that override user requests or permission checks. +## Durable host delivery (0.3.3) + +`AgentDelivery` is a public, host-independent coordinator. Construct it with +`createDispatch(signal)` returning the existing `createDevTools(...).dispatch`, +using the shared machine session. A scope contains `host`, a host-supplied +`conversationId`, the selected `backend` and verified `account`. Never accept the +conversation identity or storage directory from an app message or model argument. + +`stage(scope, {requestId, environmentUid, agentUid, message})` validates the agent +and stores immutable content without allocating a session or sending. UUID request +IDs become `agent-send.messageId`; omitting messageId on the existing action retains +its previous generated-ID behavior. `submission` records attempted, acknowledged or +unknown native-queue submission. An acknowledgement is not a delivery receipt. + +`deliver` claims the saved request under a cross-process lock, resolves a stable +host/chat/agent session handle, waits for backend readiness, persists the sending +marker, and dispatches once. `wait` waits at most 25 seconds by default; `get` and +`list` return saved status. A caller's AbortSignal stops its wait, while accepted work +continues under a separate 12-minute deadline. `cancel` applies only before acceptance. +Repeating delivery returns the existing job or exact receipt; it never replays a send. + +State is stored under `MainSequenceCLI/agent-delivery-v1` in the platform application +data directory, with mode 0700 directories and 0600 atomic files. It contains message +text, replies and public IDs, not credentials. Files are partitioned by host, chat, +backend and verified account. Pending messages expire after 24 hours; at 256 requests +the conversation refuses new requests rather than dropping deduplication history. +Deleting these files removes recovery history and must not be used to retry a send. + +Explicit recovery of failed preparation (including an agent-send preflight failure that proves `a2a.send_message` was never called) with zero dispatch attempts reuses the same request and stable session handle. After process loss, preparation can also resume through that handle. Loss during +dispatch becomes `uncertain`: the same request and further sends in that agent +conversation are blocked. The platform currently supplies no authoritative +direct-message reconciliation receipt. This provides at-most-once client dispatch, +not a distributed exactly-once guarantee or automatic retry after ambiguous failure. +Completed receipts survive process restart and can be returned to native chat again. +Only direct `message` responses are supported by this coordinator; task APIs retain +their existing independent behavior. Claude's consumer remains unchanged. + +The delivery tests cover concurrent processes, Stop before and during send, process +death, lost outcomes, immutable IDs, account/chat isolation and corrupt/symlinked +storage. Fixtures use temporary storage and never replace machine credentials. + ## Verification and release `tests/agents.test.cjs` checks environment/account boundaries, public projection, @@ -103,3 +144,17 @@ Version 0.2.6 adds readiness preparation and opt-in send-time revalidation for C 0.4.5. Existing session allocation and other adapter behavior remain unchanged. Agent list, search and detail results include `defaults: { provider, model, thinking }`, projected only from existing `llm_provider`, `llm_model` and `llm_thinking` fields. Empty or absent fields remain empty strings. These are Agent defaults for new sessions, not a claim about a resumed session’s resolved configuration. Runtime configuration and credential fields are excluded. + + +### Inline agent icons + +Desktop hosts request `includeIcons: true` on `agents-list` or `agent-get`. +Devtools forwards the platform's `include_icons: true` on agent.list, +agent.search and agent.get. Each projected agent then has an `icon` containing +bounded inline data and its reduced-motion still image, or null. Remote icon URLs +are never fetched. Missing/invalid sprites do not trigger fallback HTTP requests. +The default remains unchanged for callers that do not request icons. + +`createAgentAssets` remains a compatibility API using this same inline MCP +contract. Hosts that already have a directory response should use its icons +instead of calling the compatibility API and repeating discovery. diff --git a/docs/shared-desktop-transport.md b/docs/shared-desktop-transport.md new file mode 100644 index 0000000..b5a47c4 --- /dev/null +++ b/docs/shared-desktop-transport.md @@ -0,0 +1,23 @@ +# Shared desktop transport + +`createSharedTransport()` is an opt-in devtools API for hosts that launch a separate process for each chat. Codex supplies its `fetch` and `sessionLock` to every session-backed service: actions, agent delivery, repositories and deployments. Claude is unchanged. + +The processes connect to one per-user, protocol-versioned Unix-domain socket. Its devtools worker owns the HTTP keep-alive agents and coordinates token renewal. There is no HTTP listener, new authentication store, or background polling. Credentials still come from the existing machine login. Tokens, HTTP bodies and cached results remain in memory; only owner PID metadata and a socket live in the private directory. The directory is owner-only (0700), the socket is 0600. The service exits after all clients disconnect and it has been idle for 30 seconds. This implementation supports macOS and Linux. + +Identical in-flight GETs and explicitly marked stateless MCP reads share one request. The cache key includes the complete URL, authorization headers and semantic body, so accounts, backend URLs, environments, searches and pages remain separate. MCP correlation IDs are restored for each caller. The shared cache is bounded to 256 entries and 24 MB of encoded response bodies; each response is bounded to 8 MB. Profile reads live for five seconds, discovery reads for thirty seconds, and icons for five minutes, always capped before a JWT's expiry. Errors are not cached. Explicit invalidation discards the cache and prevents older in-flight responses from repopulating it. + +Only initialize, initialized notifications, tools/list and the known platform agent/discovery reads are marked reusable. Arbitrary project tool calls, message sends and workspace changes are never coalesced or replayed. Mutations invalidate cached reads. A connection failure returns an error; a later operation may reconnect, but the failed operation is never resent automatically. Closing one client or cancelling its wait removes that subscriber, while other subscribers keep their shared read. The final subscriber's cancellation aborts the upstream request. + +The shared session lease covers rereading, renewing and persisting the machine token pair. A waiter detects that another process already replaced its stale access token and reuses the saved pair. This prevents twenty clients from rotating or rewriting the same credentials. Lease ownership is released when a client disconnects. Existing account binding and durable agent-delivery receipts remain authoritative; the transport does not merge conversation contexts or agent jobs. + +HTTP connections are bounded to two per origin, with eight total sockets per HTTP/HTTPS agent. This is a shared pool, not one pool per panel. An upstream server can close an idle socket; the next request can create a replacement. The guarantee is bounded shared ownership and deduplicated reads, not an immortal TCP connection. + +The worker's required module source is captured when the host starts. Removing an old installation directory does not break an existing chat or its ability to reconnect. `status()` exposes only instance ID, PID, client count and counters; it does not expose URLs, headers, tokens or cached bodies. + +Verification includes twenty separate client processes, twenty packaged Codex chat servers, scoped reads, MCP ID correlation, explicit refresh, individual cancellation, worker failure without POST replay, credential expiry, and twenty simultaneous refreshes with one token write. Live acceptance is recorded by the Codex consumer, which owns UI verification. + + +Codex agent icons now travel in the existing MCP directory/search/detail response +using `include_icons: true`. There are no separate Command Center directory or +per-agent icon requests. An idle panel schedules no discovery or image polling. +The shared transport still caches and coalesces the combined directory response. diff --git a/package-lock.json b/package-lock.json index 9bcd4a9..59aeece 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.2", + "version": "0.3.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.2", + "version": "0.3.11", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index a94183f..2f674c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.2", + "version": "0.3.11", "description": "Shared MainSequence developer actions, project operations, MCP client and command runner", "license": "MIT", "engines": { diff --git a/src/index.ts b/src/index.ts index 8d6a2b6..26f0311 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export { sessionAccount, openCredentialStore } from './shared/machineSession'; export type { CredentialStore } from './shared/machineSession'; export type { ActionRequest, ActionResult, ActionCapability, ProjectContext, ProjectType, SessionStatus, SessionService, HostServices, ProcessSpec, ProcessResult, ErrorCode } from './shared/models'; export { ToolingError } from './shared/models'; +export { createSharedTransport } from './shared/sharedTransport'; +export type { SharedTransportStatus, SessionLock } from './shared/sharedTransport'; export { createSessionService } from './shared/session'; export { HostAuthSession, authSecretKey } from './shared/hostSession'; @@ -24,12 +26,16 @@ export { pythonExecutable, hasPythonProject, projectVersion, readPythonSdkStatus export type { CaptureProcess } from './shared/pythonProjectStatus'; export type { McpRequest } from './shared/mcp'; +export { createMcpConnectionCache } from './shared/mcp'; +export type { McpConnectionCache } from './shared/mcp'; export type { ActionData, DiscoveryResultMap, AgentDirectory, McpConnection, McpDiscoveryPage, McpCatalog, McpTool } from './shared/discoveryTypes'; export { listEnvironments } from './shared/environments'; export type { EnvironmentSummary } from './shared/environments'; export { bindSessionAccount } from './shared/accountScope'; export { AgentClient } from './shared/agents'; export type { AgentRequest, PlatformAgent } from './shared/agents'; +export { AgentDelivery } from './shared/agentDelivery'; +export type { AgentDeliveryScope, AgentDeliveryRequest, AgentDeliveryState, AgentDeliveryDispatch } from './shared/agentDelivery'; export { createProjectOperations, PROJECT_OPERATION_ACTIONS, validateDockerImage, validateDockerPlatform } from './shared/projectOperations'; export type { ProjectOperationRequest, ProjectOperationOptions } from './shared/projectOperations'; @@ -40,3 +46,10 @@ export type { OrganizationProjectWorkspace } from './shared/organizationProjectW export { readOrganizationProjectWorkspace } from './shared/organizationProjectWorkspace'; export { parseArguments, tokenizeCommand, HELP } from './shared/arguments'; + +export { createAgentAssets, stillAgentPng } from './shared/agentAssets'; +export type { AgentAsset } from './shared/agentAssets'; + +export { createDeployments, DEPLOYMENT_ACTIONS, HIDDEN_DEPLOYMENT } from './shared/deployments'; +export type { DeploymentRequest } from './shared/deployments'; +export { writeWorkspacePreferences, readDesktopPreferences } from './shared/workspaceSettings'; diff --git a/src/runner.ts b/src/runner.ts index 9e41e08..3630d77 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -19,7 +19,7 @@ async function main() { result = await tooling.dispatch(parsed.request!); } catch (error) { const message = error instanceof ToolingError ? error.message : 'The command could not be parsed.'; - result = { schemaVersion: 1, action: 'invalid', status: 'failure', summary: message, error: { code: 'INVALID_REQUEST', message } }; + result = { schemaVersion: 1, action: 'invalid', status: 'failure', summary: message, error: { code: error instanceof ToolingError ? error.code : 'INVALID_REQUEST', message } }; } process.stdout.write(argv.includes('--json') ? JSON.stringify(result) + '\n' : result.summary + '\n' + (result.data === undefined ? '' : JSON.stringify(result.data, null, 2) + '\n')); process.exitCode = result.status === 'success' ? 0 : result.status === 'cancelled' ? 130 : 1; diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 5a10173..81105c9 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -1,4 +1,4 @@ -import { McpConnections, McpRequest } from './mcp'; +import { McpConnections, McpRequest, createMcpConnectionCache, type McpConnectionCache } from './mcp'; import { AgentClient, AgentRequest, isAgentAction } from './agents'; import { ActionRequest, ActionResult, HostServices, ProjectAction, SessionService, ToolingError } from './models'; import { MachineSessionService } from './session'; @@ -8,9 +8,10 @@ import { OrganizationProjectClient, OrganizationProjectRequest } from './organiz import { setupOrganizationProject } from './organizationProjectWorkspace'; import { bindSessionAccount } from './accountScope'; import type { ActionData } from './discoveryTypes'; +import { currentDirectory } from './workingDirectory'; -export function createDevTools(options: ConstructorParameters[1] & { host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; mcpConnectionsPath?: string }) { - const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, ...sessionOptions } = options; +export function createDevTools(options: ConstructorParameters[1] & { host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; mcpConnectionsPath?: string; mcpCache?:McpConnectionCache }) { + const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, mcpCache=createMcpConnectionCache(), ...sessionOptions } = options; const session = suppliedSession ?? new MachineSessionService(host, sessionOptions); const env = { ...(options.env ?? process.env) }; function dispatch(request: R): Promise>>; @@ -37,17 +38,18 @@ export function createDevTools(options: ConstructorParameters scoped.status(backend, true).then(() => {}) }).dispatch(input, backend)); + // A host update may remove its old installation directory. An explicit + // backend (or environment override) has no dependency on that directory. + const directory = request.backend || env.MAINSEQUENCE_ENDPOINT ? undefined : currentDirectory(); + const backend = await resolveBackend(request.backend, directory, env); + return safeResult(await new McpConnections(session, { connectionsPath: mcpConnectionsPath, env, fetch: options.fetch, signal: options.signal, requestTimeoutMs: options.requestTimeoutMs, cache:mcpCache }).dispatch(request as McpRequest, backend)); } let data: unknown; if (['login', 'logout', 'refresh-token', 'auth-status', 'repositories'].includes(action)) { diff --git a/src/shared/agentAssets.ts b/src/shared/agentAssets.ts new file mode 100644 index 0000000..ee7682b --- /dev/null +++ b/src/shared/agentAssets.ts @@ -0,0 +1,30 @@ +import { createSessionService, requireBackend } from './session'; +import type { CredentialStore } from './machineSession'; +import { ToolingError, HostServices } from './models'; +import type { SessionLock } from './sharedTransport'; +import { AgentClient, PlatformAgent } from './agents'; +import type { AgentAsset } from './agentIcon'; +export { AgentAsset, stillAgentPng } from './agentIcon'; + +/** Compatibility API. New hosts consume agents-list with includeIcons directly. */ +export function createAgentAssets(options: { host: HostServices; signal?: AbortSignal; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; store?: CredentialStore | null; sessionLock?: SessionLock }) { + const session = createSessionService(options.host, options); + const client = new AgentClient(session, { signal: options.signal }); + return { async read(input: { backend: string; expectedAccount: string; environmentUid: string; agentUids: string[] }): Promise> { + const backend = requireBackend(input.backend); + if (!input.expectedAccount || !Array.isArray(input.agentUids) || input.agentUids.length > 20 + || input.agentUids.some(uid => !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(uid))) throw new ToolingError('INVALID_REQUEST', 'Choose up to twenty visible agents.'); + const remaining = new Set(input.agentUids.map(uid => uid.toLowerCase())), icons: Record = {}; + for (let offset = 0; remaining.size; offset += 100) { + if (offset >= 10000) throw new ToolingError('API_ERROR', 'The agent directory is too large.'); + const result = await client.dispatch({ action:'agents-list', backend, expectedAccount:input.expectedAccount, + environmentUid:input.environmentUid, includeIcons:true, limit:100, offset }, backend); + if (result.status !== 'success') throw new ToolingError(result.error?.code || 'API_ERROR', result.summary); + const page = result.data as { results: PlatformAgent[]; count: number }; + for (const agent of page.results) if (remaining.delete(agent.uid) && agent.icon) icons[agent.uid] = agent.icon; + if (!page.results.length || offset + page.results.length >= page.count) break; + } + if ((await session.status(backend, true)).username !== input.expectedAccount) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed.'); + return icons; + }}; +} diff --git a/src/shared/agentDelivery.ts b/src/shared/agentDelivery.ts new file mode 100644 index 0000000..7d678b7 --- /dev/null +++ b/src/shared/agentDelivery.ts @@ -0,0 +1,304 @@ +import { constants } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; +import { ActionRequest, ActionResult, ToolingError } from './models'; +import { requireBackend } from './session'; + +/** Supplied by the host, never by message text or a remote agent. */ +export type AgentDeliveryScope = { host: string; conversationId: string; backend: string; account: string }; +export type AgentDeliveryState = 'pending' | 'preparing' | 'sending' | 'delivered' | 'failed' | 'uncertain' | 'cancelled' | 'expired'; +type Owner = { pid: number; token: string }; +export type AgentDeliveryRequest = { + requestId: string; messageId: string; agentUid: string; agentName: string; environmentUid: string; + message: string; conversationKey?: string; taskRequestId?: string; continuationTaskUid?: string; responseKind?: 'message' | 'task'; task?: Record; handleId: string; sessionUid?: string; createdAt: string; expiresAt: string; + state: AgentDeliveryState; sendAttempts: number; reason?: string; + submission: { attempts: number; outcome: 'not-submitted' | 'attempted' | 'acknowledged' | 'unknown' }; + receipt?: { completedAt: string; sessionUid: string; message: string }; +}; +type StoredRequest = AgentDeliveryRequest & { owner?: Owner }; +type State = { version: 1; scope: string; requests: Record }; +export type AgentDeliveryDispatch = (request: ActionRequest) => Promise; +type Options = { + createDispatch(signal?: AbortSignal): AgentDeliveryDispatch; + /** Trusted integration/test configuration. Never accept this path from an app or tool argument. */ + directory?: string; + readinessTimeoutMs?: number; +}; +const hash = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +const uuid = (value: unknown): string => { + if (typeof value !== 'string' || !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(value)) throw new ToolingError('INVALID_REQUEST', 'A valid saved request or agent identifier is required.'); + return value.toLowerCase(); +}; +const failStore = () => new ToolingError('STORE_ERROR', 'Saved agent requests could not be read safely. Existing requests were kept; no message was resent.'); +const live = (pid: number) => { try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code !== 'ESRCH'; } }; +const view = (r: StoredRequest): AgentDeliveryRequest => { const { owner: _, ...publicRequest } = r; return structuredClone(publicRequest); }; +const finished = (r: StoredRequest) => !['pending', 'preparing', 'sending'].includes(r.state); +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +const defaultDirectory = () => path.join(process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI') + : process.platform === 'win32' ? path.join(process.env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence'), 'agent-delivery-v1'); + +/** + * Durable, account/chat-scoped delivery. A receipt can be read repeatedly; a send is never replayed. + * Native turn cancellation only cancels the caller's wait. Accepted work has its own bounded lifetime. + * Process loss during a send is uncertain, not evidence of failure or permission to send again. + */ +export class AgentDelivery { + private readonly directory: string; + private readonly jobs = new Map>(); + constructor(private readonly options: Options) { this.directory = path.resolve(options.directory || defaultDirectory()); } + + private scope(input: AgentDeliveryScope): { scope: AgentDeliveryScope; key: string } { + if (!input || typeof input.host !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/.test(input.host) + || typeof input.conversationId !== 'string' || !input.conversationId.trim() || input.conversationId.length > 512 + || typeof input.account !== 'string' || !input.account.trim() || input.account.length > 300) throw new ToolingError('INVALID_REQUEST', 'A verified account and host conversation are required.'); + const scope = { ...input, backend: requireBackend(input.backend) }; + return { scope, key: hash([scope.host, scope.conversationId, scope.backend, scope.account]) }; + } + private async verify(scope: AgentDeliveryScope, signal?: AbortSignal) { + signal?.throwIfAborted(); + const result = await this.options.createDispatch(signal)({ action: 'auth-status', backend: scope.backend, check: true }); + const data = result.data as { authenticated?: boolean; username?: string } | undefined; + if (result.status !== 'success') throw new ToolingError(result.error?.code || 'AUTH_REJECTED', result.summary); + if (!data?.authenticated || data.username !== scope.account) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh before accessing saved agent requests.'); + signal?.throwIfAborted(); + } + private async secureDirectory() { + await fs.mkdir(this.directory, { recursive: true, mode: 0o700 }); + const stat = await fs.lstat(this.directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid())) throw failStore(); + await fs.chmod(this.directory, 0o700); + } + private async readFile(file: string, lockRead = false): Promise { + const handle = await fs.open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); + try { + const stat = await handle.stat(); + if (lockRead && stat.nlink === 0) throw Object.assign(new Error('The lock was released while opening it.'), { code: 'ENOENT' }); + if (!stat.isFile() || stat.nlink !== 1 || stat.size > 36_000_000 || (process.getuid && stat.uid !== process.getuid())) throw failStore(); + return await handle.readFile('utf8'); + } finally { await handle.close(); } + } + private async locked(key: string, run: (state: State, save: () => Promise) => Promise): Promise { + await this.secureDirectory(); + const file = path.join(this.directory, key + '.json'), lock = file + '.lock'; + const owner = { pid: process.pid, token: randomUUID() }; let guard; + const deadline = Date.now() + 3000; + while (!guard) { + try { guard = await fs.open(lock, 'wx', 0o600); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw failStore(); + let previous: Owner; + try { previous = JSON.parse(await this.readFile(lock, true)); } + catch (e) { + // A new exclusive file can briefly precede its owner record. Never reclaim it. + if (Date.now() >= deadline) throw failStore(); + if (e instanceof SyntaxError || (e as NodeJS.ErrnoException).code === 'ENOENT') { await sleep(25); continue; } + throw failStore(); + } + if (!Number.isSafeInteger(previous.pid) || previous.pid <= 0 || typeof previous.token !== 'string') throw failStore(); + if (!live(previous.pid)) { + // One durable reclamation claim per dead owner prevents deleting a replacement lock. + const claim = lock + '.reclaim-' + uuid(previous.token); + try { const handle = await fs.open(claim, 'wx', 0o600); await handle.close(); } + catch { throw new ToolingError('PROJECT_BUSY', 'Agent storage recovery is already running. Try reading status again.'); } + if (await this.readFile(lock) !== JSON.stringify(previous)) throw failStore(); + await fs.unlink(lock); continue; + } + if (Date.now() >= deadline) throw new ToolingError('PROJECT_BUSY', 'Another agent request is updating local storage. Try reading status again.'); + await sleep(25); + } + } + try { + await guard.writeFile(JSON.stringify(owner)); await guard.sync(); + let state: State; + try { state = JSON.parse(await this.readFile(file)); } + catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw failStore(); state = { version: 1, scope: key, requests: {} }; } + if (state?.version !== 1 || state.scope !== key || !state.requests || Array.isArray(state.requests) || typeof state.requests !== 'object' || Object.keys(state.requests).length > 256) throw failStore(); + for (const [id, r] of Object.entries(state.requests)) { + if (!r || uuid(id) !== id || r.requestId !== id || r.messageId !== id || typeof r.message !== 'string' || r.message.length > 64000 + || typeof r.agentName !== 'string' || !['pending','preparing','sending','delivered','failed','uncertain','cancelled','expired'].includes(r.state) + || !/^host_[a-f0-9]{64}$/.test(r.handleId) || !Number.isFinite(Date.parse(r.createdAt)) + || !r.submission || !Number.isSafeInteger(r.submission.attempts) || r.submission.attempts < 0 + || !['not-submitted','attempted','acknowledged','unknown'].includes(r.submission.outcome) + || !Number.isSafeInteger(r.sendAttempts) || r.sendAttempts < 0 || r.sendAttempts > 1 || !Number.isFinite(Date.parse(r.expiresAt))) throw failStore(); + // 'failed' is reserved for a completed devtools result proving no remote mutation. + // Early development records counted entry into agent-send before its preflight. + if (r.state === 'failed') r.sendAttempts = 0; + uuid(r.agentUid); uuid(r.environmentUid); + if (r.sessionUid) uuid(r.sessionUid); + if (r.conversationKey) uuid(r.conversationKey); if(r.continuationTaskUid) uuid(r.continuationTaskUid); if(r.taskRequestId) uuid(r.taskRequestId); + if (r.state === 'delivered' && (!r.receipt || typeof r.receipt.message !== 'string' || !r.receipt.message || r.receipt.sessionUid !== r.sessionUid)) throw failStore(); + if (['preparing', 'sending'].includes(r.state)) { + if (!r.owner || !Number.isSafeInteger(r.owner.pid) || r.owner.pid <= 0 || typeof r.owner.token !== 'string') throw failStore(); + if (!live(r.owner.pid)) { + r.state = r.state === 'sending' ? 'uncertain' : 'pending'; + r.reason = r.state === 'uncertain' ? 'The process ended during dispatch. Delivery is unknown; resend is blocked.' : 'Preparation was interrupted before sending. Recover this same request.'; + delete r.owner; + } + } + if ((r.state === 'pending' || (r.state === 'failed' && r.sendAttempts === 0)) && Date.parse(r.expiresAt) <= Date.now()) { r.state = 'expired'; r.reason = 'This request expired without being sent.'; } + } + const save = async () => { + const serialized = JSON.stringify(state); + if (Buffer.byteLength(serialized) > 36_000_000) throw failStore(); + const temporary = file + '.' + randomUUID() + '.tmp', handle = await fs.open(temporary, 'wx', 0o600); + try { await handle.writeFile(serialized); await handle.sync(); } finally { await handle.close(); } + try { await fs.rename(temporary, file); const directory = await fs.open(this.directory, 'r'); try { await directory.sync(); } finally { await directory.close(); } } + finally { await fs.unlink(temporary).catch(() => {}); } + }; + const result = await run(state, save); await save(); return result; + } finally { await guard.close(); await fs.unlink(lock); } + } + /** Prepare a stable conversation without sending an introductory message. */ + async prepare(input: AgentDeliveryScope, selection: {environmentUid:string;agentUid:string;conversationKey?:string}, signal?:AbortSignal) { + const {scope,key}=this.scope(input), environmentUid=uuid(selection.environmentUid),agentUid=uuid(selection.agentUid); + const conversationKey=selection.conversationKey===undefined?undefined:uuid(selection.conversationKey); + await this.verify(scope,signal); + const dispatch=this.options.createDispatch(signal), target={backend:scope.backend,expectedAccount:scope.account,environmentUid,agentUid}; + const handleId='host_'+hash(conversationKey?[key,environmentUid,agentUid,conversationKey]:[key,environmentUid,agentUid]); + const session=await dispatch({action:'agent-session',...target,handleId}); + if(session.status!=='success')throw new ToolingError(session.error?.code || 'API_ERROR',session.summary); + const sessionUid=uuid((session.data as {sessionUid?:string})?.sessionUid); + const ready=await dispatch({action:'agent-readiness',...target,sessionUid,timeoutMs:90000}); + if(ready.status!=='success')throw new ToolingError(ready.error?.code || 'API_ERROR',ready.summary); + return {sessionUid,handleId,ready:true}; + } + async stage(input: AgentDeliveryScope, draft: { requestId: string; environmentUid: string; agentUid: string; message: string; conversationKey?: string; taskRequestId?: string; responseKind?: 'message' | 'task' }, signal?: AbortSignal): Promise { + const { scope, key } = this.scope(input), requestId = uuid(draft.requestId), agentUid = uuid(draft.agentUid), environmentUid = uuid(draft.environmentUid); + if (typeof draft.message !== 'string' || !draft.message.trim() || draft.message.length > 64000) throw new ToolingError('INVALID_REQUEST', 'Enter a message of 1–64000 characters.'); + const conversationKey = draft.conversationKey === undefined ? undefined : uuid(draft.conversationKey); + const responseKind = draft.responseKind ?? 'message'; + if (!['message', 'task'].includes(responseKind)) throw new ToolingError('INVALID_REQUEST', 'Choose message or task response mode.'); + await this.verify(scope, signal); + const detail = await this.options.createDispatch(signal)({ action: 'agent-get', backend: scope.backend, expectedAccount: scope.account, environmentUid, agentUid }); + const agent = (detail.data as { agent?: { uid: string; name: string; responseKinds: string[] } })?.agent; + if (detail.status !== 'success') throw new ToolingError(detail.error?.code || 'API_ERROR', detail.summary); + if (!agent || agent.uid !== agentUid || !agent.responseKinds.includes(responseKind)) throw new ToolingError('UNSUPPORTED_ACTION', 'This agent does not support the selected response mode.'); + const previousTask = draft.taskRequestId ? await this.get(scope, uuid(draft.taskRequestId), signal) : undefined; + if (previousTask && (responseKind !== 'task' || previousTask.agentUid !== agentUid || previousTask.environmentUid !== environmentUid || !previousTask.task)) throw new ToolingError('INVALID_REQUEST','Continue only a saved task belonging to this agent and environment.'); + signal?.throwIfAborted(); + return this.locked(key, async state => { + const same = (r: StoredRequest) => r.agentUid === agentUid && r.environmentUid === environmentUid && r.message === draft.message && (r.responseKind ?? 'message') === responseKind && r.conversationKey === conversationKey && r.taskRequestId === draft.taskRequestId; + const previous = state.requests[requestId]; + if (previous) { if (!same(previous)) throw new ToolingError('INVALID_REQUEST', 'This request ID already belongs to different immutable content.'); return view(previous); } + const duplicate = Object.values(state.requests).find(r => same(r) && ['pending', 'preparing', 'sending', 'uncertain'].includes(r.state)); + if (duplicate) return view(duplicate); + if (Object.keys(state.requests).length >= 256) throw new ToolingError('STORE_ERROR', 'This conversation reached its saved request limit. Existing receipts are retained.'); + const request: StoredRequest = { requestId, messageId: requestId, agentUid, environmentUid, agentName: agent.name.slice(0,300), message: draft.message, responseKind, ...(previousTask ? {taskRequestId:previousTask.requestId,continuationTaskUid:uuid(previousTask.task!.taskUid)} : {}), ...(conversationKey ? { conversationKey } : {}), + handleId: previousTask?.handleId ?? 'host_' + hash(conversationKey ? [key, environmentUid, agentUid, conversationKey] : [key, environmentUid, agentUid]), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 86400000).toISOString(), + state: 'pending', sendAttempts: 0, submission: { attempts: 0, outcome: 'not-submitted' } }; + state.requests[requestId] = request; return view(request); + }); + } + async list(input: AgentDeliveryScope, signal?: AbortSignal): Promise { + const { scope, key } = this.scope(input); await this.verify(scope, signal); + return this.locked(key, async state => Object.values(state.requests).map(view)); + } + async get(input: AgentDeliveryScope, id: string, signal?: AbortSignal): Promise { + const requestId = uuid(id); const requests = await this.list(input, signal), request = requests.find(r => r.requestId === requestId); + if (!request) throw new ToolingError('INVALID_REQUEST', 'No saved request with this ID belongs to this account and conversation.'); + return request; + } + async submission(input: AgentDeliveryScope, id: string, outcome: 'attempted' | 'acknowledged' | 'unknown', signal?: AbortSignal) { + if (!['attempted','acknowledged','unknown'].includes(outcome)) throw new ToolingError('INVALID_REQUEST', 'Invalid submission outcome.'); + const { scope, key } = this.scope(input), requestId = uuid(id); await this.verify(scope, signal); + return this.locked(key, async state => { const r = state.requests[requestId]; if (!r) throw new ToolingError('INVALID_REQUEST', 'Unknown saved request.'); + r.submission.outcome = outcome; if (outcome === 'attempted') r.submission.attempts++; return view(r); }); + } + async cancel(input: AgentDeliveryScope, id: string, signal?: AbortSignal) { + const { scope, key } = this.scope(input), requestId = uuid(id); await this.verify(scope, signal); + return this.locked(key, async state => { const r = state.requests[requestId]; if (!r) throw new ToolingError('INVALID_REQUEST', 'Unknown saved request.'); + if (r.state !== 'pending' && r.state !== 'cancelled') throw new ToolingError('INVALID_REQUEST', 'An accepted or completed message cannot be unsent. Read its saved status.'); + r.state = 'cancelled'; r.reason = 'Cancelled before dispatch.'; return view(r); }); + } + async deliver(input: AgentDeliveryScope, id: string, signal?: AbortSignal): Promise { + const { scope, key } = this.scope(input), requestId = uuid(id); await this.verify(scope, signal); + const owner = { pid: process.pid, token: randomUUID() }; + const claimed = await this.locked(key, async state => { + signal?.throwIfAborted(); + const r = state.requests[requestId]; if (!r) throw new ToolingError('INVALID_REQUEST', 'Unknown saved request in this conversation.'); + // Explicit recovery may repeat preparation when the durable marker proves no send began. + // Ambiguous transport failures remain uncertain and are never eligible. + if (r.state !== 'pending' && !(r.state === 'failed' && r.sendAttempts === 0)) return false; + if (Object.values(state.requests).some(other => other.requestId !== requestId && other.agentUid === r.agentUid && other.environmentUid === r.environmentUid && ['preparing','sending','uncertain'].includes(other.state))) { + throw new ToolingError('PROJECT_BUSY', 'This agent conversation has a running or uncertain delivery. Recover its existing request before sending another.'); + } + r.state = 'preparing'; r.owner = owner; delete r.reason; return true; + }); + if (claimed) { + const jobKey = key + requestId; + const job = this.run(scope, key, requestId, owner).catch(() => { /* Durable sending marker blocks replay if receipt persistence fails. */ }); + this.jobs.set(jobKey, job); void job.finally(() => { if (this.jobs.get(jobKey) === job) this.jobs.delete(jobKey); }); + } + return this.wait(scope, requestId, signal); + } + async wait(input: AgentDeliveryScope, id: string, signal?: AbortSignal, waitMs = 25000): Promise { + if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) throw new ToolingError('INVALID_REQUEST', 'Wait must be between zero and 30 seconds.'); + const { scope, key } = this.scope(input), requestId = uuid(id); await this.verify(scope, signal); + const deadline = Date.now() + waitMs; + while (true) { + signal?.throwIfAborted(); + const r = await this.locked(key, async state => { const value = state.requests[requestId]; if (!value) throw new ToolingError('INVALID_REQUEST', 'Unknown saved request.'); return view(value); }); + if (finished(r) || r.state === 'pending' || Date.now() >= deadline) { await this.verify(scope, signal); return r; } + await sleep(Math.min(250, Math.max(1, deadline - Date.now()))); + } + } + /** Reads/cancels only a task obtained from this chat's durable receipt; never sends a message. */ + async task(input: AgentDeliveryScope, id: string, action: 'get' | 'wait' | 'cancel', signal?: AbortSignal) { + if (!['get', 'wait', 'cancel'].includes(action)) throw new ToolingError('INVALID_REQUEST', 'Choose get, wait or cancel.'); + const { scope, key } = this.scope(input), r = await this.get(input, id, signal); + if (!r.task || !r.sessionUid) throw new ToolingError('INVALID_REQUEST', 'This request has no accepted agent task.'); + const taskUid = uuid(r.task.taskUid); + const result = await this.options.createDispatch(signal)({ action: action === 'cancel' ? 'agent-task-cancel' : action === 'wait' ? 'agent-task-wait' : 'agent-task-get', + backend: scope.backend, expectedAccount: scope.account, environmentUid: r.environmentUid, agentUid: r.agentUid, taskUid }); + if (result.status !== 'success') throw new ToolingError(result.error?.code || 'API_ERROR', result.summary); + const task = result.data as Record; + if (task.taskUid !== taskUid || task.sessionUid !== r.sessionUid) throw new ToolingError('API_ERROR', 'The task response does not match this saved request.'); + return this.locked(key, async state => { const record = state.requests[r.requestId]; + // Older responses from concurrent reads must not overwrite newer task progress. + if (Number(task.eventCursor ?? 0) >= Number(record.task?.eventCursor ?? 0)) { + record.task = task; record.receipt = { completedAt: new Date().toISOString(), sessionUid: r.sessionUid!, message: JSON.stringify(task) }; + } + return view(record); + }); + } + private async run(scope: AgentDeliveryScope, key: string, id: string, owner: Owner): Promise { + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 12 * 60_000); + const dispatch = this.options.createDispatch(controller.signal); + const update = async (fn: (r: StoredRequest) => void) => this.locked(key, async state => { + const r = state.requests[id]; if (r?.owner?.token !== owner.token) throw failStore(); fn(r); return view(r); + }); + let sending = false; + try { + const r = await update(() => {}), selection = { backend: scope.backend, environmentUid: r.environmentUid, agentUid: r.agentUid, expectedAccount: scope.account }; + const prepared = await dispatch({ action: 'agent-session', ...selection, handleId: r.handleId }); + if (prepared.status !== 'success') throw new ToolingError(prepared.error?.code || 'API_ERROR', prepared.summary); + const sessionUid = uuid((prepared.data as { sessionUid?: string })?.sessionUid); + await update(record => { record.sessionUid = sessionUid; }); + const ready = await dispatch({ action: 'agent-readiness', ...selection, sessionUid, timeoutMs: this.options.readinessTimeoutMs ?? 300000 }); + if (ready.status !== 'success') throw new ToolingError(ready.error?.code || 'API_ERROR', ready.summary); + controller.signal.throwIfAborted(); + await update(record => { record.state = 'sending'; record.sendAttempts = 1; }); sending = true; + const result = await dispatch({ action: 'agent-send', ...selection, ...(r.continuationTaskUid ? {taskUid:r.continuationTaskUid} : {sessionUid}), message: r.message, messageId: r.messageId, responseKind: r.responseKind ?? 'message' }); + const reply = result.data as { message?: string; sessionUid?: string; responseKind?: string; taskUid?: string } & Record | undefined; + await update(record => { + if (result.status === 'success' && reply?.responseKind === 'task' && r.responseKind === 'task' && reply.sessionUid === sessionUid && reply.taskUid) { + uuid(reply.taskUid); record.state = 'delivered'; record.task = reply; record.receipt = { completedAt: new Date().toISOString(), sessionUid, message: JSON.stringify(reply) }; + } else if (result.status === 'success' && reply?.responseKind === 'message' && typeof reply.message === 'string' && reply.message.trim() && reply.sessionUid === sessionUid) { + record.state = 'delivered'; record.receipt = { completedAt: new Date().toISOString(), sessionUid, message: reply.message }; + } else { + record.state = result.effects || result.status === 'success' ? 'uncertain' : 'failed'; + if (record.state === 'failed') record.sendAttempts = 0; // AgentClient proved its preflight failed before a2a.send_message. + record.reason = record.state === 'uncertain' ? 'The agent may have received this message. Resend is blocked.' : result.summary.slice(0,2000); + } + delete record.owner; + }); + } catch (error) { + await update(record => { record.state = sending ? 'uncertain' : 'failed'; + record.reason = sending ? 'Dispatch did not produce a durable reply. Resend is blocked.' : error instanceof ToolingError ? error.message.slice(0,2000) : 'Preparation failed before sending. No agent message was sent.'; + delete record.owner; + }).catch(() => {}); + } finally { clearTimeout(timer); } + } +} diff --git a/src/shared/agentIcon.ts b/src/shared/agentIcon.ts new file mode 100644 index 0000000..bb77de8 --- /dev/null +++ b/src/shared/agentIcon.ts @@ -0,0 +1,30 @@ +export type AgentAsset = { source: string; stillSource: string; rendering: 'mask' | 'color'; imageRendering: 'auto' | 'pixelated' }; +/** APNG's default image is frame zero. Hosts choose it when reduced motion is enabled. */ +export function stillAgentPng(bytes: Buffer): Buffer { + if (!bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return bytes; + const parts = [bytes.subarray(0,8)]; let offset = 8; + while (offset + 12 <= bytes.length) { + const end = offset + 12 + bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8); + if (end > bytes.length) break; + if (!['acTL','fcTL','fdAT'].includes(type)) parts.push(bytes.subarray(offset,end)); + offset = end; + if (type === 'IEND' && end === bytes.length) return Buffer.concat(parts); + } + throw new Error('The agent icon is incomplete.'); +} + +/** Only bounded inline bytes cross into the host. Never fetch the icon URL. */ +export function inlineAgentAsset(value: unknown): AgentAsset | null { + if (!value || typeof value !== 'object') return null; + const icon = value as Record, idle = icon.idle; + if (!['mask','color'].includes(icon.rendering) || !idle || !['image/png','image/svg+xml'].includes(idle.mime) + || typeof idle.bytes !== 'string' || !idle.bytes.length || idle.bytes.length > 699052 + || idle.bytes.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(idle.bytes)) return null; + try { + const bytes = Buffer.from(idle.bytes, 'base64'); + if (!bytes.length || bytes.length > 524288 || bytes.toString('base64') !== idle.bytes) return null; + const uri = (data: Buffer) => `data:${idle.mime};base64,${data.toString('base64')}`; + return { source: uri(bytes), stillSource: uri(idle.mime === 'image/png' ? stillAgentPng(bytes) : bytes), + rendering: icon.rendering, imageRendering: icon.imageRendering === 'pixelated' ? 'pixelated' : 'auto' }; + } catch { return null; } +} diff --git a/src/shared/agents.ts b/src/shared/agents.ts index bcf5960..3ab364b 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -3,15 +3,16 @@ import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport } from './mcpTransport'; import { waitForAgentReadiness } from './agentReadiness'; +import { inlineAgentAsset, AgentAsset } from './agentIcon'; type AgentSelection = { backend?: string; environmentUid: string; expectedAccount?: string }; export type AgentRequest = AgentSelection & ( - | { action: 'agents-list'; search?: string; limit?: number; offset?: number } - | { action: 'agent-get'; agentUid: string } + | { action: 'agents-list'; search?: string; limit?: number; offset?: number; includeIcons?: boolean } + | { action: 'agent-get'; agentUid: string; includeIcons?: boolean } | { action: 'agent-session'; agentUid: string; handleId?: string; sessionUid?: string } | { action: 'agent-readiness'; agentUid: string; sessionUid: string; timeoutMs?: number } | { action: 'agent-send'; agentUid: string; message: string; responseKind: 'message' | 'task'; - handleId?: string; sessionUid?: string; taskUid?: string; waitForReady?: boolean } + handleId?: string; sessionUid?: string; taskUid?: string; waitForReady?: boolean; messageId?: string } | { action: 'agent-task-get' | 'agent-task-wait' | 'agent-task-cancel'; agentUid: string; taskUid: string; afterSequence?: number } ); export type PlatformAgent = { @@ -19,6 +20,7 @@ export type PlatformAgent = { repositoryBranch: string; runtimeReleaseUid: string | null; runtimeState: string; responseKinds: string[]; defaults: { provider: string; model: string; thinking: string }; skills: { name: string; description: string; examples: string[] }[]; + icon?: AgentAsset | null; }; export function isAgentAction(action: string): boolean { return ['agents-list', 'agent-get', 'agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(action); @@ -30,10 +32,11 @@ function uuid(value: unknown): string { return value.toLowerCase(); } function text(value: unknown, limit = 16000): string { return typeof value === 'string' ? value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').slice(0, limit) : ''; } -function agent(row: any, environmentUid: string): PlatformAgent { +function agent(row: any, environmentUid: string, includeIcons = false): PlatformAgent { if (!row || uuid(row.organization_environment_uid) !== environmentUid) throw new ToolingError('INVALID_REQUEST', 'This agent belongs to a different environment. Refresh the directory.'); if (!text(row.name).trim()) throw new ToolingError('API_ERROR', 'The agent directory returned an unnamed agent.'); return { uid: uuid(row.uid), name: text(row.name, 300), description: text(row.description), environmentUid, + ...(includeIcons ? { icon: inlineAgentAsset(row.command_center_icon) } : {}), environmentName: text(row.organization_environment_name, 300), repositoryBranch: text(row.repository_branch, 300), runtimeReleaseUid: row.runtime_release_uid ? uuid(row.runtime_release_uid) : null, runtimeState: text(row.runtime_update?.state, 50) || 'unknown', @@ -53,6 +56,7 @@ export class AgentClient { const backend = requireBackend(selectedBackend), environmentUid = uuid(input.environmentUid); this.options.signal?.throwIfAborted(); if (!isAgentAction(input.action)) throw new ToolingError('INVALID_REQUEST', 'Unknown agent action.'); + if ('includeIcons' in input && input.includeIcons !== undefined && typeof input.includeIcons !== 'boolean') throw new ToolingError('INVALID_REQUEST', 'includeIcons must be a boolean.'); const identity = await this.session.status(backend, true); if (!identity.authenticated || !identity.username) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to use agents.'); if (input.expectedAccount !== undefined && input.expectedAccount !== identity.username) throw new ToolingError('AUTH_REJECTED', 'The MainSequence account changed. Refresh agents and start a new conversation.'); @@ -101,13 +105,15 @@ export class AgentClient { if (!Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0 || (input.search !== undefined && (typeof input.search !== 'string' || input.search.length > 2000))) throw new ToolingError('INVALID_REQUEST', 'Search requires text, a limit of 1–100, and a nonnegative offset.'); const search = input.search?.trim(); - const page = search ? await call('agent.search', { organization_environment_uid: environmentUid, q: search, limit }) - : await call('agent.list', { organization_environment_uid: environmentUid, limit, offset }); + const icons = input.includeIcons ? { include_icons:true } : {}; + const page = search ? await call('agent.search', { organization_environment_uid: environmentUid, q: search, limit, ...icons }) + : await call('agent.list', { organization_environment_uid: environmentUid, limit, offset, ...icons }); if (!Array.isArray(page.results) || (!search && (!Number.isInteger(page.count) || page.count < 0))) throw new ToolingError('API_ERROR', 'The platform returned invalid agent pagination.'); - data = { account: identity.username, results: page.results.map((row: any) => agent(row, environmentUid)), count: search ? page.results.length : page.count, offset: search ? 0 : offset, semantic: !!search }; + data = { account: identity.username, results: page.results.map((row: any) => agent(row, environmentUid, input.includeIcons)), count: search ? page.results.length : page.count, offset: search ? 0 : offset, semantic: !!search }; } else { const agentUid = uuid(input.agentUid); - const selected = agent(await call('agent.get', { agent_uid: agentUid }), environmentUid); + const includeIcons = input.action === 'agent-get' && input.includeIcons === true; + const selected = agent(await call('agent.get', { agent_uid: agentUid, ...(includeIcons ? { include_icons:true } : {}) }), environmentUid, includeIcons); if (selected.uid !== agentUid) throw new ToolingError('API_ERROR', 'The platform returned another agent.'); const task = async (taskUid: string) => { const result = await call('a2a.get_task', { task_uid: uuid(taskUid) }); @@ -158,6 +164,7 @@ export class AgentClient { ...(resolved.bound_handle?.handle_unique_id ? { handleId: text(resolved.bound_handle.handle_unique_id, 255) } : {}) }; } else if (input.action === 'agent-send') { + const messageId = input.messageId === undefined ? randomUUID() : uuid(input.messageId); if (typeof input.message !== 'string' || !input.message.trim() || input.message.length > 64000) throw new ToolingError('INVALID_REQUEST', 'Enter a message of 1–64000 characters.'); if (!['message', 'task'].includes(input.responseKind) || !selected.responseKinds.includes(input.responseKind)) throw new ToolingError('UNSUPPORTED_ACTION', 'The agent does not advertise the requested response mode.'); if (!selected.runtimeReleaseUid || selected.runtimeState === 'not_deployed') throw new ToolingError('UNSUPPORTED_ACTION', 'This agent has no deployed runtime.'); @@ -178,7 +185,7 @@ export class AgentClient { : input.sessionUid ? { session_uid: uuid(input.sessionUid) } : { handle_unique_id: input.handleId }); await ready(uuid(resolved.uid)); } - const reply = await call('a2a.send_message', { ...target, message: input.message, message_id: randomUUID(), response_kind: input.responseKind, + const reply = await call('a2a.send_message', { ...target, message: input.message, message_id: messageId, response_kind: input.responseKind, ...(input.responseKind === 'task' ? { completion_policy: 'poll' } : {}) }, true); if (uuid(reply.agent_uid) !== agentUid || reply.response_kind !== input.responseKind || (input.sessionUid && uuid(reply.agent_session_uid) !== uuid(input.sessionUid))) throw new ToolingError('API_ERROR', 'The agent response does not match this conversation.'); data = { account: identity.username, agent: selected, ...projectReply(reply) }; diff --git a/src/shared/arguments.ts b/src/shared/arguments.ts index b89bc87..b0819d7 100644 --- a/src/shared/arguments.ts +++ b/src/shared/arguments.ts @@ -1,6 +1,7 @@ import { readFileSync, statSync } from 'node:fs'; import type { McpRequest } from './mcp'; import { ActionRequest, ProjectType, ToolingError } from './models'; +import { currentDirectory } from './workingDirectory'; export const HELP = `MainSequence developer tooling (Node 22+) @@ -25,7 +26,7 @@ open-folder and signed-terminal require a host with those native capabilities. In Codex desktop, type /ms, select the ms slash-menu entry, then append COMMAND ... . The trusted plugin hook executes the selected command before inference. `; -export function parseArguments(argv: string[], cwd = process.cwd()): { request?: ActionRequest; json: boolean; help?: boolean } { +export function parseArguments(argv: string[], cwd?: string): { request?: ActionRequest; json: boolean; help?: boolean } { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') return { json: argv.includes('--json'), help: true }; const action = argv[0]; const flags: Record = {}; @@ -76,7 +77,7 @@ export function parseArguments(argv: string[], cwd = process.cwd()): { request?: } else if (['login', 'logout', 'refresh-token', 'repositories'].includes(action)) request = { action, ...common } as ActionRequest; else if (action === 'auth-status') request = { action, ...common, check: Boolean(flags['--check']) }; else if (action === 'setup') request = { action, ...common, repositoryUid: flags['--uid'] as string, branch: flags['--branch'] as string, baseDir: flags['--base-dir'] as string, install: Boolean(flags['--install']) }; - else request = { action, ...common, root: flags['--path'] as string || cwd, projectType: flags['--type'] as ProjectType | undefined, dryRun: Boolean(flags['--dry-run']) } as ActionRequest; + else request = { action, ...common, root: flags['--path'] as string || cwd || currentDirectory(), projectType: flags['--type'] as ProjectType | undefined, dryRun: Boolean(flags['--dry-run']) } as ActionRequest; return { request, json: Boolean(flags['--json']) }; } diff --git a/src/shared/deployments.ts b/src/shared/deployments.ts new file mode 100644 index 0000000..22e9237 --- /dev/null +++ b/src/shared/deployments.ts @@ -0,0 +1,192 @@ +import { ActionResult, HostServices, ToolingError } from './models'; +import { createSessionService, requireBackend } from './session'; +import type { SessionLock } from './sharedTransport'; +import { bindSessionAccount } from './accountScope'; + +/** Read-only deployment history for the pane and the model tool: no billing, cost or credential fields. */ +export type DeploymentRequest = + { action: 'deployment-history'; backend: string; expectedAccount: string; environmentUid: string; codeRepositoryBranchUid?: string; limit?: number; offset?: number } + | { action: 'deployment-run'; backend: string; expectedAccount: string; runUid: string; kind?: 'deployment_run' | 'workflow_run' } + | { action: 'deployment-logs'; backend: string; expectedAccount: string; runUid: string; stepUid?: string; cursor?: string; limit?: number }; +export const DEPLOYMENT_ACTIONS = new Set(['deployment-history', 'deployment-run', 'deployment-logs']); +/** Command Center's wording for a run that answers 404: it never says whether the record exists. */ +export const HIDDEN_DEPLOYMENT = "You can't see this deployment. A deployment is shown to the people who can see its release or Job."; + +const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i; +const ACTIVE = new Set(['pending', 'queued', 'running']); +const RUN_PATH = /^\/api\/v1\/(?:deployment-runs\/[a-f0-9-]{36}\/(?:logs\/)?|deployment-history\/[a-f0-9-]{36}\/)$/; + +function uuid(value: unknown, message: string): string { + if (typeof value !== 'string' || !UUID.test(value)) throw new ToolingError('INVALID_REQUEST', message); + return value.toLowerCase(); +} +function integer(value: unknown, minimum: number, maximum: number, fallback: number, message: string): number { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || (value as number) < minimum || (value as number) > maximum) throw new ToolingError('INVALID_REQUEST', message); + return value as number; +} +const text = (value: unknown, limit = 4000): string => typeof value === 'string' + ? value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, limit) : ''; +const optionalUuid = (value: unknown): string | null => typeof value === 'string' && UUID.test(value) ? value.toLowerCase() : null; +function when(value: unknown): string | null { + if (typeof value !== 'string') return null; + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toISOString() : null; +} +function duration(start: string | null, end: string | null, now: number): number | null { + if (!start) return null; + const from = Date.parse(start), to = end ? Date.parse(end) : now; + return Number.isFinite(from) && Number.isFinite(to) && to >= from ? to - from : null; +} +function errorText(value: unknown): string { + if (!value) return ''; + if (typeof value === 'string') return text(value, 2000); + if (typeof value === 'object') { + const row = value as Record; + const detail = [row.detail, row.message, row.reason].find(item => typeof item === 'string' && item.trim()); + const code = typeof row.code === 'string' ? row.code : ''; + if (detail || code) return text([code, detail].filter(Boolean).join(': '), 2000); + try { return text(JSON.stringify(value), 1000); } catch { return 'Error details unavailable.'; } + } + return text(String(value), 500); +} +const earliest = (values: (string | null)[]): string | null => values.filter((value): value is string => !!value).sort()[0] ?? null; +const latest = (values: (string | null)[]): string | null => values.filter((value): value is string => !!value).sort().at(-1) ?? null; +const list = (value: unknown): any[] => Array.isArray(value) ? value : []; +const rec = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; + +/** An ADR-074 workflow run: the sealed step graph in plan order and the runs it owns. */ +export function workflowRun(row: unknown, now: number) { + const run = rec(row); + const uid = optionalUuid(run.uid); + if (!uid) return null; + const steps = list(run.steps).slice(0, 60).map(item => { + const step = rec(item); + const startedAt = when(step.dispatch_started_at) ?? when(step.dispatch_authorized_at), finishedAt = when(step.finished_at); + const key = text(step.step_key, 200); + return { id: key, key, operation: text(step.operation, 100), resourceKey: text(step.resource_key, 200), state: text(step.state, 40) || 'unknown', + reason: text(step.reason, 500), needs: list(step.needs).slice(0, 40).map(need => text(need, 200)), deploymentRunUid: optionalUuid(step.deployment_run_uid), + startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now), error: errorText(step.error) }; + }); + const createdAt = when(run.creation_date), finishedAt = when(run.finished_at); + const startedAt = earliest([createdAt, ...steps.map(step => step.startedAt)]) ?? createdAt; + const deploymentRuns = list(run.deployment_runs).slice(0, 40).flatMap(item => { + const owned = rec(item), ownedUid = optionalUuid(owned.uid); + return ownedUid ? [{ uid: ownedUid, targetLabel: text(owned.target_label, 200), targetType: text(owned.target_type, 60), state: text(owned.state, 40) || 'unknown', + workflowStepKeys: list(owned.workflow_step_keys).slice(0, 40).map(key => text(key, 200)), + createdAt: when(owned.created_at), startedAt: when(owned.started_at), finishedAt: when(owned.finished_at) }] : []; + }); + return { kind: 'workflow_run' as const, uid, state: text(run.state, 40) || 'unknown', reason: text(run.reason, 500), workflowPath: text(run.workflow_path, 300), + repositoryUid: optionalUuid(run.code_repository_uid), repositoryName: text(run.code_repository_name, 200), + branchUid: optionalUuid(run.code_repository_branch_uid), branchName: text(run.repository_branch, 200), + commit: text(run.commit_sha, 40).slice(0, 7), createdAt, startedAt, finishedAt: finishedAt ?? (ACTIVE.has(text(run.state, 40)) ? null : latest(steps.map(step => step.finishedAt))), + durationMs: duration(startedAt, finishedAt ?? (ACTIVE.has(text(run.state, 40)) ? null : latest(steps.map(step => step.finishedAt))), now), + steps, deploymentRuns, error: errorText(run.error) }; +} + +/** A deployment run: its pipeline stages in `sequence` order and, when included, the workflow run that started it. */ +export function deploymentRun(row: unknown, now: number) { + const run = rec(row); + const uid = optionalUuid(run.uid); + if (!uid) return null; + const target = rec(run.target), repository = rec(target.code_repository), branch = rec(target.code_repository_branch); + const steps = list(rec(run.pipeline).steps).map(rec).filter(step => Number.isFinite(Number(step.sequence))) + .sort((a, b) => Number(a.sequence) - Number(b.sequence)).slice(0, 60).map(step => { + const startedAt = when(step.started_at), finishedAt = when(step.finished_at); + const stepUid = optionalUuid(step.uid), key = text(step.key, 200); + return { id: stepUid ?? key, uid: stepUid, sequence: Number(step.sequence), key, name: text(step.name, 200), kind: text(step.kind, 60), + state: text(step.state, 40) || 'unknown', outcome: text(step.outcome, 100), reason: text(step.reason ?? step.skip_reason, 500), + startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now), error: errorText(step.error) }; + }); + const createdAt = when(run.created_at), finishedAt = when(run.finished_at); + const startedAt = when(run.started_at) ?? earliest(steps.map(step => step.startedAt)) ?? createdAt; + const workflow = run.workflow_run ? workflowRun(run.workflow_run, now) : null; + return { kind: 'deployment_run' as const, uid, state: text(run.state, 40) || 'unknown', outcome: text(run.outcome, 100), operation: text(run.operation, 60), source: text(run.source, 60), + targetLabel: text(target.display_name || target.name, 200) || text(run.target_type, 60), targetType: text(run.target_type, 60), + repositoryUid: optionalUuid(repository.uid), repositoryName: text(repository.name, 200), + branchUid: optionalUuid(run.code_repository_branch_uid) ?? optionalUuid(branch.uid), branchName: text(branch.repository_branch, 200), + commit: text(run.commit_sha, 40).slice(0, 7), createdAt, startedAt, finishedAt, durationMs: duration(startedAt, finishedAt, now), + currentStepKey: text(rec(run.pipeline).current_step_key, 200), steps, + workflowStepKeys: list(run.workflow_step_keys).slice(0, 40).map(key => text(key, 200)), workflowRun: workflow, + logsAvailable: rec(run.logs).state === 'available' || typeof rec(run.logs).url === 'string', error: errorText(run.error) }; +} + +function historyEntry(row: unknown, now: number) { + const kind = rec(row).kind; + return kind === 'workflow_run' ? workflowRun(row, now) : kind === 'deployment_run' ? deploymentRun(row, now) : null; +} + +function logEntry(row: unknown) { + const entry = rec(row); + return { sequence: Number.isFinite(Number(entry.sequence)) ? Number(entry.sequence) : null, timestamp: when(entry.timestamp), stepUid: optionalUuid(entry.step_uid), + source: text(entry.source, 100), stream: text(entry.stream, 20), level: text(entry.level, 20), text: text(entry.text, 4000) }; +} + +/** A read-only bridge over the public session service; the saved credentials never leave it. */ +export function createDeployments(options: { host: HostServices; signal?: AbortSignal; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; sessionLock?: SessionLock }) { + const transport = options.fetch ?? fetch; + const session = createSessionService(options.host, { env: options.env, signal: options.signal, requestTimeoutMs: 30_000, sessionLock: options.sessionLock, + fetch: async (input, init) => { + const response = await transport(input, init); + // A run the reader may not see answers 404. Describe it without asserting existence, + // adapting only canonical run responses inside the authenticated transport. + if (response.status === 404 && RUN_PATH.test(new URL(String(input)).pathname)) { + await response.body?.cancel().catch(() => {}); + return new Response(JSON.stringify({ hidden: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return response; + } }); + + async function dispatch(input: DeploymentRequest): Promise { + const action = input?.action || 'unknown'; + try { + const backend = requireBackend(input.backend); + const scoped = await bindSessionAccount(session, backend, input.expectedAccount); + const now = Date.now(), at = new Date(now).toISOString(); + let data: unknown, summary: string; + if (input.action === 'deployment-history') { + const environmentUid = uuid(input.environmentUid, 'Choose an organization environment first.'); + const limit = integer(input.limit, 1, 100, 25, 'Request between 1 and 100 deployment history entries.'); + const offset = integer(input.offset, 0, 1_000_000, 0, 'The deployment history offset is invalid.'); + const branchUid = input.codeRepositoryBranchUid === undefined ? '' : uuid(input.codeRepositoryBranchUid, 'Choose a repository branch from the current environment.'); + const page = await scoped.request(backend, 'GET', + `/api/v1/deployment-history/?organization_environment_uid=${environmentUid}&limit=${limit}&offset=${offset}${branchUid ? `&code_repository_branch_uid=${branchUid}` : ''}`); + if (!page || !Array.isArray(page.results) || !Number.isInteger(page.count) || page.count < 0) throw new ToolingError('API_ERROR', 'The deployment history is unavailable.'); + const results = page.results.map((row: unknown) => historyEntry(row, now)).filter(Boolean); + data = { environmentUid, branchUid: branchUid || null, count: page.count, limit, offset, now: at, results }; + summary = `${page.count} deployment history ${page.count === 1 ? 'entry' : 'entries'}.`; + } else if (input.action === 'deployment-run') { + const runUid = uuid(input.runUid, 'Choose a deployment or workflow run.'); + const kind = input.kind === 'workflow_run' ? 'workflow_run' : 'deployment_run'; + const row = await scoped.request(backend, 'GET', kind === 'workflow_run' + ? `/api/v1/deployment-history/${runUid}/` : `/api/v1/deployment-runs/${runUid}/?include=workflow_run`); + if (rec(row).hidden === true) { data = { hidden: true, uid: runUid, kind, now: at }; summary = HIDDEN_DEPLOYMENT; } + else { + const run = kind === 'workflow_run' ? workflowRun(row, now) : deploymentRun(row, now); + if (!run || run.uid !== runUid) throw new ToolingError('API_ERROR', 'The deployment run record is unavailable.'); + data = { hidden: false, uid: runUid, kind, run, now: at }; + summary = `${kind === 'workflow_run' ? 'Workflow run' : 'Deployment'} ${run.state}.`; + } + } else if (input.action === 'deployment-logs') { + const runUid = uuid(input.runUid, 'Choose a deployment run.'); + const stepUid = input.stepUid === undefined ? '' : uuid(input.stepUid, 'Choose a stage of this deployment run.'); + const limit = integer(input.limit, 1, 200, 200, 'Request between 1 and 200 log entries.'); + if (input.cursor !== undefined && (typeof input.cursor !== 'string' || !/^[A-Za-z0-9_.:=~-]{1,4000}$/.test(input.cursor))) throw new ToolingError('INVALID_REQUEST', 'Use a log cursor returned by the previous page.'); + const cursor = input.cursor || ''; + const page = await scoped.request(backend, 'GET', + `/api/v1/deployment-runs/${runUid}/logs/?limit=${limit}${stepUid ? `&step_uid=${stepUid}` : ''}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`); + if (rec(page).hidden === true) { data = { hidden: true, uid: runUid, now: at }; summary = HIDDEN_DEPLOYMENT; } + else { + if (!page || !Array.isArray(page.entries)) throw new ToolingError('API_ERROR', 'The deployment logs are unavailable.'); + const entries = page.entries.slice(0, limit).map(logEntry); + data = { hidden: false, runUid, stepUid: stepUid || null, cursor: cursor || null, nextCursor: typeof page.next_cursor === 'string' && page.next_cursor ? page.next_cursor.slice(0, 4000) : null, + complete: page.complete === true, entries, now: at }; + summary = `${entries.length} log ${entries.length === 1 ? 'entry' : 'entries'}.`; + } + } else throw new ToolingError('INVALID_REQUEST', 'Unknown deployment action.'); + await scoped.status(backend, true); + return JSON.parse(session.sanitize(JSON.stringify({ schemaVersion: 1, action, status: 'success', summary, data }))); + } catch (error) { const failure = options.signal?.aborted ? new ToolingError('CANCELLED', 'The read was cancelled.') : error instanceof ToolingError ? error : new ToolingError('API_ERROR', 'Deployment information is unavailable.'); return { schemaVersion: 1, action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure', summary: failure.message, error: { code: failure.code, message: failure.message } }; } + } + return { dispatch }; +} diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index c0f509a..ac6b094 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -8,6 +8,22 @@ import { McpTransport, sendProjectMcp } from './mcpTransport'; import { codeRepositoryBranchFromResponse } from './repositoryModels'; import { listEnvironments } from './environments'; import type { McpConnection, McpDiscoveryPage } from './discoveryTypes'; +import type { McpTool } from './discoveryTypes'; +import { bindSessionAccount } from './accountScope'; + +type CachedConnection = { value:McpConnection; token:string; tools:McpTool[]; identity:string; account?:string; expiresAt:number; savedIdentity:string }; +/** Opaque, memory-only runtime. No credentials or tool results are persisted. */ +export type McpConnectionCache = Readonly<{ kind:'mainsequence-mcp-cache' }>; +const caches = new WeakMap>(); +export function createMcpConnectionCache(): McpConnectionCache { + const cache = Object.freeze({kind:'mainsequence-mcp-cache' as const}); caches.set(cache,new Map()); return cache; +} +const cacheKey = (backend:string, uid:string) => JSON.stringify([backend,uid]); +const savedIdentity = (value:McpConnection) => JSON.stringify([value.backend,value.resource_release_uid,value.mcp_connection]); +function credentialExpiry(token:string):number { + // This is only a conservative cache lifetime hint; the server verifies the JWT. + try {const exp=JSON.parse(Buffer.from(token.split('.')[1],'base64url').toString()).exp;return typeof exp==='number' && Number.isFinite(exp) ? exp*1000 : 0;} catch{return 0;} +} export type McpRequest = { backend?: string; environmentUid?: string; expectedAccount?: string } & ( | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number; enrichNames?: boolean } @@ -67,13 +83,15 @@ function storagePath(env: NodeJS.ProcessEnv): string { return path.join(dir, 'mcp-connections.json'); } -/** Only endpoint metadata is persisted. Release credentials exist in one invocation's memory. */ +/** Only endpoint metadata is persisted. A bounded runtime may reuse an in-memory grant. */ export class McpConnections { private readonly file: string; private readonly secrets = new Set(); - constructor(private readonly session: SessionService & { sanitize?(text: string): string }, private readonly options: { - connectionsPath?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; checkAccount?: () => Promise; - } = {}) { this.file = options.connectionsPath ?? storagePath(options.env ?? process.env); } + private session: SessionService & { sanitize?(text:string):string }; + private checkAccount?: (fresh?:boolean)=>Promise; + constructor(private readonly originalSession: SessionService & { sanitize?(text: string): string }, private readonly options: { + connectionsPath?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; cache?:McpConnectionCache; + } = {}) { this.file = options.connectionsPath ?? storagePath(options.env ?? process.env); this.session=originalSession; } private sanitize(value: any): any { if (typeof value === 'string') { @@ -98,7 +116,7 @@ export class McpConnections { } private async update(change: (rows: Connection[]) => Connection[]): Promise { this.options.signal?.throwIfAborted(); - await this.options.checkAccount?.(); + await this.checkAccount?.(); await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 }); const lock = this.file + '.lock', temporary = this.file + '.' + randomUUID() + '.tmp'; let handle; @@ -126,7 +144,16 @@ export class McpConnections { if (value.resource_release_uid !== releaseUid) throw new ToolingError('API_ERROR', 'The backend returned a different release.'); return value; } - private async open(value: Connection): Promise { + private client(value:Connection, token:string, initialized=false):McpTransport { + this.secrets.add(token); + return new McpTransport(async message => { + await this.checkAccount?.(); + const result = await sendProjectMcp(value.mcp_connection.url, token, message, this.options); + await this.checkAccount?.(); + return result; + },initialized); + } + private async open(value: Connection): Promise<{client:McpTransport; token:string; expiresAt:number}> { const grant = await this.session.request(value.backend, 'GET', '/api/v1/resource-releases/' + value.resource_release_uid + '/exchange-launch/'); if (grant?.mode !== 'token' || grant.release_kind !== 'fastapi' || typeof grant.token !== 'string' || !grant.token || /[\r\n]/.test(grant.token)) { @@ -136,14 +163,9 @@ export class McpConnections { if (new URL(endpoint(grant.rpc_url)).origin !== new URL(value.mcp_connection.url).origin) { throw new ToolingError('API_ERROR', 'The release credential targets a different endpoint.'); } - const client = new McpTransport(async message => { - await this.options.checkAccount?.(); - const result = await sendProjectMcp(value.mcp_connection.url, grant.token, message, this.options); - await this.options.checkAccount?.(); - return result; - }); + const client = this.client(value,grant.token); await client.initialize(); - return client; + return {client,token:grant.token,expiresAt:credentialExpiry(grant.token)}; } /** The release's name and repository from the release directory and its branch. Whatever * cannot be read is left out: labels never make a working MCP unavailable. */ @@ -185,7 +207,7 @@ export class McpConnections { } catch (error) { this.options.signal?.throwIfAborted(); if (error instanceof ToolingError && error.code === 'CANCELLED') throw error; - await this.options.checkAccount?.(); + await this.checkAccount?.(); // Unavailable optional names must not hide usable endpoints. } } @@ -219,9 +241,40 @@ export class McpConnections { } async dispatch(request: McpRequest, inputBackend: string): Promise { let callStarted = false, resultData: unknown; + let key:string|undefined, reused=false; + let retained:CachedConnection|undefined; + const cache=this.options.cache ? caches.get(this.options.cache) : undefined; try { const backend = requireBackend(inputBackend); this.options.signal?.throwIfAborted(); + let cached:CachedConnection|undefined, selected:{key:string;expiresAt?:number}|undefined; + if ('releaseUid' in request) { + key=cacheKey(backend,uuid(request.releaseUid)); + if (cache && request.action==='mcp-remove') cache.delete(key); + else if (cache) { + for(const [id,item] of cache) if(item.expiresAt<=Date.now()) cache.delete(id); + selected=await this.originalSession.selectionIdentity?.(backend); + cached=cache.get(key); + if(cached && (cached.identity!==selected?.key || cached.account!==request.expectedAccount || !['mcp-tools','mcp-call'].includes(request.action))) {cache.delete(key);cached=undefined;} + if(cached) { + const saved=(await this.read()).find(row=>row.backend===backend && row.resource_release_uid===request.releaseUid); + if(!saved || savedIdentity(saved)!==cached.savedIdentity) {cache.delete(key);cached=undefined;} + } + } + } + reused=Boolean(cached); + if(cached) { + const identity=cached.identity; + this.checkAccount=async()=>{ + this.options.signal?.throwIfAborted(); + if((await this.originalSession.selectionIdentity?.(backend))?.key!==identity) throw new ToolingError('AUTH_REJECTED','The shared MainSequence login changed. Refresh the current view.'); + }; + await this.checkAccount(); + } else { + this.session=await bindSessionAccount(this.originalSession,backend,request.expectedAccount); + this.checkAccount=request.expectedAccount===undefined ? undefined : (fresh=false)=>this.session.status(backend,fresh).then(()=>{}); + if(cache && key) selected=await this.originalSession.selectionIdentity?.(backend); + } if (request.action === 'mcp-list' && request.readOnly !== undefined && typeof request.readOnly !== 'boolean' || request.action === 'mcp-discover' && request.enrichNames !== undefined && typeof request.enrichNames !== 'boolean') { throw new ToolingError('INVALID_REQUEST', 'Discovery and listing options must be booleans.'); @@ -283,16 +336,21 @@ export class McpConnections { await this.update(rows => rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid))); data = { resource_release_uid: releaseUid }; summary = 'The saved connection was removed.'; } else { - let value = await this.current(backend, releaseUid); + let value = cached?.value ?? await this.current(backend, releaseUid); + let savedSnapshot=savedIdentity(value); requireEnvironment(value); if (request.action !== 'mcp-add') { const saved = (await this.read()).find(row => row.backend === backend && row.resource_release_uid === releaseUid); if (!saved) throw new ToolingError('INVALID_REQUEST', 'Add this MCP connection before using it.'); if (saved.mcp_connection.url !== value.mcp_connection.url) throw new ToolingError('INVALID_REQUEST', 'The MCP endpoint changed. Add the release again to confirm its new endpoint.'); + savedSnapshot=savedIdentity(saved); value = this.labelled(value, labels(saved)); } - const client = await this.open(value); - const tools = await client.tools(); + const opened = cached ? {client:this.client(value,cached.token,true),token:cached.token,expiresAt:cached.expiresAt} : await this.open(value); + const client=opened.client; + // An explicit catalog read always refreshes tools. A following call reuses it. + let tools = cached && request.action==='mcp-call' ? cached.tools : await client.tools(); + if(cached && request.action==='mcp-call' && !tools.some(tool=>tool.name===request.tool)) tools=await client.tools(); if (request.action === 'mcp-add') { value = this.labelled(value, await this.resolveLabels(value)); await this.update(rows => [...rows.filter(row => !(row.backend === backend && row.resource_release_uid === releaseUid)), value]); @@ -307,18 +365,37 @@ export class McpConnections { const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments }); if (!output || !Array.isArray(output.content)) throw new ToolingError('API_ERROR', 'The MCP tool returned an invalid result.'); resultData = output; - if (output.isError) throw new ToolingError('API_ERROR', 'The MCP tool reported an error. Inspect its result before retrying.'); + if (output.isError) { + await this.checkAccount?.(!reused); + throw new ToolingError('MCP_TOOL_ERROR', 'The connected MCP tool returned an error. Its original content and structuredContent are included in the result.'); + } data = output; summary = 'The MCP tool completed.'; } else throw new ToolingError('INVALID_REQUEST', 'Unknown MCP action.'); + if(cache && key && selected && (await this.originalSession.selectionIdentity?.(backend))?.key===selected.key) { + // Check local selection again before retaining a grant; never extend a lease + // on a warm call. Unknown/expired credential lifetimes are not cached. + // A cold setup may renew the same account while resolving labels or + // checking access. The account-bound checks below remain authoritative; + // skip retaining an old identity instead of failing an already saved connection. + const expiresAt=cached?.expiresAt ?? Math.min(Date.now()+60_000,opened.expiresAt-5_000,(selected.expiresAt ?? Infinity)-5_000); + if(expiresAt>Date.now()) { + retained={value,token:opened.token,tools,identity:selected.key,account:request.expectedAccount,expiresAt,savedIdentity:savedSnapshot}; + } + } } } - await this.options.checkAccount?.(); + await this.checkAccount?.(!reused); + if(cache && key && retained && retained.expiresAt>Date.now()) { + while(cache.size>=32 && !cache.has(key)) cache.delete(cache.keys().next().value!); + cache.set(key,retained); + } return this.sanitize({ schemaVersion: 1, action: request.action, status: 'success', summary, data }); } catch (error) { const failure = this.options.signal?.aborted ? new ToolingError('CANCELLED', 'The MCP action was cancelled.') : error instanceof ToolingError ? error : new ToolingError('INTERNAL_ERROR', 'The MCP action could not complete.'); + if(cache && key && failure.code!=='MCP_TOOL_ERROR') cache.delete(key); return this.sanitize({ schemaVersion: 1, action: request.action, status: failure.code === 'CANCELLED' ? 'cancelled' : 'failure', - summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined ? {} : { data: resultData }), + summary: failure.message, error: { code: failure.code, message: failure.message }, ...(resultData === undefined || ['AUTH_REJECTED', 'NOT_AUTHENTICATED'].includes(failure.code) ? {} : { data: resultData }), ...(callStarted ? { effects: { state: 'partial-or-uncertain', message: 'The tool may have changed remote state. No automatic retry was attempted; verify the outcome before repeating it.' } } : {}) }); } finally { this.secrets.clear(); } } diff --git a/src/shared/mcpTransport.ts b/src/shared/mcpTransport.ts index dae7f3d..e32c958 100644 --- a/src/shared/mcpTransport.ts +++ b/src/shared/mcpTransport.ts @@ -4,8 +4,7 @@ import type { McpTool } from './discoveryTypes'; /** The platform's stateless Streamable HTTP profile; no legacy SSE endpoint. */ export class McpTransport { private sequence = 0; - private initialized = false; - constructor(private readonly send: (message: Record) => Promise) {} + constructor(private readonly send: (message: Record) => Promise, private initialized = false) {} async initialize(): Promise { const result = await this.request('initialize', { @@ -132,6 +131,7 @@ export async function sendProjectMcp(url: string, token: string, message: Record try { return await readMcpResponse(await (options.fetch ?? fetch)(url, { method: 'POST', redirect: 'error', signal: controller.signal, + ...(['initialize', 'notifications/initialized', 'tools/list'].includes(String(message.method)) ? { cache: 'force-cache' as const } : {}), headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', Authorization: 'Bearer ' + token, 'MCP-Protocol-Version': '2025-11-25' }, body: JSON.stringify(message), diff --git a/src/shared/models.ts b/src/shared/models.ts index 2c3058c..2ca11e9 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -23,7 +23,7 @@ export type ActionRequest = McpRequest | AgentRequest | OrganizationProjectReque }) | (BackendSelection & { action: 'setup'; repositoryUid: string; branch: string; baseDir: string; install?: boolean }); -export type ErrorCode = 'INVALID_REQUEST' | 'NOT_AUTHENTICATED' | 'AUTH_REJECTED' | 'STORE_UNAVAILABLE' | 'STORE_ERROR' | 'PERSISTENCE_FAILED' | 'BACKEND_UNREACHABLE' | 'API_ERROR' | 'BROWSER_FAILED' | 'LOGIN_TIMEOUT' | 'INVALID_CALLBACK' | 'PROJECT_NOT_FOUND' | 'INVALID_GIT_CONTEXT' | 'PROJECT_TYPE_REQUIRED' | 'BACKEND_MISMATCH' | 'MISSING_PREREQUISITE' | 'UNSUPPORTED_ACTION' | 'PROJECT_BUSY' | 'PROCESS_FAILED' | 'PROCESS_TIMEOUT' | 'CANCELLED' | 'INTERNAL_ERROR'; +export type ErrorCode = 'CURRENT_DIRECTORY_UNAVAILABLE' | 'INVALID_REQUEST' | 'NOT_AUTHENTICATED' | 'AUTH_REJECTED' | 'STORE_UNAVAILABLE' | 'STORE_ERROR' | 'PERSISTENCE_FAILED' | 'BACKEND_UNREACHABLE' | 'API_ERROR' | 'MCP_TOOL_ERROR' | 'BROWSER_FAILED' | 'LOGIN_TIMEOUT' | 'INVALID_CALLBACK' | 'PROJECT_NOT_FOUND' | 'INVALID_GIT_CONTEXT' | 'PROJECT_TYPE_REQUIRED' | 'BACKEND_MISMATCH' | 'MISSING_PREREQUISITE' | 'UNSUPPORTED_ACTION' | 'PROJECT_BUSY' | 'PROCESS_FAILED' | 'PROCESS_TIMEOUT' | 'CANCELLED' | 'INTERNAL_ERROR'; export class ToolingError extends Error { constructor(public readonly code: ErrorCode, message: string) { super(message); this.name = 'ToolingError'; } } @@ -77,6 +77,9 @@ export interface SessionService { request(backend: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe?: boolean, options?: { timeoutMs?: number }): Promise; /** Optional for legacy custom transports. Scoped operations fail closed if unavailable. */ withAccount?(backend: string, expectedAccount: string): Promise; + /** Opaque identity of the currently selected credentials, reread locally. No secrets. + * Optional: transports without it never reuse a cached project MCP connection. */ + selectionIdentity?(backend: string): Promise<{ key: string; expiresAt?: number }>; } /** Trusted host persistence only. Credentials never cross the action-result boundary. */ diff --git a/src/shared/organizationProjectWorkspace.ts b/src/shared/organizationProjectWorkspace.ts index 257053d..1c0e082 100644 --- a/src/shared/organizationProjectWorkspace.ts +++ b/src/shared/organizationProjectWorkspace.ts @@ -8,7 +8,7 @@ import { ExecutionState, setupRepository, withProjectLock, projectEnvironment } import { repositorySshKeyIdentity } from './repositoryIdentity'; import { requireBackend } from './session'; -type Input = { backend: string; environmentUid: string; projectUid: string; baseDir: string }; +type Input = { backend: string; environmentUid: string; projectUid: string; baseDir: string; instructionsFile?: 'AGENTS.md' | 'CLAUDE.md' }; type Options = { host: HostServices; session: SessionService; env: NodeJS.ProcessEnv; signal?: AbortSignal; state: ExecutionState }; type Checkout = { repositoryUid: string; branchUid: string; name: string; branch: string; path: string; sourcePath: string }; export type OrganizationProjectWorkspace = { root: string; name: string; projectUid: string; environmentUid: string; repositories: Checkout[]; claudeUrl: string }; @@ -89,6 +89,8 @@ async function namedDirectory(parent: string, name: string, uid: string): Promis /** Stable, independent branch checkouts. No reset, stash, force checkout, deletion or automatic pull. */ export async function setupOrganizationProject(input: Input, options: Options): Promise { const { session, signal, state } = options; + if (input.instructionsFile !== undefined && !['AGENTS.md','CLAUDE.md'].includes(input.instructionsFile)) throw new ToolingError('INVALID_REQUEST','Choose AGENTS.md or CLAUDE.md instructions.'); + const backend = requireBackend(input.backend), environmentUid = projectUid(input.environmentUid), uid = projectUid(input.projectUid); const env = projectEnvironment({ backend }, options.env); // Apply the same environment to nested Git helpers, including context and .env setup. @@ -203,13 +205,15 @@ export async function setupOrganizationProject(input: Input, options: Options): } if (await run(row.path, ['symbolic-ref', '--short', 'HEAD']) !== row.branch) throw new ToolingError('INVALID_GIT_CONTEXT', 'The checkout did not select the project branch.'); } - const instructions = path.join(root, 'CLAUDE.md'); + const instructionsFile = input.instructionsFile ?? 'CLAUDE.md'; + if (!['AGENTS.md', 'CLAUDE.md'].includes(instructionsFile)) throw new ToolingError('INVALID_REQUEST', 'Choose AGENTS.md or CLAUDE.md instructions.'); + const instructions = path.join(root, instructionsFile); if (!await exists(instructions)) { const legacyInstructions = path.join(container, 'CLAUDE.md'); - const content = prior?.schemaVersion === 1 && await exists(legacyInstructions) + const content = instructionsFile === 'CLAUDE.md' && prior?.schemaVersion === 1 && await exists(legacyInstructions) ? await fs.readFile(legacyInstructions, 'utf8') : '# MainSequence workspace\n\nThe project repositories and their assigned branches are listed in `.mainsequence-project.json`.\nThe MainSequence plugin adds each repository to this Claude session with `/add-dir`.\nWork inside the appropriate repository directory; each checkout has its own Git history and instructions.\n'; - await fs.writeFile(instructions, content, { flag: 'wx' }); + await fs.writeFile(instructions, instructionsFile === 'AGENTS.md' ? content.replace('The MainSequence plugin adds each repository to this Claude session with `/add-dir`.','Open the repository paths listed in the manifest in your editor. Grant access to each checkout before working in it.') : content, { flag: 'wx' }); } await atomicJson(manifestPath, { ...manifest, status: 'ready' }); await atomicJson(path.join(root, '.mainsequence-project.json'), { ...manifest, status: 'ready' }); diff --git a/src/shared/organizationProjects.ts b/src/shared/organizationProjects.ts index 014c365..1df27ae 100644 --- a/src/shared/organizationProjects.ts +++ b/src/shared/organizationProjects.ts @@ -13,7 +13,7 @@ export type OrganizationProjectRequest = { backend?: string; expectedAccount?: s | { action: 'organization-projects'; environmentUid: string; search?: string; limit?: number; offset?: number } | { action: 'organization-project'; environmentUid: string; projectUid: string } | { action: 'organization-project-link'; environmentUid: string; projectUid: string; branchUid?: string; target: 'project' | 'repository' | 'branch'; commandCenterUrl?: string } - | { action: 'organization-project-setup'; environmentUid: string; projectUid: string; baseDir: string } + | { action: 'organization-project-setup'; environmentUid: string; projectUid: string; baseDir: string; instructionsFile?: 'AGENTS.md' | 'CLAUDE.md' } ); export function projectUid(value: unknown, label = 'UID'): string { diff --git a/src/shared/session.ts b/src/shared/session.ts index 5873e1e..7eaf5a6 100644 --- a/src/shared/session.ts +++ b/src/shared/session.ts @@ -7,6 +7,7 @@ import { normalizeBackendUrl } from './config'; import { clearSession, CredentialStore, ForeignEntryError, MachineSession, openCredentialStore, readSession, saveSession } from './machineSession'; import { HostServices, SessionService, SessionStatus, SessionPersistence, ToolingError } from './models'; import { validateExpectedAccount } from './accountScope'; +import type { SessionLock } from './sharedTransport'; class AccountChangedError extends ToolingError { constructor() { super('AUTH_REJECTED', 'The MainSequence account or session changed. Refresh the current view before continuing.'); } @@ -71,6 +72,7 @@ export class MachineSessionService implements SessionService { fetch?: typeof fetch; loginTimeoutMs?: number; requestTimeoutMs?: number; + sessionLock?: SessionLock; } = {}) { this.env = { ...(options.env ?? process.env) }; this.store = options.store === undefined ? openCredentialStore({ env: this.env }) : options.store; @@ -128,6 +130,12 @@ export class MachineSessionService implements SessionService { if (!session) throw new ToolingError('NOT_AUTHENTICATED', 'No session is saved for this backend. Run login.'); return { session, source: 'machine' }; } + async selectionIdentity(input: string): Promise<{ key: string; expiresAt?: number }> { + const backend = requireBackend(input), {session, source} = await this.selected(backend); + const expiresAt = expiry(session.access); + return { key:crypto.createHash('sha256').update(JSON.stringify([backend,source,session.username,session.access,session.refresh])).digest('hex'), + ...(expiresAt ? {expiresAt:Date.parse(expiresAt)} : {}) }; + } private async http(backend: string, method: string, apiPath: string, body?: unknown, access?: string, timeoutMs?: number): Promise { this.options.signal?.throwIfAborted(); if ((apiPath !== '/mcp' && !/^\/(api|auth)\//.test(apiPath)) || apiPath.includes('..')) throw new ToolingError('INVALID_REQUEST', 'Only existing /api/, /auth/, or central /mcp endpoints can be requested.'); @@ -138,6 +146,12 @@ export class MachineSessionService implements SessionService { try { const response = await (this.options.fetch ?? fetch)(backend + apiPath, { method, redirect: 'error', signal: controller.signal, + // Opt-in shared transports may deduplicate only these stateless platform + // reads. Never infer read safety from arbitrary project tool annotations. + ...(apiPath === '/mcp' && body && typeof body === 'object' && ( + ['initialize', 'notifications/initialized', 'tools/list'].includes((body as any).method) + || (body as any).method === 'tools/call' && ['agent.list', 'agent.search', 'agent.get', 'resource_release.mcp_endpoints'].includes((body as any).params?.name) + ) ? { cache: 'force-cache' as const } : {}), headers: { 'Content-Type': 'application/json', ...(apiPath === '/mcp' ? { Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' } : {}), ...(access ? { Authorization: `Bearer ${access}` } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) }); @@ -155,15 +169,21 @@ export class MachineSessionService implements SessionService { this.options.signal?.removeEventListener('abort', abort); } } - private renew(backend: string, expectedAccount?: string): Promise { + private renew(backend: string, expectedAccount?: string, stale?: MachineSession): Promise { const renewalKey = JSON.stringify([backend, expectedAccount]); const running = this.renewals.get(renewalKey); if (running) return running; - const task = (async () => { + const run = async () => { // Bypass cached state: another SDK process may have rotated the refresh token. const previous = await this.saved(backend); if (!previous?.refresh) throw new ToolingError('NOT_AUTHENTICATED', 'No refreshable machine session is saved for this backend.'); if (expectedAccount !== undefined && previous.username && previous.username !== expectedAccount) throw this.accountChanged(); + // Another host process may have renewed while this one waited for the + // shared lease. Reuse its saved pair; never rotate it a second time. + if (stale && previous.access && previous.access !== stale.access) { + if (expectedAccount !== undefined) await this.verifyAccount(backend, previous, expectedAccount); + return previous; + } let data: unknown; try { data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh }); } catch (error) { @@ -173,14 +193,18 @@ export class MachineSessionService implements SessionService { const session = this.remember(tokenPair(data, previous)); if (expectedAccount !== undefined) { const current = await this.saved(backend); - if (!current || current.access !== previous.access || current.refresh !== previous.refresh) throw this.accountChanged(); + // A shared transport can coalesce simultaneous rotations. Another client + // may already have saved this exact returned pair; that is the same login. + if (!current || (current.access !== previous.access || current.refresh !== previous.refresh) + && (current.access !== session.access || current.refresh !== session.refresh)) throw this.accountChanged(); } // Refresh may rotate the old token. Keep the new pair even if the subsequent // profile check is unavailable; no domain request runs before that check passes. await this.persist(backend, session); if (expectedAccount !== undefined) await this.verifyAccount(backend, session, expectedAccount); return session; - })(); + }; + const task = this.options.sessionLock ? this.options.sessionLock(backend, run, this.options.signal) : run(); this.renewals.set(renewalKey, task); void task.finally(() => this.renewals.delete(renewalKey)).catch(() => {}); return task; @@ -249,14 +273,20 @@ export class MachineSessionService implements SessionService { request: (value, method, apiPath, body, retrySafe = method === 'GET', options) => { target(value); return this.accountRequest(backend, method, apiPath, body, retrySafe, options, expectedAccount); }, - status: async value => { + status: async (value, check = true) => { target(value); - await this.accountRequest(backend, 'GET', '/api/v1/users/me/', undefined, true, undefined, expectedAccount); + // During one account-bound operation, reuse the identity verified for this + // exact access token. Still reread shared credentials on every boundary; + // a changed token must be verified before it can access the remote service. + if (check) await this.accountRequest(backend, 'GET', '/api/v1/users/me/', undefined, true, undefined, expectedAccount); const current = await this.selected(backend); await this.verifyAccount(backend, current.session, expectedAccount); - const status = await this.status(backend); - if (!status.authenticated) throw this.accountChanged(); - return { ...status, username: expectedAccount, checked: true }; + const final = await this.selected(backend); + if (final.source !== current.source || final.session.access !== current.session.access || final.session.refresh !== current.session.refresh) throw this.accountChanged(); + const source = current.source === 'environment' ? 'environment' : this.options.persistence?.mode ?? 'machine'; + return { backend, authenticated:true, source, persistence:source, + store:source === 'environment' ? null : this.options.persistence?.name ?? this.store?.name ?? null, + username:expectedAccount, expiresAt:expiry(current.session.access), checked:true }; }, login: unsupported, logout: unsupported, refresh: unsupported, sanitize: text => this.sanitize(text), @@ -269,7 +299,7 @@ export class MachineSessionService implements SessionService { const backend = requireBackend(input); let { session, source } = await this.selected(backend); if (!session.access || (expiry(session.access) && Date.parse(expiry(session.access)!) <= Date.now() + 30_000)) { - if (source === 'machine') session = await this.renew(backend, expectedAccount); + if (source === 'machine') session = await this.renew(backend, expectedAccount, session); else if (!session.access) throw new ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.'); } const send = async (selected: MachineSession): Promise => { @@ -293,7 +323,7 @@ export class MachineSessionService implements SessionService { if (source === 'environment') throw refusedCredentials('environment', backend, session.access); if (!retrySafe) throw error; const latest = await this.saved(backend); - const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount); + const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount, session); try { return await send(retry); } catch (retryError) { if (retryError instanceof AccountChangedError) throw retryError; diff --git a/src/shared/sharedTransport.ts b/src/shared/sharedTransport.ts new file mode 100644 index 0000000..b137f1d --- /dev/null +++ b/src/shared/sharedTransport.ts @@ -0,0 +1,262 @@ +/** Opt-in, per-user HTTP owner for desktop hosts with one process per chat. + * Credentials and cached responses stay in memory. The private Unix socket is + * local IPC, not a new login, backend, public listener, or autonomous poller. + */ +import * as net from 'node:net'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { createHash, randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { Readable } from 'node:stream'; +import { readMcpResponse } from './mcpTransport'; + +const PROTOCOL = 2, MAX_BYTES = 8_000_000, MAX_FRAME = 12_000_000; +type WireRequest = { url: string; method: string; headers: [string, string][]; body?: string; read: boolean; fresh: boolean }; +type WireResponse = { status: number; headers: [string, string][]; body: string }; +export type SharedTransportStatus = { protocol: number; instanceId: string; pid: number; clients: number; requests: number; cacheHits: number; coalesced: number; active: number; connections: number; maxConnectionsPerOrigin: number }; +export type SessionLock = (backend: string, run: () => Promise, signal?: AbortSignal) => Promise; +const unavailable = () => new Error('Shared MainSequence transport disconnected. No request was replayed. Read the saved operation receipt before recovery.'); +const hash = (input: string) => createHash('sha256').update(input).digest('hex'); +function headerPairs(headers: Headers): [string, string][] { const pairs: [string, string][] = []; headers.forEach((value, key) => pairs.push([key, value])); return pairs; } +function privateDirectory(directory: string) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new Error('Shared transport requires a private directory owned by the current user.'); +} +function frames(socket: net.Socket, receive: (message: any) => void) { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', chunk => { + buffer += chunk; + if (buffer.length > MAX_FRAME) { socket.destroy(); return; } + let end: number; + while ((end = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + try { receive(JSON.parse(line)); } catch { socket.destroy(); return; } + } + }); +} + +/** All participating processes share one owner. No fallback creates a private pool. */ +export function createSharedTransport(options: { directory?: string } = {}) { + if (process.platform === 'win32') throw new Error('Shared MainSequence transport currently requires Unix-domain sockets.'); + const directory = options.directory ?? path.join(os.tmpdir(), `ms-http-v${PROTOCOL}-${process.getuid!()}-${hash(os.homedir()).slice(0, 12)}`); + const socketPath = path.join(directory, 'transport.sock'); + // Capture the worker and its two internal dependencies now. Codex can remove + // this installation while a chat is alive; a later reconnect still works. + const modules = Object.fromEntries(['sharedTransport', 'mcpTransport', 'models'].map(name => [name, fs.readFileSync(path.join(__dirname, name + '.js'), 'utf8')])); + const workerSource = `const sources=${JSON.stringify(modules)},cache={};function load(name){if(cache[name])return cache[name].exports;const m={exports:{}};cache[name]=m;const r=id=>id.startsWith('./')?load(id.slice(2).replace(/\\.js$/,'')):require(id);r.main=name==='sharedTransport'?m:null;new Function('require','module','exports','__filename','__dirname',sources[name])(r,m,m.exports,'sharedTransport.js','.');return m.exports;}process.argv=['node','worker','--shared-http-worker',${JSON.stringify(directory)}];load('sharedTransport');`; + let socket: net.Socket | undefined, connecting: Promise | undefined, closed = false; + const pending = new Map(); + async function connect(): Promise { + if (closed) throw unavailable(); + if (socket && !socket.destroyed) return socket; + if (connecting) return connecting; + connecting = (async () => { + privateDirectory(directory); + const attempt = () => new Promise((resolve, reject) => { + const next = net.createConnection(socketPath); + next.once('error', reject); + next.once('connect', () => { next.removeListener('error', reject); resolve(next); }); + }); + let next: net.Socket | undefined; + try { next = await attempt(); } catch { + // The worker takes an exclusive lock before listening; concurrent clients + // may start contenders but only one can own the upstream connection pool. + const worker = spawn(process.execPath, ['-'], { cwd: os.homedir(), detached: true, stdio: ['pipe', 'ignore', 'ignore'], env: { PATH: process.env.PATH, HOME: os.homedir() } }); + worker.stdin.on('error', () => {}); worker.stdin.end(workerSource); + worker.on('error', () => {}); worker.unref(); + for (let n = 0; n < 100 && !next; n++) { + await new Promise(resolve => setTimeout(resolve, 30)); + try { next = await attempt(); } catch { /* startup, not an HTTP replay */ } + } + } + if (!next) throw unavailable(); + if (closed) { next.destroy(); throw unavailable(); } + socket = next; + next.on('error', () => {}); + next.on('close', () => { if (socket === next) socket = undefined; for (const task of pending.values()) task.reject(unavailable()); pending.clear(); }); + frames(next, message => { + const task = pending.get(message.id); if (!task) return; + pending.delete(message.id); + if (!pending.size) next!.unref(); + if (message.error) task.reject(new Error(message.error)); else task.resolve(message.result); + }); + next.unref(); return next; + })().finally(() => { connecting = undefined; }); + return connecting; + } + async function rpc(op: string, request?: WireRequest | { key: string; lease?: string }, signal?: AbortSignal | null): Promise { + signal?.throwIfAborted(); + const connection = await connect(); signal?.throwIfAborted(); + const id = randomUUID(); + return new Promise((resolve, reject) => { + const abort = () => { pending.delete(id); connection.write(JSON.stringify({ op: 'cancel', id }) + '\n'); if (!pending.size) connection.unref(); reject(signal?.reason ?? unavailable()); }; + pending.set(id, { resolve: value => { signal?.removeEventListener('abort', abort); resolve(value); }, reject: error => { signal?.removeEventListener('abort', abort); reject(error); } }); + signal?.addEventListener('abort', abort, { once: true }); + connection.ref(); connection.write(JSON.stringify({ id, op, request }) + '\n'); + }); + } + const transport: typeof fetch = async (input, init = {}) => { + if (input instanceof Request || (init.body !== undefined && init.body !== null && typeof init.body !== 'string')) throw new Error('Shared transport accepts URL requests with optional text bodies.'); + const request: WireRequest = { url: String(input), method: init.method ?? 'GET', headers: headerPairs(new Headers(init.headers)), + ...(typeof init.body === 'string' ? { body: init.body } : {}), read: init.cache === 'force-cache', fresh: init.cache === 'reload' }; + const value: WireResponse = await rpc('fetch', request, init.signal); + return new Response([204, 205, 304].includes(value.status) ? null : Buffer.from(value.body, 'base64'), { status: value.status, headers: value.headers }); + }; + const sessionLock: SessionLock = async (backend, run, signal) => { + const key = hash(backend), lease = await rpc('acquire', { key }, signal); + try { signal?.throwIfAborted(); return await run(); } + finally { await rpc('release', { key, lease }).catch(() => {}); } + }; + return { fetch: transport, sessionLock, status: (): Promise => rpc('status'), invalidate: (): Promise => rpc('invalidate'), close() { closed = true; socket?.destroy(); } }; +} + +function cacheTtl(request: WireRequest): number { + const url = new URL(request.url); + if (request.method === 'POST') return request.read ? 30_000 : 0; + if (request.method !== 'GET') return 0; + if (url.pathname === '/api/v1/users/me/') return 5_000; + if (/^\/api\/v1\/command-center\/agents\/[a-f0-9-]+\/icon\/$/.test(url.pathname)) return 300_000; + if (/^\/api\/v1\/(organization-environments|command-center\/agents|code-repositories|code-repository-branches|organization-projects)\//.test(url.pathname)) return 30_000; + return 0; +} +function readKey(request: WireRequest): string { + // IDs are transport correlation only; share reads and restore each caller's ID. + let body = request.body; + if (request.read && body) { const value = JSON.parse(body); delete value.id; body = JSON.stringify(value); } + return hash(JSON.stringify([request.url, request.method, [...request.headers].sort(), body])); +} +function responseFor(value: WireResponse, request: WireRequest): WireResponse { + if (!request.read || !request.body || value.status !== 200) return value; + const body = JSON.parse(request.body); + if (!Object.hasOwn(body, 'id')) return value; + const response = JSON.parse(Buffer.from(value.body, 'base64').toString()); + return { ...value, body: Buffer.from(JSON.stringify({ ...response, id: body.id })).toString('base64') }; +} + +async function runWorker(directory: string) { + privateDirectory(directory); + const lock = path.join(directory, 'owner.json'), socketPath = path.join(directory, 'transport.sock'); + try { fs.writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 }); } + catch { + try { const owner = JSON.parse(fs.readFileSync(lock, 'utf8')); process.kill(owner.pid, 0); return; } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') return; + // Serialize stale recovery too; never unlink another contender's new lock. + const recovery = path.join(directory, 'recover.lock'); + let fd: number; try { fd = fs.openSync(recovery, 'wx', 0o600); } catch { return; } + try { const owner = JSON.parse(fs.readFileSync(lock, 'utf8')); try { process.kill(owner.pid, 0); return; } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') return; } fs.unlinkSync(lock); } + finally { fs.closeSync(fd); fs.unlinkSync(recovery); } + return runWorker(directory); + } + } + const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 2, maxTotalSockets: 8, maxFreeSockets: 2 }); + const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 2, maxTotalSockets: 8, maxFreeSockets: 2 }); + const sockets = new Set(), cache = new Map(); + type Job = { controller: AbortController; listeners: Map; promise: Promise }; + const jobs = new Map(); let epoch = 0, requests = 0, cacheHits = 0, coalesced = 0, bytes = 0; + const instanceId = randomUUID(), invalidate = () => { epoch++; cache.clear(); bytes = 0; }; + type Lease = { socket: net.Socket; id: string; token: string }; + const leases = new Map(); + function grant(key: string) { const first = leases.get(key)?.[0]; if (first) send(first.socket, first.id, first.token); else leases.delete(key); } + function releaseFor(socket: net.Socket, id?: string) { + for (const [key, queue] of leases) { + const first = queue[0]; const keep = queue.filter(lease => lease.socket !== socket || id && lease.id !== id); + leases.set(key, keep); if (keep[0] !== first) grant(key); + } + } + const upstream = async (request: WireRequest, signal: AbortSignal): Promise => { + requests++; + const url = new URL(request.url); + const response = await new Promise((resolve, reject) => { + const call = (url.protocol === 'https:' ? https : http).request(url, { method: request.method, headers: Object.fromEntries(request.headers), + agent: url.protocol === 'https:' ? httpsAgent : httpAgent, signal, timeout: 300_000 }, result => { + const headers = new Headers(); for (const [key, value] of Object.entries(result.headers)) if (value !== undefined) headers.set(key, Array.isArray(value) ? value.join(', ') : value); + resolve(new Response([204, 205, 304].includes(result.statusCode!) ? null : Readable.toWeb(result) as ReadableStream, { status: result.statusCode, headers })); + if ([204, 205, 304].includes(result.statusCode!)) result.resume(); + }); + call.on('timeout', () => call.destroy(new Error('HTTP timeout'))); call.on('error', reject); call.end(request.body); + }); + const headers = headerPairs(response.headers); + if (request.method === 'POST' && /json|event-stream/.test(response.headers.get('content-type') ?? '') && request.body && response.ok) { + let message: any; try { message = JSON.parse(request.body); } catch { /* regular POST */ } + if (message?.jsonrpc === '2.0') { + const body = await readMcpResponse(response, message); + return { status: response.status, headers: headers.filter(([key]) => !['content-type', 'content-length', 'content-encoding'].includes(key)).concat([['content-type', 'application/json']]), body: body === undefined ? '' : Buffer.from(JSON.stringify(body)).toString('base64') }; + } + } + const reader = response.body?.getReader(); let size = 0; const parts: Buffer[] = []; + if (reader) try { while (true) { const next = await reader.read(); if (next.done) break; size += next.value.length; if (size > MAX_BYTES) throw new Error('Response size exceeded'); parts.push(Buffer.from(next.value)); } } finally { await reader.cancel().catch(() => {}); } + return { status: response.status, headers, body: Buffer.concat(parts).toString('base64') }; + }; + function send(socket: net.Socket, id: string, result?: unknown, error?: string) { if (!socket.destroyed) socket.write(JSON.stringify({ id, ...(error ? { error } : { result }) }) + '\n'); } + const server = net.createServer(socket => { + sockets.add(socket); const identity = randomUUID(); + socket.on('error', () => {}); + const detach = (id?: string) => { for (const job of jobs.values()) { for (const [key, listener] of job.listeners) if (listener.socket === socket && (!id || listener.id === id)) job.listeners.delete(key); if (!job.listeners.size) job.controller.abort(); } }; + socket.on('close', () => { sockets.delete(socket); detach(); releaseFor(socket); }); + frames(socket, message => { + const id = message.id; + if (typeof id !== 'string' || id.length > 100) { socket.destroy(); return; } + if (message.op === 'cancel') { detach(id); releaseFor(socket,id); return; } + if (message.op === 'acquire' || message.op === 'release') { + const key=message.request?.key; + if (typeof key !== 'string' || !/^[a-f0-9]{64}$/.test(key)) { socket.destroy(); return; } + const queue=leases.get(key)||[]; + if (message.op==='acquire') { if (queue.length>=256) {send(socket,id,undefined,'Too many pending session operations.');return;} queue.push({socket,id,token:randomUUID()});leases.set(key,queue);if(queue.length===1)grant(key); } + else { if (queue[0]?.socket===socket && queue[0].token===message.request.lease) {queue.shift();grant(key);} send(socket,id); } + return; + } + if (message.op === 'invalidate') { invalidate(); send(socket, id); return; } + if (message.op === 'status') { + const count = (agent: http.Agent) => [...Object.values(agent.sockets), ...Object.values(agent.freeSockets)].reduce((sum, values) => sum + (values?.length || 0), 0); + send(socket, id, { protocol: PROTOCOL, instanceId, pid: process.pid, clients: sockets.size, requests, cacheHits, coalesced, active: jobs.size, connections: count(httpAgent) + count(httpsAgent), maxConnectionsPerOrigin: 2 }); return; + } + if (message.op !== 'fetch' || jobs.size >= 256) { send(socket, id, undefined, 'Shared transport is busy or the operation is unsupported.'); return; } + const request: WireRequest = message.request; + let url: URL; try { url = new URL(request.url); } catch { socket.destroy(); return; } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !['GET', 'POST'].includes(request.method) || !Array.isArray(request.headers) || (request.body && Buffer.byteLength(request.body) > 1_000_000)) { socket.destroy(); return; } + const renewal = request.method === 'POST' && url.pathname === '/auth/jwt-token/token/refresh/'; + const read = request.method === 'GET' || request.read || renewal, key = read ? readKey(request) : randomUUID(); + if (!read || request.fresh) invalidate(); + const flightKey = epoch + ':' + key; + const found = cache.get(key); + if (found && found.until > Date.now()) { cacheHits++; send(socket, id, responseFor(found.value, request)); return; } + if (found) { bytes -= found.value.body.length; cache.delete(key); } + let job = jobs.get(flightKey); + if (job?.controller.signal.aborted) job = undefined; + if (job) coalesced++; + else { + const controller = new AbortController(), startedEpoch = epoch; + job = { controller, listeners: new Map(), promise: upstream(request, controller.signal) }; jobs.set(flightKey, job); + const owned = job; + void owned.promise.then(value => { + if (!read || value.status === 401 || value.status === 403) invalidate(); + let ttl = cacheTtl(request); + const auth = request.headers.find(([name]) => name === 'authorization')?.[1]; + try { const exp = JSON.parse(Buffer.from(auth!.split('.')[1], 'base64url').toString()).exp; if (typeof exp === 'number') ttl = Math.min(ttl, exp * 1000 - Date.now() - 5000); } catch { /* opaque token: bounded TTL */ } + let success = value.status >= 200 && value.status < 300; + if (request.read && value.body) { try { const result = JSON.parse(Buffer.from(value.body, 'base64').toString()); success = success && !result.error && !result.result?.isError; } catch { success = false; } } + if (success && ttl > 0 && epoch === startedEpoch && !controller.signal.aborted) { + while (cache.size >= 256 || bytes + value.body.length > 24_000_000) { const oldest = cache.keys().next().value; if (!oldest) break; bytes -= cache.get(oldest)!.value.body.length; cache.delete(oldest); } + cache.set(key, { value, until: Date.now() + ttl, epoch }); bytes += value.body.length; + } + for (const listener of owned.listeners.values()) send(listener.socket, listener.id, responseFor(value, listener.request)); + }, () => { if (!read) invalidate(); for (const listener of owned.listeners.values()) send(listener.socket, listener.id, undefined, 'MainSequence HTTP request failed. It was not replayed.'); }).finally(() => { if (jobs.get(flightKey) === owned) jobs.delete(flightKey); }); + } + job.listeners.set(identity + id, { socket, id, request }); + }); + }); + const cleanup = () => { for (const job of jobs.values()) job.controller.abort(); httpAgent.destroy(); httpsAgent.destroy(); server.close(); try { fs.unlinkSync(socketPath); fs.unlinkSync(lock); } catch { /* already cleaned */ } }; + for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { cleanup(); process.exit(0); }); + try { fs.unlinkSync(socketPath); } catch { /* first launch */ } + server.on('error', () => { cleanup(); process.exitCode = 1; }); + server.listen(socketPath, () => fs.chmodSync(socketPath, 0o600)); + let idleSince = Date.now(); + setInterval(() => { if (sockets.size || jobs.size) idleSince = Date.now(); else if (Date.now() - idleSince > 30_000) { cleanup(); process.exit(0); } }, 1000).unref(); +} +if (require.main === module && process.argv[2] === '--shared-http-worker') void runWorker(process.argv[3]).catch(() => { process.exitCode = 1; }); diff --git a/src/shared/workingDirectory.ts b/src/shared/workingDirectory.ts new file mode 100644 index 0000000..9b02d39 --- /dev/null +++ b/src/shared/workingDirectory.ts @@ -0,0 +1,9 @@ +import { ToolingError } from './models'; + +/** Only consult the working directory for actions that actually need a project. */ +export function currentDirectory(): string { + try { return process.cwd(); } + catch { + throw new ToolingError('CURRENT_DIRECTORY_UNAVAILABLE', 'The working directory is no longer available. Change to an existing directory or reload the plugin host; for MCP actions, pass an explicit backend.'); + } +} diff --git a/src/shared/workspaceSettings.ts b/src/shared/workspaceSettings.ts new file mode 100644 index 0000000..337bc7b --- /dev/null +++ b/src/shared/workspaceSettings.ts @@ -0,0 +1,42 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { ToolingError } from './models'; +import { requireBackend } from './session'; +import { readWorkspacePreferences } from './repositories'; +type Options = { env?: NodeJS.ProcessEnv; configPath?: string; homeDir?: string }; +function location(options: Options) { + const home = options.homeDir ?? os.homedir(), env = options.env ?? process.env; + return options.configPath ?? path.join(process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'MainSequenceCLI') : process.platform === 'win32' ? path.join(env.APPDATA || home, 'MainSequenceCLI') : path.join(home, '.config', 'mainsequence'), 'config.json'); +} +async function read(file: string): Promise> { + try { + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1000000) throw Error(); + const value = JSON.parse(await fs.readFile(file, 'utf8')); + if (!value || Array.isArray(value) || typeof value !== 'object') throw Error(); + return value; + } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw new ToolingError('STORE_ERROR', 'Workspace settings could not be read safely. Existing settings were preserved.'); } +} +export async function readDesktopPreferences(options: Options = {}) { + const config = await read(location(options)); + return { ...await readWorkspacePreferences(options), commandCenterUrl: typeof config.command_center_url === 'string' ? config.command_center_url : '' }; +} +/** Updates only non-secret workspace fields, preserving existing CLI settings. */ +export async function writeWorkspacePreferences(input: { backend: string; baseDir: string; commandCenterUrl?: string }, options: Options = {}) { + const backend = requireBackend(input.backend); + if (!path.isAbsolute(input.baseDir) || /[\0\r\n]/.test(input.baseDir)) throw new ToolingError('INVALID_REQUEST', 'Repositories folder must be an absolute path.'); + const commandCenterUrl = input.commandCenterUrl ? requireBackend(input.commandCenterUrl) : ''; + const file = location(options); await fs.mkdir(path.dirname(file), { recursive: true }); + let lock; + try { lock = await fs.open(file + '.preferences.lock', 'wx', 0o600); } + catch { throw new ToolingError('PROJECT_BUSY', 'Another settings update is in progress. Try again after it finishes.'); } + const temporary = file + '.' + randomUUID() + '.tmp'; + try { + const config = await read(file); + await fs.writeFile(temporary, JSON.stringify({ ...config, backend_url: backend, mainsequence_path: path.resolve(input.baseDir), command_center_url: commandCenterUrl }, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); + await fs.rename(temporary, file); + return readDesktopPreferences(options); + } finally { await fs.rm(temporary, { force: true }); await lock.close(); await fs.unlink(file + '.preferences.lock'); } +} diff --git a/tests/agent-assets.test.cjs b/tests/agent-assets.test.cjs new file mode 100644 index 0000000..9dc6513 --- /dev/null +++ b/tests/agent-assets.test.cjs @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const {createAgentAssets, createNodeHost, stillAgentPng} = require('../dist'); +const uid='11111111-1111-4111-8111-111111111111', envUid='22222222-2222-4222-8222-222222222222'; +const backend='https://fixture.example'; +function fixture(overrides={}) { + const calls=[]; let account='fixture'; + const service=createAgentAssets({host:createNodeHost(),store:null,env:{MAINSEQUENCE_ENDPOINT:backend,MAINSEQUENCE_ACCESS_TOKEN:'synthetic-test-token'},fetch:async (url,init)=>{ + const p=new URL(url).pathname,body=init.body&&JSON.parse(init.body);calls.push({path:p,body}); + if(p==='/api/v1/users/me/') return Response.json({username:account}); + assert.equal(p,'/mcp','No per-agent icon GET or separate REST directory'); + const reply=result=>Response.json({jsonrpc:'2.0',id:body.id,result}); + if(body.method==='initialize')return reply({protocolVersion:'2025-11-25',capabilities:{tools:{}}}); + if(body.method==='notifications/initialized')return new Response(null,{status:202}); + if(body.method==='tools/list')return reply({tools:[{name:'agent.list',inputSchema:{type:'object'}}]}); + assert.equal(body.params.name,'agent.list');assert.equal(body.params.arguments.include_icons,true); + if(overrides.switchAccount) account='another-account'; + return reply({structuredContent:{count:1,results:[{uid,name:'Fixture',organization_environment_uid:envUid, + command_center_icon:{rendering:'mask',url:'https://never-fetch.example/icon',idle:{mime:'image/svg+xml',bytes:Buffer.from('').toString('base64'),...overrides.idle}}}]}}); + }}); + return {calls,read:()=>service.read({backend,expectedAccount:'fixture',environmentUid:envUid,agentUids:[uid]})}; +} +test('compatibility assets use one inline MCP directory and never fetch icon URLs',async()=>{ + const f=fixture(), out=await f.read();assert.match(out[uid].source,/^data:image\/svg\+xml;base64,/);assert.equal(out[uid].rendering,'mask'); + assert.doesNotMatch(JSON.stringify(out),/synthetic-test-token|https:/); + assert.equal(f.calls.filter(c=>c.body?.params?.name==='agent.list').length,1); +}); +test('malformed, unsupported, missing and oversized inline assets fall back without any image requests',async()=>{ + for(const idle of [{mime:'text/html'},{bytes:'not-base64!'},{bytes:''},{bytes:Buffer.alloc(524289).toString('base64')}]) { + const f=fixture({idle});assert.deepEqual(await f.read(),{}); + assert.ok(f.calls.every(c=>['/api/v1/users/me/','/mcp'].includes(c.path))); + } +}); +test('inline assets are discarded if the shared account changes',async()=>{ + await assert.rejects(fixture({switchAccount:true}).read(),e=>e.code==='AUTH_REJECTED'); +}); +test('reduced motion removes animation chunks but keeps PNG image data; truncated chunks fail',()=>{ + const signature=Buffer.from([137,80,78,71,13,10,26,10]); + const chunk=(name,data=Buffer.alloc(0))=>{const b=Buffer.alloc(data.length+12);b.writeUInt32BE(data.length);b.write(name,4);data.copy(b,8);return b;}; + const ihdr=chunk('IHDR'),idat=chunk('IDAT',Buffer.from('pixels')),end=chunk('IEND'); + assert.deepEqual(stillAgentPng(Buffer.concat([signature,ihdr,chunk('acTL'),chunk('fcTL'),idat,chunk('fdAT'),end])),Buffer.concat([signature,ihdr,idat,end])); + assert.throws(()=>stillAgentPng(Buffer.concat([signature,chunk('IDAT').subarray(0,6)])),/incomplete/); +}); diff --git a/tests/agent-delivery.test.cjs b/tests/agent-delivery.test.cjs new file mode 100644 index 0000000..052d24c --- /dev/null +++ b/tests/agent-delivery.test.cjs @@ -0,0 +1,135 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs/promises'),path=require('node:path'),os=require('node:os'); +const {randomUUID}=require('node:crypto'); +const {spawn}=require('node:child_process'); +const {AgentDelivery}=require('../dist/index.js'); +const env='10000000-0000-4000-8000-000000000001',agent='20000000-0000-4000-8000-000000000001',session='30000000-0000-4000-8000-000000000001'; +const scope={host:'fixture',conversationId:'chat-a',backend:'https://fixture.invalid',account:'fixture@example.test'}; +const ok=data=>({schemaVersion:1,status:'success',action:'fixture',summary:'OK',data}); +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +async function fixture(t){ + const directory=await fs.mkdtemp(path.join(os.tmpdir(),'ms-delivery-'));t.after(()=>fs.rm(directory,{recursive:true,force:true})); + const calls=[],state={account:scope.account}; + const createDispatch=signal=>async r=>{calls.push(r);signal?.throwIfAborted(); + if(r.action==='auth-status')return ok({authenticated:true,username:state.account}); + if(r.action==='agent-get')return ok({agent:{uid:agent,name:'Fixture agent',responseKinds:['message']}}); + if(r.action==='agent-session')return ok({sessionUid:session}); + if(r.action==='agent-readiness')return state.preflightFailure||ok({ready:true}); + if(r.action==='agent-send'){state.started?.();if(state.gate)await state.gate;if(state.sendFailure)return state.sendFailure;return ok({responseKind:'message',sessionUid:session,message:'Reply\n preserved exact spacing'});} + throw new Error('Unexpected action '+r.action); + }; + const service=()=>new AgentDelivery({directory,createDispatch});const api=service(); + const draft=(extra={})=>({requestId:randomUUID(),environmentUid:env,agentUid:agent,message:'A harmless fixture question',...extra}); + return {directory,api,service,draft,calls,state,sends:()=>calls.filter(c=>c.action==='agent-send')}; +} +test('staging is durable, immutable, deduplicated while pending, private and isolated by host/chat/account/backend',async t=>{ + const f=await fixture(t),d=f.draft(),first=await f.api.stage(scope,d); + assert.equal(first.state,'pending');assert.equal(f.calls.some(x=>x.action==='agent-session'),false); + assert.equal((await f.service().stage(scope,f.draft())).requestId,first.requestId); + await assert.rejects(f.api.stage(scope,{...d,message:'different'}),/immutable/); + for(const change of [{conversationId:'chat-b'},{host:'other'},{backend:'https://other.invalid'}])assert.equal((await f.api.list({...scope,...change})).length,0); + await assert.rejects(f.api.get({...scope,account:'different'},d.requestId),/account changed/); + const stat=await fs.stat(f.directory);assert.equal(stat.mode&0o777,0o700); + for(const name of await fs.readdir(f.directory))if(name.endsWith('.json'))assert.equal((await fs.stat(path.join(f.directory,name))).mode&0o777,0o600); + assert.equal(f.sends().length,0); +}); +test('Stop before dispatch preserves the original request; concurrent recovery sends once and reuses the receipt',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()),stop=new AbortController();stop.abort(); + await assert.rejects(f.api.deliver(scope,r.requestId,stop.signal));assert.equal(f.sends().length,0); + const results=await Promise.all([f.api.deliver(scope,r.requestId),f.service().deliver(scope,r.requestId),f.service().deliver(scope,r.requestId)]); + assert.ok(results.every(r=>r.state==='delivered'));assert.equal(f.sends().length,1); + assert.equal(f.sends()[0].messageId,r.requestId); + const again=await f.service().deliver(scope,r.requestId);assert.equal(again.receipt.message,'Reply\n preserved exact spacing');assert.equal(f.sends().length,1); +}); +test('Stop during a send cancels only the native waiter; the background receipt survives and recovery never resends',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft());let release,started; + f.state.gate=new Promise(r=>release=r);const sent=new Promise(r=>started=r);f.state.started=started; + const stop=new AbortController(),pending=f.api.deliver(scope,r.requestId,stop.signal);await sent;stop.abort(); + await assert.rejects(pending);assert.equal((await f.api.get(scope,r.requestId)).state,'sending'); + release();const result=await f.service().wait(scope,r.requestId);assert.equal(result.state,'delivered'); + assert.equal(result.sendAttempts,1);assert.equal(f.sends().length,1);assert.equal(result.receipt.message,'Reply\n preserved exact spacing'); +}); +test('unknown send outcomes block retries and further sends in that agent conversation',async t=>{ + const f=await fixture(t);f.state.sendFailure={status:'failure',summary:'Transport lost',effects:{state:'partial-or-uncertain'}}; + const r=await f.api.stage(scope,f.draft());assert.equal((await f.api.deliver(scope,r.requestId)).state,'uncertain'); + assert.equal((await f.service().deliver(scope,r.requestId)).state,'uncertain'); + const next=await f.api.stage(scope,f.draft({message:'A different message'}));await assert.rejects(f.api.deliver(scope,next.requestId),/uncertain/);assert.equal(f.sends().length,1); +}); +test('known pre-send failures, pending cancellation, expiry and account switches cannot send',async t=>{ + const f=await fixture(t);f.state.preflightFailure={status:'failure',summary:'Not ready',error:{code:'UNSUPPORTED_ACTION'}}; + let r=await f.api.stage(scope,f.draft());assert.equal((await f.api.deliver(scope,r.requestId)).state,'failed');assert.equal(f.sends().length,0); + r=await f.api.stage(scope,f.draft({message:'Cancel me'}));await f.api.cancel(scope,r.requestId);assert.equal((await f.api.deliver(scope,r.requestId)).state,'cancelled'); + r=await f.api.stage(scope,f.draft({message:'Expire me'})); + const file=(await fs.readdir(f.directory)).find(n=>n.endsWith('.json')),p=path.join(f.directory,file),state=JSON.parse(await fs.readFile(p));state.requests[r.requestId].expiresAt=new Date(0).toISOString();await fs.writeFile(p,JSON.stringify(state)); + assert.equal((await f.api.deliver(scope,r.requestId)).state,'expired'); + f.state.account='other';await assert.rejects(f.api.list(scope),/account changed/);assert.equal(f.sends().length,0); +}); +test('a process killed after send leaves an uncertain record; restarting cannot replay it',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()),sentFile=path.join(f.directory,'sent'); + const script=`const fs=require('node:fs'),{AgentDelivery}=require(${JSON.stringify(path.resolve('dist/index.js'))}); + const ok=data=>({status:'success',summary:'OK',data}); + const service=new AgentDelivery({directory:process.argv[1],createDispatch:()=>async r=>{ + if(r.action==='auth-status')return ok({authenticated:true,username:${JSON.stringify(scope.account)}}); + if(r.action==='agent-session')return ok({sessionUid:${JSON.stringify(session)}}); + if(r.action==='agent-readiness')return ok({ready:true}); + if(r.action==='agent-send'){fs.appendFileSync(process.argv[2],r.messageId+'\\n');await new Promise(()=>{});} + }});service.deliver(${JSON.stringify(scope)},process.argv[3]);`; + const child=spawn(process.execPath,['-e',script,f.directory,sentFile,r.requestId],{stdio:'ignore'});t.after(()=>child.kill('SIGKILL')); + for(let i=0;i<200&&!await fs.stat(sentFile).then(()=>true,()=>false);i++)await delay(10); + assert.equal(await fs.readFile(sentFile,'utf8'),r.requestId+'\n'); + const exited=new Promise(resolve=>child.once('exit',resolve));child.kill('SIGKILL');await exited; + const recovered=await f.service().deliver(scope,r.requestId);assert.equal(recovered.state,'uncertain');assert.equal(recovered.sendAttempts,1);assert.equal(f.sends().length,0); + assert.equal(await fs.readFile(sentFile,'utf8'),r.requestId+'\n'); +}); +test('corrupt and symlinked storage fail closed rather than discarding deduplication history',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()),file=(await fs.readdir(f.directory)).find(n=>n.endsWith('.json')),p=path.join(f.directory,file); + await fs.writeFile(p,'broken');await assert.rejects(f.api.deliver(scope,r.requestId));assert.equal(await fs.readFile(p,'utf8'),'broken'); + await fs.unlink(p);await fs.symlink(path.join(f.directory,'missing'),p);await assert.rejects(f.api.list(scope));assert.equal(f.sends().length,0); +}); + +test('status readers tolerate a lock being released while opening it',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()); + for(let pass=0;pass<8;pass++) await Promise.all(Array.from({length:8},()=>f.service().get(scope,r.requestId))); + assert.equal(f.sends().length,0); +}); + +test('explicit recovery retries only failed preparation with zero dispatches, retaining request and session identity',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()); + f.state.preflightFailure={status:'failure',summary:'Temporary backend outage',error:{code:'API_ERROR'}}; + assert.equal((await f.api.deliver(scope,r.requestId)).state,'failed');assert.equal(f.sends().length,0); + delete f.state.preflightFailure; + const result=await f.service().deliver(scope,r.requestId);assert.equal(result.state,'delivered');assert.equal(result.requestId,r.requestId);assert.equal(f.sends().length,1); + assert.equal((await f.service().deliver(scope,r.requestId)).receipt.message,result.receipt.message);assert.equal(f.sends().length,1); +}); + +test('an agent-send preflight failure proves no remote mutation and permits same-ID recovery; an ambiguous send never does',async t=>{ + const f=await fixture(t),r=await f.api.stage(scope,f.draft()); + f.state.sendFailure={status:'failure',summary:'Backend failed during client preflight',error:{code:'BACKEND_UNREACHABLE'}}; + const failed=await f.api.deliver(scope,r.requestId);assert.equal(failed.state,'failed');assert.equal(failed.sendAttempts,0); + delete f.state.sendFailure; + const delivered=await f.service().deliver(scope,r.requestId);assert.equal(delivered.state,'delivered');assert.equal(delivered.sendAttempts,1); + assert.deepEqual(f.sends().map(x=>x.messageId),[r.requestId,r.requestId]); + assert.equal((await f.api.deliver(scope,r.requestId)).state,'delivered');assert.equal(f.sends().length,2); +}); +test('fresh agent conversation gets a distinct stable handle without losing old receipts',async t=>{ + const f=await fixture(t),first=await f.api.stage(scope,f.draft());await f.api.deliver(scope,first.requestId); + const key=randomUUID(),next=await f.api.stage(scope,f.draft({conversationKey:key}));assert.notEqual(next.handleId,first.handleId); + assert.equal((await f.api.stage(scope,f.draft({conversationKey:key}))).handleId,next.handleId);assert.equal((await f.api.get(scope,first.requestId)).state,'delivered'); +}); +test('accepted tasks survive Stop and repeated recovery without another send; task reads and cancellation bind the saved receipt',async t=>{ + const directory=await fs.mkdtemp(path.join(os.tmpdir(),'ms-task-'));t.after(()=>fs.rm(directory,{recursive:true,force:true}));let sends=0,cursor=1; + const taskUid=randomUUID(),api=new AgentDelivery({directory,createDispatch:()=>async r=>{ + if(r.action==='auth-status')return ok({authenticated:true,username:scope.account}); + if(r.action==='agent-get')return ok({agent:{uid:agent,name:'Task agent',responseKinds:['task']}}); + if(r.action==='agent-session')return ok({sessionUid:session});if(r.action==='agent-readiness')return ok({ready:true}); + if(r.action==='agent-send'){sends++;assert.equal(r.responseKind,'task');return ok({responseKind:'task',taskUid,sessionUid:session,status:'working',eventCursor:1,terminal:false});} + assert.equal(r.taskUid,taskUid);return ok({responseKind:'task',taskUid,sessionUid:session,status:r.action==='agent-task-cancel'?'cancelled':'completed',eventCursor:++cursor,terminal:true,outputs:[{text:'Task answer'}]}); + }}); + const draft={requestId:randomUUID(),environmentUid:env,agentUid:agent,message:'Fixture task',responseKind:'task'}; + await api.stage(scope,draft);const stop=new AbortController();stop.abort();await assert.rejects(api.deliver(scope,draft.requestId,stop.signal)); + const accepted=await api.deliver(scope,draft.requestId);assert.equal(accepted.task.taskUid,taskUid);await api.deliver(scope,draft.requestId);assert.equal(sends,1); + const completed=await api.task(scope,draft.requestId,'get');assert.match(completed.receipt.message,/Task answer/);await api.task(scope,draft.requestId,'cancel');assert.equal(sends,1);await assert.rejects(api.task({...scope,conversationId:'other'},draft.requestId,'get'),/No saved request/); +}); +test('Talk prepares the same durable handle without sending an introductory message',async t=>{ + const f=await fixture(t),key=randomUUID(),prepared=await f.api.prepare(scope,{environmentUid:env,agentUid:agent,conversationKey:key});assert.equal(prepared.ready,true);assert.equal(f.sends().length,0); + const saved=await f.api.stage(scope,f.draft({conversationKey:key}));assert.equal(saved.handleId,prepared.handleId); +}); diff --git a/tests/agents.test.cjs b/tests/agents.test.cjs index 3c973cd..95eba08 100644 --- a/tests/agents.test.cjs +++ b/tests/agents.test.cjs @@ -91,6 +91,12 @@ test('an older platform without messaging reports unsupported capability before const f=fixture();f.state.catalog=f.state.catalog.filter(n=>n!=='a2a.send_message');const result=await f.send();assert.equal(result.error.code,'UNSUPPORTED_ACTION');assert.equal(result.effects,undefined); }); +test('an explicit durable message ID reaches the transport unchanged and malformed IDs never send',async()=>{ + const f=fixture(),messageId=id(88);assert.equal((await f.send({messageId})).status,'success'); + assert.equal(f.calls.find(c=>c.body.params?.name==='a2a.send_message').body.params.arguments.message_id,messageId); + const bad=fixture();assert.equal((await bad.send({messageId:'not-an-id'})).status,'failure');assert.equal(bad.calls.some(c=>c.body.params?.name==='a2a.send_message'),false); +}); + test('session resolution creates or resumes without messaging, exposes only public identity, and is safely repeatable',async()=>{ const f=fixture(), selection={agentUid,expectedAccount:'person@example.test',handleId:'claude_host_agent_default'}; const first=await f.dispatch('agent-session',selection);assert.equal(first.status,'success');assert.equal(first.data.sessionUid,sessionUid); @@ -183,3 +189,16 @@ test('agent defaults are a read-only allowlisted projection across list, search assert.deepEqual((await f.dispatch('agents-list')).data.results[0].defaults,{provider:'',model:'',thinking:''}); assert.equal(f.calls.some(c=>c.body.params?.name==='a2a.send_message'),false); }); + + +test('list, semantic search and detail opt into inline icons without extra requests',async()=>{ + for(const [action,extra,tool] of [['agents-list',{},'agent.list'],['agents-list',{search:'risk'},'agent.search'],['agent-get',{agentUid},'agent.get']]) { + const f=fixture();f.state.row.command_center_icon={rendering:'color',imageRendering:'pixelated',url:'https://never-fetch.example/icon',idle:{mime:'image/svg+xml',bytes:Buffer.from('').toString('base64')}}; + const out=await f.dispatch(action,{...extra,includeIcons:true});assert.equal(out.status,'success'); + const selected=action==='agent-get'?out.data.agent:out.data.results[0];assert.match(selected.icon.source,/^data:image/);assert.equal(selected.icon.imageRendering,'pixelated'); + assert.equal(f.calls.filter(c=>c.body.method==='tools/call').length,1); + assert.equal(f.calls.find(c=>c.body.params?.name===tool).body.params.arguments.include_icons,true); + assert.doesNotMatch(JSON.stringify(out),/never-fetch/); + } + const invalid=fixture();assert.equal((await invalid.dispatch('agents-list',{includeIcons:'yes'})).status,'failure');assert.equal(invalid.calls.length,0); +}); diff --git a/tests/deployments.test.cjs b/tests/deployments.test.cjs new file mode 100644 index 0000000..6a41280 --- /dev/null +++ b/tests/deployments.test.cjs @@ -0,0 +1,5 @@ +const test=require('node:test'),assert=require('node:assert/strict'); +const {createDeployments,createNodeHost}=require('../dist'); +const id=n=>'00000000-0000-4000-8000-'+String(n).padStart(12,'0'),backend='https://fixture.example'; +function fixture(){const calls=[],state={username:'fixture'};const api=createDeployments({host:createNodeHost(),env:{MAINSEQUENCE_ENDPOINT:backend,MAINSEQUENCE_ACCESS_TOKEN:'synthetic-secret'},fetch:async(url,init)=>{const u=new URL(url);calls.push([u.pathname,init.method]);if(u.pathname==='/api/v1/users/me/')return Response.json({username:state.username});if(u.pathname==='/api/v1/deployment-history/')return Response.json({count:1,results:[{uid:id(2),kind:'deployment_run',state:'running',pipeline:{steps:[{uid:id(3),sequence:2,name:'Deploy'},{uid:id(4),sequence:1,name:'Build'}]},billing:{secret:'never-return'}}]});if(u.pathname.endsWith('/logs/'))return Response.json({entries:[{text:'synthetic-secret',level:'info'}],next_cursor:'page:2'});return Response.json({detail:'not found'},{status:404});}});return {calls,state,run:(action,args={})=>api.dispatch({action,backend,expectedAccount:'fixture',...args})};} +test('deployment reader scopes shared account, orders stages, excludes unrelated fields and protects hidden runs',async()=>{const f=fixture(),r=await f.run('deployment-history',{environmentUid:id(1)});assert.equal(r.status,'success');assert.deepEqual(r.data.results[0].steps.map(s=>s.name),['Build','Deploy']);assert.doesNotMatch(JSON.stringify(r),/never-return/);assert.equal((await f.run('deployment-run',{runUid:id(9)})).data.hidden,true);const logs=await f.run('deployment-logs',{runUid:id(2)});assert.doesNotMatch(JSON.stringify(logs),/synthetic-secret/);assert.ok(f.calls.every(c=>c[1]==='GET'));f.state.username='different';assert.equal((await f.run('deployment-history',{environmentUid:id(1)})).status,'failure');}); diff --git a/tests/discovery.test.cjs b/tests/discovery.test.cjs index bdb48cd..92f8e52 100644 --- a/tests/discovery.test.cjs +++ b/tests/discovery.test.cjs @@ -145,3 +145,21 @@ test('a rotated refresh pair is kept when profile verification is unavailable; n assert.equal(f.writes.length, 1); assert.ok(f.calls.every(c => c.path === '/api/v1/users/me/' || c.path === '/auth/jwt-token/token/refresh/')); }); + +test('local scoped checks reuse only the verified token identity and detect shared login changes', async () => { + const f = sessions(), scoped = await bindSessionAccount(f.session, backend, 'alice'); + const before = f.calls.length; + await scoped.status(backend, false); await scoped.status(backend, false); + assert.equal(f.calls.length, before, 'unchanged credentials need no extra profile request'); + f.change({ username:'alice', access:'alice-new', refresh:'alice-rotated' }); + await scoped.status(backend, false); + assert.equal(f.calls.length, before + 1, 'another process rotating tokens triggers verification'); + assert.equal(f.calls.at(-1).access, 'alice-new'); + f.change({ username:'alice', access:'bob-access', refresh:'bob-refresh' }); + await assert.rejects(scoped.status(backend, false), { code:'AUTH_REJECTED' }); + f.change(null); + await assert.rejects(scoped.status(backend, false), { code:'NOT_AUTHENTICATED' }); + f.change({ username:'alice', access:'alice-next', refresh:'alice-next-refresh' }); + f.state.switchOn='verify'; + await assert.rejects(scoped.status(backend, false), { code:'AUTH_REJECTED' }); +}); diff --git a/tests/mcp.test.cjs b/tests/mcp.test.cjs index fa8c2b5..ca5635d 100644 --- a/tests/mcp.test.cjs +++ b/tests/mcp.test.cjs @@ -4,7 +4,7 @@ const http = require('node:http'); const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); -const { createDevTools, createNodeHost } = require('../dist/index.js'); +const { createDevTools, createNodeHost, createMcpConnectionCache } = require('../dist/index.js'); const { parseArguments } = require('../dist/shared/arguments.js'); const { readMcpResponse } = require('../dist/shared/mcpTransport.js'); const { memoryStore } = require('./helpers.cjs'); @@ -14,7 +14,7 @@ async function fixture(t) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-mcp-test-')); t.after(() => fs.rm(dir, { recursive: true, force: true })); const calls = [], state = { denied: false, mode: 'json', token: 'private-release-credential', exchanged: 0, invoked: 0, - labels: true, labelsDenied: false, branchDenied: false, branchMismatch: false }; + labels: true, labelsDenied: false, branchDenied: false, branchMismatch: false, platformAccess:'private-platform-token' }; const uid = 'b0a5ee29-f649-46ab-a040-38504838a131'; const secondUid = 'b0a5ee29-f649-46ab-a040-38504838a132'; const branchUid = 'c1d2e3f4-0000-4000-8000-000000000001'; @@ -70,10 +70,12 @@ async function fixture(t) { calls.push({ server: 'platform', path: req.url, method: message?.method, authorization: req.headers.authorization }); if (req.url === '/auth/jwt-token/token/refresh/') { assert.equal(message.refresh, 'private-refresh-token'); + state.platformAccess='private-platform-token'; res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ access: 'private-platform-token', refresh: 'private-rotated-refresh' })); } - if (req.headers.authorization !== 'Bearer private-platform-token') return res.writeHead(401).end(); + if (req.headers.authorization !== 'Bearer ' + state.platformAccess) return res.writeHead(401).end(); + if (req.url === '/api/v1/users/me/') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ username:'fixture-user' })); } if (req.url === '/mcp') { if (message.method === 'initialize') return reply(res, message.id, { protocolVersion: '2025-11-25', capabilities: { tools: {} } }); if (message.method === 'notifications/initialized') return res.writeHead(202).end(); @@ -115,9 +117,154 @@ async function fixture(t) { const create = extra => createDevTools({ host: createNodeHost(), env: { MAINSEQUENCE_ENDPOINT: backend, MAINSEQUENCE_ACCESS_TOKEN: 'private-platform-token' }, mcpConnectionsPath: file, requestTimeoutMs: 500, ...extra }); const dispatch = (action, extra = {}, instance = create()) => instance.dispatch({ action, backend, releaseUid: uid, ...extra }); - return { dir, file, uid, secondUid, branchUid, backend, row, project, calls, state, create, dispatch }; + return { dir, file, uid, secondUid, branchUid, backend, row, project, calls, state, create, dispatch, tool }; } +const expiringGrant = seconds => 'eyJhbGciOiJub25lIn0.' + Buffer.from(JSON.stringify({exp:Math.floor(Date.now()/1000)+seconds})).toString('base64url') + '.fixture'; +async function cachedFixture(t,extra={}) { + const f=await fixture(t); f.state.token=expiringGrant(600); + const mcpCache=createMcpConnectionCache(); + const run=(action,input={},options={})=>f.dispatch(action,{expectedAccount:'fixture-user',...input},f.create({...extra,mcpCache,...options})); + assert.equal((await run('mcp-add')).status,'success'); + return {...f,run}; +} + +test('cold connection setup survives its own account-verified token renewal without retaining a stale grant',async t=>{ + const f=await fixture(t),store=memoryStore(),now=Date.now; + let clock=now();t.after(()=>{Date.now=now;});Date.now=()=>clock; + f.state.platformAccess=expiringGrant(90);f.state.token=expiringGrant(600); + saveSession(store,f.backend,{username:'fixture-user',access:f.state.platformAccess,refresh:'private-refresh-token'}); + const mcpCache=createMcpConnectionCache();let advanced=false; + const fetchWithExpiry=async(url,options)=>{ + const response=await fetch(url,options); + if(!advanced && JSON.parse(options.body||'{}').method==='tools/list') {clock+=65_000;advanced=true;} + return response; + }; + const run=(action,input={})=>f.dispatch(action,{expectedAccount:'fixture-user',...input},f.create({env:{},store,mcpCache,fetch:fetchWithExpiry})); + const added=await run('mcp-add');assert.equal(added.status,'success',added.summary); + assert.equal(JSON.parse(await fs.readFile(f.file)).length,1); + assert.equal(f.calls.filter(c=>c.path==='/auth/jwt-token/token/refresh/').length,1); + assert.equal((await run('mcp-tools')).status,'success'); + const before=f.calls.length; + assert.equal((await run('mcp-call',{tool:'read_value',arguments:{}})).status,'success'); + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/call']); + assert.equal(f.state.invoked,1); +}); + +test('separate request dispatchers reuse a memory-only connection: warm calls issue only tools/call',async t=>{ + const f=await cachedFixture(t), before=f.calls.length; + for(const value of [1,2]) { + const result=await f.run('mcp-call',{tool:'set_value',arguments:{value}}); + assert.equal(result.status,'success');assert.equal(result.data.structuredContent.value,value); + assert.doesNotMatch(JSON.stringify(result),/private-platform|eyJ/); + } + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/call','tools/call']); + assert.equal(f.state.invoked,2); + assert.doesNotMatch(await fs.readFile(f.file,'utf8'),/fixture|token|tools|eyJ/); +}); + +test('catalog reload fetches new schemas without reinitializing, and missing tools are checked before execution',async t=>{ + const f=await cachedFixture(t), before=f.calls.length; + f.tool.name='updated_tool'; + const result=await f.run('mcp-tools'); + assert.equal(result.data.tools[1].name,'updated_tool'); + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/list','tools/list']); + const missing=await f.run('mcp-call',{tool:'set_value',arguments:{}}); + assert.equal(missing.error.code,'INVALID_REQUEST');assert.equal(f.state.invoked,0); +}); + +test('cached transport failure is not replayed and the next explicit call performs fresh setup',async t=>{ + const f=await cachedFixture(t);f.state.mode='disconnect';const before=f.calls.length; + const failure=await f.run('mcp-call',{tool:'set_value',arguments:{value:1}}); + assert.equal(failure.effects.state,'partial-or-uncertain');assert.equal(f.state.invoked,1); + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/call']); + f.state.mode='json';const exchanged=f.state.exchanged; + assert.equal((await f.run('mcp-call',{tool:'read_value',arguments:{}})).status,'success'); + assert.equal(f.state.exchanged,exchanged+1);assert.equal(f.state.invoked,2); +}); + +test('business errors preserve the result and keep a valid connection without replaying',async t=>{ + const f=await cachedFixture(t);f.state.mode='tool-error';const before=f.calls.length; + const failure=await f.run('mcp-call',{tool:'read_value',arguments:{}}); + assert.equal(failure.error.code,'MCP_TOOL_ERROR');assert.equal(failure.data.isError,true); + f.state.mode='json';assert.equal((await f.run('mcp-call',{tool:'read_value',arguments:{}})).status,'success'); + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/call','tools/call']); +}); + +test('cached requests use their own cancellation signal; Stop cannot poison a later request',async t=>{ + const f=await cachedFixture(t), controller=new AbortController();f.state.mode='timeout'; + const pending=f.run('mcp-call',{tool:'set_value',arguments:{}},{signal:controller.signal}); + while(f.state.invoked===0) await new Promise(r=>setTimeout(r,5));controller.abort(); + const result=await pending;assert.equal(result.status,'cancelled');assert.equal(f.state.invoked,1); + f.state.mode='json';assert.equal((await f.run('mcp-tools')).status,'success'); + assert.equal(f.state.invoked,1,'catalog recovery never repeats the interrupted tool'); +}); + +test('shared login changes, removed connections and endpoint edits cannot use a cached grant',async t=>{ + const store=memoryStore(); + const f=await fixture(t);f.state.token=expiringGrant(600); + saveSession(store,f.backend,{username:'fixture-user',access:'private-platform-token',refresh:'original-refresh'}); + const mcpCache=createMcpConnectionCache(); + const run=(action,input={})=>f.dispatch(action,{expectedAccount:'fixture-user',...input},f.create({env:{},store,mcpCache})); + assert.equal((await run('mcp-add')).status,'success'); + saveSession(store,f.backend,{username:'another-user',access:'other-access',refresh:'other-refresh'}); + const before=f.calls.length; + assert.equal((await run('mcp-call',{tool:'set_value',arguments:{}})).status,'failure'); + assert.ok(f.calls.slice(before).every(c=>c.server==='platform'));assert.equal(f.state.invoked,0); + saveSession(store,f.backend,{username:'fixture-user',access:'private-platform-token',refresh:'original-refresh'}); + assert.equal((await run('mcp-tools')).status,'success'); + const rows=JSON.parse(await fs.readFile(f.file));rows[0].mcp_connection.url='https://different.example/mcp';await fs.writeFile(f.file,JSON.stringify(rows)); + assert.equal((await run('mcp-call',{tool:'set_value',arguments:{}})).error.code,'INVALID_REQUEST'); + await fs.writeFile(f.file,'[]'); + assert.equal((await run('mcp-call',{tool:'set_value',arguments:{}})).error.code,'INVALID_REQUEST');assert.equal(f.state.invoked,0); +}); + +test('the cache expires without extending on use and never reuses an expired release grant',async t=>{ + const f=await cachedFixture(t), now=Date.now; + t.after(()=>{Date.now=now;}); + const first=f.state.exchanged, original=now(); + Date.now=()=>original+40_000; + assert.equal((await f.run('mcp-call',{tool:'read_value',arguments:{}})).status,'success');assert.equal(f.state.exchanged,first); + Date.now=()=>original+61_000; + assert.equal((await f.run('mcp-tools')).status,'success');assert.equal(f.state.exchanged,first+1); + // A valid-looking opaque or near-expiry grant may still be used for this action, + // but it must never be retained for another request. + f.state.token=expiringGrant(3); + await f.run('mcp-add');const count=f.state.exchanged; + await f.run('mcp-tools');assert.equal(f.state.exchanged,count+1); +}); + +test('a cached grant still requires the selected environment and honors server revocation without replay',async t=>{ + const f=await cachedFixture(t), before=f.calls.length; + const wrong=await f.run('mcp-call',{environmentUid:f.secondUid,tool:'set_value',arguments:{}}); + assert.equal(wrong.error.code,'INVALID_REQUEST');assert.equal(f.calls.length,before); + assert.equal((await f.run('mcp-tools')).status,'success'); + f.state.token='revoked-grant';const revokedAt=f.calls.length; + const denied=await f.run('mcp-call',{tool:'set_value',arguments:{}}); + assert.equal(denied.error.code,'AUTH_REJECTED');assert.equal(f.state.invoked,0); + assert.deepEqual(f.calls.slice(revokedAt).map(c=>c.method),['tools/call']); + f.state.token=expiringGrant(600);const exchanged=f.state.exchanged; + assert.equal((await f.run('mcp-tools')).status,'success');assert.equal(f.state.exchanged,exchanged+1); +}); + +test('a login change during a warm call discards its result and never repeats the operation',async t=>{ + const f=await fixture(t), store=memoryStore();f.state.token=expiringGrant(600); + saveSession(store,f.backend,{username:'fixture-user',access:'private-platform-token',refresh:'original-refresh'}); + const mcpCache=createMcpConnectionCache(); + const run=(action,input={},options={})=>f.dispatch(action,{expectedAccount:'fixture-user',...input},f.create({env:{},store,mcpCache,...options})); + assert.equal((await run('mcp-add')).status,'success');const before=f.calls.length; + const changedFetch=async(url,options)=>{ + const response=await fetch(url,options); + if(JSON.parse(options.body || '{}').method==='tools/call') + saveSession(store,f.backend,{username:'another-user',access:'other-access',refresh:'other-refresh'}); + return response; + }; + const result=await run('mcp-call',{tool:'set_value',arguments:{value:9}},{fetch:changedFetch}); + assert.equal(result.error.code,'AUTH_REJECTED');assert.equal(result.data,undefined); + assert.equal(result.effects.state,'partial-or-uncertain');assert.equal(f.state.invoked,1); + assert.deepEqual(f.calls.slice(before).map(c=>c.method),['tools/call']); +}); + test('conversation workflow discovers, saves, immediately calls and remembers MCP connections across client instances', async t => { const f = await fixture(t); const discovered = await f.dispatch('mcp-discover'); @@ -288,10 +435,27 @@ test('failed tools are not replayed and return uncertainty with credentials reda const result = await f.dispatch('mcp-call', { tool: 'set_value', arguments: { value: 1 } }); assert.equal(result.status, 'failure', mode); assert.equal(f.state.invoked, before + 1, mode); assert.equal(result.effects.state, 'partial-or-uncertain', mode); + if (mode === 'tool-error') { + assert.equal(result.error.code, 'MCP_TOOL_ERROR'); + assert.equal(result.data.isError, true); + assert.equal(result.data.structuredContent.value, 1); + } assert.doesNotMatch(JSON.stringify(result), /private-release-credential|private-platform-token/); } }); +test('account-bound MCP calls verify remotely at the action boundaries, not every protocol message', async t => { + const f = await fixture(t); await f.dispatch('mcp-add'); + const before = f.calls.length; + const result = await f.dispatch('mcp-call', { tool:'set_value', arguments:{ value:7 }, expectedAccount:'fixture-user' }); + assert.equal(result.status, 'success'); assert.equal(result.data.structuredContent.value, 7); + const calls = f.calls.slice(before); + assert.equal(calls.filter(c => c.path === '/api/v1/users/me/').length, 2); + assert.equal(calls[0].path, '/api/v1/users/me/'); + assert.equal(calls.at(-1).path, '/api/v1/users/me/'); + assert.equal(calls.filter(c => c.method === 'tools/call').length, 1); +}); + test('POST SSE and paginated tools work; unsupported stateful endpoints are not saved', async t => { const f = await fixture(t); f.state.mode = 'sse'; await f.dispatch('mcp-add'); assert.equal((await f.dispatch('mcp-call', { tool: 'set_value', arguments: { value: 3 } })).data.structuredContent.value, 3); diff --git a/tests/shared-transport.test.cjs b/tests/shared-transport.test.cjs new file mode 100644 index 0000000..c7eb1af --- /dev/null +++ b/tests/shared-transport.test.cjs @@ -0,0 +1,115 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const http = require('node:http'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { createSharedTransport } = require('../dist'); +const token = seconds => 'eyJ.'+Buffer.from(JSON.stringify({exp:Math.floor(Date.now()/1000)+seconds})).toString('base64url')+'.fixture'; + +async function fixture(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-http-test-')); + const counts = new Map(), sockets = new Set(); + const server = http.createServer(async (req, res) => { + let raw = ''; for await (const chunk of req) raw += chunk; + const body = raw ? JSON.parse(raw) : null, key = [req.method, req.url, req.headers.authorization || '', body?.method || ''].join(' '); + counts.set(key, (counts.get(key) || 0) + 1); + if (req.url === '/fail-after-send') { req.socket.destroy(); return; } + await new Promise(resolve => setTimeout(resolve, req.url.includes('slow') ? 300 : 35)); + if (req.url === '/auth/jwt-token/token/refresh/') { res.writeHead(200, {'content-type':'application/json'}).end(JSON.stringify({access:token(3600),refresh:'rotated-fixture-refresh'})); return; } + if (req.url === '/api/v1/users/me/') { res.writeHead(200, {'content-type':'application/json'}).end(JSON.stringify({username:'fixture@example.test'})); return; } + if (req.url === '/sse') { res.writeHead(200, {'content-type':'text/event-stream'}); res.write('data: '+JSON.stringify({jsonrpc:'2.0',id:body.id,result:{ok:true}})+'\n\n'); return; } + if (body?.method === 'notifications/initialized') { res.writeHead(202).end(); return; } + res.writeHead(200, {'content-type':'application/json'}); + res.end(JSON.stringify(body?.jsonrpc ? {jsonrpc:'2.0',id:body.id,result:{ok:true}} : {url:req.url, account:req.headers.authorization})); + }); + server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); }); + server.listen(0, '127.0.0.1'); await once(server,'listening'); + const client = createSharedTransport({directory}); + t.after(async () => { try { process.kill((await client.status()).pid); } catch {} client.close(); server.closeAllConnections(); server.close(); await fs.rm(directory,{recursive:true,force:true}); }); + return {directory,client,counts,sockets,url:'http://127.0.0.1:'+server.address().port}; +} + +test('20 independent chat processes share one owner, one read and bounded sockets', async t => { + const f=await fixture(t), children=[]; + t.after(()=>children.forEach(child=>child.kill())); + const code=`const {createSharedTransport}=require(${JSON.stringify(path.resolve('dist'))});const c=createSharedTransport({directory:process.argv[1]});process.send({ready:true});process.on('message',async()=>{try{const value=await(await c.fetch(process.argv[2]+'/api/v1/command-center/agents/?environment=one',{headers:{Authorization:'Bearer one'}})).json();process.send({value,status:await c.status()});c.close();process.disconnect();}catch(e){process.send({error:e.message});process.exit(1);}});`; + await Promise.all(Array.from({length:20},async()=>{const child=spawn(process.execPath,['-e',code,f.directory,f.url],{stdio:['ignore','ignore','pipe','ipc']});children.push(child);await once(child,'message');})); + const results=await Promise.all(children.map(child=>{const answer=once(child,'message');child.send('go');return answer.then(([value])=>value);})); + assert.ok(results.every(result=>!result.error),JSON.stringify(results)); + assert.equal(new Set(results.map(result=>result.status.instanceId)).size,1); + assert.equal([...f.counts.values()].reduce((a,b)=>a+b,0),1); + assert.ok(f.sockets.size<=2); + const before=(await f.client.status()).requests; + await f.client.fetch(f.url+'/api/v1/command-center/agents/?environment=one',{headers:{Authorization:'Bearer one'}}); + assert.equal((await f.client.status()).requests,before,'warm panel must not make another read'); + console.log(JSON.stringify({clients:20,owners:1,upstreamRequests:before,openSockets:f.sockets.size})); +}); + +test('scope isolation, MCP ID correlation, live refresh and no mutation replay', async t=>{ + const f=await fixture(t), auth={Authorization:'Bearer one'}; + for(const [environment,token] of [['one','one'],['two','one'],['one','two']])await f.client.fetch(f.url+'/api/v1/command-center/agents/?environment='+environment,{headers:{Authorization:'Bearer '+token}}); + assert.equal((await f.client.status()).requests,3); + const call=(id,method='tools/list',cache='force-cache',url='/mcp')=>f.client.fetch(f.url+url,{method:'POST',cache,headers:auth,body:JSON.stringify({jsonrpc:'2.0',id,method,params:{}})}); + const results=await Promise.all(Array.from({length:20},(_,i)=>call(i+1).then(r=>r.json()))); + assert.deepEqual(results.map(r=>r.id),Array.from({length:20},(_,i)=>i+1)); + assert.equal((await f.client.status()).requests,4); + await f.client.invalidate(); await call(21); + assert.equal((await f.client.status()).requests,5); + await assert.rejects(call(22,'tools/call','default','/fail-after-send'),/not replayed/); + assert.equal([...f.counts].filter(([key])=>key.includes('/fail-after-send')).reduce((n,[,v])=>n+v,0),1); + assert.equal((await(await call(23,'tools/call','default','/sse')).json()).id,23,'SSE completes at its matching response without waiting for stream close'); +}); + +test('one stopped panel does not cancel another panel sharing its read', async t=>{ + const f=await fixture(t), other=createSharedTransport({directory:f.directory});t.after(()=>other.close()); + await other.status(); + const controller=new AbortController(); + const stopped=f.client.fetch(f.url+'/slow',{signal:controller.signal}); + const completed=other.fetch(f.url+'/slow'); + const assertion=assert.rejects(stopped); + await new Promise(resolve=>setTimeout(resolve,80));controller.abort(); + assert.equal((await(await completed).json()).url,'/slow');await assertion; + assert.equal((await other.status()).requests,1); +}); + +test('worker failure never replays an accepted POST; subsequent reads reconnect', async t=>{ + const f=await fixture(t);const original=await f.client.status(); + const pending=f.client.fetch(f.url+'/slow-send',{method:'POST',body:'{}'});const failure=assert.rejects(pending,/No request was replayed/); + await new Promise(resolve=>setTimeout(resolve,80));process.kill(original.pid,'SIGKILL');await failure; + const next=await f.client.status();assert.notEqual(next.instanceId,original.instanceId); + await f.client.fetch(f.url+'/api/v1/users/me/'); + assert.equal([...f.counts].filter(([key])=>key.includes('/slow-send')).reduce((n,[,v])=>n+v,0),1); +}); + +test('private directory and expiry boundaries prevent unsafe cache reuse',async t=>{ + const f=await fixture(t); + const token='eyJ.'+Buffer.from(JSON.stringify({exp:Math.floor(Date.now()/1000)-1})).toString('base64url')+'.signature'; + for(let i=0;i<2;i++)await f.client.fetch(f.url+'/api/v1/users/me/',{headers:{Authorization:'Bearer '+token}}); + assert.equal((await f.client.status()).requests,2); + const unsafe=path.join(f.directory,'unsafe');await fs.mkdir(unsafe,{mode:0o755});const client=createSharedTransport({directory:unsafe}); + await assert.rejects(client.status(),/private directory/);client.close(); +}); + +test('20 processes renew an expired shared session once and persist one returned pair',async t=>{ + const f=await fixture(t),file=path.join(f.directory,'fixture-session.json'),writes=path.join(f.directory,'writes.txt'); + await fs.writeFile(file,JSON.stringify({username:'fixture@example.test',access:token(-60),refresh:'old-fixture-refresh'})); + const children=[];t.after(()=>children.forEach(c=>c.kill())); + const code=`const fs=require('node:fs/promises');const {createSharedTransport,createSessionService,createNodeHost}=require(${JSON.stringify(path.resolve('dist'))});const c=createSharedTransport({directory:process.argv[1]});const session=createSessionService(createNodeHost(),{env:{},fetch:c.fetch,sessionLock:c.sessionLock,persistence:{mode:'machine',name:'fixture',read:async()=>JSON.parse(await fs.readFile(process.argv[3],'utf8')),write:async(_,value)=>{const pending=process.argv[3]+'.'+process.pid;await fs.writeFile(pending,JSON.stringify(value));await fs.rename(pending,process.argv[3]);await fs.appendFile(process.argv[4],'write\\n');},remove:async()=>{}}});process.send({ready:true});process.on('message',async()=>{try{process.send({status:await session.status(process.argv[2],true)});}catch(e){process.send({error:e.code||e.message});}finally{c.close();process.disconnect();}});`; + await Promise.all(Array.from({length:20},async()=>{const c=spawn(process.execPath,['-e',code,f.directory,f.url,file,writes],{stdio:['ignore','ignore','pipe','ipc']});children.push(c);await once(c,'message');})); + const results=await Promise.all(children.map(child=>{const answer=once(child,'message');child.send('go');return answer.then(([value])=>value);})); + assert.ok(results.every(r=>r.status?.checked&&r.status?.username==='fixture@example.test'),JSON.stringify(results)); + assert.equal((await fs.readFile(writes,'utf8')).trim().split('\n').length,1); + assert.equal([...f.counts].filter(([key])=>key.includes('/auth/jwt-token/token/refresh/')).reduce((n,[,v])=>n+v,0),1); +}); + +test('a disconnected session lease owner releases a waiting client',async t=>{ + const f=await fixture(t),other=createSharedTransport({directory:f.directory});t.after(()=>other.close()); + let entered;const started=new Promise(resolve=>entered=resolve);let finish; + const first=f.client.sessionLock(f.url,()=>{entered();return new Promise(resolve=>finish=resolve);});await started; + const second=other.sessionLock(f.url,async()=>true);f.client.close();assert.equal(await second,true);finish();await first; + // fixture cleanup uses the surviving client after the first was intentionally closed. + const pid=(await other.status()).pid;process.kill(pid); +}); diff --git a/tests/working-directory.test.cjs b/tests/working-directory.test.cjs new file mode 100644 index 0000000..64999ff --- /dev/null +++ b/tests/working-directory.test.cjs @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +test('MCP and CLI survive deleted cwd with explicit backend; implicit project lookup reports actionable failure', { skip: process.platform === 'win32' }, async t => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'ms deleted cwd ')); + t.after(() => fs.rm(temporary, { recursive: true, force: true })); + const script = ` + const assert = require('node:assert/strict'); + const fs = require('node:fs'); + const { createDevTools, createNodeHost, parseArguments } = require(${JSON.stringify(path.resolve('dist'))}); + (async () => { + let calls = 0; + const session = { + status: async backend => ({backend, authenticated:true, username:'fixture', checked:true}), + request: async (backend, method) => { + assert.equal(backend,'https://fixture.example'); assert.equal(method,'GET'); calls++; + return [{uid:'00000000-0000-4000-8000-000000000001',name:'Fixture',is_production:false}]; + } + }; + const tools = env => createDevTools({host:createNodeHost(),session,env}); + // Ordinary CLI users must retain their project .env selection. + fs.writeFileSync('.env','MAINSEQUENCE_ENDPOINT=https://fixture.example\\n'); + assert.equal((await tools({}).dispatch({action:'mcp-environments'})).status,'success'); + fs.unlinkSync('.env'); + fs.rmdirSync(process.cwd()); + // Clear Node's cached cwd so the OS lookup sees the removed inode. + process.chdir('.'); + assert.throws(() => process.cwd(),{code:'ENOENT'}); + assert.equal((await tools({}).dispatch({action:'auth-status',backend:'https://fixture.example',check:true})).status,'success'); + for (const [env,request] of [ + [{},{action:'mcp-environments',backend:'https://fixture.example'}], + [{MAINSEQUENCE_ENDPOINT:'https://fixture.example'},{action:'mcp-environments'}] + ]) { + const result = await tools(env).dispatch(request); + assert.equal(result.status,'success',JSON.stringify(result)); assert.equal(result.data.length,1); + } + const failed = await tools({}).dispatch({action:'mcp-environments'}); + assert.equal(failed.error.code,'CURRENT_DIRECTORY_UNAVAILABLE'); + assert.match(failed.error.message,/reload the plugin host/); + assert.equal(calls,3,'No fallback backend or network retry on missing cwd'); + assert.equal(parseArguments(['--help']).help,true); + assert.equal(parseArguments(['auth-status','--backend','https://fixture.example']).request.action,'auth-status'); + assert.equal(parseArguments(['mcp-list','--backend','https://fixture.example']).request.backend,'https://fixture.example'); + assert.equal(parseArguments(['status','--path','/explicit/project']).request.root,'/explicit/project'); + assert.throws(() => parseArguments(['status']),{code:'CURRENT_DIRECTORY_UNAVAILABLE'}); + console.log('deleted-cwd regression passed; fake session only; zero network'); + })().catch(error => { console.error(error); process.exitCode=1; }); + `; + const result = spawnSync(process.execPath, ['-e', script], { cwd:temporary, encoding:'utf8', timeout:10000 }); + assert.equal(result.status,0,result.stderr); + assert.match(result.stdout,/deleted-cwd regression passed/); +}); diff --git a/tests/workspace-settings.test.cjs b/tests/workspace-settings.test.cjs new file mode 100644 index 0000000..9e3d6cd --- /dev/null +++ b/tests/workspace-settings.test.cjs @@ -0,0 +1,10 @@ +const test=require('node:test'),assert=require('node:assert/strict'),fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path'); +const {writeWorkspacePreferences,readDesktopPreferences}=require('../dist'); +test('workspace settings preserve unrelated fields and reject corrupt files and relative paths',async t=>{ + const root=await fs.mkdtemp(path.join(os.tmpdir(),'ms-settings-'));t.after(()=>fs.rm(root,{recursive:true,force:true}));const configPath=path.join(root,'config.json'),options={configPath,homeDir:root,env:{}}; + await fs.writeFile(configPath,JSON.stringify({custom:{keep:true}}));const input={backend:'https://fixture.example',baseDir:path.join(root,'repos'),commandCenterUrl:'https://command.example'}; + assert.equal((await writeWorkspacePreferences(input,options)).baseDir,input.baseDir);assert.deepEqual(JSON.parse(await fs.readFile(configPath)).custom,{keep:true}); + await assert.rejects(writeWorkspacePreferences({...input,baseDir:'relative'},options),/absolute/); + await fs.writeFile(configPath,'broken');await assert.rejects(writeWorkspacePreferences(input,options),/preserved/);assert.equal(await fs.readFile(configPath,'utf8'),'broken'); + await assert.rejects(readDesktopPreferences(options),/preserved/); +}); From b8abf5fba403c679507c86c26c6915beaa685d5b Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Fri, 23 Oct 2026 15:51:17 +0200 Subject: [PATCH 2/2] Add the shared project-tool registry (0.3.12) Both host plugins present every saved project MCP connection's tools as first-class host tools named ms__, each with its own copy of the name, the definition, the result shape, the catalog refresh and the state directory (ADR 0004). This package now owns that registry: - src/shared/projectTools.ts: projectToolName (FNV-1a, 32-character cut; the CRM vector ms_crm_contacts_list_183f8138 is a frozen test), projectToolDefinition(s) with the 80-tool cap, projectToolResult pinned to both plugins' fixtures, readProjectToolCatalog with bounded concurrency and retention of an unreadable connection's earlier tools, and createProjectToolCatalog: the on-disk catalog (project-tool-catalog.json beside mcp-connections.json) with its lock, atomic writes, modification-time reload, scope-aware listing and dispose(). - mcp-catalog and mcp-tool-call actions for a host that reaches this package only through a worker process; ms-devtools mcp-catalog [--refresh] on the command line. - mainSequenceStateDirectory(env) replaces three copies of the state-directory path. - Project requests get their own budget, projectRequestTimeoutMs (default 120 s), and a tools/call honours _meta["mainsequence.ai/timeout-seconds/v1"] when larger, capped at 300 s. Error messages name the endpoint, method and status, tell a timeout from a connection failure, and keep a JSON-RPC error's own message; the codes are unchanged. - README and docs describe the registry, what stays host-specific and the measured cold-start latency; ADR 0004 records the decisions as implemented. Co-Authored-By: Claude Fable 5.1 --- README.md | 65 ++- docs/adr/0004-shared-project-tool-registry.md | 228 +++++++++++ docs/conversational-mcp.md | 9 + package-lock.json | 4 +- package.json | 2 +- src/index.ts | 7 + src/shared/actions.ts | 38 +- src/shared/arguments.ts | 12 +- src/shared/discoveryTypes.ts | 3 + src/shared/mcp.ts | 26 +- src/shared/mcpTransport.ts | 76 +++- src/shared/projectTools.ts | 355 ++++++++++++++++ src/shared/repositories.ts | 4 +- src/shared/session.ts | 13 +- src/shared/stateDirectory.ts | 9 + tests/mcp.test.cjs | 2 +- tests/project-tools.test.cjs | 385 ++++++++++++++++++ 17 files changed, 1191 insertions(+), 47 deletions(-) create mode 100644 docs/adr/0004-shared-project-tool-registry.md create mode 100644 src/shared/projectTools.ts create mode 100644 src/shared/stateDirectory.ts create mode 100644 tests/project-tools.test.cjs diff --git a/README.md b/README.md index c5291c8..54abbc4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MainSequence developer tooling -One TypeScript package owns the shared action models, browser authentication, machine session, repository resolution, project dispatch, the `ms-devtools` command runner and the project MCP client. Both SDK repositories remain unchanged. Host integrations live in their own repositories and consume only this package's public API: [VS Code](https://github.com/mainsequence-sdk/ms-vscode-extension), [Claude Desktop](https://github.com/mainsequence-sdk/mainsequence-claude-desktop) and, since 0.3.0, the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin) ([ADR 0003](docs/adr/0003-codex-plugin-extraction.md)). There are no runtime npm dependencies or LLM API calls in the runner. The MCP client calls the existing platform directory and selected project MCP endpoints; the developer actions retain their deterministic execution. +One TypeScript package owns the shared action models, browser authentication, machine session, repository resolution, project dispatch, the `ms-devtools` command runner and the project MCP client. Both SDK repositories remain unchanged. Host integrations live in their own repositories and consume only this package's public API: [VS Code](https://github.com/mainsequence-sdk/ms-vscode-extension), [Claude Desktop](https://github.com/mainsequence-sdk/mainsequence-claude-desktop) and, since 0.3.0, the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin) ([ADR 0003](docs/adr/0003-codex-plugin-extraction.md)). Since 0.3.12 both plugins register saved project MCP tools through the [shared project-tool registry](#shared-project-tool-registry-0312) ([ADR 0004](docs/adr/0004-shared-project-tool-registry.md)). There are no runtime npm dependencies or LLM API calls in the runner. The MCP client calls the existing platform directory and selected project MCP endpoints; the developer actions retain their deterministic execution. ## Build and run @@ -63,6 +63,67 @@ agent and readiness APIs as other consumers; no host UI code is included. See The `mcp-discover`, `mcp-add`, `mcp-list`, `mcp-remove`, `mcp-tools` and `mcp-call` actions discover the platform's MCP directory, remember a selected release and invoke its tools through a local MCP client, using the existing developer session and short-lived release credentials. They are available from `ms-devtools` and from `createDevTools().dispatch()`; hosts may inject `mcpConnectionsPath`. Since 0.3.1, `mcp-add` reads the release's name, its repository's name and its repository branch from the release directory and stores them with the connection as `name`, `display_name` and `code_repository_branch_uid`, so `mcp-list` labels a saved connection without a backend request; a record saved by an earlier version is completed by the first listing that can read them, and a label that cannot be read is left out rather than making the MCP unavailable. Discovery rows carry `name` and `code_repository_branch_uid` as the platform directory reports them. The conversational `mainsequence-mcp` skill that lets Codex use them without `/ms` ships with the [Codex plugin](https://github.com/mainsequence-sdk/mainsequence-codex-plugin), together with [its user guide](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/conversational-mcp.md) and [ADR 0002](https://github.com/mainsequence-sdk/mainsequence-codex-plugin/blob/main/docs/adr/0002-conversational-project-mcp-client.md). +A cold project MCP (a scaled-to-zero FastAPI release) is slow. Measured against the +Production CRM on 2026-10-23, a first `mcp-call` took 53–71 s (release detail and +exchange-launch about 2 s each, the project's `initialize` 9 s to over 60 s, `tools/call` +10–39 s); a warm one 7–10 s. Since 0.3.12 project requests have their own budget, +`projectRequestTimeoutMs` (default 120 s; `requestTimeoutMs` keeps governing backend +requests, default 30 s), and a `tools/call` honours the tool's advertised +`_meta["mainsequence.ai/timeout-seconds/v1"]` when it is larger, capped at 300 s. Hosts set +their outer limit above this (Codex `tool_timeout_sec`, the Claude Desktop worker budget). +Error messages say which endpoint answered which request (`The project MCP https://… returned +HTTP 503 for tools/call crm_contacts_list.`), tell a timeout (`timed out after 120 s`) apart +from a connection failure (`failed before a response arrived (ECONNREFUSED)`), and keep a +JSON-RPC error's own message, redacted. The error codes are unchanged. + +## Shared project-tool registry (0.3.12) + +Both host plugins present every saved project MCP connection's tools as first-class tools of +the host, named `ms__` (for example `ms_crm_contacts_list_183f8138`), so a model +calls `crm_contacts_list` like any other tool instead of walking a discovery protocol. Since +0.3.12 this package owns that registry ([ADR 0004](docs/adr/0004-shared-project-tool-registry.md)) +and the plugins keep only what is specific to their host. + +- `projectToolName(backend, environmentUid, releaseUid, tool)`: `ms_` + the remote name + (characters outside `[a-zA-Z0-9_-]` replaced by `_`, cut to 32) + `_` + the lower-case + hexadecimal FNV-1a 32-bit hash of `backend:environment:release:tool`. One remote tool has + one name in every host; the CRM vector above is a frozen test. +- `projectToolDefinition(connection, tool)` and `projectToolDefinitions(connection, tools)` + (80 per connection, duplicates and invalid tools dropped): `{ name, title, description, + inputSchema, annotations }` with the description `MainSequence []: + `, an object schema (`type: 'object'` added when the server omitted it) + and the four boolean hints, open-world by default. +- `projectToolResult(actionResult)`: what a host returns for a forwarded call: the remote + content blocks, then one JSON text block with `summary`, `effects`, `error` and + `structuredContent` when the call failed, returned structured content or no blocks; + `isError` on any non-success, with the remote content of an `MCP_TOOL_ERROR` preserved. +- `readProjectToolCatalog(dispatch, { backend?, environmentUid?, previous?, signal? })`: + `mcp-list` (read-only), then one `mcp-tools` per saved connection, two at a time. A + connection whose catalog cannot be read keeps its previous tools and is named in + `unavailable`; a removed connection loses them; connections outside the scope are carried + over. No account snapshot is attached: the session for a connection's backend is verified + when its tool is called. +- `createProjectToolCatalog({ dispatch, file?, staleMs?, notify?, disabled? })`: the shared + on-disk catalog, `project-tool-catalog.json` beside `mcp-connections.json` + (`mainSequenceStateDirectory(env)` names that directory). `list()` answers from the file at + once, scoped to the current account's backend once a refresh has learned it; `refresh()` + re-reads under a lock, so one process reads catalogs at a time while the others pick its + result up from the file; `update()`, `dispose()` and `notify` serve a long-lived host such as + the Codex MCP server. The file holds schemas and labels only, never credentials, tool + results or tokens. +- The `mcp-catalog` action returns the same definitions, bindings and state for a host that + dispatches actions instead of importing (the Claude Desktop hook reaches this package only + through a worker process): without `refresh` from the file, with `refresh: true` re-reading + a stale cache, with `'force'` always, waiting up to two minutes for another process's + refresh to end. `mcp-tool-call` is `mcp-call` answered in the `projectToolResult` shape. + `ms-devtools mcp-catalog [--refresh]` is the command-line form. + +What stays host-specific: the Codex server merges the definitions into its `tools/list`, +sends `tools/list_changed`, keeps its `contextId` discovery layer and shared transport, and +forwards an `ms_*` call through `mcp-call` and `projectToolResult`; the Claude Desktop hook +registers the definitions with `$.tool.register`, asks `$.ui.ask` before every call, +converts the result's blocks to Messages API blocks and spawns one worker per action. + ## Shared discovery API (0.3.2) `createDevTools().dispatch()` now infers typed payloads for session status, environments, @@ -167,7 +228,7 @@ Tests use a loopback fixture backend, synthetic in-memory store entries, stand-i The optional SDK compatibility script checks the **actual provided SDK source implementations** against synthetic in-memory records in both directions, with an isolated temporary Python home. It imports the SDK private session implementation only for compatibility verification, never as a product dependency, and loads its dependencies from the specified checkout without running the SDK initializer. Set `MAINSEQUENCE_TEST_PYTHON=/path/to/sdk/.venv/bin/python` when its CLI dependencies are unavailable to `python3`. Native-store behavior is tested using the extracted macOS/Linux program fixtures; live OS-store and backend refresh-rotation interoperability require a separate release check. -See [ADR 0001](docs/adr/0001-shared-developer-tooling-and-codex.md), [Phase-1 verification](docs/phase-1-verification.md) and [Phase-2 verification](docs/phase-2-verification.md) for boundaries and outstanding checks. +See [ADR 0001](docs/adr/0001-shared-developer-tooling-and-codex.md), [ADR 0004](docs/adr/0004-shared-project-tool-registry.md), [Phase-1 verification](docs/phase-1-verification.md) and [Phase-2 verification](docs/phase-2-verification.md) for boundaries and outstanding checks. ## Phase 2 host integration diff --git a/docs/adr/0004-shared-project-tool-registry.md b/docs/adr/0004-shared-project-tool-registry.md new file mode 100644 index 0000000..2684b18 --- /dev/null +++ b/docs/adr/0004-shared-project-tool-registry.md @@ -0,0 +1,228 @@ +# ADR 0004: Shared project-tool registry for host plugins + +Date: 2026-10-23. + +Status: Accepted by the owner's instruction, "OK this plan approved", after the +comparison of the Claude Desktop and Codex plugins recorded in the Codex plugin's +`docs/agents-tools-panel.md` (0.7.8). Implemented in this repository as 0.3.12 +(2026-10-23); the Claude Desktop and Codex plugins adopt it in their own releases. + +## Context + +Two host integrations present each saved project MCP connection (for example the +CRM release) as first-class tools of the host, so a model calls `crm_contacts_list` +like any other tool instead of walking a discovery protocol: + +- The Claude Desktop mod (`mainsequence-claude-desktop`, `claude/hooks/register.mjs`) + names each remote tool `ms__`, registers it with `$.tool.register`, + forwards a call to the `mcp-call` action in a fresh worker process, and converts the + result into Messages API blocks. It pins devtools 0.3.1. +- The Codex plugin (`mainsequence-codex-plugin`, `src/hosts/toolCatalog.ts` and + `src/panel-server.ts`) gained the same capability on 2026-10-23, with a catalog + cache on disk so `tools/list` answers at once and a background refresh. It pins + devtools 0.3.11. + +Both copies implement the same five things: the tool name, the tool definition +(description, object schema, annotation hints, an 80-tool cap per connection), the +result shape (remote content blocks plus a JSON block carrying summary, effects, error +and structuredContent), the refresh loop over `mcp-list` and `mcp-tools` that keeps a +connection's earlier tools when its catalog cannot be read, and the directory where +shared MainSequence state lives. The Codex copy was written to match the Claude copy +by hand; the fixture `ms_crm_contacts_list_183f8138` proves they agree today and +nothing keeps them agreeing tomorrow. The version drift (0.3.1 against 0.3.11) is +already visible: `claudeMcpNames.ts` exists only because devtools 0.3.1 did not store +connection labels. + +The comparison also found two gaps in this library that both hosts work around: + +- A cold project MCP call measured 53–71 s against the Production CRM (release + detail and exchange-launch about 2 s each, project `initialize` 9 s to over 60 s, + `tools/call` 10–39 s). The project request timeout defaults to 60 s, which aborted a + cold `initialize`; the Codex host additionally stops MCP tools at 60 s unless its + manifest says otherwise. The CRM catalog already advertises a per-tool timeout hint, + `_meta["mainsequence.ai/timeout-seconds/v1"]`, that nothing reads. +- Error messages lose what a model or a person needs: `BACKEND_UNREACHABLE` covers + both a timeout and a connection failure, `API_ERROR` reports `HTTP 503` without + saying which endpoint answered, and a JSON-RPC error's own message is dropped. Each + host then relabels errors generically (Codex's `CONTEXT_CHANGED: Refresh + MainSequence`, Claude's `PROCESS_FAILED`). + +## Decision + +This library owns the registry. It exports host-neutral building blocks and one +file-backed catalog; a host keeps only what is specific to how it registers tools, +asks for approval and renders results. + +### Public API + +Additive to the existing `createDevTools()` surface; nothing existing changes shape. + +- `projectToolName(backend, environmentUid, releaseUid, tool)`: `ms_` + the remote + name with `[^a-zA-Z0-9_-]` replaced by `_`, cut to 32 characters, + `_` + the + lower-case hexadecimal FNV-1a 32-bit hash of `${backend}:${environmentUid}:${releaseUid}:${tool}`. + The hash binds the saved connection and environment; the prefix keeps the remote + name readable. The cut is 32, not Claude's current 42, because Codex qualifies the + name as `mcp__mainsequence__` and model-facing function names are limited to 64 + characters (19 + 3 + 32 + 1 + 8 = 63). For a remote name of at most 32 characters, + which includes all 70 CRM tools today (the longest is 31), the result is identical to + the name Claude registers now. `ms_crm_contacts_list_183f8138` for backend + `http://127.0.0.1:8000`, environment `18595d12-f92a-4960-84cd-0c6798a1ad25`, release + `2de75e21-bfe1-4b77-b668-6916488ed317` is a frozen test vector in this repository. +- `projectToolDefinition(connection, tool)`: `{ name, title, description, inputSchema, annotations }` + with description `MainSequence []: `, + an `inputSchema` that is an object schema (`type: 'object'` added when the server + omitted it), only the four boolean hints `readOnlyHint`, `destructiveHint`, + `idempotentHint`, `openWorldHint` (plus `openWorldHint: true` as default). + `projectToolDefinitions(connection, tools)` applies the 80-tools-per-connection cap and + drops duplicate or invalid tools. The connection name is the release's own name, else + its repository's name, else the release UID (Claude's pane kept the repository name + first; the tool description now follows the Codex order, which is also the stored + `name`); the environment is its stored name, else its UID. +- `projectToolResult(actionResult)`: the host-neutral result for a forwarded call: + the remote `content` blocks, followed by one JSON text block with `summary`, + `effects`, `error` and `structuredContent` when the call failed or returned + structured content; `structuredContent` passed through; `isError` on any non-success, + including `MCP_TOOL_ERROR`, whose original remote content is preserved. This is the + shape Claude's `toolResult` and Codex's `projectToolCall` build today; a fixture test + pins it against both plugins' current test expectations. A result without content + blocks (a refusal before the call) is that JSON block alone, and a failure without + structured content carries the block as `structuredContent` too, as the Codex server + returned it; Claude's hook keeps its whole-result fallback only for its own host tools. +- `readProjectToolCatalog(dispatch, { backend, environmentUid?, previous?, signal? })`: + `mcp-list` (read-only) and one `mcp-tools` per saved connection with bounded + concurrency (two, matching the shared transport's per-origin limit). A connection + whose catalog cannot be read keeps its `previous` tools and is listed as + `unavailable`; a removed connection loses its tools. No `expectedAccount` snapshot is + attached: a registered tool is bound to a saved connection, and the session for that + connection's backend is verified when the tool is called. +- `createProjectToolCatalog({ file?, dispatch, staleMs?, notify?, disabled? })`: the + on-disk catalog (default `codex-tool-catalog.json` is renamed to a host-neutral + `project-tool-catalog.json` beside `mcp-connections.json`), with atomic writes, a lock + so one process refreshes at a time, stale-lock recovery, modification-time reload so + other processes see a refresh, `update(releaseUid, tools)` for a catalog just read + explicitly, `dispose()` to free the lock when a host process ends, and `notify` for a + host's change announcement. It stores schemas and labels only; never credentials, + tool results or tokens. Because the file is shared by every host on the machine, a + refresh replaces only the connections in its scope (the backend, and the environment + when given) and carries the rest over, and `list()`/`state()` are scoped to the current + account's backend once a refresh has learned it from `auth-status`, so another + backend's cached connections stay out of a host's tool list. `refresh(force, { scope?, + waitMs? })` resolves to whether this process read and wrote the catalog; with `waitMs` + it waits for another process's lock instead of skipping, and reports `PROJECT_BUSY` + in its state when the wait runs out. +- `mainSequenceStateDirectory(env)`: the one place that knows + `~/Library/Application Support/MainSequenceCLI`, `%APPDATA%\MainSequenceCLI` and + `~/.config/mainsequence`, used by the connection store and the catalog. +- Two dispatch actions for a host that cannot import this package where it registers + tools (the Claude Desktop hook runs in the mod runtime and reaches this package only + through a worker process per action): `mcp-catalog` returns the definitions, bindings + and catalog state (from the file; `refresh: true` re-reads a stale cache, `'force'` + always, waiting up to two minutes for another process's refresh to end), and + `mcp-tool-call` is `mcp-call` answered in the `projectToolResult` shape, keeping the + call's status, summary, error and effects. Neither adds a second code path: both call + the functions above. `ms-devtools mcp-catalog [--refresh]` is the command-line form. + +### Error fidelity and timeouts + +- Project requests get their own budget, `projectRequestTimeoutMs`, default 120 s; + backend requests keep 30 s. When a tool's `_meta["mainsequence.ai/timeout-seconds/v1"]` + is larger than the default it is honoured, capped at 300 s. Hosts still own their + outer limit (Codex `tool_timeout_sec`, Claude's worker budget) and set it above this. +- Existing error codes stay, because hosts switch on them. Messages become precise: + which origin and method (`initialize`, `tools/call crm_contacts_list`) answered which + HTTP status, whether a request timed out after how many seconds or failed to connect, + and the redacted JSON-RPC error message. Hosts pass these messages through instead + of relabelling them. + +### Host adapter contract + +A host implements, and only implements: + +1. obtaining definitions (`list()` from the catalog, or `readProjectToolCatalog` when + it has no persistent process), +2. registering or listing them in its own vocabulary (`$.tool.register`, MCP + `tools/list`), +3. forwarding a call: `mcp-call` with the binding's backend, environment, release and + remote tool name, then `projectToolResult` and the host's own block conversion, +4. approvals and presentation (Claude asks per call with `$.ui.ask`; Codex relies on + the host's tool approval modes), +5. refresh triggers: after a connection or account change, and a change announcement + where the host supports one (`tools/list_changed`). + +Everything in 1–3 is the shared code above; 4 and 5 are a few lines per host. A third +host, such as the VS Code extension, implements the same five points and gets the same +names, schemas, results and errors. + +## Keeping Claude Desktop stable + +The Claude plugin is the reference behaviour; this registry reproduces it rather than +redesigning it. Concretely: + +- Names for every remote tool of up to 32 characters are unchanged. A longer remote + name (none in the current CRM catalog) would change once when Claude adopts the + shared helper; its hash, and so its identity, does not. The frozen name vector guards + the scheme. +- The `mcp-call` action, the `ActionResult` shape, the connection file format, the + Keychain session store and credential handling are untouched. The library's API grows; + no existing export changes. +- `projectToolResult` is pinned by a fixture to Claude's current `toolResult` output, + so the blocks Claude converts to the Messages API are the same bytes. The hook's + `toolResult` keeps only that conversion (and a whole-result fallback for the host's own + tools, deployment status and agents); a project tool's result arrives from the worker + already shaped, through `mcp-tool-call`. +- Claude keeps its process model and safety: one worker per call, the 30-minute budget, + `$.ui.ask` before every project call, stale names refused by the handler. The catalog + cache is an optimization it may adopt (tools registered at boot from the cache instead + of after N worker processes); when the cache is missing or unreadable, behaviour is + today's. +- Adopting the registry is a Claude release of its own: devtools pin 0.3.1 → the new + version, `claudeMcpNames.ts` removed because devtools stores connection labels since + 0.3.1, `register.mjs` reduced to the adapter contract, the existing + `verify:claude*` scripts and `claude-mcp.test.cjs` kept green, and a live acceptance + against the CRM recorded in its docs, as for every Claude release. + +Known limit, unchanged by this decision: the name hashes the backend string. Today +Claude is bound to `http://127.0.0.1:8000` and Codex to `http://local-backend:8000` +for the same development backend, so the same CRM tool has two names across hosts on +this workstation until one string is used for both. Normalising aliases would change +the Keychain account that holds the session, so this ADR does not do it; it documents +the recommendation to configure one backend string per machine. + +## Extensibility + +- Server-declared metadata flows through once: the timeout hint today; approval or + visibility hints a project may declare tomorrow are read in `projectToolDefinition` + and reach every host without host changes. +- The catalog is host-neutral state. Two hosts on one machine share one refresh and + one file; a refresh by either is visible to the other at its next listing. +- The library keeps no host SDK dependency and no network listener; the pieces are pure + functions plus a file, and remain usable from the `ms-devtools` runner for scripts + and verification. + +## Alternatives considered + +- Keep two implementations and a cross-repository test vector. Rejected: the drift has + already happened (0.3.1 against 0.3.11, `claudeMcpNames.ts`), and the error and + timeout fixes would have to be made twice more. +- A standalone MainSequence MCP proxy server that both hosts connect to natively. + Rejected: Claude registers tools in-process and asks for approval per call; a daemon + would add a lifecycle, a credential surface and a second place where the platform + session is held, against ADR 0002's decision that the plugin is the MCP client. +- Asking the platform directory to proxy project tools. Out of scope, as in ADR 0002: + each project remains a direct MCP endpoint reached with its own release credential. + +## Rollout and verification + +1. devtools: the API above, with tests for the name vector, definitions, result + parity with both plugins' current fixtures, catalog refresh with retention, lock and + disposal, modification-time reload, timeouts and the improved messages. Released as + 0.3.12 (or 0.4.0 if the API needs a breaking change, which this ADR does not expect). +2. Codex plugin: `src/hosts/toolCatalog.ts` replaced by the library; the server keeps + `tools/list` merging, `tools/list_changed` and the thin `projectToolCall`; + `tool_timeout_sec` stays at 300 s in its manifest; its suite and the live CRM check + repeated. +3. Claude Desktop: as described under stability, in its own release. + +Each step keeps its repository's suite green against the exact devtools pin; the Codex +build refuses a pin mismatch. Fixture tests are development evidence; the live checks +against a deployed CRM remain the acceptance of each plugin release. diff --git a/docs/conversational-mcp.md b/docs/conversational-mcp.md index b3c05ca..8701093 100644 --- a/docs/conversational-mcp.md +++ b/docs/conversational-mcp.md @@ -8,3 +8,12 @@ This package keeps the client and its command-line form. `ms-devtools mcp-discov `ms-devtools --help`, and `createDevTools().dispatch()` accepts the same `mcp-*` actions through `ActionRequest`. Hosts may inject `mcpConnectionsPath` for their own metadata location. See [ADR 0002](adr/0002-conversational-project-mcp-client.md). + +Since 0.3.12 the saved connections' tools are also available as a host registers them, +`ms__`, through `ms-devtools mcp-catalog [--refresh]` and the `mcp-catalog` +action, from the shared `project-tool-catalog.json` beside `mcp-connections.json`; see the +[shared project-tool registry](../README.md#shared-project-tool-registry-0312) and +[ADR 0004](adr/0004-shared-project-tool-registry.md). A cold project MCP has taken over a +minute to answer `initialize`: project requests get 120 s by default +(`projectRequestTimeoutMs`), and a tool's advertised +`_meta["mainsequence.ai/timeout-seconds/v1"]` is honoured when larger, capped at 300 s. diff --git a/package-lock.json b/package-lock.json index 59aeece..51e731b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.11", + "version": "0.3.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.11", + "version": "0.3.12", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index 2f674c2..0bc0173 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.11", + "version": "0.3.12", "description": "Shared MainSequence developer actions, project operations, MCP client and command runner", "license": "MIT", "engines": { diff --git a/src/index.ts b/src/index.ts index 26f0311..2c92fe9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,13 @@ export type { McpRequest } from './shared/mcp'; export { createMcpConnectionCache } from './shared/mcp'; export type { McpConnectionCache } from './shared/mcp'; export type { ActionData, DiscoveryResultMap, AgentDirectory, McpConnection, McpDiscoveryPage, McpCatalog, McpTool } from './shared/discoveryTypes'; +// The shared project-tool registry (ADR 0004): one name, definition, result shape and catalog for every host. +export { mainSequenceStateDirectory } from './shared/stateDirectory'; +export { projectToolName, isProjectToolName, projectToolConnection, projectToolConnectionLabel, projectToolDefinition, projectToolDefinitions, projectToolBinding, projectToolRegistration, + projectToolResult, projectToolTimeoutMs, readProjectToolCatalog, readProjectToolCatalogFile, projectToolCatalogPath, createProjectToolCatalog, + PROJECT_TOOL_PREFIX, MAX_PROJECT_TOOLS_PER_CONNECTION, PROJECT_TOOL_TIMEOUT_HINT, DEFAULT_PROJECT_REQUEST_TIMEOUT_MS, MAX_PROJECT_TOOL_TIMEOUT_MS } from './shared/projectTools'; +export type { ProjectToolBinding, ProjectToolConnection, ProjectToolCatalog, ProjectToolCatalogOptions, ProjectToolCatalogState, ProjectToolCatalogStore, ProjectToolCatalogView, + ProjectToolDefinition, ProjectToolDispatch, ProjectToolResult, ProjectToolScope } from './shared/projectTools'; export { listEnvironments } from './shared/environments'; export type { EnvironmentSummary } from './shared/environments'; export { bindSessionAccount } from './shared/accountScope'; diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 81105c9..38b2a39 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -9,9 +9,18 @@ import { setupOrganizationProject } from './organizationProjectWorkspace'; import { bindSessionAccount } from './accountScope'; import type { ActionData } from './discoveryTypes'; import { currentDirectory } from './workingDirectory'; +import { createProjectToolCatalog, projectToolResult, type ProjectToolCatalogView, type ProjectToolScope } from './projectTools'; -export function createDevTools(options: ConstructorParameters[1] & { host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; mcpConnectionsPath?: string; mcpCache?:McpConnectionCache }) { - const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, mcpCache=createMcpConnectionCache(), ...sessionOptions } = options; +const UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i; +export function createDevTools(options: ConstructorParameters[1] & { + host: HostServices; session?: SessionService & { sanitize?(text: string): string }; allowMcpSkills?: boolean; allowLegacyProjectCredentials?: boolean; + mcpConnectionsPath?: string; mcpCache?:McpConnectionCache; + /** The HTTP budget of one project MCP request (default 120 s). Backend requests keep `requestTimeoutMs` (default 30 s). */ + projectRequestTimeoutMs?: number; + /** Where `mcp-catalog` keeps the shared project-tool catalog; default beside `mcp-connections.json`. */ + projectToolCatalogPath?: string; +}) { + const { host, session: suppliedSession, allowMcpSkills, allowLegacyProjectCredentials, mcpConnectionsPath, mcpCache=createMcpConnectionCache(), projectRequestTimeoutMs, projectToolCatalogPath, ...sessionOptions } = options; const session = suppliedSession ?? new MachineSessionService(host, sessionOptions); const env = { ...(options.env ?? process.env) }; function dispatch(request: R): Promise>>; @@ -49,7 +58,13 @@ export function createDevTools(options: ConstructorParameters, backend: string): Promise> { + if (request.refresh !== undefined && typeof request.refresh !== 'boolean' && request.refresh !== 'force') throw new ToolingError('INVALID_REQUEST', 'refresh must be a boolean or "force".'); + if (request.environmentUid !== undefined && (typeof request.environmentUid !== 'string' || !UUID.test(request.environmentUid))) throw new ToolingError('INVALID_REQUEST', 'The environment must be an organization environment UUID.'); + const scope: ProjectToolScope = { backend, ...(request.environmentUid ? { environmentUid: request.environmentUid.toLowerCase() } : {}) }; + const catalog = createProjectToolCatalog({ file: projectToolCatalogPath, env, dispatch: () => dispatch }); + const refreshed = request.refresh ? await catalog.refresh(request.refresh === 'force', { scope, waitMs: 120_000 }) : false; + const data: ProjectToolCatalogView = { backend, ...(scope.environmentUid ? { environmentUid: scope.environmentUid } : {}), file: catalog.file, refreshed, ...catalog.view(scope) }; + if (request.refresh && data.error) return { schemaVersion: 1, action: 'mcp-catalog', status: 'failure', summary: data.error.message, error: data.error, data }; + const unavailable = data.unavailable.length ? ` ${data.unavailable.join(', ')} could not be read now${data.retained ? '; earlier tools stay registered' : ''}.` : ''; + return { schemaVersion: 1, action: 'mcp-catalog', status: 'success', data, + summary: `${data.count} project MCP tools from ${data.connections.length} saved connections${refreshed ? ', catalogs re-read' : ', from the shared catalog'}.${unavailable}` }; + } function safeResult(result: ActionResult): ActionResult { // The dispatcher is the one public output boundary for every host. const sanitize = (value: unknown): unknown => { @@ -114,7 +144,7 @@ export function validateRequest(request: ActionRequest): void { if (request.backend !== undefined && typeof request.backend !== 'string') throw new ToolingError('INVALID_REQUEST', 'Backend must be a URL string.'); return; } - if (request && ['mcp-environments', 'mcp-discover', 'mcp-add', 'mcp-list', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(request.action)) { + if (request && ['mcp-environments', 'mcp-discover', 'mcp-add', 'mcp-list', 'mcp-remove', 'mcp-tools', 'mcp-call', 'mcp-catalog', 'mcp-tool-call'].includes(request.action)) { if (request.backend !== undefined && typeof request.backend !== 'string') throw new ToolingError('INVALID_REQUEST', 'Backend must be a URL string.'); return; } diff --git a/src/shared/arguments.ts b/src/shared/arguments.ts index b0819d7..47602f2 100644 --- a/src/shared/arguments.ts +++ b/src/shared/arguments.ts @@ -11,6 +11,7 @@ ms-devtools mcp-discover [--search TEXT] [--limit N] [--offset N] [--backend URL ms-devtools mcp-list [--backend URL] ms-devtools mcp-add|mcp-remove|mcp-tools --uid RELEASE_UUID [--backend URL] ms-devtools mcp-call --uid RELEASE_UUID --tool NAME [--arguments-file JSON_FILE] [--backend URL] +ms-devtools mcp-catalog [--refresh] [--backend URL] ms-devtools status|resolve [--path ROOT] [--type python|vite_react] [--backend URL] ms-devtools sync [--path ROOT] ms-devtools sdk-status|update-sdk|update-agents|install-skills|install|env-cleanup [--path ROOT] @@ -25,12 +26,15 @@ skills-sync requires MainSequence MCP and reports unsupported. open-folder and signed-terminal require a host with those native capabilities. In Codex desktop, type /ms, select the ms slash-menu entry, then append COMMAND ... . The trusted plugin hook executes the selected command before inference. +mcp-catalog lists the saved connections' tools as hosts register them (ms__) from the +shared catalog; --refresh re-reads every connection's catalog first. A cold project MCP can take +over a minute to answer. `; export function parseArguments(argv: string[], cwd?: string): { request?: ActionRequest; json: boolean; help?: boolean } { if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') return { json: argv.includes('--json'), help: true }; const action = argv[0]; const flags: Record = {}; - const boolean = new Set(['--json', '--check', '--dry-run', '--install']); + const boolean = new Set(['--json', '--check', '--dry-run', '--install', '--refresh']); const valued = new Set(['--backend', '--path', '--type', '--uid', '--branch', '--base-dir', '--search', '--limit', '--offset', '--tool', '--arguments-file']); for (let i = 1; i < argv.length; i++) { const key = argv[i]; @@ -52,6 +56,7 @@ export function parseArguments(argv: string[], cwd?: string): { request?: Action : action === 'mcp-list' ? global : ['mcp-add', 'mcp-remove', 'mcp-tools'].includes(action) ? [...global, '--uid'] : action === 'mcp-call' ? [...global, '--uid', '--tool', '--arguments-file'] + : action === 'mcp-catalog' ? [...global, '--refresh'] : ['login', 'logout', 'refresh-token', 'repositories'].includes(action) ? global : action === 'auth-status' ? [...global, '--check'] : action === 'setup' ? [...global, '--uid', '--branch', '--base-dir', '--install'] @@ -60,7 +65,7 @@ export function parseArguments(argv: string[], cwd?: string): { request?: Action const backend = typeof flags['--backend'] === 'string' ? flags['--backend'] : undefined; const common = { backend }; let request: ActionRequest; - if (['mcp-discover', 'mcp-list', 'mcp-add', 'mcp-remove', 'mcp-tools', 'mcp-call'].includes(action)) { + if (['mcp-discover', 'mcp-list', 'mcp-add', 'mcp-remove', 'mcp-tools', 'mcp-call', 'mcp-catalog'].includes(action)) { let args: Record = {}; if (typeof flags['--arguments-file'] === 'string') { try { @@ -73,7 +78,8 @@ export function parseArguments(argv: string[], cwd?: string): { request?: Action ...(flags['--search'] !== undefined ? { search: flags['--search'] } : {}), ...(flags['--limit'] !== undefined ? { limit: Number(flags['--limit']) } : {}), ...(flags['--offset'] !== undefined ? { offset: Number(flags['--offset']) } : {}), - ...(action === 'mcp-call' ? { tool: flags['--tool'], arguments: args } : {}) } as McpRequest; + ...(action === 'mcp-call' ? { tool: flags['--tool'], arguments: args } : {}), + ...(action === 'mcp-catalog' && flags['--refresh'] ? { refresh: 'force' } : {}) } as McpRequest; } else if (['login', 'logout', 'refresh-token', 'repositories'].includes(action)) request = { action, ...common } as ActionRequest; else if (action === 'auth-status') request = { action, ...common, check: Boolean(flags['--check']) }; else if (action === 'setup') request = { action, ...common, repositoryUid: flags['--uid'] as string, branch: flags['--branch'] as string, baseDir: flags['--base-dir'] as string, install: Boolean(flags['--install']) }; diff --git a/src/shared/discoveryTypes.ts b/src/shared/discoveryTypes.ts index 5dd9131..98a1a9a 100644 --- a/src/shared/discoveryTypes.ts +++ b/src/shared/discoveryTypes.ts @@ -2,6 +2,7 @@ import type { ActionRequest, SessionStatus } from './models'; import type { PlatformAgent } from './agents'; import type { EnvironmentSummary } from './environments'; import type { OrganizationEnvironment, OrganizationProjectPage } from './organizationProjects'; +import type { ProjectToolCatalogView, ProjectToolResult } from './projectTools'; export type McpConnection = { backend: string; resource_release_uid: string; name?: string; display_name?: string; code_repository_branch_uid?: string; @@ -28,6 +29,8 @@ export type DiscoveryResultMap = { 'mcp-discover': McpDiscoveryPage; 'mcp-list': McpConnection[]; 'mcp-tools': McpCatalog; + 'mcp-catalog': ProjectToolCatalogView; + 'mcp-tool-call': ProjectToolResult; }; export type ActionData = R extends { action: infer A } ? A extends keyof DiscoveryResultMap ? DiscoveryResultMap[A] : unknown : unknown; diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index ac6b094..6d01710 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -1,10 +1,11 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import * as os from 'node:os'; import { randomUUID } from 'node:crypto'; import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport, sendProjectMcp } from './mcpTransport'; +import { mainSequenceStateDirectory } from './stateDirectory'; +import { DEFAULT_PROJECT_REQUEST_TIMEOUT_MS, projectToolTimeoutMs } from './projectTools'; import { codeRepositoryBranchFromResponse } from './repositoryModels'; import { listEnvironments } from './environments'; import type { McpConnection, McpDiscoveryPage } from './discoveryTypes'; @@ -29,6 +30,11 @@ export type McpRequest = { backend?: string; environmentUid?: string; expectedAc | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number; enrichNames?: boolean } | { action: 'mcp-list'; readOnly?: boolean } | { action: 'mcp-environments' } + /** The shared project-tool registry (ADR 0004): definitions and bindings from the on-disk catalog, + * re-reading the saved connections' catalogs when `refresh` is true (if stale) or 'force'. */ + | { action: 'mcp-catalog'; refresh?: boolean | 'force' } + /** `mcp-call`, answered in the host-neutral tool result shape (`projectToolResult`). */ + | { action: 'mcp-tool-call'; releaseUid: string; tool: string; arguments: Record } | { action: 'mcp-add' | 'mcp-remove' | 'mcp-tools'; releaseUid: string } | { action: 'mcp-call'; releaseUid: string; tool: string; arguments: Record } ); @@ -78,9 +84,7 @@ function connection(backend: string, row: any): Connection { ...(typeof c.organization_environment_name === 'string' && c.organization_environment_name.trim() ? { organization_environment_name: c.organization_environment_name.trim() } : {}), active_revision_uid: uuid(c.active_revision_uid) } }; } function storagePath(env: NodeJS.ProcessEnv): string { - const dir = process.platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support', 'MainSequenceCLI') - : process.platform === 'win32' ? path.join(env.APPDATA || os.homedir(), 'MainSequenceCLI') : path.join(os.homedir(), '.config', 'mainsequence'); - return path.join(dir, 'mcp-connections.json'); + return path.join(mainSequenceStateDirectory(env), 'mcp-connections.json'); } /** Only endpoint metadata is persisted. A bounded runtime may reuse an in-memory grant. */ @@ -90,7 +94,9 @@ export class McpConnections { private session: SessionService & { sanitize?(text:string):string }; private checkAccount?: (fresh?:boolean)=>Promise; constructor(private readonly originalSession: SessionService & { sanitize?(text: string): string }, private readonly options: { - connectionsPath?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; cache?:McpConnectionCache; + connectionsPath?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; + /** The HTTP budget of one project MCP request (default 120 s); backend requests keep `requestTimeoutMs`. */ + projectRequestTimeoutMs?: number; cache?:McpConnectionCache; } = {}) { this.file = options.connectionsPath ?? storagePath(options.env ?? process.env); this.session=originalSession; } private sanitize(value: any): any { @@ -144,11 +150,12 @@ export class McpConnections { if (value.resource_release_uid !== releaseUid) throw new ToolingError('API_ERROR', 'The backend returned a different release.'); return value; } + private get projectTimeoutMs(): number { return this.options.projectRequestTimeoutMs ?? DEFAULT_PROJECT_REQUEST_TIMEOUT_MS; } private client(value:Connection, token:string, initialized=false):McpTransport { this.secrets.add(token); - return new McpTransport(async message => { + return new McpTransport(async (message, request) => { await this.checkAccount?.(); - const result = await sendProjectMcp(value.mcp_connection.url, token, message, this.options); + const result = await sendProjectMcp(value.mcp_connection.url, token, message, { fetch: this.options.fetch, signal: this.options.signal, timeoutMs: request?.timeoutMs ?? this.projectTimeoutMs }); await this.checkAccount?.(); return result; },initialized); @@ -247,6 +254,7 @@ export class McpConnections { try { const backend = requireBackend(inputBackend); this.options.signal?.throwIfAborted(); + if (request.action === 'mcp-catalog' || request.action === 'mcp-tool-call') throw new ToolingError('INVALID_REQUEST', 'mcp-catalog and mcp-tool-call are dispatched by createDevTools(), not by the connection client.'); let cached:CachedConnection|undefined, selected:{key:string;expiresAt?:number}|undefined; if ('releaseUid' in request) { key=cacheKey(backend,uuid(request.releaseUid)); @@ -362,7 +370,9 @@ export class McpConnections { if (!request.arguments || typeof request.arguments !== 'object' || Array.isArray(request.arguments)) throw new ToolingError('INVALID_REQUEST', 'Tool arguments must be a JSON object.'); this.options.signal?.throwIfAborted(); callStarted = true; - const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments }); + // A tool that advertises a longer run (`_meta["mainsequence.ai/timeout-seconds/v1"]`) gets it, capped at 300 s. + const output = await client.request('tools/call', { name: request.tool, arguments: request.arguments }, + { timeoutMs: projectToolTimeoutMs(tools.find(tool => tool.name === request.tool), this.projectTimeoutMs) }); if (!output || !Array.isArray(output.content)) throw new ToolingError('API_ERROR', 'The MCP tool returned an invalid result.'); resultData = output; if (output.isError) { diff --git a/src/shared/mcpTransport.ts b/src/shared/mcpTransport.ts index e32c958..f43fe62 100644 --- a/src/shared/mcpTransport.ts +++ b/src/shared/mcpTransport.ts @@ -1,10 +1,32 @@ import { ToolingError } from './models'; import type { McpTool } from './discoveryTypes'; +import { DEFAULT_PROJECT_REQUEST_TIMEOUT_MS } from './projectTools'; + +/** One request's own options; the transport's defaults apply otherwise. */ +export type McpRequestOptions = { timeoutMs?: number }; +export type McpSend = (message: Record, options?: McpRequestOptions) => Promise; + +const safeText = (value: unknown, max: number) => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').trim().slice(0, max); +/** `initialize`, `tools/list`, `tools/call crm_contacts_list`: what a message asked the server for. */ +export function describeMcpMessage(message: Record): string { + const method = typeof message.method === 'string' ? message.method : 'request'; + const name = method === 'tools/call' && message.params && typeof message.params === 'object' ? (message.params as Record).name : undefined; + return typeof name === 'string' && name ? `${method} ${safeText(name, 120)}` : method; +} +/** "0.3", "1", "120": a budget in seconds, as a person reads it. */ +export const describeSeconds = (ms: number): string => (ms / 1000).toFixed(ms % 1000 ? 1 : 0); +/** Why a request failed before any response arrived: the system error code (ECONNREFUSED, ENOTFOUND, + * ECONNRESET, a certificate code) when there is one. Free-text error messages never reach a host: + * they can carry addresses and credentials. */ +export function failureReason(error: unknown): string { + const code = (error as { cause?: { code?: unknown } } | undefined)?.cause?.code ?? (error as { code?: unknown } | undefined)?.code; + return typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,40}$/.test(code) ? code : ''; +} /** The platform's stateless Streamable HTTP profile; no legacy SSE endpoint. */ export class McpTransport { private sequence = 0; - constructor(private readonly send: (message: Record) => Promise, private initialized = false) {} + constructor(private readonly send: McpSend, private initialized = false) {} async initialize(): Promise { const result = await this.request('initialize', { @@ -18,16 +40,21 @@ export class McpTransport { this.initialized = true; } - async request(method: string, params: Record): Promise { + async request(method: string, params: Record, options?: McpRequestOptions): Promise { if (method !== 'initialize' && !this.initialized) throw new ToolingError('API_ERROR', 'The MCP connection has not initialized.'); const id = ++this.sequence; - const response = await this.send({ jsonrpc: '2.0', id, method, params }) as any; + const message = { jsonrpc: '2.0', id, method, params }; + const response = await this.send(message, options) as any; if (!response || response.jsonrpc !== '2.0' || response.id !== id) { - throw new ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response.'); + throw new ToolingError('API_ERROR', 'The MCP endpoint returned an invalid or mismatched response to ' + describeMcpMessage(message) + '.'); + } + if (response.error) { + // The server's own words, bounded; the dispatcher redacts credentials before any host sees them. + const detail = safeText(response.error.message, 300); + throw new ToolingError('API_ERROR', 'The MCP endpoint rejected ' + describeMcpMessage(message) + + (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + (detail ? ': ' + detail : '.')); } - if (response.error) throw new ToolingError('API_ERROR', 'The MCP endpoint rejected ' + method - + (Number.isInteger(response.error.code) ? ' (JSON-RPC ' + response.error.code + ')' : '') + '.'); - if (!Object.hasOwn(response, 'result')) throw new ToolingError('API_ERROR', 'The MCP response has no result.'); + if (!Object.hasOwn(response, 'result')) throw new ToolingError('API_ERROR', 'The MCP response to ' + describeMcpMessage(message) + ' has no result.'); return response.result; } @@ -63,8 +90,10 @@ export class McpTransport { } } -/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation. */ -export async function readMcpResponse(response: Response, message: Record): Promise { +/** Read bounded JSON or POST SSE; never reconnect or replay a tool invocation. `target` names the + * endpoint in errors ("project MCP https://crm.example"), so a person or a model sees which server + * answered which request with which status. */ +export async function readMcpResponse(response: Response, message: Record, target = 'MCP endpoint'): Promise { if (response.headers.has('mcp-session-id')) { await response.body?.cancel(); throw new ToolingError('UNSUPPORTED_ACTION', 'Stateful MCP sessions are outside the platform profile.'); @@ -72,23 +101,23 @@ export async function readMcpResponse(response: Response, message: Record { - try { return JSON.parse(raw); } catch { throw new ToolingError('API_ERROR', 'The MCP endpoint returned invalid JSON.'); } + try { return JSON.parse(raw); } catch { throw new ToolingError('API_ERROR', 'The ' + target + ' returned invalid JSON for ' + describeMcpMessage(message) + '.'); } }; try { while (true) { @@ -114,20 +143,27 @@ export async function readMcpResponse(response: Response, message: Record {}); reader.releaseLock(); } } +/** One request to a project's MCP endpoint with the release credential. The budget is the project + * request timeout (default 120 s: a scaled-to-zero release has taken over a minute to answer + * `initialize`), or the caller's own for a tool that advertises a longer run. A timeout and a + * connection failure keep the same code, `BACKEND_UNREACHABLE`, and say which they were. */ export async function sendProjectMcp(url: string, token: string, message: Record, options: { - fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; + fetch?: typeof fetch; signal?: AbortSignal; timeoutMs?: number; }): Promise { options.signal?.throwIfAborted(); const controller = new AbortController(), abort = () => controller.abort(); options.signal?.addEventListener('abort', abort, { once: true }); - const timer = setTimeout(abort, options.requestTimeoutMs ?? 60_000); + const budget = options.timeoutMs ?? DEFAULT_PROJECT_REQUEST_TIMEOUT_MS; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; controller.abort(); }, budget); + const origin = (() => { try { return new URL(url).origin; } catch { return url; } })(); try { return await readMcpResponse(await (options.fetch ?? fetch)(url, { method: 'POST', redirect: 'error', signal: controller.signal, @@ -135,11 +171,13 @@ export async function sendProjectMcp(url: string, token: string, message: Record headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', Authorization: 'Bearer ' + token, 'MCP-Protocol-Version': '2025-11-25' }, body: JSON.stringify(message), - }), message); + }), message, 'project MCP ' + origin); } catch (error) { if (options.signal?.aborted) throw new ToolingError('CANCELLED', 'The MCP request was cancelled. Cancellation does not undo a remote operation.'); if (error instanceof ToolingError) throw error; - throw new ToolingError('BACKEND_UNREACHABLE', 'The MCP request failed or timed out. It was not retried.'); + if (timedOut) throw new ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' timed out after ' + describeSeconds(budget) + ' s. It was not retried.'); + const reason = failureReason(error); + throw new ToolingError('BACKEND_UNREACHABLE', 'The project MCP ' + describeMcpMessage(message) + ' request to ' + origin + ' failed before a response arrived' + (reason ? ' (' + reason + ')' : '') + '. It was not retried.'); } finally { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); diff --git a/src/shared/projectTools.ts b/src/shared/projectTools.ts new file mode 100644 index 0000000..b9776ee --- /dev/null +++ b/src/shared/projectTools.ts @@ -0,0 +1,355 @@ +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { ActionRequest, ActionResult, ErrorCode, SessionStatus, ToolingError } from './models'; +import type { McpCatalog, McpConnection, McpTool } from './discoveryTypes'; +import { mainSequenceStateDirectory } from './stateDirectory'; + +/** + * Saved project MCP tools as first-class tools of a host (ADR 0004). One tool name, one + * definition, one result shape, one refresh loop and one on-disk catalog serve every host; + * a host keeps only how it registers tools, asks for approval and renders results. + * The catalog stores schemas and labels, never credentials, tool results or tokens. + */ +type Row = Record; +export type ProjectToolDispatch = (request: ActionRequest) => Promise>; +/** Which saved connections a listing or a refresh covers. Without a scope, every saved connection. */ +export type ProjectToolScope = { backend?: string; environmentUid?: string }; +export type ProjectToolBinding = { backend: string; environmentUid: string; environmentName: string; releaseUid: string; connection: string; tool: string }; +export type ProjectToolConnection = { backend: string; releaseUid: string; environmentUid: string; environmentName: string; name: string; tools: McpTool[] }; +export type ProjectToolCatalog = { version: 1; updatedAt: string; connections: ProjectToolConnection[] }; +export type ProjectToolDefinition = { name: string; title: string; description: string; inputSchema: Row; + annotations: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean } }; +export type ProjectToolResult = { content: Row[]; structuredContent?: unknown; isError?: true }; +export type ProjectToolCatalogState = { + count: number; + connections: { backend: string; releaseUid: string; environmentUid: string; environmentName: string; name: string; tools: number }[]; + updatedAt: string | null; refreshing: boolean; unavailable: string[]; retained: number; + error: { code: ErrorCode; message: string } | null; +}; +/** The `mcp-catalog` action's payload: the state plus what a host registers and how it forwards a call. */ +export type ProjectToolCatalogView = ProjectToolCatalogState & { + backend: string; environmentUid?: string; file: string; refreshed: boolean; + definitions: ProjectToolDefinition[]; bindings: Record; +}; +export type ProjectToolCatalogOptions = { + /** A dispatcher for one refresh; the signal aborts when the catalog is disposed mid-refresh. */ + dispatch: (signal: AbortSignal) => ProjectToolDispatch; + file?: string; env?: NodeJS.ProcessEnv; staleMs?: number; disabled?: boolean; + /** Called when the registered definitions changed, for a host's change announcement. */ + notify?: () => void; +}; + +export const PROJECT_TOOL_PREFIX = 'ms_'; +export const MAX_PROJECT_TOOLS_PER_CONNECTION = 80; +/** A project may advertise how long one of its tools can take; the client honours it up to the cap. */ +export const PROJECT_TOOL_TIMEOUT_HINT = 'mainsequence.ai/timeout-seconds/v1'; +export const DEFAULT_PROJECT_REQUEST_TIMEOUT_MS = 120_000; +export const MAX_PROJECT_TOOL_TIMEOUT_MS = 300_000; +const CATALOG_FILE = 'project-tool-catalog.json'; +const LOCK_STALE_MS = 10 * 60_000; +const UUID = /^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/i; +const HINTS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const; +const object = (value: unknown): value is Row => value !== null && typeof value === 'object' && !Array.isArray(value); +const text = (value: unknown, max = 300): string => typeof value === 'string' ? value.trim().slice(0, max) : ''; +const clean = (value: string, max: number) => value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '').slice(0, max); + +/** `ms_` + the remote name (unsafe characters replaced, cut to 32) + `_` + FNV-1a 32-bit of + * `backend:environment:release:tool`. The hash binds the saved connection and environment; the + * prefix keeps the remote name readable. The cut is 32 because Codex qualifies the name as + * `mcp__mainsequence__` under a 64-character limit; for remote names up to 32 characters + * the result is the name Claude Desktop has registered since 0.4.13. */ +export function projectToolName(backend: string, environmentUid: string, releaseUid: string, tool: string): string { + const value = `${backend}:${environmentUid}:${releaseUid}:${tool}`; + let hash = 2166136261; + for (let i = 0; i < value.length; i++) hash = Math.imul(hash ^ value.charCodeAt(i), 16777619) >>> 0; + return `${PROJECT_TOOL_PREFIX}${String(tool).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 32)}_${hash.toString(16)}`; +} +export const isProjectToolName = (name: unknown): name is string => typeof name === 'string' && name.startsWith(PROJECT_TOOL_PREFIX); + +function hints(value: unknown): ProjectToolDefinition['annotations'] extends infer A ? Partial : never { + const result: Partial = {}; + for (const flag of HINTS) if (object(value) && typeof value[flag] === 'boolean') result[flag] = value[flag]; + return result; +} +/** Only what a host needs to register and call a tool: name, description, object schema and the four hints. */ +function normalizeTool(value: unknown): McpTool | undefined { + if (!object(value) || typeof value.name !== 'string' || !value.name || !object(value.inputSchema)) return; + return { name: value.name, ...(typeof value.description === 'string' ? { description: value.description } : {}), inputSchema: value.inputSchema, annotations: hints(value.annotations) }; +} +const normalizeTools = (tools: unknown): McpTool[] => (Array.isArray(tools) ? tools : []).map(normalizeTool).filter((tool): tool is McpTool => Boolean(tool)).slice(0, MAX_PROJECT_TOOLS_PER_CONNECTION); + +/** The release's own name, else its repository's name, else the release UID. */ +export const projectToolConnectionLabel = (connection: Pick): string => + text(connection.name) || text(connection.display_name) || connection.resource_release_uid; +/** The catalog record of a saved connection and its current tools. */ +export function projectToolConnection(connection: McpConnection, tools: McpTool[] = []): ProjectToolConnection { + return { backend: connection.backend, releaseUid: connection.resource_release_uid.toLowerCase(), environmentUid: connection.mcp_connection.organization_environment_uid.toLowerCase(), + environmentName: text(connection.mcp_connection.organization_environment_name), name: projectToolConnectionLabel(connection), tools: normalizeTools(tools) }; +} +type Source = ProjectToolConnection | McpConnection; +const source = (connection: Source): ProjectToolConnection => 'mcp_connection' in connection ? projectToolConnection(connection) : connection; + +/** The host-neutral definition of one remote tool: `MainSequence []: `, + * an object schema (`type: 'object'` added when the server omitted it) and the four boolean hints, + * open-world by default. */ +export function projectToolDefinition(connection: Source, tool: McpTool): ProjectToolDefinition { + const c = source(connection), label = c.name || c.releaseUid; + const schema = object(tool.inputSchema) ? tool.inputSchema : {}; + return { name: projectToolName(c.backend, c.environmentUid, c.releaseUid, tool.name), title: `${label}: ${tool.name}`, + description: clean(`MainSequence ${label} [${c.environmentName || c.environmentUid}]: ${tool.description || tool.name}`, 2000), + inputSchema: schema.type === 'object' ? schema : { type: 'object', ...schema }, annotations: { openWorldHint: true, ...hints(tool.annotations) } }; +} +/** Up to 80 definitions per connection, one per distinct name. */ +export function projectToolDefinitions(connection: Source, tools?: McpTool[]): ProjectToolDefinition[] { + const seen = new Set(), definitions: ProjectToolDefinition[] = []; + for (const tool of normalizeTools(tools ?? ('tools' in connection ? connection.tools : []))) { + const definition = projectToolDefinition(connection, tool); + if (seen.has(definition.name)) continue; + seen.add(definition.name); definitions.push(definition); + } + return definitions; +} +/** What a host sends back to `mcp-call` for a registered tool. */ +export function projectToolBinding(connection: Source, tool: McpTool | string): ProjectToolBinding { + const c = source(connection); + return { backend: c.backend, environmentUid: c.environmentUid, environmentName: c.environmentName, releaseUid: c.releaseUid, connection: c.name || c.releaseUid, tool: typeof tool === 'string' ? tool : tool.name }; +} +const inScope = (connection: { backend: string; environmentUid: string }, scope?: ProjectToolScope) => + (!scope?.backend || connection.backend === scope.backend) && (!scope?.environmentUid || connection.environmentUid === scope.environmentUid.toLowerCase()); +/** Definitions and bindings for every tool of the catalog's connections in scope. */ +export function projectToolRegistration(catalog: ProjectToolCatalog | null, scope?: ProjectToolScope): { definitions: ProjectToolDefinition[]; bindings: Map } { + const definitions: ProjectToolDefinition[] = [], bindings = new Map(); + for (const connection of catalog?.connections ?? []) { + if (!inScope(connection, scope)) continue; + for (const tool of connection.tools.slice(0, MAX_PROJECT_TOOLS_PER_CONNECTION)) { + const definition = projectToolDefinition(connection, tool); + if (bindings.has(definition.name)) continue; + bindings.set(definition.name, projectToolBinding(connection, tool)); definitions.push(definition); + } + } + return { definitions, bindings }; +} + +/** The host-neutral result of a forwarded `mcp-call`: the remote content blocks, then one JSON text + * block with `summary`, `effects`, `error` and `structuredContent` when the call failed, returned + * structured content or no blocks at all; `isError` on any non-success, including `MCP_TOOL_ERROR`, + * whose original remote content is preserved. A failure without structured content carries that + * JSON as `structuredContent`, so a host that reads only structured output still sees the error. */ +export function projectToolResult(result: ActionResult): ProjectToolResult { + const data = object(result.data) ? result.data : undefined; + const blocks = Array.isArray(data?.content) ? data.content.filter((block): block is Row => object(block) && typeof block.type === 'string') : []; + const failed = result.status !== 'success', structured = data?.structuredContent; + const extra = { summary: result.summary, ...(result.effects ? { effects: result.effects } : {}), ...(result.error ? { error: result.error } : {}), ...(structured !== undefined ? { structuredContent: structured } : {}) }; + const content = [...blocks, ...(!blocks.length || failed || structured !== undefined ? [{ type: 'text', text: JSON.stringify(extra) }] : [])]; + return { ...(failed ? { isError: true as const } : {}), content, ...(structured !== undefined ? { structuredContent: structured } : failed ? { structuredContent: extra } : {}) }; +} + +/** The HTTP budget for one `tools/call`: the default, or the tool's advertised seconds when larger, capped. */ +export function projectToolTimeoutMs(tool: McpTool | undefined, defaultMs = DEFAULT_PROJECT_REQUEST_TIMEOUT_MS): number { + const hint = object(tool?._meta) ? tool!._meta![PROJECT_TOOL_TIMEOUT_HINT] : undefined; + if (typeof hint !== 'number' || !Number.isFinite(hint) || hint <= 0) return defaultMs; + return Math.max(defaultMs, Math.min(Math.round(hint * 1000), MAX_PROJECT_TOOL_TIMEOUT_MS)); +} + +function success(result: ActionResult): T { + if (result.status !== 'success') throw new ToolingError(result.error?.code ?? 'API_ERROR', result.summary); + if (result.data === undefined) throw new ToolingError('API_ERROR', 'MainSequence returned no data.'); + return result.data; +} +/** Every saved connection in scope and its current tools: `mcp-list` (read-only), then one `mcp-tools` + * per connection, two at a time (the shared transport's per-origin limit). A connection whose catalog + * cannot be read now keeps its `previous` tools and is named in `unavailable`; a removed connection + * loses them; connections outside the scope are carried over from `previous` unchanged. No account + * snapshot is attached: a registered tool is bound to a saved connection, and the session for that + * connection's backend is verified when the tool is called. */ +export async function readProjectToolCatalog(dispatch: ProjectToolDispatch, options: { + backend?: string; environmentUid?: string; previous?: ProjectToolCatalog | null; signal?: AbortSignal; + /** The session status already read for this refresh; otherwise `auth-status` is dispatched first. */ + session?: SessionStatus; +} = {}): Promise<{ catalog: ProjectToolCatalog; backend: string; unavailable: string[]; retained: number }> { + const status = options.session ?? success(await dispatch({ action: 'auth-status', ...(options.backend ? { backend: options.backend } : {}) })) as SessionStatus; + if (!status.authenticated || !status.backend) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to register saved project MCP tools.'); + const backend = status.backend, environmentUid = options.environmentUid?.toLowerCase(); + if (environmentUid !== undefined && !UUID.test(environmentUid)) throw new ToolingError('INVALID_REQUEST', 'The environment must be an organization environment UUID.'); + const scope = { backend, environmentUid }, environment = environmentUid ? { environmentUid } : {}; + const rows = success(await dispatch({ action: 'mcp-list', backend, readOnly: true, ...environment })) as McpConnection[]; + const previous = options.previous?.connections ?? []; + const connections = previous.filter(connection => !inScope(connection, scope)), unavailable: string[] = []; + let retained = 0; + const pending = [...rows]; + await Promise.all(Array.from({ length: Math.min(2, pending.length) }, async () => { + while (pending.length) { + options.signal?.throwIfAborted(); + const row = pending.shift()!; + try { + const output = success(await dispatch({ action: 'mcp-tools', backend, releaseUid: row.resource_release_uid, ...environment })) as McpCatalog; + const current = output.connection; + connections.push(projectToolConnection({ ...row, name: row.name ?? current?.name, display_name: row.display_name ?? current?.display_name, + mcp_connection: { ...row.mcp_connection, organization_environment_name: row.mcp_connection.organization_environment_name ?? current?.mcp_connection?.organization_environment_name } }, output.tools)); + } catch (error) { + if (error instanceof ToolingError && error.code === 'CANCELLED') throw error; + options.signal?.throwIfAborted(); + const earlier = previous.find(connection => connection.backend === backend && connection.releaseUid === row.resource_release_uid.toLowerCase()); + if (earlier) { connections.push(earlier); retained += earlier.tools.length; } + unavailable.push(projectToolConnectionLabel(row)); + } + } + })); + connections.sort((a, b) => a.backend.localeCompare(b.backend) || a.environmentUid.localeCompare(b.environmentUid) || a.releaseUid.localeCompare(b.releaseUid)); + return { catalog: { version: 1, updatedAt: new Date().toISOString(), connections }, backend, unavailable, retained }; +} + +function connectionRecord(value: unknown): ProjectToolConnection | undefined { + if (!object(value) || typeof value.backend !== 'string' || !UUID.test(String(value.releaseUid)) || !UUID.test(String(value.environmentUid)) || !Array.isArray(value.tools)) return; + return { backend: value.backend, releaseUid: String(value.releaseUid).toLowerCase(), environmentUid: String(value.environmentUid).toLowerCase(), + environmentName: text(value.environmentName), name: text(value.name), tools: normalizeTools(value.tools) }; +} +/** Beside `mcp-connections.json`, shared by every host on the machine. */ +export const projectToolCatalogPath = (env: NodeJS.ProcessEnv = process.env): string => path.join(mainSequenceStateDirectory(env), CATALOG_FILE); +/** A missing or damaged cache is an empty catalog, never an error. */ +export function readProjectToolCatalogFile(file: string): ProjectToolCatalog | null { + try { + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!object(raw) || raw.version !== 1 || !Array.isArray(raw.connections)) return null; + return { version: 1, updatedAt: text(raw.updatedAt, 40), connections: raw.connections.map(connectionRecord).filter((item): item is ProjectToolConnection => Boolean(item)) }; + } catch { return null; } +} +const sleep = (ms: number, signal?: AbortSignal) => new Promise(resolve => { + const timer = setTimeout(done, ms); + function done() { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); } + signal?.addEventListener('abort', done, { once: true }); +}); + +/** The file-backed catalog: read at once from the shared cache so a host can register tools before + * any network request, refreshed under a lock so one process reads the catalogs at a time while the + * others pick its result up from the file, and reloaded when another process wrote it. */ +export function createProjectToolCatalog(options: ProjectToolCatalogOptions) { + const file = options.file ?? projectToolCatalogPath(options.env), staleMs = options.staleMs ?? 15 * 60_000; + let catalog = readProjectToolCatalogFile(file), loadedAt = mtime(), running: Promise | null = null; + let current: { controller: AbortController; locked: boolean } | null = null; + // The scope of the last refresh: the current account's backend (learned from auth-status), so a + // listing shows this host's connections even when another backend's are cached beside them. + let known: ProjectToolScope | undefined; + const state = { unavailable: [] as string[], retained: 0, error: null as ProjectToolCatalogState['error'] }; + function mtime(): number { try { return fs.statSync(file).mtimeMs; } catch { return 0; } } + /** Another process may have refreshed the shared cache since this one read it. */ + function reload(): void { + const now = mtime(); + if (now === loadedAt) return; + catalog = readProjectToolCatalogFile(file); loadedAt = now; + } + const registration = (scope?: ProjectToolScope) => projectToolRegistration(catalog, scope ?? known); + async function write(next: ProjectToolCatalog): Promise { + await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); + const temporary = `${file}.${randomUUID()}.tmp`; + try { await fsp.writeFile(temporary, JSON.stringify(next, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); await fsp.rename(temporary, file); } + finally { await fsp.rm(temporary, { force: true }); } + } + function adopt(next: ProjectToolCatalog | null, scope?: ProjectToolScope): void { + const before = JSON.stringify(registration().definitions); + if (next) { catalog = next; loadedAt = mtime(); } + if (scope) known = scope; + if (JSON.stringify(registration().definitions) !== before) options.notify?.(); + } + /** One process refreshes at a time. A live lock means another process is refreshing: without a + * wait the caller reuses its result from the file; with one it waits for the lock up to `waitMs`. + * An interrupted refresh from a closed host must not block every later one (stale after 10 min). */ + async function lock(waitMs: number, signal: AbortSignal): Promise<(() => Promise) | null> { + const name = file + '.lock', deadline = Date.now() + waitMs; + for (let attempt = 0; attempt < 10_000; attempt++) { + signal.throwIfAborted(); + try { + const handle = await fsp.open(name, 'wx', 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); + return async () => { await handle.close(); await fsp.rm(name, { force: true }); }; + } catch (error: any) { + if (error?.code === 'ENOENT') { await fsp.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); continue; } + if (error?.code !== 'EEXIST') return null; + let stale: boolean; + try { stale = Date.now() - (await fsp.stat(name)).mtimeMs >= LOCK_STALE_MS; } catch { continue; } + if (stale) { await fsp.rm(name, { force: true }); continue; } + if (Date.now() >= deadline) return null; + await sleep(250, signal); + } + } + return null; + } + return { + file, + /** Definitions for the host's tool list, from the newest shared cache. */ + list(scope?: ProjectToolScope): ProjectToolDefinition[] { reload(); return registration(scope).definitions; }, + binding(name: string): ProjectToolBinding | undefined { reload(); return registration({}).bindings.get(name); }, + state(scope?: ProjectToolScope): ProjectToolCatalogState { + reload(); + const selected = scope ?? known; + return { count: registration(scope).definitions.length, + connections: (catalog?.connections ?? []).filter(connection => inScope(connection, selected)).map(connection => ({ backend: connection.backend, releaseUid: connection.releaseUid, + environmentUid: connection.environmentUid, environmentName: connection.environmentName, name: connection.name, tools: connection.tools.length })), + updatedAt: catalog?.updatedAt || null, refreshing: running !== null, unavailable: state.unavailable, retained: state.retained, error: state.error }; + }, + /** The state plus definitions and bindings, as the `mcp-catalog` action returns them. */ + view(scope?: ProjectToolScope): ProjectToolCatalogState & { definitions: ProjectToolDefinition[]; bindings: Record } { + const { definitions, bindings } = registration(scope); + return { ...this.state(scope), definitions, bindings: Object.fromEntries(bindings) }; + }, + /** Read every saved connection's current catalog. Without `force`, a fresh cache is kept. Resolves + * true when this process read and wrote the catalog; the state carries why it did not. */ + refresh(force = false, extra: { scope?: ProjectToolScope; waitMs?: number } = {}): Promise { + if (options.disabled) return Promise.resolve(false); + if (running) return running; + reload(); + const controller = new AbortController(); + current = { controller, locked: false }; + running = (async () => { + const dispatch = options.dispatch(controller.signal), scope = extra.scope; + const fresh = () => Boolean(catalog && Date.parse(catalog.updatedAt) > Date.now() - staleMs); + try { + // A fresh cache with a known scope needs nothing; otherwise one local session read tells + // whether there is a session to read catalogs with, and which backend the listing is for. + if (!force && (known || scope) && fresh()) { if (scope) adopt(null, scope); return false; } + const session = success(await dispatch({ action: 'auth-status', ...(scope?.backend ? { backend: scope.backend } : {}) })) as SessionStatus; + if (session.backend) adopt(null, scope ?? { backend: session.backend }); + if (!session.authenticated || !session.backend) throw new ToolingError('NOT_AUTHENTICATED', 'Sign in to MainSequence to register saved project MCP tools.'); + if (!force && fresh()) return false; + const release = await lock(extra.waitMs ?? 0, controller.signal); + if (!release) { + if (extra.waitMs) state.error = { code: 'PROJECT_BUSY', message: 'Another MainSequence process is refreshing the project tool catalog. The registered tools come from its last result; refresh again later.' }; + return false; + } + current!.locked = true; + try { + reload(); + const read = await readProjectToolCatalog(dispatch, { ...scope, backend: session.backend, previous: catalog, signal: controller.signal, session }); + await write(read.catalog); + state.unavailable = read.unavailable; state.retained = read.retained; state.error = null; + adopt(read.catalog, scope ?? { backend: read.backend }); + return true; + } finally { current!.locked = false; await release(); } + } catch (error) { + state.error = { code: error instanceof ToolingError ? error.code : 'INTERNAL_ERROR', message: error instanceof ToolingError ? error.message : 'The saved project MCP tools could not be refreshed.' }; + return false; + } + })().finally(() => { running = null; current = null; }); + return running; + }, + /** A host process is ending: stop its refresh and free the shared lock for the next one at once. */ + dispose(): void { + if (!current) return; + current.controller.abort(); + if (current.locked) { try { fs.rmSync(file + '.lock', { force: true }); } catch { /* the stale-lock rule still applies */ } } + }, + /** A catalog just read explicitly for one saved release replaces that connection's tools without another round trip. */ + async update(releaseUid: string, tools: McpTool[], backend?: string): Promise { + if (options.disabled) return; + reload(); + const index = catalog?.connections.findIndex(item => item.releaseUid === releaseUid.toLowerCase() && (!backend || item.backend === backend)) ?? -1; + if (!catalog || index < 0) { void this.refresh(true); return; } + const connections = catalog.connections.map((item, i) => i === index ? { ...item, tools: normalizeTools(tools) } : item); + const next: ProjectToolCatalog = { version: 1, updatedAt: new Date().toISOString(), connections }; + try { await write(next); adopt(next); } catch { /* the in-memory tools are already current */ } + }, + }; +} +export type ProjectToolCatalogStore = ReturnType; diff --git a/src/shared/repositories.ts b/src/shared/repositories.ts index 9b2a92e..1c86134 100644 --- a/src/shared/repositories.ts +++ b/src/shared/repositories.ts @@ -7,6 +7,7 @@ import { requireBackend } from './session'; import { repositorySshKeyIdentity } from './repositoryIdentity'; import { RepositoryClient } from './repositoryClient'; import { codeRepositoryBranchFromResponse, codeRepositoryFromResponse, gitHubRepositoryBindingFromResponse, CodeRepository, CodeRepositoryGitContextResolution } from './repositoryModels'; +import { mainSequenceStateDirectory } from './stateDirectory'; export async function readProjectEnv(root: string): Promise> { let text: string; @@ -36,8 +37,7 @@ export async function resolveBackend(explicit?: string, root?: string, env: Node /** Resolve non-secret workspace settings without requiring a session or writing configuration. */ export async function readWorkspacePreferences(options: { env?: NodeJS.ProcessEnv; configPath?: string; homeDir?: string } = {}): Promise<{ backend: string; baseDir: string }> { const env = options.env ?? process.env, home = options.homeDir ?? os.homedir(); - const directory = process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'MainSequenceCLI') - : process.platform === 'win32' ? path.join(env.APPDATA || home, 'MainSequenceCLI') : path.join(home, '.config', 'mainsequence'); + const directory = mainSequenceStateDirectory(env, home); const config = await fs.readFile(options.configPath ?? path.join(directory, 'config.json'), 'utf8') .then(text => JSON.parse(text)).catch(() => ({})); const folder = config?.mainsequence_path; diff --git a/src/shared/session.ts b/src/shared/session.ts index 7eaf5a6..bd58799 100644 --- a/src/shared/session.ts +++ b/src/shared/session.ts @@ -1,7 +1,7 @@ // Extracted from ms-vscode-extension/src/api.ts and authSession.ts (0.1.45). // Host-independent machine mode; the VS Code legacy fallback stays with that host. import * as crypto from 'crypto'; -import { readMcpResponse } from './mcpTransport'; +import { describeSeconds, failureReason, readMcpResponse } from './mcpTransport'; import * as http from 'http'; import { normalizeBackendUrl } from './config'; import { clearSession, CredentialStore, ForeignEntryError, MachineSession, openCredentialStore, readSession, saveSession } from './machineSession'; @@ -142,7 +142,9 @@ export class MachineSessionService implements SessionService { const controller = new AbortController(); const abort = () => controller.abort(); this.options.signal?.addEventListener('abort', abort, { once: true }); - const timer = setTimeout(abort, this.options.requestTimeoutMs ?? timeoutMs ?? 30_000); + const budget = this.options.requestTimeoutMs ?? timeoutMs ?? 30_000; + let timedOut = false; + const timer = setTimeout(() => { timedOut = true; controller.abort(); }, budget); try { const response = await (this.options.fetch ?? fetch)(backend + apiPath, { method, redirect: 'error', signal: controller.signal, @@ -156,14 +158,15 @@ export class MachineSessionService implements SessionService { body: body === undefined ? undefined : JSON.stringify(body) }); if (response.status === 401 || response.status === 403) throw new ToolingError('AUTH_REJECTED', 'The backend rejected the selected credentials.'); - if (!response.ok) throw new ToolingError('API_ERROR', `The backend returned HTTP ${response.status}.`); - if (apiPath === '/mcp') return await readMcpResponse(response, body as Record) as T; + if (!response.ok) throw new ToolingError('API_ERROR', `The backend ${backend} returned HTTP ${response.status} for ${method} ${apiPath}.`); + if (apiPath === '/mcp') return await readMcpResponse(response, body as Record, 'platform MCP ' + backend) as T; if (response.status === 204) return undefined as T; try { return await response.json() as T; } catch { throw new ToolingError('API_ERROR', 'The backend returned an invalid JSON response.'); } } catch (error) { if (this.options.signal?.aborted) throw new ToolingError('CANCELLED', 'The action was cancelled.'); if (error instanceof ToolingError) throw error; - throw new ToolingError('BACKEND_UNREACHABLE', 'The backend could not be reached. The saved session has been kept.'); + throw new ToolingError('BACKEND_UNREACHABLE', `The backend ${backend} could not be reached for ${method} ${apiPath}: ` + + (timedOut ? `the request timed out after ${describeSeconds(budget)} s` : failureReason(error) || 'the connection failed') + '. The saved session has been kept.'); } finally { clearTimeout(timer); this.options.signal?.removeEventListener('abort', abort); diff --git a/src/shared/stateDirectory.ts b/src/shared/stateDirectory.ts new file mode 100644 index 0000000..ff23de4 --- /dev/null +++ b/src/shared/stateDirectory.ts @@ -0,0 +1,9 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; + +/** The one place that knows where shared MainSequence CLI state lives: the SDKs' configuration, + * the saved MCP connections and the project tool catalog (ADR 0004). */ +export function mainSequenceStateDirectory(env: NodeJS.ProcessEnv = process.env, home: string = os.homedir()): string { + return process.platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'MainSequenceCLI') + : process.platform === 'win32' ? path.join(env.APPDATA || home, 'MainSequenceCLI') : path.join(home, '.config', 'mainsequence'); +} diff --git a/tests/mcp.test.cjs b/tests/mcp.test.cjs index ca5635d..5aa00b2 100644 --- a/tests/mcp.test.cjs +++ b/tests/mcp.test.cjs @@ -115,7 +115,7 @@ async function fixture(t) { }); const file = path.join(dir, 'connections.json'); const create = extra => createDevTools({ host: createNodeHost(), env: { MAINSEQUENCE_ENDPOINT: backend, MAINSEQUENCE_ACCESS_TOKEN: 'private-platform-token' }, - mcpConnectionsPath: file, requestTimeoutMs: 500, ...extra }); + mcpConnectionsPath: file, requestTimeoutMs: 500, projectRequestTimeoutMs: 500, ...extra }); const dispatch = (action, extra = {}, instance = create()) => instance.dispatch({ action, backend, releaseUid: uid, ...extra }); return { dir, file, uid, secondUid, branchUid, backend, row, project, calls, state, create, dispatch, tool }; } diff --git a/tests/project-tools.test.cjs b/tests/project-tools.test.cjs new file mode 100644 index 0000000..27c9808 --- /dev/null +++ b/tests/project-tools.test.cjs @@ -0,0 +1,385 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('node:http'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { createDevTools, createNodeHost, createSessionService, ToolingError, parseArguments, HELP, + projectToolName, isProjectToolName, projectToolDefinition, projectToolDefinitions, projectToolResult, projectToolTimeoutMs, + projectToolRegistration, readProjectToolCatalog, readProjectToolCatalogFile, createProjectToolCatalog, projectToolCatalogPath, mainSequenceStateDirectory } = require('../dist/index.js'); + +// The shared project-tool registry (ADR 0004). The catalog cases were the Codex plugin's +// tests/panel-tool-catalog.test.cjs before the registry moved here. +const backend = 'https://backend.example.test'; +const environmentUid = '10000000-0000-4000-8000-000000000001'; +const releaseUid = '30000000-0000-4000-8000-000000000001'; +const row = { backend, resource_release_uid: releaseUid, name: 'Metrics', display_name: 'Research project', + mcp_connection: { url: 'https://project.example.test/mcp', transport: 'streamable-http', resource_metadata_url: 'https://project.example.test/.well-known/oauth-protected-resource?resource_release_uid=' + releaseUid, + organization_environment_uid: environmentUid, organization_environment_name: 'Research', active_revision_uid: '20000000-0000-4000-8000-000000000001' } }; +const metrics = { name: 'metrics', description: 'Inspect metrics', inputSchema: { type: 'object', properties: { window: { type: 'string' } } }, annotations: { readOnlyHint: true, title: 'ignored' }, _meta: { 'mainsequence.ai/timeout-seconds/v1': 60 } }; +const ok = (action, data) => ({ schemaVersion: 1, action, status: 'success', summary: 'ok', data }); +const failure = (action, code, summary) => ({ schemaVersion: 1, action, status: 'failure', summary, error: { code, message: summary } }); + +async function fixture(t, respond) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-catalog-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = path.join(root, 'nested', 'project-tool-catalog.json'); + const actions = []; let notified = 0; + const dispatch = () => async request => { actions.push(request); return respond(request, actions); }; + const catalog = createProjectToolCatalog({ file, dispatch, notify: () => { notified++; } }); + return { root, file, actions, catalog, notified: () => notified }; +} +const healthy = (tools = [metrics], rows = [row]) => request => request.action === 'auth-status' ? ok(request.action, { backend, authenticated: true, source: 'machine' }) + : request.action === 'mcp-list' ? ok(request.action, rows) + : request.action === 'mcp-tools' ? ok(request.action, { connection: row, tools }) + : failure(request.action, 'INVALID_REQUEST', 'unexpected ' + request.action); + +test('tool names are the ones Claude Desktop registers, so one remote tool has one name in both hosts', () => { + // Frozen vector: the name Claude registered for the CRM contacts list on the reference workstation. + assert.equal(projectToolName('http://127.0.0.1:8000', '18595d12-f92a-4960-84cd-0c6798a1ad25', '2de75e21-bfe1-4b77-b668-6916488ed317', 'crm_contacts_list'), 'ms_crm_contacts_list_183f8138'); + assert.notEqual(projectToolName('http://local-backend:8000', '18595d12-f92a-4960-84cd-0c6798a1ad25', '2de75e21-bfe1-4b77-b668-6916488ed317', 'crm_contacts_list'), 'ms_crm_contacts_list_183f8138', 'the backend string is part of the identity'); + // Codex qualifies the name as mcp__mainsequence__ under 64 characters: 19 + 44. + const long = projectToolName(backend, environmentUid, releaseUid, 'crm.prospecting/profiles-create-with-a-very-long-name'); + assert.match(long, /^ms_crm_prospecting_profiles-create-_[0-9a-f]{1,8}$/); assert.ok(long.length <= 44); + assert.ok(isProjectToolName('ms_metrics_1')); assert.equal(isProjectToolName('mainsequence_open'), false); assert.equal(isProjectToolName(7), false); +}); + +test('definitions carry the shared description, an object schema, the four hints and the per-connection cap', () => { + const definition = projectToolDefinition(row, metrics); + assert.equal(definition.name, projectToolName(backend, environmentUid, releaseUid, 'metrics')); + assert.equal(definition.title, 'Metrics: metrics'); + assert.equal(definition.description, 'MainSequence Metrics [Research]: Inspect metrics'); + assert.deepEqual(definition.inputSchema, metrics.inputSchema); + assert.deepEqual(definition.annotations, { openWorldHint: true, readOnlyHint: true }, 'only the four boolean hints, open-world by default'); + // The label is the release's name, else its repository's name, else the release UID; the environment its name, else its UID. + const nameless = { ...row, name: undefined, mcp_connection: { ...row.mcp_connection, organization_environment_name: undefined } }; + assert.equal(projectToolDefinition(nameless, { name: 'bare', inputSchema: { properties: {} } }).description, `MainSequence Research project [${environmentUid}]: bare`); + assert.equal(projectToolDefinition({ ...nameless, display_name: ' ' }, { name: 'bare', inputSchema: {} }).description, `MainSequence ${releaseUid} [${environmentUid}]: bare`); + assert.deepEqual(projectToolDefinition(row, { name: 'bare', inputSchema: { properties: {} } }).inputSchema, { type: 'object', properties: {} }, 'a schema without a type is an object schema'); + assert.deepEqual(projectToolDefinition(row, { name: 'closed', inputSchema: { type: 'object' }, annotations: { openWorldHint: false, destructiveHint: 'yes' } }).annotations, { openWorldHint: false }); + assert.equal(projectToolDefinitions(row, Array.from({ length: 130 }, (_, i) => ({ name: `tool_${i}`, inputSchema: { type: 'object' } }))).length, 80, 'registration keeps the 80-tool cap'); + assert.equal(projectToolDefinitions(row, [metrics, metrics, { name: 7 }, { name: 'noschema' }]).length, 1, 'duplicate and invalid tools are dropped'); +}); + +test('projectToolResult is the shape both plugins built: remote blocks, one JSON block with summary, effects, error and structuredContent, isError on any failure', () => { + // Pinned to the Claude Desktop hook's toolResult fixtures (claude-ui.test.cjs) before the registry moved here. + const success = projectToolResult({ schemaVersion: 1, action: 'mcp-call', status: 'success', summary: 'The MCP tool completed.', + data: { content: [{ type: 'text', text: 'Found 2 contacts' }, { type: 'image', data: 'AAAA', mimeType: 'image/png' }], isError: false, structuredContent: { count: 2 } } }); + assert.deepEqual(success.content.slice(0, 2), [{ type: 'text', text: 'Found 2 contacts' }, { type: 'image', data: 'AAAA', mimeType: 'image/png' }], 'remote blocks pass through untouched'); + assert.deepEqual(JSON.parse(success.content[2].text), { summary: 'The MCP tool completed.', structuredContent: { count: 2 } }); + assert.deepEqual(success.structuredContent, { count: 2 }); assert.equal(success.isError, undefined); + const plain = projectToolResult({ schemaVersion: 1, action: 'mcp-call', status: 'success', summary: 'ok', data: { content: [{ type: 'resource_link', uri: 'file:///report.pdf', name: 'report' }, 'junk', { type: 7 }] } }); + assert.deepEqual(plain, { content: [{ type: 'resource_link', uri: 'file:///report.pdf', name: 'report' }] }, 'a success with blocks and no structured content is the blocks alone'); + const failed = projectToolResult({ schemaVersion: 1, action: 'mcp-call', status: 'failure', summary: 'The connected MCP tool returned an error.', error: { code: 'MCP_TOOL_ERROR', message: 'The connected MCP tool returned an error.' }, + data: { content: [{ type: 'text', text: 'missing uid' }], isError: true }, effects: { state: 'partial-or-uncertain', message: 'verify' } }); + assert.equal(failed.isError, true); assert.equal(failed.content[0].text, 'missing uid', 'the remote tool\'s own content is preserved'); + assert.deepEqual(JSON.parse(failed.content[1].text), { summary: 'The connected MCP tool returned an error.', effects: { state: 'partial-or-uncertain', message: 'verify' }, error: { code: 'MCP_TOOL_ERROR', message: 'The connected MCP tool returned an error.' } }); + assert.deepEqual(failed.structuredContent, JSON.parse(failed.content[1].text), 'a failure without structured content carries the JSON block as structured content, as the Codex server returned it'); + // Pinned to the Codex server's expectation for a call refused before any project request. + const refused = projectToolResult({ schemaVersion: 1, action: 'mcp-call', status: 'failure', summary: 'No session is saved for this backend. Run login.', error: { code: 'NOT_AUTHENTICATED', message: 'No session is saved for this backend. Run login.' } }); + assert.equal(refused.isError, true); assert.equal(refused.content.length, 1); assert.equal(refused.content[0].type, 'text'); + assert.equal(JSON.parse(refused.content[0].text).error.code, 'NOT_AUTHENTICATED'); assert.equal(refused.structuredContent.error.code, 'NOT_AUTHENTICATED'); + assert.equal(projectToolResult({ schemaVersion: 1, action: 'mcp-call', status: 'cancelled', summary: 'The MCP action was cancelled.', error: { code: 'CANCELLED', message: 'The MCP action was cancelled.' } }).isError, true); +}); + +test('a tool that advertises a longer run gets it, capped at 300 s; otherwise the default budget applies', () => { + assert.equal(projectToolTimeoutMs(undefined), 120_000); + assert.equal(projectToolTimeoutMs(metrics), 120_000, 'a hint below the default does not shorten it'); + assert.equal(projectToolTimeoutMs({ ...metrics, _meta: { 'mainsequence.ai/timeout-seconds/v1': 240 } }), 240_000); + assert.equal(projectToolTimeoutMs({ ...metrics, _meta: { 'mainsequence.ai/timeout-seconds/v1': 900 } }), 300_000); + assert.equal(projectToolTimeoutMs({ ...metrics, _meta: { 'mainsequence.ai/timeout-seconds/v1': 900 } }, 400_000), 400_000, 'a host that asks for more keeps its own budget'); + for (const hint of ['60', -1, 0, Infinity, NaN, null]) assert.equal(projectToolTimeoutMs({ ...metrics, _meta: { 'mainsequence.ai/timeout-seconds/v1': hint } }, 5_000), 5_000); +}); + +test('the catalog lives beside the saved connections in the shared state directory, and the CLI can read it', () => { + const env = { APPDATA: 'C:\\Users\\fixture\\AppData\\Roaming' }; + assert.equal(path.dirname(projectToolCatalogPath(env)), mainSequenceStateDirectory(env)); + assert.equal(path.basename(projectToolCatalogPath()), 'project-tool-catalog.json'); + assert.match(mainSequenceStateDirectory(), process.platform === 'darwin' ? /Library\/Application Support\/MainSequenceCLI$/ : process.platform === 'win32' ? /MainSequenceCLI$/ : /\.config\/mainsequence$/); + assert.deepEqual(parseArguments(['mcp-catalog', '--refresh', '--backend', backend]).request, { action: 'mcp-catalog', backend, refresh: 'force' }); + assert.deepEqual(parseArguments(['mcp-catalog']).request, { action: 'mcp-catalog', backend: undefined }); + assert.throws(() => parseArguments(['mcp-list', '--refresh']), { code: 'INVALID_REQUEST' }); + assert.match(HELP, /mcp-catalog \[--refresh\]/); +}); + +test('a damaged cache is an empty catalog; valid rows become definitions with the remote schema and bindings', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-catalog-')); t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = path.join(root, 'catalog.json'); + assert.equal(readProjectToolCatalogFile(file), null); + await fs.writeFile(file, '{not json'); assert.equal(readProjectToolCatalogFile(file), null); + await fs.writeFile(file, JSON.stringify({ version: 2, connections: [] })); assert.equal(readProjectToolCatalogFile(file), null); + await fs.writeFile(file, JSON.stringify({ version: 1, updatedAt: 'x', connections: [ + { backend, releaseUid: releaseUid.toUpperCase(), environmentUid, environmentName: 'Research', name: 'Metrics', tools: [metrics, { name: 7 }, { name: 'noschema' }, { name: 'bare', inputSchema: { properties: {} } }] }, + { backend, releaseUid: 'not-a-uuid', environmentUid, tools: [] }, 'junk'] })); + const catalog = readProjectToolCatalogFile(file); + assert.equal(catalog.connections.length, 1); assert.equal(catalog.connections[0].releaseUid, releaseUid); + assert.deepEqual(catalog.connections[0].tools.map(tool => tool.name), ['metrics', 'bare']); + assert.deepEqual(catalog.connections[0].tools[0].annotations, { readOnlyHint: true }, 'only the four boolean hints survive'); + assert.equal('_meta' in catalog.connections[0].tools[0], false, 'the cache holds schemas and labels only'); + const { definitions, bindings } = projectToolRegistration(catalog); + assert.equal(definitions[0].name, projectToolName(backend, environmentUid, releaseUid, 'metrics')); + assert.equal(definitions[0].description, 'MainSequence Metrics [Research]: Inspect metrics'); + assert.deepEqual(definitions[0].inputSchema, metrics.inputSchema); + assert.deepEqual(definitions[0].annotations, { openWorldHint: true, readOnlyHint: true }); + assert.deepEqual(definitions[1].inputSchema, { type: 'object', properties: {} }, 'a schema without a type is an object schema'); + assert.deepEqual(bindings.get(definitions[0].name), { backend, environmentUid, environmentName: 'Research', releaseUid, connection: 'Metrics', tool: 'metrics' }); + assert.deepEqual(projectToolRegistration(catalog, { backend: 'https://elsewhere.example.test' }).definitions, [], 'a scope selects connections by backend'); + assert.equal(projectToolRegistration(catalog, { backend, environmentUid: environmentUid.toUpperCase() }).definitions.length, 2, 'and by environment'); +}); + +test('a scoped read replaces only the connections in scope, keeps an unreadable connection\'s earlier tools and propagates cancellation', async () => { + const otherEnvironment = '10000000-0000-4000-8000-000000000002', otherRelease = '30000000-0000-4000-8000-000000000002'; + const previous = { version: 1, updatedAt: 'x', connections: [ + { backend, releaseUid, environmentUid, environmentName: 'Research', name: 'Old', tools: [{ name: 'stale', inputSchema: { type: 'object' }, annotations: {} }] }, + { backend, releaseUid: otherRelease, environmentUid: otherEnvironment, environmentName: 'Production', name: 'Other', tools: [metrics] }, + { backend: 'https://elsewhere.example.test', releaseUid, environmentUid, environmentName: 'Research', name: 'Elsewhere', tools: [metrics] }] }; + const actions = []; + const read = await readProjectToolCatalog(async request => { actions.push(request); return healthy()(request); }, { backend, environmentUid, previous }); + assert.equal(read.backend, backend); assert.deepEqual(read.unavailable, []); assert.equal(read.retained, 0); + assert.deepEqual(read.catalog.connections.map(c => [c.backend, c.name, c.tools.map(tool => tool.name)]), + [[backend, 'Metrics', ['metrics']], [backend, 'Other', ['metrics']], ['https://elsewhere.example.test', 'Elsewhere', ['metrics']]], 'the other environment and the other backend are carried over'); + assert.deepEqual(actions.map(a => a.action), ['auth-status', 'mcp-list', 'mcp-tools']); + assert.ok(actions.slice(1).every(a => a.environmentUid === environmentUid && a.backend === backend && a.expectedAccount === undefined)); + assert.equal(actions[1].readOnly, true); + const degraded = await readProjectToolCatalog(async request => request.action === 'mcp-tools' + ? failure(request.action, 'BACKEND_UNREACHABLE', 'The project MCP initialize request to https://project.example.test timed out after 120 s. It was not retried.') : healthy()(request), { backend, previous }); + assert.deepEqual(degraded.unavailable, ['Metrics']); assert.equal(degraded.retained, 1); + assert.deepEqual(degraded.catalog.connections.map(c => [c.name, c.tools.map(tool => tool.name)]), [['Old', ['stale']], ['Elsewhere', ['metrics']]], 'an unscoped read covers the whole backend: the removed connection loses its tools, the unreadable one keeps them'); + await assert.rejects(readProjectToolCatalog(async request => request.action === 'mcp-tools' + ? { schemaVersion: 1, action: request.action, status: 'cancelled', summary: 'stopped', error: { code: 'CANCELLED', message: 'stopped' } } : healthy()(request), { backend }), { code: 'CANCELLED' }); + await assert.rejects(readProjectToolCatalog(async request => ok(request.action, { backend, authenticated: false, source: 'none' }), { backend }), { code: 'NOT_AUTHENTICATED' }); + await assert.rejects(readProjectToolCatalog(healthy(), { backend, environmentUid: 'research' }), { code: 'INVALID_REQUEST' }); +}); + +test('refresh reads every saved connection once, writes the cache without secrets and announces the change', async t => { + const f = await fixture(t, healthy()); + assert.deepEqual(f.catalog.list(), []); assert.equal(f.catalog.state().count, 0); + assert.equal(await f.catalog.refresh(true), true); + assert.deepEqual(f.actions.map(a => a.action), ['auth-status', 'mcp-list', 'mcp-tools']); + assert.equal(f.actions[1].readOnly, true); assert.equal(f.actions[2].backend, backend); assert.equal(f.actions[2].releaseUid, releaseUid); + assert.equal(f.actions[2].expectedAccount, undefined, 'registered tools bind to the saved connection, not to an account snapshot'); + const [definition] = f.catalog.list(); + assert.equal(definition.name, projectToolName(backend, environmentUid, releaseUid, 'metrics')); + assert.deepEqual(f.catalog.binding(definition.name), { backend, environmentUid, environmentName: 'Research', releaseUid, connection: 'Metrics', tool: 'metrics' }); + assert.equal(f.notified(), 1); + const written = JSON.parse(await fs.readFile(f.file, 'utf8')); + assert.equal(written.version, 1); assert.equal(written.connections[0].tools[0].name, 'metrics'); + assert.doesNotMatch(JSON.stringify(written), /mcp_connection|resource_metadata_url|token/); + if (process.platform !== 'win32') assert.equal(((await fs.stat(f.file)).mode & 0o777).toString(8), '600'); + const state = f.catalog.state(); + assert.equal(state.count, 1); assert.deepEqual(state.connections, [{ backend, releaseUid, environmentUid, environmentName: 'Research', name: 'Metrics', tools: 1 }]); + assert.equal(state.error, null); assert.equal(state.refreshing, false); assert.deepEqual(state.unavailable, []); + const view = f.catalog.view({ backend }); + assert.equal(view.definitions.length, 1); assert.deepEqual(Object.keys(view.bindings), [definition.name]); + // A fresh cache is not read again without force; a forced refresh with the same catalog announces nothing. + assert.equal(await f.catalog.refresh(), false); assert.equal(f.actions.length, 3); + await f.catalog.refresh(true); assert.equal(f.actions.length, 6); assert.equal(f.notified(), 1); +}); + +test('an unreadable connection keeps its earlier tools; a removed connection loses them', async t => { + let mode = 'healthy'; + const f = await fixture(t, request => mode === 'healthy' || request.action !== 'mcp-tools' ? healthy(undefined, mode === 'removed' ? [] : [row])(request) + : failure(request.action, 'BACKEND_UNREACHABLE', 'The project MCP initialize request to https://project.example.test timed out after 120 s. It was not retried.')); + await f.catalog.refresh(true); const before = f.catalog.list(); + mode = 'failing'; await f.catalog.refresh(true); + assert.deepEqual(f.catalog.list(), before); assert.deepEqual(f.catalog.state().unavailable, ['Metrics']); assert.equal(f.catalog.state().retained, 1); assert.equal(f.catalog.state().error, null); + mode = 'removed'; await f.catalog.refresh(true); + assert.deepEqual(f.catalog.list(), []); assert.deepEqual(f.catalog.state().unavailable, []); assert.equal(f.notified(), 2); +}); + +test('without a session nothing is read and the state says so; a signed-out refresh keeps the cached tools', async t => { + let authenticated = true; + const f = await fixture(t, request => request.action === 'auth-status' ? ok(request.action, { backend, authenticated, source: authenticated ? 'machine' : 'none' }) : healthy()(request)); + await f.catalog.refresh(true); authenticated = false; const count = f.actions.length; + assert.equal(await f.catalog.refresh(true), false); + assert.equal(f.actions.length, count + 1); assert.equal(f.catalog.state().error.code, 'NOT_AUTHENTICATED'); assert.equal(f.catalog.list().length, 1); +}); + +test('a listing is scoped to the current backend once a refresh learned it, so another backend\'s cached connections stay out of a host\'s tool list', async t => { + const elsewhere = { backend: 'https://elsewhere.example.test', releaseUid, environmentUid, environmentName: 'Research', name: 'Elsewhere', tools: [metrics] }; + const f = await fixture(t, healthy()); + await fs.mkdir(path.dirname(f.file), { recursive: true }); + await fs.writeFile(f.file, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), connections: [elsewhere] })); + const catalog = createProjectToolCatalog({ file: f.file, dispatch: () => healthy(), notify: () => { f.changed = (f.changed || 0) + 1; } }); + assert.equal(catalog.list().length, 1, 'before the backend is known the whole cache is listed'); + assert.equal(await catalog.refresh(), false, 'the cache is fresh'); + assert.deepEqual(catalog.list(), [], 'knowing the backend scopes the listing'); assert.equal(f.changed, 1, 'and the change is announced'); + assert.equal(catalog.list({ backend: elsewhere.backend }).length, 1); assert.ok(catalog.binding(projectToolName(elsewhere.backend, environmentUid, releaseUid, 'metrics')), 'a binding resolves whatever its backend'); + await catalog.refresh(true); + assert.deepEqual(catalog.list().map(d => d.description), ['MainSequence Metrics [Research]: Inspect metrics']); + assert.equal(readProjectToolCatalogFile(f.file).connections.length, 2, 'the other backend\'s connection is kept in the shared file'); +}); + +test('one process refreshes at a time; the others reuse its file, a stale lock is cleared, and a waiting refresh takes the lock when it is freed', async t => { + const f = await fixture(t, healthy()); + await fs.mkdir(path.dirname(f.file), { recursive: true }); + await fs.writeFile(f.file + '.lock', '{"pid":1}'); + assert.equal(await f.catalog.refresh(true), false); + assert.deepEqual(f.actions.map(a => a.action), ['auth-status'], 'a live lock means another process is already refreshing: only the session was checked'); + assert.equal(f.catalog.state().error, null, 'a background refresh that found a live lock is not an error'); + const old = new Date(Date.now() - 11 * 60_000); await fs.utimes(f.file + '.lock', old, old); + await f.catalog.refresh(true); assert.equal(f.actions.length, 4); assert.equal(await fs.stat(f.file + '.lock').catch(e => e.code), 'ENOENT'); + const other = createProjectToolCatalog({ file: f.file, dispatch: () => async request => { if (request.action === 'auth-status') return healthy()(request); throw new Error('the other process must not read'); } }); + assert.equal(other.list().length, 1, 'a second server process reads the shared cache'); + await new Promise(resolve => setTimeout(resolve, 20)); + await f.catalog.update(releaseUid, [metrics, { name: 'export', inputSchema: { type: 'object' } }]); + assert.equal(other.list().length, 2, 'a change written by one process is visible to the next list of another'); + assert.equal(other.binding(projectToolName(backend, environmentUid, releaseUid, 'export')).tool, 'export'); + await other.refresh(); assert.equal(other.state().count, 2, 'a fresh shared cache is not refreshed again'); + // A refresh that may wait (the mcp-catalog action) takes the lock once the other process frees it, and reports when it cannot. + await fs.writeFile(f.file + '.lock', '{"pid":1}'); + const count = f.actions.length; + setTimeout(() => fs.rm(f.file + '.lock', { force: true }), 300); + assert.equal(await f.catalog.refresh(true, { waitMs: 5_000 }), true); assert.equal(f.actions.length, count + 3); + await fs.writeFile(f.file + '.lock', '{"pid":1}'); + assert.equal(await f.catalog.refresh(true, { waitMs: 300 }), false); assert.equal(f.catalog.state().error.code, 'PROJECT_BUSY'); + await fs.rm(f.file + '.lock', { force: true }); +}); + +test('disposing a host mid-refresh aborts it and frees the shared lock immediately', async t => { + let release; + const f = await fixture(t, request => request.action === 'mcp-tools' ? new Promise(resolve => { release = () => resolve(healthy()(request)); }) : healthy()(request)); + const refresh = f.catalog.refresh(true); + while (!release) await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal(await fs.stat(f.file + '.lock').then(() => true, () => false), true, 'the refresh holds the lock'); + f.catalog.dispose(); + assert.equal(await fs.stat(f.file + '.lock').then(() => true, e => e.code), 'ENOENT', 'the lock is freed synchronously'); + release(); await refresh; + const other = createProjectToolCatalog({ file: f.file, dispatch: () => healthy() }); + await other.refresh(true); assert.equal(other.list().length, 1, 'the next host refreshes without waiting for the stale-lock timeout'); +}); + +test('a disabled catalog still serves the cache but never reads or writes on its own', async t => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-catalog-')); t.after(() => fs.rm(root, { recursive: true, force: true })); + const file = path.join(root, 'catalog.json'); + await fs.writeFile(file, JSON.stringify({ version: 1, updatedAt: '2020-01-01T00:00:00.000Z', connections: [{ backend, releaseUid, environmentUid, environmentName: 'Research', name: 'Metrics', tools: [metrics] }] })); + const catalog = createProjectToolCatalog({ file, disabled: true, dispatch: () => async () => { throw new Error('disabled catalogs do not dispatch'); } }); + assert.equal(catalog.list().length, 1); + assert.equal(await catalog.refresh(true), false); await catalog.update(releaseUid, []); + assert.equal(catalog.list().length, 1); +}); + +/** A real project MCP server behind the public dispatcher, with a fake platform session: the + * mcp-catalog and mcp-tool-call actions, the per-tool timeout and the precise error messages. */ +async function projectFixture(t) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-project-tools-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const state = { mode: 'json', hint: undefined, invoked: 0, endpoint: undefined }; + const tool = () => ({ name: 'set_value', description: 'Write a fixture value', inputSchema: { type: 'object', properties: { value: { type: 'number' } } }, + annotations: { destructiveHint: true }, ...(state.hint ? { _meta: { 'mainsequence.ai/timeout-seconds/v1': state.hint } } : {}) }); + const server = http.createServer(async (req, res) => { + let text = ''; for await (const chunk of req) text += chunk; + const message = JSON.parse(text); + const reply = result => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result })); }; + if (req.headers.authorization !== 'Bearer fixture-release-token') return res.writeHead(401).end(); + if (message.method === 'initialize') return reply({ protocolVersion: '2025-11-25', capabilities: { tools: {} } }); + if (message.method === 'notifications/initialized') return res.writeHead(202).end(); + if (message.method === 'tools/list') return reply({ tools: [tool()] }); + if (message.method === 'tools/call') { + state.invoked++; + if (state.mode === 'hang') return; + if (state.mode === '503') return res.writeHead(503).end('busy'); + if (state.mode === 'rpc-error') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ jsonrpc: '2.0', id: message.id, error: { code: -32602, message: 'value must be a number\u0007 fixture-release-token' } })); } + return reply({ content: [{ type: 'text', text: 'value=' + message.params.arguments.value }], structuredContent: { value: message.params.arguments.value }, isError: state.mode === 'tool-error' }); + } + res.writeHead(400).end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + const project = 'http://127.0.0.1:' + server.address().port; + const endpoint = () => state.endpoint || project; + const connection = () => ({ backend, resource_release_uid: releaseUid, name: 'metatable-mcp', mcp_connection: { url: endpoint() + '/mcp', transport: 'streamable-http', + resource_metadata_url: backend + '/.well-known/oauth-protected-resource/mcp?resource_release_uid=' + releaseUid, + organization_environment_uid: environmentUid, organization_environment_name: 'Research', active_revision_uid: '20000000-0000-4000-8000-000000000001' } }); + const connections = path.join(dir, 'connections.json'), catalogFile = path.join(dir, 'project-tool-catalog.json'); + const save = () => fs.writeFile(connections, JSON.stringify([connection()])); + await save(); + const unsupported = async () => { throw new ToolingError('UNSUPPORTED_ACTION', 'fixture'); }; + const session = { sanitize: value => value.split('fixture-release-token').join('[redacted]'), + status: async input => ({ backend: input, authenticated: true, source: 'environment', persistence: 'environment', store: null, username: 'fixture-user' }), + login: unsupported, refresh: unsupported, logout: unsupported, + request: async (input, method, apiPath) => { + if (apiPath === '/api/v1/resource-releases/' + releaseUid + '/') return { uid: releaseUid, release_kind: 'fastapi', mcp_connection: connection().mcp_connection }; + if (apiPath === '/api/v1/resource-releases/' + releaseUid + '/exchange-launch/') return { mode: 'token', release_kind: 'fastapi', token: 'fixture-release-token', rpc_url: endpoint() + '/' }; + throw new ToolingError('API_ERROR', 'unexpected ' + method + ' ' + apiPath); + } }; + const create = (extra = {}) => createDevTools({ host: createNodeHost(), session, env: {}, mcpConnectionsPath: connections, projectToolCatalogPath: catalogFile, projectRequestTimeoutMs: 300, ...extra }); + return { dir, state, project, connections, catalogFile, create, save }; +} + +test('mcp-catalog answers from the shared file at once, re-reads the saved connections on request and scopes by environment', async t => { + const f = await projectFixture(t); + const cached = await f.create().dispatch({ action: 'mcp-catalog', backend }); + assert.equal(cached.status, 'success', cached.summary); assert.equal(cached.data.count, 0); assert.equal(cached.data.refreshed, false); assert.equal(cached.data.file, f.catalogFile); + assert.match(cached.summary, /from the shared catalog/); + const refreshed = await f.create().dispatch({ action: 'mcp-catalog', backend, environmentUid, refresh: 'force' }); + assert.equal(refreshed.status, 'success', refreshed.summary); assert.equal(refreshed.data.refreshed, true); assert.equal(refreshed.data.count, 1); + assert.match(refreshed.summary, /1 project MCP tools from 1 saved connections, catalogs re-read\./); + const name = projectToolName(backend, environmentUid, releaseUid, 'set_value'); + assert.deepEqual(refreshed.data.definitions.map(d => [d.name, d.description, d.annotations]), [[name, 'MainSequence metatable-mcp [Research]: Write a fixture value', { openWorldHint: true, destructiveHint: true }]]); + assert.deepEqual(refreshed.data.bindings[name], { backend, environmentUid, environmentName: 'Research', releaseUid, connection: 'metatable-mcp', tool: 'set_value' }); + assert.deepEqual(refreshed.data.connections, [{ backend, releaseUid, environmentUid, environmentName: 'Research', name: 'metatable-mcp', tools: 1 }]); + assert.equal(refreshed.data.environmentUid, environmentUid); assert.deepEqual(refreshed.data.unavailable, []); assert.equal(refreshed.data.error, null); + assert.doesNotMatch(await fs.readFile(f.catalogFile, 'utf8'), /fixture-release-token|resource_metadata_url/); + const again = await f.create().dispatch({ action: 'mcp-catalog', backend }); + assert.equal(again.data.count, 1, 'the next process registers from the file without a network request'); assert.equal(again.data.refreshed, false); + assert.equal((await f.create().dispatch({ action: 'mcp-catalog', backend, environmentUid: '10000000-0000-4000-8000-000000000009' })).data.count, 0, 'another environment sees none of them'); + assert.equal((await f.create().dispatch({ action: 'mcp-catalog', backend, refresh: true })).data.refreshed, false, 'a fresh cache is not re-read without force'); + assert.equal((await f.create().dispatch({ action: 'mcp-catalog', backend, environmentUid: 'research' })).error.code, 'INVALID_REQUEST'); + assert.equal((await f.create().dispatch({ action: 'mcp-catalog', backend, refresh: 'yes' })).error.code, 'INVALID_REQUEST'); + // A failed re-read reports why and still returns what the file holds. + f.state.endpoint = 'https://127.0.0.1:1'; await f.save(); + const degraded = await f.create().dispatch({ action: 'mcp-catalog', backend, refresh: 'force' }); + assert.equal(degraded.status, 'success', degraded.summary); assert.deepEqual(degraded.data.unavailable, ['metatable-mcp']); assert.equal(degraded.data.retained, 1); assert.equal(degraded.data.count, 1); + assert.match(degraded.summary, /metatable-mcp could not be read now; earlier tools stay registered/); +}); + +test('mcp-tool-call answers in the shared result shape and honours a tool\'s advertised timeout', async t => { + const f = await projectFixture(t); + const call = extra => f.create().dispatch({ action: 'mcp-tool-call', backend, environmentUid, releaseUid, tool: 'set_value', arguments: { value: 4 }, ...extra }); + const result = await call(); + assert.equal(result.status, 'success', result.summary); assert.equal(result.action, 'mcp-tool-call'); assert.equal(result.summary, 'The MCP tool completed.'); + assert.deepEqual(result.data.content[0], { type: 'text', text: 'value=4' }); assert.deepEqual(JSON.parse(result.data.content[1].text), { summary: 'The MCP tool completed.', structuredContent: { value: 4 } }); + assert.deepEqual(result.data.structuredContent, { value: 4 }); assert.equal(result.data.isError, undefined); + f.state.mode = 'tool-error'; + const refused = await call(); + assert.equal(refused.status, 'failure'); assert.equal(refused.error.code, 'MCP_TOOL_ERROR'); assert.equal(refused.data.isError, true); + assert.equal(refused.data.content[0].text, 'value=4'); assert.equal(JSON.parse(refused.data.content[1].text).error.code, 'MCP_TOOL_ERROR'); assert.equal(refused.effects.state, 'partial-or-uncertain'); + assert.equal((await call({ tool: 'missing' })).data.structuredContent.error.code, 'INVALID_REQUEST', 'a refusal before the call is the JSON block alone'); + // The default project budget is 120 s; this fixture lowers it to 0.3 s. A tool that advertises + // one second gets one second: the hint is honoured when it is larger than the budget. + f.state.mode = 'hang'; + let started = Date.now(), timeout = await call(); + assert.equal(timeout.error.code, 'BACKEND_UNREACHABLE'); assert.match(timeout.summary, /^The project MCP tools\/call set_value request to http:\/\/127\.0\.0\.1:\d+ timed out after 0\.3 s\. It was not retried\.$/); + assert.equal(timeout.effects.state, 'partial-or-uncertain', 'a timed-out call may have run'); + f.state.hint = 1; started = Date.now(); timeout = await call(); + assert.match(timeout.summary, /timed out after 1 s/); assert.ok(Date.now() - started >= 900, 'the advertised second was waited for'); +}); + +test('errors say which endpoint answered which request: HTTP status, redacted JSON-RPC message, timeout or connection failure', async t => { + const f = await projectFixture(t); + const call = () => f.create().dispatch({ action: 'mcp-call', backend, releaseUid, tool: 'set_value', arguments: { value: 1 } }); + f.state.mode = '503'; + const status = await call(); + assert.equal(status.error.code, 'API_ERROR'); assert.match(status.summary, /^The project MCP http:\/\/127\.0\.0\.1:\d+ returned HTTP 503 for tools\/call set_value\.$/); + f.state.mode = 'rpc-error'; + const rejected = await call(); + assert.equal(rejected.error.code, 'API_ERROR'); + assert.match(rejected.summary, /^The MCP endpoint rejected tools\/call set_value \(JSON-RPC -32602\): value must be a number \[redacted\]$/, 'the server\'s own message, without control characters or credentials'); + // A closed port: the release credential exchange succeeds, the project's initialize cannot connect. + const closed = http.createServer(); await new Promise(resolve => closed.listen(0, '127.0.0.1', resolve)); + const port = closed.address().port; await new Promise(resolve => closed.close(resolve)); + f.state.endpoint = 'http://127.0.0.1:' + port; await f.save(); + const unreachable = await call(); + assert.equal(unreachable.error.code, 'BACKEND_UNREACHABLE'); + assert.match(unreachable.summary, new RegExp(`^The project MCP initialize request to http://127\\.0\\.0\\.1:${port} failed before a response arrived \\(ECONNREFUSED\\)\\. It was not retried\\.$`)); + assert.equal(unreachable.effects, undefined, 'nothing was invoked'); + // The platform session names the backend, method and path the same way. + const platform = (fetch, options = {}) => createSessionService(createNodeHost(), { env: { MAINSEQUENCE_ENDPOINT: backend, MAINSEQUENCE_ACCESS_TOKEN: 'fixture-platform-token' }, store: null, fetch, ...options }); + await assert.rejects(platform(async () => new Response('busy', { status: 503 })).request(backend, 'GET', '/api/v1/code-repositories/'), + error => error.code === 'API_ERROR' && error.message === `The backend ${backend} returned HTTP 503 for GET /api/v1/code-repositories/.`); + await assert.rejects(platform(async () => { throw Object.assign(new TypeError('fetch failed'), { cause: Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' }) }); }).request(backend, 'GET', '/api/v1/code-repositories/'), + error => error.code === 'BACKEND_UNREACHABLE' && error.message === `The backend ${backend} could not be reached for GET /api/v1/code-repositories/: ECONNREFUSED. The saved session has been kept.`); + await assert.rejects(platform((url, init) => new Promise((_, reject) => init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')))), { requestTimeoutMs: 50 }).request(backend, 'POST', '/api/v1/agents/', {}), + error => error.code === 'BACKEND_UNREACHABLE' && error.message === `The backend ${backend} could not be reached for POST /api/v1/agents/: the request timed out after 0.1 s. The saved session has been kept.`); +});