UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

200 lines • 7.91 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { MAX_PAGES, TIMEOUT } from "../core/mcp/constants.js"; import { abortable } from "../utils/abort.js"; import { VERSION } from "../version.js"; import { Accessor } from "./base.js"; /** Where a failed MCP session end or client close is reported; silent until a host sets `debug`. */ export const logger = {}; /** * Whether the server read the request and refused it, which says nothing * against the connection: a JSON-RPC error outside the codes the SDK spends * on transport, timeout and session faults. Mirrors Python's `answered`. */ export async function answered(error) { const { INVALID_PARAMS, METHOD_NOT_FOUND, ProtocolError } = await import('@modelcontextprotocol/client'); if (!(error instanceof ProtocolError)) return false; const code = error.code; return code === INVALID_PARAMS || code === METHOD_NOT_FOUND || code < -32768 || code > -32000; } /** Whether the SDK gave up waiting for the server's answer. Mirrors Python's `timed_out`. */ export async function timedOut(error) { const { SdkError, SdkErrorCode } = await import('@modelcontextprotocol/client'); return error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout; } /** * A streamable-HTTP MCP server, one client kept per install. * * The client opens on first use and stays open until the CLI is removed. A * call that fails drops it, unless the server answered it with an error: the * next call reopens one, and the dropped one closes once the calls running on * it end. A cancelled call leaves it open. Unlike Python, every caller shares it: each request's * fetch goes through its caller's pool, so its rule and policies judge it. * The SDK loads on the first request, so building or restoring an MCP CLI * never imports it. */ export class MCPAccessor extends Accessor { config; opening = null; calls = new Map(); dropped = new Set(); closing = new Set(); constructor(config) { super(); this.config = config; } /** The one header `auth` asks for; none without auth. */ headers() { const auth = this.config.auth; if (auth === undefined || auth === null) return {}; if (auth.type === 'bearer') return { Authorization: `Bearer ${auth.token}` }; return { [auth.name ?? '']: auth.token }; } /** An initialized client, which the caller closes. */ async connect(signal) { const { Client, StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); const transport = new StreamableHTTPClientTransport(new URL(this.config.url), { requestInit: { headers: this.headers() }, }); const client = new Client({ name: 'mirage', version: VERSION }, { listMaxPages: MAX_PAGES }); try { await client.connect(transport, { timeout: TIMEOUT * 1000, ...(signal ? { signal } : {}) }); } catch (error) { await client.close(); throw error; } return client; } /** * End the server's MCP session, waiting at most the call timeout, then close * the client, which aborts a session end still in flight. */ async shut(client) { let timer; try { await Promise.race([ client.transport?.terminateSession(), new Promise((_, reject) => { timer = setTimeout(() => { reject(new Error('the server did not end the session in time')); }, TIMEOUT * 1000); }), ]); } catch (error) { logger.debug?.(`MCP session end failed: ${String(error)}`); } finally { clearTimeout(timer); } await client.close(); } /** Shut `opening` in the background, kept in `closing` so `close` joins it. */ shutting(opening) { const done = opening .then((client) => this.shut(client)) .catch((error) => { logger.debug?.(`MCP client close failed: ${String(error)}`); }) .finally(() => { this.closing.delete(done); }); this.closing.add(done); return done; } /** Run `use` on an initialized client, closed when it settles. */ async session(use, signal) { const client = await this.connect(signal); try { return await use(client); } finally { await this.shut(client); } } /** The kept client's opening, started when there is none. */ kept() { if (this.opening === null) { const opening = this.connect(); this.opening = opening; opening.catch(() => { if (this.opening === opening) this.opening = null; }); } return this.opening; } /** Stop handing out `opening`; it closes once the calls running on it end. */ drop(opening) { if (this.opening === opening) this.opening = null; this.dropped.add(opening); } /** * Count a call off `opening`, and start closing it if dropped and that was * its last; the call does not wait for the close, which may hang. */ release(opening) { const left = (this.calls.get(opening) ?? 1) - 1; if (left > 0) { this.calls.set(opening, left); return; } this.calls.delete(opening); if (this.dropped.delete(opening)) void this.shutting(opening); } /** The server's title and every tool it lists; the SDK reads every page. */ async listTools() { return this.session(async (client) => { const { tools } = await client.listTools({}, { timeout: TIMEOUT * 1000 }); const info = client.getServerVersion(); if (info === undefined) return ['', tools]; return [info.title === undefined || info.title === '' ? info.name : info.title, tools]; }); } /** Call one tool on the kept client, abandoned when `signal` aborts. */ async callTool(name, args, signal) { const opening = this.kept(); this.calls.set(opening, (this.calls.get(opening) ?? 0) + 1); try { const client = await abortable(opening, signal); return await client.callTool({ name, arguments: args }, { timeout: TIMEOUT * 1000, ...(signal ? { signal } : {}) }); } catch (error) { if (signal?.aborted !== true && !(await answered(error))) this.drop(opening); throw error; } finally { this.release(opening); } } /** Close the kept client and any dropped one. */ async close() { const openings = [...(this.opening === null ? [] : [this.opening]), ...this.dropped]; this.opening = null; this.dropped.clear(); for (const opening of openings) void this.shutting(opening); while (this.closing.size > 0) await Promise.allSettled([...this.closing]); } } //# sourceMappingURL=mcp.js.map