diff --git a/README.md b/README.md index 7cdbd93..8626093 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,47 @@ The machine record is the SDKs' existing `MainSequenceCLI.auth` service, backend 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) + +`createDevTools().dispatch()` now infers typed payloads for session status, environments, +agent directories, MCP discovery, saved connections and tool catalogs. `ActionResult` +defaults to `unknown` for compatibility; the public exports include `McpConnection`, +`McpTool`, `McpCatalog`, `McpDiscoveryPage`, `AgentDirectory` and `DiscoveryResultMap`. +Backend response validation stays in devtools; host applications own their view state. + +```ts +const verified = await devtools.dispatch({ action: 'auth-status', backend, check: true }); +if (verified.status !== 'success' || !verified.data?.username) throw new Error('Sign in first'); +const scope = { backend, expectedAccount: verified.data.username, environmentUid }; +const saved = await devtools.dispatch({ action: 'mcp-list', ...scope, readOnly: true }); +const available = await devtools.dispatch({ action: 'mcp-discover', ...scope, enrichNames: true, limit: 20 }); +``` + +- `mcp-list` with `readOnly: true` reads saved metadata without label lookups, locks or + connection-file writes. Missing stores remain absent; corrupt files are preserved and + reported. Omit the option (or explicitly use `false`) to retain the legacy label-backfill + behavior. Normal authentication renewal is independent of connection metadata writes. +- `mcp-discover` with `enrichNames: true` resolves optional repository names once per branch, + with at most four concurrent lookups and no connection writes. Missing optional metadata + leaves usable endpoints visible. Cancellation and account changes still fail the action. +- `mcp-environments` and `organization-environments` use the same bounded `listEnvironments` + loader. Short pages advance by their actual length; duplicate rows, changing totals, + premature empty pages and directories over 10,000 entries are refused. The existing MCP + result retains `isProduction`; the Projects result retains `{uid, name}`. +- MCP and organization-project actions accept optional `expectedAccount`. The built-in + session verifies the actual credential used for requests and retries, rejects a different + account and discards responses when the selected credentials change during a read. Agent + operations also use this binding when the session supports it. This does not make remote + mutations atomic with account changes or roll back work already dispatched. + +`bindSessionAccount(session, backend, expectedAccount)` is available for public domain +clients. Custom `SessionService` implementations can implement the optional `withAccount` +method to provide that contract. New opt-in MCP/Projects account checks fail with +`UNSUPPORTED_ACTION` when the custom transport lacks it; existing unscoped callers and +legacy custom agent transports retain their prior behavior. The built-in scoped session +cannot log in, log out or change authentication. Hosts still invalidate stale selections +when their configured backend/environment changes. + ## Project actions Backend selection uses an explicit `--backend`, process `MAINSEQUENCE_ENDPOINT`, the Git-root `.env` endpoint, existing CLI configuration, then the standard backend. Project selection validates the real Git root, attached branch, full HEAD SHA, and canonical remote identity. Ambiguous Python/frontend projects require `--type python` or `--type vite_react`. diff --git a/package-lock.json b/package-lock.json index 96711b6..9bcd4a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.1", + "version": "0.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dev-mainsequence/devtools", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "bin": { "ms-devtools": "dist/runner.js" diff --git a/package.json b/package.json index e9e8482..a94183f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dev-mainsequence/devtools", - "version": "0.3.1", + "version": "0.3.2", "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 0cebbfd..8d6a2b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ export { pythonExecutable, hasPythonProject, projectVersion, readPythonSdkStatus export type { CaptureProcess } from './shared/pythonProjectStatus'; export type { McpRequest } 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'; diff --git a/src/shared/accountScope.ts b/src/shared/accountScope.ts new file mode 100644 index 0000000..635edac --- /dev/null +++ b/src/shared/accountScope.ts @@ -0,0 +1,15 @@ +import { SessionService, ToolingError } from './models'; + +export function validateExpectedAccount(value: unknown): asserts value is string { + if (typeof value !== 'string' || !value.trim() || value.length > 300 || /[\r\n\0]/.test(value)) { + throw new ToolingError('INVALID_REQUEST', 'Use the account returned by verified session status.'); + } +} + +/** Opt-in account scope for domain operations; never silently weaken a custom transport. */ +export async function bindSessionAccount(session: SessionService, backend: string, expectedAccount?: string): Promise { + if (expectedAccount === undefined) return session; + validateExpectedAccount(expectedAccount); + if (!session.withAccount) throw new ToolingError('UNSUPPORTED_ACTION', 'This session transport does not support account-bound operations. Upgrade its session implementation.'); + return session.withAccount(backend, expectedAccount); +} diff --git a/src/shared/actions.ts b/src/shared/actions.ts index f6e0ab1..5a10173 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -6,11 +6,14 @@ import { listRepositories, projectContext, resolveBackend, resolveRepository } f import { DRY_RUN_ACTIONS, projectCapabilities, runProjectAction, setupRepository, withProjectLock } from './projects'; import { OrganizationProjectClient, OrganizationProjectRequest } from './organizationProjects'; import { setupOrganizationProject } from './organizationProjectWorkspace'; +import { bindSessionAccount } from './accountScope'; +import type { ActionData } from './discoveryTypes'; 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; const session = suppliedSession ?? new MachineSessionService(host, sessionOptions); const env = { ...(options.env ?? process.env) }; + function dispatch(request: R): Promise>>; async function dispatch(request: ActionRequest): Promise { const state = { mutationStarted: false }; const action = request?.action || 'unknown'; @@ -20,17 +23,20 @@ export function createDevTools(options: ConstructorParameters scoped.status(backend, true).then(() => {}) }).dispatch(input, backend)); } let data: unknown; if (['login', 'logout', 'refresh-token', 'auth-status', 'repositories'].includes(action)) { diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 74d7be8..bcf5960 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -59,11 +59,15 @@ export class AgentClient { if (['agent-session', 'agent-readiness', 'agent-send', 'agent-task-get', 'agent-task-wait', 'agent-task-cancel'].includes(input.action) && !input.expectedAccount) { throw new ToolingError('INVALID_REQUEST', 'Agent conversations must be bound to the account returned by discovery.'); } + // The built-in session binds every request/retry to the selected account. Legacy + // custom agent transports retain their existing account-check contract. + const session = input.expectedAccount !== undefined && this.session.withAccount + ? await this.session.withAccount(backend, input.expectedAccount) : this.session; const timeouts = new Map(); const client = new McpTransport(message => { const remaining = readinessDeadline === undefined ? undefined : Math.ceil(readinessDeadline - performance.now()); if (remaining !== undefined && remaining <= 0) throw new ToolingError('PROCESS_TIMEOUT', 'The agent did not become ready in time. No message was sent. Try again.'); - return this.session.request(backend, 'POST', '/mcp', message, + return session.request(backend, 'POST', '/mcp', message, message.method !== 'tools/call' || !['a2a.send_message', 'a2a.cancel_task'].includes((message.params as any)?.name), remaining !== undefined ? { timeoutMs: Math.min(30000, remaining) } : message.method === 'tools/call' && timeouts.has((message.params as any)?.name) ? { timeoutMs: timeouts.get((message.params as any).name) } : undefined); diff --git a/src/shared/discoveryTypes.ts b/src/shared/discoveryTypes.ts new file mode 100644 index 0000000..5dd9131 --- /dev/null +++ b/src/shared/discoveryTypes.ts @@ -0,0 +1,33 @@ +import type { ActionRequest, SessionStatus } from './models'; +import type { PlatformAgent } from './agents'; +import type { EnvironmentSummary } from './environments'; +import type { OrganizationEnvironment, OrganizationProjectPage } from './organizationProjects'; + +export type McpConnection = { + backend: string; resource_release_uid: string; name?: string; display_name?: string; code_repository_branch_uid?: string; + mcp_connection: { url: string; transport: 'streamable-http'; resource_metadata_url: string; + organization_environment_uid: string; organization_environment_name?: string; active_revision_uid: string }; +}; +export type McpTool = { + name: string; description?: string; inputSchema: Record; + annotations?: Record & { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean }; + _meta?: Record; + [key: string]: unknown; +}; +export type McpDiscoveryPage = { count: number; results: McpConnection[] }; +export type McpCatalog = { connection: McpConnection; tools: McpTool[] }; +export type AgentDirectory = { account: string; results: PlatformAgent[]; count: number; offset: number; semantic: boolean }; + +/** Additive typed read contracts; unrelated actions retain their existing unknown payload. */ +export type DiscoveryResultMap = { + 'auth-status': SessionStatus; + 'mcp-environments': EnvironmentSummary[]; + 'organization-environments': OrganizationEnvironment[]; + 'organization-projects': OrganizationProjectPage; + 'agents-list': AgentDirectory; + 'mcp-discover': McpDiscoveryPage; + 'mcp-list': McpConnection[]; + 'mcp-tools': McpCatalog; +}; +export type ActionData = R extends { action: infer A } + ? A extends keyof DiscoveryResultMap ? DiscoveryResultMap[A] : unknown : unknown; diff --git a/src/shared/environments.ts b/src/shared/environments.ts new file mode 100644 index 0000000..6d866f6 --- /dev/null +++ b/src/shared/environments.ts @@ -0,0 +1,34 @@ +import { SessionService, ToolingError } from './models'; +import { requireBackend } from './session'; + +export type EnvironmentSummary = { uid: string; name: string; isProduction: boolean }; + +/** One bounded directory reader for all hosts and domain clients. No settings writes. */ +export async function listEnvironments(session: Pick, inputBackend: string, signal?: AbortSignal): Promise { + const backend = requireBackend(inputBackend), items: EnvironmentSummary[] = [], seen = new Set(); + let expectedCount: number | undefined; + for (let offset = 0; ;) { + signal?.throwIfAborted(); + const page = await session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`); + signal?.throwIfAborted(); + const rows = Array.isArray(page) ? page : page?.results; + const count = Array.isArray(page) ? page.length : page?.count; + if (!Array.isArray(rows) || !Number.isSafeInteger(count) || count < 0 || count > 10_000 || offset + rows.length > count + || (expectedCount !== undefined && expectedCount !== count) || (offset > 0 && Array.isArray(page))) { + throw new ToolingError('API_ERROR', 'The environment pagination is invalid or changed. Refresh the directory.'); + } + expectedCount = count; + for (const row of rows) { + if (typeof row?.uid !== 'string' || !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(row.uid) + || typeof row.name !== 'string' || !row.name.trim() || /[\r\n\0]/.test(row.name)) { + throw new ToolingError('API_ERROR', 'The backend returned an invalid organization environment.'); + } + const uid = row.uid.toLowerCase(); + if (seen.has(uid)) throw new ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.'); + seen.add(uid); items.push({ uid, name: row.name.trim(), isProduction: row.is_production === true }); + } + offset += rows.length; + if (offset === count) return items; + if (!rows.length) throw new ToolingError('API_ERROR', 'Environment pagination stopped before completion.'); + } +} diff --git a/src/shared/mcp.ts b/src/shared/mcp.ts index 3492c6d..c0f509a 100644 --- a/src/shared/mcp.ts +++ b/src/shared/mcp.ts @@ -6,21 +6,19 @@ import { ActionResult, SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { McpTransport, sendProjectMcp } from './mcpTransport'; import { codeRepositoryBranchFromResponse } from './repositoryModels'; +import { listEnvironments } from './environments'; +import type { McpConnection, McpDiscoveryPage } from './discoveryTypes'; -export type McpRequest = { backend?: string; environmentUid?: string } & ( - | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number } - | { action: 'mcp-list' } +export type McpRequest = { backend?: string; environmentUid?: string; expectedAccount?: string } & ( + | { action: 'mcp-discover'; search?: string; limit?: number; offset?: number; enrichNames?: boolean } + | { action: 'mcp-list'; readOnly?: boolean } | { action: 'mcp-environments' } | { action: 'mcp-add' | 'mcp-remove' | 'mcp-tools'; releaseUid: string } | { action: 'mcp-call'; releaseUid: string; tool: string; arguments: Record } ); /** `name` is the release's own name, `display_name` its repository's name; both are * resolved when a connection is added and stored with it, so listing needs no lookup. */ -type Connection = { - backend: string; resource_release_uid: string; name?: string; display_name?: string; code_repository_branch_uid?: string; - mcp_connection: { url: string; transport: 'streamable-http'; resource_metadata_url: string; - organization_environment_uid: string; organization_environment_name?: string; active_revision_uid: string }; -}; +type Connection = McpConnection; const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function uuid(value: unknown): string { @@ -74,7 +72,7 @@ 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; + connectionsPath?: string; env?: NodeJS.ProcessEnv; fetch?: typeof fetch; signal?: AbortSignal; requestTimeoutMs?: number; checkAccount?: () => Promise; } = {}) { this.file = options.connectionsPath ?? storagePath(options.env ?? process.env); } private sanitize(value: any): any { @@ -100,6 +98,7 @@ export class McpConnections { } private async update(change: (rows: Connection[]) => Connection[]): Promise { this.options.signal?.throwIfAborted(); + await this.options.checkAccount?.(); await fs.mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 }); const lock = this.file + '.lock', temporary = this.file + '.' + randomUUID() + '.tmp'; let handle; @@ -137,7 +136,12 @@ 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(message => sendProjectMcp(value.mcp_connection.url, grant.token, message, this.options)); + 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; + }); await client.initialize(); return client; } @@ -166,6 +170,29 @@ export class McpConnections { private labelled(value: Connection, found: Labels): Connection { return connection(value.backend, { ...value, ...found }); } + /** Optional discovery labels, deduplicated by branch. Never writes connection metadata. */ + private async discoveryNames(rows: Connection[]): Promise { + const pending = [...new Set(rows.filter(row => !row.display_name).flatMap(row => row.code_repository_branch_uid ? [row.code_repository_branch_uid] : []))]; + const names = new Map(); + await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { + while (pending.length) { + this.options.signal?.throwIfAborted(); + const uid = pending.shift()!; + try { + const raw = await this.session.request(rows[0].backend, 'GET', '/api/v1/code-repository-branches/' + uid + '/'); + const branch = codeRepositoryBranchFromResponse(raw, 'CodeRepositoryBranch response'); + if (branch.uid.toLowerCase() === uid) names.set(uid, branch.code_repository_name); + } catch (error) { + this.options.signal?.throwIfAborted(); + if (error instanceof ToolingError && error.code === 'CANCELLED') throw error; + await this.options.checkAccount?.(); + // Unavailable optional names must not hide usable endpoints. + } + } + })); + return rows.map(row => !row.display_name && row.code_repository_branch_uid && names.has(row.code_repository_branch_uid) + ? this.labelled(row, { display_name: names.get(row.code_repository_branch_uid) }) : row); + } /** Records saved before labels were stored are completed once, when the backend answers. */ private async backfill(rows: Connection[]): Promise { const pending = rows.filter(row => !row.display_name); @@ -195,6 +222,10 @@ export class McpConnections { try { const backend = requireBackend(inputBackend); this.options.signal?.throwIfAborted(); + 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.'); + } const environmentUid = request.environmentUid === undefined ? undefined : uuid(request.environmentUid); const belongs = (value: Connection) => !environmentUid || value.mcp_connection.organization_environment_uid === environmentUid; const requireEnvironment = (value: Connection) => { @@ -202,24 +233,8 @@ export class McpConnections { }; let data: unknown, summary: string; if (request.action === 'mcp-environments') { - const environments = new Map(); - for (let offset = 0; ; offset += 100) { - this.options.signal?.throwIfAborted(); - if (offset >= 10000) throw new ToolingError('API_ERROR', 'The environment list is too large.'); - const page = await this.session.request(backend, 'GET', `/api/v1/organization-environments/?limit=100&offset=${offset}`); - const items = Array.isArray(page) ? page : page?.results; - if (!Array.isArray(items)) throw new ToolingError('API_ERROR', 'The backend returned an invalid environment list.'); - for (const row of items) { - if (typeof row?.name !== 'string' || !row.name.trim()) throw new ToolingError('API_ERROR', 'An organization environment has no name.'); - const uid = uuid(row.uid); - environments.set(uid, { uid, name: row.name.trim(), isProduction: row.is_production === true }); - } - if (Array.isArray(page)) break; - if (!Number.isInteger(page.count) || page.count < 0) throw new ToolingError('API_ERROR', 'The environment pagination is invalid.'); - if (offset + 100 >= page.count) break; - if (!items.length) throw new ToolingError('API_ERROR', 'The environment directory changed; refresh it.'); - } - data = [...environments.values()]; summary = 'Organization environments available to your MainSequence account.'; + data = await listEnvironments(this.session, backend, this.options.signal); + summary = 'Organization environments available to your MainSequence account.'; } else if (request.action === 'mcp-discover') { if ((request.search !== undefined && typeof request.search !== 'string') || (request.limit !== undefined && (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100)) @@ -251,9 +266,14 @@ export class McpConnections { const values = [...matching.values()], offset = request.offset ?? 0; data = { count: values.length, results: values.slice(offset, offset + (request.limit ?? 100)) }; } + if (request.enrichNames) { + const page = data as McpDiscoveryPage; + data = { ...page, results: await this.discoveryNames(page.results) }; + } summary = 'Available project MCP endpoints; discovery does not save a connection.'; } else if (request.action === 'mcp-list') { - data = await this.backfill((await this.read()).filter(row => row.backend === backend && belongs(row))); + const saved = (await this.read()).filter(row => row.backend === backend && belongs(row)); + data = request.readOnly ? saved : await this.backfill(saved); summary = 'Saved MCP connections for this backend. Availability is checked when used.'; } else { const releaseUid = uuid(request.releaseUid); @@ -292,6 +312,7 @@ export class McpConnections { } else throw new ToolingError('INVALID_REQUEST', 'Unknown MCP action.'); } } + await this.options.checkAccount?.(); 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.') diff --git a/src/shared/mcpTransport.ts b/src/shared/mcpTransport.ts index eb44cfe..dae7f3d 100644 --- a/src/shared/mcpTransport.ts +++ b/src/shared/mcpTransport.ts @@ -1,4 +1,5 @@ import { ToolingError } from './models'; +import type { McpTool } from './discoveryTypes'; /** The platform's stateless Streamable HTTP profile; no legacy SSE endpoint. */ export class McpTransport { @@ -31,17 +32,26 @@ export class McpTransport { return response.result; } - async tools(): Promise { - const tools: any[] = [], cursors = new Set(), names = new Set(); + async tools(): Promise { + const tools: McpTool[] = [], cursors = new Set(), names = new Set(); let cursor: string | undefined; do { const page = await this.request('tools/list', cursor ? { cursor } : {}); if (!Array.isArray(page?.tools)) throw new ToolingError('API_ERROR', 'The MCP tool catalog is invalid.'); for (const tool of page.tools) { - if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || names.has(tool.name)) { + if (!tool || typeof tool.name !== 'string' || !tool.name || !tool.inputSchema || typeof tool.inputSchema !== 'object' || Array.isArray(tool.inputSchema) || names.has(tool.name)) { throw new ToolingError('API_ERROR', 'The MCP tool catalog contains an invalid or duplicate tool.'); } - names.add(tool.name); tools.push(tool); + const normalized = { ...tool }; + if (typeof normalized.description !== 'string') delete normalized.description; + for (const field of ['annotations', '_meta']) if (!normalized[field] || typeof normalized[field] !== 'object' || Array.isArray(normalized[field])) delete normalized[field]; + if (normalized.annotations) { + normalized.annotations = { ...normalized.annotations }; + for (const flag of ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']) { + if (typeof normalized.annotations[flag] !== 'boolean') delete normalized.annotations[flag]; + } + } + names.add(tool.name); tools.push(normalized); } cursor = page.nextCursor; if (cursor !== undefined && (typeof cursor !== 'string' || !cursor || cursors.has(cursor))) { diff --git a/src/shared/models.ts b/src/shared/models.ts index bb404b6..2c3058c 100644 --- a/src/shared/models.ts +++ b/src/shared/models.ts @@ -49,13 +49,13 @@ export type ProjectContext = { commit: string; gitCommonDir: string; }; -export type ActionResult = { +export type ActionResult = { schemaVersion: 1; action: string; status: 'success' | 'failure' | 'cancelled'; summary: string; error?: { code: ErrorCode; message: string }; - data?: unknown; + data?: T; effects?: { state: 'partial-or-uncertain'; message: string }; }; export type ProcessSpec = { executable: string; args: string[]; cwd: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeoutMs?: number }; @@ -75,6 +75,8 @@ export interface SessionService { refresh(backend: string): Promise; logout(backend: string): Promise<{ revoked: boolean }>; 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; } /** Trusted host persistence only. Credentials never cross the action-result boundary. */ diff --git a/src/shared/organizationProjects.ts b/src/shared/organizationProjects.ts index 4062317..014c365 100644 --- a/src/shared/organizationProjects.ts +++ b/src/shared/organizationProjects.ts @@ -2,12 +2,13 @@ import { SessionService, ToolingError } from './models'; import { requireBackend } from './session'; import { codeRepositoryBranchFromResponse, CodeRepositoryBranch } from './repositoryModels'; import { RepositoryClient } from './repositoryClient'; +import { listEnvironments } from './environments'; export type OrganizationProject = { uid: string; organization_environment_uid: string; name: string; description: string; code_repository_branch_uids: string[] }; export type OrganizationProjectBranch = CodeRepositoryBranch & { organization_environment_uid: string; code_repository_type: string }; export type OrganizationProjectPage = { results: OrganizationProject[]; count: number; limit: number; offset: number }; export type OrganizationEnvironment = { uid: string; name: string }; -export type OrganizationProjectRequest = { backend?: string } & ( +export type OrganizationProjectRequest = { backend?: string; expectedAccount?: string } & ( | { action: 'organization-environments' } | { action: 'organization-projects'; environmentUid: string; search?: string; limit?: number; offset?: number } | { action: 'organization-project'; environmentUid: string; projectUid: string } @@ -51,15 +52,7 @@ export class OrganizationProjectClient { constructor(private readonly session: Pick, backend: string, private readonly signal?: AbortSignal) { this.backend = requireBackend(backend); } private async get(apiPath: string): Promise { this.signal?.throwIfAborted(); return this.session.request(this.backend, 'GET', apiPath); } async environments(): Promise { - const items: OrganizationEnvironment[] = []; - for (let offset = 0; ; ) { - const payload = pageValues(await this.get(`/api/v1/organization-environments/?limit=100&offset=${offset}`)); - const rows = payload.results.map(row => ({ uid: projectUid(object(row).uid), name: line(row.name, 'environment name') })); - if (rows.some(row => items.some(item => item.uid === row.uid))) throw new ToolingError('API_ERROR', 'Environment pagination returned duplicate rows.'); - items.push(...rows); offset += rows.length; - if (offset >= payload.count) return items; - if (!rows.length) throw new ToolingError('API_ERROR', 'Environment pagination stopped before completion.'); - } + return (await listEnvironments(this.session, this.backend, this.signal)).map(({ uid, name }) => ({ uid, name })); } async list(environment: string, options: { search?: string; limit?: number; offset?: number } = {}): Promise { const environmentUid = projectUid(environment, 'Environment UID'), limit = options.limit ?? 25, offset = options.offset ?? 0; diff --git a/src/shared/session.ts b/src/shared/session.ts index 21772d7..5873e1e 100644 --- a/src/shared/session.ts +++ b/src/shared/session.ts @@ -6,6 +6,11 @@ import * as http from 'http'; 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'; + +class AccountChangedError extends ToolingError { + constructor() { super('AUTH_REJECTED', 'The MainSequence account or session changed. Refresh the current view before continuing.'); } +} export function requireBackend(input: string): string { const backend = normalizeBackendUrl(input); @@ -57,6 +62,7 @@ export class MachineSessionService implements SessionService { private readonly env: NodeJS.ProcessEnv; private readonly renewals = new Map>(); private readonly secrets = new Set(); + private readonly verifiedAccounts = new Map(); constructor(private readonly host: HostServices, private readonly options: { store?: CredentialStore | null; persistence?: SessionPersistence; @@ -149,13 +155,15 @@ export class MachineSessionService implements SessionService { this.options.signal?.removeEventListener('abort', abort); } } - private renew(backend: string): Promise { - const running = this.renewals.get(backend); + private renew(backend: string, expectedAccount?: string): Promise { + const renewalKey = JSON.stringify([backend, expectedAccount]); + const running = this.renewals.get(renewalKey); if (running) return running; const task = (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(); let data: unknown; try { data = await this.http(backend, 'POST', '/auth/jwt-token/token/refresh/', { refresh: previous.refresh }); } catch (error) { @@ -163,11 +171,18 @@ export class MachineSessionService implements SessionService { throw error; } 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(); + } + // 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; })(); - this.renewals.set(backend, task); - void task.finally(() => this.renewals.delete(backend)).catch(() => {}); + this.renewals.set(renewalKey, task); + void task.finally(() => this.renewals.delete(renewalKey)).catch(() => {}); return task; } private async persist(backend: string, session: MachineSession): Promise { @@ -206,22 +221,82 @@ export class MachineSessionService implements SessionService { }; } async request(input: string, method: 'GET' | 'POST', apiPath: string, body?: unknown, retrySafe = method === 'GET', options?: { timeoutMs?: number }): Promise { + return this.accountRequest(input, method, apiPath, body, retrySafe, options); + } + private accountChanged(): ToolingError { + return new AccountChangedError(); + } + private recordAccount(backend: string, session: MachineSession, profile: any, expectedAccount: string): void { + const username = profile?.user?.username || profile?.username; + if (username !== expectedAccount) { + this.verifiedAccounts.delete(JSON.stringify([backend, session.access])); + throw this.accountChanged(); + } + if (this.verifiedAccounts.size >= 32) this.verifiedAccounts.delete(this.verifiedAccounts.keys().next().value!); + this.verifiedAccounts.set(JSON.stringify([backend, session.access]), username); + } + private async verifyAccount(backend: string, session: MachineSession, expectedAccount: string): Promise { + if (this.verifiedAccounts.get(JSON.stringify([backend, session.access])) === expectedAccount) return; + this.recordAccount(backend, session, await this.http(backend, 'GET', '/api/v1/users/me/', undefined, session.access), expectedAccount); + } + /** The same selected credential is verified and used. A 401 retry must verify its new credential too. */ + async withAccount(input: string, expectedAccount: string): Promise { + const backend = requireBackend(input); + validateExpectedAccount(expectedAccount); + const target = (value: string) => { if (requireBackend(value) !== backend) throw new ToolingError('BACKEND_MISMATCH', 'The request belongs to another backend.'); }; + const unsupported = async (): Promise => { throw new ToolingError('UNSUPPORTED_ACTION', 'Account-bound operations cannot change authentication.'); }; + const scoped: SessionService & { sanitize(text: string): string } = { + request: (value, method, apiPath, body, retrySafe = method === 'GET', options) => { + target(value); return this.accountRequest(backend, method, apiPath, body, retrySafe, options, expectedAccount); + }, + status: async value => { + target(value); + 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 }; + }, + login: unsupported, logout: unsupported, refresh: unsupported, + sanitize: text => this.sanitize(text), + }; + await scoped.status(backend, true); + return scoped; + } + private async accountRequest(input: string, method: 'GET' | 'POST', apiPath: string, body: unknown, retrySafe: boolean, options?: { timeoutMs?: number }, expectedAccount?: string): Promise { if (options?.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 300000)) throw new ToolingError('INVALID_REQUEST', 'Request timeout must be between 1 and 300000 milliseconds.'); 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); + if (source === 'machine') session = await this.renew(backend, expectedAccount); else if (!session.access) throw new ToolingError('NOT_AUTHENTICATED', 'The explicit environment has no access token.'); } - try { return await this.http(backend, method, apiPath, body, session.access, options?.timeoutMs); } + const send = async (selected: MachineSession): Promise => { + const checkSelected = async () => { + if (expectedAccount === undefined) return; + const current = await this.selected(backend); + if (current.source !== source || current.session.access !== selected.access || current.session.refresh !== selected.refresh) throw this.accountChanged(); + }; + if (expectedAccount !== undefined && apiPath !== '/api/v1/users/me/') await this.verifyAccount(backend, selected, expectedAccount); + await checkSelected(); + const value = await this.http(backend, method, apiPath, body, selected.access, options?.timeoutMs); + if (expectedAccount !== undefined && apiPath === '/api/v1/users/me/') this.recordAccount(backend, selected, value, expectedAccount); + await checkSelected(); + return value; + }; + try { return await send(session); } catch (error) { if (!(error instanceof ToolingError) || error.code !== 'AUTH_REJECTED') throw error; + // Scope failures are not expired-token failures and must never trigger a replay. + if (error instanceof AccountChangedError) throw error; 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); - try { return await this.http(backend, method, apiPath, body, retry.access, options?.timeoutMs); } + const retry = latest?.access && latest.access !== session.access ? latest : await this.renew(backend, expectedAccount); + try { return await send(retry); } catch (retryError) { + if (retryError instanceof AccountChangedError) throw retryError; if (retryError instanceof ToolingError && retryError.code === 'AUTH_REJECTED') throw refusedCredentials('machine', backend, retry.refresh || retry.access); throw retryError; } diff --git a/tests/discovery-types.test.cjs b/tests/discovery-types.test.cjs new file mode 100644 index 0000000..8252306 --- /dev/null +++ b/tests/discovery-types.test.cjs @@ -0,0 +1,36 @@ +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 { spawnSync } = require('node:child_process'); + +test('public dispatcher infers read payloads and preserves legacy ActionResult compatibility', async t => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ms-discovery-types-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const entry = path.resolve('dist/index').replaceAll('\\', '/'); + const source = `import { createDevTools, createNodeHost, ActionResult, McpConnection, McpTool } from ${JSON.stringify(entry)}; +const api = createDevTools({ host: createNodeHost() }); +async function read() { + const state = await api.dispatch({action:'auth-status'}); + const username: string | undefined = state.data?.username; + const saved = await api.dispatch({action:'mcp-list', readOnly:true, expectedAccount:'alice'}); + const connections: McpConnection[] | undefined = saved.data; + const catalog = await api.dispatch({action:'mcp-tools', releaseUid:'id', expectedAccount:'alice'}); + const tools: McpTool[] | undefined = catalog.data?.tools; + const environments = await api.dispatch({action:'mcp-environments'}); + const production: boolean | undefined = environments.data?.[0].isProduction; + const agents = await api.dispatch({action:'agents-list',environmentUid:'id'}); + const name: string | undefined = agents.data?.results[0].name; + const legacy: ActionResult = saved; + // @ts-expect-error The result of mcp-list is connections, not a tool catalog. + saved.data?.tools; + // @ts-expect-error Read-only listing must use a boolean. + api.dispatch({action:'mcp-list',readOnly:'yes'}); + return {username,connections,tools,production,name,legacy}; +} +void read;`; + const file = path.join(dir, 'consumer.ts'); await fs.writeFile(file, source); + const result = spawnSync(process.execPath, [require.resolve('typescript/lib/tsc.js'), '--strict', '--noEmit', '--skipLibCheck', '--target', 'ES2022', '--module', 'commonjs', file], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stdout + result.stderr); +}); diff --git a/tests/discovery.test.cjs b/tests/discovery.test.cjs new file mode 100644 index 0000000..bdb48cd --- /dev/null +++ b/tests/discovery.test.cjs @@ -0,0 +1,147 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createDevTools, createNodeHost, createSessionService, listEnvironments, bindSessionAccount } = require('../dist'); +const backend = 'https://platform.example.test'; +const uid = n => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +const environment = n => ({ uid: uid(n), name: `Environment ${n}`, is_production: n === 2 }); + +test('both environment actions share short-page pagination and retain compatible projections', async () => { + for (const action of ['mcp-environments', 'organization-environments']) { + const offsets = []; + const session = { request: async (url, method, apiPath) => { + assert.equal(url, backend); assert.equal(method, 'GET'); + const offset = Number(new URL(apiPath, backend).searchParams.get('offset')); offsets.push(offset); + return { count: 3, results: offset === 0 ? [environment(1), environment(2)] : [environment(3)] }; + } }; + const result = await createDevTools({ host: createNodeHost(), session, env: {} }).dispatch({ action, backend }); + assert.equal(result.status, 'success'); assert.deepEqual(offsets, [0, 2]); + assert.deepEqual(result.data, [1, 2, 3].map(n => ({ uid: uid(n), name: `Environment ${n}`, ...(action === 'mcp-environments' ? { isProduction: n === 2 } : {}) }))); + } +}); + +test('environment directory supports complete arrays and empty pages, rejects inconsistent/duplicate/unbounded pages', async () => { + assert.deepEqual(await listEnvironments({ request: async () => [] }, backend), []); + assert.equal((await listEnvironments({ request: async () => [environment(2)] }, backend))[0].isProduction, true); + const invalid = [ + [{ count: 1, results: [] }], [{ count: -1, results: [] }], [{ count: 10001, results: [] }], + [{ count: 1, results: [environment(1), environment(2)] }], + [{ count: 2, results: [environment(1), environment(1)] }], + [{ count: 2, results: [environment(1)] }, { count: 2, results: [environment(1)] }], + [{ count: 2, results: [environment(1)] }, { count: 3, results: [environment(2)] }], + [{ count: 1, results: [{ uid: 'bad', name: 'Bad' }] }], + [{ count: 1, results: [{ ...environment(1), name: '\n' }] }], + ]; + for (const pages of invalid) await assert.rejects(listEnvironments({ request: async () => pages.shift() }, backend), { code: 'API_ERROR' }); + const controller = new AbortController(); controller.abort(); + await assert.rejects(listEnvironments({ request: async () => { throw new Error('must not fetch'); } }, backend, controller.signal), { name: 'AbortError' }); +}); + +function sessions(options = {}) { + let saved = { username: 'alice', access: 'alice-access', refresh: 'alice-refresh' }; + const calls = [], writes = []; + const state = { switchOn: '', retry: '', profileUnavailable: false }; + const session = createSessionService(createNodeHost(), { env: {}, signal: options.signal, + persistence: { mode: 'machine', name: 'test', read: async () => saved && { ...saved }, + write: async (_backend, value) => { writes.push(value); saved = { ...value }; }, remove: async () => { saved = null; } }, + fetch: async (url, init) => { + const path = new URL(url).pathname, access = init.headers.Authorization?.replace('Bearer ', ''); + calls.push({ path, access }); + const reply = data => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + if (path === '/auth/jwt-token/token/refresh/') { + if (state.switchOn === 'refresh') saved = { username: 'bob', access: 'bob-access', refresh: 'bob-refresh' }; + return reply({ access: 'alice-new', refresh: 'alice-rotated' }); + } + if (path === '/api/v1/users/me/') { + if (state.profileUnavailable) return new Response('{}', { status: 503 }); + if (state.switchOn === 'verify') saved = { username: 'bob', access: 'bob-access', refresh: 'bob-refresh' }; + return reply({ username: access.startsWith('bob') ? 'bob' : 'alice' }); + } + if (state.retry && access === 'alice-access') { + const user = state.retry; + saved = { username: user, access: user + '-new', refresh: user + '-rotated' }; + return new Response('{}', { status: 401 }); + } + if (state.switchOn === 'read') saved = { username: 'bob', access: 'bob-access', refresh: 'bob-refresh' }; + return reply({ count: 1, results: [environment(1)] }); + } }); + return { session, calls, writes, state, change: value => { saved = value; }, saved: () => saved }; +} + +test('account scope verifies actual credentials, rejects cross-backend calls and custom transports without support', async () => { + const f = sessions(); + await assert.rejects(bindSessionAccount(f.session, backend, 'bob'), { code: 'AUTH_REJECTED' }); + assert.ok(f.calls.every(c => c.path === '/api/v1/users/me/')); + const scoped = await bindSessionAccount(f.session, backend, 'alice'); + await assert.rejects(async () => scoped.request('https://other.test', 'GET', '/api/v1/test/'), { code: 'BACKEND_MISMATCH' }); + await assert.rejects(scoped.logout(backend), { code: 'UNSUPPORTED_ACTION' }); + await assert.rejects(bindSessionAccount({}, backend, 'alice'), { code: 'UNSUPPORTED_ACTION' }); + await assert.rejects(bindSessionAccount(f.session, backend, ''), { code: 'INVALID_REQUEST' }); + assert.deepEqual(f.writes, []); + f.change({ username: 'outdated-display-name', access: 'alice-access', refresh: 'alice-refresh' }); + const current = await bindSessionAccount(f.session, backend, 'alice'); + assert.equal((await current.status(backend)).username, 'alice', 'verified identity wins over cached metadata'); +}); + +test('account changes during identity verification refuse the domain request; changes during a read discard its result', async () => { + for (const point of ['verify', 'read']) { + const f = sessions(); + if (point === 'verify') f.state.switchOn = point; + const api = createDevTools({ host: createNodeHost(), session: f.session, env: {} }); + if (point === 'read') f.state.switchOn = point; + const result = await api.dispatch({ action: 'mcp-environments', backend, expectedAccount: 'alice' }); + assert.equal(result.status, 'failure'); assert.equal(result.error.code, 'AUTH_REJECTED'); assert.equal(result.data, undefined); + if (point === 'verify') assert.ok(f.calls.every(c => c.path === '/api/v1/users/me/')); + assert.ok(f.calls.filter(c => c.path !== '/api/v1/users/me/').every(c => c.access === 'alice-access')); + assert.deepEqual(f.writes, []); + } +}); + +test('401 retry accepts a new token for the same account and refuses a different account without replaying the domain request', async () => { + for (const next of ['alice', 'bob']) { + const f = sessions(), scoped = await bindSessionAccount(f.session, backend, 'alice'); f.state.retry = next; + const request = scoped.request(backend, 'GET', '/api/v1/organization-environments/'); + if (next === 'alice') assert.equal((await request).count, 1); + else await assert.rejects(request, { code: 'AUTH_REJECTED' }); + const reads = f.calls.filter(c => c.path === '/api/v1/organization-environments/'); + assert.deepEqual(reads.map(c => c.access), next === 'alice' ? ['alice-access', 'alice-new'] : ['alice-access']); + assert.deepEqual(f.writes, []); + } +}); + +test('scoped renewal preserves an account saved by another process and can renew the same account normally', async () => { + for (const changed of [false, true]) { + const f = sessions(); + const expired = 'e30.' + Buffer.from(JSON.stringify({ exp: 1 })).toString('base64url') + '.sig'; + f.change({ username: 'alice', access: expired, refresh: 'alice-refresh' }); + if (changed) f.state.switchOn = 'refresh'; + if (changed) { + await assert.rejects(bindSessionAccount(f.session, backend, 'alice'), { code: 'AUTH_REJECTED' }); + assert.equal(f.saved().username, 'bob'); assert.deepEqual(f.writes, []); + } else { + const scoped = await bindSessionAccount(f.session, backend, 'alice'); + assert.equal((await scoped.status(backend)).username, 'alice'); assert.equal(f.writes.length, 1); + } + } +}); + +test('project and MCP reads both enforce the optional account precondition; cancellation never returns a directory', async () => { + for (const action of ['mcp-environments', 'organization-environments']) { + const f = sessions(); + const api = createDevTools({ host: createNodeHost(), session: f.session, env: {} }); + assert.equal((await api.dispatch({ action, backend, expectedAccount: 'bob' })).error.code, 'AUTH_REJECTED'); + assert.ok(f.calls.every(c => c.path === '/api/v1/users/me/')); + } + const controller = new AbortController(), f = sessions({ signal: controller.signal }); controller.abort(); + const api = createDevTools({ host: createNodeHost(), session: f.session, env: {}, signal: controller.signal }); + assert.equal((await api.dispatch({ action: 'mcp-environments', backend, expectedAccount: 'alice' })).status, 'cancelled'); + assert.deepEqual(f.calls, []); +}); + +test('a rotated refresh pair is kept when profile verification is unavailable; no domain request runs', async () => { + const f = sessions(); + f.change({ username: 'alice', access: '', refresh: 'alice-refresh' }); f.state.profileUnavailable = true; + await assert.rejects(bindSessionAccount(f.session, backend, 'alice'), { code: 'API_ERROR' }); + assert.equal(f.saved().access, 'alice-new'); assert.equal(f.saved().refresh, 'alice-rotated'); + 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/')); +}); diff --git a/tests/mcp.test.cjs b/tests/mcp.test.cjs index 209b8a1..fa8c2b5 100644 --- a/tests/mcp.test.cjs +++ b/tests/mcp.test.cjs @@ -405,3 +405,44 @@ test('plain HTTP off-loopback, URL credentials and endpoint query strings are re assert.equal(f.state.exchanged, 0); assert.equal((await f.dispatch('mcp-list')).data.length, 0); }); + +test('read-only saved listing preserves legacy bytes without label requests, locks or connection changes', async t => { + const f = await fixture(t); + const legacy = { ...f.row(), backend: f.backend, resource_release_uid: f.uid }; + delete legacy.name; delete legacy.uid; + const foreign = { ...legacy, backend: 'https://elsewhere.test' }; + const bytes = JSON.stringify([legacy, foreign], null, 4) + '\n\n'; + await fs.writeFile(f.file, bytes); await fs.writeFile(f.file + '.lock', 'other process'); + const before = f.calls.length; + const result = await f.dispatch('mcp-list', { readOnly: true, environmentUid: legacy.mcp_connection.organization_environment_uid }); + assert.equal(result.status, 'success'); assert.equal(result.data.length, 1); assert.equal(result.data[0].name, undefined); + assert.equal(f.calls.length, before); assert.equal(await fs.readFile(f.file, 'utf8'), bytes); + assert.equal(await fs.readFile(f.file + '.lock', 'utf8'), 'other process'); + assert.equal((await f.dispatch('mcp-list', { readOnly: 'yes' })).error.code, 'INVALID_REQUEST'); + assert.equal((await f.dispatch('mcp-list', { readOnly: true, environmentUid: f.secondUid })).data.length, 0); +}); + +test('read-only saved listing retains corrupt files and does not create missing stores', async t => { + const f = await fixture(t); + assert.deepEqual((await f.dispatch('mcp-list', { readOnly: true })).data, []); + await assert.rejects(fs.stat(f.file), { code: 'ENOENT' }); + await fs.writeFile(f.file, '{corrupt'); + const result = await f.dispatch('mcp-list', { readOnly: true }); + assert.equal(result.error.code, 'STORE_ERROR'); assert.equal(await fs.readFile(f.file, 'utf8'), '{corrupt'); + assert.deepEqual(f.calls, []); +}); + +test('optional discovery enrichment resolves shared branches once, preserves names, and never saves connections', async t => { + const f = await fixture(t); + const plain = await f.dispatch('mcp-discover'); + assert.equal(plain.data.results[0].display_name, undefined); + const enriched = await f.dispatch('mcp-discover', { enrichNames: true }); + assert.equal(enriched.status, 'success'); assert.ok(enriched.data.results.every(row => row.display_name === 'MetaTablesApi' && row.name === 'metatable-mcp')); + assert.equal(f.calls.filter(c => c.path === '/api/v1/code-repository-branches/' + f.branchUid + '/').length, 1); + await assert.rejects(fs.stat(f.file), { code: 'ENOENT' }); + f.state.branchDenied = true; + const unavailable = await f.dispatch('mcp-discover', { enrichNames: true }); + assert.equal(unavailable.status, 'success'); assert.equal(unavailable.data.count, 2); assert.equal(unavailable.data.results[0].display_name, undefined); + assert.equal((await f.dispatch('mcp-discover', { enrichNames: 'yes' })).error.code, 'INVALID_REQUEST'); + assert.equal(f.state.invoked, 0); assert.equal(f.state.exchanged, 0); +});