mcporter/tests/cli-call-execution.test.ts
Sebastian B Otaegui 3e27b64021
Some checks failed
CI / build (${{ matrix.os }}) (ubuntu-latest) (push) Has been cancelled
CI / build (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / build (${{ matrix.os }}) (windows-latest) (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
fix(runtime): preserve disableOAuth across headless paths (#198)
* feat(runtime): add `disableOAuth` connect option (cache-friendly OAuth suppression)

Closes #197.

Long-running headless callers (daemons, scheduled jobs, CI workers) need
to suppress the interactive OAuth flow without losing connection caching.
The only existing knob — `maxOAuthAttempts: 0` — couples those two concerns
because `useCache` is gated on `options.maxOAuthAttempts === undefined`.
Daemons that wrap `connect` to force `maxOAuthAttempts: 0` end up spawning
a fresh transport per `callTool`/`listTools` and `runtime.close()` cannot
reap any of them.

Add an additive `disableOAuth: boolean` option that suppresses OAuth at
the transport layer (short-circuits `shouldEstablishOAuth` and
`maybePromoteHttpDefinition`) but preserves caching. The cache entry
metadata gains a `disableOAuth` field so connections established with
the flag don't share a slot with connections that could refresh into an
OAuth flow — switching the flag between calls evicts and re-establishes,
mirroring the existing `allowCachedAuth` mismatch path.

Backward compatibility:

* `maxOAuthAttempts: 0` keeps its legacy escape-the-cache contract
  unchanged. Existing callers see no behavior change.
* `skipCache: true` keeps its behavior unchanged.
* `disableOAuth` defaults to undefined; only opt-in changes behavior.

Also export `ConnectOptions` from `runtime.ts` and add the parameter to
the `Runtime.connect` interface signature — the implementation already
accepted options at runtime but the interface only exposed
`connect(server)`, so callers couldn't pass options through the type
system. (Pre-existing gap surfaced by adding the new test coverage.)

Tests added to `tests/runtime-integration.test.ts`:

* `reuses cached connection when disableOAuth: true is passed` — two
  calls return the same ClientContext, `close()` reaps it.
* `maxOAuthAttempts: 0 still bypasses the cache (existing contract
  preserved)` — regression guard.
* `evicts and re-establishes the cached client when disableOAuth flag
  changes` — the core eviction semantic.

`pnpm test` (709 pass / 3 skip), `pnpm lint`, `pnpm typecheck` all
green.

* fix(runtime): preserve disableOAuth across helper calls

* fix(daemon): forward disableOAuth through keep-alive paths

* feat(cli): expose disableOAuth for headless commands

* fix(runtime): preserve cached slot across connect(disableOAuth) → callTool/listTools

Addresses PR #198 review comment r3366238654.

The documented headless setup is:

    await runtime.connect(server, { disableOAuth: true });
    await runtime.callTool(server, 'foo', { ... });

The first call stored the cache slot with `allowCachedAuth: undefined`,
but `callTool()` internally calls `this.connect(server, {
allowCachedAuth: true, disableOAuth: <effective>: true })` and the
cache-match check treated the two options shapes as structurally
different:

    existing.allowCachedAuth (undefined)
       !== options.allowCachedAuth (true)
       && options.allowCachedAuth !== undefined
    => MISMATCH => evict + reopen transport

Every first callTool / listTools after a pre-connect spawned a fresh
transport, defeating the pooling guarantee that motivated the
disableOAuth option in the first place. Same shape affected `listTools`
(which defaults `allowCachedAuth: options.allowCachedAuth ?? true`).

Fix: normalize at the connect() entrypoint. A `disableOAuth: true`
caller has no path to interactive OAuth, so cached-token application
is the only auth they can ever use — default `allowCachedAuth: true`
when the caller didn't pick a side. Explicit `false` is honored
(header-only / anonymous callers). The normalized value flows through
both the cache lookup and the cache write so subsequent internal
callers compose without eviction.

Two regression tests added to `tests/runtime-integration.test.ts`:

  - `preserves the cached client across connect(disableOAuth:true) →
    callTool() (no implicit eviction)`
  - `preserves the cached client across connect(disableOAuth:true) →
    listTools() (no implicit eviction)`

Both call `runtime.connect(disableOAuth:true)`, then invoke the
internal-cached path (callTool or listTools), then re-call
`runtime.connect(disableOAuth:true)` and assert the resulting
ClientContext is `=== ` the first one. Both tests fail without this
fix (the second connect returns a new ClientContext because the first
was evicted).

`pnpm test` 723 pass / 3 skip / 0 fail. `pnpm lint` + `pnpm
typecheck` clean. No push.

* docs(examples): add headless-pooling-demo for disableOAuth verification

Demonstrates the three patterns under the new `disableOAuth` option
against a local mock MCP server (no real auth). Reproducible artifact
for PR #198 review proof.

Patterns demonstrated:

* Legacy `maxOAuthAttempts: 0` (uncached): 5 connect() calls produce
  5 distinct ClientContexts. Existing contract preserved.
* `disableOAuth: true` on every connect: 5 calls produce 1
  ClientContext. Cache reuse under cache-friendly suppression.
* Documented headless setup — pre-connect(disableOAuth: true) +
  5 callTool() — proves the pre-connected slot survives the implicit
  internal connect path. Directly demonstrates the fix from b0e3e2e.

Run: `pnpm tsx examples/headless-pooling-demo.ts`

Sample output is intentionally redacted to no PII / no secrets: a local
http://127.0.0.1:<random-port>/mcp server with a public `add` tool.

* style(examples): oxfmt headless-pooling-demo (CI fix)

* fix(server-proxy): thread disableOAuth through schema-discovery listTools

Addresses PR #198 review comment r3366307210 (clawsweeper proxy gap).

The Proxy returned by `createServerProxy` calls `ensureMetadata()` on
every tool invocation, which fires `runtime.listTools(server, {
includeSchema: true })` for schema discovery. That call ran BEFORE the
proxy parsed the caller's options bag, so a `proxy.tool({ ... }, {
disableOAuth: true })` invocation on an OAuth server with no cached
schema could still trigger an interactive OAuth flow during metadata
fetch — defeating the no-browser guarantee the option was meant to
provide.

Fix:

* Pre-scan callArgs once for `disableOAuth: true` before invoking
  `ensureMetadata`. The scan is a single linear pass over the
  already-present argument list and short-circuits on the first match.
* Extend `ensureMetadata(toolName, { disableOAuth? })` and forward the
  flag to the underlying `runtime.listTools(serverName, { includeSchema:
  true, disableOAuth: true })` call.
* The schema-fetch path that was vulnerable now inherits the same
  no-OAuth posture as the eventual `runtime.callTool` invocation. End-
  to-end no-browser guarantee is preserved across the proxy interface.

Regression test in `tests/server-proxy.test.ts`:

  > threads disableOAuth through schema discovery so
  > proxy.tool({disableOAuth:true}) cannot trigger OAuth during
  > metadata fetch

Asserts BOTH:
- `runtime.listTools` called with `{ includeSchema: true, disableOAuth:
  true }`
- `runtime.callTool` called with the eventual tool args and
  `disableOAuth: true`

Locks the contract on both halves so a future refactor that re-introduces
the gap on either side will fail loudly.

Full suite: 724 pass / 3 skipped / 0 fail. `pnpm check` (format + lint
+ typecheck) clean.

* refactor(cli): drop --disable-oauth alias; keep only --no-oauth

The PR originally exposed two CLI names for the same intent:
--disable-oauth (mirroring the JS option `disableOAuth: true`) and
--no-oauth (the GNU-style boolean opt-out). Two names for one
behavior is noise — documentation has to mention both, users have to
learn both, and they invite drift.

--no-oauth is the right shape for a per-invocation boolean opt-out:
- Matches the dominant unix convention (git --no-verify, npm --no-save,
  bun --no-cache, curl --no-progress-meter).
- Shorter to type.
- Composes naturally with other flags in scripts.

The JS option name stays `disableOAuth: boolean` — that's the right
shape for a JS option (verb+noun, no Boolean-negation prefix
ambiguity), and the JS and CLI naming conventions are genuinely
different domains.

Removed CLI registrations + help text + internal forwarding for
--disable-oauth across:
- src/cli/call-arguments.ts (FLAG_HANDLERS registration)
- src/cli/call-command.ts (internal listArgs forwarding, 2 sites)
- src/cli/call-help.ts (help text)
- src/cli/list-command.ts (help text)
- src/cli/list-flags.ts (token check)
- src/cli/resource-command.ts (token check + help text)
- docs/cli-reference.md (3 references)

Renamed test cases that exclusively exercised --disable-oauth to
exercise --no-oauth instead, preserving regression coverage:
- tests/call-arguments.test.ts
- tests/cli-list-flags.test.ts
- tests/cli-resource-command.test.ts

The internal cache-key fragment `disable-oauth:` in
src/cli/tool-cache.ts is kept — it mirrors the JS option name (which
stays `disableOAuth`), not the CLI flag.

Tests: 724 passed, 3 skipped, 0 failed.
Lint: 0 warnings, 0 errors.
Typecheck: clean.

* fix(runtime): forward disableOAuth through callOnce

* chore: update dependencies

* fix(server-proxy): preserve schema-owned option fields

* fix(runtime): isolate OAuth cache variants safely

* fix(server-proxy): isolate schema discovery posture

* fix(server-proxy): preserve OAuth posture during discovery

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-08 16:11:23 -07:00

483 lines
17 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { resolveEphemeralServer } from '../src/cli/adhoc-server.js';
import type { ServerDefinition } from '../src/config.js';
process.env.MCPORTER_DISABLE_AUTORUN = '1';
const cliModulePromise = import('../src/cli.js');
describe('CLI call execution behavior', () => {
it('auto-selects the sole tool when omitted', async () => {
const toolName = 'list_issues';
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub(
{
linear: [
{
name: toolName,
description: 'List issues',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number' },
},
required: [],
},
},
],
},
{
definitions: [
{
name: 'linear',
command: { kind: 'stdio', command: 'linear', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
],
}
);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['linear', 'limit=5']);
expect(callTool).toHaveBeenCalledWith('linear', toolName, expect.objectContaining({ args: { limit: 5 } }));
logSpy.mockRestore();
});
it('restores numeric-looking key=value args to schema-declared strings', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool, listTools } = createRuntimeStub({
slack: [
{
name: 'conversations_replies',
inputSchema: {
type: 'object',
properties: {
channel_id: { type: 'string' },
thread_ts: { type: 'string' },
latest: { anyOf: [{ type: 'string' }, { type: 'null' }] },
limit: { type: 'number' },
},
required: ['channel_id', 'thread_ts'],
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, [
'slack.conversations_replies',
'channel_id=C1234567890',
'thread_ts=1234567890.123456',
'latest=1234567899.987654',
'limit=1',
]);
expect(callTool).toHaveBeenCalledWith(
'slack',
'conversations_replies',
expect.objectContaining({
args: {
channel_id: 'C1234567890',
thread_ts: '1234567890.123456',
latest: '1234567899.987654',
limit: 1,
},
})
);
expect(listTools).toHaveBeenCalledWith('slack', {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
it('wraps bare long-flag strings when the schema declares an array', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool, listTools } = createRuntimeStub({
email: [
{
name: 'send_email',
inputSchema: {
type: 'object',
properties: {
to: { type: 'array', items: { type: 'string' } },
subject: { type: 'string' },
},
required: ['to', 'subject'],
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['email.send_email', '--to', 'miguel@example.com', '--subject', 'Test']);
expect(callTool).toHaveBeenCalledWith(
'email',
'send_email',
expect.objectContaining({
args: {
to: ['miguel@example.com'],
subject: 'Test',
},
})
);
expect(listTools).toHaveBeenCalledWith('email', {
autoAuthorize: true,
includeSchema: true,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
it('does not load schemas for numeric values supplied via --args JSON', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool, listTools } = createRuntimeStub({
linear: [
{
name: 'list_issues',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number' },
},
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['linear.list_issues', '--args', '{"limit":5}']);
expect(callTool).toHaveBeenCalledWith('linear', 'list_issues', expect.objectContaining({ args: { limit: 5 } }));
expect(listTools).not.toHaveBeenCalled();
logSpy.mockRestore();
});
it('marks MCP isError tool results as process failures', async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
try {
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub({
linear: [{ name: 'explode', inputSchema: { type: 'object', properties: {} } }],
});
callTool.mockResolvedValueOnce({ content: [{ type: 'text', text: 'Unknown resource' }], isError: true });
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['linear.explode']);
expect(process.exitCode).toBe(1);
logSpy.mockRestore();
} finally {
process.exitCode = previousExitCode;
}
});
it('auto-corrects near-miss tool names returned as MCP isError content', async () => {
const { handleCall } = await cliModulePromise;
const callTool = vi
.fn()
.mockResolvedValueOnce({ content: [{ type: 'text', text: 'Unknown tool: read_wiki_structur' }], isError: true })
.mockResolvedValueOnce({ ok: true });
const listTools = vi.fn().mockResolvedValue([{ name: 'read_wiki_structure' }]);
const runtime = {
callTool,
listTools,
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['deepwiki.read_wiki_structur']);
const notes = logSpy.mock.calls.map((call) => call.join(' '));
expect(notes.some((line) => line.includes('Auto-corrected tool call to deepwiki.read_wiki_structure'))).toBe(true);
expect(callTool).toHaveBeenCalledTimes(2);
expect(callTool).toHaveBeenNthCalledWith(
1,
'deepwiki',
'read_wiki_structur',
expect.objectContaining({ args: {} })
);
expect(callTool).toHaveBeenNthCalledWith(
2,
'deepwiki',
'read_wiki_structure',
expect.objectContaining({ args: {} })
);
logSpy.mockRestore();
});
it('keeps auto-correct diagnostics off stdout for JSON output', async () => {
const { handleCall } = await cliModulePromise;
const callTool = vi
.fn()
.mockResolvedValueOnce({ content: [{ type: 'text', text: 'Unknown tool: read_wiki_structur' }], isError: true })
.mockResolvedValueOnce({ content: [{ type: 'text', text: '{"ok":true}' }] });
const listTools = vi.fn().mockResolvedValue([{ name: 'read_wiki_structure' }]);
const runtime = {
callTool,
listTools,
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await handleCall(runtime, ['deepwiki.read_wiki_structur', '--output', 'json']);
expect(errorSpy.mock.calls.map((call) => call.join(' ')).join('\n')).toContain(
'Auto-corrected tool call to deepwiki.read_wiki_structure'
);
expect(logSpy).toHaveBeenCalledTimes(1);
expect(JSON.parse(logSpy.mock.calls[0]?.[0]?.toString() ?? '{}')).toEqual({ ok: true });
logSpy.mockRestore();
errorSpy.mockRestore();
});
it('still requires an explicit tool when multiple are available', async () => {
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub(
{
linear: [
{ name: 'list_issues', inputSchema: {} },
{ name: 'create_issue', inputSchema: {} },
],
},
{
definitions: [
{
name: 'linear',
command: { kind: 'stdio', command: 'linear', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
},
],
}
);
await expect(handleCall(runtime, ['linear'])).rejects.toThrow(
'Missing tool name. Provide it via <server>.<tool> or --tool.'
);
expect(callTool).not.toHaveBeenCalled();
});
it('runs quoted stdio commands without --stdio and infers the tool automatically', async () => {
const command = 'npx -y vercel-domains-mcp';
const { name: adhocName } = resolveEphemeralServer({ stdioCommand: command });
const { handleCall } = await cliModulePromise;
const { runtime, callTool } = createRuntimeStub({
[adhocName]: [
{
name: 'getDomainAvailability',
inputSchema: {
type: 'object',
properties: { domain: { type: 'string' } },
required: ['domain'],
},
},
],
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, [command, 'domain=answeroverflow.com']);
expect(callTool).toHaveBeenCalledWith(
adhocName,
'getDomainAvailability',
expect.objectContaining({
args: { domain: 'answeroverflow.com' },
})
);
logSpy.mockRestore();
});
it('aborts long-running tools when the timeout elapses', async () => {
vi.useFakeTimers();
try {
const { handleCall } = await cliModulePromise;
const close = vi.fn().mockResolvedValue(undefined);
const runtime = {
callTool: () =>
new Promise((resolve) => {
setTimeout(() => resolve('done'), 1000);
}),
close,
};
const promise = handleCall(runtime as never, ['chrome-devtools.list_pages', '--timeout', '10']);
const expectation = expect(promise).rejects.toThrow('Call to chrome-devtools.list_pages timed out after 10ms.');
await vi.runOnlyPendingTimersAsync();
await expectation;
expect(close).toHaveBeenCalledWith('chrome-devtools');
} finally {
vi.useRealTimers();
}
});
it('auto-corrects near-miss tool names', async () => {
const { handleCall } = await cliModulePromise;
const callTool = vi
.fn()
.mockRejectedValueOnce(new Error('MCP error -32602: Tool listIssues not found'))
.mockResolvedValueOnce({ ok: true });
const listTools = vi.fn().mockResolvedValue([{ name: 'list_issues' }]);
const runtime = {
callTool,
listTools,
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await handleCall(runtime, ['linear.listIssues']);
const notes = logSpy.mock.calls.map((call) => call.join(' '));
expect(notes.some((line) => line.includes('Auto-corrected tool call to linear.list_issues'))).toBe(true);
expect(callTool).toHaveBeenCalledTimes(2);
expect(callTool).toHaveBeenNthCalledWith(1, 'linear', 'listIssues', expect.objectContaining({ args: {} }));
expect(callTool).toHaveBeenNthCalledWith(2, 'linear', 'list_issues', expect.objectContaining({ args: {} }));
expect(listTools).toHaveBeenCalledWith('linear', {
autoAuthorize: true,
includeSchema: false,
allowCachedAuth: true,
disableOAuth: undefined,
});
logSpy.mockRestore();
});
it('suggests similar tool names when the match is uncertain', async () => {
const { handleCall } = await cliModulePromise;
const callTool = vi.fn().mockRejectedValue(new Error('MCP error -32602: Tool listIssues not found'));
const listTools = vi.fn().mockResolvedValue([{ name: 'list_issue_statuses' }]);
const runtime = {
callTool,
listTools,
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(handleCall(runtime, ['linear.listIssues'])).rejects.toThrow('listIssues not found');
const messages = errorSpy.mock.calls.map((call) => call.join(' '));
expect(messages.some((line) => line.includes('Did you mean linear.list_issue_statuses'))).toBe(true);
errorSpy.mockRestore();
});
it("falls back to 'list' output when calling a missing help tool", async () => {
const listModule = await import('../src/cli/list-command.js');
const listSpy = vi.spyOn(listModule, 'handleList').mockResolvedValue(undefined);
const { handleCall } = await cliModulePromise;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const definition: ServerDefinition = {
name: 'chrome-devtools',
description: 'Chrome DevTools MCP server',
command: { kind: 'stdio', command: 'chrome-devtools', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
};
const { runtime, callTool } = createRuntimeStub(
{
'chrome-devtools': [
{
name: 'take_snapshot',
description: 'Takes a snapshot.',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
},
],
},
{ definitions: [definition] }
);
try {
await handleCall(runtime, ['chrome-devtools.help']);
expect(listSpy).toHaveBeenNthCalledWith(1, runtime, ['chrome-devtools']);
expect(
logSpy.mock.calls.some((call) => call.some((line) => line.includes("does not expose a 'help' tool")))
).toBe(true);
logSpy.mockClear();
await handleCall(runtime, ['chrome-devtools.help', '--output', 'json']);
expect(listSpy).toHaveBeenNthCalledWith(2, runtime, ['chrome-devtools', '--json']);
expect(callTool).not.toHaveBeenCalled();
} finally {
listSpy.mockRestore();
logSpy.mockRestore();
}
});
it('treats list_tools selector as a shortcut for mcporter list', async () => {
const listModule = await import('../src/cli/list-command.js');
const listSpy = vi.spyOn(listModule, 'handleList').mockResolvedValue(undefined);
const { handleCall } = await cliModulePromise;
const definition: ServerDefinition = {
name: 'chrome-devtools',
description: 'Chrome DevTools MCP server',
command: { kind: 'stdio', command: 'chrome-devtools', args: [], cwd: process.cwd() },
source: { kind: 'local', path: '<test>' },
};
const { runtime, callTool } = createRuntimeStub({ 'chrome-devtools': [] }, { definitions: [definition] });
try {
await handleCall(runtime, ['chrome-devtools.list_tools']);
await handleCall(runtime, ['chrome-devtools.list_tools', '--output', 'json']);
expect(listSpy).toHaveBeenNthCalledWith(1, runtime, ['chrome-devtools']);
expect(listSpy).toHaveBeenNthCalledWith(2, runtime, ['chrome-devtools', '--json']);
expect(callTool).not.toHaveBeenCalled();
} finally {
listSpy.mockRestore();
}
});
});
function createRuntimeStub(
toolCatalog: Record<
string,
Array<{
name: string;
description?: string;
inputSchema?: unknown;
}>
>,
options: { definitions?: ServerDefinition[] } = {}
): {
runtime: Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
callTool: ReturnType<typeof vi.fn>;
listTools: ReturnType<typeof vi.fn>;
} {
const definitions = new Map<string, ServerDefinition>();
for (const entry of options.definitions ?? []) {
definitions.set(entry.name, entry);
}
const callTool = vi.fn().mockResolvedValue({ ok: true });
const listTools = vi.fn().mockImplementation(async (server: string) => {
const tools = toolCatalog[server];
if (!tools) {
throw new Error(`Unknown MCP server '${server}'.`);
}
return tools;
});
const close = vi.fn().mockResolvedValue(undefined);
const runtime = {
getDefinitions: () => [...definitions.values()],
getDefinition: vi.fn().mockImplementation((name: string) => {
const definition = definitions.get(name);
if (!definition) {
throw new Error(`Unknown MCP server '${name}'.`);
}
return definition;
}),
registerDefinition: vi.fn().mockImplementation((definition: ServerDefinition) => {
definitions.set(definition.name, definition);
}),
listTools,
callTool,
close,
} as unknown as Awaited<ReturnType<(typeof import('../src/runtime.js'))['createRuntime']>>;
return { runtime, callTool, listTools };
}