Compare commits
21 Commits
fix/list-u
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe87142d89 | ||
|
|
782e028abe | ||
|
|
2a9b353b21 | ||
|
|
f02bef36d2 | ||
|
|
7491ed5a85 | ||
|
|
8beee8764f | ||
|
|
c1b58296db | ||
|
|
6f3f42ca42 | ||
|
|
53747cac63 | ||
|
|
4037f0a064 | ||
|
|
37391ce70b | ||
|
|
023314cf31 | ||
|
|
f2f67b4a38 | ||
|
|
870df28717 | ||
|
|
c9325a6a4a | ||
|
|
4813cdfe7a | ||
|
|
3e27b64021 | ||
|
|
8f74252a4d | ||
|
|
0fb13581fb | ||
|
|
2c04671b92 | ||
|
|
14ff39a59b |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
|||||||
os: [ubuntu-latest, macos-15, windows-latest]
|
os: [ubuntu-latest, macos-15, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
@ -48,6 +48,11 @@ jobs:
|
|||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm --version
|
- run: pnpm --version
|
||||||
- run: pnpm check
|
- run: pnpm check
|
||||||
|
if: matrix.os != 'macos-15'
|
||||||
|
|
||||||
|
- name: Check without type-aware oxlint
|
||||||
|
if: matrix.os == 'macos-15'
|
||||||
|
run: pnpm format:check && pnpm typecheck
|
||||||
|
|
||||||
- name: Verify generated schema is committed
|
- name: Verify generated schema is committed
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
|||||||
2
.github/workflows/crabbox-hydrate.yml
vendored
2
.github/workflows/crabbox-hydrate.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
|||||||
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
|
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
|
|||||||
27
CHANGELOG.md
27
CHANGELOG.md
@ -1,18 +1,43 @@
|
|||||||
# mcporter Changelog
|
# mcporter Changelog
|
||||||
|
|
||||||
## [0.11.4] - Unreleased
|
## [0.12.1] - 2026-06-18
|
||||||
|
|
||||||
|
- Add `key=@path` and `--key @path` call arguments for exact UTF-8 file values, with `@@` escaping for literal leading `@`. (Issue #212, thanks @andr-ec)
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
- Skip imported server entries with unresolvable editor-specific environment placeholders, and allow later valid duplicates to take effect without relaxing validation for local config. (PR #209, thanks @Loveacup)
|
||||||
|
|
||||||
### OAuth
|
### OAuth
|
||||||
|
|
||||||
|
- Treat corrupt cached OAuth tokens and client metadata as missing so connections can re-authenticate, while keeping corrupt callback state data fail-closed. (Issue #207, thanks @KrasimirKralev)
|
||||||
|
|
||||||
|
### Tooling / Dependencies
|
||||||
|
|
||||||
|
- Refresh development dependencies and security overrides, including Vite, esbuild, and Hono.
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-06-10
|
||||||
|
|
||||||
|
### OAuth
|
||||||
|
|
||||||
|
- Add cache-friendly `disableOAuth` support across headless runtime, CLI, daemon, proxy, and `callOnce` paths so callers can suppress interactive OAuth without losing connection reuse. (Issues #197, #199, #201, thanks @feniix)
|
||||||
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
|
- Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials.
|
||||||
|
- Prevent concurrent OAuth vault updates from briefly exposing empty lock files and losing credential entries under load.
|
||||||
|
|
||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
|
- Add per-server Streamable HTTP paths for `mcporter serve` at `/mcp/<server>`, exposing one keep-alive server with original tool names while preserving aggregate `/mcp` namespacing. (PR #194, thanks @zm2231)
|
||||||
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
|
- Add `mcporter record` and `mcporter replay` helpers for capturing and replaying MCP JSON-RPC traffic, with server filters and daemon-safe manual env setup. (PR #192, thanks @LDMB123)
|
||||||
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
|
- Prevent direct daemon starts from rebinding over an already-running healthy daemon, avoiding orphaned keep-alive processes during foreground or launch races. (PR #195, thanks @zm2231)
|
||||||
|
- Return a non-zero exit code for explicit `mcporter list <unknown-server>` failures while preserving aggregate list health checks by default. (Issue #203, thanks @theo674)
|
||||||
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
|
- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi)
|
||||||
|
- Keep CloudBase MCP alive by default so device-code authentication can finish polling and persist credentials after returning `AUTH_PENDING`. (PR #193, thanks @sevzq)
|
||||||
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
|
- Keep daemon-managed stdio servers warm across repeated `mcporter list` requests instead of treating non-interactive tool listing as a throwaway process. (Issue #188, thanks @robertoronderosjr)
|
||||||
|
|
||||||
|
### Tooling / Dependencies
|
||||||
|
|
||||||
|
- Refresh development dependencies and satisfy the stricter `oxlint` check.
|
||||||
|
|
||||||
## [0.11.3] - 2026-05-21
|
## [0.11.3] - 2026-05-21
|
||||||
|
|
||||||
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)
|
- Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026)
|
||||||
|
|||||||
@ -143,6 +143,7 @@ LINEAR_API_KEY=sk_linear_example npx mcporter call linear.search_documentation q
|
|||||||
```bash
|
```bash
|
||||||
npx mcporter call chrome-devtools.take_snapshot
|
npx mcporter call chrome-devtools.take_snapshot
|
||||||
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
|
npx mcporter call 'linear.create_comment(issueId: "LNR-123", body: "Hello world")'
|
||||||
|
npx mcporter call linear.create_comment issueId=LNR-123 body=@comment.md
|
||||||
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
|
npx mcporter call https://mcp.linear.app/mcp.list_issues assignee=me
|
||||||
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
npx mcporter call shadcn.io/api/mcp.getComponent component=vortex # protocol optional; defaults to https
|
||||||
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
npx mcporter call linear.listIssues --tool listIssues # auto-corrects to list_issues
|
||||||
@ -163,6 +164,7 @@ Helpful flags:
|
|||||||
- `--save-images <dir>` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged).
|
- `--save-images <dir>` (on `mcporter call`) -- save MCP image content blocks to files in the given directory (opt-in; stdout output shape stays unchanged).
|
||||||
- `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings.
|
- `--raw-strings` (on `mcporter call`) -- keep numeric-looking argument values (for `key=value`, `key:value`, and trailing positional values) as strings.
|
||||||
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
|
- `--no-coerce` (on `mcporter call`) -- keep all `key=value` and positional values as raw strings (disables bool/null/number/JSON coercion).
|
||||||
|
- `key=@path` / `--key @path` (on `mcporter call`) -- read a named argument as exact UTF-8 text from a file; use `@@` for a literal leading `@`.
|
||||||
- `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`.
|
- `--` (on `mcporter call`) -- stop flag parsing so the remaining tokens stay literal positional values, even when they start with `--`.
|
||||||
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
|
- `--json` (on `mcporter list`) -- emit JSON summaries/counts instead of text. Multi-server runs report per-server statuses, counts, and connection issues; single-server runs include the full tool metadata.
|
||||||
- `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy.
|
- `--status`, `--exit-code`, `--quiet` (on `mcporter list`) -- run concise server health checks through the existing list flow; `--quiet` suppresses output and exits 1 if anything checked is unhealthy.
|
||||||
@ -199,7 +201,7 @@ npx mcporter call --stdio "bun run ./local-server.ts" --name local-tools
|
|||||||
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
|
- Stop it anytime with `mcporter daemon stop`, pre-warm with `mcporter daemon start`, or bounce it via `mcporter daemon restart` after tweaking configs/env.
|
||||||
- All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out.
|
- All other servers stay ephemeral; add `"lifecycle": "keep-alive"` to a server entry (or set `MCPORTER_KEEPALIVE=name`) when you want the daemon to manage it. You can also set `"lifecycle": "ephemeral"` (or `MCPORTER_DISABLE_KEEPALIVE=name`) to opt out.
|
||||||
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
|
- The daemon only manages named servers that come from your config/imports. Ad-hoc STDIO/HTTP targets invoked via `--stdio …`, `--http-url …`, or inline function-call syntax remain per-process today; persist them into `config/mcporter.json` (or use `--persist`) if you need them to participate in the shared daemon.
|
||||||
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`.
|
- `mcporter serve --stdio` exposes every daemon-managed keep-alive server as one MCP stdio bridge for clients such as Claude Code or Codex. Register it once, then call namespaced tools like `chrome-devtools__list_pages`; add `--servers a,b` to limit the bridge or `--http <port>` to serve Streamable HTTP on localhost at `/mcp`. HTTP mode also exposes `/mcp/<server>` for one selected keep-alive server with its original, unprefixed tool names.
|
||||||
- Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry.
|
- Troubleshooting? Run `mcporter daemon start --log` (or `--log-file /tmp/daemon.log`) to tee stdout/stderr into a file, and add `--log-servers chrome-devtools` when you only want call traces for a specific MCP. Per-server configs can also set `"logging": { "daemon": { "enabled": true } }` to force detailed logging for that entry.
|
||||||
|
|
||||||
## Friendlier Tool Calls
|
## Friendlier Tool Calls
|
||||||
@ -254,7 +256,7 @@ const result = await callOnce({
|
|||||||
console.log(result); // raw MCP envelope
|
console.log(result); // raw MCP envelope
|
||||||
```
|
```
|
||||||
|
|
||||||
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports), handles OAuth prompts, and closes transports when it finishes. It is ideal for manual runs or wiring MCPorter directly into an agent tool hook.
|
`callOnce` automatically discovers the selected server (including Cursor/Claude/Codex/Windsurf/OpenCode/VS Code imports), handles OAuth prompts, and closes transports when it finishes. It is ideal for manual runs or wiring MCPorter directly into an agent tool hook. In headless contexts, pass `disableOAuth: true` to suppress interactive OAuth and rely on cached tokens only — the library equivalent of the CLI's `--no-oauth` flag.
|
||||||
|
|
||||||
## Compose Automations with the Runtime
|
## Compose Automations with the Runtime
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,7 @@ Key details:
|
|||||||
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
|
- `--key value`, `--key=value`, `key=value`, `key:value`, `key: value`, and `key:=value` all map to the same named-argument handling, so you can type whichever feels most natural for your shell. Long flag keys convert kebab-case to camelCase (`--save-to-drafts true` becomes `saveToDrafts: true`). The `:=` form is accepted as a compatibility alias for `=`.
|
||||||
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
|
- By default, arguments keep the same validation pipeline as the function-call syntax—enums, numbers, and booleans are coerced automatically, and missing required fields raise errors.
|
||||||
- `--args -` and `--json -` read a JSON object from stdin.
|
- `--args -` and `--json -` read a JSON object from stdin.
|
||||||
|
- Named flag-style values can read exact UTF-8 text from a file with `key=@path` or `--key @path`. Paths resolve from the current working directory, file contents remain strings without coercion, and `key=@@literal` produces the literal value `@literal`. Function-call strings such as `body: "@literal"` remain literal.
|
||||||
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
|
- Bare string values supplied via long flags wrap into one-item arrays when the tool schema declares that field as an array.
|
||||||
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
|
- Numeric-looking `key=value` arguments are restored to their original string spelling when the tool schema declares that parameter as a string, which keeps timestamp-like IDs such as Slack `thread_ts=1234567890.123456` intact.
|
||||||
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
|
- `--raw-strings` disables numeric coercion for flag-style and positional values so IDs/codes stay literal strings (`code=12345` stays `"12345"`).
|
||||||
|
|||||||
@ -38,6 +38,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--exit-code` – exit 1 when any checked server is unhealthy.
|
- `--exit-code` – exit 1 when any checked server is unhealthy.
|
||||||
- `--quiet` – suppress output and exit 1 when any checked server is unhealthy.
|
- `--quiet` – suppress output and exit 1 when any checked server is unhealthy.
|
||||||
- `--timeout <ms>` – per-server timeout when enumerating all servers.
|
- `--timeout <ms>` – per-server timeout when enumerating all servers.
|
||||||
|
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||||
|
tokens only while keeping eligible connections pooled.
|
||||||
|
|
||||||
## `mcporter call <server.tool>`
|
## `mcporter call <server.tool>`
|
||||||
|
|
||||||
@ -51,7 +53,10 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
- `--save-images <dir>` – persist image content blocks to files under the specified directory.
|
||||||
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
- `--raw-strings` – disable numeric coercion for flag-style and positional values.
|
||||||
- `--no-coerce` – disable all flag-style/positional value coercion.
|
- `--no-coerce` – disable all flag-style/positional value coercion.
|
||||||
|
- `key=@path` / `--key @path` – read a named UTF-8 string argument from a file; prefix with `@@` for a literal leading `@`.
|
||||||
- `--tail-log` – stream tail output when the tool returns log handles.
|
- `--tail-log` – stream tail output when the tool returns log handles.
|
||||||
|
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||||
|
tokens only while keeping eligible connections pooled.
|
||||||
|
|
||||||
## `mcporter resource <server> [uri]`
|
## `mcporter resource <server> [uri]`
|
||||||
|
|
||||||
@ -63,6 +68,8 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
- `--output auto|text|markdown|json|raw` – choose how to render the response.
|
||||||
- `--json` – shortcut for `--output json`.
|
- `--json` – shortcut for `--output json`.
|
||||||
- `--raw` – shortcut for `--output raw`.
|
- `--raw` – shortcut for `--output raw`.
|
||||||
|
- `--no-oauth` – never start an interactive OAuth flow; use cached
|
||||||
|
tokens only while keeping eligible connections pooled.
|
||||||
|
|
||||||
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
|
## `mcporter serve [--servers a,b,c] [--stdio | --http <port>]`
|
||||||
|
|
||||||
@ -71,14 +78,17 @@ A quick reference for the primary `mcporter` subcommands. Each command inherits
|
|||||||
- `tools/list` queries the daemon for each selected server and publishes tools
|
- `tools/list` queries the daemon for each selected server and publishes tools
|
||||||
as `server__tool`; `tools/call` strips the prefix and routes the call through
|
as `server__tool`; `tools/call` strips the prefix and routes the call through
|
||||||
the daemon.
|
the daemon.
|
||||||
|
- In HTTP mode, `/mcp` keeps the aggregate namespaced bridge, while
|
||||||
|
`/mcp/<server>` exposes one selected keep-alive server with its original,
|
||||||
|
unprefixed tool names.
|
||||||
- Only configured keep-alive servers participate. Add
|
- Only configured keep-alive servers participate. Add
|
||||||
`"lifecycle": "keep-alive"` to a server definition when you want it managed
|
`"lifecycle": "keep-alive"` to a server definition when you want it managed
|
||||||
by the daemon.
|
by the daemon.
|
||||||
- Flags:
|
- Flags:
|
||||||
- `--stdio` – serve MCP over stdio; this is the default and is the mode to
|
- `--stdio` – serve MCP over stdio; this is the default and is the mode to
|
||||||
register with Claude Code, Codex, and similar clients.
|
register with Claude Code, Codex, and similar clients.
|
||||||
- `--http <port>` – serve MCP Streamable HTTP on `/mcp`, bound to
|
- `--http <port>` – serve MCP Streamable HTTP on `/mcp` and
|
||||||
`127.0.0.1` by default.
|
`/mcp/<server>`, bound to `127.0.0.1` by default.
|
||||||
- `--host <host>` – override the HTTP bind host when you intentionally need a
|
- `--host <host>` – override the HTTP bind host when you intentionally need a
|
||||||
non-local listener.
|
non-local listener.
|
||||||
- `--servers <csv>` – expose only the listed keep-alive server names.
|
- `--servers <csv>` – expose only the listed keep-alive server names.
|
||||||
|
|||||||
@ -8,7 +8,7 @@ read_when:
|
|||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools`). No extra flags for agents.
|
- **Invisible keep-alive:** `mcporter call` should transparently start (and reuse) a per-login daemon whenever a configured server requires persistence (e.g., `chrome-devtools` or CloudBase device authentication). No extra flags for agents.
|
||||||
- **Shared state:** Multiple CLI invocations/agents within the same user session must reuse the same warm transport so STDIO servers can hold tabs, cookies, and other stateful context.
|
- **Shared state:** Multiple CLI invocations/agents within the same user session must reuse the same warm transport so STDIO servers can hold tabs, cookies, and other stateful context.
|
||||||
- **Per-config scope:** The daemon lives under the current user account, keyed by config path (`~/.mcporter/daemon/daemon-<config-hash>.sock` on Unix-like systems, or `$XDG_STATE_HOME/mcporter/daemon/...` when set), and never crosses user boundaries.
|
- **Per-config scope:** The daemon lives under the current user account, keyed by config path (`~/.mcporter/daemon/daemon-<config-hash>.sock` on Unix-like systems, or `$XDG_STATE_HOME/mcporter/daemon/...` when set), and never crosses user boundaries.
|
||||||
- **Resilience:** If the daemon or a keep-alive server crashes, the next CLI call restarts it automatically.
|
- **Resilience:** If the daemon or a keep-alive server crashes, the next CLI call restarts it automatically.
|
||||||
@ -34,7 +34,7 @@ read_when:
|
|||||||
- **Keep-alive detection:**
|
- **Keep-alive detection:**
|
||||||
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
|
- Extend `ServerDefinition` with `lifecycle?: "ephemeral" | { mode: "keep-alive", idleTimeoutMs?: number }`.
|
||||||
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
|
- Provide a config-level `defaultKeepAlive` array or `MCPORTER_KEEPALIVE` env var for quick overrides.
|
||||||
- Ship a hardcoded allowlist (initially `chrome-devtools`, `mobile-mcp`, `playwright`) so existing configs benefit immediately; users can opt out per server.
|
- Ship a hardcoded allowlist (`chrome-devtools`, `mobile-mcp`, `playwright`, `cloudbase`) so existing configs benefit immediately; users can opt out per server.
|
||||||
|
|
||||||
## CLI Surface
|
## CLI Surface
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ mcporter leans into the **code-execution-with-MCP** pattern Anthropic recommends
|
|||||||
- **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types.
|
- **Typed clients.** [`mcporter emit-ts`](emit-ts.md) emits `.d.ts` interfaces or a ready-to-run client wrapping `createServerProxy()` so agents call MCP tools with full TypeScript types.
|
||||||
- **Friendly composable API.** [`createServerProxy()`](tool-calling.md) maps tools to camelCase methods, applies JSON-schema defaults, validates required arguments, and returns a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers.
|
- **Friendly composable API.** [`createServerProxy()`](tool-calling.md) maps tools to camelCase methods, applies JSON-schema defaults, validates required arguments, and returns a `CallResult` with `.text()`, `.markdown()`, `.json()`, `.images()`, `.content()` helpers.
|
||||||
- **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth <url>` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md).
|
- **Ad-hoc connections + auto-OAuth.** Point the CLI at any MCP endpoint (HTTP, SSE, stdio) without touching config. Hosted MCPs that need a browser login (Supabase, Vercel, etc.) are auto-detected — `mcporter auth <url>` promotes the definition to OAuth on the fly. See [Ad-hoc connections](adhoc.md).
|
||||||
- **MCP bridge for agents.** `mcporter serve --stdio` exposes daemon-managed keep-alive servers as one MCP server, with tools namespaced as `server__tool`, so clients can share the same warm daemon-backed transports.
|
- **MCP bridge for agents.** `mcporter serve` exposes daemon-managed keep-alive servers as one MCP server with namespaced `server__tool` tools, or as per-server HTTP paths that keep original tool names.
|
||||||
- **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports.
|
- **OAuth & stdio ergonomics.** Built-in OAuth caching, token refresh, log tailing, and stdio wrappers — same interface across HTTP, SSE, and stdio transports.
|
||||||
|
|
||||||
## Built for agents
|
## Built for agents
|
||||||
|
|||||||
@ -35,7 +35,7 @@ Use `createServerProxy(runtime, name)` inside scripts when you want ergonomic ca
|
|||||||
2. Automatically merges default values.
|
2. Automatically merges default values.
|
||||||
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
|
3. Returns a `CallResult` helper so you can render `.text()`, `.markdown()`, or `.json()` without manual parsing.
|
||||||
|
|
||||||
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control.
|
When you need raw access (custom transports, streaming), use the bare `Client` from `@modelcontextprotocol/sdk` or inspect `runtime.connect(name)` for lower-level control. Headless callers that must rely on cached tokens without launching OAuth can pass `disableOAuth: true` to `connect`, `callTool`, `listTools`, resource helpers, and `callOnce`; this suppresses interactive OAuth while keeping eligible connections pooled.
|
||||||
|
|
||||||
## Debug + Support Docs
|
## Debug + Support Docs
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ mcporter call context7.resolve-library-id libraryName: value
|
|||||||
|
|
||||||
- Use `--flag value` when you prefer long-form CLI syntax.
|
- Use `--flag value` when you prefer long-form CLI syntax.
|
||||||
- Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`.
|
- Mixed forms are fine: `mcporter call linear.create_issue --team ENG title=value due: tomorrow`.
|
||||||
|
- Use `body=@comment.md` (or `--body @comment.md`) to read an exact UTF-8 string from a file; use `body=@@literal` when the value itself starts with `@`.
|
||||||
- `--args '{"title":"Bug"}'` still ingests JSON payloads directly.
|
- `--args '{"title":"Bug"}'` still ingests JSON payloads directly.
|
||||||
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.
|
- Unknown long flags now error instead of silently becoming tool arguments; use `title=value`, `--args`, or `--` before literal positional values beginning with `--`.
|
||||||
|
|
||||||
|
|||||||
170
examples/headless-pooling-demo.ts
Normal file
170
examples/headless-pooling-demo.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demonstration: `disableOAuth: true` provides cache-friendly OAuth
|
||||||
|
* suppression for headless callers.
|
||||||
|
*
|
||||||
|
* Spins up a local mock MCP server (no real auth), then exercises three
|
||||||
|
* patterns side-by-side and counts the distinct ClientContext objects
|
||||||
|
* the runtime hands out:
|
||||||
|
*
|
||||||
|
* 1. Legacy `maxOAuthAttempts: 0` — uncached (existing contract).
|
||||||
|
* 2. `disableOAuth: true` direct connects — pooled.
|
||||||
|
* 3. The documented headless setup — pre-connect with
|
||||||
|
* `disableOAuth: true`, then 5 `callTool` invocations. Verifies the
|
||||||
|
* pre-connected slot is preserved (no implicit eviction).
|
||||||
|
*
|
||||||
|
* Run: pnpm tsx examples/headless-pooling-demo.ts
|
||||||
|
*
|
||||||
|
* Counting strategy: ClientContext object identity. Each call to
|
||||||
|
* `createClientContext` inside the runtime returns a fresh object;
|
||||||
|
* cached calls return the same object. We track the set of unique
|
||||||
|
* objects and report cardinality.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Server as HttpServer } from 'node:http';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import express from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createRuntime } from '../src/index.js';
|
||||||
|
|
||||||
|
const INVOCATIONS = 5;
|
||||||
|
|
||||||
|
async function startMockServer(): Promise<{ baseUrl: URL; httpServer: HttpServer }> {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const mcp = new McpServer({ name: 'demo', version: '1.0.0' });
|
||||||
|
mcp.registerTool(
|
||||||
|
'add',
|
||||||
|
{
|
||||||
|
title: 'Addition',
|
||||||
|
description: 'Add two numbers',
|
||||||
|
inputSchema: { a: z.number(), b: z.number() },
|
||||||
|
outputSchema: { result: z.number() },
|
||||||
|
},
|
||||||
|
async ({ a, b }) => {
|
||||||
|
const result = { result: a + b };
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: JSON.stringify(result) }],
|
||||||
|
structuredContent: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/mcp', (_req, res) => res.sendStatus(405));
|
||||||
|
app.post('/mcp', async (req, res) => {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined,
|
||||||
|
enableJsonResponse: true,
|
||||||
|
});
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close().catch(() => {});
|
||||||
|
});
|
||||||
|
await mcp.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpServer = app.listen(0, '127.0.0.1');
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.once('listening', resolve);
|
||||||
|
httpServer.once('error', reject);
|
||||||
|
});
|
||||||
|
const address = httpServer.address() as AddressInfo;
|
||||||
|
return { baseUrl: new URL(`http://127.0.0.1:${address.port}/mcp`), httpServer };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// The mock MCP server below has no `auth: 'oauth'` definition, so the
|
||||||
|
// OAuth flow is not exercised here. This demo focuses on the
|
||||||
|
// cache-behavior fix (the main fix in PR #198). OAuth-suppression
|
||||||
|
// semantics under `disableOAuth: true` are exercised by the unit
|
||||||
|
// tests in `tests/runtime-transport.test.ts` (shouldEstablishOAuth)
|
||||||
|
// and `tests/runtime-integration.test.ts` (cache + eviction).
|
||||||
|
const { baseUrl, httpServer } = await startMockServer();
|
||||||
|
console.log(`[demo] Mock MCP server listening at ${baseUrl}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ----- Pattern A: legacy maxOAuthAttempts: 0 (uncached) ------------
|
||||||
|
{
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'demo',
|
||||||
|
description: 'Demo server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const contexts = new Set<unknown>();
|
||||||
|
for (let i = 0; i < INVOCATIONS; i++) {
|
||||||
|
contexts.add(await runtime.connect('demo', { maxOAuthAttempts: 0 }));
|
||||||
|
}
|
||||||
|
console.log(`[demo] Pattern A — legacy maxOAuthAttempts: 0`);
|
||||||
|
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
|
||||||
|
console.log(`[demo] Expected: ${INVOCATIONS} (legacy contract: cache disabled when maxOAuthAttempts is set)`);
|
||||||
|
console.log(`[demo] Result: ${contexts.size === INVOCATIONS ? 'OK' : 'UNEXPECTED'}\n`);
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern B: disableOAuth: true on every connect ---------------
|
||||||
|
{
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'demo',
|
||||||
|
description: 'Demo server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const contexts = new Set<unknown>();
|
||||||
|
for (let i = 0; i < INVOCATIONS; i++) {
|
||||||
|
contexts.add(await runtime.connect('demo', { disableOAuth: true }));
|
||||||
|
}
|
||||||
|
console.log(`[demo] Pattern B — disableOAuth: true on every connect`);
|
||||||
|
console.log(`[demo] ${INVOCATIONS} connect() calls → ${contexts.size} distinct ClientContexts`);
|
||||||
|
console.log(`[demo] Expected: 1 (cache reuse under cache-friendly suppression)`);
|
||||||
|
console.log(`[demo] Result: ${contexts.size === 1 ? 'PASS' : 'FAIL'}\n`);
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern C: documented headless setup + 5 callTool ------------
|
||||||
|
{
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'demo',
|
||||||
|
description: 'Demo server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const initial = await runtime.connect('demo', { disableOAuth: true });
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < INVOCATIONS; i++) {
|
||||||
|
const result = (await runtime.callTool('demo', 'add', {
|
||||||
|
args: { a: i, b: i + 1 },
|
||||||
|
})) as { structuredContent?: { result: number } };
|
||||||
|
sum += result.structuredContent?.result ?? 0;
|
||||||
|
}
|
||||||
|
const afterCalls = await runtime.connect('demo', { disableOAuth: true });
|
||||||
|
const reused = afterCalls === initial;
|
||||||
|
console.log(`[demo] Pattern C — pre-connect(disableOAuth:true) + ${INVOCATIONS} callTool()`);
|
||||||
|
console.log(`[demo] Sum of ${INVOCATIONS} add() results: ${sum}`);
|
||||||
|
console.log(`[demo] Post-callTool connect() === pre-connect ClientContext: ${reused}`);
|
||||||
|
console.log(`[demo] Expected: true (no implicit eviction from callTool internals)`);
|
||||||
|
console.log(`[demo] Result: ${reused ? 'PASS' : 'FAIL'}\n`);
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
40
package.json
40
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcporter",
|
"name": "mcporter",
|
||||||
"version": "0.11.3",
|
"version": "0.12.1",
|
||||||
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
"description": "TypeScript runtime and CLI for connecting to configured Model Context Protocol servers.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
@ -72,31 +72,31 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iarna/toml": "^2.2.5",
|
"@iarna/toml": "^2.2.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.17.0",
|
||||||
"commander": "^14.0.3",
|
"commander": "^15.0.0",
|
||||||
"es-toolkit": "^1.46.1",
|
"es-toolkit": "^1.48.1",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"ora": "^9.4.0",
|
"ora": "^9.4.1",
|
||||||
"rolldown": "1.0.1",
|
"rolldown": "1.1.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/estree": "^1.0.9",
|
"@types/estree": "^1.0.9",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^26.0.0",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260514.1",
|
"@typescript/native-preview": "7.0.0-dev.20260623.1",
|
||||||
"@vitest/coverage-v8": "^4.1.6",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"bun-types": "^1.3.14",
|
"bun-types": "^1.3.14",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"oxfmt": "^0.49.0",
|
"oxfmt": "^0.56.0",
|
||||||
"oxlint": "^1.64.0",
|
"oxlint": "^1.71.0",
|
||||||
"oxlint-tsgolint": "^0.22.1",
|
"oxlint-tsgolint": "^0.23.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "8.0.13",
|
"vite": "8.0.16",
|
||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"devEngines": {
|
"devEngines": {
|
||||||
"runtime": [
|
"runtime": [
|
||||||
@ -109,13 +109,5 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2"
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"body-parser": "2.2.1",
|
|
||||||
"ip-address": "10.1.1",
|
|
||||||
"qs": "6.15.2",
|
|
||||||
"vite": "8.0.13"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
1418
pnpm-lock.yaml
generated
1418
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,5 +2,8 @@ onlyBuiltDependencies:
|
|||||||
- esbuild
|
- esbuild
|
||||||
overrides:
|
overrides:
|
||||||
body-parser: 2.2.1
|
body-parser: 2.2.1
|
||||||
|
esbuild: 0.28.1
|
||||||
|
hono: 4.12.25
|
||||||
ip-address: 10.1.1
|
ip-address: 10.1.1
|
||||||
vite: 8.0.13
|
qs: 6.15.2
|
||||||
|
vite: 8.0.16
|
||||||
|
|||||||
@ -74,16 +74,26 @@ export function metadataPathForArtifact(artifactPath: string): string {
|
|||||||
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
// readCliMetadata loads metadata for a generated CLI artifact, preferring the embedded
|
||||||
// inspect command and falling back to legacy sidecar files.
|
// inspect command and falling back to legacy sidecar files.
|
||||||
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
export async function readCliMetadata(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||||
|
let embeddedError: unknown;
|
||||||
|
try {
|
||||||
|
return await readMetadataFromCli(artifactPath);
|
||||||
|
} catch (error) {
|
||||||
|
embeddedError = error;
|
||||||
|
}
|
||||||
|
|
||||||
const legacyPath = metadataPathForArtifact(artifactPath);
|
const legacyPath = metadataPathForArtifact(artifactPath);
|
||||||
try {
|
try {
|
||||||
const buffer = await fs.readFile(legacyPath, 'utf8');
|
const buffer = await fs.readFile(legacyPath, 'utf8');
|
||||||
return JSON.parse(buffer) as CliArtifactMetadata;
|
return JSON.parse(buffer) as CliArtifactMetadata;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (isErrno(error, 'ENOENT') && embeddedError) {
|
||||||
|
throw embeddedError;
|
||||||
|
}
|
||||||
if (!isErrno(error, 'ENOENT')) {
|
if (!isErrno(error, 'ENOENT')) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return await readMetadataFromCli(artifactPath);
|
throw embeddedError;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
async function readMetadataFromCli(artifactPath: string): Promise<CliArtifactMetadata> {
|
||||||
|
|||||||
46
src/cli.ts
46
src/cli.ts
@ -239,16 +239,16 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
: null;
|
: null;
|
||||||
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
const runtime = createKeepAliveRuntime(baseRuntime, { daemonClient, keepAliveServers });
|
||||||
|
|
||||||
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
|
||||||
if (inference.kind === 'abort') {
|
|
||||||
process.exitCode = inference.exitCode;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolvedCommand = inference.command;
|
|
||||||
const resolvedArgs = inference.args;
|
|
||||||
|
|
||||||
let primaryError: unknown;
|
let primaryError: unknown;
|
||||||
try {
|
try {
|
||||||
|
const inference = inferCommandRouting(command, args, runtime.getDefinitions());
|
||||||
|
if (inference.kind === 'abort') {
|
||||||
|
process.exitCode = inference.exitCode;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolvedCommand = inference.command;
|
||||||
|
const resolvedArgs = inference.args;
|
||||||
|
|
||||||
if (resolvedCommand === 'list') {
|
if (resolvedCommand === 'list') {
|
||||||
if (consumeHelpTokens(resolvedArgs)) {
|
if (consumeHelpTokens(resolvedArgs)) {
|
||||||
const { printListHelp } = await import('./cli/list-command.js');
|
const { printListHelp } = await import('./cli/list-command.js');
|
||||||
@ -308,14 +308,15 @@ export async function runCli(argv: string[]): Promise<void> {
|
|||||||
await importedHandleResource(runtime, resolvedArgs);
|
await importedHandleResource(runtime, resolvedArgs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printHelp(`Unknown command '${resolvedCommand}'.`);
|
||||||
|
process.exit(1);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
primaryError = error;
|
primaryError = error;
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
|
await closeRuntimeAfterCommand(runtime, { suppressReplayCloseError: primaryError !== undefined });
|
||||||
}
|
}
|
||||||
printHelp(`Unknown command '${resolvedCommand}'.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function closeRuntimeAfterCommand(
|
async function closeRuntimeAfterCommand(
|
||||||
@ -479,6 +480,7 @@ async function maybeHandleSimpleDaemonFastCall(
|
|||||||
tool: parsed.tool,
|
tool: parsed.tool,
|
||||||
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
args: Object.keys(parsed.args).length > 0 ? parsed.args : undefined,
|
||||||
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
timeoutMs: resolveCallTimeout(parsed.timeoutMs),
|
||||||
|
disableOAuth: parsed.disableOAuth,
|
||||||
});
|
});
|
||||||
const { callResult } = wrapCallResult(result);
|
const { callResult } = wrapCallResult(result);
|
||||||
printCallOutput(callResult, result, parsed.output);
|
printCallOutput(callResult, result, parsed.output);
|
||||||
@ -583,6 +585,8 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
|||||||
server,
|
server,
|
||||||
includeSchema: options?.includeSchema,
|
includeSchema: options?.includeSchema,
|
||||||
autoAuthorize: options?.autoAuthorize,
|
autoAuthorize: options?.autoAuthorize,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
})) as Awaited<ReturnType<Runtime['listTools']>>,
|
||||||
callTool: (server, toolName, options) =>
|
callTool: (server, toolName, options) =>
|
||||||
daemonClient.callTool({
|
daemonClient.callTool({
|
||||||
@ -590,9 +594,27 @@ function createDaemonOnlyRuntime(daemonClient: import('./daemon/client.js').Daem
|
|||||||
tool: toolName,
|
tool: toolName,
|
||||||
args: options?.args,
|
args: options?.args,
|
||||||
timeoutMs: options?.timeoutMs,
|
timeoutMs: options?.timeoutMs,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
|
}),
|
||||||
|
listResources: (server, options) => {
|
||||||
|
const params: Record<string, unknown> = { ...options };
|
||||||
|
delete params.allowCachedAuth;
|
||||||
|
delete params.disableOAuth;
|
||||||
|
delete params.oauthSessionOptions;
|
||||||
|
return daemonClient.listResources({
|
||||||
|
server,
|
||||||
|
params,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
readResource: (server, uri, options) =>
|
||||||
|
daemonClient.readResource({
|
||||||
|
server,
|
||||||
|
uri,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
}),
|
}),
|
||||||
listResources: (server, options) => daemonClient.listResources({ server, params: options ?? {} }),
|
|
||||||
readResource: (server, uri) => daemonClient.readResource({ server, uri }),
|
|
||||||
connect: async (server) => {
|
connect: async (server) => {
|
||||||
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
throw new Error(`Server '${server}' is only available through daemon request methods.`);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -49,7 +49,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
|||||||
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
headers: __configInternals.ensureHttpAcceptHeader(spec.headers),
|
||||||
};
|
};
|
||||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||||
const name = slugify(spec.name ?? canonical ?? inferNameFromUrl(url));
|
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromUrl(url));
|
||||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||||
const definition: ServerDefinition = {
|
const definition: ServerDefinition = {
|
||||||
name,
|
name,
|
||||||
@ -84,7 +84,7 @@ export function resolveEphemeralServer(spec: EphemeralServerSpec): EphemeralServ
|
|||||||
cwd,
|
cwd,
|
||||||
};
|
};
|
||||||
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
const canonical = spec.name ? undefined : canonicalKeepAliveName(command);
|
||||||
const name = slugify(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
const name = normalizeEphemeralName(spec.name ?? canonical ?? inferNameFromCommand(parts));
|
||||||
const lifecycle = resolveLifecycle(name, undefined, command);
|
const lifecycle = resolveLifecycle(name, undefined, command);
|
||||||
const definition: ServerDefinition = {
|
const definition: ServerDefinition = {
|
||||||
name,
|
name,
|
||||||
@ -206,6 +206,14 @@ function slugify(value: string): string {
|
|||||||
.replace(/-{2,}/g, '-');
|
.replace(/-{2,}/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEphemeralName(value: string): string {
|
||||||
|
const name = slugify(value);
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('Ad-hoc server name must contain at least one letter or digit.');
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
export function splitCommandLine(input: string): string[] {
|
export function splitCommandLine(input: string): string[] {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
let current = '';
|
let current = '';
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export interface CallArgsParseResult {
|
|||||||
tailLog: boolean;
|
tailLog: boolean;
|
||||||
output: OutputFormat;
|
output: OutputFormat;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
disableOAuth?: boolean;
|
||||||
ephemeral?: EphemeralServerSpec;
|
ephemeral?: EphemeralServerSpec;
|
||||||
rawStrings?: boolean;
|
rawStrings?: boolean;
|
||||||
saveImagesDir?: string;
|
saveImagesDir?: string;
|
||||||
@ -59,6 +60,7 @@ const FLAG_HANDLERS: Record<string, FlagHandler> = {
|
|||||||
'--tool': handleToolFlag,
|
'--tool': handleToolFlag,
|
||||||
'--timeout': handleTimeoutFlag,
|
'--timeout': handleTimeoutFlag,
|
||||||
'--tail-log': handleTailLogFlag,
|
'--tail-log': handleTailLogFlag,
|
||||||
|
'--no-oauth': handleDisableOAuthFlag,
|
||||||
'--save-images': handleSaveImagesFlag,
|
'--save-images': handleSaveImagesFlag,
|
||||||
'--yes': handleNoopFlag,
|
'--yes': handleNoopFlag,
|
||||||
'--raw-strings': handleRawStringsFlag,
|
'--raw-strings': handleRawStringsFlag,
|
||||||
@ -191,7 +193,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
index += parsed.consumed;
|
index += parsed.consumed;
|
||||||
const value = coerceValue(parsed.rawValue, state.coercionMode);
|
const { value, schemaValue } = resolveNamedArgumentValue(parsed.rawValue, state.coercionMode);
|
||||||
if (parsed.key === 'tool' && !result.tool) {
|
if (parsed.key === 'tool' && !result.tool) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new Error("Argument 'tool' must be a string value.");
|
throw new Error("Argument 'tool' must be a string value.");
|
||||||
@ -208,7 +210,7 @@ function applyTrailingArguments(positional: string[], result: CallArgsParseResul
|
|||||||
}
|
}
|
||||||
if (state.coercionMode === 'default' && typeof value === 'number') {
|
if (state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
result.schemaStringCoercionCandidates ??= {};
|
result.schemaStringCoercionCandidates ??= {};
|
||||||
result.schemaStringCoercionCandidates[parsed.key] = parsed.rawValue;
|
result.schemaStringCoercionCandidates[parsed.key] = schemaValue;
|
||||||
}
|
}
|
||||||
result.args[parsed.key] = value;
|
result.args[parsed.key] = value;
|
||||||
}
|
}
|
||||||
@ -256,6 +258,11 @@ function handleTailLogFlag(context: FlagHandlerContext): number {
|
|||||||
return context.index + 1;
|
return context.index + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDisableOAuthFlag(context: FlagHandlerContext): number {
|
||||||
|
context.result.disableOAuth = true;
|
||||||
|
return context.index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
function handleSaveImagesFlag(context: FlagHandlerContext): number {
|
function handleSaveImagesFlag(context: FlagHandlerContext): number {
|
||||||
context.result.saveImagesDir = consumeFlagValue(
|
context.result.saveImagesDir = consumeFlagValue(
|
||||||
context.args,
|
context.args,
|
||||||
@ -320,18 +327,53 @@ function handleNamedArgumentFlag(context: FlagHandlerContext): number {
|
|||||||
eqIndex === -1
|
eqIndex === -1
|
||||||
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
? consumeFlagValue(context.args, context.index, token, `Flag '${token}' requires a value.`)
|
||||||
: body.slice(eqIndex + 1);
|
: body.slice(eqIndex + 1);
|
||||||
const value = coerceValue(rawValue, context.state.coercionMode);
|
const { value, schemaValue } = resolveNamedArgumentValue(rawValue, context.state.coercionMode);
|
||||||
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
if (context.state.coercionMode === 'default' && typeof value === 'number') {
|
||||||
context.result.schemaStringCoercionCandidates ??= {};
|
context.result.schemaStringCoercionCandidates ??= {};
|
||||||
context.result.schemaStringCoercionCandidates[key] = rawValue;
|
context.result.schemaStringCoercionCandidates[key] = schemaValue;
|
||||||
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
} else if (context.state.coercionMode === 'default' && typeof value === 'string') {
|
||||||
context.result.schemaArrayCoercionCandidates ??= {};
|
context.result.schemaArrayCoercionCandidates ??= {};
|
||||||
context.result.schemaArrayCoercionCandidates[key] = rawValue;
|
context.result.schemaArrayCoercionCandidates[key] = schemaValue;
|
||||||
}
|
}
|
||||||
context.result.args[key] = value;
|
context.result.args[key] = value;
|
||||||
return context.index + (eqIndex === -1 ? 2 : 1);
|
return context.index + (eqIndex === -1 ? 2 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNamedArgumentValue(
|
||||||
|
rawValue: string,
|
||||||
|
coercionMode: CoercionMode
|
||||||
|
): { value: unknown; schemaValue: string } {
|
||||||
|
if (rawValue.startsWith('@@')) {
|
||||||
|
const literal = rawValue.slice(1);
|
||||||
|
return { value: literal, schemaValue: literal };
|
||||||
|
}
|
||||||
|
if (rawValue.length > 0 && rawValue.trim() === '') {
|
||||||
|
return { value: rawValue, schemaValue: rawValue };
|
||||||
|
}
|
||||||
|
if (!rawValue.startsWith('@')) {
|
||||||
|
return { value: coerceValue(rawValue, coercionMode), schemaValue: rawValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = rawValue.slice(1);
|
||||||
|
if (!filePath) {
|
||||||
|
throw new CliUsageError("Argument file reference '@' requires a path. Use '@@' for a literal leading '@'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let contents: Buffer;
|
||||||
|
try {
|
||||||
|
contents = fs.readFileSync(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new CliUsageError(`Unable to read argument file '${filePath}': ${detail}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: true }).decode(contents);
|
||||||
|
return { value: text, schemaValue: text };
|
||||||
|
} catch {
|
||||||
|
throw new CliUsageError(`Argument file '${filePath}' is not valid UTF-8 text.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
function normalizeLongFlagArgumentKey(rawKey: string): string {
|
||||||
if (!rawKey || rawKey.startsWith('-')) {
|
if (!rawKey || rawKey.startsWith('-')) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@ -39,6 +39,7 @@ interface PreparedCallRequest extends ResolvedCallTarget {
|
|||||||
parsed: CallArgsParseResult;
|
parsed: CallArgsParseResult;
|
||||||
hydratedArgs: Record<string, unknown>;
|
hydratedArgs: Record<string, unknown>;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
disableOAuth?: boolean;
|
||||||
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
ephemeralTarget?: PrepareEphemeralServerTargetResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,12 +67,19 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
|||||||
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
const ephemeralTarget = await normalizeParsedCallArguments(runtime, parsed);
|
||||||
const { server, tool } = await resolveServerAndTool(runtime, parsed);
|
const { server, tool } = await resolveServerAndTool(runtime, parsed);
|
||||||
|
|
||||||
if (await maybeDescribeServer(runtime, server, tool, parsed.output)) {
|
if (await maybeDescribeServer(runtime, server, tool, parsed.output, parsed.disableOAuth)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
const timeoutMs = resolveCallTimeout(parsed.timeoutMs);
|
||||||
const hydratedArgs = await hydratePositionalArguments(runtime, server, tool, parsed.args, parsed.positionalArgs);
|
const hydratedArgs = await hydratePositionalArguments(
|
||||||
|
runtime,
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
parsed.args,
|
||||||
|
parsed.positionalArgs,
|
||||||
|
parsed.disableOAuth
|
||||||
|
);
|
||||||
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
const schemaAwareArgs = await enforceSchemaAwareArgumentTypes(
|
||||||
runtime,
|
runtime,
|
||||||
server,
|
server,
|
||||||
@ -79,9 +87,18 @@ async function prepareCallRequest(runtime: Runtime, args: string[]): Promise<Pre
|
|||||||
hydratedArgs,
|
hydratedArgs,
|
||||||
parsed.schemaStringCoercionCandidates,
|
parsed.schemaStringCoercionCandidates,
|
||||||
parsed.schemaArrayCoercionCandidates,
|
parsed.schemaArrayCoercionCandidates,
|
||||||
timeoutMs
|
timeoutMs,
|
||||||
|
parsed.disableOAuth
|
||||||
);
|
);
|
||||||
return { parsed, server, tool, hydratedArgs: schemaAwareArgs, timeoutMs, ephemeralTarget };
|
return {
|
||||||
|
parsed,
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
hydratedArgs: schemaAwareArgs,
|
||||||
|
timeoutMs,
|
||||||
|
disableOAuth: parsed.disableOAuth,
|
||||||
|
ephemeralTarget,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function normalizeParsedCallArguments(
|
async function normalizeParsedCallArguments(
|
||||||
@ -145,7 +162,7 @@ async function resolveServerAndTool(runtime: Runtime, parsed: CallArgsParseResul
|
|||||||
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
|
throw new Error('Missing server name. Provide it via <server>.<tool> or --server.');
|
||||||
}
|
}
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
tool = await inferSingleToolName(runtime, server);
|
tool = await inferSingleToolName(runtime, server, parsed.disableOAuth);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
throw new Error('Missing tool name. Provide it via <server>.<tool> or --tool.');
|
||||||
}
|
}
|
||||||
@ -165,7 +182,8 @@ async function invokePreparedCall(
|
|||||||
prepared.tool,
|
prepared.tool,
|
||||||
prepared.hydratedArgs,
|
prepared.hydratedArgs,
|
||||||
prepared.timeoutMs,
|
prepared.timeoutMs,
|
||||||
prepared.parsed.output
|
prepared.parsed.output,
|
||||||
|
prepared.disableOAuth
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
const issue = maybeReportConnectionIssue(prepared.server, prepared.tool, error);
|
||||||
@ -224,11 +242,15 @@ async function maybeDescribeServer(
|
|||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string,
|
server: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
outputFormat: OutputFormat
|
outputFormat: OutputFormat,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (tool === 'list_tools') {
|
if (tool === 'list_tools') {
|
||||||
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
console.log(dimText(`[mcporter] ${server}.list_tools is a shortcut for 'mcporter list ${server}'.`));
|
||||||
const listArgs = [server];
|
const listArgs = [server];
|
||||||
|
if (disableOAuth) {
|
||||||
|
listArgs.push('--no-oauth');
|
||||||
|
}
|
||||||
if (outputFormat === 'json') {
|
if (outputFormat === 'json') {
|
||||||
listArgs.push('--json');
|
listArgs.push('--json');
|
||||||
}
|
}
|
||||||
@ -239,7 +261,9 @@ async function maybeDescribeServer(
|
|||||||
if (tool !== 'help') {
|
if (tool !== 'help') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const tools = await runtime.listTools(server, { includeSchema: false, autoAuthorize: false }).catch(() => undefined);
|
const tools = await runtime
|
||||||
|
.listTools(server, { includeSchema: false, autoAuthorize: false, disableOAuth })
|
||||||
|
.catch(() => undefined);
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -249,6 +273,9 @@ async function maybeDescribeServer(
|
|||||||
}
|
}
|
||||||
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
|
console.log(dimText(`[mcporter] ${server} does not expose a 'help' tool; showing mcporter list output instead.`));
|
||||||
const listArgs = [server];
|
const listArgs = [server];
|
||||||
|
if (disableOAuth) {
|
||||||
|
listArgs.push('--no-oauth');
|
||||||
|
}
|
||||||
if (outputFormat === 'json') {
|
if (outputFormat === 'json') {
|
||||||
listArgs.push('--json');
|
listArgs.push('--json');
|
||||||
}
|
}
|
||||||
@ -296,7 +323,8 @@ async function enforceSchemaAwareArgumentTypes(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
stringCandidates: Record<string, string> | undefined,
|
stringCandidates: Record<string, string> | undefined,
|
||||||
arrayCandidates: Record<string, string> | undefined,
|
arrayCandidates: Record<string, string> | undefined,
|
||||||
timeoutMs: number
|
timeoutMs: number,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (
|
if (
|
||||||
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
(!stringCandidates || Object.keys(stringCandidates).length === 0) &&
|
||||||
@ -305,9 +333,10 @@ async function enforceSchemaAwareArgumentTypes(
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = await withTimeout(loadToolMetadata(runtime, server, { includeSchema: true }), timeoutMs).catch(
|
const tools = await withTimeout(
|
||||||
() => undefined
|
loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }),
|
||||||
);
|
timeoutMs
|
||||||
|
).catch(() => undefined);
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
@ -389,14 +418,15 @@ async function hydratePositionalArguments(
|
|||||||
server: string,
|
server: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
namedArgs: Record<string, unknown>,
|
namedArgs: Record<string, unknown>,
|
||||||
positionalArgs: unknown[] | undefined
|
positionalArgs: unknown[] | undefined,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
if (!positionalArgs || positionalArgs.length === 0) {
|
if (!positionalArgs || positionalArgs.length === 0) {
|
||||||
return namedArgs;
|
return namedArgs;
|
||||||
}
|
}
|
||||||
// We need the schema order to know which field each positional argument maps to; pull the
|
// We need the schema order to know which field each positional argument maps to; pull the
|
||||||
// tool list with schemas instead of guessing locally so optional/required order stays correct.
|
// tool list with schemas instead of guessing locally so optional/required order stays correct.
|
||||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: true }).catch(() => undefined);
|
const tools = await loadToolMetadata(runtime, server, { includeSchema: true, disableOAuth }).catch(() => undefined);
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
throw new Error('Unable to load tool metadata; name positional arguments explicitly.');
|
||||||
}
|
}
|
||||||
@ -436,9 +466,10 @@ type ToolResolution = IdentifierResolution;
|
|||||||
|
|
||||||
async function inferSingleToolName(
|
async function inferSingleToolName(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string
|
server: string,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false });
|
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth });
|
||||||
if (tools.length !== 1) {
|
if (tools.length !== 1) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -456,10 +487,11 @@ async function invokeWithAutoCorrection(
|
|||||||
tool: string,
|
tool: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat
|
outputFormat: OutputFormat,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||||
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
|
// Attempt the original request first; if it fails with a "tool not found" we opportunistically retry once with a better match.
|
||||||
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true);
|
return attemptCall(runtime, server, tool, args, timeoutMs, outputFormat, true, disableOAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function attemptCall(
|
async function attemptCall(
|
||||||
@ -469,14 +501,24 @@ async function attemptCall(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat,
|
outputFormat: OutputFormat,
|
||||||
allowCorrection: boolean
|
allowCorrection: boolean,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string }> {
|
): Promise<{ result: unknown; resolvedTool: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs }), timeoutMs);
|
const result = await withTimeout(runtime.callTool(server, tool, { args, timeoutMs, disableOAuth }), timeoutMs);
|
||||||
if (allowCorrection && isErrorCallResult(result)) {
|
if (allowCorrection && isErrorCallResult(result)) {
|
||||||
const resolution = await maybeResolveToolName(runtime, server, tool, result);
|
const resolution = await maybeResolveToolName(runtime, server, tool, result, disableOAuth);
|
||||||
if (resolution) {
|
if (resolution) {
|
||||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
const retry = await maybeRetryResolvedTool(
|
||||||
|
runtime,
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
args,
|
||||||
|
timeoutMs,
|
||||||
|
outputFormat,
|
||||||
|
resolution,
|
||||||
|
disableOAuth
|
||||||
|
);
|
||||||
if (retry) {
|
if (retry) {
|
||||||
return retry;
|
return retry;
|
||||||
}
|
}
|
||||||
@ -497,13 +539,22 @@ async function attemptCall(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolution = await maybeResolveToolName(runtime, server, tool, error);
|
const resolution = await maybeResolveToolName(runtime, server, tool, error, disableOAuth);
|
||||||
if (!resolution) {
|
if (!resolution) {
|
||||||
maybeReportConnectionIssue(server, tool, error);
|
maybeReportConnectionIssue(server, tool, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = await maybeRetryResolvedTool(runtime, server, tool, args, timeoutMs, outputFormat, resolution);
|
const retry = await maybeRetryResolvedTool(
|
||||||
|
runtime,
|
||||||
|
server,
|
||||||
|
tool,
|
||||||
|
args,
|
||||||
|
timeoutMs,
|
||||||
|
outputFormat,
|
||||||
|
resolution,
|
||||||
|
disableOAuth
|
||||||
|
);
|
||||||
if (!retry) {
|
if (!retry) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -518,7 +569,8 @@ async function maybeRetryResolvedTool(
|
|||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
outputFormat: OutputFormat,
|
outputFormat: OutputFormat,
|
||||||
resolution: ToolResolution
|
resolution: ToolResolution,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
): Promise<{ result: unknown; resolvedTool: string } | undefined> {
|
||||||
const messages = renderIdentifierResolutionMessages({
|
const messages = renderIdentifierResolutionMessages({
|
||||||
entity: 'tool',
|
entity: 'tool',
|
||||||
@ -536,14 +588,15 @@ async function maybeRetryResolvedTool(
|
|||||||
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
const emitAutoMessage = outputFormat === 'json' || outputFormat === 'raw' ? console.error : console.log;
|
||||||
emitAutoMessage(dimText(messages.auto));
|
emitAutoMessage(dimText(messages.auto));
|
||||||
}
|
}
|
||||||
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false);
|
return attemptCall(runtime, server, resolution.value, args, timeoutMs, outputFormat, false, disableOAuth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeResolveToolName(
|
async function maybeResolveToolName(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: string,
|
server: string,
|
||||||
attemptedTool: string,
|
attemptedTool: string,
|
||||||
error: unknown
|
error: unknown,
|
||||||
|
disableOAuth: boolean | undefined
|
||||||
): Promise<ToolResolution | undefined> {
|
): Promise<ToolResolution | undefined> {
|
||||||
const missingName = extractMissingToolFromError(error);
|
const missingName = extractMissingToolFromError(error);
|
||||||
if (!missingName) {
|
if (!missingName) {
|
||||||
@ -555,7 +608,7 @@ async function maybeResolveToolName(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = await loadToolMetadata(runtime, server, { includeSchema: false }).catch(() => undefined);
|
const tools = await loadToolMetadata(runtime, server, { includeSchema: false, disableOAuth }).catch(() => undefined);
|
||||||
if (!tools) {
|
if (!tools) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export const CALL_HELP_ARGUMENT_LINES = [
|
export const CALL_HELP_ARGUMENT_LINES = [
|
||||||
' key=value / key:value Flag-style named arguments.',
|
' key=value / key:value Flag-style named arguments.',
|
||||||
|
' key=@path Read a UTF-8 string value from a file; use @@ for a literal @.',
|
||||||
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
' function-call syntax \'server.tool(arg: "value", other: 1)\'.',
|
||||||
' --args <json> Provide a JSON object payload.',
|
' --args <json> Provide a JSON object payload.',
|
||||||
' positional values Accepted when schema order is known.',
|
' positional values Accepted when schema order is known.',
|
||||||
@ -10,6 +11,7 @@ export const CALL_HELP_RUNTIME_FLAG_LINES = [
|
|||||||
' --timeout <ms> Override the call timeout.',
|
' --timeout <ms> Override the call timeout.',
|
||||||
' --output text|markdown|json|raw Control formatting.',
|
' --output text|markdown|json|raw Control formatting.',
|
||||||
' --save-images <dir> Save image content blocks to a directory.',
|
' --save-images <dir> Save image content blocks to a directory.',
|
||||||
|
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||||
' --raw-strings Keep numeric-looking argument values as strings.',
|
' --raw-strings Keep numeric-looking argument values as strings.',
|
||||||
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
' --no-coerce Keep all key/value and positional arguments as raw strings.',
|
||||||
' --tail-log Stream returned log handles.',
|
' --tail-log Stream returned log handles.',
|
||||||
@ -31,6 +33,7 @@ export const CALL_HELP_ADHOC_SERVER_LINES = [
|
|||||||
|
|
||||||
export const CALL_HELP_EXAMPLE_LINES = [
|
export const CALL_HELP_EXAMPLE_LINES = [
|
||||||
' mcporter call linear.list_issues team=ENG limit:5',
|
' mcporter call linear.list_issues team=ENG limit:5',
|
||||||
|
' mcporter call linear.create_comment body=@comment.md',
|
||||||
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
' mcporter call "linear.create_issue(title: \\"Bug\\", team: \\"ENG\\")"',
|
||||||
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
' mcporter call https://api.example.com/mcp.fetch url:https://example.com',
|
||||||
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
' mcporter call --stdio "bun run ./server.ts" scrape url=https://example.com',
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { resolveConfigPath } from '../config/path-discovery.js';
|
|||||||
import { parseLogLevel } from '../logging.js';
|
import { parseLogLevel } from '../logging.js';
|
||||||
import { extractFlags } from './flag-utils.js';
|
import { extractFlags } from './flag-utils.js';
|
||||||
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
import { getActiveLogger, getActiveLogLevel, logError, setLogLevel } from './logger-context.js';
|
||||||
|
import { parsePositiveInteger } from './timeouts.js';
|
||||||
|
|
||||||
export interface GlobalCliContext {
|
export interface GlobalCliContext {
|
||||||
readonly globalFlags: Record<string, string | undefined>;
|
readonly globalFlags: Record<string, string | undefined>;
|
||||||
@ -29,8 +30,8 @@ export function buildGlobalContext(argv: string[]): GlobalCliContext | { exit: t
|
|||||||
|
|
||||||
let oauthTimeoutOverride: number | undefined;
|
let oauthTimeoutOverride: number | undefined;
|
||||||
if (globalFlags['--oauth-timeout']) {
|
if (globalFlags['--oauth-timeout']) {
|
||||||
const parsed = Number.parseInt(globalFlags['--oauth-timeout'], 10);
|
const parsed = parsePositiveInteger(globalFlags['--oauth-timeout']);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
logError("Flag '--oauth-timeout' must be a positive integer (milliseconds).");
|
||||||
return { exit: true, code: 1 };
|
return { exit: true, code: 1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -259,7 +259,7 @@ async function writeFile(targetPath: string, contents: string): Promise<void> {
|
|||||||
function computeImportPath(fromPath: string, typesPath: string): string {
|
function computeImportPath(fromPath: string, typesPath: string): string {
|
||||||
const fromDir = path.dirname(fromPath);
|
const fromDir = path.dirname(fromPath);
|
||||||
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
|
const relative = path.relative(fromDir, typesPath).replace(/\\/g, '/');
|
||||||
const withoutExt = relative.replace(/\.[^.]+$/, '');
|
const withoutExt = relative.endsWith('.d.ts') ? relative.slice(0, -5) : relative.replace(/\.[^.]+$/, '');
|
||||||
if (withoutExt.startsWith('.')) {
|
if (withoutExt.startsWith('.')) {
|
||||||
return withoutExt;
|
return withoutExt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { parsePositiveInteger } from '../timeouts.js';
|
||||||
|
|
||||||
export interface GeneratorCommonFlags {
|
export interface GeneratorCommonFlags {
|
||||||
runtime?: 'node' | 'bun';
|
runtime?: 'node' | 'bun';
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
@ -31,8 +33,8 @@ export function extractGeneratorFlags(args: string[], options: ExtractOptions =
|
|||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error("Flag '--timeout' requires a value.");
|
throw new Error("Flag '--timeout' requires a value.");
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(raw, 10);
|
const parsed = parsePositiveInteger(raw);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
throw new Error('--timeout must be a positive integer.');
|
throw new Error('--timeout must be a positive integer.');
|
||||||
}
|
}
|
||||||
result.timeout = parsed;
|
result.timeout = parsed;
|
||||||
|
|||||||
@ -101,6 +101,7 @@ export function renderTemplate({
|
|||||||
tool: entry.tool,
|
tool: entry.tool,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
assertUniqueGeneratedCommandNames(renderedTools);
|
||||||
const toolHelp = renderedTools.map((entry) => ({
|
const toolHelp = renderedTools.map((entry) => ({
|
||||||
name: entry.commandName,
|
name: entry.commandName,
|
||||||
description: entry.tool.tool.description ?? '',
|
description: entry.tool.tool.description ?? '',
|
||||||
@ -237,7 +238,7 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
|||||||
\t}
|
\t}
|
||||||
\tconst values = value.split(',').map((entry) => entry.trim());
|
\tconst values = value.split(',').map((entry) => entry.trim());
|
||||||
\tif (itemType === 'number') {
|
\tif (itemType === 'number') {
|
||||||
\t\treturn values.map((entry) => parseFloat(entry));
|
\t\treturn values.map((entry) => parseFiniteNumber(entry));
|
||||||
\t}
|
\t}
|
||||||
\tif (itemType === 'boolean') {
|
\tif (itemType === 'boolean') {
|
||||||
\t\treturn values.map((entry) => entry !== 'false');
|
\t\treturn values.map((entry) => entry !== 'false');
|
||||||
@ -245,6 +246,15 @@ function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolea
|
|||||||
\treturn values;
|
\treturn values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseFiniteNumber(value: string): number {
|
||||||
|
\tconst trimmed = value.trim();
|
||||||
|
\tconst parsed = Number(trimmed);
|
||||||
|
\tif (trimmed === '' || !Number.isFinite(parsed)) {
|
||||||
|
\t\tthrow new Error('Expected a finite number.');
|
||||||
|
\t}
|
||||||
|
\treturn parsed;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
||||||
\tconst base = { ...server } as Record<string, unknown>;
|
\tconst base = { ...server } as Record<string, unknown>;
|
||||||
\tif ((server.command as any).kind === 'http') {
|
\tif ((server.command as any).kind === 'http') {
|
||||||
@ -462,7 +472,9 @@ export function renderToolCommand(
|
|||||||
({ option, camelCaseProp }) =>
|
({ option, camelCaseProp }) =>
|
||||||
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
||||||
)
|
)
|
||||||
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
|
.join(
|
||||||
|
', '
|
||||||
|
)}].filter((entry) => entry.value === undefined || (typeof entry.value === 'string' && entry.value.trim() === '')).map((entry) => entry.flag);
|
||||||
\t\t\tif (missingRequired.length > 0) {
|
\t\t\tif (missingRequired.length > 0) {
|
||||||
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
||||||
\t\t\t}`
|
\t\t\t}`
|
||||||
@ -549,7 +561,7 @@ export const templateTestHelpers = { computeRelativeStdioCwd };
|
|||||||
function optionParser(option: GeneratedOption): string | undefined {
|
function optionParser(option: GeneratedOption): string | undefined {
|
||||||
switch (option.type) {
|
switch (option.type) {
|
||||||
case 'number':
|
case 'number':
|
||||||
return '(value) => parseFloat(value)';
|
return '(value) => parseFiniteNumber(value)';
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return "(value) => value !== 'false'";
|
return "(value) => value !== 'false'";
|
||||||
case 'object':
|
case 'object':
|
||||||
@ -570,3 +582,16 @@ function optionParser(option: GeneratedOption): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertUniqueGeneratedCommandNames(tools: Array<{ commandName: string; tool: ToolMetadata }>): void {
|
||||||
|
const commands = new Map<string, string>();
|
||||||
|
for (const entry of tools) {
|
||||||
|
const previous = commands.get(entry.commandName);
|
||||||
|
if (previous) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated command name collision '${entry.commandName}' for tools '${previous}' and '${entry.tool.tool.name}'.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
commands.set(entry.commandName, entry.tool.tool.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -50,6 +50,27 @@ export function buildToolMetadata(tool: ServerToolInfo): ToolMetadata {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildToolMetadataList(
|
||||||
|
tools: ServerToolInfo[],
|
||||||
|
options: { readonly sort?: boolean } = {}
|
||||||
|
): ToolMetadata[] {
|
||||||
|
const result = tools.map((tool) => buildToolMetadata(tool));
|
||||||
|
if (options.sort !== false) {
|
||||||
|
result.sort((left, right) => left.tool.name.localeCompare(right.tool.name));
|
||||||
|
}
|
||||||
|
const methods = new Map<string, string>();
|
||||||
|
for (const entry of result) {
|
||||||
|
const previous = methods.get(entry.methodName);
|
||||||
|
if (previous) {
|
||||||
|
throw new Error(
|
||||||
|
`Generated proxy method collision '${entry.methodName}' for tools '${previous}' and '${entry.tool.name}'.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
methods.set(entry.methodName, entry.tool.name);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildEmbeddedSchemaMap(tools: ToolMetadata[]): Record<string, unknown> {
|
export function buildEmbeddedSchemaMap(tools: ToolMetadata[]): Record<string, unknown> {
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const entry of tools.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name))) {
|
for (const entry of tools.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name))) {
|
||||||
|
|||||||
@ -93,6 +93,9 @@ function parseInspectFlags(args: string[]): InspectFlags {
|
|||||||
if (!artifactPath) {
|
if (!artifactPath) {
|
||||||
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
|
throw new Error('Usage: mcporter inspect-cli <artifact> [--json]');
|
||||||
}
|
}
|
||||||
|
if (args.length > 0) {
|
||||||
|
throw new Error(`Unexpected inspect-cli argument '${args[0]}'.`);
|
||||||
|
}
|
||||||
return { artifactPath, format };
|
return { artifactPath, format };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export async function handleList(
|
|||||||
let completedCount = 0;
|
let completedCount = 0;
|
||||||
|
|
||||||
const tasks = servers.map((server, index) =>
|
const tasks = servers.map((server, index) =>
|
||||||
checkListServer(runtime, server, perServerTimeoutMs).then((result) => {
|
checkListServer(runtime, server, perServerTimeoutMs, flags.disableOAuth).then((result) => {
|
||||||
summaryResults[index] = result;
|
summaryResults[index] = result;
|
||||||
if (renderedResults) {
|
if (renderedResults) {
|
||||||
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
const rendered = renderServerListRow(result, perServerTimeoutMs, { verbose: flags.verbose });
|
||||||
@ -175,7 +175,7 @@ export async function handleList(
|
|||||||
|
|
||||||
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
|
const resolved = resolveServerDefinition(runtime, target, { quiet: flags.quiet });
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
maybeSetListExitCode([{ status: 'error' }], flags);
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target = resolved.name;
|
target = resolved.name;
|
||||||
@ -190,7 +190,7 @@ export async function handleList(
|
|||||||
if (flags.statusOnly) {
|
if (flags.statusOnly) {
|
||||||
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
const previousStdioLogMode = flags.quiet || flags.format === 'json' ? setStdioLogMode('silent') : undefined;
|
||||||
try {
|
try {
|
||||||
const result = await checkListServer(runtime, definition, timeoutMs);
|
const result = await checkListServer(runtime, definition, timeoutMs, flags.disableOAuth);
|
||||||
await persistPreparedEphemeralServer(runtime, prepared);
|
await persistPreparedEphemeralServer(runtime, prepared);
|
||||||
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
|
const entry = buildJsonListEntry(result, Math.round(timeoutMs / 1000), {
|
||||||
includeSchemas: false,
|
includeSchemas: false,
|
||||||
@ -228,6 +228,7 @@ export async function handleList(
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: flags.disableOAuth,
|
||||||
}),
|
}),
|
||||||
timeoutMs
|
timeoutMs
|
||||||
),
|
),
|
||||||
@ -298,6 +299,7 @@ export async function handleList(
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: flags.disableOAuth,
|
||||||
}),
|
}),
|
||||||
timeoutMs
|
timeoutMs
|
||||||
),
|
),
|
||||||
@ -397,12 +399,13 @@ export async function handleList(
|
|||||||
async function checkListServer(
|
async function checkListServer(
|
||||||
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
runtime: Awaited<ReturnType<(typeof import('../runtime.js'))['createRuntime']>>,
|
||||||
server: ServerDefinition,
|
server: ServerDefinition,
|
||||||
timeoutMs: number
|
timeoutMs: number,
|
||||||
|
disableOAuth: boolean
|
||||||
): Promise<ListSummaryResult> {
|
): Promise<ListSummaryResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const tools = await withTimeout(
|
const tools = await withTimeout(
|
||||||
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true }),
|
runtime.listTools(server.name, { autoAuthorize: false, allowCachedAuth: true, disableOAuth }),
|
||||||
timeoutMs
|
timeoutMs
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -483,6 +486,7 @@ export function printListHelp(): void {
|
|||||||
' --verbose Show all config sources for matching servers.',
|
' --verbose Show all config sources for matching servers.',
|
||||||
' --sources Include source arrays in JSON output without other verbose details.',
|
' --sources Include source arrays in JSON output without other verbose details.',
|
||||||
' --timeout <ms> Override the per-server discovery timeout.',
|
' --timeout <ms> Override the per-server discovery timeout.',
|
||||||
|
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||||
'',
|
'',
|
||||||
'Examples:',
|
'Examples:',
|
||||||
' mcporter list',
|
' mcporter list',
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function extractListFlags(args: string[]): {
|
|||||||
quiet: boolean;
|
quiet: boolean;
|
||||||
exitCode: boolean;
|
exitCode: boolean;
|
||||||
statusOnly: boolean;
|
statusOnly: boolean;
|
||||||
|
disableOAuth: boolean;
|
||||||
} {
|
} {
|
||||||
let schema = false;
|
let schema = false;
|
||||||
let timeoutMs: number | undefined;
|
let timeoutMs: number | undefined;
|
||||||
@ -27,6 +28,7 @@ export function extractListFlags(args: string[]): {
|
|||||||
let quiet = false;
|
let quiet = false;
|
||||||
let exitCode = false;
|
let exitCode = false;
|
||||||
let statusOnly = false;
|
let statusOnly = false;
|
||||||
|
let disableOAuth = false;
|
||||||
const format = consumeOutputFormat(args, {
|
const format = consumeOutputFormat(args, {
|
||||||
defaultFormat: 'text',
|
defaultFormat: 'text',
|
||||||
allowed: ['text', 'json'],
|
allowed: ['text', 'json'],
|
||||||
@ -82,6 +84,11 @@ export function extractListFlags(args: string[]): {
|
|||||||
args.splice(index, 1);
|
args.splice(index, 1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (token === '--no-oauth') {
|
||||||
|
disableOAuth = true;
|
||||||
|
args.splice(index, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (token === '--timeout') {
|
if (token === '--timeout') {
|
||||||
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
timeoutMs = consumeTimeoutFlag(args, index, { flagName: '--timeout' });
|
||||||
continue;
|
continue;
|
||||||
@ -133,5 +140,6 @@ export function extractListFlags(args: string[]): {
|
|||||||
quiet,
|
quiet,
|
||||||
exitCode,
|
exitCode,
|
||||||
statusOnly,
|
statusOnly,
|
||||||
|
disableOAuth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -266,5 +266,5 @@ function quoteCommandSegment(segment: string): string {
|
|||||||
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
|
if (/^[A-Za-z0-9_./:-]+$/.test(segment)) {
|
||||||
return segment;
|
return segment;
|
||||||
}
|
}
|
||||||
return JSON.stringify(segment);
|
return `'${segment.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
import { inspect } from 'node:util';
|
import { inspect } from 'node:util';
|
||||||
import type { CallResult } from '../result-utils.js';
|
import type { CallResult } from '../result-utils.js';
|
||||||
import { logWarn } from './logger-context.js';
|
import { logWarn } from './logger-context.js';
|
||||||
@ -33,17 +34,8 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
if (typeof result === 'string') {
|
|
||||||
const idx = result.indexOf(':');
|
|
||||||
if (idx !== -1) {
|
|
||||||
const candidate = result.slice(idx + 1).trim();
|
|
||||||
if (candidate) {
|
|
||||||
candidates.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (result && typeof result === 'object') {
|
if (result && typeof result === 'object') {
|
||||||
const possibleKeys = ['logPath', 'logFile', 'logfile', 'path'];
|
const possibleKeys = ['logPath', 'logFile', 'logfile'];
|
||||||
for (const key of possibleKeys) {
|
for (const key of possibleKeys) {
|
||||||
const value = (result as Record<string, unknown>)[key];
|
const value = (result as Record<string, unknown>)[key];
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@ -53,6 +45,10 @@ export function tailLogIfRequested(result: unknown, enabled: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
|
if (!path.isAbsolute(candidate)) {
|
||||||
|
logWarn(`Refusing to tail non-absolute log path: ${candidate}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!fs.existsSync(candidate)) {
|
if (!fs.existsSync(candidate)) {
|
||||||
logWarn(`Log path not found: ${candidate}`);
|
logWarn(`Log path not found: ${candidate}`);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
|||||||
enableRawShortcut: true,
|
enableRawShortcut: true,
|
||||||
jsonShortcutFlag: '--json',
|
jsonShortcutFlag: '--json',
|
||||||
});
|
});
|
||||||
|
const disableOAuth = consumeDisableOAuthFlag(args);
|
||||||
const server = args.shift();
|
const server = args.shift();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
|
throw new Error('Missing server name. Usage: mcporter resource <server> [uri]');
|
||||||
@ -24,7 +25,14 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
|||||||
|
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
try {
|
try {
|
||||||
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
if (disableOAuth === undefined) {
|
||||||
|
result = uri ? await runtime.readResource(server, uri) : await runtime.listResources(server);
|
||||||
|
} else {
|
||||||
|
const connectOptions = { disableOAuth };
|
||||||
|
result = uri
|
||||||
|
? await runtime.readResource(server, uri, connectOptions)
|
||||||
|
: await runtime.listResources(server, connectOptions);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const issue = analyzeConnectionError(error);
|
const issue = analyzeConnectionError(error);
|
||||||
if (output === 'json' || output === 'raw') {
|
if (output === 'json' || output === 'raw') {
|
||||||
@ -39,6 +47,20 @@ export async function handleResource(runtime: Runtime, args: string[]): Promise<
|
|||||||
printCallOutput(callResult, result, output);
|
printCallOutput(callResult, result, output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function consumeDisableOAuthFlag(args: string[]): boolean | undefined {
|
||||||
|
let disableOAuth: boolean | undefined;
|
||||||
|
for (let index = 0; index < args.length; ) {
|
||||||
|
const token = args[index];
|
||||||
|
if (token === '--no-oauth') {
|
||||||
|
disableOAuth = true;
|
||||||
|
args.splice(index, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return disableOAuth;
|
||||||
|
}
|
||||||
|
|
||||||
export function printResourceHelp(): void {
|
export function printResourceHelp(): void {
|
||||||
console.error(
|
console.error(
|
||||||
[
|
[
|
||||||
@ -51,6 +73,7 @@ export function printResourceHelp(): void {
|
|||||||
' --output auto|text|markdown|json|raw Choose output rendering.',
|
' --output auto|text|markdown|json|raw Choose output rendering.',
|
||||||
' --json Shortcut for --output json.',
|
' --json Shortcut for --output json.',
|
||||||
' --raw Shortcut for --output raw.',
|
' --raw Shortcut for --output raw.',
|
||||||
|
' --no-oauth Never start OAuth; use cached tokens only.',
|
||||||
'',
|
'',
|
||||||
'Examples:',
|
'Examples:',
|
||||||
' mcporter resource docs',
|
' mcporter resource docs',
|
||||||
|
|||||||
@ -93,7 +93,7 @@ Expose daemon-managed keep-alive MCP servers as one MCP server.
|
|||||||
Flags:
|
Flags:
|
||||||
--servers <csv> Restrict the bridge to the listed keep-alive server names.
|
--servers <csv> Restrict the bridge to the listed keep-alive server names.
|
||||||
--stdio Serve MCP over stdio (default).
|
--stdio Serve MCP over stdio (default).
|
||||||
--http <port> Serve MCP Streamable HTTP on /mcp.
|
--http <port> Serve MCP Streamable HTTP on /mcp and /mcp/<server>.
|
||||||
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
|
--host <host> Host for --http (default: ${DEFAULT_SERVE_HTTP_HOST}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,21 @@
|
|||||||
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
|
const DEFAULT_LIST_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
||||||
|
const POSITIVE_INTEGER_PATTERN = /^[1-9]\d*$/;
|
||||||
|
|
||||||
|
export function parsePositiveInteger(raw: string | undefined): number | undefined {
|
||||||
|
if (!raw || !POSITIVE_INTEGER_PATTERN.test(raw)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(raw);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// parseTimeout reads timeout values from strings while honoring defaults.
|
// parseTimeout reads timeout values from strings while honoring defaults.
|
||||||
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
export function parseTimeout(raw: string | undefined, fallback: number): number {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(raw, 10);
|
return parsePositiveInteger(raw) ?? fallback;
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
export const LIST_TIMEOUT_MS = parseTimeout(process.env.MCPORTER_LIST_TIMEOUT, DEFAULT_LIST_TIMEOUT_MS);
|
||||||
@ -58,8 +63,8 @@ export function consumeTimeoutFlag(
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
throw new Error(missingValueMessage);
|
throw new Error(missingValueMessage);
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = parsePositiveInteger(value);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (parsed === undefined) {
|
||||||
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
throw new Error(`${flagName} must be a positive integer (milliseconds).`);
|
||||||
}
|
}
|
||||||
args.splice(index, 2);
|
args.splice(index, 2);
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import type { ListToolsOptions, Runtime } from '../runtime.js';
|
import type { ListToolsOptions, Runtime } from '../runtime.js';
|
||||||
import { buildToolMetadata, type ToolMetadata } from './generate/tools.js';
|
import { buildToolMetadataList, type ToolMetadata } from './generate/tools.js';
|
||||||
|
|
||||||
interface LoadToolMetadataOptions {
|
interface LoadToolMetadataOptions {
|
||||||
includeSchema?: boolean;
|
includeSchema?: boolean;
|
||||||
autoAuthorize?: boolean;
|
autoAuthorize?: boolean;
|
||||||
allowCachedAuth?: boolean;
|
allowCachedAuth?: boolean;
|
||||||
|
disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
const runtimeCache = new WeakMap<Runtime, Map<string, Promise<ToolMetadata[]>>>();
|
||||||
@ -13,7 +14,8 @@ function cacheKey(serverName: string, options: LoadToolMetadataOptions): string
|
|||||||
const includeSchema = options.includeSchema !== false;
|
const includeSchema = options.includeSchema !== false;
|
||||||
const autoAuthorize = options.autoAuthorize !== false;
|
const autoAuthorize = options.autoAuthorize !== false;
|
||||||
const allowCachedAuth = options.allowCachedAuth !== false;
|
const allowCachedAuth = options.allowCachedAuth !== false;
|
||||||
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}`;
|
const disableOAuth = options.disableOAuth === true;
|
||||||
|
return `${serverName}::schema:${includeSchema ? '1' : '0'}::auth:${autoAuthorize ? '1' : '0'}::cached-auth:${allowCachedAuth ? '1' : '0'}::disable-oauth:${disableOAuth ? '1' : '0'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadToolMetadata(
|
export async function loadToolMetadata(
|
||||||
@ -37,10 +39,11 @@ export async function loadToolMetadata(
|
|||||||
includeSchema,
|
includeSchema,
|
||||||
autoAuthorize,
|
autoAuthorize,
|
||||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
allowCachedAuth: options.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: options.disableOAuth,
|
||||||
};
|
};
|
||||||
const promise = runtime
|
const promise = runtime
|
||||||
.listTools(serverName, listOptions)
|
.listTools(serverName, listOptions)
|
||||||
.then((tools) => tools.map((tool) => buildToolMetadata(tool)))
|
.then((tools) => buildToolMetadataList(tools, { sort: false }))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
cache?.delete(key);
|
cache?.delete(key);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@ -121,12 +121,44 @@ function validateVaultPayload(value: unknown): VaultPayload {
|
|||||||
) {
|
) {
|
||||||
throw new CliUsageError("Vault payload 'clientInfo' must be an object.");
|
throw new CliUsageError("Vault payload 'clientInfo' must be an object.");
|
||||||
}
|
}
|
||||||
|
validateOAuthTokens(record.tokens as Record<string, unknown>);
|
||||||
|
if (record.clientInfo !== undefined) {
|
||||||
|
validateOAuthClientInfo(record.clientInfo as Record<string, unknown>);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tokens: record.tokens as OAuthTokens,
|
tokens: record.tokens as OAuthTokens,
|
||||||
...(record.clientInfo ? { clientInfo: record.clientInfo as OAuthClientInformationMixed } : {}),
|
...(record.clientInfo ? { clientInfo: record.clientInfo as OAuthClientInformationMixed } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateOAuthTokens(tokens: Record<string, unknown>): void {
|
||||||
|
if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
|
||||||
|
throw new CliUsageError('Vault payload tokens.access_token must be a non-empty string.');
|
||||||
|
}
|
||||||
|
if (typeof tokens.token_type !== 'string' || tokens.token_type.length === 0) {
|
||||||
|
throw new CliUsageError('Vault payload tokens.token_type must be a non-empty string.');
|
||||||
|
}
|
||||||
|
for (const key of ['refresh_token', 'scope'] as const) {
|
||||||
|
if (tokens[key] !== undefined && typeof tokens[key] !== 'string') {
|
||||||
|
throw new CliUsageError(`Vault payload tokens.${key} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
tokens.expires_in !== undefined &&
|
||||||
|
(!Number.isFinite(tokens.expires_in) || typeof tokens.expires_in !== 'number')
|
||||||
|
) {
|
||||||
|
throw new CliUsageError('Vault payload tokens.expires_in must be a finite number.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOAuthClientInfo(clientInfo: Record<string, unknown>): void {
|
||||||
|
for (const [key, value] of Object.entries(clientInfo)) {
|
||||||
|
if (value !== undefined && value !== null && typeof value !== 'string') {
|
||||||
|
throw new CliUsageError(`Vault payload clientInfo.${key} must be a string.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function printVaultHelp(): void {
|
export function printVaultHelp(): void {
|
||||||
const lines = [
|
const lines = [
|
||||||
'Usage: mcporter vault <set|clear> ...',
|
'Usage: mcporter vault <set|clear> ...',
|
||||||
|
|||||||
@ -58,10 +58,16 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const [name, rawEntry] of entries) {
|
for (const [name, rawEntry] of entries) {
|
||||||
|
const source: ServerSource = { kind: 'import', path: resolved, importKind };
|
||||||
|
const baseDir = path.dirname(resolved);
|
||||||
|
try {
|
||||||
|
normalizeServerEntry(name, rawEntry, baseDir, source, [source]);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (merged.has(name)) {
|
if (merged.has(name)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const source: ServerSource = { kind: 'import', path: resolved, importKind };
|
|
||||||
const existing = merged.get(name);
|
const existing = merged.get(name);
|
||||||
// Keep the first-seen source as canonical while tracking all alternates
|
// Keep the first-seen source as canonical while tracking all alternates
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -70,7 +76,7 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
}
|
}
|
||||||
merged.set(name, {
|
merged.set(name, {
|
||||||
raw: rawEntry,
|
raw: rawEntry,
|
||||||
baseDir: path.dirname(resolved),
|
baseDir,
|
||||||
source,
|
source,
|
||||||
sources: [source],
|
sources: [source],
|
||||||
});
|
});
|
||||||
@ -99,7 +105,13 @@ export async function loadServerDefinitions(options: LoadConfigOptions = {}): Pr
|
|||||||
|
|
||||||
const servers: ServerDefinition[] = [];
|
const servers: ServerDefinition[] = [];
|
||||||
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
|
for (const [name, { raw, baseDir: entryBaseDir, source, sources }] of merged) {
|
||||||
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
|
try {
|
||||||
|
servers.push(normalizeServerEntry(name, raw, entryBaseDir, source, sources));
|
||||||
|
} catch (error) {
|
||||||
|
if (source.kind !== 'import') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return servers;
|
return servers;
|
||||||
|
|||||||
@ -452,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
|
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
|
||||||
|
await cleanupDaemonArtifactsIfOwned(options, process.pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupDaemonArtifactsIfOwned(
|
||||||
|
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
|
||||||
|
ownerPid: number
|
||||||
|
): Promise<void> {
|
||||||
|
// A superseded daemon may finish shutting down after its replacement has
|
||||||
|
// already rebound the same paths. Never let that old process unlink the
|
||||||
|
// replacement daemon's live socket and metadata.
|
||||||
|
const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined);
|
||||||
|
if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
try {
|
await fs.unlink(paths.socketPath).catch(() => {});
|
||||||
await fs.unlink(options.socketPath);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await fs.unlink(options.metadataPath);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
await fs.unlink(paths.metadataPath).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSocketRequest(
|
async function handleSocketRequest(
|
||||||
@ -503,6 +509,13 @@ async function handleSocketRequest(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeDaemonDisableOAuth(value: boolean | undefined): boolean {
|
||||||
|
// Daemon messages are independent requests. Omission means the caller did
|
||||||
|
// not request OAuth suppression, so a previous --no-oauth pooled transport
|
||||||
|
// must not make later ordinary calls inherit the no-OAuth posture.
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
async function processRequest(
|
async function processRequest(
|
||||||
rawPayload: string,
|
rawPayload: string,
|
||||||
runtime: Runtime,
|
runtime: Runtime,
|
||||||
@ -554,6 +567,7 @@ async function processRequest(
|
|||||||
const result = await runtime.callTool(params.server, params.tool, {
|
const result = await runtime.callTool(params.server, params.tool, {
|
||||||
args: params.args ?? {},
|
args: params.args ?? {},
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
});
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
@ -581,6 +595,7 @@ async function processRequest(
|
|||||||
includeSchema: params.includeSchema,
|
includeSchema: params.includeSchema,
|
||||||
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
|
autoAuthorize: resolveDaemonListToolsAutoAuthorize(params, definition),
|
||||||
allowCachedAuth: params.allowCachedAuth ?? true,
|
allowCachedAuth: params.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
});
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
@ -603,7 +618,11 @@ async function processRequest(
|
|||||||
logEvent(logContext, `listResources start server=${params.server}`);
|
logEvent(logContext, `listResources start server=${params.server}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runtime.listResources(params.server, params.params);
|
const result = await runtime.listResources(params.server, {
|
||||||
|
...params.params,
|
||||||
|
allowCachedAuth: params.allowCachedAuth,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
logEvent(logContext, `listResources success server=${params.server}`);
|
logEvent(logContext, `listResources success server=${params.server}`);
|
||||||
@ -625,7 +644,10 @@ async function processRequest(
|
|||||||
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
logEvent(logContext, `readResource start server=${params.server} uri=${params.uri}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runtime.readResource(params.server, params.uri);
|
const result = await runtime.readResource(params.server, params.uri, {
|
||||||
|
allowCachedAuth: params.allowCachedAuth,
|
||||||
|
disableOAuth: normalizeDaemonDisableOAuth(params.disableOAuth),
|
||||||
|
});
|
||||||
markActivity(params.server, activity);
|
markActivity(params.server, activity);
|
||||||
if (loggable) {
|
if (loggable) {
|
||||||
logEvent(logContext, `readResource success server=${params.server}`);
|
logEvent(logContext, `readResource success server=${params.server}`);
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export interface CallToolParams {
|
|||||||
readonly tool: string;
|
readonly tool: string;
|
||||||
readonly args?: Record<string, unknown>;
|
readonly args?: Record<string, unknown>;
|
||||||
readonly timeoutMs?: number;
|
readonly timeoutMs?: number;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListToolsParams {
|
export interface ListToolsParams {
|
||||||
@ -35,16 +36,21 @@ export interface ListToolsParams {
|
|||||||
readonly includeSchema?: boolean;
|
readonly includeSchema?: boolean;
|
||||||
readonly autoAuthorize?: boolean;
|
readonly autoAuthorize?: boolean;
|
||||||
readonly allowCachedAuth?: boolean;
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListResourcesParams {
|
export interface ListResourcesParams {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly params?: Record<string, unknown>;
|
readonly params?: Record<string, unknown>;
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReadResourceParams {
|
export interface ReadResourceParams {
|
||||||
readonly server: string;
|
readonly server: string;
|
||||||
readonly uri: string;
|
readonly uri: string;
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CloseServerParams {
|
export interface CloseServerParams {
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import type { ListResourcesRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
||||||
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ServerDefinition } from '../config.js';
|
import type { ServerDefinition } from '../config.js';
|
||||||
import { isKeepAliveServer } from '../lifecycle.js';
|
import { isKeepAliveServer } from '../lifecycle.js';
|
||||||
import type { CallOptions, ListToolsOptions, Runtime } from '../runtime.js';
|
import type {
|
||||||
|
CallOptions,
|
||||||
|
ConnectOptions,
|
||||||
|
ListResourcesOptions,
|
||||||
|
ListToolsOptions,
|
||||||
|
ReadResourceOptions,
|
||||||
|
Runtime,
|
||||||
|
} from '../runtime.js';
|
||||||
import type { DaemonClient } from './client.js';
|
import type { DaemonClient } from './client.js';
|
||||||
|
|
||||||
interface KeepAliveRuntimeOptions {
|
interface KeepAliveRuntimeOptions {
|
||||||
@ -62,6 +68,7 @@ class KeepAliveRuntime implements Runtime {
|
|||||||
includeSchema: options?.includeSchema,
|
includeSchema: options?.includeSchema,
|
||||||
autoAuthorize: options?.autoAuthorize,
|
autoAuthorize: options?.autoAuthorize,
|
||||||
allowCachedAuth: options?.allowCachedAuth ?? true,
|
allowCachedAuth: options?.allowCachedAuth ?? true,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})
|
})
|
||||||
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
)) as Awaited<ReturnType<Runtime['listTools']>>;
|
||||||
}
|
}
|
||||||
@ -76,30 +83,45 @@ class KeepAliveRuntime implements Runtime {
|
|||||||
tool: toolName,
|
tool: toolName,
|
||||||
args: options?.args,
|
args: options?.args,
|
||||||
timeoutMs: options?.timeoutMs,
|
timeoutMs: options?.timeoutMs,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.base.callTool(server, toolName, options);
|
return this.base.callTool(server, toolName, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown> {
|
async listResources(server: string, options?: ListResourcesOptions): Promise<unknown> {
|
||||||
|
if (options?.oauthSessionOptions) {
|
||||||
|
return this.base.listResources(server, options);
|
||||||
|
}
|
||||||
|
const { allowCachedAuth, disableOAuth, ...params } = options ?? {};
|
||||||
if (this.shouldUseDaemon(server)) {
|
if (this.shouldUseDaemon(server)) {
|
||||||
return this.invokeWithRestart(server, 'listResources', () =>
|
return this.invokeWithRestart(server, 'listResources', () =>
|
||||||
this.daemon.listResources({ server, params: options ?? {} })
|
this.daemon.listResources({ server, params, allowCachedAuth, disableOAuth })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.base.listResources(server, options);
|
return this.base.listResources(server, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async readResource(server: string, uri: string): Promise<unknown> {
|
async readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown> {
|
||||||
if (this.shouldUseDaemon(server)) {
|
if (options?.oauthSessionOptions) {
|
||||||
return this.invokeWithRestart(server, 'readResource', () => this.daemon.readResource({ server, uri }));
|
return this.base.readResource(server, uri, options);
|
||||||
}
|
}
|
||||||
return this.base.readResource(server, uri);
|
if (this.shouldUseDaemon(server)) {
|
||||||
|
return this.invokeWithRestart(server, 'readResource', () =>
|
||||||
|
this.daemon.readResource({
|
||||||
|
server,
|
||||||
|
uri,
|
||||||
|
allowCachedAuth: options?.allowCachedAuth,
|
||||||
|
disableOAuth: options?.disableOAuth,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.base.readResource(server, uri, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(server: string): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||||
return this.base.connect(server);
|
return this.base.connect(server, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(server?: string): Promise<void> {
|
async close(server?: string): Promise<void> {
|
||||||
|
|||||||
117
src/fs-json.ts
117
src/fs-json.ts
@ -8,6 +8,7 @@ const LOCK_POLL_MS = 25;
|
|||||||
const MALFORMED_LOCK_STALE_MS = 1_000;
|
const MALFORMED_LOCK_STALE_MS = 1_000;
|
||||||
const MAX_SYMLINK_DEPTH = 40;
|
const MAX_SYMLINK_DEPTH = 40;
|
||||||
const DEFAULT_ATOMIC_FILE_MODE = 0o600;
|
const DEFAULT_ATOMIC_FILE_MODE = 0o600;
|
||||||
|
const localLockTails = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
// readJsonFile reads a JSON file and returns undefined when the file does not exist.
|
// readJsonFile reads a JSON file and returns undefined when the file does not exist.
|
||||||
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
|
export async function readJsonFile<T = unknown>(filePath: string): Promise<T | undefined> {
|
||||||
@ -64,49 +65,51 @@ export async function withFileLock<T>(
|
|||||||
options: { timeoutMs?: number } = {}
|
options: { timeoutMs?: number } = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
|
const lockTargetPath = await resolvePathFollowingSymlinks(filePath);
|
||||||
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
|
|
||||||
let lockPath = `${lockTargetPath}.lock`;
|
|
||||||
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
|
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let acquired = false;
|
return withLocalLock(lockTargetPath, timeoutMs, async () => {
|
||||||
|
await fs.mkdir(path.dirname(lockTargetPath), { recursive: true });
|
||||||
|
let lockPath = `${lockTargetPath}.lock`;
|
||||||
|
const fallbackLockPath = lockTargetPath !== filePath ? `${filePath}.lock` : undefined;
|
||||||
|
let acquired = false;
|
||||||
|
|
||||||
while (!acquired) {
|
while (!acquired) {
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
|
await fs.writeFile(lockPath, `${process.pid}\n${new Date().toISOString()}\n`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
flag: 'wx',
|
flag: 'wx',
|
||||||
});
|
});
|
||||||
acquired = true;
|
acquired = true;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
|
if (fallbackLockPath && lockPath !== fallbackLockPath && isPermissionError(error)) {
|
||||||
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
|
await fs.mkdir(path.dirname(fallbackLockPath), { recursive: true });
|
||||||
lockPath = fallbackLockPath;
|
lockPath = fallbackLockPath;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (await removeRecoverableLock(lockPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Date.now() - startedAt > timeoutMs) {
|
||||||
|
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
|
||||||
|
}
|
||||||
|
await sleep(LOCK_POLL_MS);
|
||||||
}
|
}
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if (await removeRecoverableLock(lockPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Date.now() - startedAt > timeoutMs) {
|
|
||||||
throw new Error(`Timed out waiting for file lock ${lockPath}`, { cause: error });
|
|
||||||
}
|
|
||||||
await sleep(LOCK_POLL_MS);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await task();
|
return await task();
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(lockPath).catch((error) => {
|
await fs.unlink(lockPath).catch((error) => {
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermissionError(error: unknown): boolean {
|
function isPermissionError(error: unknown): boolean {
|
||||||
@ -118,6 +121,46 @@ async function sleep(ms: number): Promise<void> {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function withLocalLock<T>(key: string, timeoutMs: number, task: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = localLockTails.get(key) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const current = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const tail = previous.then(() => current);
|
||||||
|
localLockTails.set(key, tail);
|
||||||
|
try {
|
||||||
|
await waitForLocalLock(previous, timeoutMs, key);
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
void tail.then(() => {
|
||||||
|
if (localLockTails.get(key) === tail) {
|
||||||
|
localLockTails.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLocalLock(previous: Promise<void>, timeoutMs: number, key: string): Promise<void> {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
previous,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error(`Timed out waiting for file lock ${key}.lock`)),
|
||||||
|
Math.max(0, timeoutMs)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveAtomicWriteTarget(filePath: string): Promise<{ path: string; mode?: number }> {
|
async function resolveAtomicWriteTarget(filePath: string): Promise<{ path: string; mode?: number }> {
|
||||||
try {
|
try {
|
||||||
const stats = await fs.lstat(filePath);
|
const stats = await fs.lstat(filePath);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { ensureInvocationDefaults, fetchTools, resolveServerDefinition } from '.
|
|||||||
import { resolveRuntimeKind } from './cli/generate/runtime.js';
|
import { resolveRuntimeKind } from './cli/generate/runtime.js';
|
||||||
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
|
import { readPackageMetadata, writeTemplate } from './cli/generate/template.js';
|
||||||
import type { ToolMetadata } from './cli/generate/tools.js';
|
import type { ToolMetadata } from './cli/generate/tools.js';
|
||||||
import { buildToolMetadata, toolsTestHelpers } from './cli/generate/tools.js';
|
import { buildToolMetadataList, toolsTestHelpers } from './cli/generate/tools.js';
|
||||||
import { type CliArtifactMetadata, serializeDefinition } from './cli-metadata.js';
|
import { type CliArtifactMetadata, serializeDefinition } from './cli-metadata.js';
|
||||||
import { stableJsonStringify } from './cli/generate/stable-json.js';
|
import { stableJsonStringify } from './cli/generate/stable-json.js';
|
||||||
import type { ServerDefinition } from './config.js';
|
import type { ServerDefinition } from './config.js';
|
||||||
@ -62,9 +62,7 @@ export async function generateCli(
|
|||||||
: { ...baseDefinition, description: derivedDescription };
|
: { ...baseDefinition, description: derivedDescription };
|
||||||
const embeddedDefinition = stripBuildSources(definition);
|
const embeddedDefinition = stripBuildSources(definition);
|
||||||
const serializedDefinition = serializeDefinition(embeddedDefinition);
|
const serializedDefinition = serializeDefinition(embeddedDefinition);
|
||||||
const toolMetadata: ToolMetadata[] = tools
|
const toolMetadata: ToolMetadata[] = buildToolMetadataList(tools);
|
||||||
.map((tool) => buildToolMetadata(tool))
|
|
||||||
.toSorted((left, right) => left.tool.name.localeCompare(right.tool.name));
|
|
||||||
const generator = await readPackageMetadata();
|
const generator = await readPackageMetadata();
|
||||||
const baseInvocation = ensureInvocationDefaults(
|
const baseInvocation = ensureInvocationDefaults(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -4,7 +4,10 @@ export type { CallResult, ConnectionIssue, ImageContent } from './result-utils.j
|
|||||||
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
|
export { createCallResult, describeConnectionIssue, wrapCallResult } from './result-utils.js';
|
||||||
export type {
|
export type {
|
||||||
CallOptions,
|
CallOptions,
|
||||||
|
ConnectOptions,
|
||||||
|
ListResourcesOptions,
|
||||||
ListToolsOptions,
|
ListToolsOptions,
|
||||||
|
ReadResourceOptions,
|
||||||
Runtime,
|
Runtime,
|
||||||
RuntimeLogger,
|
RuntimeLogger,
|
||||||
RuntimeOptions,
|
RuntimeOptions,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { CommandSpec, RawLifecycle, ServerDefinition, ServerLifecycle } from './config-schema.js';
|
import type { CommandSpec, RawLifecycle, ServerDefinition, ServerLifecycle } from './config-schema.js';
|
||||||
|
|
||||||
const DEFAULT_KEEP_ALIVE = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
|
const DEFAULT_KEEP_ALIVE = new Set(['chrome-devtools', 'mobile-mcp', 'playwright', 'cloudbase']);
|
||||||
|
|
||||||
const includeOverride = parseList(process.env.MCPORTER_KEEPALIVE);
|
const includeOverride = parseList(process.env.MCPORTER_KEEPALIVE);
|
||||||
const excludeOverride = parseList(process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_KEEPALIVE);
|
const excludeOverride = parseList(process.env.MCPORTER_DISABLE_KEEPALIVE ?? process.env.MCPORTER_NO_KEEPALIVE);
|
||||||
@ -19,6 +19,7 @@ const KEEP_ALIVE_COMMANDS: CommandSignature[] = [
|
|||||||
{ label: 'chrome-devtools', fragments: ['chrome-devtools-mcp'] },
|
{ label: 'chrome-devtools', fragments: ['chrome-devtools-mcp'] },
|
||||||
{ label: 'mobile-mcp', fragments: ['@mobilenext/mobile-mcp', 'mobile-mcp'] },
|
{ label: 'mobile-mcp', fragments: ['@mobilenext/mobile-mcp', 'mobile-mcp'] },
|
||||||
{ label: 'playwright', fragments: ['@playwright/mcp', 'playwright/mcp'] },
|
{ label: 'playwright', fragments: ['@playwright/mcp', 'playwright/mcp'] },
|
||||||
|
{ label: 'cloudbase', fragments: ['@cloudbase/cloudbase-mcp', 'cloudbase-mcp'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CHROME_DEVTOOLS_URL_PLACEHOLDERS = [String.raw`\${CHROME_DEVTOOLS_URL}`, '$env:CHROME_DEVTOOLS_URL'];
|
const CHROME_DEVTOOLS_URL_PLACEHOLDERS = [String.raw`\${CHROME_DEVTOOLS_URL}`, '$env:CHROME_DEVTOOLS_URL'];
|
||||||
|
|||||||
@ -152,7 +152,7 @@ class DirectoryPersistence implements OAuthPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readTokens(): Promise<OAuthTokens | undefined> {
|
async readTokens(): Promise<OAuthTokens | undefined> {
|
||||||
return readJsonFile<OAuthTokens>(this.tokenPath);
|
return this.readJsonOrUndefined<OAuthTokens>(this.tokenPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||||
@ -162,7 +162,7 @@ class DirectoryPersistence implements OAuthPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
async readClientInfo(): Promise<OAuthClientInformationMixed | undefined> {
|
||||||
return readJsonFile<OAuthClientInformationMixed>(this.clientInfoPath);
|
return this.readJsonOrUndefined<OAuthClientInformationMixed>(this.clientInfoPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
async saveClientInfo(info: OAuthClientInformationMixed): Promise<void> {
|
||||||
@ -187,9 +187,31 @@ class DirectoryPersistence implements OAuthPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async readState(): Promise<string | undefined> {
|
async readState(): Promise<string | undefined> {
|
||||||
|
// Deliberately NOT corrupt-tolerant: a corrupt OAuth state must fail the
|
||||||
|
// flow closed. Returning undefined here would skip the CSRF state check on
|
||||||
|
// the authorization callback (see oauth.ts), so only the credential caches
|
||||||
|
// (tokens/client) degrade to re-auth.
|
||||||
return readJsonFile<string>(this.statePath);
|
return readJsonFile<string>(this.statePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A present-but-corrupt credential cache (tokens/client) means "no usable
|
||||||
|
// credentials": degrade to re-auth instead of crashing the connection,
|
||||||
|
// mirroring VaultPersistence and the daemon/server-proxy readers. Genuine I/O
|
||||||
|
// faults still propagate (readJsonFile re-throws everything except ENOENT).
|
||||||
|
// OAuth state is intentionally excluded (see readState) so its CSRF check
|
||||||
|
// still fails closed on a corrupt state file.
|
||||||
|
private async readJsonOrUndefined<T>(filePath: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
return await readJsonFile<T>(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.logger?.debug?.(`Ignoring corrupt OAuth cache file ${filePath}: ${error.message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveState(value: string): Promise<void> {
|
async saveState(value: string): Promise<void> {
|
||||||
await this.ensureDir();
|
await this.ensureDir();
|
||||||
await writeJsonFile(this.statePath, value);
|
await writeJsonFile(this.statePath, value);
|
||||||
|
|||||||
@ -150,8 +150,8 @@ class PersistentOAuthClientProvider implements OAuthClientProvider {
|
|||||||
// previous client registration is cached with a different redirect URI the
|
// previous client registration is cached with a different redirect URI the
|
||||||
// auth server will reject the request with `invalid_redirect_uri`. Clear
|
// auth server will reject the request with `invalid_redirect_uri`. Clear
|
||||||
// the stale registration so the next flow re-registers with the new URI.
|
// the stale registration so the next flow re-registers with the new URI.
|
||||||
// Wrapped in try/catch so persistence errors (malformed JSON, permission
|
// Wrapped in try/catch so non-recoverable persistence errors (for example,
|
||||||
// issues) close the already-bound callback server instead of leaking it.
|
// permission issues) close the already-bound callback server instead of leaking it.
|
||||||
if (usesDynamicPort) {
|
if (usesDynamicPort) {
|
||||||
try {
|
try {
|
||||||
const cachedClient = await persistence.readClientInfo();
|
const cachedClient = await persistence.readClientInfo();
|
||||||
|
|||||||
624
src/runtime.ts
624
src/runtime.ts
@ -18,6 +18,14 @@ export { MCPORTER_VERSION } from './version.js';
|
|||||||
const PACKAGE_NAME = 'mcporter';
|
const PACKAGE_NAME = 'mcporter';
|
||||||
const OAUTH_CODE_TIMEOUT_MS = resolveOAuthTimeoutFromEnv();
|
const OAUTH_CODE_TIMEOUT_MS = resolveOAuthTimeoutFromEnv();
|
||||||
|
|
||||||
|
type CachedClientEntry = {
|
||||||
|
readonly server: string;
|
||||||
|
readonly promise: Promise<ClientContext>;
|
||||||
|
readonly contextPromise?: Promise<ClientContext>;
|
||||||
|
readonly allowCachedAuth: boolean | undefined;
|
||||||
|
readonly disableOAuth: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface RuntimeOptions {
|
export interface RuntimeOptions {
|
||||||
readonly configPath?: string;
|
readonly configPath?: string;
|
||||||
readonly servers?: ServerDefinition[];
|
readonly servers?: ServerDefinition[];
|
||||||
@ -35,6 +43,11 @@ export type RuntimeLogger = Logger;
|
|||||||
export interface CallOptions {
|
export interface CallOptions {
|
||||||
readonly args?: CallToolRequest['params']['arguments'];
|
readonly args?: CallToolRequest['params']['arguments'];
|
||||||
readonly timeoutMs?: number;
|
readonly timeoutMs?: number;
|
||||||
|
/**
|
||||||
|
* Suppress interactive OAuth for this call while still allowing cached
|
||||||
|
* bearer tokens to be applied. Intended for headless callers.
|
||||||
|
*/
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListToolsOptions {
|
export interface ListToolsOptions {
|
||||||
@ -42,13 +55,49 @@ export interface ListToolsOptions {
|
|||||||
readonly autoAuthorize?: boolean;
|
readonly autoAuthorize?: boolean;
|
||||||
readonly allowCachedAuth?: boolean;
|
readonly allowCachedAuth?: boolean;
|
||||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||||
|
/**
|
||||||
|
* Suppress interactive OAuth for this listing while keeping the connection
|
||||||
|
* cache available. Prefer this over `autoAuthorize: false` for long-running
|
||||||
|
* headless callers that need cached-token-only behavior.
|
||||||
|
*/
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectOptions {
|
export type ListResourcesOptions = Partial<ListResourcesRequest['params']> & {
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ReadResourceOptions {
|
||||||
|
readonly allowCachedAuth?: boolean;
|
||||||
|
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectOptions {
|
||||||
readonly maxOAuthAttempts?: number;
|
readonly maxOAuthAttempts?: number;
|
||||||
readonly skipCache?: boolean;
|
readonly skipCache?: boolean;
|
||||||
readonly allowCachedAuth?: boolean;
|
readonly allowCachedAuth?: boolean;
|
||||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||||
|
/**
|
||||||
|
* When `true`, never start an OAuth flow for this server — equivalent
|
||||||
|
* to `maxOAuthAttempts: 0` for the purpose of avoiding interactive
|
||||||
|
* authorization. Unlike `maxOAuthAttempts: 0`, callers passing
|
||||||
|
* `disableOAuth: true` participate in connection caching: repeated
|
||||||
|
* `connect()` / `callTool()` / `listTools()` calls reuse the same
|
||||||
|
* `ClientContext`, and `close()` reaps it.
|
||||||
|
*
|
||||||
|
* Intended for long-running headless callers (daemons, scheduled jobs,
|
||||||
|
* CI workers) that have no browser and must rely on cached tokens.
|
||||||
|
*
|
||||||
|
* Cache identity: clients established with `disableOAuth: true` are
|
||||||
|
* stored in their own cache slot — sharing with a connection that
|
||||||
|
* could refresh into an OAuth flow would violate the no-browser-launch
|
||||||
|
* guarantee. Switching the flag between calls keeps both variants cached
|
||||||
|
* until the caller closes the server or runtime.
|
||||||
|
*/
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Runtime {
|
export interface Runtime {
|
||||||
@ -59,9 +108,9 @@ export interface Runtime {
|
|||||||
getInstructions?(server: string): Promise<string | undefined>;
|
getInstructions?(server: string): Promise<string | undefined>;
|
||||||
listTools(server: string, options?: ListToolsOptions): Promise<ServerToolInfo[]>;
|
listTools(server: string, options?: ListToolsOptions): Promise<ServerToolInfo[]>;
|
||||||
callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown>;
|
callTool(server: string, toolName: string, options?: CallOptions): Promise<unknown>;
|
||||||
listResources(server: string, options?: Partial<ListResourcesRequest['params']>): Promise<unknown>;
|
listResources(server: string, options?: ListResourcesOptions): Promise<unknown>;
|
||||||
readResource(server: string, uri: string): Promise<unknown>;
|
readResource(server: string, uri: string, options?: ReadResourceOptions): Promise<unknown>;
|
||||||
connect(server: string): Promise<ClientContext>;
|
connect(server: string, options?: ConnectOptions): Promise<ClientContext>;
|
||||||
close(server?: string): Promise<void>;
|
close(server?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,11 +141,13 @@ export async function callOnce(params: {
|
|||||||
toolName: string;
|
toolName: string;
|
||||||
args?: Record<string, unknown>;
|
args?: Record<string, unknown>;
|
||||||
configPath?: string;
|
configPath?: string;
|
||||||
|
disableOAuth?: boolean;
|
||||||
}): Promise<unknown> {
|
}): Promise<unknown> {
|
||||||
const runtime = await createRuntime({ configPath: params.configPath });
|
const runtime = await createRuntime({ configPath: params.configPath });
|
||||||
try {
|
try {
|
||||||
return await runtime.callTool(params.server, params.toolName, {
|
return await runtime.callTool(params.server, params.toolName, {
|
||||||
args: params.args,
|
args: params.args,
|
||||||
|
disableOAuth: params.disableOAuth,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await runtime.close(params.server);
|
await runtime.close(params.server);
|
||||||
@ -105,13 +156,13 @@ export async function callOnce(params: {
|
|||||||
|
|
||||||
class McpRuntime implements Runtime {
|
class McpRuntime implements Runtime {
|
||||||
private readonly definitions: Map<string, ServerDefinition>;
|
private readonly definitions: Map<string, ServerDefinition>;
|
||||||
private readonly clients = new Map<
|
private readonly clients = new Map<string, CachedClientEntry>();
|
||||||
string,
|
private readonly activeClientKeys = new Map<string, string>();
|
||||||
{
|
private readonly contextCacheKeys = new WeakMap<ClientContext, string>();
|
||||||
readonly promise: Promise<ClientContext>;
|
private readonly contextCachePromises = new WeakMap<ClientContext, Promise<ClientContext>>();
|
||||||
readonly allowCachedAuth: boolean | undefined;
|
private readonly connectionSetupTails = new Map<string, Promise<void>>();
|
||||||
}
|
private readonly serverGenerations = new Map<string, number>();
|
||||||
>();
|
private readonly retirementPromises = new Map<string, Set<Promise<void>>>();
|
||||||
private readonly logger: RuntimeLogger;
|
private readonly logger: RuntimeLogger;
|
||||||
private readonly clientInfo: { name: string; version: string };
|
private readonly clientInfo: { name: string; version: string };
|
||||||
private readonly oauthTimeoutMs?: number;
|
private readonly oauthTimeoutMs?: number;
|
||||||
@ -162,12 +213,15 @@ class McpRuntime implements Runtime {
|
|||||||
if (!options.overwrite && this.definitions.has(definition.name)) {
|
if (!options.overwrite && this.definitions.has(definition.name)) {
|
||||||
throw new Error(`MCP server '${definition.name}' already exists.`);
|
throw new Error(`MCP server '${definition.name}' already exists.`);
|
||||||
}
|
}
|
||||||
|
this.bumpServerGeneration(definition.name);
|
||||||
this.definitions.set(definition.name, definition);
|
this.definitions.set(definition.name, definition);
|
||||||
this.clients.delete(definition.name);
|
this.retireCachedEntriesForServer(definition.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getInstructions(server: string): Promise<string | undefined> {
|
async getInstructions(server: string): Promise<string | undefined> {
|
||||||
const cached = this.clients.get(server.trim());
|
const active = this.activeClientForServer(server);
|
||||||
|
const fallbackEntries = active ? [] : this.cachedEntriesForServer(server);
|
||||||
|
const cached = active ?? (fallbackEntries.length === 1 ? fallbackEntries[0] : undefined);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -188,12 +242,23 @@ class McpRuntime implements Runtime {
|
|||||||
// listTools queries tool metadata and optionally includes schemas when requested.
|
// listTools queries tool metadata and optionally includes schemas when requested.
|
||||||
async listTools(server: string, options: ListToolsOptions = {}): Promise<ServerToolInfo[]> {
|
async listTools(server: string, options: ListToolsOptions = {}): Promise<ServerToolInfo[]> {
|
||||||
// Toggle auto authorization so list can run without forcing OAuth flows.
|
// Toggle auto authorization so list can run without forcing OAuth flows.
|
||||||
|
// `disableOAuth` is the cache-friendly suppression path; when present it
|
||||||
|
// supersedes the legacy `autoAuthorize: false` uncached behavior.
|
||||||
const autoAuthorize = options.autoAuthorize !== false;
|
const autoAuthorize = options.autoAuthorize !== false;
|
||||||
|
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||||
|
const allowCachedAuth = this.effectiveAllowCachedAuthForOperation(
|
||||||
|
server,
|
||||||
|
options.allowCachedAuth,
|
||||||
|
disableOAuth,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const useLegacyNoAuthorize = !autoAuthorize && disableOAuth !== true;
|
||||||
const context = await this.connect(server, {
|
const context = await this.connect(server, {
|
||||||
maxOAuthAttempts: autoAuthorize ? undefined : 0,
|
maxOAuthAttempts: useLegacyNoAuthorize ? 0 : undefined,
|
||||||
skipCache: !autoAuthorize,
|
skipCache: useLegacyNoAuthorize,
|
||||||
allowCachedAuth: options.allowCachedAuth ?? true,
|
allowCachedAuth,
|
||||||
oauthSessionOptions: options.oauthSessionOptions,
|
oauthSessionOptions: options.oauthSessionOptions,
|
||||||
|
disableOAuth,
|
||||||
});
|
});
|
||||||
let closeError: unknown;
|
let closeError: unknown;
|
||||||
const tools: ServerToolInfo[] = [];
|
const tools: ServerToolInfo[] = [];
|
||||||
@ -214,10 +279,10 @@ class McpRuntime implements Runtime {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Keep-alive STDIO transports often die when Chrome closes; drop the cached client
|
// Keep-alive STDIO transports often die when Chrome closes; drop the cached client
|
||||||
// so the next call spins up a fresh process instead of reusing the broken handle.
|
// so the next call spins up a fresh process instead of reusing the broken handle.
|
||||||
await this.resetConnectionOnError(server, error);
|
await this.resetConnectionOnError(server, error, context);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (!autoAuthorize) {
|
if (useLegacyNoAuthorize) {
|
||||||
try {
|
try {
|
||||||
await this.closeContext(context);
|
await this.closeContext(context);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -240,10 +305,14 @@ class McpRuntime implements Runtime {
|
|||||||
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
|
`Tool '${toolName}' is not accessible on server '${definition.name}' (blocked by configuration).`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let context: ClientContext | undefined;
|
||||||
try {
|
try {
|
||||||
const { client } = await this.connect(server, {
|
const disableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||||
allowCachedAuth: true,
|
context = await this.connect(server, {
|
||||||
|
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(server, undefined, disableOAuth, true),
|
||||||
|
disableOAuth,
|
||||||
});
|
});
|
||||||
|
const { client } = context;
|
||||||
const params: CallToolRequest['params'] = {
|
const params: CallToolRequest['params'] = {
|
||||||
name: toolName,
|
name: toolName,
|
||||||
arguments: options.args ?? {},
|
arguments: options.args ?? {},
|
||||||
@ -264,102 +333,379 @@ class McpRuntime implements Runtime {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Runtime timeouts and transport crashes should tear down the cached connection so
|
// Runtime timeouts and transport crashes should tear down the cached connection so
|
||||||
// the daemon (or direct runtime) can relaunch the MCP server on the next attempt.
|
// the daemon (or direct runtime) can relaunch the MCP server on the next attempt.
|
||||||
await this.resetConnectionOnError(server, error);
|
await this.resetConnectionOnError(server, error, context);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listResources delegates to the MCP resources/list method with passthrough params.
|
// listResources delegates to the MCP resources/list method with passthrough params.
|
||||||
async listResources(server: string, options: Partial<ListResourcesRequest['params']> = {}): Promise<unknown> {
|
async listResources(server: string, options: ListResourcesOptions = {}): Promise<unknown> {
|
||||||
|
const { allowCachedAuth, disableOAuth, oauthSessionOptions, ...params } = options;
|
||||||
|
let context: ClientContext | undefined;
|
||||||
try {
|
try {
|
||||||
const { client } = await this.connect(server);
|
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, disableOAuth);
|
||||||
return await client.listResources(options as ListResourcesRequest['params']);
|
context = await this.connect(server, {
|
||||||
|
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
|
||||||
|
server,
|
||||||
|
allowCachedAuth,
|
||||||
|
effectiveDisableOAuth,
|
||||||
|
undefined
|
||||||
|
),
|
||||||
|
oauthSessionOptions,
|
||||||
|
disableOAuth: effectiveDisableOAuth,
|
||||||
|
});
|
||||||
|
const { client } = context;
|
||||||
|
return await client.listResources(params as ListResourcesRequest['params']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fatal listResources errors usually mean the underlying transport has gone away.
|
// Fatal listResources errors usually mean the underlying transport has gone away.
|
||||||
await this.resetConnectionOnError(server, error);
|
await this.resetConnectionOnError(server, error, context);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async readResource(server: string, uri: string): Promise<unknown> {
|
async readResource(server: string, uri: string, options: ReadResourceOptions = {}): Promise<unknown> {
|
||||||
|
let context: ClientContext | undefined;
|
||||||
try {
|
try {
|
||||||
const { client } = await this.connect(server);
|
const effectiveDisableOAuth = this.effectiveDisableOAuthForOperation(server, options.disableOAuth);
|
||||||
|
context = await this.connect(server, {
|
||||||
|
allowCachedAuth: this.effectiveAllowCachedAuthForOperation(
|
||||||
|
server,
|
||||||
|
options.allowCachedAuth,
|
||||||
|
effectiveDisableOAuth,
|
||||||
|
undefined
|
||||||
|
),
|
||||||
|
oauthSessionOptions: options.oauthSessionOptions,
|
||||||
|
disableOAuth: effectiveDisableOAuth,
|
||||||
|
});
|
||||||
|
const { client } = context;
|
||||||
return await client.readResource({ uri } satisfies ReadResourceRequest['params']);
|
return await client.readResource({ uri } satisfies ReadResourceRequest['params']);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.resetConnectionOnError(server, error);
|
await this.resetConnectionOnError(server, error, context);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private effectiveDisableOAuthForOperation(server: string, requested: boolean | undefined): boolean | undefined {
|
||||||
|
if (requested !== undefined) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
const cached = this.cachedEntriesForServer(server);
|
||||||
|
const active = this.activeClientForServer(server);
|
||||||
|
if (active) {
|
||||||
|
return active.disableOAuth;
|
||||||
|
}
|
||||||
|
if (cached.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [first] = cached;
|
||||||
|
return cached.every((entry) => entry.disableOAuth === first?.disableOAuth) ? first?.disableOAuth : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private effectiveAllowCachedAuthForOperation(
|
||||||
|
server: string,
|
||||||
|
requested: boolean | undefined,
|
||||||
|
disableOAuth: boolean | undefined,
|
||||||
|
defaultValue: boolean | undefined
|
||||||
|
): boolean | undefined {
|
||||||
|
if (requested !== undefined) {
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
if (disableOAuth !== true) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
const active = this.activeClientForServer(server);
|
||||||
|
if (active?.disableOAuth === true) {
|
||||||
|
return active.allowCachedAuth;
|
||||||
|
}
|
||||||
|
const cached = this.cachedEntriesForServer(server).filter((entry) => entry.disableOAuth);
|
||||||
|
return cached.length === 1 ? cached[0]?.allowCachedAuth : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cachedEntriesForServer(server: string): CachedClientEntry[] {
|
||||||
|
const normalized = server.trim();
|
||||||
|
return [...this.clients.values()].filter((entry) => entry.server === normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private retireCachedEntriesForServer(server: string): void {
|
||||||
|
const normalized = server.trim();
|
||||||
|
const retired: CachedClientEntry[] = [];
|
||||||
|
for (const [key, cached] of this.clients.entries()) {
|
||||||
|
if (cached.server === normalized) {
|
||||||
|
this.clients.delete(key);
|
||||||
|
retired.push(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeClientKeys.delete(normalized);
|
||||||
|
if (retired.length > 0) {
|
||||||
|
const retirement = this.trackRetirement(normalized, this.closeCachedEntries(retired));
|
||||||
|
void retirement.catch((error) => {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Failed to close retired '${normalized}' connection: ${detail}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private activeClientForServer(server: string): CachedClientEntry | undefined {
|
||||||
|
const normalized = server.trim();
|
||||||
|
const activeKey = this.activeClientKeys.get(normalized);
|
||||||
|
if (!activeKey) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const active = this.clients.get(activeKey);
|
||||||
|
return active?.server === normalized ? active : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serverGeneration(server: string): number {
|
||||||
|
return this.serverGenerations.get(server.trim()) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpServerGeneration(server: string): void {
|
||||||
|
const normalized = server.trim();
|
||||||
|
this.serverGenerations.set(normalized, this.serverGeneration(normalized) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpAllServerGenerations(): void {
|
||||||
|
const servers = new Set<string>([
|
||||||
|
...this.definitions.keys(),
|
||||||
|
...[...this.clients.values()].map((entry) => entry.server),
|
||||||
|
...this.connectionSetupTails.keys(),
|
||||||
|
]);
|
||||||
|
for (const server of servers) {
|
||||||
|
this.bumpServerGeneration(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// connect lazily instantiates a client context per server and memoizes it.
|
// connect lazily instantiates a client context per server and memoizes it.
|
||||||
async connect(server: string, options: ConnectOptions = {}): Promise<ClientContext> {
|
async connect(server: string, options: ConnectOptions = {}): Promise<ClientContext> {
|
||||||
// Reuse cached connections unless the caller explicitly opted out.
|
// Reuse cached connections unless the caller explicitly opted out.
|
||||||
const normalized = server.trim();
|
const normalized = server.trim();
|
||||||
|
let definition = this.definitions.get(normalized);
|
||||||
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
|
|
||||||
|
|
||||||
if (useCache) {
|
|
||||||
const existing = this.clients.get(normalized);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.allowCachedAuth === options.allowCachedAuth || options.allowCachedAuth === undefined) {
|
|
||||||
return existing.promise;
|
|
||||||
}
|
|
||||||
await this.close(normalized).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const definition = this.definitions.get(normalized);
|
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
throw new Error(`Unknown MCP server '${normalized}'.`);
|
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||||
}
|
}
|
||||||
|
const generation = this.serverGeneration(normalized);
|
||||||
|
|
||||||
const connection = createClientContext(definition, this.logger, this.clientInfo, {
|
// `maxOAuthAttempts: 0` keeps its legacy escape-the-cache contract.
|
||||||
|
// `disableOAuth: true` is the cache-friendly OAuth-suppression knob:
|
||||||
|
// it disables the interactive OAuth flow at the transport layer but
|
||||||
|
// participates in caching (own slot, see the eviction rule below).
|
||||||
|
const disableOAuth = options.disableOAuth === true;
|
||||||
|
// Normalize: a caller asking for `disableOAuth: true` has no path to
|
||||||
|
// OAuth, so cached-token application is the only auth they can ever
|
||||||
|
// use — default `allowCachedAuth: true` when the caller didn't pick
|
||||||
|
// a side. Without this, the documented headless setup
|
||||||
|
// `connect(server, { disableOAuth: true })` stored
|
||||||
|
// `allowCachedAuth: undefined`, and the next internal `callTool` /
|
||||||
|
// `listTools` (which force `allowCachedAuth: true`) immediately
|
||||||
|
// evicted and reopened the transport. Explicit `false` is honored
|
||||||
|
// (header-only / anonymous callers).
|
||||||
|
const effectiveAllowCachedAuth = options.allowCachedAuth ?? (disableOAuth ? true : undefined);
|
||||||
|
const useCache = options.skipCache !== true && options.maxOAuthAttempts === undefined;
|
||||||
|
let ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
|
||||||
|
let cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
|
||||||
|
let cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
|
||||||
|
let cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
|
||||||
|
|
||||||
|
if (useCache) {
|
||||||
|
const existing = this.findCachedEntryForRequest(
|
||||||
|
normalized,
|
||||||
|
definition,
|
||||||
|
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
|
||||||
|
cacheAllowCachedAuth,
|
||||||
|
cacheDisableOAuth
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
const [existingKey, cached] = existing;
|
||||||
|
const activeEntry = ignoresAuthCachePolicy
|
||||||
|
? {
|
||||||
|
...cached,
|
||||||
|
allowCachedAuth: effectiveAllowCachedAuth,
|
||||||
|
disableOAuth,
|
||||||
|
}
|
||||||
|
: cached;
|
||||||
|
if (activeEntry !== cached) {
|
||||||
|
this.clients.set(existingKey, activeEntry);
|
||||||
|
}
|
||||||
|
this.activeClientKeys.set(normalized, existingKey);
|
||||||
|
return activeEntry.promise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseConnectionSetup: (() => void) | undefined;
|
||||||
|
if (useCache && this.shouldSerializeConnectionSetup(definition, disableOAuth)) {
|
||||||
|
releaseConnectionSetup = await this.enterConnectionSetup(normalized);
|
||||||
|
try {
|
||||||
|
if (this.serverGeneration(normalized) !== generation) {
|
||||||
|
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||||
|
}
|
||||||
|
const refreshedDefinition = this.definitions.get(normalized);
|
||||||
|
if (!refreshedDefinition) {
|
||||||
|
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||||
|
}
|
||||||
|
definition = refreshedDefinition;
|
||||||
|
ignoresAuthCachePolicy = this.ignoresAuthCachePolicy(definition);
|
||||||
|
cacheAllowCachedAuth = ignoresAuthCachePolicy ? undefined : effectiveAllowCachedAuth;
|
||||||
|
cacheDisableOAuth = ignoresAuthCachePolicy ? false : disableOAuth;
|
||||||
|
cacheKey = this.cacheKey(normalized, cacheAllowCachedAuth, cacheDisableOAuth);
|
||||||
|
const existing = this.findCachedEntryForRequest(
|
||||||
|
normalized,
|
||||||
|
definition,
|
||||||
|
ignoresAuthCachePolicy ? undefined : options.allowCachedAuth,
|
||||||
|
cacheAllowCachedAuth,
|
||||||
|
cacheDisableOAuth
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
releaseConnectionSetup();
|
||||||
|
releaseConnectionSetup = undefined;
|
||||||
|
const [existingKey, cached] = existing;
|
||||||
|
this.activeClientKeys.set(normalized, existingKey);
|
||||||
|
return cached.promise;
|
||||||
|
}
|
||||||
|
await this.retireConflictingOAuthEntries(normalized, cacheKey);
|
||||||
|
if (this.serverGeneration(normalized) !== generation) {
|
||||||
|
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||||
|
}
|
||||||
|
const latestDefinition = this.definitions.get(normalized);
|
||||||
|
if (!latestDefinition) {
|
||||||
|
throw new Error(`Unknown MCP server '${normalized}'.`);
|
||||||
|
}
|
||||||
|
definition = latestDefinition;
|
||||||
|
} catch (error) {
|
||||||
|
releaseConnectionSetup?.();
|
||||||
|
releaseConnectionSetup = undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let connectionDefinition = definition;
|
||||||
|
let contextPromise = createClientContext(definition, this.logger, this.clientInfo, {
|
||||||
maxOAuthAttempts: options.maxOAuthAttempts,
|
maxOAuthAttempts: options.maxOAuthAttempts,
|
||||||
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
|
oauthTimeoutMs: this.oauthTimeoutMs ?? OAUTH_CODE_TIMEOUT_MS,
|
||||||
onDefinitionPromoted: (promoted) => this.definitions.set(promoted.name, promoted),
|
onDefinitionPromoted: (promoted) => {
|
||||||
allowCachedAuth: options.allowCachedAuth,
|
if (
|
||||||
|
this.serverGeneration(normalized) === generation &&
|
||||||
|
this.definitions.get(normalized) === connectionDefinition
|
||||||
|
) {
|
||||||
|
this.definitions.set(promoted.name, promoted);
|
||||||
|
connectionDefinition = promoted;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
allowCachedAuth: effectiveAllowCachedAuth,
|
||||||
oauthSessionOptions: options.oauthSessionOptions,
|
oauthSessionOptions: options.oauthSessionOptions,
|
||||||
|
disableOAuth,
|
||||||
recordPath: this.recordPath,
|
recordPath: this.recordPath,
|
||||||
replayPath: this.replayPath,
|
replayPath: this.replayPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (useCache) {
|
if (useCache) {
|
||||||
this.clients.set(normalized, { promise: connection, allowCachedAuth: options.allowCachedAuth });
|
const previousActiveKey = this.activeClientKeys.get(normalized);
|
||||||
|
contextPromise = contextPromise.then((context) => {
|
||||||
|
this.contextCacheKeys.set(context, cacheKey);
|
||||||
|
this.contextCachePromises.set(context, contextPromise);
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
let connection!: Promise<ClientContext>;
|
||||||
|
connection = contextPromise.then((context) => {
|
||||||
|
const stillCached = this.clients.get(cacheKey)?.promise === connection;
|
||||||
|
if (this.serverGeneration(normalized) !== generation || !stillCached) {
|
||||||
|
this.contextCacheKeys.delete(context);
|
||||||
|
this.contextCachePromises.delete(context);
|
||||||
|
throw new Error(`Connection setup for MCP server '${normalized}' was superseded.`);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
});
|
||||||
|
this.activeClientKeys.set(normalized, cacheKey);
|
||||||
|
this.clients.set(cacheKey, {
|
||||||
|
server: normalized,
|
||||||
|
promise: connection,
|
||||||
|
contextPromise,
|
||||||
|
allowCachedAuth: ignoresAuthCachePolicy ? effectiveAllowCachedAuth : cacheAllowCachedAuth,
|
||||||
|
disableOAuth: ignoresAuthCachePolicy ? disableOAuth : cacheDisableOAuth,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
return await connection;
|
return await connection;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.clients.delete(normalized);
|
const ownsCacheEntry = this.clients.get(cacheKey)?.promise === connection;
|
||||||
|
if (ownsCacheEntry) {
|
||||||
|
this.clients.delete(cacheKey);
|
||||||
|
if (
|
||||||
|
this.activeClientKeys.get(normalized) === cacheKey &&
|
||||||
|
previousActiveKey &&
|
||||||
|
this.clients.has(previousActiveKey)
|
||||||
|
) {
|
||||||
|
this.activeClientKeys.set(normalized, previousActiveKey);
|
||||||
|
} else if (
|
||||||
|
this.activeClientKeys.get(normalized) === cacheKey ||
|
||||||
|
this.cachedEntriesForServer(normalized).length === 0
|
||||||
|
) {
|
||||||
|
this.activeClientKeys.delete(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
} finally {
|
||||||
|
releaseConnectionSetup?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return connection;
|
releaseConnectionSetup?.();
|
||||||
|
return contextPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// close tears down transports (and OAuth sessions) for a single server or all servers.
|
// close tears down transports (and OAuth sessions) for a single server or all servers.
|
||||||
async close(server?: string): Promise<void> {
|
async close(server?: string): Promise<void> {
|
||||||
if (server) {
|
if (server) {
|
||||||
const normalized = server.trim();
|
const normalized = server.trim();
|
||||||
const cached = this.clients.get(normalized);
|
this.bumpServerGeneration(normalized);
|
||||||
if (!cached) {
|
const entries = [...this.clients.entries()].filter(([, cached]) => cached.server === normalized);
|
||||||
return;
|
if (entries.length === 0) {
|
||||||
|
this.activeClientKeys.delete(normalized);
|
||||||
}
|
}
|
||||||
const context = await cached.promise;
|
for (const [key] of entries) {
|
||||||
try {
|
this.clients.delete(key);
|
||||||
await this.closeContext(context);
|
|
||||||
} finally {
|
|
||||||
this.clients.delete(normalized);
|
|
||||||
}
|
}
|
||||||
|
this.activeClientKeys.delete(normalized);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
void this.trackRetirement(normalized, this.closeCachedEntries(entries.map(([, cached]) => cached)));
|
||||||
|
}
|
||||||
|
await this.awaitRetirements(normalized);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, cached] of this.clients.entries()) {
|
this.bumpAllServerGenerations();
|
||||||
try {
|
const entries = [...this.clients.entries()];
|
||||||
const context = await cached.promise;
|
this.clients.clear();
|
||||||
await this.closeContext(context);
|
this.activeClientKeys.clear();
|
||||||
} finally {
|
const byServer = new Map<string, CachedClientEntry[]>();
|
||||||
this.clients.delete(name);
|
for (const [, cached] of entries) {
|
||||||
}
|
const serverEntries = byServer.get(cached.server) ?? [];
|
||||||
|
serverEntries.push(cached);
|
||||||
|
byServer.set(cached.server, serverEntries);
|
||||||
|
}
|
||||||
|
for (const [serverName, serverEntries] of byServer) {
|
||||||
|
void this.trackRetirement(serverName, this.closeCachedEntries(serverEntries));
|
||||||
|
}
|
||||||
|
await this.awaitRetirements();
|
||||||
|
}
|
||||||
|
|
||||||
|
private contextPromiseFor(cached: CachedClientEntry): Promise<ClientContext> {
|
||||||
|
return cached.contextPromise ?? cached.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeCachedEntries(entries: CachedClientEntry[]): Promise<void> {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
entries.map(async (cached) => {
|
||||||
|
const context = await this.contextPromiseFor(cached);
|
||||||
|
try {
|
||||||
|
await this.closeContext(context);
|
||||||
|
} finally {
|
||||||
|
this.contextCacheKeys.delete(context);
|
||||||
|
this.contextCachePromises.delete(context);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||||
|
if (firstFailure) {
|
||||||
|
throw firstFailure.reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,23 +738,165 @@ class McpRuntime implements Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resetConnectionOnError(server: string, error: unknown): Promise<void> {
|
private async resetConnectionOnError(server: string, error: unknown, failedContext?: ClientContext): Promise<void> {
|
||||||
if (!shouldResetConnection(error)) {
|
if (!shouldResetConnection(error)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = server.trim();
|
const normalized = server.trim();
|
||||||
if (!this.clients.has(normalized)) {
|
if (!failedContext) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Reuse the existing close() helper so transport shutdown stays consistent with
|
const failedKey = this.contextCacheKeys.get(failedContext);
|
||||||
// normal runtime disposal (wait for STDIO children, close OAuth sessions, etc.).
|
const failedEntry = failedKey ? this.clients.get(failedKey) : undefined;
|
||||||
await this.close(normalized);
|
const failedContextPromise = this.contextCachePromises.get(failedContext);
|
||||||
|
if (
|
||||||
|
!failedKey ||
|
||||||
|
failedEntry?.server !== normalized ||
|
||||||
|
!failedContextPromise ||
|
||||||
|
this.contextPromiseFor(failedEntry) !== failedContextPromise
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.clients.get(failedKey)?.promise !== failedEntry.promise) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.clients.delete(failedKey);
|
||||||
|
if (this.activeClientKeys.get(normalized) === failedKey || this.cachedEntriesForServer(normalized).length === 0) {
|
||||||
|
this.activeClientKeys.delete(normalized);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.closeContext(failedContext);
|
||||||
|
} finally {
|
||||||
|
this.contextCacheKeys.delete(failedContext);
|
||||||
|
this.contextCachePromises.delete(failedContext);
|
||||||
|
}
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
const detail = closeError instanceof Error ? closeError.message : String(closeError);
|
const detail = closeError instanceof Error ? closeError.message : String(closeError);
|
||||||
this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`);
|
this.logger.warn(`Failed to reset '${normalized}' after error: ${detail}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findCachedEntryForRequest(
|
||||||
|
server: string,
|
||||||
|
definition: ServerDefinition,
|
||||||
|
requestedAllowCachedAuth: boolean | undefined,
|
||||||
|
effectiveAllowCachedAuth: boolean | undefined,
|
||||||
|
disableOAuth: boolean
|
||||||
|
): [string, CachedClientEntry] | undefined {
|
||||||
|
const exactKey = this.cacheKey(server, effectiveAllowCachedAuth, disableOAuth);
|
||||||
|
if (this.ignoresAuthCachePolicy(definition)) {
|
||||||
|
const exact = this.clients.get(exactKey);
|
||||||
|
return exact ? [exactKey, exact] : undefined;
|
||||||
|
}
|
||||||
|
if (requestedAllowCachedAuth !== undefined) {
|
||||||
|
const exact = this.clients.get(exactKey);
|
||||||
|
return exact ? [exactKey, exact] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeKey = this.activeClientKeys.get(server);
|
||||||
|
const active = activeKey ? this.clients.get(activeKey) : undefined;
|
||||||
|
const policyMatches = (cached: CachedClientEntry) =>
|
||||||
|
effectiveAllowCachedAuth === undefined || cached.allowCachedAuth === effectiveAllowCachedAuth;
|
||||||
|
if (activeKey && active?.server === server && active.disableOAuth === disableOAuth && policyMatches(active)) {
|
||||||
|
return [activeKey, active];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = [...this.clients.entries()].filter(
|
||||||
|
([, cached]) => cached.server === server && cached.disableOAuth === disableOAuth && policyMatches(cached)
|
||||||
|
);
|
||||||
|
if (matches.length === 1) {
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = this.clients.get(exactKey);
|
||||||
|
return exact ? [exactKey, exact] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async retireConflictingOAuthEntries(server: string, keepKey: string): Promise<void> {
|
||||||
|
const conflicting = [...this.clients.entries()].filter(
|
||||||
|
([key, cached]) => key !== keepKey && cached.server === server && !cached.disableOAuth
|
||||||
|
);
|
||||||
|
if (conflicting.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key] of conflicting) {
|
||||||
|
this.clients.delete(key);
|
||||||
|
if (this.activeClientKeys.get(server) === key) {
|
||||||
|
this.activeClientKeys.delete(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.trackRetirement(server, this.closeCachedEntries(conflicting.map(([, cached]) => cached)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldSerializeConnectionSetup(definition: ServerDefinition, disableOAuth: boolean): boolean {
|
||||||
|
return definition.command.kind === 'http' && !disableOAuth && !this.ignoresAuthCachePolicy(definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ignoresAuthCachePolicy(definition: ServerDefinition): boolean {
|
||||||
|
const replayServer = process.env.MCPORTER_REPLAY_SERVER;
|
||||||
|
const replaysDefinition = Boolean(this.replayPath) && (!replayServer || replayServer === definition.name);
|
||||||
|
return definition.command.kind === 'stdio' || replaysDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackRetirement(server: string, retirement: Promise<void>): Promise<void> {
|
||||||
|
const pending = this.retirementPromises.get(server) ?? new Set<Promise<void>>();
|
||||||
|
pending.add(retirement);
|
||||||
|
this.retirementPromises.set(server, pending);
|
||||||
|
const cleanup = () => {
|
||||||
|
pending.delete(retirement);
|
||||||
|
if (pending.size === 0) {
|
||||||
|
this.retirementPromises.delete(server);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
retirement.then(cleanup, cleanup);
|
||||||
|
return retirement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async awaitRetirements(server?: string): Promise<void> {
|
||||||
|
const pending = server ? [...(this.retirementPromises.get(server) ?? [])] : [];
|
||||||
|
if (!server) {
|
||||||
|
for (const retirements of this.retirementPromises.values()) {
|
||||||
|
pending.push(...retirements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const results = await Promise.allSettled(pending);
|
||||||
|
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
||||||
|
if (firstFailure) {
|
||||||
|
throw firstFailure.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enterConnectionSetup(server: string): Promise<() => void> {
|
||||||
|
const previous = this.connectionSetupTails.get(server) ?? Promise.resolve();
|
||||||
|
let releaseCurrent!: () => void;
|
||||||
|
const current = new Promise<void>((resolve) => {
|
||||||
|
releaseCurrent = resolve;
|
||||||
|
});
|
||||||
|
const tail = previous.catch(() => {}).then(() => current);
|
||||||
|
this.connectionSetupTails.set(server, tail);
|
||||||
|
await previous.catch(() => {});
|
||||||
|
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
released = true;
|
||||||
|
releaseCurrent();
|
||||||
|
void tail.finally(() => {
|
||||||
|
if (this.connectionSetupTails.get(server) === tail) {
|
||||||
|
this.connectionSetupTails.delete(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheKey(server: string, allowCachedAuth: boolean | undefined, disableOAuth: boolean): string {
|
||||||
|
const cachedAuthKey =
|
||||||
|
allowCachedAuth === true ? 'cached-auth-on' : allowCachedAuth === false ? 'cached-auth-off' : 'cached-auth-unset';
|
||||||
|
return `${server}\u0000oauth-disabled:${disableOAuth ? '1' : '0'}\u0000${cachedAuthKey}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL.
|
// createConsoleLogger produces the default runtime logger honoring MCPORTER_LOG_LEVEL.
|
||||||
|
|||||||
@ -86,6 +86,14 @@ export interface CreateClientContextOptions {
|
|||||||
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
|
readonly onDefinitionPromoted?: (definition: ServerDefinition) => void;
|
||||||
readonly allowCachedAuth?: boolean;
|
readonly allowCachedAuth?: boolean;
|
||||||
readonly oauthSessionOptions?: OAuthSessionOptions;
|
readonly oauthSessionOptions?: OAuthSessionOptions;
|
||||||
|
/**
|
||||||
|
* When `true`, suppress the interactive OAuth flow entirely. See
|
||||||
|
* `ConnectOptions.disableOAuth` in `runtime.ts` for the caller-facing
|
||||||
|
* semantics. Internally this short-circuits `shouldEstablishOAuth` and
|
||||||
|
* `maybePromoteHttpDefinition` so the unauthorized-fallback path
|
||||||
|
* cannot re-enable OAuth on a daemon-shaped caller.
|
||||||
|
*/
|
||||||
|
readonly disableOAuth?: boolean;
|
||||||
readonly recordPath?: string;
|
readonly recordPath?: string;
|
||||||
readonly replayPath?: string;
|
readonly replayPath?: string;
|
||||||
}
|
}
|
||||||
@ -188,7 +196,11 @@ function maybePromoteHttpDefinition(
|
|||||||
logger: Logger,
|
logger: Logger,
|
||||||
options: CreateClientContextOptions
|
options: CreateClientContextOptions
|
||||||
): ServerDefinition | undefined {
|
): ServerDefinition | undefined {
|
||||||
if (options.maxOAuthAttempts === 0) {
|
// Both flags suppress promotion-to-OAuth on a 401 fallback. Without
|
||||||
|
// this guard, a daemon-mode caller hitting an unauthorized response
|
||||||
|
// could trigger `maybeEnableOAuth` and effectively re-enable OAuth
|
||||||
|
// on the next attempt — defeating the no-browser-launch contract.
|
||||||
|
if (options.maxOAuthAttempts === 0 || options.disableOAuth === true) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return maybeEnableOAuth(definition, logger);
|
return maybeEnableOAuth(definition, logger);
|
||||||
@ -355,7 +367,8 @@ async function attemptHttpClientContext(
|
|||||||
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
|
throw new Error(`Server '${activeDefinition.name}' is not configured for HTTP transport.`);
|
||||||
}
|
}
|
||||||
let oauthSession: OAuthSession | undefined;
|
let oauthSession: OAuthSession | undefined;
|
||||||
const shouldEstablishOAuth = activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0;
|
const shouldEstablishOAuth =
|
||||||
|
activeDefinition.auth === 'oauth' && options.maxOAuthAttempts !== 0 && options.disableOAuth !== true;
|
||||||
if (shouldEstablishOAuth) {
|
if (shouldEstablishOAuth) {
|
||||||
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
|
oauthSession = await createOAuthSession(activeDefinition, logger, options.oauthSessionOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/serve.ts
41
src/serve.ts
@ -21,6 +21,7 @@ export interface ServeOptions {
|
|||||||
readonly runtime: Pick<Runtime, 'listTools' | 'callTool'>;
|
readonly runtime: Pick<Runtime, 'listTools' | 'callTool'>;
|
||||||
readonly definitions: readonly ServerDefinition[];
|
readonly definitions: readonly ServerDefinition[];
|
||||||
readonly servers?: readonly string[];
|
readonly servers?: readonly string[];
|
||||||
|
readonly bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServeStdioOptions extends ServeOptions {}
|
export interface ServeStdioOptions extends ServeOptions {}
|
||||||
@ -53,11 +54,28 @@ export async function serveStdio(options: ServeStdioOptions): Promise<void> {
|
|||||||
export async function serveHttp(options: ServeHttpOptions): Promise<http.Server> {
|
export async function serveHttp(options: ServeHttpOptions): Promise<http.Server> {
|
||||||
const httpServer = http.createServer((request, response) => {
|
const httpServer = http.createServer((request, response) => {
|
||||||
const url = new URL(request.url ?? '/', `http://${DEFAULT_SERVE_HTTP_HOST}`);
|
const url = new URL(request.url ?? '/', `http://${DEFAULT_SERVE_HTTP_HOST}`);
|
||||||
if (url.pathname !== '/mcp') {
|
let bridgeOptions: ServeOptions;
|
||||||
|
if (url.pathname === '/mcp') {
|
||||||
|
bridgeOptions = options;
|
||||||
|
} else if (url.pathname.startsWith('/mcp/')) {
|
||||||
|
let only: string;
|
||||||
|
try {
|
||||||
|
only = decodeURIComponent(url.pathname.slice('/mcp/'.length));
|
||||||
|
} catch {
|
||||||
|
response.writeHead(400).end('Bad request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const known = selectServedServers(options.definitions, options.servers).some((served) => served.name === only);
|
||||||
|
if (!known) {
|
||||||
|
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end(`Unknown server '${only}'`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bridgeOptions = { ...options, servers: [only], bare: true };
|
||||||
|
} else {
|
||||||
response.writeHead(404).end('Not found');
|
response.writeHead(404).end('Not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bridgeServer = createBridgeServer(options);
|
const bridgeServer = createBridgeServer(bridgeOptions);
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: undefined,
|
sessionIdGenerator: undefined,
|
||||||
});
|
});
|
||||||
@ -90,9 +108,14 @@ export async function serveHttp(options: ServeHttpOptions): Promise<http.Server>
|
|||||||
|
|
||||||
export function createBridgeServer(options: ServeOptions): McpServer {
|
export function createBridgeServer(options: ServeOptions): McpServer {
|
||||||
const servedServers = selectServedServers(options.definitions, options.servers);
|
const servedServers = selectServedServers(options.definitions, options.servers);
|
||||||
if (servedServers.length === 0) {
|
const [firstServed] = servedServers;
|
||||||
|
if (!firstServed) {
|
||||||
throw new Error('No keep-alive MCP servers are available to serve.');
|
throw new Error('No keep-alive MCP servers are available to serve.');
|
||||||
}
|
}
|
||||||
|
const bare = options.bare === true;
|
||||||
|
if (bare && servedServers.length !== 1) {
|
||||||
|
throw new Error('Bare serve mode requires exactly one served server.');
|
||||||
|
}
|
||||||
|
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
{ name: 'mcporter-serve', version: MCPORTER_VERSION },
|
{ name: 'mcporter-serve', version: MCPORTER_VERSION },
|
||||||
@ -100,7 +123,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
} satisfies ServerCapabilities,
|
} satisfies ServerCapabilities,
|
||||||
instructions: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
instructions: bare
|
||||||
|
? `MCPorter bridge exposing the '${firstServed.name}' server.`
|
||||||
|
: 'MCPorter bridge exposing daemon-managed MCP servers. Tool names are namespaced as server__tool.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -119,8 +144,8 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
|||||||
|
|
||||||
for (const tool of listed) {
|
for (const tool of listed) {
|
||||||
tools.push({
|
tools.push({
|
||||||
name: encodeToolName(served.name, tool.name),
|
name: bare ? tool.name : encodeToolName(served.name, tool.name),
|
||||||
description: describeTool(served.name, tool.description),
|
description: bare ? tool.description : describeTool(served.name, tool.description),
|
||||||
inputSchema: normalizeInputSchema(tool.inputSchema),
|
inputSchema: normalizeInputSchema(tool.inputSchema),
|
||||||
outputSchema: normalizeOutputSchema(tool.outputSchema),
|
outputSchema: normalizeOutputSchema(tool.outputSchema),
|
||||||
});
|
});
|
||||||
@ -130,7 +155,9 @@ export function createBridgeServer(options: ServeOptions): McpServer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
const target = decodeToolName(request.params.name, servedServers);
|
const target = bare
|
||||||
|
? { server: firstServed.name, tool: request.params.name }
|
||||||
|
: decodeToolName(request.params.name, servedServers);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
|
throw new McpError(ErrorCode.InvalidParams, `Unknown bridged tool '${request.params.name}'.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,16 @@ type ToolSchemaInfo = {
|
|||||||
propertySet: Set<string>;
|
propertySet: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const KNOWN_OPTION_KEYS = new Set(['tailLog', 'timeout', 'stream', 'streamLog', 'mimeType', 'metadata', 'log']);
|
const KNOWN_OPTION_KEYS = new Set([
|
||||||
|
'disableOAuth',
|
||||||
|
'tailLog',
|
||||||
|
'timeout',
|
||||||
|
'stream',
|
||||||
|
'streamLog',
|
||||||
|
'mimeType',
|
||||||
|
'metadata',
|
||||||
|
'log',
|
||||||
|
]);
|
||||||
|
|
||||||
export interface ServerProxyOptions {
|
export interface ServerProxyOptions {
|
||||||
readonly mapPropertyToTool?: (property: string | symbol) => string;
|
readonly mapPropertyToTool?: (property: string | symbol) => string;
|
||||||
@ -43,6 +52,51 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isProxyOptionKey(key: string): boolean {
|
||||||
|
return key === 'args' || KNOWN_OPTION_KEYS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMetadataOptions(callArgs: unknown[]): {
|
||||||
|
options: { autoAuthorize?: false; disableOAuth?: boolean };
|
||||||
|
optionObjects: Set<Record<string, unknown>>;
|
||||||
|
} {
|
||||||
|
const options: { autoAuthorize?: false; disableOAuth?: boolean } = {};
|
||||||
|
const optionObjects = new Set<Record<string, unknown>>();
|
||||||
|
|
||||||
|
for (const [index, arg] of callArgs.entries()) {
|
||||||
|
if (!isPlainObject(arg) || arg.disableOAuth !== true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(arg);
|
||||||
|
const isOptionsOnlyObject = keys.length > 0 && keys.every(isProxyOptionKey);
|
||||||
|
const hasClearlySeparateToolArgs = callArgs.some((other, otherIndex) => {
|
||||||
|
if (otherIndex === index) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isPlainObject(other)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.hasOwn(other, 'args') || Object.keys(other).some((key) => !isProxyOptionKey(key));
|
||||||
|
});
|
||||||
|
// `args` plus proxy options is reserved envelope syntax; use proxy.call()
|
||||||
|
// when a tool schema itself owns both `args` and `disableOAuth`.
|
||||||
|
const hasExplicitArgsEnvelope = Object.hasOwn(arg, 'args');
|
||||||
|
// A sole object can be a tool argument whose schema owns `disableOAuth`.
|
||||||
|
// Multi-argument calls suppress discovery defensively, then let the schema
|
||||||
|
// classify option-only objects unless another argument is clearly tool input.
|
||||||
|
const isUnambiguousOptionsObject = isOptionsOnlyObject && (hasClearlySeparateToolArgs || hasExplicitArgsEnvelope);
|
||||||
|
if (isUnambiguousOptionsObject) {
|
||||||
|
options.disableOAuth = true;
|
||||||
|
} else if (isOptionsOnlyObject && callArgs.length > 1 && options.disableOAuth !== true) {
|
||||||
|
options.autoAuthorize = false;
|
||||||
|
}
|
||||||
|
if (isUnambiguousOptionsObject) {
|
||||||
|
optionObjects.add(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { options, optionObjects };
|
||||||
|
}
|
||||||
|
|
||||||
// createToolSchemaInfo normalizes schema metadata used for argument mapping.
|
// createToolSchemaInfo normalizes schema metadata used for argument mapping.
|
||||||
function createToolSchemaInfo(schemaRaw: unknown): ToolSchemaInfo | undefined {
|
function createToolSchemaInfo(schemaRaw: unknown): ToolSchemaInfo | undefined {
|
||||||
if (!schemaRaw || typeof schemaRaw !== 'object') {
|
if (!schemaRaw || typeof schemaRaw !== 'object') {
|
||||||
@ -145,7 +199,7 @@ export function createServerProxy(
|
|||||||
const toolSchemaCache = new Map<string, ToolSchemaInfo>();
|
const toolSchemaCache = new Map<string, ToolSchemaInfo>();
|
||||||
const persistedSchemas = new Map<string, Record<string, unknown>>();
|
const persistedSchemas = new Map<string, Record<string, unknown>>();
|
||||||
const toolAliasMap = new Map<string, string>();
|
const toolAliasMap = new Map<string, string>();
|
||||||
let schemaFetch: Promise<void> | null = null;
|
const schemaFetches = new Map<string, Promise<void>>();
|
||||||
let diskLoad: Promise<void> | null = null;
|
let diskLoad: Promise<void> | null = null;
|
||||||
let persistPromise: Promise<void> | null = null;
|
let persistPromise: Promise<void> | null = null;
|
||||||
let refreshPending = false;
|
let refreshPending = false;
|
||||||
@ -184,7 +238,13 @@ export function createServerProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensureMetadata loads schema information for the requested tool, optionally refreshing from the server.
|
// ensureMetadata loads schema information for the requested tool, optionally refreshing from the server.
|
||||||
async function ensureMetadata(toolName: string): Promise<ToolSchemaInfo | undefined> {
|
// Unambiguous proxy options use cache-friendly OAuth suppression. Ambiguous
|
||||||
|
// option-shaped arguments use an uncached no-authorize fetch so discovery
|
||||||
|
// cannot launch OAuth or change the runtime's active connection posture.
|
||||||
|
async function ensureMetadata(
|
||||||
|
toolName: string,
|
||||||
|
metadataOptions: { autoAuthorize?: false; disableOAuth?: boolean } = {}
|
||||||
|
): Promise<ToolSchemaInfo | undefined> {
|
||||||
await consumePersist();
|
await consumePersist();
|
||||||
const cached = toolSchemaCache.get(toolName);
|
const cached = toolSchemaCache.get(toolName);
|
||||||
if (cached && !refreshPending) {
|
if (cached && !refreshPending) {
|
||||||
@ -202,9 +262,28 @@ export function createServerProxy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const disableOAuth = metadataOptions.disableOAuth === true;
|
||||||
|
const schemaFetchKey = disableOAuth
|
||||||
|
? 'disable-oauth'
|
||||||
|
: metadataOptions.autoAuthorize === false
|
||||||
|
? 'no-authorize'
|
||||||
|
: 'default';
|
||||||
|
let schemaFetch = schemaFetches.get(schemaFetchKey);
|
||||||
if (!schemaFetch) {
|
if (!schemaFetch) {
|
||||||
|
const listToolsOptions: {
|
||||||
|
includeSchema: true;
|
||||||
|
autoAuthorize?: false;
|
||||||
|
disableOAuth?: boolean;
|
||||||
|
} = {
|
||||||
|
includeSchema: true,
|
||||||
|
};
|
||||||
|
if (disableOAuth) {
|
||||||
|
listToolsOptions.disableOAuth = true;
|
||||||
|
} else if (metadataOptions.autoAuthorize === false) {
|
||||||
|
listToolsOptions.autoAuthorize = false;
|
||||||
|
}
|
||||||
schemaFetch = runtime
|
schemaFetch = runtime
|
||||||
.listTools(serverName, { includeSchema: true })
|
.listTools(serverName, listToolsOptions)
|
||||||
.then((tools) => {
|
.then((tools) => {
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
if (!tool.inputSchema || typeof tool.inputSchema !== 'object') {
|
if (!tool.inputSchema || typeof tool.inputSchema !== 'object') {
|
||||||
@ -216,9 +295,12 @@ export function createServerProxy(
|
|||||||
refreshPending = false;
|
refreshPending = false;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
schemaFetch = null;
|
if (schemaFetches.get(schemaFetchKey) === schemaFetch) {
|
||||||
|
schemaFetches.delete(schemaFetchKey);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
schemaFetches.set(schemaFetchKey, schemaFetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
await schemaFetch;
|
await schemaFetch;
|
||||||
@ -301,9 +383,11 @@ export function createServerProxy(
|
|||||||
: mapPropertyToTool(propertyKey);
|
: mapPropertyToTool(propertyKey);
|
||||||
|
|
||||||
return async (...callArgs: unknown[]) => {
|
return async (...callArgs: unknown[]) => {
|
||||||
|
const { options: metadataOptions, optionObjects } = inferMetadataOptions(callArgs);
|
||||||
|
|
||||||
let schemaInfo: ToolSchemaInfo | undefined;
|
let schemaInfo: ToolSchemaInfo | undefined;
|
||||||
try {
|
try {
|
||||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||||
} catch {
|
} catch {
|
||||||
schemaInfo = undefined;
|
schemaInfo = undefined;
|
||||||
}
|
}
|
||||||
@ -312,7 +396,7 @@ export function createServerProxy(
|
|||||||
if (alias && alias !== resolvedToolName) {
|
if (alias && alias !== resolvedToolName) {
|
||||||
resolvedToolName = alias;
|
resolvedToolName = alias;
|
||||||
try {
|
try {
|
||||||
schemaInfo = await ensureMetadata(resolvedToolName);
|
schemaInfo = await ensureMetadata(resolvedToolName, metadataOptions);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore and keep prior schema if available
|
// ignore and keep prior schema if available
|
||||||
}
|
}
|
||||||
@ -327,6 +411,7 @@ export function createServerProxy(
|
|||||||
if (isPlainObject(arg)) {
|
if (isPlainObject(arg)) {
|
||||||
const keys = Object.keys(arg);
|
const keys = Object.keys(arg);
|
||||||
const treatAsArgs =
|
const treatAsArgs =
|
||||||
|
!optionObjects.has(arg) &&
|
||||||
schemaInfo !== undefined &&
|
schemaInfo !== undefined &&
|
||||||
keys.length > 0 &&
|
keys.length > 0 &&
|
||||||
(keys.every((key) => schemaInfo.propertySet.has(key)) ||
|
(keys.every((key) => schemaInfo.propertySet.has(key)) ||
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { parseCallArguments } from '../src/cli/call-arguments.js';
|
import { parseCallArguments } from '../src/cli/call-arguments.js';
|
||||||
|
|
||||||
@ -93,6 +95,55 @@ describe('parseCallArguments', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads exact UTF-8 text from @path named argument values', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||||
|
const payloadPath = path.join(tempDir, 'payload.txt');
|
||||||
|
fs.writeFileSync(payloadPath, 'first line\nsecond line\n', 'utf8');
|
||||||
|
try {
|
||||||
|
const parsed = parseCallArguments(['server.tool', `body=@${payloadPath}`]);
|
||||||
|
expect(parsed.args.body).toBe('first line\nsecond line\n');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports @path through generic long tool flags', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||||
|
const payloadPath = path.join(tempDir, 'payload.txt');
|
||||||
|
fs.writeFileSync(payloadPath, 'from file', 'utf8');
|
||||||
|
try {
|
||||||
|
const parsed = parseCallArguments(['server.tool', '--body', `@${payloadPath}`]);
|
||||||
|
expect(parsed.args.body).toBe('from file');
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves whitespace-only generic long flag values', () => {
|
||||||
|
const parsed = parseCallArguments(['server.tool', '--body', ' ']);
|
||||||
|
expect(parsed.args.body).toBe(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses @@ to preserve a literal leading @ without reading a file', () => {
|
||||||
|
const parsed = parseCallArguments(['server.tool', 'body=@@literal']);
|
||||||
|
expect(parsed.args.body).toBe('@literal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports missing and non-UTF-8 argument files before transport', () => {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-call-args-'));
|
||||||
|
const invalidPath = path.join(tempDir, 'invalid.bin');
|
||||||
|
fs.writeFileSync(invalidPath, Buffer.from([0xc3, 0x28]));
|
||||||
|
try {
|
||||||
|
expect(() => parseCallArguments(['server.tool', `body=@${path.join(tempDir, 'missing.txt')}`])).toThrow(
|
||||||
|
/Unable to read argument file/
|
||||||
|
);
|
||||||
|
expect(() => parseCallArguments(['server.tool', `body=@${invalidPath}`])).toThrow(/not valid UTF-8 text/);
|
||||||
|
expect(() => parseCallArguments(['server.tool', 'body=@'])).toThrow(/requires a path/);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('throws when generic long flags are missing a value', () => {
|
it('throws when generic long flags are missing a value', () => {
|
||||||
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
|
expect(() => parseCallArguments(['server.tool', '--source'])).toThrow("Flag '--source' requires a value.");
|
||||||
});
|
});
|
||||||
@ -175,6 +226,12 @@ describe('parseCallArguments', () => {
|
|||||||
expect(parsed.positionalArgs).toEqual(['123']);
|
expect(parsed.positionalArgs).toEqual(['123']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('captures --no-oauth as a runtime flag instead of a tool argument', () => {
|
||||||
|
const parsed = parseCallArguments(['server.tool', '--no-oauth', 'limit=5']);
|
||||||
|
expect(parsed.disableOAuth).toBe(true);
|
||||||
|
expect(parsed.args).toEqual({ limit: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
it('captures --save-images output directory', () => {
|
it('captures --save-images output directory', () => {
|
||||||
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
|
const parsed = parseCallArguments(['--save-images', './tmp/images', 'server.tool']);
|
||||||
expect(parsed.saveImagesDir).toBe('./tmp/images');
|
expect(parsed.saveImagesDir).toBe('./tmp/images');
|
||||||
|
|||||||
@ -86,6 +86,7 @@ describe('CLI call execution behavior', () => {
|
|||||||
autoAuthorize: true,
|
autoAuthorize: true,
|
||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@ -125,6 +126,7 @@ describe('CLI call execution behavior', () => {
|
|||||||
autoAuthorize: true,
|
autoAuthorize: true,
|
||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
});
|
});
|
||||||
@ -338,6 +340,7 @@ describe('CLI call execution behavior', () => {
|
|||||||
autoAuthorize: true,
|
autoAuthorize: true,
|
||||||
includeSchema: false,
|
includeSchema: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
logSpy.mockRestore();
|
logSpy.mockRestore();
|
||||||
|
|||||||
@ -89,6 +89,16 @@ describe('daemon call fast path', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves CloudBase calls on the config-aware runtime path', async () => {
|
||||||
|
mocks.createRuntime.mockRejectedValue(new Error('runtime path used'));
|
||||||
|
const { runCli } = await import('../src/cli.js');
|
||||||
|
|
||||||
|
await expect(runCli(['call', 'cloudbase.auth', '--output', 'json'])).rejects.toThrow('runtime path used');
|
||||||
|
|
||||||
|
expect(mocks.createRuntime).toHaveBeenCalled();
|
||||||
|
expect(mocks.daemonCallTool).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['MCPORTER_RECORD', 'MCPORTER_REPLAY'] as const)(
|
it.each(['MCPORTER_RECORD', 'MCPORTER_REPLAY'] as const)(
|
||||||
'bypasses the daemon fast path while %s is active',
|
'bypasses the daemon fast path while %s is active',
|
||||||
async (modeEnv) => {
|
async (modeEnv) => {
|
||||||
|
|||||||
@ -34,12 +34,50 @@ async function ensureDistBuilt(): Promise<void> {
|
|||||||
|
|
||||||
async function hasBun(): Promise<boolean> {
|
async function hasBun(): Promise<boolean> {
|
||||||
return await new Promise<boolean>((resolve) => {
|
return await new Promise<boolean>((resolve) => {
|
||||||
execFile('bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
|
execFile(process.env.BUN_BIN ?? 'bun', ['--version'], { cwd: process.cwd(), env: process.env }, (error) => {
|
||||||
resolve(!error);
|
resolve(!error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bunCompileSupport: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
async function hasRunnableBunCompile(): Promise<boolean> {
|
||||||
|
bunCompileSupport ??= probeRunnableBunCompile();
|
||||||
|
return await bunCompileSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRunnableBunCompile(): Promise<boolean> {
|
||||||
|
if (!(await hasBun())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-bun-compile-probe-'));
|
||||||
|
const sourcePath = path.join(tempDir, 'probe.ts');
|
||||||
|
const binaryPath = path.join(tempDir, 'probe');
|
||||||
|
try {
|
||||||
|
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
|
||||||
|
const bun = process.env.BUN_BIN ?? 'bun';
|
||||||
|
const built = await new Promise<boolean>((resolve) => {
|
||||||
|
execFile(
|
||||||
|
bun,
|
||||||
|
['build', sourcePath, '--compile', '--outfile', binaryPath],
|
||||||
|
{ cwd: tempDir, env: process.env },
|
||||||
|
(error) => resolve(!error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!built) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
execFile(binaryPath, [], { cwd: tempDir, env: process.env }, (error, stdout) => {
|
||||||
|
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureBunSupport(reason: string): Promise<boolean> {
|
async function ensureBunSupport(reason: string): Promise<boolean> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
console.warn(`bun not supported on Windows; skipping ${reason}.`);
|
console.warn(`bun not supported on Windows; skipping ${reason}.`);
|
||||||
@ -52,6 +90,17 @@ async function ensureBunSupport(reason: string): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureRunnableBunCompile(reason: string): Promise<boolean> {
|
||||||
|
if (!(await ensureBunSupport(reason))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!(await hasRunnableBunCompile())) {
|
||||||
|
console.warn(`bun-compiled binaries cannot run on this runner; skipping ${reason}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function runGeneratedCli(
|
async function runGeneratedCli(
|
||||||
bundlePath: string,
|
bundlePath: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
@ -566,7 +615,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => {
|
it('runs "node dist/cli.js generate-cli --compile" when bun is available', async () => {
|
||||||
if (!(await ensureBunSupport('compile integration test'))) {
|
if (!(await ensureRunnableBunCompile('compile integration test'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-'));
|
||||||
@ -616,7 +665,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
|
it('end-to-end: compiles a "bun" CLI and calls ping', async () => {
|
||||||
if (!(await ensureBunSupport('Bun CLI end-to-end test'))) {
|
if (!(await ensureRunnableBunCompile('Bun CLI end-to-end test'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-bun-'));
|
||||||
@ -690,7 +739,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
|||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
it('runs "node dist/cli.js generate-cli --compile" using the Bun bundler by default', async () => {
|
it('runs "node dist/cli.js generate-cli --compile" using the Bun bundler by default', async () => {
|
||||||
if (!(await ensureBunSupport('Bun bundler compile integration test'))) {
|
if (!(await ensureRunnableBunCompile('Bun bundler compile integration test'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cli-compile-bun-'));
|
||||||
@ -739,7 +788,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it('accepts inline stdio commands (e.g., "npx -y chrome-devtools-mcp@latest") when compiling', async () => {
|
it('accepts inline stdio commands (e.g., "npx -y chrome-devtools-mcp@latest") when compiling', async () => {
|
||||||
if (!(await ensureBunSupport('inline stdio compile integration test'))) {
|
if (!(await ensureRunnableBunCompile('inline stdio compile integration test'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-inline-stdio-'));
|
||||||
@ -884,7 +933,7 @@ await new Promise((resolve) => { transport.onclose = resolve; });
|
|||||||
console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke');
|
console.warn('set MCPORTER_STANDALONE_BINARY_TEST=1 to run standalone Bun release binary smoke');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await ensureBunSupport('standalone Bun release binary smoke'))) {
|
if (!(await ensureRunnableBunCompile('standalone Bun release binary smoke'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@ -45,4 +45,17 @@ describe('mcporter help shortcuts (hidden)', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(expectSnippet));
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining(expectSnippet));
|
||||||
expect(process.exitCode).toBe(0);
|
expect(process.exitCode).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['serve', '--help'],
|
||||||
|
['serve', 'help'],
|
||||||
|
])('prints serve HTTP endpoint help for %j', async (...args) => {
|
||||||
|
const { runCli } = await cliModulePromise;
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await runCli(args);
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('/mcp/<server>'));
|
||||||
|
expect(process.exitCode).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -12,4 +12,10 @@ describe('inspect-cli flag parsing', () => {
|
|||||||
it('validates explicit format values', () => {
|
it('validates explicit format values', () => {
|
||||||
expect(() => inspectInternals.parseInspectFlags(['--format', 'xml', 'artifact'])).toThrow(/format/);
|
expect(() => inspectInternals.parseInspectFlags(['--format', 'xml', 'artifact'])).toThrow(/format/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects extra positional arguments', () => {
|
||||||
|
expect(() => inspectInternals.parseInspectFlags(['artifact', 'shadow'])).toThrow(
|
||||||
|
/Unexpected inspect-cli argument 'shadow'/
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -260,6 +260,7 @@ describe('CLI list classification and routing', () => {
|
|||||||
expect(listTools).toHaveBeenCalledWith('linear', {
|
expect(listTools).toHaveBeenCalledWith('linear', {
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -330,6 +331,8 @@ describe('CLI list classification and routing', () => {
|
|||||||
|
|
||||||
it('suggests a server name when the typo is large', async () => {
|
it('suggests a server name when the typo is large', async () => {
|
||||||
const { handleList } = await cliModulePromise;
|
const { handleList } = await cliModulePromise;
|
||||||
|
const previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
const definition = linearDefinition;
|
const definition = linearDefinition;
|
||||||
const listTools = vi.fn();
|
const listTools = vi.fn();
|
||||||
const runtime = {
|
const runtime = {
|
||||||
@ -343,13 +346,17 @@ describe('CLI list classification and routing', () => {
|
|||||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
await handleList(runtime, ['zzz']);
|
try {
|
||||||
|
await handleList(runtime, ['zzz']);
|
||||||
|
|
||||||
const errorLines = errorSpy.mock.calls.map((call) => call.join(' '));
|
const errorLines = errorSpy.mock.calls.map((call) => call.join(' '));
|
||||||
expect(errorLines.some((line) => line.includes('Did you mean linear?'))).toBe(true);
|
expect(errorLines.some((line) => line.includes('Did you mean linear?'))).toBe(true);
|
||||||
expect(listTools).not.toHaveBeenCalled();
|
expect(listTools).not.toHaveBeenCalled();
|
||||||
|
expect(process.exitCode).toBe(1);
|
||||||
errorSpy.mockRestore();
|
} finally {
|
||||||
logSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,6 +19,7 @@ describe('CLI list flag parsing', () => {
|
|||||||
quiet: false,
|
quiet: false,
|
||||||
exitCode: false,
|
exitCode: false,
|
||||||
statusOnly: false,
|
statusOnly: false,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
expect(args).toEqual(['server']);
|
expect(args).toEqual(['server']);
|
||||||
});
|
});
|
||||||
@ -39,10 +40,19 @@ describe('CLI list flag parsing', () => {
|
|||||||
quiet: false,
|
quiet: false,
|
||||||
exitCode: false,
|
exitCode: false,
|
||||||
statusOnly: false,
|
statusOnly: false,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
expect(args).toEqual(['server']);
|
expect(args).toEqual(['server']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses --no-oauth and removes it from args', async () => {
|
||||||
|
const { extractListFlags } = await cliModulePromise;
|
||||||
|
const args = ['--no-oauth', 'server'];
|
||||||
|
const flags = extractListFlags(args);
|
||||||
|
expect(flags.disableOAuth).toBe(true);
|
||||||
|
expect(args).toEqual(['server']);
|
||||||
|
});
|
||||||
|
|
||||||
it('parses --json flag and removes it from args', async () => {
|
it('parses --json flag and removes it from args', async () => {
|
||||||
const { extractListFlags } = await cliModulePromise;
|
const { extractListFlags } = await cliModulePromise;
|
||||||
const args = ['--json', 'server'];
|
const args = ['--json', 'server'];
|
||||||
|
|||||||
@ -37,20 +37,26 @@ function createRuntime(): Runtime {
|
|||||||
describe('handleList JSON output', () => {
|
describe('handleList JSON output', () => {
|
||||||
it('emits aggregated status counts', async () => {
|
it('emits aggregated status counts', async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
|
const previousExitCode = process.exitCode;
|
||||||
|
process.exitCode = undefined;
|
||||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
await runHandleList(runtime, ['--json']);
|
try {
|
||||||
|
await runHandleList(runtime, ['--json']);
|
||||||
|
|
||||||
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
|
const payload = JSON.parse(logSpy.mock.calls.at(-1)?.[0] ?? '{}');
|
||||||
expect(payload.mode).toBe('list');
|
expect(payload.mode).toBe('list');
|
||||||
expect(payload.counts.auth).toBe(1);
|
expect(payload.counts.auth).toBe(1);
|
||||||
const healthyEntry = payload.servers.find((entry: { name: string }) => entry.name === 'healthy');
|
const healthyEntry = payload.servers.find((entry: { name: string }) => entry.name === 'healthy');
|
||||||
expect(healthyEntry.status).toBe('ok');
|
expect(healthyEntry.status).toBe('ok');
|
||||||
const authEntry = payload.servers.find((entry: { name: string }) => entry.name === 'auth-server');
|
const authEntry = payload.servers.find((entry: { name: string }) => entry.name === 'auth-server');
|
||||||
expect(authEntry.status).toBe('auth');
|
expect(authEntry.status).toBe('auth');
|
||||||
expect(authEntry.issue.kind).toBe('auth');
|
expect(authEntry.issue.kind).toBe('auth');
|
||||||
|
expect(process.exitCode).toBeUndefined();
|
||||||
logSpy.mockRestore();
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
process.exitCode = previousExitCode;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets a non-zero exit code for unhealthy multi-server checks when requested', async () => {
|
it('sets a non-zero exit code for unhealthy multi-server checks when requested', async () => {
|
||||||
|
|||||||
67
tests/cli-metadata.test.ts
Normal file
67
tests/cli-metadata.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { metadataPathForArtifact, readCliMetadata } from '../src/cli-metadata.js';
|
||||||
|
|
||||||
|
describe('readCliMetadata', () => {
|
||||||
|
it('prefers embedded metadata over stale sidecar metadata', async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-metadata-'));
|
||||||
|
const artifact = path.join(tempDir, process.platform === 'win32' ? 'artifact.exe' : 'artifact');
|
||||||
|
const embedded = metadataPayload('embedded');
|
||||||
|
const sidecar = metadataPayload('sidecar');
|
||||||
|
const previousEmbeddedMetadata = process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||||
|
const previousNodeOptions = process.env.NODE_OPTIONS;
|
||||||
|
process.env.MCPORTER_TEST_EMBEDDED_METADATA = JSON.stringify(embedded);
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const preload = path.join(tempDir, 'inspect-preload.cjs');
|
||||||
|
await fs.copyFile(process.execPath, artifact);
|
||||||
|
await fs.writeFile(
|
||||||
|
preload,
|
||||||
|
'console.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA); process.exit(0);\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const requirePath = preload.replaceAll(path.sep, path.posix.sep);
|
||||||
|
process.env.NODE_OPTIONS = `${previousNodeOptions ? `${previousNodeOptions} ` : ''}--require ${requirePath}`;
|
||||||
|
} else {
|
||||||
|
const artifactContent = '#!/usr/bin/env node\nconsole.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA);\n';
|
||||||
|
await fs.writeFile(artifact, artifactContent, 'utf8');
|
||||||
|
await fs.chmod(artifact, 0o755);
|
||||||
|
}
|
||||||
|
await fs.writeFile(metadataPathForArtifact(artifact), JSON.stringify(sidecar), 'utf8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(readCliMetadata(artifact)).resolves.toMatchObject({
|
||||||
|
server: { name: 'embedded' },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (previousEmbeddedMetadata === undefined) {
|
||||||
|
delete process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||||
|
} else {
|
||||||
|
process.env.MCPORTER_TEST_EMBEDDED_METADATA = previousEmbeddedMetadata;
|
||||||
|
}
|
||||||
|
if (previousNodeOptions === undefined) {
|
||||||
|
delete process.env.NODE_OPTIONS;
|
||||||
|
} else {
|
||||||
|
process.env.NODE_OPTIONS = previousNodeOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function metadataPayload(name: string) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: '1970-01-01T00:00:00.000Z',
|
||||||
|
generator: { name: 'mcporter', version: 'test' },
|
||||||
|
server: {
|
||||||
|
name,
|
||||||
|
definition: {
|
||||||
|
name,
|
||||||
|
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artifact: { path: '', kind: 'template' as const },
|
||||||
|
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -52,6 +52,13 @@ describe('mcporter --oauth-timeout flag', () => {
|
|||||||
createRuntimeSpy.mockRestore();
|
createRuntimeSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects malformed --oauth-timeout values', async () => {
|
||||||
|
const { runCli } = await import('../src/cli.js');
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
await expect(runCli(['--oauth-timeout', '5000abc', 'list'])).rejects.toThrow(/process\.exit/);
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('positive integer'));
|
||||||
|
});
|
||||||
|
|
||||||
it('returns once runtime.listTools surfaces an OAuth timeout error', async () => {
|
it('returns once runtime.listTools surfaces an OAuth timeout error', async () => {
|
||||||
const definition = {
|
const definition = {
|
||||||
name: 'fake',
|
name: 'fake',
|
||||||
|
|||||||
@ -53,6 +53,17 @@ describe('handleResource', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes disableOAuth to resource helpers when requested', async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
await handleResource(runtime, ['docs', 'memo://one', '--no-oauth']);
|
||||||
|
expect(runtime.readResource).toHaveBeenCalledWith('docs', 'memo://one', { disableOAuth: true });
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('prints structured JSON for resource listing failures', async () => {
|
it('prints structured JSON for resource listing failures', async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found'));
|
runtime.listResources.mockRejectedValue(new Error('MCP error -32601: Method not found'));
|
||||||
|
|||||||
26
tests/cli-timeouts.test.ts
Normal file
26
tests/cli-timeouts.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { consumeTimeoutFlag, parseTimeout } from '../src/cli/timeouts.js';
|
||||||
|
|
||||||
|
describe('CLI timeout parsing', () => {
|
||||||
|
it('accepts positive integer millisecond values', () => {
|
||||||
|
expect(parseTimeout('2500', 30_000)).toBe(2_500);
|
||||||
|
|
||||||
|
const args = ['--timeout', '7500', 'server'];
|
||||||
|
expect(consumeTimeoutFlag(args, 0)).toBe(7_500);
|
||||||
|
expect(args).toEqual(['server']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back for non-positive and partially numeric environment values', () => {
|
||||||
|
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
|
||||||
|
expect(parseTimeout(value, 30_000)).toBe(30_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-positive and partially numeric CLI flag values', () => {
|
||||||
|
for (const value of ['0', '-1', '1s', '10abc', '100.5']) {
|
||||||
|
expect(() => consumeTimeoutFlag(['--timeout', value], 0)).toThrow(
|
||||||
|
'--timeout must be a positive integer (milliseconds).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -223,6 +223,48 @@ describe('config imports', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to a later imported duplicate when an earlier import has unresolved placeholders', async () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mcporter-import-fallback-'));
|
||||||
|
try {
|
||||||
|
const configPath = path.join(tempRoot, 'config', 'mcporter.json');
|
||||||
|
const cursorPath = path.join(tempRoot, '.cursor', 'mcp.json');
|
||||||
|
const claudePath = path.join(ensureFakeHomeDir(), '.claude', 'settings.json');
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: {}, imports: ['cursor', 'claude-code'] }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
cursorPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
shared: { command: 'cursor-mcp', args: ['${workspaceFolder}'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
claudePath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
shared: { command: 'claude-mcp', args: ['--usable'] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const servers = await loadServerDefinitions({ configPath, rootDir: tempRoot });
|
||||||
|
const shared = servers.find((server) => server.name === 'shared');
|
||||||
|
|
||||||
|
expect(shared?.command.kind).toBe('stdio');
|
||||||
|
expect(shared?.command.kind === 'stdio' ? shared.command.command : undefined).toBe('claude-mcp');
|
||||||
|
expect(shared?.source).toEqual({
|
||||||
|
kind: 'import',
|
||||||
|
path: claudePath,
|
||||||
|
importKind: 'claude-code',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('loads Claude project-scoped servers without treating metadata as servers', async () => {
|
it('loads Claude project-scoped servers without treating metadata as servers', async () => {
|
||||||
const homeDir = ensureFakeHomeDir();
|
const homeDir = ensureFakeHomeDir();
|
||||||
const claudeDir = path.join(homeDir, '.claude');
|
const claudeDir = path.join(homeDir, '.claude');
|
||||||
|
|||||||
@ -5,7 +5,12 @@ import os from 'node:os';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { ServerDefinition } from '../src/config.js';
|
import type { ServerDefinition } from '../src/config.js';
|
||||||
import { __testProcessRequest, isDaemonResponding, metadataMatches } from '../src/daemon/host.js';
|
import {
|
||||||
|
__testProcessRequest,
|
||||||
|
cleanupDaemonArtifactsIfOwned,
|
||||||
|
isDaemonResponding,
|
||||||
|
metadataMatches,
|
||||||
|
} from '../src/daemon/host.js';
|
||||||
import type { DaemonRequest } from '../src/daemon/protocol.js';
|
import type { DaemonRequest } from '../src/daemon/protocol.js';
|
||||||
import type { Runtime } from '../src/runtime.js';
|
import type { Runtime } from '../src/runtime.js';
|
||||||
|
|
||||||
@ -49,6 +54,7 @@ describe('daemon host request handling', () => {
|
|||||||
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
|
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
|
||||||
args: {},
|
args: {},
|
||||||
timeoutMs: undefined,
|
timeoutMs: undefined,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||||
@ -61,6 +67,7 @@ describe('daemon host request handling', () => {
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: undefined,
|
autoAuthorize: undefined,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -78,6 +85,7 @@ describe('daemon host request handling', () => {
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: undefined,
|
autoAuthorize: undefined,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,6 +103,37 @@ describe('daemon host request handling', () => {
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards disableOAuth on daemon callTool and listTools requests', async () => {
|
||||||
|
const runtime = createRuntimeDouble();
|
||||||
|
const managedServers = createManagedServers();
|
||||||
|
|
||||||
|
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||||
|
id: 'call',
|
||||||
|
method: 'callTool',
|
||||||
|
params: { server: 'oauth', tool: 'ping', disableOAuth: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('oauth', 'ping', {
|
||||||
|
args: {},
|
||||||
|
timeoutMs: undefined,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await __testProcessRequest('', runtime as unknown as Runtime, managedServers, new Map(), metadata, logContext, {
|
||||||
|
id: 'list',
|
||||||
|
method: 'listTools',
|
||||||
|
params: { server: 'oauth', includeSchema: true, disableOAuth: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('oauth', {
|
||||||
|
includeSchema: true,
|
||||||
|
autoAuthorize: undefined,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -112,6 +151,7 @@ describe('daemon host request handling', () => {
|
|||||||
includeSchema: undefined,
|
includeSchema: undefined,
|
||||||
autoAuthorize: undefined,
|
autoAuthorize: undefined,
|
||||||
allowCachedAuth: false,
|
allowCachedAuth: false,
|
||||||
|
disableOAuth: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -147,12 +187,6 @@ describeUnixSocket('isDaemonResponding', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function statusServer(result: Record<string, unknown>): net.Server {
|
|
||||||
return net.createServer((socket) => {
|
|
||||||
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns true when the socket answers status with a matching socket and live pid', async () => {
|
it('returns true when the socket answers status with a matching socket and live pid', async () => {
|
||||||
const p = socketPath();
|
const p = socketPath();
|
||||||
await listen(statusServer({ pid: process.pid, socketPath: p }), p);
|
await listen(statusServer({ pid: process.pid, socketPath: p }), p);
|
||||||
@ -217,6 +251,41 @@ describe('metadataMatches', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('daemon artifact cleanup', () => {
|
||||||
|
let dir: string;
|
||||||
|
let metadataPath: string;
|
||||||
|
let socketPath: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-'));
|
||||||
|
metadataPath = path.join(dir, 'daemon.json');
|
||||||
|
socketPath = path.join(dir, 'daemon.sock');
|
||||||
|
await fs.writeFile(socketPath, 'socket', 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes artifacts still owned by the stopping daemon', async () => {
|
||||||
|
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8');
|
||||||
|
|
||||||
|
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
|
||||||
|
|
||||||
|
await expect(fs.access(metadataPath)).rejects.toThrow();
|
||||||
|
await expect(fs.access(socketPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves artifacts replaced by a newer daemon', async () => {
|
||||||
|
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8');
|
||||||
|
|
||||||
|
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
|
||||||
|
|
||||||
|
await expect(fs.access(metadataPath)).resolves.toBeUndefined();
|
||||||
|
await expect(fs.access(socketPath)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
|
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
|
||||||
return {
|
return {
|
||||||
callTool: vi.fn().mockResolvedValue({ ok: true }),
|
callTool: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
@ -244,3 +313,9 @@ function createManagedServers(): Map<string, ServerDefinition> {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusServer(result: Record<string, unknown>): net.Server {
|
||||||
|
return net.createServer((socket) => {
|
||||||
|
socket.on('data', () => socket.end(JSON.stringify({ id: '1', ok: true, result })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -101,6 +101,10 @@ describe('emit-ts templates', () => {
|
|||||||
expect(source).toContain('wrapCallResult');
|
expect(source).toContain('wrapCallResult');
|
||||||
expect(source).toContain('proxy.listComments');
|
expect(source).toContain('proxy.listComments');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not leave a .d suffix when importing generated declaration files', () => {
|
||||||
|
expect(emitTsTestInternals.computeImportPath('/tmp/client.ts', '/tmp/client.d.ts')).toBe('./client');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleEmitTs', () => {
|
describe('handleEmitTs', () => {
|
||||||
|
|||||||
18
tests/fixtures/stdio-memory-server.mjs
vendored
18
tests/fixtures/stdio-memory-server.mjs
vendored
@ -32,6 +32,24 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'echo_text',
|
||||||
|
{
|
||||||
|
title: 'Echo Text',
|
||||||
|
description: 'Return the provided text unchanged',
|
||||||
|
inputSchema: {
|
||||||
|
text: z.string(),
|
||||||
|
},
|
||||||
|
outputSchema: {
|
||||||
|
text: z.string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({ text }) => ({
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
structuredContent: { text },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'list_entities',
|
'list_entities',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -238,6 +238,39 @@ describe('fs-json helpers', () => {
|
|||||||
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
|
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the timeout while waiting for a same-process lock', async () => {
|
||||||
|
const lockTarget = path.join(tempDir, 'shared.json');
|
||||||
|
let enter!: () => void;
|
||||||
|
let unblock!: () => void;
|
||||||
|
const entered = new Promise<void>((resolve) => {
|
||||||
|
enter = resolve;
|
||||||
|
});
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
unblock = resolve;
|
||||||
|
});
|
||||||
|
const holder = withFileLock(lockTarget, async () => {
|
||||||
|
enter();
|
||||||
|
await blocked;
|
||||||
|
});
|
||||||
|
await entered;
|
||||||
|
|
||||||
|
await expect(withFileLock(lockTarget, async () => {}, { timeoutMs: 50 })).rejects.toThrow(
|
||||||
|
/Timed out waiting for file lock/
|
||||||
|
);
|
||||||
|
|
||||||
|
let followerEntered = false;
|
||||||
|
const follower = withFileLock(lockTarget, async () => {
|
||||||
|
followerEntered = true;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(followerEntered).toBe(false);
|
||||||
|
|
||||||
|
unblock();
|
||||||
|
await Promise.all([holder, follower]);
|
||||||
|
expect(followerEntered).toBe(true);
|
||||||
|
await expect(fs.access(`${lockTarget}.lock`)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers lock files left by dead processes', async () => {
|
it('recovers lock files left by dead processes', async () => {
|
||||||
const lockTarget = path.join(tempDir, 'shared.json');
|
const lockTarget = path.join(tempDir, 'shared.json');
|
||||||
await fs.writeFile(`${lockTarget}.lock`, '99999999\n2026-01-01T00:00:00.000Z\n', 'utf8');
|
await fs.writeFile(`${lockTarget}.lock`, '99999999\n2026-01-01T00:00:00.000Z\n', 'utf8');
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
buildFallbackLiteral,
|
buildFallbackLiteral,
|
||||||
buildPlaceholder,
|
buildPlaceholder,
|
||||||
buildToolMetadata,
|
buildToolMetadata,
|
||||||
|
buildToolMetadataList,
|
||||||
extractOptions,
|
extractOptions,
|
||||||
getDescriptorDefault,
|
getDescriptorDefault,
|
||||||
getDescriptorDescription,
|
getDescriptorDescription,
|
||||||
@ -45,6 +46,15 @@ describe('generate helpers', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects generated proxy method collisions', () => {
|
||||||
|
expect(() =>
|
||||||
|
buildToolMetadataList([
|
||||||
|
{ name: 'some-tool', inputSchema: undefined, outputSchema: undefined },
|
||||||
|
{ name: 'some_tool', inputSchema: undefined, outputSchema: undefined },
|
||||||
|
])
|
||||||
|
).toThrow(/Generated proxy method collision 'someTool'/);
|
||||||
|
});
|
||||||
|
|
||||||
it('extracts detailed option information', () => {
|
it('extracts detailed option information', () => {
|
||||||
const options = extractOptions(sampleTool);
|
const options = extractOptions(sampleTool);
|
||||||
const first = options.find((option) => option.property === 'firstValue');
|
const first = options.find((option) => option.property === 'firstValue');
|
||||||
|
|||||||
@ -203,9 +203,9 @@ describeGenerateCli('generateCli', () => {
|
|||||||
});
|
});
|
||||||
await fs.mkdir(path.join(tmpDir, 'schema-cache'), { recursive: true });
|
await fs.mkdir(path.join(tmpDir, 'schema-cache'), { recursive: true });
|
||||||
const exec = await import('node:child_process');
|
const exec = await import('node:child_process');
|
||||||
const bunAvailable = await hasBun(exec);
|
const bunAvailable = await hasRunnableBunCompile(exec);
|
||||||
if (!bunAvailable) {
|
if (!bunAvailable) {
|
||||||
console.warn('bun is not available on this runner; skipping compilation checks.');
|
console.warn('bun-compiled binaries cannot run on this runner; skipping compilation checks.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ensureDistBuilt();
|
await ensureDistBuilt();
|
||||||
@ -891,3 +891,38 @@ async function hasBun(exec: typeof import('node:child_process')) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bunCompileSupport: Promise<boolean> | undefined;
|
||||||
|
|
||||||
|
async function hasRunnableBunCompile(exec: typeof import('node:child_process')) {
|
||||||
|
bunCompileSupport ??= probeRunnableBunCompile(exec);
|
||||||
|
return await bunCompileSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeRunnableBunCompile(exec: typeof import('node:child_process')) {
|
||||||
|
if (!(await hasBun(exec))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(tmpDir, 'bun-compile-probe-'));
|
||||||
|
const sourcePath = path.join(tempDir, 'probe.ts');
|
||||||
|
const binaryPath = path.join(tempDir, 'probe');
|
||||||
|
try {
|
||||||
|
await fs.writeFile(sourcePath, 'console.log("mcporter-bun-compile-probe");\n', 'utf8');
|
||||||
|
const bun = process.env.BUN_BIN ?? 'bun';
|
||||||
|
const built = await new Promise<boolean>((resolve) => {
|
||||||
|
exec.execFile(bun, ['build', sourcePath, '--compile', '--outfile', binaryPath], execOptions(), (error) =>
|
||||||
|
resolve(!error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!built) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await new Promise<boolean>((resolve) => {
|
||||||
|
exec.execFile(binaryPath, [], execOptions(), (error, stdout) => {
|
||||||
|
resolve(!error && stdout.trim() === 'mcporter-bun-compile-probe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import type { ServerDefinition } from '../src/config.js';
|
import type { ServerDefinition } from '../src/config.js';
|
||||||
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
|
import { createKeepAliveRuntime } from '../src/daemon/runtime-wrapper.js';
|
||||||
import type { CallOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
|
import type { CallOptions, ConnectOptions, ListToolsOptions, Runtime } from '../src/runtime.js';
|
||||||
|
|
||||||
class FakeRuntime implements Runtime {
|
class FakeRuntime implements Runtime {
|
||||||
private readonly definitions: ServerDefinition[];
|
private readonly definitions: ServerDefinition[];
|
||||||
@ -10,6 +10,7 @@ class FakeRuntime implements Runtime {
|
|||||||
public readonly listToolsMock = vi.fn().mockResolvedValue([{ name: 'local-tool' }]);
|
public readonly listToolsMock = vi.fn().mockResolvedValue([{ name: 'local-tool' }]);
|
||||||
public readonly listResourcesMock = vi.fn().mockResolvedValue([]);
|
public readonly listResourcesMock = vi.fn().mockResolvedValue([]);
|
||||||
public readonly readResourceMock = vi.fn().mockResolvedValue({ contents: [] });
|
public readonly readResourceMock = vi.fn().mockResolvedValue({ contents: [] });
|
||||||
|
public readonly connectMock = vi.fn().mockResolvedValue({ client: {}, transport: {}, definition: {} });
|
||||||
public readonly closeMock = vi.fn().mockResolvedValue(undefined);
|
public readonly closeMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
constructor(definitions: ServerDefinition[]) {
|
constructor(definitions: ServerDefinition[]) {
|
||||||
@ -56,8 +57,8 @@ class FakeRuntime implements Runtime {
|
|||||||
return await this.readResourceMock(server, uri);
|
return await this.readResourceMock(server, uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect(): Promise<never> {
|
async connect(server: string, options?: ConnectOptions): Promise<Awaited<ReturnType<Runtime['connect']>>> {
|
||||||
throw new Error('not implemented');
|
return await this.connectMock(server, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(server?: string): Promise<void> {
|
async close(server?: string): Promise<void> {
|
||||||
@ -102,6 +103,7 @@ describe('createKeepAliveRuntime', () => {
|
|||||||
tool: 'ping',
|
tool: 'ping',
|
||||||
args: { value: 1 },
|
args: { value: 1 },
|
||||||
timeoutMs: 4_200,
|
timeoutMs: 4_200,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await keepAliveRuntime.listTools('alpha', { includeSchema: true });
|
await keepAliveRuntime.listTools('alpha', { includeSchema: true });
|
||||||
@ -110,6 +112,7 @@ describe('createKeepAliveRuntime', () => {
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: undefined,
|
autoAuthorize: undefined,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await keepAliveRuntime.listTools('alpha', { allowCachedAuth: false });
|
await keepAliveRuntime.listTools('alpha', { allowCachedAuth: false });
|
||||||
@ -118,15 +121,26 @@ describe('createKeepAliveRuntime', () => {
|
|||||||
includeSchema: undefined,
|
includeSchema: undefined,
|
||||||
autoAuthorize: undefined,
|
autoAuthorize: undefined,
|
||||||
allowCachedAuth: false,
|
allowCachedAuth: false,
|
||||||
|
disableOAuth: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await keepAliveRuntime.listResources('alpha', { cursor: '1' });
|
await keepAliveRuntime.listResources('alpha', { cursor: '1' });
|
||||||
expect(daemon.listResources).toHaveBeenCalledWith({ server: 'alpha', params: { cursor: '1' } });
|
expect(daemon.listResources).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
params: { cursor: '1' },
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(keepAliveRuntime.readResource('alpha', 'memo://1')).resolves.toEqual({
|
await expect(keepAliveRuntime.readResource('alpha', 'memo://1')).resolves.toEqual({
|
||||||
contents: [{ uri: 'memo://1', text: 'daemon-resource' }],
|
contents: [{ uri: 'memo://1', text: 'daemon-resource' }],
|
||||||
});
|
});
|
||||||
expect(daemon.readResource).toHaveBeenCalledWith({ server: 'alpha', uri: 'memo://1' });
|
expect(daemon.readResource).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
uri: 'memo://1',
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
await keepAliveRuntime.close('alpha');
|
await keepAliveRuntime.close('alpha');
|
||||||
expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
|
expect(daemon.closeServer).toHaveBeenCalledWith({ server: 'alpha' });
|
||||||
@ -138,6 +152,58 @@ describe('createKeepAliveRuntime', () => {
|
|||||||
expect(runtime.closeMock).toHaveBeenCalledWith(undefined);
|
expect(runtime.closeMock).toHaveBeenCalledWith(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards disableOAuth through daemon requests and connect wrappers', async () => {
|
||||||
|
const runtime = new FakeRuntime(definitions);
|
||||||
|
const daemon = {
|
||||||
|
callTool: vi.fn().mockResolvedValue('daemon-call'),
|
||||||
|
listTools: vi.fn().mockResolvedValue([{ name: 'remote-tool' }]),
|
||||||
|
listResources: vi.fn().mockResolvedValue(['resource']),
|
||||||
|
readResource: vi.fn().mockResolvedValue({ contents: [] }),
|
||||||
|
closeServer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const keepAliveRuntime = createKeepAliveRuntime(runtime as unknown as Runtime, {
|
||||||
|
daemonClient: daemon as never,
|
||||||
|
keepAliveServers: new Set(['alpha']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await keepAliveRuntime.callTool('alpha', 'ping', { disableOAuth: true });
|
||||||
|
expect(daemon.callTool).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
tool: 'ping',
|
||||||
|
args: undefined,
|
||||||
|
timeoutMs: undefined,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await keepAliveRuntime.listTools('alpha', { disableOAuth: true });
|
||||||
|
expect(daemon.listTools).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
includeSchema: undefined,
|
||||||
|
autoAuthorize: undefined,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await keepAliveRuntime.listResources('alpha', { cursor: '1', disableOAuth: true });
|
||||||
|
expect(daemon.listResources).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
params: { cursor: '1' },
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await keepAliveRuntime.readResource('alpha', 'memo://1', { disableOAuth: true });
|
||||||
|
expect(daemon.readResource).toHaveBeenCalledWith({
|
||||||
|
server: 'alpha',
|
||||||
|
uri: 'memo://1',
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await keepAliveRuntime.connect('alpha', { disableOAuth: true });
|
||||||
|
expect(runtime.connectMock).toHaveBeenCalledWith('alpha', { disableOAuth: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('restarts daemon servers after fatal errors and retries the operation', async () => {
|
it('restarts daemon servers after fatal errors and retries the operation', async () => {
|
||||||
const runtime = new FakeRuntime(definitions);
|
const runtime = new FakeRuntime(definitions);
|
||||||
const daemon = {
|
const daemon = {
|
||||||
|
|||||||
@ -16,6 +16,20 @@ const CHROME_COMMAND_ENV: CommandSpec = {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CLOUDBASE_NPX_COMMAND: CommandSpec = {
|
||||||
|
kind: 'stdio',
|
||||||
|
command: 'npx',
|
||||||
|
args: ['-y', '@cloudbase/cloudbase-mcp@latest'],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLOUDBASE_BIN_COMMAND: CommandSpec = {
|
||||||
|
kind: 'stdio',
|
||||||
|
command: 'cloudbase-mcp',
|
||||||
|
args: [],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
};
|
||||||
|
|
||||||
describe('resolveLifecycle', () => {
|
describe('resolveLifecycle', () => {
|
||||||
it('forces chrome-devtools placeholder runs to be ephemeral', () => {
|
it('forces chrome-devtools placeholder runs to be ephemeral', () => {
|
||||||
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND);
|
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND);
|
||||||
@ -26,4 +40,19 @@ describe('resolveLifecycle', () => {
|
|||||||
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND_ENV);
|
const lifecycle = resolveLifecycle('chrome-devtools', undefined, CHROME_COMMAND_ENV);
|
||||||
expect(lifecycle?.mode).toBe('ephemeral');
|
expect(lifecycle?.mode).toBe('ephemeral');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('auto-enables keep-alive for CloudBase MCP package commands', () => {
|
||||||
|
const lifecycle = resolveLifecycle('cloudbase', undefined, CLOUDBASE_NPX_COMMAND);
|
||||||
|
expect(lifecycle?.mode).toBe('keep-alive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-enables keep-alive for CloudBase MCP binary commands', () => {
|
||||||
|
const lifecycle = resolveLifecycle('tcb', undefined, CLOUDBASE_BIN_COMMAND);
|
||||||
|
expect(lifecycle?.mode).toBe('keep-alive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors explicit ephemeral lifecycle for CloudBase MCP commands', () => {
|
||||||
|
const lifecycle = resolveLifecycle('cloudbase', 'ephemeral', CLOUDBASE_NPX_COMMAND);
|
||||||
|
expect(lifecycle?.mode).toBe('ephemeral');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -137,6 +137,17 @@ describe('list output helpers', () => {
|
|||||||
expect(entry.authCommand).toBe(buildAuthCommandHint(definition));
|
expect(entry.authCommand).toBe(buildAuthCommandHint(definition));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shell-quotes auth hints for stdio commands', () => {
|
||||||
|
const hint = buildAuthCommandHint({
|
||||||
|
name: 'unsafe',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: ['server.js', '--name', "$(touch bad)'"], cwd: process.cwd() },
|
||||||
|
auth: 'oauth',
|
||||||
|
source: { kind: 'local', path: '<adhoc>' },
|
||||||
|
});
|
||||||
|
expect(hint).toContain('mcporter auth --stdio node server.js --name ');
|
||||||
|
expect(hint).toContain("'$(touch bad)'\\'''");
|
||||||
|
});
|
||||||
|
|
||||||
it('exposes source list in JSON only when includeSources is true', () => {
|
it('exposes source list in JSON only when includeSources is true', () => {
|
||||||
const withSources: ServerDefinition = {
|
const withSources: ServerDefinition = {
|
||||||
...definition,
|
...definition,
|
||||||
|
|||||||
@ -44,6 +44,30 @@ describe('oauth persistence', () => {
|
|||||||
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
await Promise.all(tempRoots.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('degrades corrupt credential caches to undefined but keeps corrupt OAuth state failing closed', async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-corrupt-'));
|
||||||
|
tempRoots.push(tmp);
|
||||||
|
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp);
|
||||||
|
hasSpy = true;
|
||||||
|
|
||||||
|
const cacheDir = path.join(tmp, 'cache');
|
||||||
|
await fs.mkdir(cacheDir, { recursive: true });
|
||||||
|
// Truncated / malformed credential files, e.g. an interrupted write.
|
||||||
|
await fs.writeFile(path.join(cacheDir, 'tokens.json'), '{ "access_token": "part');
|
||||||
|
await fs.writeFile(path.join(cacheDir, 'client.json'), 'not json at all');
|
||||||
|
await fs.writeFile(path.join(cacheDir, 'state.txt'), '"unterminated');
|
||||||
|
|
||||||
|
const persistence = await buildOAuthPersistence(mkDef('service', cacheDir));
|
||||||
|
|
||||||
|
// Corrupt credential caches must read as "no usable credentials" (degrade to
|
||||||
|
// re-auth), not surface a SyntaxError that crashes the connection.
|
||||||
|
expect(await persistence.readTokens()).toBeUndefined();
|
||||||
|
expect(await persistence.readClientInfo()).toBeUndefined();
|
||||||
|
// OAuth state must NOT silently degrade: returning undefined would skip the
|
||||||
|
// CSRF state check on callback (oauth.ts). It must fail closed.
|
||||||
|
await expect(persistence.readState()).rejects.toThrow(SyntaxError);
|
||||||
|
});
|
||||||
|
|
||||||
it('prefers explicit tokenCacheDir before vault when reading tokens', async () => {
|
it('prefers explicit tokenCacheDir before vault when reading tokens', async () => {
|
||||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-'));
|
||||||
tempRoots.push(tmp);
|
tempRoots.push(tmp);
|
||||||
|
|||||||
@ -148,10 +148,9 @@ describe('FileOAuthClientProvider session lifecycle', () => {
|
|||||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('clearing stale client registration'));
|
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('clearing stale client registration'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closes the callback server when stale-client reads throw', async () => {
|
it('closes the callback server when stale-client reads have I/O errors', async () => {
|
||||||
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
|
const tokenCacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-oauth-test-'));
|
||||||
tempDirs.push(tokenCacheDir);
|
tempDirs.push(tokenCacheDir);
|
||||||
await fs.writeFile(path.join(tokenCacheDir, 'client.json'), '{not-valid-json', 'utf8');
|
|
||||||
const definition: ServerDefinition = {
|
const definition: ServerDefinition = {
|
||||||
name: 'test-oauth-read-failure',
|
name: 'test-oauth-read-failure',
|
||||||
description: 'Test OAuth server',
|
description: 'Test OAuth server',
|
||||||
@ -165,6 +164,8 @@ describe('FileOAuthClientProvider session lifecycle', () => {
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||||
|
const readFileSpy = vi.spyOn(fs, 'readFile').mockRejectedValueOnce(readError);
|
||||||
const originalCreateServer = http.createServer.bind(http);
|
const originalCreateServer = http.createServer.bind(http);
|
||||||
const createdServers: http.Server[] = [];
|
const createdServers: http.Server[] = [];
|
||||||
const createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((...args) => {
|
const createServerSpy = vi.spyOn(http, 'createServer').mockImplementation((...args) => {
|
||||||
@ -174,11 +175,12 @@ describe('FileOAuthClientProvider session lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(createOAuthSession(definition, logger)).rejects.toThrow(SyntaxError);
|
await expect(createOAuthSession(definition, logger)).rejects.toMatchObject({ code: 'EACCES' });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(createdServers).toHaveLength(1);
|
expect(createdServers).toHaveLength(1);
|
||||||
expect(createdServers[0]?.listening).toBe(false);
|
expect(createdServers[0]?.listening).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
readFileSpy.mockRestore();
|
||||||
createServerSpy.mockRestore();
|
createServerSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
125
tests/runtime-cache-policy.test.ts
Normal file
125
tests/runtime-cache-policy.test.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
createClientContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../src/runtime/transport.js', () => ({
|
||||||
|
createClientContext: mocks.createClientContext,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import type { ServerDefinition } from '../src/config.js';
|
||||||
|
import { createRuntime } from '../src/runtime.js';
|
||||||
|
|
||||||
|
type ClientContext = Awaited<ReturnType<Awaited<ReturnType<typeof createRuntime>>['connect']>>;
|
||||||
|
|
||||||
|
function fakeContext(
|
||||||
|
definition: ServerDefinition,
|
||||||
|
clientClose: ReturnType<typeof vi.fn> = vi.fn().mockResolvedValue(undefined)
|
||||||
|
): ClientContext {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
close: clientClose,
|
||||||
|
},
|
||||||
|
transport: {
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
definition,
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runtime cache policy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.createClientContext.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let stale OAuth promotion overwrite a replacement definition', async () => {
|
||||||
|
const initial: ServerDefinition = {
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http', url: new URL('https://old.example.com/mcp') },
|
||||||
|
};
|
||||||
|
let resolveConnection!: (context: ClientContext) => void;
|
||||||
|
let promote!: (definition: ServerDefinition) => void;
|
||||||
|
mocks.createClientContext.mockImplementation(
|
||||||
|
(
|
||||||
|
_definition: ServerDefinition,
|
||||||
|
_logger: unknown,
|
||||||
|
_clientInfo: unknown,
|
||||||
|
options: { onDefinitionPromoted?: (definition: ServerDefinition) => void }
|
||||||
|
) => {
|
||||||
|
promote = options.onDefinitionPromoted ?? (() => {});
|
||||||
|
return new Promise<ClientContext>((resolve) => {
|
||||||
|
resolveConnection = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const runtime = await createRuntime({ servers: [initial] });
|
||||||
|
const connecting = runtime.connect('oauth');
|
||||||
|
const expectation = expect(connecting).rejects.toThrow('superseded');
|
||||||
|
await vi.waitFor(() => expect(mocks.createClientContext).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const replacement: ServerDefinition = {
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http', url: new URL('https://new.example.com/mcp') },
|
||||||
|
};
|
||||||
|
runtime.registerDefinition(replacement, { overwrite: true });
|
||||||
|
promote({ ...initial, auth: 'oauth' });
|
||||||
|
resolveConnection(fakeContext(initial));
|
||||||
|
|
||||||
|
await expectation;
|
||||||
|
expect(runtime.getDefinition('oauth')).toBe(replacement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses one replay client across auth posture changes', async () => {
|
||||||
|
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
|
||||||
|
const definition: ServerDefinition = {
|
||||||
|
name: 'replay',
|
||||||
|
command: { kind: 'http', url: new URL('https://replay.example.com/mcp') },
|
||||||
|
};
|
||||||
|
const context = fakeContext(definition);
|
||||||
|
mocks.createClientContext.mockResolvedValue(context);
|
||||||
|
const runtime = await createRuntime({ servers: [definition] });
|
||||||
|
|
||||||
|
const first = await runtime.connect('replay');
|
||||||
|
const second = await runtime.connect('replay', {
|
||||||
|
allowCachedAuth: false,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toBe(first);
|
||||||
|
expect(mocks.createClientContext).toHaveBeenCalledOnce();
|
||||||
|
await runtime.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps auth posture isolation for servers excluded by the replay filter', async () => {
|
||||||
|
vi.stubEnv('MCPORTER_REPLAY', 'cache-policy-test');
|
||||||
|
vi.stubEnv('MCPORTER_REPLAY_SERVER', 'other-server');
|
||||||
|
const definition: ServerDefinition = {
|
||||||
|
name: 'live',
|
||||||
|
command: { kind: 'http', url: new URL('https://live.example.com/mcp') },
|
||||||
|
};
|
||||||
|
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
|
||||||
|
mocks.createClientContext.mockImplementation((current: ServerDefinition) => {
|
||||||
|
const closeMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const context = fakeContext(current, closeMock);
|
||||||
|
closeMocks.push(closeMock);
|
||||||
|
return Promise.resolve(context);
|
||||||
|
});
|
||||||
|
const runtime = await createRuntime({ servers: [definition] });
|
||||||
|
|
||||||
|
const first = await runtime.connect('live');
|
||||||
|
const second = await runtime.connect('live', {
|
||||||
|
allowCachedAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(mocks.createClientContext).toHaveBeenCalledTimes(2);
|
||||||
|
expect(closeMocks[0]).toHaveBeenCalled();
|
||||||
|
await runtime.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
157
tests/runtime-cache.test.ts
Normal file
157
tests/runtime-cache.test.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createRuntime } from '../src/runtime.js';
|
||||||
|
|
||||||
|
type TestRuntime = Awaited<ReturnType<typeof createRuntime>>;
|
||||||
|
type ClientContext = Awaited<ReturnType<TestRuntime['connect']>>;
|
||||||
|
type CachedClientEntry = {
|
||||||
|
readonly server: string;
|
||||||
|
readonly promise: Promise<ClientContext>;
|
||||||
|
readonly allowCachedAuth: boolean | undefined;
|
||||||
|
readonly disableOAuth: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeContext(instructions: string): ClientContext {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getInstructions: vi.fn(() => instructions),
|
||||||
|
},
|
||||||
|
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
definition: {
|
||||||
|
name: 'temp',
|
||||||
|
description: 'test',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runtime cache entries', () => {
|
||||||
|
it('reads instructions from the active cached entry', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
const older = fakeContext('older instructions');
|
||||||
|
const active = fakeContext('active instructions');
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<string, CachedClientEntry>;
|
||||||
|
activeClientKeys: Map<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
internals.clients.set('temp:older', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(older),
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.clients.set('temp:active', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(active),
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
internals.activeClientKeys.set('temp', 'temp:active');
|
||||||
|
|
||||||
|
await expect(runtime.getInstructions?.('temp')).resolves.toBe('active instructions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes cached entries when replacing a server definition', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
const context = fakeContext('old instructions');
|
||||||
|
const transport = context.transport as unknown as { close: ReturnType<typeof vi.fn> };
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<string, CachedClientEntry>;
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
internals.clients.set('temp:old', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(context),
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.contextCacheKeys.set(context, 'temp:old');
|
||||||
|
|
||||||
|
runtime.registerDefinition(
|
||||||
|
{
|
||||||
|
name: 'temp',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
{ overwrite: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
|
||||||
|
expect(internals.clients.has('temp:old')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes cached entries before awaiting shutdown', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
let releaseClose!: () => void;
|
||||||
|
const clientClose = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseClose = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const context = {
|
||||||
|
...fakeContext('closing instructions'),
|
||||||
|
client: {
|
||||||
|
close: clientClose,
|
||||||
|
getInstructions: vi.fn(() => 'closing instructions'),
|
||||||
|
},
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<string, CachedClientEntry>;
|
||||||
|
activeClientKeys: Map<string, string>;
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
};
|
||||||
|
internals.clients.set('temp:closing', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(context),
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.activeClientKeys.set('temp', 'temp:closing');
|
||||||
|
internals.contextCacheKeys.set(context, 'temp:closing');
|
||||||
|
|
||||||
|
const closing = runtime.close('temp');
|
||||||
|
|
||||||
|
expect(internals.clients.has('temp:closing')).toBe(false);
|
||||||
|
expect(internals.activeClientKeys.has('temp')).toBe(false);
|
||||||
|
await vi.waitFor(() => expect(clientClose).toHaveBeenCalled());
|
||||||
|
releaseClose();
|
||||||
|
await closing;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts closing cached variants concurrently', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
let resolvePending!: (context: ClientContext) => void;
|
||||||
|
const pending = new Promise<ClientContext>((resolve) => {
|
||||||
|
resolvePending = resolve;
|
||||||
|
});
|
||||||
|
const pendingContext = fakeContext('pending instructions');
|
||||||
|
const readyContext = fakeContext('ready instructions');
|
||||||
|
const readyTransport = readyContext.transport as unknown as { close: ReturnType<typeof vi.fn> };
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<string, CachedClientEntry>;
|
||||||
|
};
|
||||||
|
internals.clients.set('temp:pending', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: pending,
|
||||||
|
allowCachedAuth: false,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.clients.set('temp:ready', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(readyContext),
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const closing = runtime.close('temp');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(readyTransport.close).toHaveBeenCalled());
|
||||||
|
resolvePending(pendingContext);
|
||||||
|
await closing;
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -48,9 +48,10 @@ describe('runtime callTool timeouts', () => {
|
|||||||
const runtime = await createRuntime({ servers: [] });
|
const runtime = await createRuntime({ servers: [] });
|
||||||
const callTool = vi.fn(() => new Promise(() => {}));
|
const callTool = vi.fn(() => new Promise(() => {}));
|
||||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
|
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
const fakeContext = {
|
const fakeContext = {
|
||||||
client: { callTool },
|
client: { callTool },
|
||||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
transport,
|
||||||
definition: {
|
definition: {
|
||||||
name: 'temp',
|
name: 'temp',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
@ -60,16 +61,42 @@ describe('runtime callTool timeouts', () => {
|
|||||||
oauthSession: undefined,
|
oauthSession: undefined,
|
||||||
} as unknown as ClientContext;
|
} as unknown as ClientContext;
|
||||||
vi.spyOn(runtime, 'connect').mockResolvedValue(fakeContext);
|
vi.spyOn(runtime, 'connect').mockResolvedValue(fakeContext);
|
||||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
const cachedPromise = Promise.resolve(fakeContext);
|
||||||
'temp',
|
(
|
||||||
Promise.resolve(fakeContext)
|
runtime as unknown as {
|
||||||
);
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
).clients.set('temp:test', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: cachedPromise,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
}
|
||||||
|
).contextCacheKeys.set(fakeContext, 'temp:test');
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
}
|
||||||
|
).contextCachePromises.set(fakeContext, cachedPromise);
|
||||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||||
|
|
||||||
const promise = runtime.callTool('temp', 'ping', { timeoutMs: 123 });
|
const promise = runtime.callTool('temp', 'ping', { timeoutMs: 123 });
|
||||||
const expectation = expect(promise).rejects.toThrow('Timeout');
|
const expectation = expect(promise).rejects.toThrow('Timeout');
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(200);
|
||||||
await expectation;
|
await expectation;
|
||||||
expect(closeSpy).toHaveBeenCalledWith('temp');
|
expect(closeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(transport.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function throwConnectBoom(): never {
|
||||||
|
throw new Error('connect boom');
|
||||||
|
}
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => {
|
const mocks = vi.hoisted(() => {
|
||||||
const connectMock = vi.fn();
|
const connectMock = vi.fn();
|
||||||
const listToolsMock = vi.fn();
|
const listToolsMock = vi.fn();
|
||||||
@ -108,7 +115,7 @@ vi.mock('../src/oauth-persistence.js', () => ({
|
|||||||
readCachedAccessToken: mocks.readCachedAccessTokenMock,
|
readCachedAccessToken: mocks.readCachedAccessTokenMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createRuntime } from '../src/runtime.js';
|
import { callOnce, createRuntime } from '../src/runtime.js';
|
||||||
|
|
||||||
describe('mcporter composability', () => {
|
describe('mcporter composability', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -228,6 +235,30 @@ describe('mcporter composability', () => {
|
|||||||
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent');
|
expect(instance?.options?.env?.MCPORTER_STDIO_TEST).toBe('from-parent');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses stdio clients across auth-policy no-op differences', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'local',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: ['-v'], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.connect('local');
|
||||||
|
await runtime.callTool('local', 'echo', {});
|
||||||
|
await runtime.connect('local', { disableOAuth: true });
|
||||||
|
await runtime.listTools('local', { autoAuthorize: false });
|
||||||
|
|
||||||
|
expect(mocks.stdioInstances).toHaveLength(1);
|
||||||
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('overrides inherited env vars with server-specific values', async () => {
|
it('overrides inherited env vars with server-specific values', async () => {
|
||||||
vi.stubEnv('MCPORTER_STDIO_TEST', 'parent');
|
vi.stubEnv('MCPORTER_STDIO_TEST', 'parent');
|
||||||
const runtime = await createRuntime({
|
const runtime = await createRuntime({
|
||||||
@ -271,6 +302,375 @@ describe('mcporter composability', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves a disabled-OAuth cached connection through high-level helpers', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: true });
|
||||||
|
await runtime.callTool('oauth', 'ping');
|
||||||
|
await runtime.listTools('oauth');
|
||||||
|
await runtime.listResources('oauth');
|
||||||
|
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses active cached-auth connections for resource helpers with unspecified auth policy', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
|
||||||
|
await runtime.listTools('oauth');
|
||||||
|
await runtime.listResources('oauth');
|
||||||
|
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses disableOAuth on cold callTool/listTools helper connections', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
auth: 'oauth' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.callTool('oauth', 'ping', { disableOAuth: true });
|
||||||
|
await runtime.listTools('oauth', { disableOAuth: true });
|
||||||
|
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
expect(mocks.connectMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves cached-auth opt out for disabled-OAuth helper calls', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
auth: 'oauth' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false });
|
||||||
|
await runtime.callTool('oauth', 'ping');
|
||||||
|
await runtime.listTools('oauth');
|
||||||
|
await runtime.listResources('oauth');
|
||||||
|
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
expect(mocks.readCachedAccessTokenMock).not.toHaveBeenCalled();
|
||||||
|
await runtime.connect('oauth', { disableOAuth: true });
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps separate cached transports for OAuth posture changes', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const disabled = await runtime.connect('oauth', { disableOAuth: true });
|
||||||
|
const disabledTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||||
|
const normal = await runtime.connect('oauth');
|
||||||
|
|
||||||
|
expect(normal).not.toBe(disabled);
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(2);
|
||||||
|
expect(disabledTransport.close).not.toHaveBeenCalled();
|
||||||
|
await expect(runtime.connect('oauth', { disableOAuth: true })).resolves.toBe(disabled);
|
||||||
|
await runtime.callTool('oauth', 'ping');
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the previous active cached variant when a new variant fails to connect', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runtime.connect('oauth');
|
||||||
|
await runtime.connect('oauth', { disableOAuth: true });
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
activeClientKeys: Map<string, string>;
|
||||||
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
const disabledKey = [...internals.clients.entries()].find(
|
||||||
|
([, cached]) => cached.disableOAuth && cached.allowCachedAuth === true
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
|
mocks.connectMock.mockImplementationOnce(throwConnectBoom).mockImplementationOnce(throwConnectBoom);
|
||||||
|
await expect(runtime.connect('oauth', { disableOAuth: true, allowCachedAuth: false })).rejects.toThrow(
|
||||||
|
'connect boom'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(internals.activeClientKeys.get('oauth')).toBe(disabledKey);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent OAuth-capable HTTP variant setup', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||||
|
transport.start?.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const first = runtime.connect('oauth', { allowCachedAuth: false });
|
||||||
|
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||||
|
const second = runtime.connect('oauth', { allowCachedAuth: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
releaseFirst();
|
||||||
|
await first;
|
||||||
|
await second;
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
await runtime.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not create a new OAuth-capable variant after close interrupts retirement', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await runtime.connect('oauth', { allowCachedAuth: false });
|
||||||
|
let releaseClose!: () => void;
|
||||||
|
const firstClient = mocks.clientInstances[0] as { close: () => Promise<void> };
|
||||||
|
firstClient.close = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseClose = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const replacement = runtime.connect('oauth', { allowCachedAuth: true });
|
||||||
|
const replacementExpectation = expect(replacement).rejects.toThrow('superseded');
|
||||||
|
await vi.waitFor(() => expect(firstClient.close).toHaveBeenCalled());
|
||||||
|
const closing = runtime.close('oauth');
|
||||||
|
releaseClose();
|
||||||
|
|
||||||
|
await Promise.all([replacementExpectation, closing]);
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases serialized setup after conflicting-entry retirement fails', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
|
const rejected = Promise.reject(new Error('retire boom')) as Promise<ClientContext>;
|
||||||
|
void rejected.catch(() => {});
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
contextPromise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
).clients.set('oauth:conflict', {
|
||||||
|
server: 'oauth',
|
||||||
|
promise: rejected,
|
||||||
|
contextPromise: rejected,
|
||||||
|
allowCachedAuth: false,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(runtime.connect('oauth', { allowCachedAuth: true })).rejects.toThrow('retire boom');
|
||||||
|
await expect(runtime.connect('oauth', { allowCachedAuth: true })).resolves.toBeDefined();
|
||||||
|
await runtime.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels queued OAuth-capable setup when the server closes', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://oauth.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let releaseFirst!: () => void;
|
||||||
|
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||||
|
transport.start?.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseFirst = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runtime.connect('oauth', { allowCachedAuth: false });
|
||||||
|
const firstExpectation = expect(first).rejects.toThrow('superseded');
|
||||||
|
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||||
|
const second = runtime.connect('oauth', { allowCachedAuth: true });
|
||||||
|
const secondExpectation = expect(second).rejects.toThrow('superseded');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const closing = runtime.close('oauth');
|
||||||
|
releaseFirst();
|
||||||
|
await Promise.all([firstExpectation, secondExpectation, closing]);
|
||||||
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an in-flight connection when its definition is replaced', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://old.example.com/mcp') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let releaseConnect!: () => void;
|
||||||
|
mocks.connectMock.mockImplementationOnce((transport: { start?: ReturnType<typeof vi.fn> }) => {
|
||||||
|
transport.start?.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseConnect = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const connecting = runtime.connect('oauth');
|
||||||
|
const waiting = runtime.connect('oauth');
|
||||||
|
const expectations = Promise.all([
|
||||||
|
expect(connecting).rejects.toThrow('superseded'),
|
||||||
|
expect(waiting).rejects.toThrow('superseded'),
|
||||||
|
]);
|
||||||
|
await vi.waitFor(() => expect(mocks.streamableInstances).toHaveLength(1));
|
||||||
|
runtime.registerDefinition(
|
||||||
|
{
|
||||||
|
name: 'oauth',
|
||||||
|
command: { kind: 'http' as const, url: new URL('https://new.example.com/mcp') },
|
||||||
|
},
|
||||||
|
{ overwrite: true }
|
||||||
|
);
|
||||||
|
releaseConnect();
|
||||||
|
|
||||||
|
await expectations;
|
||||||
|
const oldTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||||
|
await vi.waitFor(() => expect(oldTransport.close).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards disableOAuth through callOnce', async () => {
|
||||||
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-call-once-'));
|
||||||
|
const configPath = path.join(tempDir, 'mcporter.json');
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({
|
||||||
|
mcpServers: {
|
||||||
|
oauth: {
|
||||||
|
url: 'https://oauth.example.com/mcp',
|
||||||
|
auth: 'oauth',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await callOnce({
|
||||||
|
server: 'oauth',
|
||||||
|
toolName: 'ping',
|
||||||
|
args: { ok: true },
|
||||||
|
configPath,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.callToolMock).toHaveBeenCalledWith({
|
||||||
|
name: 'ping',
|
||||||
|
arguments: { ok: true },
|
||||||
|
});
|
||||||
|
const streamableTransport = mocks.streamableInstances[0] as {
|
||||||
|
options?: { authProvider?: unknown };
|
||||||
|
};
|
||||||
|
expect(streamableTransport.options?.authProvider).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reconnects when callTool needs cached auth after an uncached connection', async () => {
|
it('reconnects when callTool needs cached auth after an uncached connection', async () => {
|
||||||
const runtime = await createRuntime({
|
const runtime = await createRuntime({
|
||||||
servers: [
|
servers: [
|
||||||
@ -284,11 +684,13 @@ describe('mcporter composability', () => {
|
|||||||
try {
|
try {
|
||||||
await runtime.listTools('oauth', { allowCachedAuth: false });
|
await runtime.listTools('oauth', { allowCachedAuth: false });
|
||||||
expect(mocks.streamableInstances).toHaveLength(1);
|
expect(mocks.streamableInstances).toHaveLength(1);
|
||||||
|
const firstTransport = mocks.streamableInstances[0] as { close: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
|
mocks.readCachedAccessTokenMock.mockResolvedValue('cached-token');
|
||||||
await runtime.callTool('oauth', 'ping');
|
await runtime.callTool('oauth', 'ping');
|
||||||
|
|
||||||
expect(mocks.streamableInstances).toHaveLength(2);
|
expect(mocks.streamableInstances).toHaveLength(2);
|
||||||
|
expect(firstTransport.close).toHaveBeenCalled();
|
||||||
const streamableTransport = mocks.streamableInstances[1] as {
|
const streamableTransport = mocks.streamableInstances[1] as {
|
||||||
options?: { requestInit?: { headers?: Record<string, string> } };
|
options?: { requestInit?: { headers?: Record<string, string> } };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,11 +11,12 @@ describe('runtime connection resets', () => {
|
|||||||
const runtime = await createRuntime({ servers: [] });
|
const runtime = await createRuntime({ servers: [] });
|
||||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||||
|
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
const context = {
|
const context = {
|
||||||
client: {
|
client: {
|
||||||
callTool: vi.fn().mockRejectedValue(rejected),
|
callTool: vi.fn().mockRejectedValue(rejected),
|
||||||
},
|
},
|
||||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
transport,
|
||||||
definition: {
|
definition: {
|
||||||
name: 'temp',
|
name: 'temp',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
@ -25,25 +26,53 @@ describe('runtime connection resets', () => {
|
|||||||
oauthSession: undefined,
|
oauthSession: undefined,
|
||||||
} as unknown as ClientContext;
|
} as unknown as ClientContext;
|
||||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
const promise = Promise.resolve(context);
|
||||||
'temp',
|
(
|
||||||
Promise.resolve(context)
|
runtime as unknown as {
|
||||||
);
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
).clients.set('temp:test', {
|
||||||
|
server: 'temp',
|
||||||
|
promise,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
}
|
||||||
|
).contextCacheKeys.set(context, 'temp:test');
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
}
|
||||||
|
).contextCachePromises.set(context, promise);
|
||||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||||
|
|
||||||
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
|
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
|
||||||
expect(closeSpy).toHaveBeenCalledWith('temp');
|
expect(closeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(transport.close).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the connection open for user-facing InvalidParams errors', async () => {
|
it('keeps the connection open for user-facing InvalidParams errors', async () => {
|
||||||
const runtime = await createRuntime({ servers: [] });
|
const runtime = await createRuntime({ servers: [] });
|
||||||
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
const rejected = new McpError(ErrorCode.InvalidParams, 'Tool help not found');
|
const rejected = new McpError(ErrorCode.InvalidParams, 'Tool help not found');
|
||||||
|
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
const context = {
|
const context = {
|
||||||
client: {
|
client: {
|
||||||
callTool: vi.fn().mockRejectedValue(rejected),
|
callTool: vi.fn().mockRejectedValue(rejected),
|
||||||
},
|
},
|
||||||
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
transport,
|
||||||
definition: {
|
definition: {
|
||||||
name: 'temp',
|
name: 'temp',
|
||||||
description: 'test',
|
description: 'test',
|
||||||
@ -53,13 +82,222 @@ describe('runtime connection resets', () => {
|
|||||||
oauthSession: undefined,
|
oauthSession: undefined,
|
||||||
} as unknown as ClientContext;
|
} as unknown as ClientContext;
|
||||||
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||||
(runtime as unknown as { clients: Map<string, Promise<ClientContext>> }).clients.set(
|
const promise = Promise.resolve(context);
|
||||||
'temp',
|
(
|
||||||
Promise.resolve(context)
|
runtime as unknown as {
|
||||||
);
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
).clients.set('temp:test', {
|
||||||
|
server: 'temp',
|
||||||
|
promise,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
}
|
||||||
|
).contextCacheKeys.set(context, 'temp:test');
|
||||||
|
(
|
||||||
|
runtime as unknown as {
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
}
|
||||||
|
).contextCachePromises.set(context, promise);
|
||||||
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
const closeSpy = vi.spyOn(runtime, 'close').mockResolvedValue();
|
||||||
|
|
||||||
await expect(runtime.callTool('temp', 'help')).rejects.toThrow('Tool help not found');
|
await expect(runtime.callTool('temp', 'help')).rejects.toThrow('Tool help not found');
|
||||||
expect(closeSpy).not.toHaveBeenCalled();
|
expect(closeSpy).not.toHaveBeenCalled();
|
||||||
|
expect(transport.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not wait for unrelated cached connections when resetting a failed context', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
|
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||||
|
const transport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const context = {
|
||||||
|
client: {
|
||||||
|
callTool: vi.fn().mockRejectedValue(rejected),
|
||||||
|
},
|
||||||
|
transport,
|
||||||
|
definition: {
|
||||||
|
name: 'temp',
|
||||||
|
description: 'test',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
const unresolved = new Promise<ClientContext>(() => {});
|
||||||
|
const failedPromise = Promise.resolve(context);
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
};
|
||||||
|
internals.clients.set('temp:unrelated', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: unresolved,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.clients.set('temp:failed', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: failedPromise,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
internals.contextCacheKeys.set(context, 'temp:failed');
|
||||||
|
internals.contextCachePromises.set(context, failedPromise);
|
||||||
|
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||||
|
|
||||||
|
await expect(runtime.callTool('temp', 'list_pages')).rejects.toThrow('Connection closed');
|
||||||
|
expect(transport.close).toHaveBeenCalled();
|
||||||
|
expect(internals.clients.has('temp:failed')).toBe(false);
|
||||||
|
expect(internals.clients.has('temp:unrelated')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves cached entries alone when an uncached list operation fails', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
|
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||||
|
const cachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const uncachedTransport = { close: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const cachedContext = {
|
||||||
|
client: {},
|
||||||
|
transport: cachedTransport,
|
||||||
|
definition: {
|
||||||
|
name: 'temp',
|
||||||
|
description: 'test',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
const uncachedContext = {
|
||||||
|
client: {
|
||||||
|
listTools: vi.fn().mockRejectedValue(rejected),
|
||||||
|
},
|
||||||
|
transport: uncachedTransport,
|
||||||
|
definition: {
|
||||||
|
name: 'temp',
|
||||||
|
description: 'test',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
};
|
||||||
|
internals.clients.set('temp:cached', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: Promise.resolve(cachedContext),
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.contextCacheKeys.set(cachedContext, 'temp:cached');
|
||||||
|
vi.spyOn(runtime, 'connect').mockResolvedValue(uncachedContext);
|
||||||
|
|
||||||
|
await expect(runtime.listTools('temp', { autoAuthorize: false })).rejects.toThrow('Connection closed');
|
||||||
|
expect(uncachedTransport.close).toHaveBeenCalled();
|
||||||
|
expect(cachedTransport.close).not.toHaveBeenCalled();
|
||||||
|
expect(internals.clients.has('temp:cached')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not evict a replacement while closing a failed stdio context', async () => {
|
||||||
|
const runtime = await createRuntime({ servers: [] });
|
||||||
|
type ClientContext = Awaited<ReturnType<typeof runtime.connect>>;
|
||||||
|
const rejected = new McpError(ErrorCode.ConnectionClosed, 'Connection closed');
|
||||||
|
let releaseClose!: () => void;
|
||||||
|
const transport = {
|
||||||
|
close: vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
releaseClose = resolve;
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
client: {
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
callTool: vi.fn().mockRejectedValue(rejected),
|
||||||
|
},
|
||||||
|
transport,
|
||||||
|
definition: {
|
||||||
|
name: 'temp',
|
||||||
|
description: 'test',
|
||||||
|
command: { kind: 'stdio', command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
source: { kind: 'local', path: '<test>' },
|
||||||
|
},
|
||||||
|
oauthSession: undefined,
|
||||||
|
} as unknown as ClientContext;
|
||||||
|
const promise = Promise.resolve(context);
|
||||||
|
const internals = runtime as unknown as {
|
||||||
|
clients: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
server: string;
|
||||||
|
promise: Promise<ClientContext>;
|
||||||
|
allowCachedAuth: boolean | undefined;
|
||||||
|
disableOAuth: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
contextCacheKeys: WeakMap<ClientContext, string>;
|
||||||
|
contextCachePromises: WeakMap<ClientContext, Promise<ClientContext>>;
|
||||||
|
};
|
||||||
|
internals.clients.set('temp:stdio', {
|
||||||
|
server: 'temp',
|
||||||
|
promise,
|
||||||
|
allowCachedAuth: undefined,
|
||||||
|
disableOAuth: false,
|
||||||
|
});
|
||||||
|
internals.contextCacheKeys.set(context, 'temp:stdio');
|
||||||
|
internals.contextCachePromises.set(context, promise);
|
||||||
|
vi.spyOn(runtime, 'connect').mockResolvedValue(context);
|
||||||
|
|
||||||
|
const call = runtime.callTool('temp', 'list_pages');
|
||||||
|
const expectation = expect(call).rejects.toThrow('Connection closed');
|
||||||
|
await vi.waitFor(() => expect(transport.close).toHaveBeenCalled());
|
||||||
|
const replacement = Promise.resolve({
|
||||||
|
...context,
|
||||||
|
transport: { close: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
} as unknown as ClientContext);
|
||||||
|
internals.clients.set('temp:stdio', {
|
||||||
|
server: 'temp',
|
||||||
|
promise: replacement,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
releaseClose();
|
||||||
|
|
||||||
|
await expectation;
|
||||||
|
expect(internals.clients.get('temp:stdio')?.promise).toBe(replacement);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -133,4 +133,157 @@ describe('runtime integration', () => {
|
|||||||
|
|
||||||
await runtime.close('integration');
|
await runtime.close('integration');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reuses cached connection when disableOAuth: true is passed', async () => {
|
||||||
|
// Headless-daemon use case: the caller wants OAuth suppression
|
||||||
|
// (no browser launches) but still expects connection caching so
|
||||||
|
// every callTool doesn't spawn a fresh transport. Previously the
|
||||||
|
// only way to suppress OAuth was `maxOAuthAttempts: 0`, which
|
||||||
|
// forced `useCache = false` as a side effect — see the connect()
|
||||||
|
// gate. `disableOAuth: true` preserves caching.
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
const second = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
expect(second).toBe(first);
|
||||||
|
|
||||||
|
// close() reaps the cached client.
|
||||||
|
await runtime.close('integration');
|
||||||
|
const reopened = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
expect(reopened).not.toBe(first);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats disableOAuth: false like omitted for cache identity', async () => {
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const explicitFalse = await runtime.connect('integration', { disableOAuth: false });
|
||||||
|
const omitted = await runtime.connect('integration', {});
|
||||||
|
expect(omitted).toBe(explicitFalse);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maxOAuthAttempts: 0 still bypasses the cache (existing contract preserved)', async () => {
|
||||||
|
// Regression guard: callers passing maxOAuthAttempts: 0 today get
|
||||||
|
// a fresh client per call. That contract is unchanged — only the
|
||||||
|
// new `disableOAuth` flag enables caching with OAuth suppression.
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await runtime.connect('integration', { maxOAuthAttempts: 0 });
|
||||||
|
const second = await runtime.connect('integration', { maxOAuthAttempts: 0 });
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps separate cached clients when disableOAuth flag changes', async () => {
|
||||||
|
// Connections established with disableOAuth: true vs without are
|
||||||
|
// semantically different (the former cannot inherit an OAuth
|
||||||
|
// session that may refresh into a flow). The cache slot must not
|
||||||
|
// be shared across that boundary.
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
const withFlowAllowed = await runtime.connect('integration', {});
|
||||||
|
expect(withFlowAllowed).not.toBe(cached);
|
||||||
|
const cachedAgain = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
expect(cachedAgain).toBe(cached);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the cached client across connect(disableOAuth:true) → callTool() (no implicit eviction)', async () => {
|
||||||
|
// Regression for the PR-198 review note (Codex r3366238654): the
|
||||||
|
// documented headless setup is `await runtime.connect(server, {
|
||||||
|
// disableOAuth: true })`. That call stored the cache slot with
|
||||||
|
// `allowCachedAuth: undefined`. The subsequent internal
|
||||||
|
// `callTool()` path forces `allowCachedAuth: true`, and the
|
||||||
|
// cache-match check (existing.allowCachedAuth === options.allowCachedAuth
|
||||||
|
// || options.allowCachedAuth === undefined) treated the two as
|
||||||
|
// structurally different — every first callTool evicted and
|
||||||
|
// reopened the transport. Defeats the pooling guarantee for the
|
||||||
|
// common pre-connect path.
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
|
||||||
|
const callResult = (await runtime.callTool('integration', 'add', {
|
||||||
|
args: { a: 1, b: 2 },
|
||||||
|
})) as { structuredContent?: { result: number } };
|
||||||
|
expect(callResult.structuredContent?.result).toBe(3);
|
||||||
|
|
||||||
|
// After callTool, the cache slot should still hold the same
|
||||||
|
// ClientContext established by the prior connect() — no eviction,
|
||||||
|
// no extra transport spawned.
|
||||||
|
const afterCall = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
expect(afterCall).toBe(initial);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the cached client across connect(disableOAuth:true) → listTools() (no implicit eviction)', async () => {
|
||||||
|
// Same shape as the callTool regression: listTools also forces
|
||||||
|
// `allowCachedAuth: options.allowCachedAuth ?? true` internally,
|
||||||
|
// so the pre-connected slot was being evicted on first listTools.
|
||||||
|
const runtime = await createRuntime({
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
name: 'integration',
|
||||||
|
description: 'Integration test server',
|
||||||
|
command: { kind: 'http', url: baseUrl },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
const tools = await runtime.listTools('integration');
|
||||||
|
expect(tools.some((tool) => tool.name === 'add')).toBe(true);
|
||||||
|
|
||||||
|
const afterList = await runtime.connect('integration', { disableOAuth: true });
|
||||||
|
expect(afterList).toBe(initial);
|
||||||
|
|
||||||
|
await runtime.close('integration');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -352,6 +352,80 @@ describe('createClientContext (HTTP)', () => {
|
|||||||
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
await createClientContext(definition, logger, clientInfo, { maxOAuthAttempts: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not create OAuth sessions for OAuth HTTP servers when disableOAuth is true', async () => {
|
||||||
|
const definition = stubOAuthHttpDefinition('https://example.com/secure');
|
||||||
|
|
||||||
|
mocks.connectWithAuth.mockImplementationOnce(async (_client, transport, session) => {
|
||||||
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
||||||
|
expect(session).toBeUndefined();
|
||||||
|
return transport;
|
||||||
|
});
|
||||||
|
|
||||||
|
const context = await createClientContext(definition, logger, clientInfo, {
|
||||||
|
disableOAuth: true,
|
||||||
|
allowCachedAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.definition.auth).toBe('oauth');
|
||||||
|
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not promote ad-hoc HTTP servers after Streamable 401 when disableOAuth is true', async () => {
|
||||||
|
const definition = stubHttpDefinition('https://example.com/secure');
|
||||||
|
|
||||||
|
mocks.connectWithAuth
|
||||||
|
.mockImplementationOnce(async (_client, transport, session) => {
|
||||||
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
||||||
|
expect(session).toBeUndefined();
|
||||||
|
throw new Error('SSE error: Non-200 status code (401)');
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async (_client, transport, session) => {
|
||||||
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
||||||
|
expect(session).toBeUndefined();
|
||||||
|
return transport;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
|
||||||
|
const context = await createClientContext(definition, logger, clientInfo, {
|
||||||
|
disableOAuth: true,
|
||||||
|
onDefinitionPromoted,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.definition.auth).toBeUndefined();
|
||||||
|
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
|
||||||
|
expect(promotedDefinitions).toEqual([]);
|
||||||
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not promote ad-hoc HTTP servers after SSE 401 when disableOAuth is true', async () => {
|
||||||
|
const definition = stubHttpDefinition('https://example.com/sse-auth');
|
||||||
|
|
||||||
|
mocks.connectWithAuth
|
||||||
|
.mockImplementationOnce(async (_client, transport, session) => {
|
||||||
|
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
|
||||||
|
expect(session).toBeUndefined();
|
||||||
|
throw new Error('HTTP error 405: Method Not Allowed');
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(async (_client, transport, session) => {
|
||||||
|
expect(transport).toBeInstanceOf(SSEClientTransport);
|
||||||
|
expect(session).toBeUndefined();
|
||||||
|
throw new Error('SSE error: Non-200 status code (401)');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { promotedDefinitions, onDefinitionPromoted } = createPromotionRecorder();
|
||||||
|
await expect(
|
||||||
|
createClientContext(definition, logger, clientInfo, {
|
||||||
|
disableOAuth: true,
|
||||||
|
onDefinitionPromoted,
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Non-200 status code (401)');
|
||||||
|
|
||||||
|
expect(mocks.createOAuthSession).not.toHaveBeenCalled();
|
||||||
|
expect(promotedDefinitions).toEqual([]);
|
||||||
|
expect(mocks.connectWithAuth).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('promotes ad-hoc HTTP servers after generic 401 errors from Streamable HTTP', async () => {
|
it('promotes ad-hoc HTTP servers after generic 401 errors from Streamable HTTP', async () => {
|
||||||
const definition = stubHttpDefinition('https://example.com/secure');
|
const definition = stubHttpDefinition('https://example.com/secure');
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import http from 'node:http';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||||
@ -295,4 +296,117 @@ describe('mcporter serve bridge', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes a single server unprefixed in bare mode', async () => {
|
||||||
|
const runtime = {
|
||||||
|
listTools: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (server: string) => [
|
||||||
|
{ name: 'ping', description: `${server} ping`, inputSchema: { type: 'object' } },
|
||||||
|
]),
|
||||||
|
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong' }] }),
|
||||||
|
};
|
||||||
|
const bridge = createBridgeServer({ runtime, definitions, servers: ['alpha'], bare: true });
|
||||||
|
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||||
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||||
|
await Promise.all([bridge.connect(serverTransport), client.connect(clientTransport)]);
|
||||||
|
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools).toEqual([expect.objectContaining({ name: 'ping', description: 'alpha ping' })]);
|
||||||
|
await expect(client.callTool({ name: 'ping', arguments: { value: 1 } })).resolves.toEqual({
|
||||||
|
content: [{ type: 'text', text: 'pong' }],
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('alpha', 'ping', { args: { value: 1 } });
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
await bridge.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bare mode unless exactly one server is served', () => {
|
||||||
|
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
|
||||||
|
expect(() => createBridgeServer({ runtime, definitions, bare: true })).toThrow(
|
||||||
|
'Bare serve mode requires exactly one served server.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves a single server unprefixed over /mcp/<server> without changing aggregate /mcp', async () => {
|
||||||
|
const runtime = {
|
||||||
|
listTools: vi.fn().mockResolvedValue([{ name: 'ping', inputSchema: { type: 'object' } }]),
|
||||||
|
callTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'pong-http' }] }),
|
||||||
|
};
|
||||||
|
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
|
||||||
|
const address = httpServer.address();
|
||||||
|
if (!address || typeof address !== 'object') {
|
||||||
|
throw new Error('Expected test HTTP server to listen on a TCP port.');
|
||||||
|
}
|
||||||
|
const perServerClient = new Client({ name: 'test-http-client', version: '1.0.0' });
|
||||||
|
const perServerTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp/alpha`));
|
||||||
|
const aggregateClient = new Client({ name: 'test-http-aggregate-client', version: '1.0.0' });
|
||||||
|
const aggregateTransport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${address.port}/mcp`));
|
||||||
|
try {
|
||||||
|
await perServerClient.connect(perServerTransport);
|
||||||
|
const perServerTools = await perServerClient.listTools();
|
||||||
|
expect(perServerTools.tools.map((tool) => tool.name)).toEqual(['ping']);
|
||||||
|
await expect(perServerClient.callTool({ name: 'ping', arguments: {} })).resolves.toEqual({
|
||||||
|
content: [{ type: 'text', text: 'pong-http' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await aggregateClient.connect(aggregateTransport);
|
||||||
|
const aggregateTools = await aggregateClient.listTools();
|
||||||
|
expect(aggregateTools.tools.map((tool) => tool.name)).toEqual(['alpha__ping']);
|
||||||
|
await expect(aggregateClient.callTool({ name: 'alpha__ping', arguments: {} })).resolves.toEqual({
|
||||||
|
content: [{ type: 'text', text: 'pong-http' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const unknown = await fetch(`http://127.0.0.1:${address.port}/mcp/nope`);
|
||||||
|
expect(unknown.status).toBe(404);
|
||||||
|
expect(unknown.headers.get('content-type')).toBe('text/plain; charset=utf-8');
|
||||||
|
expect(await unknown.text()).toBe("Unknown server 'nope'");
|
||||||
|
} finally {
|
||||||
|
await perServerClient.close().catch(() => {});
|
||||||
|
await aggregateClient.close().catch(() => {});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for a malformed percent-encoded server path', async () => {
|
||||||
|
const runtime = { listTools: vi.fn(), callTool: vi.fn() };
|
||||||
|
const httpServer = await serveHttp({ runtime, definitions, servers: ['alpha'], port: 0 });
|
||||||
|
const address = httpServer.address();
|
||||||
|
if (!address || typeof address !== 'object') {
|
||||||
|
throw new Error('Expected test HTTP server to listen on a TCP port.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await new Promise<number>((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{ host: '127.0.0.1', port: address.port, path: '/mcp/%E0%A4%A', method: 'GET' },
|
||||||
|
(res) => {
|
||||||
|
res.resume();
|
||||||
|
resolve(res.statusCode ?? 0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
expect(status).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
httpServer.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -333,4 +333,273 @@ describe('createServerProxy', () => {
|
|||||||
tailLog: true,
|
tailLog: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('threads disableOAuth through schema discovery so proxy.tool({disableOAuth:true}) cannot trigger OAuth during metadata fetch', async () => {
|
||||||
|
// Regression for the PR-198 reviewer note: the proxy fired
|
||||||
|
// `runtime.listTools(server, { includeSchema: true })` for schema
|
||||||
|
// discovery BEFORE parsing the caller's options. On an OAuth
|
||||||
|
// server with no cached schema, that pre-call could start an
|
||||||
|
// interactive OAuth flow even when the eventual tool call had
|
||||||
|
// `disableOAuth: true`. Fix: the proxy must extract disableOAuth
|
||||||
|
// up front and pass it to listTools so the no-OAuth contract
|
||||||
|
// covers the whole proxy interaction.
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
'some-tool': {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
foo: { type: 'string' },
|
||||||
|
disableOAuth: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['foo'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (args: unknown, options: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ foo: 'bar' }, { disableOAuth: true });
|
||||||
|
|
||||||
|
// The schema-fetch listTools call must carry disableOAuth: true.
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
// And the eventual tool call must too — already covered by the
|
||||||
|
// existing KNOWN_OPTION_KEYS handling, asserted here so both
|
||||||
|
// halves of the contract are locked together.
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: { foo: 'bar' },
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects disableOAuth metadata options before later argument objects', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
'some-tool': {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
foo: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['foo'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (options: unknown, args: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ disableOAuth: true }, { foo: 'bar' });
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: { foo: 'bar' },
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves schema-owned disableOAuth fields after metadata discovery', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
'some-tool': {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
disableOAuth: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['disableOAuth'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (args: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ disableOAuth: true });
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: { disableOAuth: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves schema-owned disableOAuth fields beside proxy options', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
'some-tool': {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
disableOAuth: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['disableOAuth'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (args: unknown, options: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ disableOAuth: true }, { tailLog: true });
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
autoAuthorize: false,
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: { disableOAuth: true },
|
||||||
|
tailLog: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves schema-owned disableOAuth fields beside positional arguments', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
'some-tool': {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
disableOAuth: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['value', 'disableOAuth'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (value: string, args: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn('x', { disableOAuth: true });
|
||||||
|
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: {
|
||||||
|
value: 'x',
|
||||||
|
disableOAuth: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not override active OAuth posture when schema discovery is cached', async () => {
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
value: { type: 'string' },
|
||||||
|
disableOAuth: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['value', 'disableOAuth'],
|
||||||
|
};
|
||||||
|
const runtime = createMockRuntime({ 'some-tool': schema });
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock', {
|
||||||
|
initialSchemas: { 'some-tool': schema },
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
const fn = proxy.someTool as (value: string, args: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn('x', { disableOAuth: true });
|
||||||
|
|
||||||
|
expect(runtime.listTools).not.toHaveBeenCalled();
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'some-tool', {
|
||||||
|
args: {
|
||||||
|
value: 'x',
|
||||||
|
disableOAuth: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses schema discovery for split proxy option bags', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
ping: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.ping as (options: unknown, additionalOptions: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ disableOAuth: true }, { tailLog: true });
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
autoAuthorize: false,
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'ping', {
|
||||||
|
disableOAuth: true,
|
||||||
|
tailLog: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports explicit args envelopes for option-only disableOAuth metadata discovery', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
ping: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.ping as (options: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ args: {}, disableOAuth: true });
|
||||||
|
|
||||||
|
expect(runtime.listTools).toHaveBeenCalledWith('mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'ping', {
|
||||||
|
args: {},
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not join an unsuppressed in-flight schema fetch for a disabled-OAuth call', async () => {
|
||||||
|
const tools: ServerToolInfo[] = [
|
||||||
|
{
|
||||||
|
name: 'ping',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let resolveOrdinary!: (tools: ServerToolInfo[]) => void;
|
||||||
|
const listTools = vi.fn((_server: string, options?: { disableOAuth?: boolean }) => {
|
||||||
|
if (options?.disableOAuth === true) {
|
||||||
|
return Promise.resolve(tools);
|
||||||
|
}
|
||||||
|
return new Promise<ServerToolInfo[]>((resolve) => {
|
||||||
|
resolveOrdinary = resolve;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const runtime = {
|
||||||
|
callTool: vi.fn(async (_, __, options) => options),
|
||||||
|
listTools,
|
||||||
|
getDefinition: vi.fn(() => {
|
||||||
|
throw new Error('no persistent schema cache');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock', {
|
||||||
|
cacheSchemas: false,
|
||||||
|
}) as Record<string, unknown>;
|
||||||
|
const fn = proxy.ping as (options?: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
const ordinary = fn();
|
||||||
|
await vi.waitFor(() => expect(listTools).toHaveBeenCalledTimes(1));
|
||||||
|
const suppressed = fn({ args: {}, disableOAuth: true });
|
||||||
|
|
||||||
|
await expect(suppressed).resolves.toBeDefined();
|
||||||
|
expect(listTools).toHaveBeenNthCalledWith(2, 'mock', {
|
||||||
|
includeSchema: true,
|
||||||
|
disableOAuth: true,
|
||||||
|
});
|
||||||
|
resolveOrdinary(tools);
|
||||||
|
await ordinary;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves schema-owned fields that share proxy option names', async () => {
|
||||||
|
const runtime = createMockRuntime({
|
||||||
|
wait: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
timeout: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['timeout'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const proxy = createServerProxy(runtime as unknown as Runtime, 'mock') as Record<string, unknown>;
|
||||||
|
const fn = proxy.wait as (args: unknown) => Promise<CallResult>;
|
||||||
|
|
||||||
|
await fn({ timeout: 1000 });
|
||||||
|
|
||||||
|
expect(runtime.callTool).toHaveBeenCalledWith('mock', 'wait', {
|
||||||
|
args: { timeout: 1000 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -117,4 +117,20 @@ describe('stdio MCP servers (filesystem + memory)', () => {
|
|||||||
},
|
},
|
||||||
20000
|
20000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
memoryTest(
|
||||||
|
'passes multiline @path argument values unchanged to a stdio MCP server',
|
||||||
|
async () => {
|
||||||
|
const payloadPath = path.join(tempDir, 'multiline.txt');
|
||||||
|
const payload = 'first line\nsecond line\n';
|
||||||
|
await fs.writeFile(payloadPath, payload, 'utf8');
|
||||||
|
const callResult = await runCli(
|
||||||
|
['call', 'memory-test.echo_text', '--output', 'json', `text=@${payloadPath}`],
|
||||||
|
configPath
|
||||||
|
);
|
||||||
|
expect(callResult.stderr).toBe('');
|
||||||
|
expect(JSON.parse(callResult.stdout)).toMatchObject({ text: payload });
|
||||||
|
},
|
||||||
|
20000
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { templateTestHelpers } from '../src/cli/generate/template.js';
|
import { renderTemplate, templateTestHelpers } from '../src/cli/generate/template.js';
|
||||||
|
import type { CliArtifactMetadata } from '../src/cli-metadata.js';
|
||||||
import type { ServerDefinition } from '../src/config.js';
|
import type { ServerDefinition } from '../src/config.js';
|
||||||
|
|
||||||
const { computeRelativeStdioCwd } = templateTestHelpers;
|
const { computeRelativeStdioCwd } = templateTestHelpers;
|
||||||
@ -49,3 +50,103 @@ describe('computeRelativeStdioCwd', () => {
|
|||||||
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'relative-dir' }), outputPath)).toBe(expected);
|
expect(computeRelativeStdioCwd(stdioDef({ cwd: 'relative-dir' }), outputPath)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('renderTemplate', () => {
|
||||||
|
it('rejects sanitized command name collisions before emitting a broken CLI', () => {
|
||||||
|
expect(() =>
|
||||||
|
renderTemplate({
|
||||||
|
runtimeKind: 'node',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
definition: stdioDef(),
|
||||||
|
serverName: 'demo',
|
||||||
|
generator: { name: 'mcporter', version: 'test' },
|
||||||
|
metadata: metadataFor('demo'),
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
tool: { name: 'foo/bar', inputSchema: undefined, outputSchema: undefined },
|
||||||
|
methodName: 'fooSlash',
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tool: { name: 'foo_bar', inputSchema: undefined, outputSchema: undefined },
|
||||||
|
methodName: 'fooUnderscore',
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toThrow(/Generated command name collision 'foo-bar'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits strict numeric parsing and empty required-string validation', () => {
|
||||||
|
const source = renderTemplate({
|
||||||
|
runtimeKind: 'node',
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
definition: stdioDef(),
|
||||||
|
serverName: 'demo',
|
||||||
|
generator: { name: 'mcporter', version: 'test' },
|
||||||
|
metadata: metadataFor('demo'),
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
tool: {
|
||||||
|
name: 'sum',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
count: { type: 'number' },
|
||||||
|
coords: { type: 'array', items: { type: 'number' } },
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['name'],
|
||||||
|
},
|
||||||
|
outputSchema: undefined,
|
||||||
|
},
|
||||||
|
methodName: 'sum',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
property: 'count',
|
||||||
|
cliName: 'count',
|
||||||
|
required: false,
|
||||||
|
type: 'number',
|
||||||
|
placeholder: '<count:number>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'coords',
|
||||||
|
cliName: 'coords',
|
||||||
|
required: false,
|
||||||
|
type: 'array',
|
||||||
|
arrayItemType: 'number',
|
||||||
|
placeholder: '<coords:value1,value2>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'name',
|
||||||
|
cliName: 'name',
|
||||||
|
required: true,
|
||||||
|
type: 'string',
|
||||||
|
placeholder: '<name>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(source).toContain('parseFiniteNumber');
|
||||||
|
expect(source).not.toContain('parseFloat');
|
||||||
|
expect(source).toContain("typeof entry.value === 'string' && entry.value.trim() === ''");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function metadataFor(serverName: string): CliArtifactMetadata {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: '1970-01-01T00:00:00.000Z',
|
||||||
|
generator: { name: 'mcporter', version: 'test' },
|
||||||
|
server: {
|
||||||
|
name: serverName,
|
||||||
|
definition: {
|
||||||
|
name: serverName,
|
||||||
|
command: { kind: 'stdio' as const, command: 'node', args: [], cwd: process.cwd() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
artifact: { path: '', kind: 'template' as const },
|
||||||
|
invocation: { runtime: 'node' as const, timeoutMs: 30_000, minify: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -40,6 +40,14 @@ describe('loadToolMetadata', () => {
|
|||||||
expect(listTools).toHaveBeenCalledTimes(2);
|
expect(listTools).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('differentiates cache entries by disableOAuth flag', async () => {
|
||||||
|
const listTools = vi.fn(async () => [demoTool]);
|
||||||
|
const runtime = createRuntimeStub(listTools);
|
||||||
|
await loadToolMetadata(runtime, 'integration', { includeSchema: true });
|
||||||
|
await loadToolMetadata(runtime, 'integration', { includeSchema: true, disableOAuth: true });
|
||||||
|
expect(listTools).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('passes cached OAuth preference to the runtime', async () => {
|
it('passes cached OAuth preference to the runtime', async () => {
|
||||||
const listTools = vi.fn(async () => [demoTool]);
|
const listTools = vi.fn(async () => [demoTool]);
|
||||||
const runtime = createRuntimeStub(listTools);
|
const runtime = createRuntimeStub(listTools);
|
||||||
@ -47,11 +55,13 @@ describe('loadToolMetadata', () => {
|
|||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
});
|
});
|
||||||
expect(listTools).toHaveBeenCalledWith('integration', {
|
expect(listTools).toHaveBeenCalledWith('integration', {
|
||||||
includeSchema: true,
|
includeSchema: true,
|
||||||
autoAuthorize: false,
|
autoAuthorize: false,
|
||||||
allowCachedAuth: true,
|
allowCachedAuth: true,
|
||||||
|
disableOAuth: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user