Support ad-hoc auth flows
This commit is contained in:
parent
49ac707ce0
commit
96a2f0ee81
@ -8,6 +8,7 @@
|
||||
- Guaranteed that default listings always show at least five parameters (even if every field is optional) before summarising the rest, and added compact summaries (`// optional (N): …`).
|
||||
- Added `src/cli/list-detail-helpers.ts` plus dedicated unit tests (`tests/list-detail-helpers.test.ts`) covering wrapping, param selection, and optional summaries; introduced an inline snapshot test for a complex Linear server to prevent regressions in the CLI formatter.
|
||||
- Exported the identifier normalization helpers so other modules can reuse the shared Levenshtein logic without duplicate implementations.
|
||||
- Added a shared `extractEphemeralServerFlags` helper so `list`, `call`, and `auth` parse ad-hoc transports consistently, extended `mcporter auth` to accept bare URLs/`--http-url`/`--stdio`, and taught single-server listings to hint `mcporter auth https://…` when a 401 occurs. Docs (`README.md`, `docs/adhoc.md`, `docs/local.md`, `docs/call-heuristic.md`) and new tests (`tests/cli-auth.test.ts`, `tests/cli-ephemeral-flags.test.ts`, expanded `tests/cli-list.test.ts`) cover the workflow.
|
||||
|
||||
## [0.3.0] - 2025-11-06
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ npx mcporter list https://mcp.linear.app/mcp --all-parameters
|
||||
npx mcporter list --stdio "bun run ./local-server.ts" --env TOKEN=xyz
|
||||
```
|
||||
|
||||
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. The CLI infers a stable name for caching OAuth tokens and can merge the generated entry into a config file whenever you pass `--persist`. Full details live in [docs/adhoc.md](docs/adhoc.md).
|
||||
You can now point `mcporter list` at ad-hoc servers: provide a URL directly or use the new `--http-url/--stdio` flags (plus `--env`, `--cwd`, `--name`, or `--persist`) to describe any MCP endpoint. Follow up with `mcporter auth https://…` (or the same flag set) to finish OAuth without editing config. Full details live in [docs/adhoc.md](docs/adhoc.md).
|
||||
|
||||
Single-server listings now read like a TypeScript header file so you can copy/paste the signature straight into `mcporter call`:
|
||||
|
||||
@ -85,7 +85,7 @@ Helpful flags:
|
||||
- `--tail-log` -- stream the last 20 lines of any log files referenced by the tool response.
|
||||
- `--output <format>` or `--raw` -- control formatted output (defaults to pretty-printed auto detection).
|
||||
- `--all-parameters` -- show every schema field when listing a server (default output shows at least five parameters plus a summary of the rest).
|
||||
- `--http-url <https://…>` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist <config.json>` as needed).
|
||||
- `--http-url <https://…>` / `--stdio "command …"` -- describe an ad-hoc MCP server inline (pair with `--env KEY=value`, `--cwd`, `--name`, and `--persist <config.json>` as needed). These flags now work with `mcporter auth` too, so `mcporter auth https://mcp.example.com/mcp` just works.
|
||||
- For OAuth-protected servers such as `vercel`, run `npx mcporter auth vercel` once to complete login.
|
||||
|
||||
> Tip: You can skip the verb entirely—`mcporter firecrawl` automatically runs `mcporter list firecrawl`, and dotted tokens like `mcporter linear.list_issues` dispatch to the call command (typo fixes included).
|
||||
|
||||
@ -23,13 +23,14 @@ You can also pass a bare URL as the selector (`mcporter list https://mcp.linear.
|
||||
- Otherwise we derive a slug:
|
||||
- HTTP: `<host>` plus a sanitized path fragment (e.g. `mcp-linear-app-mcp`).
|
||||
- STDIO: executable basename + script (`node-mcp-server`).
|
||||
- The inferred name is printed so you know what to reuse later (`mcporter auth <name>`).
|
||||
- The inferred name is printed so you know what to reuse later. If you don’t persist the definition, run `mcporter auth https://mcp.linear.app/mcp` (or supply `--name linear` so `mcporter auth linear` also works) to finish OAuth with the same settings.
|
||||
|
||||
This name becomes the cache key for OAuth tokens and log preferences, so repeated ad-hoc calls still benefit from credential reuse.
|
||||
|
||||
## Auth & Persistence
|
||||
|
||||
- OAuth flows are allowed; successful tokens store under the inferred name just like regular definitions.
|
||||
- `mcporter auth` accepts the same `--http-url/--stdio` flags (and even bare URLs), so you can immediately re-run `mcporter auth https://…` after a 401 without touching a config file.
|
||||
- Nothing is written to disk unless you pass `--persist /path/to/config.json`. When set, we merge the generated definition into that file (creating it if necessary) so future runs can rely on the standard config pipeline.
|
||||
|
||||
## Safety Nets
|
||||
|
||||
@ -22,3 +22,4 @@
|
||||
- `mcporter list <server>` now applies the same edit-distance heuristic to server names. If you type `vercek`, the CLI auto-corrects to `vercel` (and logs `[mcporter] Auto-corrected server name to vercel (input: vercek).`).
|
||||
- When the typo is too large, we keep the original failure but emit a hint: `[mcporter] Did you mean linear?` followed by the usual “Unknown MCP server …” line. This avoids giant stack traces while pointing to the right name.
|
||||
- The heuristic considers every configured server (including ad-hoc ones registered via `--http-url/--stdio`). Tests covering this behaviour live in `tests/cli-list.test.ts`.
|
||||
- `mcporter auth` shares the same routing logic, so `mcporter auth https://mcp.example.com/mcp` (or even `mcporter auth vercek`) will spin up the temporary definition, auto-correct close names, and launch OAuth without touching the config file.
|
||||
|
||||
@ -15,6 +15,9 @@ pnpm exec tsx src/cli.ts call context7.resolve-library-id libraryName=react
|
||||
|
||||
# auth flow
|
||||
pnpm exec tsx src/cli.ts auth vercel
|
||||
|
||||
# ad-hoc auth
|
||||
pnpm exec tsx src/cli.ts auth https://mcp.supabase.com/mcp
|
||||
```
|
||||
|
||||
These invocations match the `pnpm mcporter:*` scripts and are ideal when you’re iterating on TypeScript without rebuilding.
|
||||
|
||||
34
src/cli.ts
34
src/cli.ts
@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { handleCall as runHandleCall } from './cli/call-command.js';
|
||||
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './cli/adhoc-server.js';
|
||||
import { inferCommandRouting } from './cli/command-inference.js';
|
||||
import { extractEphemeralServerFlags } from './cli/ephemeral-flags.js';
|
||||
import { handleList } from './cli/list-command.js';
|
||||
import { formatSourceSuffix } from './cli/list-format.js';
|
||||
import { getActiveLogger, getActiveLogLevel, logError, logInfo, logWarn, setLogLevel } from './cli/logger-context.js';
|
||||
@ -728,16 +730,38 @@ if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
|
||||
});
|
||||
}
|
||||
// handleAuth clears cached tokens and executes standalone OAuth flows.
|
||||
async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntime>>, args: string[]): Promise<void> {
|
||||
export async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntime>>, args: string[]): Promise<void> {
|
||||
// Peel off optional flags before we consume positional args.
|
||||
const resetIndex = args.indexOf('--reset');
|
||||
const shouldReset = resetIndex !== -1;
|
||||
if (shouldReset) {
|
||||
args.splice(resetIndex, 1);
|
||||
}
|
||||
const target = args.shift();
|
||||
let ephemeralSpec: EphemeralServerSpec | undefined = extractEphemeralServerFlags(args);
|
||||
let target = args.shift();
|
||||
if (!ephemeralSpec && target && looksLikeHttpUrl(target)) {
|
||||
ephemeralSpec = { httpUrl: target };
|
||||
target = undefined;
|
||||
}
|
||||
|
||||
if (ephemeralSpec && target && !looksLikeHttpUrl(target)) {
|
||||
ephemeralSpec = { ...ephemeralSpec, name: ephemeralSpec.name ?? target };
|
||||
}
|
||||
|
||||
let ephemeralResolution: ReturnType<typeof resolveEphemeralServer> | undefined;
|
||||
if (ephemeralSpec) {
|
||||
ephemeralResolution = resolveEphemeralServer(ephemeralSpec);
|
||||
runtime.registerDefinition(ephemeralResolution.definition, { overwrite: true });
|
||||
if (ephemeralSpec.persistPath) {
|
||||
await persistEphemeralServer(ephemeralResolution, ephemeralSpec.persistPath);
|
||||
}
|
||||
if (!target) {
|
||||
target = ephemeralResolution.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Usage: mcporter auth <server>');
|
||||
throw new Error('Usage: mcporter auth <server | url> [--http-url <url> | --stdio <command>]');
|
||||
}
|
||||
|
||||
const definition = runtime.getDefinition(target);
|
||||
@ -762,3 +786,7 @@ async function handleAuth(runtime: Awaited<ReturnType<typeof createRuntime>>, ar
|
||||
throw new Error(`Failed to authorize '${target}': ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { createCallResult } from '../result-utils.js';
|
||||
import { type EphemeralServerSpec, persistEphemeralServer, resolveEphemeralServer } from './adhoc-server.js';
|
||||
import { parseCallExpressionFragment } from './call-expression-parser.js';
|
||||
import { chooseClosestIdentifier, normalizeIdentifier } from './identifier-helpers.js';
|
||||
import { extractEphemeralServerFlags } from './ephemeral-flags.js';
|
||||
import { type OutputFormat, printCallOutput, tailLogIfRequested } from './output-utils.js';
|
||||
import { dumpActiveHandles } from './runtime-debug.js';
|
||||
import { dimText } from './terminal.js';
|
||||
@ -25,13 +26,9 @@ function isOutputFormat(value: string): value is OutputFormat {
|
||||
export function parseCallArguments(args: string[]): CallArgsParseResult {
|
||||
// Maintain backwards compatibility with legacy positional + key=value forms.
|
||||
const result: CallArgsParseResult = { args: {}, tailLog: false, output: 'auto' };
|
||||
const ephemeral = extractEphemeralServerFlags(args);
|
||||
result.ephemeral = ephemeral;
|
||||
const positional: string[] = [];
|
||||
const ensureEphemeral = (): EphemeralServerSpec => {
|
||||
if (!result.ephemeral) {
|
||||
result.ephemeral = {};
|
||||
}
|
||||
return result.ephemeral;
|
||||
};
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
@ -75,91 +72,6 @@ export function parseCallArguments(args: string[]): CallArgsParseResult {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--http-url') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--http-url' requires a value.");
|
||||
}
|
||||
ensureEphemeral().httpUrl = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--allow-http') {
|
||||
ensureEphemeral().allowInsecureHttp = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--stdio') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--stdio' requires a value.");
|
||||
}
|
||||
ensureEphemeral().stdioCommand = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--stdio-arg') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--stdio-arg' requires a value.");
|
||||
}
|
||||
const ephemeral = ensureEphemeral();
|
||||
ephemeral.stdioArgs = [...(ephemeral.stdioArgs ?? []), value];
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--env') {
|
||||
const value = args[index + 1];
|
||||
if (!value || !value.includes('=')) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const [key, ...rest] = value.split('=');
|
||||
if (!key) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const ephemeral = ensureEphemeral();
|
||||
const envMap = ephemeral.env ? { ...ephemeral.env } : {};
|
||||
envMap[key] = rest.join('=');
|
||||
ephemeral.env = envMap;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--cwd') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--cwd' requires a value.");
|
||||
}
|
||||
ensureEphemeral().cwd = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--name') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--name' requires a value.");
|
||||
}
|
||||
ensureEphemeral().name = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--description') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--description' requires a value.");
|
||||
}
|
||||
ensureEphemeral().description = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--persist') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--persist' requires a value.");
|
||||
}
|
||||
ensureEphemeral().persistPath = value;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (token === '--yes') {
|
||||
index += 1;
|
||||
continue;
|
||||
|
||||
128
src/cli/ephemeral-flags.ts
Normal file
128
src/cli/ephemeral-flags.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import type { EphemeralServerSpec } from './adhoc-server.js';
|
||||
|
||||
interface ExtractOptions {
|
||||
allowPersist?: boolean;
|
||||
}
|
||||
|
||||
// extractEphemeralServerFlags scans argv for ad-hoc server descriptors (HTTP/STDIO/env/etc.)
|
||||
// and removes them so higher-level parsers can focus on command-specific flags.
|
||||
export function extractEphemeralServerFlags(
|
||||
args: string[],
|
||||
options: ExtractOptions = {}
|
||||
): EphemeralServerSpec | undefined {
|
||||
let spec: EphemeralServerSpec | undefined;
|
||||
const ensureSpec = (): EphemeralServerSpec => {
|
||||
if (!spec) {
|
||||
spec = {};
|
||||
}
|
||||
return spec;
|
||||
};
|
||||
|
||||
const allowPersist = options.allowPersist ?? true;
|
||||
let index = 0;
|
||||
while (index < args.length) {
|
||||
const token = args[index];
|
||||
if (!token) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--http-url') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--http-url' requires a value.");
|
||||
}
|
||||
ensureSpec().httpUrl = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--allow-http') {
|
||||
ensureSpec().allowInsecureHttp = true;
|
||||
args.splice(index, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--stdio') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--stdio' requires a value.");
|
||||
}
|
||||
ensureSpec().stdioCommand = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--stdio-arg') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--stdio-arg' requires a value.");
|
||||
}
|
||||
const current = ensureSpec();
|
||||
current.stdioArgs = [...(current.stdioArgs ?? []), value];
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--env') {
|
||||
const value = args[index + 1];
|
||||
if (!value || !value.includes('=')) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const [key, ...rest] = value.split('=');
|
||||
if (!key) {
|
||||
throw new Error("Flag '--env' requires KEY=value.");
|
||||
}
|
||||
const current = ensureSpec();
|
||||
const envMap = current.env ? { ...current.env } : {};
|
||||
envMap[key] = rest.join('=');
|
||||
current.env = envMap;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--cwd') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--cwd' requires a value.");
|
||||
}
|
||||
ensureSpec().cwd = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--name') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--name' requires a value.");
|
||||
}
|
||||
ensureSpec().name = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === '--description') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--description' requires a value.");
|
||||
}
|
||||
ensureSpec().description = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowPersist && token === '--persist') {
|
||||
const value = args[index + 1];
|
||||
if (!value) {
|
||||
throw new Error("Flag '--persist' requires a value.");
|
||||
}
|
||||
ensureSpec().persistPath = value;
|
||||
args.splice(index, 2);
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
@ -76,7 +76,8 @@ export function formatSourceSuffix(source: ServerSource | undefined, inline = fa
|
||||
export function classifyListError(
|
||||
error: unknown,
|
||||
serverName: string,
|
||||
_timeoutSeconds: number
|
||||
_timeoutSeconds: number,
|
||||
options?: { authCommand?: string }
|
||||
): {
|
||||
colored: string;
|
||||
summary: string;
|
||||
@ -84,8 +85,9 @@ export function classifyListError(
|
||||
authCommand?: string;
|
||||
} {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
const note = yellowText(`auth required — run 'mcporter auth ${serverName}'`);
|
||||
return { colored: note, summary: 'auth required', category: 'auth', authCommand: `mcporter auth ${serverName}` };
|
||||
const authCommand = options?.authCommand ?? `mcporter auth ${serverName}`;
|
||||
const note = yellowText(`auth required — run '${authCommand}'`);
|
||||
return { colored: note, summary: 'auth required', category: 'auth', authCommand };
|
||||
}
|
||||
|
||||
const rawMessage =
|
||||
@ -104,8 +106,9 @@ export function classifyListError(
|
||||
normalized.includes('invalid_token') ||
|
||||
normalized.includes('forbidden')
|
||||
) {
|
||||
const note = yellowText(`auth required — run 'mcporter auth ${serverName}'`);
|
||||
return { colored: note, summary: 'auth required', category: 'auth', authCommand: `mcporter auth ${serverName}` };
|
||||
const authCommand = options?.authCommand ?? `mcporter auth ${serverName}`;
|
||||
const note = yellowText(`auth required — run '${authCommand}'`);
|
||||
return { colored: note, summary: 'auth required', category: 'auth', authCommand };
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
46
tests/cli-auth.test.ts
Normal file
46
tests/cli-auth.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
process.env.MCPORTER_DISABLE_AUTORUN = '1';
|
||||
const cliModulePromise = import('../src/cli.js');
|
||||
|
||||
const createRuntimeDouble = () => {
|
||||
const definitions = new Map<string, Record<string, unknown>>();
|
||||
const registerDefinition = vi.fn((definition: Record<string, unknown>) => {
|
||||
definitions.set(definition.name as string, { ...definition });
|
||||
});
|
||||
const getDefinition = vi.fn((name: string) => {
|
||||
const definition = definitions.get(name);
|
||||
if (!definition) {
|
||||
throw new Error(`Unknown MCP server '${name}'.`);
|
||||
}
|
||||
return definition;
|
||||
});
|
||||
const listTools = vi.fn().mockResolvedValue([{ name: 'ok' }]);
|
||||
const runtime = {
|
||||
registerDefinition,
|
||||
getDefinition,
|
||||
getDefinitions: () => Array.from(definitions.values()),
|
||||
listTools,
|
||||
} as unknown as Awaited<ReturnType<typeof import('../src/runtime.js')['createRuntime']>>;
|
||||
return { runtime, listTools };
|
||||
};
|
||||
|
||||
describe('mcporter auth ad-hoc support', () => {
|
||||
it('registers ad-hoc HTTP servers via --http-url', async () => {
|
||||
const { handleAuth } = await cliModulePromise;
|
||||
const { runtime, listTools } = createRuntimeDouble();
|
||||
|
||||
await handleAuth(runtime, ['--http-url', 'https://mcp.deepwiki.com/sse']);
|
||||
|
||||
expect(listTools).toHaveBeenCalledWith('mcp-deepwiki-com-sse', { autoAuthorize: true });
|
||||
});
|
||||
|
||||
it('accepts bare URLs as the auth target', async () => {
|
||||
const { handleAuth } = await cliModulePromise;
|
||||
const { runtime, listTools } = createRuntimeDouble();
|
||||
|
||||
await handleAuth(runtime, ['https://mcp.supabase.com/mcp']);
|
||||
|
||||
expect(listTools).toHaveBeenCalledWith('mcp-supabase-com-mcp', { autoAuthorize: true });
|
||||
});
|
||||
});
|
||||
19
tests/cli-ephemeral-flags.test.ts
Normal file
19
tests/cli-ephemeral-flags.test.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { extractEphemeralServerFlags } from '../src/cli/ephemeral-flags.js';
|
||||
|
||||
describe('extractEphemeralServerFlags', () => {
|
||||
it('parses HTTP URLs and env overrides', () => {
|
||||
const args = ['--http-url', 'https://mcp.example.com/mcp', '--env', 'TOKEN=abc', 'list'];
|
||||
const spec = extractEphemeralServerFlags(args);
|
||||
expect(spec).toEqual({ httpUrl: 'https://mcp.example.com/mcp', env: { TOKEN: 'abc' } });
|
||||
expect(args).toEqual(['list']);
|
||||
});
|
||||
|
||||
it('captures stdio commands and additional args', () => {
|
||||
const args = ['--stdio', 'bun run ./server.ts', '--stdio-arg', '--watch', 'call'];
|
||||
const spec = extractEphemeralServerFlags(args);
|
||||
expect(spec).toEqual({ stdioCommand: 'bun run ./server.ts', stdioArgs: ['--watch'] });
|
||||
expect(args).toEqual(['call']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user