From 8f7fb4acc45e0d3f493914bcaf5dd5cfaf77a4e8 Mon Sep 17 00:00:00 2001 From: Mateusz Polnik Date: Fri, 4 Sep 2026 16:12:18 +0200 Subject: [PATCH 1/4] refactor(mcp): replace vendored adapter with published pi-mcp-adapter package Remove the in-repo mcp-adapter extension (~78 files) and depend on the published pi-mcp-adapter package instead. Kimchi-specific concerns live in a new thin src/extensions/mcp/ wrapper: config loading, OAuth storage migration, server probing, read-only tool registry integration, and tool annotation catalog. Adds --mcp-config CLI flag for pointing at a specific MCP config file, and updates ACP session/new handling, permissions, and the e2e MCP suites accordingly. Co-Authored-By: Kimchi --- .github/workflows/canary.yml | 24 + .github/workflows/release.yml | 23 + docs/mcp-adapter-audit.md | 229 + package.json | 6 +- pnpm-lock.yaml | 287 + src/agent-discovery/agents/claude-code.ts | 2 +- src/agent-discovery/agents/cursor.ts | 2 +- src/agent-discovery/agents/opencode.ts | 2 +- src/agent-discovery/engine.ts | 2 +- src/agent-discovery/index.ts | 2 +- src/cli-args.ts | 7 + src/cli.ts | 18 +- src/commands/mcp.test.ts | 767 +- src/commands/mcp.ts | 137 +- src/commands/registry.ts | 2 +- src/extensions/__mocks__/extension-api.ts | 33 +- src/extensions/context-budget-tools.ts | 8 +- src/extensions/context-budget.test.ts | 15 +- src/extensions/ferment/resume.test.ts | 3 +- src/extensions/ferment/tool-scope.test.ts | 2 + src/extensions/mcp-adapter/CHANGELOG.md | 264 - src/extensions/mcp-adapter/LICENSE | 21 - src/extensions/mcp-adapter/README.md | 316 - .../mcp-adapter/acp-mcp-convert.test.ts | 147 - src/extensions/mcp-adapter/acp-mcp-convert.ts | 90 - .../mcp-adapter/app-bridge.bundle.js | 10430 ---------------- src/extensions/mcp-adapter/bm25.ts | 187 - .../cache-resolver.integration.test.ts | 279 - .../mcp-adapter/caller-servers.test.ts | 116 - src/extensions/mcp-adapter/caller-servers.ts | 65 - src/extensions/mcp-adapter/commands.ts | 235 - src/extensions/mcp-adapter/config.test.ts | 231 - src/extensions/mcp-adapter/config.ts | 234 - src/extensions/mcp-adapter/consent-manager.ts | 64 - .../mcp-adapter/context-providers.test.ts | 131 - .../mcp-adapter/context-providers.ts | 55 - .../direct-tool-visibility.test.ts | 61 - .../mcp-adapter/direct-tool-visibility.ts | 47 - src/extensions/mcp-adapter/direct-tools.ts | 415 - src/extensions/mcp-adapter/errors.ts | 198 - src/extensions/mcp-adapter/glimpse-ui.ts | 78 - .../mcp-adapter/host-html-template.ts | 423 - src/extensions/mcp-adapter/index.test.ts | 362 - src/extensions/mcp-adapter/index.ts | 636 - src/extensions/mcp-adapter/init.ts | 416 - src/extensions/mcp-adapter/lifecycle.ts | 93 - src/extensions/mcp-adapter/logger.ts | 169 - .../mcp-adapter/mcp-auth-flow.test.ts | 266 - src/extensions/mcp-adapter/mcp-auth-flow.ts | 429 - src/extensions/mcp-adapter/mcp-auth.ts | 273 - .../mcp-callback-server.preview.test.ts | 57 - .../mcp-adapter/mcp-callback-server.test.ts | 168 - .../mcp-adapter/mcp-callback-server.ts | 290 - .../mcp-adapter/mcp-oauth-provider.ts | 297 - src/extensions/mcp-adapter/mcp-panel.test.ts | 339 - src/extensions/mcp-adapter/mcp-panel.ts | 829 -- .../mcp-adapter/metadata-cache.test.ts | 83 - src/extensions/mcp-adapter/metadata-cache.ts | 327 - src/extensions/mcp-adapter/npx-resolver.ts | 430 - src/extensions/mcp-adapter/oauth-handler.ts | 57 - .../mcp-adapter/proxy-modes.test.ts | 144 - src/extensions/mcp-adapter/proxy-modes.ts | 1033 -- .../mcp-adapter/resolve-probe-name.test.ts | 48 - .../mcp-adapter/resolve-probe-name.ts | 45 - src/extensions/mcp-adapter/resource-tools.ts | 17 - .../mcp-adapter/server-manager.test.ts | 518 - src/extensions/mcp-adapter/server-manager.ts | 578 - src/extensions/mcp-adapter/state.ts | 45 - .../mcp-adapter/tool-metadata.test.ts | 115 - src/extensions/mcp-adapter/tool-metadata.ts | 182 - src/extensions/mcp-adapter/tool-registrar.ts | 46 - src/extensions/mcp-adapter/types.ts | 447 - .../mcp-adapter/ui-resource-handler.ts | 143 - src/extensions/mcp-adapter/ui-server.ts | 619 - src/extensions/mcp-adapter/ui-session.ts | 378 - src/extensions/mcp-adapter/ui-stream-types.ts | 93 - src/extensions/mcp-adapter/utils.ts | 77 - src/extensions/mcp/acp-config.test.ts | 45 + src/extensions/mcp/acp-config.ts | 39 + src/extensions/mcp/annotation-catalog.test.ts | 115 + src/extensions/mcp/annotation-catalog.ts | 165 + src/extensions/mcp/config.test.ts | 76 + src/extensions/mcp/config.ts | 30 + src/extensions/mcp/index.test.ts | 243 + src/extensions/mcp/index.ts | 188 + src/extensions/mcp/keyring-require-bridge.ts | 105 + src/extensions/mcp/oauth-migration.test.ts | 159 + src/extensions/mcp/oauth-migration.ts | 161 + src/extensions/mcp/probe.ts | 279 + src/extensions/mcp/read-only-tools.test.ts | 23 + src/extensions/mcp/read-only-tools.ts | 10 + src/extensions/permissions/index.test.ts | 10 + src/extensions/permissions/index.ts | 6 + src/extensions/tool-exposure.test.ts | 16 +- src/modes/acp/ext-methods/mcp.test.ts | 318 +- src/modes/acp/ext-methods/mcp.ts | 59 +- src/modes/acp/probe-mcp-server.test.ts | 426 +- src/modes/acp/server.test.ts | 37 +- src/modes/acp/server.ts | 39 +- src/setup-wizard.ts | 2 +- .../planning/read-only-tool-registry.test.ts | 21 +- .../planning/read-only-tool-registry.ts | 26 +- .../planning/tool-profile-manager.test.ts | 13 +- src/shared/planning/tool-profile-manager.ts | 14 +- .../planning/tool-session-scope.test.ts | 21 + src/shared/planning/tool-session-scope.ts | 26 + tests/e2e/mcp/fixture-server.mjs | 4 +- tests/e2e/tui/mcp-failures.test.ts | 6 +- tests/e2e/tui/mcp-lifecycle.test.ts | 13 +- tests/e2e/tui/mcp-oauth.test.ts | 84 +- tests/e2e/tui/mcp-panel.test.ts | 3 +- tests/e2e/tui/mcp-restart.test.ts | 15 +- tests/e2e/tui/mcp-stdio.test.ts | 74 +- tests/e2e/tui/support/mcp-fixture.ts | 23 +- tests/e2e/tui/support/mcp-model-script.ts | 2 +- tsconfig.json | 1 + 116 files changed, 2819 insertions(+), 25785 deletions(-) create mode 100644 docs/mcp-adapter-audit.md delete mode 100644 src/extensions/mcp-adapter/CHANGELOG.md delete mode 100644 src/extensions/mcp-adapter/LICENSE delete mode 100644 src/extensions/mcp-adapter/README.md delete mode 100644 src/extensions/mcp-adapter/acp-mcp-convert.test.ts delete mode 100644 src/extensions/mcp-adapter/acp-mcp-convert.ts delete mode 100644 src/extensions/mcp-adapter/app-bridge.bundle.js delete mode 100644 src/extensions/mcp-adapter/bm25.ts delete mode 100644 src/extensions/mcp-adapter/cache-resolver.integration.test.ts delete mode 100644 src/extensions/mcp-adapter/caller-servers.test.ts delete mode 100644 src/extensions/mcp-adapter/caller-servers.ts delete mode 100644 src/extensions/mcp-adapter/commands.ts delete mode 100644 src/extensions/mcp-adapter/config.test.ts delete mode 100644 src/extensions/mcp-adapter/config.ts delete mode 100644 src/extensions/mcp-adapter/consent-manager.ts delete mode 100644 src/extensions/mcp-adapter/context-providers.test.ts delete mode 100644 src/extensions/mcp-adapter/context-providers.ts delete mode 100644 src/extensions/mcp-adapter/direct-tool-visibility.test.ts delete mode 100644 src/extensions/mcp-adapter/direct-tool-visibility.ts delete mode 100644 src/extensions/mcp-adapter/direct-tools.ts delete mode 100644 src/extensions/mcp-adapter/errors.ts delete mode 100644 src/extensions/mcp-adapter/glimpse-ui.ts delete mode 100644 src/extensions/mcp-adapter/host-html-template.ts delete mode 100644 src/extensions/mcp-adapter/index.test.ts delete mode 100644 src/extensions/mcp-adapter/index.ts delete mode 100644 src/extensions/mcp-adapter/init.ts delete mode 100644 src/extensions/mcp-adapter/lifecycle.ts delete mode 100644 src/extensions/mcp-adapter/logger.ts delete mode 100644 src/extensions/mcp-adapter/mcp-auth-flow.test.ts delete mode 100644 src/extensions/mcp-adapter/mcp-auth-flow.ts delete mode 100644 src/extensions/mcp-adapter/mcp-auth.ts delete mode 100644 src/extensions/mcp-adapter/mcp-callback-server.preview.test.ts delete mode 100644 src/extensions/mcp-adapter/mcp-callback-server.test.ts delete mode 100644 src/extensions/mcp-adapter/mcp-callback-server.ts delete mode 100644 src/extensions/mcp-adapter/mcp-oauth-provider.ts delete mode 100644 src/extensions/mcp-adapter/mcp-panel.test.ts delete mode 100644 src/extensions/mcp-adapter/mcp-panel.ts delete mode 100644 src/extensions/mcp-adapter/metadata-cache.test.ts delete mode 100644 src/extensions/mcp-adapter/metadata-cache.ts delete mode 100644 src/extensions/mcp-adapter/npx-resolver.ts delete mode 100644 src/extensions/mcp-adapter/oauth-handler.ts delete mode 100644 src/extensions/mcp-adapter/proxy-modes.test.ts delete mode 100644 src/extensions/mcp-adapter/proxy-modes.ts delete mode 100644 src/extensions/mcp-adapter/resolve-probe-name.test.ts delete mode 100644 src/extensions/mcp-adapter/resolve-probe-name.ts delete mode 100644 src/extensions/mcp-adapter/resource-tools.ts delete mode 100644 src/extensions/mcp-adapter/server-manager.test.ts delete mode 100644 src/extensions/mcp-adapter/server-manager.ts delete mode 100644 src/extensions/mcp-adapter/state.ts delete mode 100644 src/extensions/mcp-adapter/tool-metadata.test.ts delete mode 100644 src/extensions/mcp-adapter/tool-metadata.ts delete mode 100644 src/extensions/mcp-adapter/tool-registrar.ts delete mode 100644 src/extensions/mcp-adapter/types.ts delete mode 100644 src/extensions/mcp-adapter/ui-resource-handler.ts delete mode 100644 src/extensions/mcp-adapter/ui-server.ts delete mode 100644 src/extensions/mcp-adapter/ui-session.ts delete mode 100644 src/extensions/mcp-adapter/ui-stream-types.ts delete mode 100644 src/extensions/mcp-adapter/utils.ts create mode 100644 src/extensions/mcp/acp-config.test.ts create mode 100644 src/extensions/mcp/acp-config.ts create mode 100644 src/extensions/mcp/annotation-catalog.test.ts create mode 100644 src/extensions/mcp/annotation-catalog.ts create mode 100644 src/extensions/mcp/config.test.ts create mode 100644 src/extensions/mcp/config.ts create mode 100644 src/extensions/mcp/index.test.ts create mode 100644 src/extensions/mcp/index.ts create mode 100644 src/extensions/mcp/keyring-require-bridge.ts create mode 100644 src/extensions/mcp/oauth-migration.test.ts create mode 100644 src/extensions/mcp/oauth-migration.ts create mode 100644 src/extensions/mcp/probe.ts create mode 100644 src/extensions/mcp/read-only-tools.test.ts create mode 100644 src/extensions/mcp/read-only-tools.ts create mode 100644 src/shared/planning/tool-session-scope.test.ts create mode 100644 src/shared/planning/tool-session-scope.ts diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 53e179ddf..c6c0dd10d 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -13,6 +13,7 @@ on: - 'biome.json' - 'scripts/**' - 'tools/proxy-helper/**' + - '.github/workflows/**' workflow_dispatch: inputs: force: @@ -152,6 +153,29 @@ jobs: if: ${{ matrix.os == 'windows' }} run: node scripts/build-binary.js --target ${{ matrix.target }} + - name: Install Linux credential-store service + if: ${{ matrix.os == 'linux' }} + run: | + sudo apt-get update + sudo apt-get install --yes dbus-x11 gnome-keyring + + - name: Verify MCP native keyring + if: ${{ matrix.os == 'darwin' }} + run: ./dist/bin/kimchi mcp keyring-check --json + + - name: Verify MCP native keyring on Linux + if: ${{ matrix.os == 'linux' }} + run: | + dbus-run-session -- bash -euo pipefail -c ' + printf "\n" | gnome-keyring-daemon --unlock --components=secrets + ./dist/bin/kimchi mcp keyring-check --json + ' + + - name: Verify MCP native keyring on Windows + if: ${{ matrix.os == 'windows' }} + shell: pwsh + run: .\dist\bin\kimchi.exe mcp keyring-check --json + - name: Verify binary architecture if: ${{ matrix.os != 'windows' }} run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5328c9f59..fae7f7493 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,29 @@ jobs: - name: Build binary run: node scripts/build-binary.js --target ${{ matrix.target }} + - name: Install Linux credential-store service + if: ${{ matrix.os == 'linux' }} + run: | + sudo apt-get update + sudo apt-get install --yes dbus-x11 gnome-keyring + + - name: Verify MCP native keyring + if: ${{ matrix.os == 'darwin' }} + run: ./dist/bin/kimchi mcp keyring-check --json + + - name: Verify MCP native keyring on Linux + if: ${{ matrix.os == 'linux' }} + run: | + dbus-run-session -- bash -euo pipefail -c ' + printf "\n" | gnome-keyring-daemon --unlock --components=secrets + ./dist/bin/kimchi mcp keyring-check --json + ' + + - name: Verify MCP native keyring on Windows + if: ${{ matrix.os == 'windows' }} + shell: pwsh + run: .\dist\bin\kimchi.exe mcp keyring-check --json + - name: Verify binary architecture if: ${{ matrix.os != 'windows' }} run: | diff --git a/docs/mcp-adapter-audit.md b/docs/mcp-adapter-audit.md new file mode 100644 index 000000000..10e0c4242 --- /dev/null +++ b/docs/mcp-adapter-audit.md @@ -0,0 +1,229 @@ +# MCP Adapter Unvendoring Audit + +- **Decision date:** 2026-09-04 +- **Vendored baseline:** `pi-mcp-adapter` 2.4.0 plus Kimchi changes +- **Replacement:** exact dependency `pi-mcp-adapter@2.32.1` +- **Ownership decision:** no changes will be upstreamed; required integration behavior is owned locally by Kimchi + +## Outcome + +The vendored `src/extensions/mcp-adapter/` tree has been removed. Kimchi now +uses the stable published adapter through a small facade in +[`src/extensions/mcp/`](../src/extensions/mcp/). + +This is not a dependency-only swap. The facade retains the behavior that is +part of Kimchi's host contract, while adapter implementation details and fixes +that are present in 2.32.1 are deliberately returned to package ownership. +The migration removes 57 vendored files and roughly 25,000 lines of copied +implementation and tests. + +No upstream issue, pull request, or upstream removal condition is planned. +The remaining local code is the permanent Kimchi integration boundary unless +Kimchi's product requirements change. + +## Local behavior retained + +### Configuration compatibility + +Kimchi continues to support the project configuration path +`.kimchi/mcp.json`. An explicit `--mcp-config` path takes precedence. The +facade constructs the effective configuration before creating the published +adapter, so this compatibility does not require a copied config loader. + +Relevant code: + +- [`src/extensions/mcp/config.ts`](../src/extensions/mcp/config.ts) +- [`src/cli-args.ts`](../src/cli-args.ts) + +### OAuth credential migration and compiled keyring support + +Legacy plaintext OAuth records are copied once into the adapter's hashed +credential layout. The migration preserves the full record, including tokens, +dynamic client registration, PKCE verifier, state, and URL. Invalid records +are left untouched with a warning, existing destination records are never +overwritten, and untrusted custom OAuth directories are not selected as +automatic migration targets. + +The published adapter dynamically requires `@napi-rs/keyring`. Kimchi's Bun +binary cannot resolve that native module from its compiled virtual filesystem, +so a narrow local bridge supplies the statically bundled module. A private, +file-backed implementation is available only to isolated E2E processes. The +`mcp keyring-check --json` command always exercises native credential-store +CRUD and is run by release and canary workflows on each target OS. + +Relevant code: + +- [`src/extensions/mcp/oauth-migration.ts`](../src/extensions/mcp/oauth-migration.ts) +- [`src/extensions/mcp/keyring-require-bridge.ts`](../src/extensions/mcp/keyring-require-bridge.ts) +- [`src/commands/mcp.ts`](../src/commands/mcp.ts) +- [`.github/workflows/release.yml`](../.github/workflows/release.yml) +- [`.github/workflows/canary.yml`](../.github/workflows/canary.yml) + +### ACP caller-supplied servers + +ACP `session/new` and `session/load` continue to accept caller-supplied MCP +servers. Kimchi converts ACP stdio and HTTP definitions, rejects unsupported +SSE definitions, merges each session's definitions with file configuration, +and gives the caller's definition precedence on a name collision. Each ACP +session receives its own adapter instance; no caller configuration is stored +in a process-global registry. + +Relevant code: + +- [`src/extensions/mcp/acp-config.ts`](../src/extensions/mcp/acp-config.ts) +- [`src/modes/acp/server.ts`](../src/modes/acp/server.ts) + +### Transient CLI and Desktop probes + +`kimchi mcp probe --json` and ACP `_kimchi.dev/probe_mcp_server` remain +supported. The local probe hosts a short-lived published adapter instance and +uses its public gateway contract to connect and describe tools. It applies +timeouts, supports stdio and HTTP/OAuth flows, isolates same-name/different-URL +credentials, and always shuts the adapter down. + +Relevant code: + +- [`src/extensions/mcp/probe.ts`](../src/extensions/mcp/probe.ts) +- [`src/commands/mcp.ts`](../src/commands/mcp.ts) +- [`src/modes/acp/ext-methods/mcp.ts`](../src/modes/acp/ext-methods/mcp.ts) + +### Planning-mode safety and tool-profile integration + +Kimchi must not expose write-capable MCP tools while a session is in plan +mode. The published adapter's narrowed cache metadata does not expose MCP +`annotations`, so the facade observes the raw public MCP client's `tools/list` +response and stores only the classification needed by Kimchi. + +The rules are intentionally fail-closed: + +- `readOnlyHint: true` is read-only. +- `readOnlyHint: false` is not read-only. +- contradictory observations are a conflict and are not read-only. +- a missing annotation may use the existing `get`, `search`, `list`, `read`, + or `fetch` name heuristic, but only after a real tool observation. +- an unknown tool is not read-only. + +Cached classifications are bound to the effective server configuration hash, +so changing a command, URL, headers, environment, auth, or tool filters makes +the old annotation cache ineligible. The classification applies both to direct +tools and gateway calls. A write or unknown gateway call attempted in plan mode returns +`plan_mode_write_blocked` before it reaches the MCP server. Session-scoped +state keeps concurrent extension/API wrappers from leaking profiles or +classifications between sessions. A planning snapshot is refreshed before the +agent starts, closing the race where direct tools finish registering after the +initial profile selection. + +Relevant code: + +- [`src/extensions/mcp/annotation-catalog.ts`](../src/extensions/mcp/annotation-catalog.ts) +- [`src/extensions/mcp/read-only-tools.ts`](../src/extensions/mcp/read-only-tools.ts) +- [`src/extensions/mcp/index.ts`](../src/extensions/mcp/index.ts) +- [`src/shared/planning/tool-session-scope.ts`](../src/shared/planning/tool-session-scope.ts) +- [`src/shared/planning/read-only-tool-registry.ts`](../src/shared/planning/read-only-tool-registry.ts) +- [`src/shared/planning/tool-profile-manager.ts`](../src/shared/planning/tool-profile-manager.ts) +- [`src/extensions/permissions/index.ts`](../src/extensions/permissions/index.ts) + +### Conservative adapter defaults + +The facade disables the model-facing `mcpScript` tool and omits the MCP +gateway entirely when no server is configured. Direct-tool updates are folded +back through Kimchi's active tool profile so the adapter cannot silently widen +a restricted profile. + +## Vendored patches deliberately dropped + +The following changes existed in the 2.4.0 vendor fork but are not recreated +locally. They belong to the adapter implementation, and the stable package now +has equivalent or superseding behavior: + +- Lazy and host-aware agent-directory and cache path resolution. +- Oversized-output protection after selecting compatible limits. +- Compact MCP tool call and result rendering. +- Direct-tool synchronization and first-request availability. +- Cancellation propagation to in-flight MCP calls. +- Recovery after a keep-alive MCP process crashes or its client closes. +- OAuth callback listener cleanup, port selection, strict-port behavior, and + abort handling. +- Invalid-config warnings and empty-status handling. +- Panel display, reconnect, authentication, narrow-layout, and sanitization + fixes. +- Host-name branding in OAuth callback pages and dynamic client registration. +- Stale cache cleanup and hot direct-tool refresh. + +Keeping parallel copies of these fixes would require Kimchi to depend on +private adapter internals, recreate fixed lifecycle code, and continuously +reconcile two implementations. Any regression in these areas is now handled +by pinning or upgrading the dependency, or by a narrow Kimchi-side adapter +workaround if the regression violates a Kimchi product contract. It will not +be handled by restoring the vendor tree. + +## Intentional behavior changes + +These are accepted migration changes and should not be mistaken for +regressions: + +- MCP search uses the package's weighted gateway search, not Kimchi's former + BM25 implementation or combined MCP/native tool index. +- Search and describe results remain gateway results; they are not injected as + temporary native tools for the next turn. +- Missing path-like tool arguments are no longer filled automatically with the + session working directory. +- Output truncation and artifact handling use the package's current policy + (50 KiB or 2,000 lines with overflow written to a temporary artifact), not + the old Kimchi `maxToolResultChars` setting. +- Resource operations use the package's `read_` spelling rather than + the former `get_` spelling. +- Saving the package MCP panel closes it and refreshes direct tools. +- OAuth callback and dynamic-client display names use package branding rather + than the old vendored Kimchi branding. + +## Highest-risk failure scenarios and required tests + +| Risk | Expected failure if broken | Coverage / release gate | +| --- | --- | --- | +| Compiled native keyring loading | OAuth cannot read or persist credentials in a distributed binary | Build the binary and run `kimchi mcp keyring-check --json` on macOS, Linux under a Secret Service session, and Windows in release/canary CI | +| OAuth layout migration | Existing users are prompted to authenticate again, lose dynamic registration, or have credentials overwritten | Compiled-process upgrade test plus invalid-record and destination-conflict unit cases | +| Plan-mode race or classification leak | A write-capable direct or gateway MCP tool becomes callable during planning | TUI scenario with explicit `readOnlyHint: true` and `false`; assert the blocked call never reaches the fixture server; unit tests for unknown/conflicting annotations and multiple sessions | +| ACP session isolation | One Desktop session sees another session's servers, or caller definitions lose precedence | ACP `session/new`/`session/load`, collision, direct-tool registration, and multi-session configuration tests | +| Probe cleanup and OAuth isolation | Probe hangs, leaves a callback listener/process alive, or overwrites another server's credentials | CLI and compiled ACP probes for stdio, HTTP, timeout/failure, OAuth, and same-name/different-URL behavior | +| Adapter startup and direct-tool synchronization | First request lacks tools, a restrictive profile is widened, or stale tools survive reconnect | TUI lifecycle, restart, stdio, failure, and planning scenarios | +| Transport/OAuth lifecycle | Cancellation is ignored, keep-alive restart fails, or HTTP authentication loops | MCP TUI HTTP/OAuth/restart suites plus MCP conformance initialize, tools, SSE retry, discovery, and pre-registration suites | +| UI replacement | Panel crashes on narrow output, fails to reconnect/save, or renders unsafe content | TUI panel/UI scenarios and focused facade tests | +| Config compatibility | `.kimchi/mcp.json`, `--mcp-config`, or caller-wins precedence silently changes | Config precedence and ACP conversion/merge tests | + +## Verification record + +The migration is accepted only when all of the following remain green: + +- Full Vitest unit/integration suite. +- `pnpm run lint` and `pnpm run typecheck`. +- All MCP TUI suites: stdio, failures, HTTP, OAuth, restart, panel, + lifecycle, and UI. +- ACP caller-server and probe workflows. +- MCP conformance: initialize, tool calls, SSE retry, OAuth metadata + discovery, and OAuth pre-registration. +- `pnpm run build:binary` followed by native `mcp keyring-check`. +- Release/canary keyring checks on every distributed operating system. + +Local verification on macOS arm64 has passed the full unit suite (9,515 tests; +10 skipped), all 35 MCP TUI scenarios, all eight ACP MCP scenarios, the +complete MCP conformance matrix, lint, type checking, binary compilation, and +native macOS Keychain CRUD. +The release and canary workflows contain Linux Secret Service and Windows +Credential Manager runtime checks. Those two native backends remain an +environment validation gate until a CI run executes the updated workflows; +cross-compilation alone is not evidence that they work. + +The broader smoke suite has three known failures outside this migration: one +live model request receives HTTP 401, and two agent-session tracking cases do +not create their expected child session files. They are not MCP release +signals. + +## Ongoing maintenance boundary + +Kimchi owns only the facade contracts listed above. The published package owns +transport behavior, process lifecycle, callback servers, output protection, +cache mechanics, tool rendering, and panel implementation. Future adapter +upgrades must rerun this document's risk matrix. A package regression may be +worked around locally when necessary, but copying the package implementation +back into `src/extensions/` is explicitly out of scope. diff --git a/package.json b/package.json index d9f26b71b..88774a723 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,14 @@ "dependencies": { "@agentclientprotocol/sdk": "0.19.2", "@bulkhead-ai/core": "^0.7.0", - "@kimchi-dev/kimchi-workflows": "0.0.8", "@clack/prompts": "^1.3.0", "@earendil-works/pi-coding-agent": "0.84.1", "@earendil-works/pi-tui": "0.84.1", + "@kimchi-dev/kimchi-workflows": "0.0.8", + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.1", "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "1.3.0", "@shikijs/cli": "^4.0.2", "@types/diff": "^8.0.0", "@types/proper-lockfile": "^4.1.4", @@ -59,6 +61,7 @@ "diff": "^9.0.0", "micromatch": "^4.0.8", "open": "^10.2.0", + "pi-mcp-adapter": "2.32.1", "proper-lockfile": "^4.1.2", "shell-quote": "1.8.4", "shiki": "^4.0.2", @@ -88,6 +91,7 @@ "@microsoft/tui-test": "0.0.4", "@mixmark-io/domino": "^2.2.0", "@modelcontextprotocol/conformance": "0.1.16", + "@types/cross-spawn": "6.0.6", "@types/micromatch": "^4.0.10", "@types/node": "^22.19.18", "@types/shell-quote": "^1.7.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a4a37a5f..f85de5004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,12 +40,18 @@ importers: '@kimchi-dev/kimchi-workflows': specifier: 0.0.8 version: 0.0.8(@earendil-works/pi-coding-agent@0.84.1(patch_hash=d3074927a86746b8c663af0b071a0ec301579ca4016b50bb1f162a8ec99fa36d)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3))(@earendil-works/pi-tui@0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602))(@opentelemetry/api@1.9.0)(typebox@1.3.7)(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 '@modelcontextprotocol/ext-apps': specifier: ^1.7.1 version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) + '@napi-rs/keyring': + specifier: 1.3.0 + version: 1.3.0 '@shikijs/cli': specifier: ^4.0.2 version: 4.0.2 @@ -70,6 +76,9 @@ importers: open: specifier: ^10.2.0 version: 10.2.0 + pi-mcp-adapter: + specifier: 2.32.1 + version: 2.32.1(@earendil-works/pi-ai@0.84.1(patch_hash=833908f0ccb469da56b0e90c4b49c47df1048cfa0e3fdfd07fa312d2e7b2765f)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3))(@earendil-works/pi-tui@0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(typebox@1.3.7)(zod@4.4.3) proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -119,6 +128,9 @@ importers: '@modelcontextprotocol/conformance': specifier: 0.1.16 version: 0.1.16(supports-color@10.2.2) + '@types/cross-spawn': + specifier: 6.0.6 + version: 6.0.6 '@types/micromatch': specifier: ^4.0.10 version: 4.0.10 @@ -754,10 +766,18 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + '@modelcontextprotocol/conformance@0.1.16': resolution: {integrity: sha512-GI7qiN0r39/MH2srVUR3AXaEN0YLCro20lIBbnvc1frBhszenxvUifBuTzxeVQVagILfBzCIcnungUOma8OrgA==} hasBin: true + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + '@modelcontextprotocol/ext-apps@1.7.1': resolution: {integrity: sha512-J3WdG1A4JSSKnSWKyU+895dBVYBV2Utgtf7fUsUK45mlkETm53a/1DR6Pm3hUGKqLLQthZLmpxOg8VPzJi/lyg==} engines: {node: '>=20'} @@ -782,6 +802,82 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@nodable/entities@2.1.0': resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} @@ -855,6 +951,10 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pkgr/core@0.1.2': + resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -1184,6 +1284,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cross-spawn@6.0.6': + resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2265,6 +2368,23 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pi-mcp-adapter@2.32.1: + resolution: {integrity: sha512-GNLYa2U9T5ZqIhZmhx/RTenEjfakJTelq/z6Q+At5SIxyuYvrvobriDEVsnx+lqetVDizUCudTWLfdZytQu0rg==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@earendil-works/pi-ai': ^0.84.1 + '@earendil-works/pi-tui': '*' + typebox: '*' + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@earendil-works/pi-ai': + optional: true + '@earendil-works/pi-tui': + optional: true + typebox: + optional: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2331,6 +2451,33 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + recheck-jar@4.5.0: + resolution: {integrity: sha512-Ad7oCQmY8cQLzd3QVNXjzZ+S6MbImGhR4AaW2yiGzteOfMV45522rt6nSzFyt8p3mCEaMcm/4MoZrMSxUcCbrA==} + + recheck-linux-x64@4.5.0: + resolution: {integrity: sha512-52kXsR/v+IbGIKYYFZfSZcgse/Ci9IA2HnuzrtvRRcfODkcUGe4n72ESQ8nOPwrdHFg9i4j9/YyPh1HWWgpJ6A==} + cpu: [x64] + os: [linux] + + recheck-macos-arm64@4.5.0: + resolution: {integrity: sha512-qIyK3dRuLkORQvv0b59fZZRXweSmjjWaoA4K8Kgifz0anMBH4pqsDV6plBlgjcRmW9yC12wErIRzifREaKnk2w==} + cpu: [arm64] + os: [darwin] + + recheck-macos-x64@4.5.0: + resolution: {integrity: sha512-1wp/eiLxcjC/Ex4wurlrS/LGzt8IiF4TiK5sEjldu4HVAKdNCnnmsS9a5vFpfcikDz4ZuZlLlTi1VbQTxHlwZg==} + cpu: [x64] + os: [darwin] + + recheck-windows-x64@4.5.0: + resolution: {integrity: sha512-ekBKwAp0oKkMULn5zgmHEYLwSJfkfb95AbTtbDkQazNkqYw9PRD/mVyFUR6Ff2IeRyZI0gxy+N2AKBISWydhug==} + cpu: [x64] + os: [win32] + + recheck@4.5.0: + resolution: {integrity: sha512-kPnbOV6Zfx9a25AZ++28fI1q78L/UVRQmmuazwVRPfiiqpMs+WbOU69Shx820XgfKWfak0JH75PUvZMFtRGSsw==} + engines: {node: '>=20'} + regex-recursion@6.0.2: resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} @@ -2439,6 +2586,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2490,6 +2641,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} @@ -2504,6 +2659,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + engines: {node: ^14.18.0 || >=16.0.0} + tar@7.5.15: resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} engines: {node: '>=18'} @@ -3466,6 +3625,16 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + jose: 6.2.3 + pkce-challenge: 5.0.1 + zod: 4.4.3 + '@modelcontextprotocol/conformance@0.1.16(supports-color@10.2.2)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) @@ -3481,6 +3650,10 @@ snapshots: - '@cfworker/json-schema' - supports-color + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.4.3 + '@modelcontextprotocol/ext-apps@1.7.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) @@ -3509,6 +3682,57 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@nodable/entities@2.1.0': {} '@octokit/auth-token@6.0.0': {} @@ -3587,6 +3811,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pkgr/core@0.1.2': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -3852,6 +4078,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cross-spawn@6.0.6': + dependencies: + '@types/node': 22.19.18 + '@types/deep-eql@4.0.2': {} '@types/diff@8.0.0': @@ -4885,6 +5115,29 @@ snapshots: pathval@2.0.1: {} + pi-mcp-adapter@2.32.1(@earendil-works/pi-ai@0.84.1(patch_hash=833908f0ccb469da56b0e90c4b49c47df1048cfa0e3fdfd07fa312d2e7b2765f)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3))(@earendil-works/pi-tui@0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(typebox@1.3.7)(zod@4.4.3): + dependencies: + '@modelcontextprotocol/client': 2.0.0 + '@modelcontextprotocol/core': 2.0.0 + '@modelcontextprotocol/ext-apps': 1.7.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) + '@napi-rs/keyring': 1.3.0 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + cross-spawn: 7.0.6 + open: 10.2.0 + recheck: 4.5.0 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + zod: 4.4.3 + optionalDependencies: + '@earendil-works/pi-ai': 0.84.1(patch_hash=833908f0ccb469da56b0e90c4b49c47df1048cfa0e3fdfd07fa312d2e7b2765f)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3) + '@earendil-works/pi-tui': 0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602) + typebox: 1.3.7 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - react + - react-dom + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -4960,6 +5213,31 @@ snapshots: react-is@18.3.1: {} + recheck-jar@4.5.0: + optional: true + + recheck-linux-x64@4.5.0: + optional: true + + recheck-macos-arm64@4.5.0: + optional: true + + recheck-macos-x64@4.5.0: + optional: true + + recheck-windows-x64@4.5.0: + optional: true + + recheck@4.5.0: + dependencies: + synckit: 0.9.2 + optionalDependencies: + recheck-jar: 4.5.0 + recheck-linux-x64: 4.5.0 + recheck-macos-arm64: 4.5.0 + recheck-macos-x64: 4.5.0 + recheck-windows-x64: 4.5.0 + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -5111,6 +5389,8 @@ snapshots: slash@3.0.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -5159,6 +5439,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -5171,6 +5453,11 @@ snapshots: dependencies: has-flag: 4.0.0 + synckit@0.9.2: + dependencies: + '@pkgr/core': 0.1.2 + tslib: 2.8.1 + tar@7.5.15: dependencies: '@isaacs/fs-minipass': 4.0.1 diff --git a/src/agent-discovery/agents/claude-code.ts b/src/agent-discovery/agents/claude-code.ts index 1accb20d7..1b328c1ce 100644 --- a/src/agent-discovery/agents/claude-code.ts +++ b/src/agent-discovery/agents/claude-code.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os" import { join } from "node:path" -import type { ServerEntry } from "../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" import { hasBearerAuthorizationHeader } from "../engine.js" import type { AgentDefinition } from "../index.js" diff --git a/src/agent-discovery/agents/cursor.ts b/src/agent-discovery/agents/cursor.ts index 3484b0583..87fc1e9dd 100644 --- a/src/agent-discovery/agents/cursor.ts +++ b/src/agent-discovery/agents/cursor.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os" import { join } from "node:path" -import type { ServerEntry } from "../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" import { hasBearerAuthorizationHeader } from "../engine.js" import type { AgentDefinition } from "../index.js" diff --git a/src/agent-discovery/agents/opencode.ts b/src/agent-discovery/agents/opencode.ts index eb4a8f28c..fb8dc5c1f 100644 --- a/src/agent-discovery/agents/opencode.ts +++ b/src/agent-discovery/agents/opencode.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os" import { join } from "node:path" -import type { ServerEntry } from "../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" import { hasBearerAuthorizationHeader } from "../engine.js" import type { AgentDefinition } from "../index.js" import { parseJsonc } from "../jsonc.js" diff --git a/src/agent-discovery/engine.ts b/src/agent-discovery/engine.ts index 69b91ff21..86a125f51 100644 --- a/src/agent-discovery/engine.ts +++ b/src/agent-discovery/engine.ts @@ -1,5 +1,5 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" -import type { ServerEntry } from "../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" import type { AgentDefinition, AgentDiscovery } from "./index.js" function msg(err: unknown): string { diff --git a/src/agent-discovery/index.ts b/src/agent-discovery/index.ts index 36271a676..3e1f7c5c7 100644 --- a/src/agent-discovery/index.ts +++ b/src/agent-discovery/index.ts @@ -1,4 +1,4 @@ -import type { ServerEntry } from "../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" import { claudeCode } from "./agents/claude-code.js" import { cursor } from "./agents/cursor.js" import { openCode } from "./agents/opencode.js" diff --git a/src/cli-args.ts b/src/cli-args.ts index 459cfc6c6..144a00201 100644 --- a/src/cli-args.ts +++ b/src/cli-args.ts @@ -184,6 +184,11 @@ export const CLI_OPTIONS: Record = { description: "Replace the merged permissions config with this file", placeholder: "", }, + "mcp-config": { + type: "string", + description: "Use a specific MCP configuration file", + placeholder: "", + }, verbose: { type: "boolean", description: "Force verbose startup (overrides quietStartup)", @@ -221,6 +226,7 @@ export interface SessionCliArgs { auto?: boolean yolo?: boolean "permissions-config"?: string + "mcp-config"?: string verbose?: boolean } positionals: string[] @@ -264,6 +270,7 @@ const CACHEABLE_OPTION_NAMES = [ "auto", "yolo", "permissions-config", + "mcp-config", "verbose", ] as const satisfies ReadonlyArray diff --git a/src/cli.ts b/src/cli.ts index eaaab5824..a55094bbf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,7 +87,8 @@ import loginExtension from "./extensions/login/index.js" import { createStartupAuthGate, createStartupAuthGateState } from "./extensions/login/startup-auth.js" import loopGuardExtension from "./extensions/loop-guard.js" import lspExtension from "./extensions/lsp.js" -import mcpAdapterExtension from "./extensions/mcp-adapter/index.js" +import mcpAdapterExtension, { createKimchiMcpAdapterExtension } from "./extensions/mcp/index.js" +import { UpstreamMcpProbe } from "./extensions/mcp/probe.js" import modelGuardExtension from "./extensions/model-guard.js" import modelSwitchExtension from "./extensions/model-switch.js" import omitKimchiMaxTokensExtension from "./extensions/omit-kimchi-max-tokens.js" @@ -582,6 +583,9 @@ try { ? [terminalColorsExtension, kimchiMinimalTintsExtension, uiExtension] : [] const effectiveSkillPaths = [...new Set([...skillPaths])] + const mcpAdapterExtensions = enabledExtensionFactories([ + { id: "plugins.mcp-apps", factory: mcpAdapterExtension }, + ] satisfies ManagedExtensionFactory[]) const extensionFactories = [ // First so its session_start handler syncs project trust onto the // settings watcher before any other handler reads settings. @@ -628,9 +632,7 @@ try { bashToolGuardExtension, bashTimeoutGuidanceExtension, hiddenToolGuidanceExtension, - ...enabledExtensionFactories([ - { id: "plugins.mcp-apps", factory: mcpAdapterExtension }, - ] satisfies ManagedExtensionFactory[]), + ...(IS_ACP_MODE ? [] : mcpAdapterExtensions), ideAdapterExtension, // Ferment must see raw input before prompt enrichment rewrites print-mode text. ...enabledExtensionFactories([ @@ -711,11 +713,15 @@ try { if (IS_ACP_MODE) { const { runAcpMode } = await import("./modes/acp/server.js") - const { McpServerManager } = await import("./extensions/mcp-adapter/server-manager.js") await runAcpMode({ extensionFactories, agentDir, - mcpServerManager: new McpServerManager(), + ...(mcpAdapterExtensions.length > 0 + ? { + mcpExtensionFactory: createKimchiMcpAdapterExtension, + mcpProbe: new UpstreamMcpProbe(), + } + : {}), appendSystemPrompt: parsePiArgs(rawArgs).appendSystemPrompt, }) } else { diff --git a/src/commands/mcp.test.ts b/src/commands/mcp.test.ts index 39e68baa7..43368d594 100644 --- a/src/commands/mcp.test.ts +++ b/src/commands/mcp.test.ts @@ -1,736 +1,173 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -// --------------------------------------------------------------------------- -// Mocks — declared before imports that consume them -// --------------------------------------------------------------------------- +const probeTools = vi.hoisted(() => vi.fn()) +const verifyMcpKeyringRuntime = vi.hoisted(() => vi.fn()) -// Mock McpServerManager so we never spawn real subprocesses or HTTP connections. -const { mockProbeTools, mockCloseAll } = vi.hoisted(() => ({ - mockProbeTools: vi.fn(), - mockCloseAll: vi.fn(), -})) - -vi.mock("../extensions/mcp-adapter/server-manager.js", () => ({ - McpServerManager: class MockMcpServerManager { - probeTools = mockProbeTools - closeAll = mockCloseAll +vi.mock("../extensions/mcp/probe.js", () => ({ + UpstreamMcpProbe: class { + probeTools = probeTools }, })) -// Mock the auth flow module — we control supportsOAuth and authenticate. -const { mockSupportsOAuth, mockAuthenticate } = vi.hoisted(() => ({ - mockSupportsOAuth: vi.fn(), - mockAuthenticate: vi.fn(), -})) - -vi.mock("../extensions/mcp-adapter/mcp-auth-flow.js", () => ({ - supportsOAuth: mockSupportsOAuth, - authenticate: mockAuthenticate, -})) - -// Mock the auth storage module — we control getAuthEntry / removeAuthEntry so -// the URL-mismatch guard can be exercised without touching the filesystem. -const { mockGetAuthEntry, mockRemoveAuthEntry } = vi.hoisted(() => ({ - mockGetAuthEntry: vi.fn(), - mockRemoveAuthEntry: vi.fn(), -})) - -vi.mock("../extensions/mcp-adapter/mcp-auth.js", () => ({ - getAuthEntry: mockGetAuthEntry, - removeAuthEntry: mockRemoveAuthEntry, -})) +vi.mock("../extensions/mcp/keyring-require-bridge.js", () => ({ verifyMcpKeyringRuntime })) import { runMcp } from "./mcp.js" -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Feed stdin data to process.stdin and emit 'end'. */ function mockStdin(data: string): void { - const stdin = process.stdin as unknown as { - setEncoding: (enc: string) => void - emit: (event: string, ...args: unknown[]) => boolean - } - // Buffer the data, then emit 'data' and 'end' on next tick. process.nextTick(() => { - stdin.emit("data", data) - stdin.emit("end") + process.stdin.emit("data", data) + process.stdin.emit("end") }) } -/** Emit stdin data but never emit 'end' — simulates a parent that opens the - * pipe without closing it, so readStdin would hang without the timeout. */ -function mockStdinOpen(data: string): void { - const stdin = process.stdin as unknown as { - setEncoding: (enc: string) => void - emit: (event: string, ...args: unknown[]) => boolean - } - process.nextTick(() => { - stdin.emit("data", data) - // Intentionally do NOT emit 'end'. - }) -} - -/** Read and parse the JSON written to stdout. */ -function captureStdout(): { data: string; json: Record } { +function captureStdout(): { readonly json: Record } { const writes: string[] = [] vi.spyOn(process.stdout, "write").mockImplementation( - // process.stdout.write is overloaded: (chunk, cb?) or (chunk, encoding?, cb?). - // Accept both shapes so the mock satisfies the union type. ( chunk: string | Uint8Array, - encodingOrCb?: BufferEncoding | ((err?: Error | null) => void), - cb?: (err?: Error | null) => void, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, ) => { writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString()) - const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb - // Node's write callback is asynchronous — defer it so the emitResult - // promise that awaits it resolves on the next tick, mirroring real I/O. - if (callback) process.nextTick(() => callback(null)) + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback + if (done) process.nextTick(() => done(null)) return true }, ) return { - get data() { - return writes.join("") - }, get json() { - return JSON.parse(writes.join("")) + return JSON.parse(writes.join("")) as Record }, } } -const SERVER_NAME = "my-server" -const STDIO_SERVER = { command: "node", args: ["server.js"] } -const URL_SERVER = { url: "https://example.com/mcp" } -const OAUTH_SERVER = { url: "https://example.com/mcp", auth: "oauth" as const } - -/** Wrap a server entry in the { name, server } stdin contract. */ -function probeInput( - server: typeof STDIO_SERVER | typeof URL_SERVER | typeof OAUTH_SERVER | Record, - name = SERVER_NAME, -): string { +function input(name = "fixture", server: Record = { command: "node", args: ["server.js"] }): string { return JSON.stringify({ name, server }) } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("kimchi mcp probe", () => { beforeEach(() => { - vi.clearAllMocks() - mockCloseAll.mockResolvedValue(undefined) - mockSupportsOAuth.mockReturnValue(false) - // Default: no pending auth - mockAuthenticate.mockResolvedValue("authenticated") - // Default: no stored auth entry (new server). Individual tests override - // this to simulate an existing entry with a same/different URL. - mockGetAuthEntry.mockReturnValue(undefined) - mockRemoveAuthEntry.mockReturnValue(undefined) + probeTools.mockReset() + probeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) + verifyMcpKeyringRuntime.mockReset() + verifyMcpKeyringRuntime.mockReturnValue({ backend: "native", platform: "darwin", arch: "arm64", writable: true }) }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() }) - // --- argument parsing ------------------------------------------------- - - it("returns 1 and prints error for unknown subcommand", async () => { - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true) - const code = await runMcp(["bogus"]) - expect(code).toBe(1) - expect(stderrSpy).toHaveBeenCalled() - }) - - it("returns 1 and emits JSON error on stdout when --json flag is missing", async () => { - const out = captureStdout() - const code = await runMcp(["probe"]) - expect(code).toBe(1) - expect(out.json.error).toContain("--json") - }) - - // --- TypeBox schema validation ------------------------------------------ - - it("returns 1 when server config has neither command nor url (semantic check remains)", async () => { - mockStdin(probeInput({})) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Server config must have either 'command' or 'url'") - }) - - it("returns 1 with JSON error when server is missing", async () => { - mockStdin(JSON.stringify({ name: SERVER_NAME })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - expect(out.json.error).toContain("server") - }) - - it("returns 1 with JSON error when server is null", async () => { - mockStdin(JSON.stringify({ name: SERVER_NAME, server: null })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - expect(out.json.error).toContain("object") - }) - - it("returns 1 with JSON error when server is wrong type (string)", async () => { - mockStdin(JSON.stringify({ name: SERVER_NAME, server: "not-an-object" })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - expect(out.json.error).toContain("object") - }) - - it("returns 1 with JSON error when name is missing", async () => { - mockStdin(JSON.stringify({ server: STDIO_SERVER })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - expect(out.json.error).toContain("name") - }) - - it("returns 1 with JSON error when name is empty", async () => { - mockStdin(probeInput(STDIO_SERVER, "")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - }) - - it("returns 1 when name contains path separator /", async () => { - mockStdin(probeInput(STDIO_SERVER, "foo/bar")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") + it("rejects unknown subcommands", async () => { + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true) + expect(await runMcp(["unknown"])).toBe(1) + expect(stderr).toHaveBeenCalled() }) - it("returns 1 when name contains path separator backslash", async () => { - mockStdin(probeInput(STDIO_SERVER, "foo\\bar")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - }) - - it("returns 1 when name is .. (path traversal)", async () => { - mockStdin(probeInput(STDIO_SERVER, "..")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - }) - - it("returns 1 when name contains consecutive dots (foo..bar)", async () => { - mockStdin(probeInput(STDIO_SERVER, "foo..bar")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - }) - - it("accepts name with single dots (github.com)", async () => { - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false }) - mockStdin(probeInput(STDIO_SERVER, "github.com")) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - expect(mockProbeTools).toHaveBeenCalledWith("github.com", STDIO_SERVER) - }) - - it("returns 1 when unknown top-level properties are present", async () => { - mockStdin(JSON.stringify({ name: SERVER_NAME, server: STDIO_SERVER, extra: true })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Invalid probe input") - }) - - it("accepts server with additionalProperties (full ServerEntry shape)", async () => { - const fullServer = { command: "npx", args: ["-y", "server.js"], env: { FOO: "bar" }, cwd: "/tmp", debug: true } - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false }) - mockStdin(probeInput(fullServer)) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - }) - - it("returns exit code 1 with JSON error for all validation failures", async () => { - // Verify error envelope shape for a validation failure - mockStdin(JSON.stringify({ name: SERVER_NAME, server: null })) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json).toEqual({ - tools: [], - needsAuth: false, - error: expect.stringContaining("Invalid probe input"), - }) - }) - - // --- readStdin guards: TTY, timeout, size cap -------------------------- - - it("returns 1 when stdin is a TTY", async () => { - // Simulate an interactive launch with no piped input. - Object.defineProperty(process.stdin, "isTTY", { - value: true, - configurable: true, + it("verifies the native keyring through the compiled-runtime bridge", async () => { + const output = captureStdout() + expect(await runMcp(["keyring-check", "--json"])).toBe(0) + expect(verifyMcpKeyringRuntime).toHaveBeenCalledOnce() + expect(output.json).toEqual({ + ok: true, + backend: "native", + platform: "darwin", + arch: "arm64", writable: true, }) - const out = captureStdout() - try { - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("No input on stdin") - } finally { - // Restore the non-TTY default so subsequent tests see no piped TTY. - ;(process.stdin as { isTTY?: boolean }).isTTY = undefined - } }) - it("returns 1 when stdin input times out", async () => { - vi.useFakeTimers() - try { - // Emit data but never 'end' — readStdin would hang without the timeout. - mockStdinOpen(probeInput(STDIO_SERVER)) - const out = captureStdout() - - const probePromise = runMcp(["probe", "--json"]) - await vi.advanceTimersByTimeAsync(5000) - const code = await probePromise - - expect(code).toBe(1) - expect(out.json.error).toContain("Timed out after 5000ms") - } finally { - vi.useRealTimers() - } + it("reports native keyring failures", async () => { + verifyMcpKeyringRuntime.mockImplementation(() => { + throw new Error("credential store unavailable") + }) + const output = captureStdout() + expect(await runMcp(["keyring-check", "--json"])).toBe(1) + expect(output.json).toEqual(expect.objectContaining({ ok: false, error: "credential store unavailable" })) }) - it("returns 1 when stdin input exceeds 1MB", async () => { - // Build a payload larger than 1MB. The first chunk alone crosses the cap. - const big = JSON.stringify({ name: SERVER_NAME, server: STDIO_SERVER, padding: "x".repeat(1_050_000) }) - mockStdinOpen(big) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("stdin input exceeded 1MB") + it("requires JSON mode", async () => { + const output = captureStdout() + expect(await runMcp(["probe"])).toBe(1) + expect(output.json.error).toContain("--json") }) - // --- successful stdio probe ------------------------------------------- - - it("connects, lists tools, and prints JSON for a stdio server", async () => { - mockProbeTools.mockResolvedValue({ - tools: [ - { name: "tool_a", title: "Tool A", description: "Does A" }, - { name: "tool_b", description: "Does B" }, - ], - needsAuth: false, - }) - mockStdin(probeInput(STDIO_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json).toEqual({ - tools: [ - { name: "tool_a", title: "Tool A", description: "Does A" }, - { name: "tool_b", title: undefined, description: "Does B" }, - ], - needsAuth: false, - error: null, - }) - expect(mockProbeTools).toHaveBeenCalledTimes(1) - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, STDIO_SERVER) - expect(mockCloseAll).toHaveBeenCalledTimes(1) + it("validates the input envelope and server shape", async () => { + mockStdin(JSON.stringify({ name: "fixture", server: null })) + const output = captureStdout() + expect(await runMcp(["probe", "--json"])).toBe(1) + expect(output.json.error).toContain("Invalid probe input") }) - // --- needsAuth without OAuth (returns needsAuth: true) ---------------- - - it("returns needsAuth: true when server needs auth but OAuth is not supported", async () => { - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: true }) - mockSupportsOAuth.mockReturnValue(false) - mockStdin(probeInput(URL_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json).toEqual({ tools: [], needsAuth: true, error: null }) - expect(mockAuthenticate).not.toHaveBeenCalled() + it.each(["", "..", "foo/bar", "foo\\bar", "foo..bar"])("rejects unsafe server name %j", async (name) => { + mockStdin(input(name)) + const output = captureStdout() + expect(await runMcp(["probe", "--json"])).toBe(1) + expect(output.json.error).toContain("Invalid probe input") }) - // --- OAuth flow: auth succeeds, retries probe -------------------------- - - it("attempts OAuth flow and retries probe when needsAuth + OAuth supported", async () => { - mockSupportsOAuth.mockReturnValue(true) - mockAuthenticate.mockResolvedValue("authenticated") - - // First probe returns needsAuth, second probe (after auth) returns tools - mockProbeTools - .mockResolvedValueOnce({ tools: [], needsAuth: true }) - .mockResolvedValueOnce({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(mockAuthenticate).toHaveBeenCalledTimes(1) - expect(mockAuthenticate).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER.url, OAUTH_SERVER) - expect(mockProbeTools).toHaveBeenCalledTimes(2) - expect(mockProbeTools).toHaveBeenNthCalledWith(1, SERVER_NAME, OAUTH_SERVER) - expect(mockProbeTools).toHaveBeenNthCalledWith(2, SERVER_NAME, OAUTH_SERVER) - expect(out.json).toEqual({ - tools: [{ name: "secure_tool", title: undefined, description: undefined }], + it("delegates discovery and OAuth to the isolated upstream probe", async () => { + const server = { url: "https://example.test/mcp", auth: "oauth" } + probeTools.mockResolvedValue({ + tools: [{ name: "lookup", description: "Look up data" }], needsAuth: false, error: null, }) - }) - - // --- repeat probe: tokens already exist, OAuth is skipped ------------- - - it("skips OAuth when the first probe returns tools (tokens already exist)", async () => { - mockSupportsOAuth.mockReturnValue(true) - // First probe returns tools directly — stored tokens were found. - mockProbeTools.mockResolvedValue({ - tools: [{ name: "secure_tool" }], - needsAuth: false, - }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(mockProbeTools).toHaveBeenCalledTimes(1) - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockAuthenticate).not.toHaveBeenCalled() - expect(out.json).toEqual({ - tools: [{ name: "secure_tool", title: undefined, description: undefined }], + mockStdin(input("remote", server)) + const output = captureStdout() + + expect(await runMcp(["probe", "--json"])).toBe(0) + expect(probeTools).toHaveBeenCalledWith( + "remote", + server, + expect.objectContaining({ authenticate: true, cwd: process.cwd(), signal: expect.any(AbortSignal) }), + ) + expect(output.json).toEqual({ + tools: [{ name: "lookup", description: "Look up data" }], needsAuth: false, error: null, }) }) - // --- OAuth flow: auth fails, returns needsAuth: true ------------------ - - it("returns needsAuth: true with error message when OAuth flow fails", async () => { - mockSupportsOAuth.mockReturnValue(true) - mockAuthenticate.mockRejectedValue(new Error("user denied")) - - // Auth fails immediately — no retry probe should happen. - mockProbeTools.mockResolvedValueOnce({ tools: [], needsAuth: true }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(mockAuthenticate).toHaveBeenCalledTimes(1) - expect(mockProbeTools).toHaveBeenCalledTimes(1) - expect(out.json).toEqual({ tools: [], needsAuth: true, error: "user denied" }) - }) - - it("returns needsAuth: true with real error when OAuth fails with port-in-use message", async () => { - mockSupportsOAuth.mockReturnValue(true) - const oauthError = "port 19876 is held by another process" - mockAuthenticate.mockRejectedValue(new Error(oauthError)) - mockProbeTools.mockResolvedValueOnce({ tools: [], needsAuth: true }) + it("returns authentication requirements as a successful probe", async () => { + probeTools.mockResolvedValue({ tools: [], needsAuth: true, error: "User denied authorization" }) + mockStdin(input()) + const output = captureStdout() - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.needsAuth).toBe(true) - expect(out.json.error).toContain(oauthError) + expect(await runMcp(["probe", "--json"])).toBe(0) + expect(output.json).toEqual({ tools: [], needsAuth: true, error: "User denied authorization" }) }) - // --- OAuth flow: auth times out, returns needsAuth: true -------------- + it("returns transport failures with exit code one", async () => { + probeTools.mockResolvedValue({ tools: [], needsAuth: false, error: "connection refused" }) + mockStdin(input()) + const output = captureStdout() - it("returns needsAuth: true with timeout message when OAuth flow times out", async () => { - vi.useFakeTimers() - try { - mockSupportsOAuth.mockReturnValue(true) - // authenticate never resolves — simulates user walking away - mockAuthenticate.mockReturnValue(new Promise(() => {})) - mockProbeTools.mockResolvedValueOnce({ tools: [], needsAuth: true }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const probePromise = runMcp(["probe", "--json"]) - // Advance past the 60s OAuth timeout - await vi.advanceTimersByTimeAsync(60_000) - const code = await probePromise - - expect(code).toBe(0) - expect(out.json).toEqual({ tools: [], needsAuth: true, error: "OAuth flow timed out" }) - } finally { - vi.useRealTimers() - } + expect(await runMcp(["probe", "--json"])).toBe(1) + expect(output.json.error).toBe("connection refused") }) - it("returns exit code 1 when non-OAuth server times out", async () => { + it("aborts a hanging stdio probe after fifteen seconds", async () => { vi.useFakeTimers() - try { - mockSupportsOAuth.mockReturnValue(false) - // probeTools never resolves — simulates server hanging - mockProbeTools.mockReturnValue(new Promise(() => {})) - - mockStdin(probeInput(STDIO_SERVER)) - const out = captureStdout() - - const probePromise = runMcp(["probe", "--json"]) - // Advance past the 15s non-OAuth timeout - await vi.advanceTimersByTimeAsync(15_000) - const code = await probePromise - - expect(code).toBe(1) - expect(out.json.needsAuth).toBe(false) - expect(out.json.error).toContain("timed out") - } finally { - vi.useRealTimers() - } - }) - - // --- error handling --------------------------------------------------- - - it("returns exit code 1 with error JSON when probe throws", async () => { - mockProbeTools.mockRejectedValue(new Error("connection refused")) - mockStdin(probeInput(STDIO_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json).toEqual({ - tools: [], - needsAuth: false, - error: "connection refused", - }) - expect(mockCloseAll).toHaveBeenCalledTimes(1) - }) - - it("returns exit code 1 when stdin is not valid JSON", async () => { - mockStdin("not json {{{") - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json.error).toContain("Failed to parse JSON") - }) - - // --- cleanup ---------------------------------------------------------- - - it("always calls closeAll in the finally block", async () => { - mockProbeTools.mockRejectedValue(new Error("boom")) - mockStdin(probeInput(STDIO_SERVER)) - - await runMcp(["probe", "--json"]) - expect(mockCloseAll).toHaveBeenCalledTimes(1) - }) - - // --- stdout flush ------------------------------------------------------ - - it("awaits the stdout write callback before resolving (large payload >64KB is not truncated)", async () => { - // Generate a payload well above the ~64KB pipe buffer: 5000 tool objects - // with long names. If emitResult didn't await the write callback, a - // subsequent process.exit() could truncate the output mid-stream. - const bigTools = Array.from({ length: 5000 }, (_, i) => ({ - name: `tool_${String(i).padStart(6, "0")}_${"x".repeat(30)}`, - description: "y".repeat(30), - })) - mockProbeTools.mockResolvedValue({ tools: bigTools, needsAuth: false }) - - mockStdin(probeInput(STDIO_SERVER)) - const out = captureStdout() - const code = await runMcp(["probe", "--json"]) - - expect(code).toBe(0) - // Parse the captured stdout and verify the full payload survived. - const parsed = out.json as { - tools: Array<{ name: string; description: string }> - needsAuth: boolean - error: string | null - } - expect(parsed.tools).toHaveLength(5000) - expect(parsed.tools[0].name).toBe(bigTools[0].name) - expect(parsed.tools[4999].name).toBe(bigTools[4999].name) - expect(parsed.tools[4999].description).toBe(bigTools[4999].description) - expect(parsed.needsAuth).toBe(false) - expect(parsed.error).toBeNull() - }) - - // --- inline-error routing (probeTools no longer throws) ------------ - - // After the merge, probeTools returns connect/tool errors inline via - // `result.error` instead of throwing. The CLI must still surface those as - // exit-1 failures so the UI can display them (preserving the pre-unification - // contract where a connection failure was a thrown error). - it("surfaces a probeTools inline error as exit code 1 without throwing", async () => { - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: "Connection refused" }) - mockStdin(probeInput(STDIO_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(1) - expect(out.json).toEqual({ tools: [], needsAuth: false, error: "Connection refused" }) - expect(mockCloseAll).toHaveBeenCalledTimes(1) - }) - - // --- URL mismatch guard (OAuth token store key) ---------------------- - - it("uses the real name and does not clean up when an auth entry with a matching URL exists", async () => { - // Existing entry stored for the same URL → repeat probe of an authorized - // server. The guard should reuse the real name so stored tokens are found - // and OAuth is skipped. - mockGetAuthEntry.mockReturnValue({ serverUrl: OAUTH_SERVER.url, tokens: { accessToken: "tok" } }) - mockSupportsOAuth.mockReturnValue(true) - mockProbeTools.mockResolvedValue({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - // Real name used for both probe and (not invoked) auth. - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockAuthenticate).not.toHaveBeenCalled() - // No cleanup — real credentials must survive the probe. - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() - }) - - it("uses a throwaway name and cleans it up when an auth entry with a different URL exists", async () => { - // The user edited the server's URL but kept the name. A stored entry - // exists under the real name for a DIFFERENT URL — probing with the real - // name would overwrite the real server's tokens. - mockGetAuthEntry.mockReturnValue({ serverUrl: "https://old.example.com/mcp", tokens: { accessToken: "tok" } }) - mockSupportsOAuth.mockReturnValue(true) - // First probe needs auth; after auth, retry returns tools. - mockProbeTools - .mockResolvedValueOnce({ tools: [], needsAuth: true }) - .mockResolvedValueOnce({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - - // Both probeTools calls and authenticate received the throwaway name. - const firstCallArg = mockProbeTools.mock.calls[0]?.[0] - const secondCallArg = mockProbeTools.mock.calls[1]?.[0] - const authNameArg = mockAuthenticate.mock.calls[0]?.[0] - expect(firstCallArg).toMatch(/^__probe_[0-9a-f-]{36}$/) - expect(secondCallArg).toBe(firstCallArg) - expect(authNameArg).toBe(firstCallArg) - // The real name was never used as the token-store key. - expect(mockProbeTools).not.toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockAuthenticate).not.toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER.url, OAUTH_SERVER) - // Throwaway credentials cleaned up in the finally block. - expect(mockRemoveAuthEntry).toHaveBeenCalledTimes(1) - expect(mockRemoveAuthEntry).toHaveBeenCalledWith(firstCallArg) - }) - - it("uses the real name and does not clean up when no auth entry exists (new server)", async () => { - // No stored entry → new server. The guard should use the real name so the - // first probe persists tokens under it and a repeat probe finds them. - mockGetAuthEntry.mockReturnValue(undefined) - mockSupportsOAuth.mockReturnValue(true) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() - }) - - it("does not overwrite the real server's tokens when probing an edited URL (entry for a different URL)", async () => { - // Scenario: editing a server's URL and probing it must NOT overwrite the - // real server's stored tokens. A throwaway name isolates the probe's - // credentials, and removeAuthEntry wipes them afterwards. - const realUrl = "https://old.example.com/mcp" - mockGetAuthEntry.mockReturnValue({ serverUrl: realUrl, tokens: { accessToken: "real-tok" } }) - mockSupportsOAuth.mockReturnValue(true) - // Probe needs auth, OAuth succeeds, retry returns tools. - mockProbeTools - .mockResolvedValueOnce({ tools: [], needsAuth: true }) - .mockResolvedValueOnce({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - - // The real name (SERVER_NAME) was never passed as the token-store key. - const probeNames = mockProbeTools.mock.calls.map((c) => c[0]) - expect(probeNames).not.toContain(SERVER_NAME) - expect(mockAuthenticate).not.toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER.url, OAUTH_SERVER) - // Throwaway name was cleaned up exactly once. - expect(mockRemoveAuthEntry).toHaveBeenCalledTimes(1) - expect(mockRemoveAuthEntry.mock.calls[0]?.[0]).not.toBe(SERVER_NAME) - }) - - it("reuses the real name and skips OAuth on a repeat probe of an unchanged authorized server", async () => { - // A repeat probe of an unchanged, authorized server: the stored entry's - // URL matches, so the real name is used and the first probe finds the - // stored tokens — authenticate() is never called. - mockGetAuthEntry.mockReturnValue({ serverUrl: OAUTH_SERVER.url, tokens: { accessToken: "tok" } }) - mockSupportsOAuth.mockReturnValue(true) - mockProbeTools.mockResolvedValue({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(mockProbeTools).toHaveBeenCalledTimes(1) - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockAuthenticate).not.toHaveBeenCalled() - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() - expect(out.json).toEqual({ - tools: [{ name: "secure_tool", title: undefined, description: undefined }], - needsAuth: false, - error: null, - }) - }) - - it("uses the real name and does not clean up when the entry is from an incomplete OAuth flow", async () => { - // An OAuth flow that was started but never finished leaves an entry - // with only oauthState/codeVerifier — no serverUrl. Treating it as a - // URL mismatch would probe under a throwaway name whose OAuth tokens - // the finally block then deletes, leaving every subsequent probe with - // needsAuth: true. The real name must be reused so the flow can - // complete on the correct entry. - mockGetAuthEntry.mockReturnValue({ oauthState: "state-123" }) - mockSupportsOAuth.mockReturnValue(true) - mockProbeTools.mockResolvedValue({ tools: [{ name: "secure_tool" }], needsAuth: false }) - - mockStdin(probeInput(OAUTH_SERVER)) - const out = captureStdout() - - const code = await runMcp(["probe", "--json"]) - expect(code).toBe(0) - expect(out.json.error).toBeNull() - expect(mockProbeTools).toHaveBeenCalledWith(SERVER_NAME, OAUTH_SERVER) - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() + probeTools.mockImplementation( + (_name, _server, options: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true }) + }), + ) + mockStdin(input()) + const output = captureStdout() + const result = runMcp(["probe", "--json"]) + await vi.advanceTimersByTimeAsync(15_000) + + expect(await result).toBe(1) + expect(output.json.error).toContain("timed out after 15 seconds") + }) + + it("rejects invalid JSON", async () => { + mockStdin("not json") + const output = captureStdout() + expect(await runMcp(["probe", "--json"])).toBe(1) + expect(output.json.error).toContain("Failed to parse JSON") }) }) diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 363bf4375..b9e668753 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,10 +1,9 @@ /** * `kimchi mcp probe` — transient MCP server tool discovery. * - * Reads a `{ name: string, server: ServerEntry }` JSON from stdin, - * connects to the server using a throwaway {@link McpServerManager} - * connection, calls `tools/list`, prints the result as JSON to stdout, - * and exits. + * Reads a `{ name: string, server: ServerEntry }` JSON from stdin, connects + * through an isolated upstream adapter instance, calls `tools/list`, prints + * the result as JSON to stdout, and exits. * * The `name` field selects the OAuth token-store key. Before any OAuth * write, the probe checks whether an auth entry already exists under that @@ -26,21 +25,11 @@ * Exit: 0 on success (including needs-auth), 1 on error */ +import type { ServerEntry } from "pi-mcp-adapter/types" import { Type } from "typebox" import { Value } from "typebox/value" -import { removeAuthEntry } from "../extensions/mcp-adapter/mcp-auth.js" -import { authenticate, supportsOAuth } from "../extensions/mcp-adapter/mcp-auth-flow.js" -import { resolveProbeName } from "../extensions/mcp-adapter/resolve-probe-name.js" -import { McpServerManager } from "../extensions/mcp-adapter/server-manager.js" -import type { McpTool, ServerEntry } from "../extensions/mcp-adapter/types.js" - -type ProbeTool = Pick - -interface ProbeResult { - tools: ProbeTool[] - needsAuth: boolean - error: string | null -} +import { verifyMcpKeyringRuntime } from "../extensions/mcp/keyring-require-bridge.js" +import { type ProbeResult, UpstreamMcpProbe } from "../extensions/mcp/probe.js" /** * TypeBox schema for the probe stdin input. @@ -69,13 +58,34 @@ export async function runMcp(args: string[]): Promise { if (subcommand === "probe") { return runProbe(args.slice(1)) } + if (subcommand === "keyring-check") { + return runKeyringCheck(args.slice(1)) + } // Future: `kimchi mcp list`, `kimchi mcp status`, etc. process.stderr.write(`Unknown mcp subcommand: ${subcommand ?? "(none)"}\n`) process.stderr.write("Usage: kimchi mcp probe --json < server-config.json\n") + process.stderr.write(" kimchi mcp keyring-check --json\n") return 1 } +async function runKeyringCheck(args: string[]): Promise { + if (!args.includes("--json")) return emitError("--json flag is required", null) + try { + return emitJson({ ok: true, ...verifyMcpKeyringRuntime() }, 0) + } catch (err) { + return emitJson( + { + ok: false, + platform: process.platform, + arch: process.arch, + error: err instanceof Error ? err.message : String(err), + }, + 1, + ) + } +} + async function runProbe(args: string[]): Promise { const json = args.includes("--json") @@ -114,76 +124,26 @@ async function runProbe(args: string[]): Promise { return await emitError("Server config must have either 'command' or 'url'", null) } - // Non-OAuth servers: 15 second timeout. - // OAuth-capable servers that need auth: 60 second timeout (browser redirect + callback). - const isOAuthCapable = supportsOAuth(definition) - const timeoutMs = isOAuthCapable ? 60_000 : 15_000 - const timeoutMsg = isOAuthCapable + const timeoutMs = definition.url ? 60_000 : 15_000 + const timeoutMsg = definition.url ? "Probe timed out after 60 seconds (including OAuth flow)" : "Probe timed out after 15 seconds" - - // Guard against URL mismatch in the OAuth token store. The token store is - // keyed by server name, so probing a server whose URL was edited (but whose - // name stayed the same) would otherwise overwrite the real server's stored - // credentials. See {@link resolveProbeName} for the decision rules. - const probeName = resolveProbeName(name, definition) - const usedThrowaway = probeName !== name - - const manager = new McpServerManager() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error(timeoutMsg)), timeoutMs) + const probe = new UpstreamMcpProbe() try { - // Use `probeName` (the real name or a throwaway) so the token-store key - // is shared between the initial probe, authenticate(), and the retry. - // A repeat probe of an already-authorized server finds stored OAuth - // tokens on the first call and skips the browser flow entirely. - let result = await withTimeout(manager.probeTools(probeName, definition), timeoutMs, timeoutMsg) - // probeTools returns errors inline via `result.error` (it does not throw - // on connect/tools failures) — surface those as exit-1 failures so the UI - // can display them, preserving the pre-unification contract. - if (result.error) { - return await emitError(result.error, null) - } - - // If the server needs auth and OAuth is supported, attempt the full - // OAuth flow (browser redirect + callback) then retry the probe. - if (result.needsAuth && isOAuthCapable && definition.url) { - try { - await withTimeout(authenticate(probeName, definition.url, definition), timeoutMs, "OAuth flow timed out") - } catch (err) { - // Auth failed or timed out — return needsAuth: true with the real - // error message so the UI can display it. Exit 0 because the probe - // ran successfully; the user just needs to authorize. - const message = err instanceof Error ? err.message : String(err) - return await emitResult({ tools: [], needsAuth: true, error: message }, 0) - } - - // Retry probe after successful auth, reusing the same name so the - // token store has the credentials. - result = await withTimeout(manager.probeTools(probeName, definition), timeoutMs, timeoutMsg) - if (result.error) { - return await emitError(result.error, null) - } - } - - const output: ProbeResult = { - tools: result.tools.map((t) => ({ - name: t.name, - title: t.title, - description: t.description, - })), - needsAuth: result.needsAuth, - error: null, - } - return await emitResult(output, 0) + const result = await probe.probeTools(name, definition, { + authenticate: true, + cwd: process.cwd(), + signal: controller.signal, + }) + if (controller.signal.aborted) throw controller.signal.reason + if (result.error && !result.needsAuth) return await emitError(result.error, null) + return await emitResult(result, 0) } catch (err) { return await emitError(err instanceof Error ? err.message : String(err), null) } finally { - await manager.closeAll().catch(() => {}) - // Clean up throwaway probe credentials so the token store never - // accumulates `__probe_*` entries. Never called for the real name — - // real credentials must survive the probe. - if (usedThrowaway) { - removeAuthEntry(probeName) - } + clearTimeout(timer) } } @@ -238,20 +198,11 @@ function readStdin(): Promise { }) } -function withTimeout(promise: Promise, ms: number, message: string): Promise { - let timer: ReturnType | undefined - const timerPromise = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(message)), ms) - }) - // Attach a no-op catch to the original promise so that if it rejects - // after the timer wins the race, the rejection is not unhandled. - promise.catch(() => {}) - return Promise.race([promise, timerPromise]).finally(() => { - if (timer) clearTimeout(timer) - }) +function emitResult(result: ProbeResult, exitCode: number): Promise { + return emitJson(result, exitCode) } -function emitResult(result: ProbeResult, exitCode: number): Promise { +function emitJson(result: unknown, exitCode: number): Promise { return new Promise((resolve, reject) => { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`, (err) => { if (err) { diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 7195035c6..7e5216214 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -33,7 +33,7 @@ export const COMMANDS: CommandDefinition[] = [ { name: "update", summary: "Check for and install Kimchi/package updates", run: runUpdate }, { name: "config", summary: "Inspect or change kimchi config (e.g. telemetry)", run: runConfig }, { name: "resources", summary: "Enable or disable Kimchi hooks, tools, extensions, and plugins", run: runResources }, - { name: "mcp", summary: "MCP server utilities (probe, ...)", run: runMcp }, + { name: "mcp", summary: "MCP server utilities (probe and diagnostics)", run: runMcp }, { name: "version", summary: "Print the kimchi version", run: runVersion }, ] diff --git a/src/extensions/__mocks__/extension-api.ts b/src/extensions/__mocks__/extension-api.ts index 2e9facd93..fcb37c48e 100644 --- a/src/extensions/__mocks__/extension-api.ts +++ b/src/extensions/__mocks__/extension-api.ts @@ -1,5 +1,6 @@ -import type { ExtensionAPI, ExtensionHandler } from "@earendil-works/pi-coding-agent" +import type { ExtensionAPI, ExtensionHandler, ToolDefinition } from "@earendil-works/pi-coding-agent" import { vi } from "vitest" +import { createMiniEventBus } from "./mini-event-bus.js" type RegisteredHandler = ExtensionHandler @@ -11,6 +12,10 @@ export function createExtensionApi(): { appendEntry: ReturnType> setModel: ReturnType> emitEvent: ReturnType + registerTool: ReturnType> + setActiveTools: ReturnType> + getRegisteredTools(): ToolDefinition[] + getActiveToolNames(): string[] getAppendedEntries(type: string): T[] } { const handlers = new Map() @@ -26,18 +31,32 @@ export function createExtensionApi(): { }) const setModel = vi.fn(async () => true) const registerCommand = vi.fn() - const registerTool = vi.fn() - const emitEvent = vi.fn() + const registeredTools = new Map() + const activeToolNames = new Set() + const registerTool = vi.fn((tool: ToolDefinition) => { + registeredTools.set(tool.name, tool) + activeToolNames.add(tool.name) + }) as ReturnType> + const setActiveTools = vi.fn((toolNames: string[]) => { + activeToolNames.clear() + for (const name of toolNames) activeToolNames.add(name) + }) + const getActiveTools = vi.fn(() => [...activeToolNames]) + const getAllTools = vi.fn(() => [...registeredTools.values()]) + const { events, emit } = createMiniEventBus() return { api: { on, registerCommand, registerTool, + getAllTools, + getActiveTools, + setActiveTools, sendMessage, appendEntry, setModel, - events: { emit: emitEvent }, + events, } as unknown as ExtensionAPI, getHandler(event: string): ExtensionHandler { const handler = handlers.get(event)?.[0] @@ -49,7 +68,11 @@ export function createExtensionApi(): { }, sendMessage, setModel, - emitEvent, + emitEvent: emit, + registerTool, + setActiveTools, + getRegisteredTools: () => [...registeredTools.values()], + getActiveToolNames: () => [...activeToolNames], appendEntry: appendEntry as unknown as ReturnType>, getAppendedEntries(type: string): T[] { return appendedEntries.filter((entry) => entry.type === type).map((entry) => entry.payload as T) diff --git a/src/extensions/context-budget-tools.ts b/src/extensions/context-budget-tools.ts index 77d801c37..f44f06bd2 100644 --- a/src/extensions/context-budget-tools.ts +++ b/src/extensions/context-budget-tools.ts @@ -105,6 +105,7 @@ function createCaptureApi(): CaptureApi { { get: (_target, prop) => { if (prop === "registerTool") return (tool: AnyToolDef) => tools.set(tool.name, tool) + if (prop === "getFlag") return () => undefined if (prop === "on") { return (event: string, handler: (payload: unknown) => unknown) => { const list = handlers.get(event) ?? [] @@ -149,7 +150,7 @@ export const EXTENSION_SOURCES: ExtensionSource[] = [ // With zero configured MCP servers, the adapter registers no tools at all. // context-budget.test.ts mocks this state, so this module contributes // nothing to the canonical measurement. - { module: "./mcp-adapter/index.js", source: "mcp-adapter" }, + { module: "./mcp/index.js", source: "mcp-adapter" }, { module: "./tags.js", source: "tags(set_phase)" }, { module: "./claude-code-skills/index.js", source: "claude-code-skills(skill)" }, ] @@ -192,7 +193,10 @@ async function measureExtensionTools( if (typeof imported.default !== "function") throw new Error("no default factory export") const { api, tools, fire } = createCaptureApi() await imported.default(api) - await fire("session_start") + // The upstream MCP adapter registers its zero-server tool surface at + // extension load. Starting its async runtime would require a complete + // session context and cannot add tools for this fixture. + if (source !== "mcp-adapter") await fire("session_start") for (const tool of tools.values()) out.set(tool.name, entry(`extension:${source}`, tool)) } catch (error) { exclusions.push({ diff --git a/src/extensions/context-budget.test.ts b/src/extensions/context-budget.test.ts index 0e4c1066b..d9d083c6c 100644 --- a/src/extensions/context-budget.test.ts +++ b/src/extensions/context-budget.test.ts @@ -34,20 +34,11 @@ import { measureCanonicalToolSurface } from "./context-budget-tools.js" // surface must not depend on the ambient machine's mcp.json. The metadata // cache is stubbed too — with zero servers the factory would otherwise purge // and rewrite the developer machine's real mcp-cache.json. -vi.mock("./mcp-adapter/config.js", async (importOriginal) => { - const original = await importOriginal() +vi.mock("./mcp/config.js", async (importOriginal) => { + const original = await importOriginal() return { ...original, - loadMcpConfig: () => ({ config: { mcpServers: {} }, warnings: [] }), - } -}) -vi.mock("./mcp-adapter/metadata-cache.js", async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - loadMetadataCache: () => undefined, - overwriteMetadataCache: () => {}, - flushMetadataCache: () => {}, + loadKimchiMcpConfig: () => ({ config: { mcpServers: {} }, warnings: [] }), } }) diff --git a/src/extensions/ferment/resume.test.ts b/src/extensions/ferment/resume.test.ts index 87270dfde..2c922b7fc 100644 --- a/src/extensions/ferment/resume.test.ts +++ b/src/extensions/ferment/resume.test.ts @@ -20,6 +20,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { FermentEventStore } from "../../ferment/event-store.js" import { clearFermentCache } from "../../ferment/store.js" +import { createMiniEventBus } from "../__mocks__/mini-event-bus.js" import { FERMENT_EVENTS } from "./domain-events.js" import { maybeInjectScopingStopNudge, resetAllScopingStopNudgeCounts } from "./nudge.js" import { @@ -70,7 +71,7 @@ function createHarness() { getAllTools: vi.fn(() => []), setActiveTools: vi.fn(), getFlag: vi.fn(() => undefined), - events: { emit: vi.fn() }, + events: createMiniEventBus().events, } as unknown as ExtensionAPI return { fermentsDir, eventStorage, runtime, pi, sentMessages } diff --git a/src/extensions/ferment/tool-scope.test.ts b/src/extensions/ferment/tool-scope.test.ts index f26e53f6e..93ab8d561 100644 --- a/src/extensions/ferment/tool-scope.test.ts +++ b/src/extensions/ferment/tool-scope.test.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { describe, expect, it, vi } from "vitest" import type { Ferment, Phase } from "../../ferment/types.js" +import { createMiniEventBus } from "../__mocks__/mini-event-bus.js" import type { PendingPlanReview } from "./plan-review.js" import type { FermentRuntime } from "./runtime.js" import { FERMENT_TOOL_NAMES } from "./tool-names.js" @@ -20,6 +21,7 @@ function createPi(initialActive: string[], allTools: string[]) { active = names }), on: vi.fn(), + events: createMiniEventBus().events, } as unknown as ExtensionAPI return pi } diff --git a/src/extensions/mcp-adapter/CHANGELOG.md b/src/extensions/mcp-adapter/CHANGELOG.md deleted file mode 100644 index 1c989c4e5..000000000 --- a/src/extensions/mcp-adapter/CHANGELOG.md +++ /dev/null @@ -1,264 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [2.4.0] - 2026-04-13 - -### Added -- `settings.disableProxyTool` to hide the `mcp` proxy tool once configured direct tools are fully available from cache. Thanks @tanavamsikrishna for PR #41. -- Per-server `excludeTools` to hide specific MCP tools/resources by original or prefixed name across direct tools, proxy discovery, and the `/mcp` panel. Thanks @ahmadaccino for issue #36. -- `settings.autoAuth` to optionally trigger OAuth automatically from proxy/direct tool usage, then rerun the original blocked connect/tool operation once after authentication succeeds. Thanks @unimonkiez for issue #34. - -### Fixed -- Regenerated `package-lock.json` so the root lockfile metadata matches `package.json` again, including the declared `open`, `@types/bun`, `@types/open`, and `tsx` entries. -- Kept the `mcp` proxy tool available as a first-session fallback when configured direct tools are still missing cache metadata, avoiding no-tool startup gaps. - -## [2.3.5] - 2026-04-13 - -### Fixed -- Session lifecycle now always tears down OAuth callback state on restart and shutdown, preventing callback-server leaks across session transitions. -- OAuth callback server now calls `unref()` after successful bind so it no longer keeps sub-agent processes alive by itself. -- Strict OAuth port mode now rebinds to the configured callback port when safe, while refusing to switch ports when authorizations are still pending. -- Added focused lifecycle/callback-server regression coverage for teardown, `unref()`, strict rebinding, and pending-auth guardrails. -- Thanks @blai for the investigation and PR #43 that surfaced the sub-agent hang/root lifecycle issues. - -## [2.3.4] - 2026-04-12 - -### Fixed -- OAuth callback handling now allows dynamic-registration flows to fall back to a free local port when the preferred callback port is busy, while keeping pre-registered clients on their exact configured redirect port. -- Documented the new callback-port behavior and added focused auth-flow regression coverage. - -## [2.3.3] - 2026-04-12 - -### Fixed -- Remove the blank footer status line when no MCP servers are configured by clearing the MCP status entry instead of setting it to an empty string. Thanks @HazAT for PR #27. - -## [2.3.2] - 2026-04-11 - -### Added -- Optional `oauth.grantType: "client_credentials"` for non-interactive machine-to-machine OAuth on HTTP MCP servers. - -### Fixed -- `/mcp-auth ` now handles `client_credentials` without browser/callback flow. -- MCP panel status no longer marks `client_credentials` servers as auth-blocked solely because no stored user tokens exist yet. -- OAuth auth flow now closes temporary transports consistently on success, refresh, and auth removal paths. -- Init paths now preserve debug-level context for previously silent direct-tool bootstrap and lazy-connect failures. - -## [2.3.1] - 2026-04-11 - -### Fixed -- Removed `/mcp-auth-callback`. OAuth auth now hard-cuts to `/mcp-auth ` only. - -## [2.3.0] - 2026-04-11 - -### Added -- OAuth callback server initialization on session start and a deprecated `/mcp-auth-callback` command that now points users to `/mcp-auth `. - -### Fixed -- OAuth `needs-auth` handling across `/mcp` status/panel, `mcp({ connect })`, `mcp({ tool })`, reconnect flow, lazy/direct tool execution, and startup bootstrap. -- OAuth callback cleanup now cancels by stored OAuth state and closes pending transports on failure/cancel paths. -- Callback server now fails fast when the OAuth callback port is occupied by another process. -- Package manifest test now ignores root `*.test.ts` files. - -## [2.2.2] - 2026-04-03 - -### Fixed -- Session lifecycle teardown now handles repeated `session_start` transitions safely and prevents stale async init results from replacing newer state. -- Shutdown now still runs `gracefulShutdown()` even if metadata cache flushing throws, avoiding leaked MCP processes. -- Proxy/direct tool init error paths now preserve and surface underlying error messages instead of returning generic failures. -- Invalid `mcp` tool `args` now fail by throwing with parse/type context instead of returning non-failing tool payloads. -- Added focused lifecycle regressions tests for stale init cleanup and init-error visibility. - -## [2.2.1] - 2026-03-23 - -### Fixed -- Added `promptSnippet` to MCP proxy tool and direct MCP tools so they appear in the system prompt's Available tools section (required since pi 0.59.0) - -## [2.2.0] - 2026-03-16 - -### Added -- **MCP UI Integration** - Support for the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. Tools with `_meta.ui.resourceUri` open interactive UIs: - - Bidirectional AppBridge communication (tool calls, messages, context updates) - - Works with both stdio and HTTP MCP servers - - User consent management for tool calls from UI (configurable: never/once-per-server/always) - - Keyboard shortcuts: Cmd/Ctrl+Enter to complete, Escape to cancel - - UI prompts/intents trigger agent turns via `pi.sendMessage({ triggerTurn: true })` - - `mcp({ action: "ui-messages" })` retrieves accumulated messages from UI sessions - -- **Session reuse** - When the agent calls the same tool while its UI is already open, results push to the existing window instead of replacing it. Per-call stream IDs with independent sequences. Error results scoped to the individual call. - -- **Glimpse integration** - MCP UI opens in a native macOS WKWebView window instead of a browser tab when [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`). Falls back to browser on non-macOS or when unavailable. Override with `MCP_UI_VIEWER=browser` or `MCP_UI_VIEWER=glimpse`. - -- **Logger module** (`logger.ts`) - Centralized logging with levels (debug/info/warn/error), contextual child loggers, and `MCP_UI_DEBUG=1` env var. - -- **Error types** (`errors.ts`) - Structured errors with recovery hints: `ResourceFetchError`, `ResourceParseError`, `BridgeConnectionError`, `ConsentError`, `SessionError`, `ServerError`, and `wrapError()` helper. - -- **Test suite** - 178 tests covering consent manager, UI resource handler, host HTML template, logger, and error types. - -- **Interactive visualizer example** (`examples/interactive-visualizer`) - Minimal MCP server demonstrating charts (bar/line/pie/doughnut via Chart.js), bidirectional messaging, and streaming. - -### Fixed -- Host-iframe timing: bridge now connects before loading iframe, fixing `ui/initialize` timeout on first load -- All internal `log.info` calls demoted to `log.debug` to eliminate stdout noise during normal use - -### Technical Notes -- Uses local minified AppBridge bundle (408KB) to avoid CDN Zod bundling issues -- Serves app HTML from `/ui-app` endpoint instead of blob URLs to avoid iframe issues -- SSE for real-time tool result streaming to browser - -## [2.1.2] - 2026-02-03 - -### Changed -- Added demo video and `pi.video` field to package.json for pi package browser. - -## [2.1.0] - 2026-02-02 - -### Added -- **Direct tool registration** - Promote specific MCP tools to first-class Pi tools via `directTools` config (per-server or global). Direct tools appear in the agent's tool list alongside builtins, so the LLM uses them without needing to search through the proxy first. Registers from cached metadata at startup — no server connections needed. -- **`/mcp` interactive panel** - New TUI overlay replacing the text-based status dump. Shows server connection status, tool lists with direct/proxy toggles, token cost estimates, inline reconnect, and auth notices. Changes written to config on save. -- **Auto-enriched proxy description** - The `mcp` proxy tool description now includes server names and tool counts from the metadata cache, so the LLM knows what's available without a search call (~30 extra tokens). -- **`MCP_DIRECT_TOOLS` env var** - Subagent processes receive their direct tool configuration via environment variable, keeping subagents lean by default. -- **First-run bootstrap** - Servers with `directTools` configured but no cache entry are connected during `session_start` to populate the cache. Direct tools become available after restart. -- Config provenance tracking for correct write-back to user/project/import sources -- Builtin name collision guard (skips direct tools that would shadow `read`, `write`, etc.) -- Cross-server name deduplication for `prefix: "none"` and `prefix: "short"` modes - -## [2.0.1] - 2026-02-01 - -### Fixed -- Adapt execute signature to pi v0.51.0: add signal, onUpdate, ctx parameters - -## [2.0.0] - 2026-01-29 - -### Changed -- **BREAKING: Lazy startup by default** - All servers now default to `lifecycle: "lazy"` and only connect when a tool call needs them. Previously all servers connected eagerly on session start. Set `lifecycle: "keep-alive"` or `lifecycle: "eager"` to restore the old behavior per-server. -- **Idle timeout** - Connected servers are automatically disconnected after 10 minutes of inactivity (configurable via `settings.idleTimeout` or per-server `idleTimeout`). Cached metadata keeps search/list working after disconnect. Set `idleTimeout: 0` to disable. -- `/mcp reconnect` accepts an optional server name to connect or reconnect a single server - -### Added -- **Metadata cache** - Tool and resource metadata persisted to `~/.pi/agent/mcp-cache.json`. Enables search/list/describe without live connections. Per-server config hashing with 7-day staleness. Multi-session safe via read-merge-write with per-process tmp files. -- **npx binary resolution** - Resolves npx package binaries to direct paths, eliminating the ~143 MB npm parent process per server. Persistent cache at `~/.pi/agent/mcp-npx-cache.json` with 24h TTL. -- **`mcp({ connect: "server-name" })` mode** - Explicitly trigger connection and metadata refresh for a named server -- **Failure backoff** - Servers that fail to connect are skipped for 60 seconds to avoid repeated connection storms -- **In-flight tracking** - Active tool calls prevent idle timeout from shutting down a server mid-request -- **Prefix-match fallback** - Tool calls with unrecognized names try to match a server prefix and lazy-connect the matching server -- Lifecycle options: `lazy` (default), `eager` (connect at startup, no auto-reconnect), `keep-alive` (unchanged) -- Per-server `idleTimeout` override and global `settings.idleTimeout` -- First-run bootstrap: connects all servers on first session to populate the cache - -### Fixed -- Connection close race condition: concurrent close + connect no longer orphans server processes -- **Fuzzy tool name matching** - Hyphens and underscores are treated as equivalent during tool lookup. MCP tools like `resolve-library-id` are now found when called as `resolve_library_id`, which LLMs naturally guess since the prefix separator is `_`. -- **Better "tool not found" errors** - When a server is identified (via prefix match or override) but the tool isn't found, the error now lists that server's available tools so the LLM can self-correct immediately instead of needing a separate list call - -## [1.6.0] - 2026-01-29 - -### Added -- **Unified pi tool search** - `mcp({ search: "..." })` now searches both MCP tools and Pi tools (from installed extensions) -- Pi tools appear first in results with `[pi tool]` prefix -- Details object includes `server: "pi"` for pi tools -- Banner image for README - -## [1.5.1] - 2026-01-26 - -### Changed -- Added `pi-package` keyword for npm discoverability (pi v0.50.0 package system) - -## [1.5.0] - 2026-01-22 - -### Changed -- **BREAKING: `args` parameter is now a JSON string** - The `args` parameter which previously accepted an object now accepts a JSON string. This change was required for compatibility with Claude's Vertex AI API (`google-antigravity` provider) which rejects `patternProperties` in JSON schemas (generated by `Type.Record()`). - -### Added -- **Type validation for args** - Parsed args are now validated to ensure they're a JSON object (not null, array, or primitive). Clear error messages for invalid input. -- **`isError: true` on error responses** - JSON parse errors and type validation errors now properly set `isError: true` to indicate failure to the LLM. - -### Migration -```typescript -// Before (1.4.x) -mcp({ tool: "my_tool", args: { key: "value" } }) - -// After (1.5.0) -mcp({ tool: "my_tool", args: '{"key": "value"}' }) -``` - -## [1.4.1] - 2026-01-19 - -### Changed - -- Status bar shows server count instead of tool count ("MCP: 5 servers") - -## [1.4.0] - 2026-01-19 - -### Changed - -- **Non-blocking startup** - Pi starts immediately, MCP servers connect in background. First MCP call waits only if init isn't done yet. - -### Fixed - -- Tool metadata now includes `inputSchema` after `/mcp reconnect` (was missing, breaking describe and error hints) - -## [1.3.0] - 2026-01-19 - -### Changed - -- **Parallel server connections** - All MCP servers now connect in parallel on startup instead of sequentially, significantly faster with many servers - -## [1.2.2] - 2026-01-19 - -### Fixed - -- Installer now downloads from `main` branch (renamed from `master`) - -## [1.2.1] - 2026-01-19 - -### Added - -- **npx installer** - Run `npx pi-mcp-adapter` to install (downloads files, installs deps, configures settings.json) - -## [1.1.0] - 2026-01-19 - -### Changed - -- **Search includes schemas by default** - Search results now include parameter schemas, reducing tool calls needed (search + call instead of search + describe + call) -- **Space-separated search terms match as OR** - `"navigate screenshot"` finds tools matching either word (like most search engines) -- **Suppress server stderr by default** - MCP server logs no longer clutter terminal on startup -- Use `includeSchemas: false` for compact output without schemas -- Use `debug: true` per-server to show stderr when troubleshooting - -## [1.0.0] - 2026-01-19 - -### Added - -- **Single unified `mcp` tool** with token-efficient architecture (~200 tokens vs ~15,000 for individual tools) -- **Five operation modes:** - - `mcp({})` - Show server status - - `mcp({ server: "name" })` - List tools from a server - - `mcp({ search: "query" })` - Search tools by name/description - - `mcp({ describe: "tool_name" })` - Show tool details and parameter schema - - `mcp({ tool: "name", args: {...} })` - Call a tool -- **Stdio transport** for local MCP servers (command + args) -- **HTTP transport** with automatic fallback (StreamableHTTP → SSE) -- **Config imports** from Cursor, Claude Code, Claude Desktop, VS Code, Windsurf, Codex -- **Resource tools** - MCP resources exposed as callable tools -- **OAuth support** - Token file-based authentication -- **Bearer token auth** - Static or environment variable tokens -- **Keep-alive connections** with automatic health checks and reconnection -- **Schema on-demand** - Parameter schemas shown in `describe` mode and error responses -- **Commands:** - - `/mcp` or `/mcp status` - Show server status - - `/mcp tools` - List all tools - - `/mcp reconnect` - Force reconnect all servers - - `/mcp-auth ` - Show OAuth setup instructions - -### Architecture - -- Tools stored in metadata map, not registered individually with Pi -- MCP server validates arguments (no client-side schema conversion) -- Reconnect callback updates metadata after auto-reconnect -- Human-readable schema formatting for LLM consumption diff --git a/src/extensions/mcp-adapter/LICENSE b/src/extensions/mcp-adapter/LICENSE deleted file mode 100644 index 2389b2cb8..000000000 --- a/src/extensions/mcp-adapter/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Nico Bailon - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/extensions/mcp-adapter/README.md b/src/extensions/mcp-adapter/README.md deleted file mode 100644 index 45183c7fc..000000000 --- a/src/extensions/mcp-adapter/README.md +++ /dev/null @@ -1,316 +0,0 @@ -

- pi-mcp-adapter -

- -# Pi MCP Adapter - -Use MCP servers with [Pi](https://github.com/badlogic/pi-mono/) without burning your context window. - -https://github.com/user-attachments/assets/4b7c66ff-e27e-4639-b195-22c3db406a5a - -## Why This Exists - -Mario wrote about [why you might not need MCP](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/). The problem: tool definitions are verbose. A single MCP server can burn 10k+ tokens, and you're paying that cost whether you use those tools or not. Connect a few servers and you've burned half your context window before the conversation starts. - -His take: skip MCP entirely, write simple CLI tools instead. - -But the MCP ecosystem has useful stuff - databases, browsers, APIs. This adapter gives you access without the bloat. One proxy tool (~200 tokens) instead of hundreds. The agent discovers what it needs on-demand. Servers only start when you actually use them. - -## Install - -```bash -pi install npm:pi-mcp-adapter -``` - -Restart kimchi after installation. - -## Quick Start - -Create `~/.pi/agent/mcp.json`: - -```json -{ - "mcpServers": { - "chrome-devtools": { - "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"] - } - } -} -``` - -Servers are **lazy by default** — they won't connect until you actually call one of their tools. The adapter caches tool metadata so search and describe work without live connections. - -``` -mcp({ search: "screenshot" }) -``` -``` -chrome_devtools_take_screenshot - Take a screenshot of the page or element. - - Parameters: - format (enum: "png", "jpeg", "webp") [default: "png"] - fullPage (boolean) - Full page instead of viewport -``` -``` -mcp({ tool: "chrome_devtools_take_screenshot", args: '{"format": "png"}' }) -``` - -Note: `args` is a JSON string, not an object. - -Two calls instead of 26 tools cluttering the context. - -## Config - -### Server Options - -```json -{ - "mcpServers": { - "my-server": { - "command": "npx", - "args": ["-y", "some-mcp-server"], - "lifecycle": "lazy", - "idleTimeout": 10 - } - } -} -``` - -| Field | Description | -|-------|-------------| -| `command` | Executable for stdio transport | -| `args` | Command arguments | -| `env` | Environment variables (`${VAR}` interpolation) | -| `cwd` | Working directory | -| `url` | HTTP endpoint (StreamableHTTP with SSE fallback) | -| `auth` | `"bearer"` or `"oauth"` | -| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth | -| `bearerToken` / `bearerTokenEnv` | Token or env var name | -| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` | -| `idleTimeout` | Minutes before idle disconnect (overrides global) | -| `exposeResources` | Expose MCP resources as tools (default: true) | -| `directTools` | `true`, `string[]`, or `false` — register tools individually instead of through proxy | -| `excludeTools` | `string[]` of tool names to hide (matches original names like `get_screenshot` and prefixed names like `figma_get_screenshot`) | -| `debug` | Show server stderr (default: false) | - -### Lifecycle Modes - -- **`lazy`** (default) — Don't connect at startup. Connect on first tool call. Disconnect after idle timeout. Cached metadata keeps search/list working without connections. -- **`eager`** — Connect at startup but don't auto-reconnect if the connection drops. No idle timeout by default (set `idleTimeout` explicitly to enable). -- **`keep-alive`** — Connect at startup. Auto-reconnect via health checks. No idle timeout. Use for servers you always need available. - -### Settings - -```json -{ - "settings": { - "toolPrefix": "server", - "idleTimeout": 10 - }, - "mcpServers": { } -} -``` - -| Setting | Description | -|---------|-------------| -| `toolPrefix` | `"server"` (default), `"short"` (strips `-mcp` suffix), or `"none"` | -| `idleTimeout` | Global idle timeout in minutes (default: 10, 0 to disable) | -| `directTools` | Global default for all servers (default: false). Per-server overrides this. | -| `disableProxyTool` | Hide the `mcp` proxy tool once configured direct tools are fully available from cache. | -| `autoAuth` | Auto-run OAuth on `connect`/tool calls when a server needs auth, then retry once (default: false). | - -Per-server `idleTimeout` overrides the global setting. - -### Direct Tools - -By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover tools via search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config. - -Per-server: - -```json -{ - "mcpServers": { - "chrome-devtools": { - "command": "npx", - "args": ["-y", "chrome-devtools-mcp@latest"], - "directTools": true - }, - "github": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "directTools": ["search_repositories", "get_file_contents"] - }, - "huge-server": { - "command": "npx", - "args": ["-y", "mega-mcp@latest"] - } - } -} -``` - -| Value | Behavior | -|-------|----------| -| `true` | Register all tools from this server as individual Pi tools | -| `["tool_a", "tool_b"]` | Register only these tools (use original MCP names) | -| Omitted or `false` | Proxy only (default) | - -To set a global default for all servers: - -```json -{ - "settings": { - "directTools": true - }, - "mcpServers": { - "huge-server": { - "directTools": false - } - } -} -``` - -Per-server `directTools` overrides the global setting. The example above registers direct tools for every server except `huge-server`. - -To exclude specific tools while still using `directTools: true`, add `excludeTools` on the server: - -```json -{ - "mcpServers": { - "figma": { - "url": "http://localhost:3845/mcp", - "directTools": true, - "excludeTools": ["get_figjam", "figma_get_code_connect_map"] - } - } -} -``` - -`excludeTools` filters direct tools, proxy search/list/describe, and the `/mcp` panel view. - -Each direct tool costs ~150-300 tokens in the system prompt (name + description + schema). Good for targeted sets of 5-20 tools. For servers with 75+ tools, stick with the proxy or pick specific tools with a `string[]`. - -Direct tools register from the metadata cache (`~/.pi/agent/mcp-cache.json`), so no server connections are needed at startup. On the first session after adding `directTools` to a new server, the cache won't exist yet — tools fall back to proxy-only and the cache populates in the background. Restart kimchi and they'll be available. To force it: `/mcp reconnect ` then restart. - -**Interactive configuration:** Run `/mcp` to open an interactive panel showing all servers with connection status, tools, and direct/proxy toggles. You can reconnect servers, initiate OAuth, and toggle tools between direct and proxy — all from one overlay. Changes are written to your config file; restart kimchi to apply. - -**Agent integration:** Agents can request direct MCP tools in their frontmatter with `mcp:server-name` syntax. - -### MCP UI Integration - -MCP servers can ship interactive UIs via the [MCP UI](https://github.com/MCP-UI-Org/mcp-ui) standard. When you call a tool that has a UI resource, the adapter opens it in a native macOS window via [Glimpse](https://github.com/hazat/glimpse) if available, otherwise falls back to the browser. - -**How it works:** - -1. Agent calls a tool like `launch_dashboard` -2. The tool's metadata includes `_meta.ui.resourceUri` pointing to a UI resource -3. pi-mcp-adapter fetches the UI HTML and opens it in an iframe -4. The UI can call MCP tools and send messages back to the agent - -**Native rendering:** On macOS, if [Glimpse](https://github.com/hazat/glimpse) is installed (`pi install npm:glimpseui`), UIs open in a native WKWebView window instead of a browser tab. Set `MCP_UI_VIEWER=browser` to force the browser, or `MCP_UI_VIEWER=glimpse` to require native rendering. - -**Bidirectional communication:** The UI talks back. When it sends a prompt or intent, the message is stored and `triggerTurn()` wakes the agent. The agent retrieves messages via `mcp({ action: "ui-messages" })` and responds, enabling conversational UIs where the app and agent collaborate in real-time. - -**Session reuse:** When the agent calls the same tool again while its UI is already open, the adapter pushes the new result to the existing window instead of replacing it. This enables live updates — the agent can refine a chart, add data, or respond to user input without losing the current view. Different tools still replace the session as before. - -**Message types from UI:** - -| Type | Purpose | -|------|---------| -| `prompt` | User message that triggers an agent response | -| `intent` | Structured action with name + params | -| `notify` | Fire-and-forget notification | -| `message` | Generic message payload | -| (custom) | Any other type forwarded as intent | - -**Retrieving UI messages:** - -``` -mcp({ action: "ui-messages" }) -``` - -Returns accumulated messages from UI sessions. Each message includes `type`, `sessionId`, `serverName`, `toolName`, and `timestamp`. Prompt messages include `prompt`, intent messages include `intent` and `params`. - -**Browser controls:** - -- **Cmd/Ctrl+Enter** — Complete and close -- **Escape** — Cancel and close -- **Done/Cancel buttons** — Same as keyboard shortcuts - -**Technical notes:** - -- Tool consent gates whether UIs can call MCP tools (never/once-per-server/always) -- Works with both stdio and HTTP MCP servers -- Uses a local 408KB AppBridge bundle (MCP SDK + Zod) for browser↔server communication - -### Local Example: Interactive Visualizer - -A minimal MCP UI example at `examples/interactive-visualizer` demonstrating charts, bidirectional messaging, and streaming. From that directory: - -```bash -npm install -npm run build -npm run install-local -``` - -Restart kimchi, then ask the agent to show a chart — it calls `show_chart` and opens the UI in Glimpse (macOS) or the browser. Use `npm run uninstall-local` to remove the MCP entry. - -### Import Existing Configs - -Already have MCP set up elsewhere? Import it: - -```json -{ - "imports": ["cursor", "claude-code", "claude-desktop"], - "mcpServers": { } -} -``` - -Supported: `cursor`, `claude-code`, `claude-desktop`, `vscode`, `windsurf`, `codex` - -### Project Config - -Add `.pi/mcp.json` in a project root for project-specific servers. Project config overrides global and imported servers. - -## Usage - -| Mode | Example | -|------|---------| -| Status | `mcp({ })` | -| List server | `mcp({ server: "name" })` | -| Search | `mcp({ search: "screenshot navigate" })` | -| Describe | `mcp({ describe: "tool_name" })` | -| Call | `mcp({ tool: "...", args: '{"key": "value"}' })` | -| Connect | `mcp({ connect: "server-name" })` | -| UI messages | `mcp({ action: "ui-messages" })` | - -Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd. - -Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_library_id` finds `context7_resolve-library-id`. - -## Commands - -| Command | What it does | -|---------|--------------| -| `/mcp` | Interactive panel (server status, tool toggles, reconnect) | -| `/mcp tools` | List all tools | -| `/mcp reconnect` | Reconnect all servers | -| `/mcp reconnect ` | Connect or reconnect a single server | -| `/mcp-auth ` | OAuth setup | - -If `settings.autoAuth` is `true`, `mcp({ connect: ... })`, `mcp({ tool: ... })`, and direct tool calls will automatically run OAuth when needed and retry once. In non-interactive sessions, browser-based OAuth still requires running `/mcp-auth ` manually. - -## How It Works - -- One `mcp` tool in context (~200 tokens) instead of hundreds -- Servers are lazy by default — they connect on first tool call, not at startup -- Tool metadata is cached to disk so search/list/describe work without live connections -- Idle servers disconnect after 10 minutes (configurable), reconnect automatically on next use -- npx-based servers resolve to direct binary paths, skipping the ~143 MB npm parent process -- MCP server validates arguments, not the adapter -- Keep-alive servers get health checks and auto-reconnect -- Specific tools can be promoted from the proxy to first-class Pi tools via `directTools` config, so the LLM sees them directly instead of having to search - -## Limitations - -- Cross-session server sharing not yet implemented (each Pi session runs its own server processes) diff --git a/src/extensions/mcp-adapter/acp-mcp-convert.test.ts b/src/extensions/mcp-adapter/acp-mcp-convert.test.ts deleted file mode 100644 index 762c4e1f9..000000000 --- a/src/extensions/mcp-adapter/acp-mcp-convert.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import { describe, expect, it } from "vitest" -import { convertAcpMcpServer, convertAcpMcpServers } from "./acp-mcp-convert.js" -import type { ServerEntry } from "./types.js" - -describe("convertAcpMcpServer", () => { - describe("stdio transport", () => { - it("converts a stdio server with command, args, and env", () => { - const server: McpServer = { - name: "filesystem", - command: "/path/to/mcp-server", - args: ["--stdio"], - env: [{ name: "API_KEY", value: "secret123" }], - } - const entry = convertAcpMcpServer(server) - expect(entry).toEqual({ - command: "/path/to/mcp-server", - args: ["--stdio"], - env: { API_KEY: "secret123" }, - }) - }) - - it("converts a stdio server with empty env array (omits env key)", () => { - const server: McpServer = { - name: "simple", - command: "node", - args: ["server.js"], - env: [], - } - const entry = convertAcpMcpServer(server) - expect(entry).toEqual({ - command: "node", - args: ["server.js"], - }) - expect(entry.env).toBeUndefined() - }) - - it("converts a stdio server with multiple env vars", () => { - const server: McpServer = { - name: "multi-env", - command: "run", - args: [], - env: [ - { name: "FOO", value: "bar" }, - { name: "BAZ", value: "qux" }, - ], - } - const entry = convertAcpMcpServer(server) - expect(entry.env).toEqual({ FOO: "bar", BAZ: "qux" }) - }) - }) - - describe("http transport", () => { - it("converts an http server with url and headers", () => { - const server: McpServer = { - name: "api-server", - type: "http", - url: "https://api.example.com/mcp", - headers: [ - { name: "Authorization", value: "Bearer token123" }, - { name: "Content-Type", value: "application/json" }, - ], - } - const entry = convertAcpMcpServer(server) - expect(entry).toEqual({ - url: "https://api.example.com/mcp", - headers: { - Authorization: "Bearer token123", - "Content-Type": "application/json", - }, - }) - }) - - it("converts an http server without headers (omits headers key)", () => { - const server: McpServer = { - name: "no-headers", - type: "http", - url: "https://api.example.com/mcp", - headers: [], - } - const entry = convertAcpMcpServer(server) - expect(entry).toEqual({ - url: "https://api.example.com/mcp", - }) - expect(entry.headers).toBeUndefined() - }) - }) - - describe("sse transport", () => { - it("rejects SSE servers since sse is not advertised in mcpCapabilities", () => { - const server: McpServer = { - name: "event-stream", - type: "sse", - url: "https://events.example.com/mcp", - headers: [{ name: "X-API-Key", value: "apikey456" }], - } - expect(() => convertAcpMcpServer(server)).toThrow(/SSE transport is not supported/) - }) - }) - - it("throws on unrecognized server shape", () => { - const malformed = { name: "bad" } as unknown as McpServer - expect(() => convertAcpMcpServer(malformed)).toThrow(/Unrecognized ACP McpServer shape for server "bad"/) - }) -}) - -describe("convertAcpMcpServers", () => { - it("returns empty record for empty array", () => { - expect(convertAcpMcpServers([])).toEqual({}) - }) - - it("converts a single stdio server", () => { - const servers: McpServer[] = [{ name: "fs", command: "/path", args: ["--stdio"], env: [] }] - const result = convertAcpMcpServers(servers) - expect(result).toEqual({ - fs: { command: "/path", args: ["--stdio"] }, - }) - }) - - it("converts mixed stdio and http servers", () => { - const servers: McpServer[] = [ - { name: "fs", command: "/path", args: [], env: [] }, - { name: "api", type: "http", url: "https://api.example.com", headers: [] }, - ] - const result = convertAcpMcpServers(servers) - expect(Object.keys(result).sort()).toEqual(["api", "fs"]) - expect(result.fs).toEqual({ command: "/path", args: [] }) - expect(result.api).toEqual({ url: "https://api.example.com" }) - }) - - it("duplicate names: last-wins", () => { - const servers: McpServer[] = [ - { name: "dup", command: "/first", args: [], env: [] }, - { name: "dup", command: "/second", args: [], env: [] }, - ] - const result = convertAcpMcpServers(servers) - expect(result.dup).toEqual({ command: "/second", args: [] }) - }) - - it("returns correctly typed ServerEntry records", () => { - const servers: McpServer[] = [{ name: "s", command: "c", args: ["a"], env: [{ name: "K", value: "V" }] }] - const result: Record = convertAcpMcpServers(servers) - expect(result.s.command).toBe("c") - expect(result.s.args).toEqual(["a"]) - expect(result.s.env).toEqual({ K: "V" }) - }) -}) diff --git a/src/extensions/mcp-adapter/acp-mcp-convert.ts b/src/extensions/mcp-adapter/acp-mcp-convert.ts deleted file mode 100644 index afb91f952..000000000 --- a/src/extensions/mcp-adapter/acp-mcp-convert.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { McpServer } from "@agentclientprotocol/sdk" -import type { ServerEntry } from "./types.js" - -/** - * Convert an ACP `McpServer` `EnvVariable[]` (array of `{name, value}`) to the - * `Record` shape that `ServerEntry.env` expects. - */ -function envArrayToRecord( - env: ReadonlyArray<{ name: string; value: string }> | undefined, -): Record | undefined { - if (!env || env.length === 0) return undefined - const result: Record = {} - for (const { name, value } of env) { - result[name] = value - } - return result -} - -/** - * Convert an ACP `HttpHeader[]` (array of `{name, value}`) to the - * `Record` shape that `ServerEntry.headers` expects. - */ -function headersArrayToRecord( - headers: ReadonlyArray<{ name: string; value: string }> | undefined, -): Record | undefined { - if (!headers || headers.length === 0) return undefined - const result: Record = {} - for (const { name, value } of headers) { - result[name] = value - } - return result -} - -/** - * Convert a single ACP `McpServer` (stdio / http / sse variant) to the - * Kimchi-internal `ServerEntry` shape. - * - * The ACP SDK uses a tagged union with an optional `type` discriminator - * (absent = stdio, "http" = http, "sse" = sse). `ServerEntry` uses the presence - * of `command` (stdio) vs `url` (http/sse) to distinguish transports. - */ -export function convertAcpMcpServer(server: McpServer): ServerEntry { - // Capture the name early for error messages — don't use JSON.stringify - // on the server object as env/headers may contain secrets. - const name = server.name - - // Stdio: no `type` field, has `command` - if ("command" in server) { - const entry: ServerEntry = { - command: server.command, - args: server.args, - } - const env = envArrayToRecord(server.env) - if (env) entry.env = env - return entry - } - - // SSE: we don't advertise sse support in mcpCapabilities, so reject. - if ("type" in server && server.type === "sse") { - throw new Error(`SSE transport is not supported for server "${name}"`) - } - - // HTTP: `type === "http"` - if ("url" in server) { - const entry: ServerEntry = { - url: server.url, - } - const headers = headersArrayToRecord(server.headers) - if (headers) entry.headers = headers - return entry - } - - // Should not happen with a well-formed ACP McpServer, but guard anyway. - throw new Error(`Unrecognized ACP McpServer shape for server "${name}"`) -} - -/** - * Convert an array of ACP `McpServer` entries to a `Record` - * keyed by server `name`. Caller-supplied servers are merged into the MCP - * adapter's server set alongside config-sourced servers. - * - * Duplicate names: last-wins (matches `Object.fromEntries` semantics). - */ -export function convertAcpMcpServers(servers: ReadonlyArray): Record { - const result: Record = {} - for (const server of servers) { - result[server.name] = convertAcpMcpServer(server) - } - return result -} diff --git a/src/extensions/mcp-adapter/app-bridge.bundle.js b/src/extensions/mcp-adapter/app-bridge.bundle.js deleted file mode 100644 index 311dafb55..000000000 --- a/src/extensions/mcp-adapter/app-bridge.bundle.js +++ /dev/null @@ -1,10430 +0,0 @@ -var Mu = Object.defineProperty -var $e = (t, r) => { - for (var n in r) Mu(t, n, { get: r[n], enumerable: !0 }) -} -var s = {} -$e(s, { - $brand: () => pn, - $input: () => Si, - $output: () => ki, - NEVER: () => mn, - TimePrecision: () => ji, - ZodAny: () => Mc, - ZodArray: () => Hc, - ZodBase64: () => Ea, - ZodBase64URL: () => Aa, - ZodBigInt: () => Dt, - ZodBigIntFormat: () => Ma, - ZodBoolean: () => Zt, - ZodCIDRv4: () => Da, - ZodCIDRv6: () => Ra, - ZodCUID: () => ja, - ZodCUID2: () => Pa, - ZodCatch: () => au, - ZodCustom: () => Hr, - ZodCustomStringFormat: () => Ec, - ZodDate: () => qr, - ZodDefault: () => eu, - ZodDiscriminatedUnion: () => Jc, - ZodE164: () => Ca, - ZodEmail: () => ka, - ZodEmoji: () => wa, - ZodEnum: () => Ot, - ZodError: () => wm, - ZodFile: () => Xc, - ZodGUID: () => Ar, - ZodIPv4: () => Ua, - ZodIPv6: () => Za, - ZodISODate: () => Dr, - ZodISODateTime: () => Zr, - ZodISODuration: () => Er, - ZodISOTime: () => Rr, - ZodIntersection: () => Bc, - ZodIssueCode: () => Sp, - ZodJWT: () => La, - ZodKSUID: () => Na, - ZodLazy: () => pu, - ZodLiteral: () => Qc, - ZodMap: () => Gc, - ZodNaN: () => cu, - ZodNanoID: () => Ia, - ZodNever: () => Vc, - ZodNonOptional: () => Ba, - ZodNull: () => Lc, - ZodNullable: () => Yc, - ZodNumber: () => Ut, - ZodNumberFormat: () => He, - ZodObject: () => Vr, - ZodOptional: () => Ja, - ZodPipe: () => Wa, - ZodPrefault: () => ru, - ZodPromise: () => fu, - ZodReadonly: () => uu, - ZodRealError: () => Fe, - ZodRecord: () => Va, - ZodSet: () => Kc, - ZodString: () => Nt, - ZodStringFormat: () => C, - ZodSuccess: () => iu, - ZodSymbol: () => Ac, - ZodTemplateLiteral: () => mu, - ZodTransform: () => Fa, - ZodTuple: () => Wc, - ZodType: () => P, - ZodULID: () => Ta, - ZodURL: () => Sa, - ZodUUID: () => fe, - ZodUndefined: () => Cc, - ZodUnion: () => qa, - ZodUnknown: () => qc, - ZodVoid: () => Fc, - ZodXID: () => Oa, - _ZodString: () => za, - _default: () => tu, - any: () => ap, - array: () => T, - base64: () => Jm, - base64url: () => Bm, - bigint: () => tp, - boolean: () => H, - catch: () => su, - check: () => gu, - cidrv4: () => Fm, - cidrv6: () => Hm, - clone: () => te, - coerce: () => Ka, - config: () => F, - core: () => de, - cuid: () => Em, - cuid2: () => Am, - custom: () => Ga, - date: () => cp, - discriminatedUnion: () => Fr, - e164: () => Wm, - email: () => jm, - emoji: () => Dm, - endsWith: () => zt, - enum: () => X, - file: () => hp, - flattenError: () => it, - float32: () => Qm, - float64: () => Xm, - formatError: () => at, - function: () => pa, - getErrorMap: () => Ip, - globalRegistry: () => ce, - gt: () => me, - gte: () => ee, - guid: () => Pm, - includes: () => $t, - instanceof: () => xp, - int: () => xa, - int32: () => Ym, - int64: () => rp, - intersection: () => Et, - ipv4: () => qm, - ipv6: () => Vm, - iso: () => Ve, - json: () => kp, - jwt: () => Gm, - keyof: () => up, - ksuid: () => Mm, - lazy: () => du, - length: () => qe, - literal: () => w, - locales: () => gt, - looseObject: () => Q, - lowercase: () => _t, - lt: () => le, - lte: () => oe, - map: () => dp, - maxLength: () => Me, - maxSize: () => Le, - mime: () => kt, - minLength: () => ve, - minSize: () => Pe, - multipleOf: () => je, - nan: () => _p, - nanoid: () => Rm, - nativeEnum: () => gp, - negative: () => ra, - never: () => Mr, - nonnegative: () => oa, - nonoptional: () => ou, - nonpositive: () => na, - normalize: () => St, - null: () => Rt, - nullable: () => Cr, - nullish: () => vp, - number: () => Z, - object: () => z, - optional: () => q, - overwrite: () => pe, - parse: () => ba, - parseAsync: () => _a, - partialRecord: () => pp, - pipe: () => Lr, - positive: () => ta, - prefault: () => nu, - preprocess: () => Jr, - prettifyError: () => Sn, - promise: () => $p, - property: () => ia, - readonly: () => lu, - record: () => L, - refine: () => hu, - regex: () => bt, - regexes: () => Se, - registry: () => mr, - safeParse: () => ya, - safeParseAsync: () => $a, - set: () => fp, - setErrorMap: () => wp, - size: () => vt, - startsWith: () => xt, - strictObject: () => lp, - string: () => l, - stringFormat: () => Km, - stringbool: () => zp, - success: () => bp, - superRefine: () => vu, - symbol: () => op, - templateLiteral: () => yp, - toJSONSchema: () => da, - toLowerCase: () => It, - toUpperCase: () => jt, - transform: () => Ha, - treeifyError: () => kn, - trim: () => wt, - tuple: () => mp, - uint32: () => ep, - uint64: () => np, - ulid: () => Cm, - undefined: () => ip, - union: () => R, - unknown: () => M, - uppercase: () => yt, - url: () => Zm, - uuid: () => Tm, - uuidv4: () => Om, - uuidv6: () => Nm, - uuidv7: () => Um, - void: () => sp, - xid: () => Lm, -}) -var de = {} -$e(de, { - $ZodAny: () => Xo, - $ZodArray: () => pt, - $ZodAsyncError: () => se, - $ZodBase64: () => qo, - $ZodBase64URL: () => Vo, - $ZodBigInt: () => cr, - $ZodBigIntFormat: () => Wo, - $ZodBoolean: () => mt, - $ZodCIDRv4: () => Co, - $ZodCIDRv6: () => Lo, - $ZodCUID: () => jo, - $ZodCUID2: () => Po, - $ZodCatch: () => vi, - $ZodCheck: () => V, - $ZodCheckBigIntFormat: () => no, - $ZodCheckEndsWith: () => ho, - $ZodCheckGreaterThan: () => or, - $ZodCheckIncludes: () => fo, - $ZodCheckLengthEquals: () => uo, - $ZodCheckLessThan: () => nr, - $ZodCheckLowerCase: () => mo, - $ZodCheckMaxLength: () => so, - $ZodCheckMaxSize: () => oo, - $ZodCheckMimeType: () => bo, - $ZodCheckMinLength: () => co, - $ZodCheckMinSize: () => io, - $ZodCheckMultipleOf: () => to, - $ZodCheckNumberFormat: () => ro, - $ZodCheckOverwrite: () => _o, - $ZodCheckProperty: () => vo, - $ZodCheckRegex: () => lo, - $ZodCheckSizeEquals: () => ao, - $ZodCheckStartsWith: () => go, - $ZodCheckStringFormat: () => Re, - $ZodCheckUpperCase: () => po, - $ZodCustom: () => zi, - $ZodCustomStringFormat: () => Jo, - $ZodDate: () => ti, - $ZodDefault: () => di, - $ZodDiscriminatedUnion: () => ni, - $ZodE164: () => Fo, - $ZodEmail: () => ko, - $ZodEmoji: () => wo, - $ZodEnum: () => ci, - $ZodError: () => ot, - $ZodFile: () => li, - $ZodFunction: () => Ur, - $ZodGUID: () => xo, - $ZodIPv4: () => Eo, - $ZodIPv6: () => Ao, - $ZodISODate: () => Zo, - $ZodISODateTime: () => Uo, - $ZodISODuration: () => Ro, - $ZodISOTime: () => Do, - $ZodIntersection: () => oi, - $ZodJWT: () => Ho, - $ZodKSUID: () => No, - $ZodLazy: () => xi, - $ZodLiteral: () => ui, - $ZodMap: () => ai, - $ZodNaN: () => bi, - $ZodNanoID: () => Io, - $ZodNever: () => Yo, - $ZodNonOptional: () => gi, - $ZodNull: () => Qo, - $ZodNullable: () => pi, - $ZodNumber: () => sr, - $ZodNumberFormat: () => Bo, - $ZodObject: () => ri, - $ZodOptional: () => mi, - $ZodPipe: () => ft, - $ZodPrefault: () => fi, - $ZodPromise: () => $i, - $ZodReadonly: () => _i, - $ZodRealError: () => Ze, - $ZodRecord: () => ii, - $ZodRegistry: () => Ae, - $ZodSet: () => si, - $ZodString: () => we, - $ZodStringFormat: () => A, - $ZodSuccess: () => hi, - $ZodSymbol: () => Go, - $ZodTemplateLiteral: () => yi, - $ZodTransform: () => dt, - $ZodTuple: () => Ie, - $ZodType: () => j, - $ZodULID: () => To, - $ZodURL: () => So, - $ZodUUID: () => zo, - $ZodUndefined: () => Ko, - $ZodUnion: () => ur, - $ZodUnknown: () => Ee, - $ZodVoid: () => ei, - $ZodXID: () => Oo, - $brand: () => pn, - $constructor: () => u, - $input: () => Si, - $output: () => ki, - Doc: () => lt, - JSONSchema: () => Zc, - JSONSchemaGenerator: () => Tt, - NEVER: () => mn, - TimePrecision: () => ji, - _any: () => Gi, - _array: () => Pt, - _base64: () => Pr, - _base64url: () => Tr, - _bigint: () => qi, - _boolean: () => Li, - _catch: () => _m, - _cidrv4: () => Ir, - _cidrv6: () => jr, - _coercedBigint: () => Vi, - _coercedBoolean: () => Mi, - _coercedDate: () => Yi, - _coercedNumber: () => Zi, - _coercedString: () => Ii, - _cuid: () => yr, - _cuid2: () => $r, - _custom: () => ca, - _date: () => Xi, - _default: () => hm, - _discriminatedUnion: () => im, - _e164: () => Or, - _email: () => pr, - _emoji: () => br, - _endsWith: () => zt, - _enum: () => lm, - _file: () => sa, - _float32: () => Ri, - _float64: () => Ei, - _gt: () => me, - _gte: () => ee, - _guid: () => ht, - _includes: () => $t, - _int: () => Di, - _int32: () => Ai, - _int64: () => Fi, - _intersection: () => am, - _ipv4: () => Sr, - _ipv6: () => wr, - _isoDate: () => Ti, - _isoDateTime: () => Pi, - _isoDuration: () => Ni, - _isoTime: () => Oi, - _jwt: () => Nr, - _ksuid: () => kr, - _lazy: () => zm, - _length: () => qe, - _literal: () => pm, - _lowercase: () => _t, - _lt: () => le, - _lte: () => oe, - _map: () => cm, - _max: () => oe, - _maxLength: () => Me, - _maxSize: () => Le, - _mime: () => kt, - _min: () => ee, - _minLength: () => ve, - _minSize: () => Pe, - _multipleOf: () => je, - _nan: () => ea, - _nanoid: () => _r, - _nativeEnum: () => mm, - _negative: () => ra, - _never: () => Ki, - _nonnegative: () => oa, - _nonoptional: () => vm, - _nonpositive: () => na, - _normalize: () => St, - _null: () => Wi, - _nullable: () => gm, - _number: () => Ui, - _optional: () => fm, - _overwrite: () => pe, - _parse: () => Xt, - _parseAsync: () => Yt, - _pipe: () => ym, - _positive: () => ta, - _promise: () => km, - _property: () => ia, - _readonly: () => $m, - _record: () => sm, - _refine: () => ua, - _regex: () => bt, - _safeParse: () => er, - _safeParseAsync: () => tr, - _set: () => um, - _size: () => vt, - _startsWith: () => xt, - _string: () => wi, - _stringFormat: () => ma, - _stringbool: () => la, - _success: () => bm, - _symbol: () => Ji, - _templateLiteral: () => xm, - _toLowerCase: () => It, - _toUpperCase: () => jt, - _transform: () => dm, - _trim: () => wt, - _tuple: () => aa, - _uint32: () => Ci, - _uint64: () => Hi, - _ulid: () => xr, - _undefined: () => Bi, - _union: () => om, - _unknown: () => Ce, - _uppercase: () => yt, - _url: () => vr, - _uuid: () => dr, - _uuidv4: () => fr, - _uuidv6: () => gr, - _uuidv7: () => hr, - _void: () => Qi, - _xid: () => zr, - clone: () => te, - config: () => F, - flattenError: () => it, - formatError: () => at, - function: () => pa, - globalConfig: () => Ke, - globalRegistry: () => ce, - isValidBase64: () => Mo, - isValidBase64URL: () => Hs, - isValidJWT: () => Js, - locales: () => gt, - parse: () => st, - parseAsync: () => ct, - prettifyError: () => Sn, - regexes: () => Se, - registry: () => mr, - safeParse: () => De, - safeParseAsync: () => ut, - toDotPath: () => Is, - toJSONSchema: () => da, - treeifyError: () => kn, - util: () => y, - version: () => yo, -}) -var mn = Object.freeze({ status: "aborted" }) -function u(t, r, n) { - function i(c, p) { - var h - Object.defineProperty(c, "_zod", { value: c._zod ?? {}, enumerable: !1 }), - (h = c._zod).traits ?? (h.traits = new Set()), - c._zod.traits.add(t), - r(c, p) - for (const g in a.prototype) g in c || Object.defineProperty(c, g, { value: a.prototype[g].bind(c) }) - ;(c._zod.constr = a), (c._zod.def = p) - } - const e = n?.Parent ?? Object - class o extends e {} - Object.defineProperty(o, "name", { value: t }) - function a(c) { - var p - const h = n?.Parent ? new o() : this - i(h, c), (p = h._zod).deferred ?? (p.deferred = []) - for (const g of h._zod.deferred) g() - return h - } - return ( - Object.defineProperty(a, "init", { value: i }), - Object.defineProperty(a, Symbol.hasInstance, { - value: (c) => (n?.Parent && c instanceof n.Parent ? !0 : c?._zod?.traits?.has(t)), - }), - Object.defineProperty(a, "name", { value: t }), - a - ) -} -var pn = Symbol("zod_brand"), - se = class extends Error { - constructor() { - super("Encountered Promise during synchronous parse. Use .parseAsync() instead.") - } - }, - Ke = {} -function F(t) { - return t && Object.assign(Ke, t), Ke -} -var y = {} -$e(y, { - BIGINT_FORMAT_RANGES: () => xn, - Class: () => fn, - NUMBER_FORMAT_RANGES: () => $n, - aborted: () => ze, - allowsEval: () => bn, - assert: () => Ju, - assertEqual: () => qu, - assertIs: () => Fu, - assertNever: () => Hu, - assertNotEqual: () => Vu, - assignProp: () => vn, - cached: () => Ye, - captureStackTrace: () => Qt, - cleanEnum: () => il, - cleanRegex: () => et, - clone: () => te, - createTransparentProxy: () => Xu, - defineLazy: () => U, - esc: () => xe, - escapeRegex: () => ue, - extend: () => tl, - finalizeIssue: () => re, - floatSafeRemainder: () => hn, - getElementAtPath: () => Bu, - getEnumValues: () => Xe, - getLengthableOrigin: () => nt, - getParsedType: () => Qu, - getSizableOrigin: () => rt, - isObject: () => Ne, - isPlainObject: () => Ue, - issue: () => zn, - joinValues: () => f, - jsonStringifyReplacer: () => gn, - merge: () => rl, - normalizeParams: () => v, - nullish: () => he, - numKeys: () => Ku, - omit: () => el, - optionalKeys: () => yn, - partial: () => nl, - pick: () => Yu, - prefixIssues: () => Y, - primitiveTypes: () => _n, - promiseAllObject: () => Wu, - propertyKeyTypes: () => tt, - randomString: () => Gu, - required: () => ol, - stringifyPrimitive: () => _, - unwrapMessage: () => Qe, -}) -function qu(t) { - return t -} -function Vu(t) { - return t -} -function Fu(t) {} -function Hu(t) { - throw new Error() -} -function Ju(t) {} -function Xe(t) { - const r = Object.values(t).filter((i) => typeof i == "number") - return Object.entries(t) - .filter(([i, e]) => r.indexOf(+i) === -1) - .map(([i, e]) => e) -} -function f(t, r = "|") { - return t.map((n) => _(n)).join(r) -} -function gn(t, r) { - return typeof r == "bigint" ? r.toString() : r -} -function Ye(t) { - return { - get value() { - { - const n = t() - return Object.defineProperty(this, "value", { value: n }), n - } - throw new Error("cached value already set") - }, - } -} -function he(t) { - return t == null -} -function et(t) { - const r = t.startsWith("^") ? 1 : 0, - n = t.endsWith("$") ? t.length - 1 : t.length - return t.slice(r, n) -} -function hn(t, r) { - const n = (t.toString().split(".")[1] || "").length, - i = (r.toString().split(".")[1] || "").length, - e = n > i ? n : i, - o = Number.parseInt(t.toFixed(e).replace(".", "")), - a = Number.parseInt(r.toFixed(e).replace(".", "")) - return (o % a) / 10 ** e -} -function U(t, r, n) { - Object.defineProperty(t, r, { - get() { - { - const e = n() - return (t[r] = e), e - } - throw new Error("cached value already set") - }, - set(e) { - Object.defineProperty(t, r, { value: e }) - }, - configurable: !0, - }) -} -function vn(t, r, n) { - Object.defineProperty(t, r, { value: n, writable: !0, enumerable: !0, configurable: !0 }) -} -function Bu(t, r) { - return r ? r.reduce((n, i) => n?.[i], t) : t -} -function Wu(t) { - const r = Object.keys(t), - n = r.map((i) => t[i]) - return Promise.all(n).then((i) => { - const e = {} - for (let o = 0; o < r.length; o++) e[r[o]] = i[o] - return e - }) -} -function Gu(t = 10) { - let r = "abcdefghijklmnopqrstuvwxyz", - n = "" - for (let i = 0; i < t; i++) n += r[Math.floor(Math.random() * r.length)] - return n -} -function xe(t) { - return JSON.stringify(t) -} -var Qt = Error.captureStackTrace ? Error.captureStackTrace : (...t) => {} -function Ne(t) { - return typeof t == "object" && t !== null && !Array.isArray(t) -} -var bn = Ye(() => { - if (typeof navigator < "u" && navigator?.userAgent?.includes("Cloudflare")) return !1 - try { - const t = Function - return new t(""), !0 - } catch { - return !1 - } -}) -function Ue(t) { - if (Ne(t) === !1) return !1 - const r = t.constructor - if (r === void 0) return !0 - const n = r.prototype - return !(Ne(n) === !1 || Object.prototype.hasOwnProperty.call(n, "isPrototypeOf") === !1) -} -function Ku(t) { - let r = 0 - for (const n in t) Object.prototype.hasOwnProperty.call(t, n) && r++ - return r -} -var Qu = (t) => { - const r = typeof t - switch (r) { - case "undefined": - return "undefined" - case "string": - return "string" - case "number": - return Number.isNaN(t) ? "nan" : "number" - case "boolean": - return "boolean" - case "function": - return "function" - case "bigint": - return "bigint" - case "symbol": - return "symbol" - case "object": - return Array.isArray(t) - ? "array" - : t === null - ? "null" - : t.then && typeof t.then == "function" && t.catch && typeof t.catch == "function" - ? "promise" - : typeof Map < "u" && t instanceof Map - ? "map" - : typeof Set < "u" && t instanceof Set - ? "set" - : typeof Date < "u" && t instanceof Date - ? "date" - : typeof File < "u" && t instanceof File - ? "file" - : "object" - default: - throw new Error(`Unknown data type: ${r}`) - } - }, - tt = new Set(["string", "number", "symbol"]), - _n = new Set(["string", "number", "bigint", "boolean", "symbol", "undefined"]) -function ue(t) { - return t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") -} -function te(t, r, n) { - const i = new t._zod.constr(r ?? t._zod.def) - return (!r || n?.parent) && (i._zod.parent = t), i -} -function v(t) { - const r = t - if (!r) return {} - if (typeof r == "string") return { error: () => r } - if (r?.message !== void 0) { - if (r?.error !== void 0) throw new Error("Cannot specify both `message` and `error` params") - r.error = r.message - } - return delete r.message, typeof r.error == "string" ? { ...r, error: () => r.error } : r -} -function Xu(t) { - let r - return new Proxy( - {}, - { - get(n, i, e) { - return r ?? (r = t()), Reflect.get(r, i, e) - }, - set(n, i, e, o) { - return r ?? (r = t()), Reflect.set(r, i, e, o) - }, - has(n, i) { - return r ?? (r = t()), Reflect.has(r, i) - }, - deleteProperty(n, i) { - return r ?? (r = t()), Reflect.deleteProperty(r, i) - }, - ownKeys(n) { - return r ?? (r = t()), Reflect.ownKeys(r) - }, - getOwnPropertyDescriptor(n, i) { - return r ?? (r = t()), Reflect.getOwnPropertyDescriptor(r, i) - }, - defineProperty(n, i, e) { - return r ?? (r = t()), Reflect.defineProperty(r, i, e) - }, - }, - ) -} -function _(t) { - return typeof t == "bigint" ? t.toString() + "n" : typeof t == "string" ? `"${t}"` : `${t}` -} -function yn(t) { - return Object.keys(t).filter((r) => t[r]._zod.optin === "optional" && t[r]._zod.optout === "optional") -} -var $n = { - safeint: [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], - int32: [-2147483648, 2147483647], - uint32: [0, 4294967295], - float32: [-34028234663852886e22, 34028234663852886e22], - float64: [-Number.MAX_VALUE, Number.MAX_VALUE], - }, - xn = { - int64: [BigInt("-9223372036854775808"), BigInt("9223372036854775807")], - uint64: [BigInt(0), BigInt("18446744073709551615")], - } -function Yu(t, r) { - const n = {}, - i = t._zod.def - for (const e in r) { - if (!(e in i.shape)) throw new Error(`Unrecognized key: "${e}"`) - r[e] && (n[e] = i.shape[e]) - } - return te(t, { ...t._zod.def, shape: n, checks: [] }) -} -function el(t, r) { - const n = { ...t._zod.def.shape }, - i = t._zod.def - for (const e in r) { - if (!(e in i.shape)) throw new Error(`Unrecognized key: "${e}"`) - r[e] && delete n[e] - } - return te(t, { ...t._zod.def, shape: n, checks: [] }) -} -function tl(t, r) { - if (!Ue(r)) throw new Error("Invalid input to extend: expected a plain object") - const n = { - ...t._zod.def, - get shape() { - const i = { ...t._zod.def.shape, ...r } - return vn(this, "shape", i), i - }, - checks: [], - } - return te(t, n) -} -function rl(t, r) { - return te(t, { - ...t._zod.def, - get shape() { - const n = { ...t._zod.def.shape, ...r._zod.def.shape } - return vn(this, "shape", n), n - }, - catchall: r._zod.def.catchall, - checks: [], - }) -} -function nl(t, r, n) { - const i = r._zod.def.shape, - e = { ...i } - if (n) - for (const o in n) { - if (!(o in i)) throw new Error(`Unrecognized key: "${o}"`) - n[o] && (e[o] = t ? new t({ type: "optional", innerType: i[o] }) : i[o]) - } - else for (const o in i) e[o] = t ? new t({ type: "optional", innerType: i[o] }) : i[o] - return te(r, { ...r._zod.def, shape: e, checks: [] }) -} -function ol(t, r, n) { - const i = r._zod.def.shape, - e = { ...i } - if (n) - for (const o in n) { - if (!(o in e)) throw new Error(`Unrecognized key: "${o}"`) - n[o] && (e[o] = new t({ type: "nonoptional", innerType: i[o] })) - } - else for (const o in i) e[o] = new t({ type: "nonoptional", innerType: i[o] }) - return te(r, { ...r._zod.def, shape: e, checks: [] }) -} -function ze(t, r = 0) { - for (let n = r; n < t.issues.length; n++) if (t.issues[n]?.continue !== !0) return !0 - return !1 -} -function Y(t, r) { - return r.map((n) => { - var i - return (i = n).path ?? (i.path = []), n.path.unshift(t), n - }) -} -function Qe(t) { - return typeof t == "string" ? t : t?.message -} -function re(t, r, n) { - const i = { ...t, path: t.path ?? [] } - if (!t.message) { - const e = - Qe(t.inst?._zod.def?.error?.(t)) ?? - Qe(r?.error?.(t)) ?? - Qe(n.customError?.(t)) ?? - Qe(n.localeError?.(t)) ?? - "Invalid input" - i.message = e - } - return delete i.inst, delete i.continue, r?.reportInput || delete i.input, i -} -function rt(t) { - return t instanceof Set ? "set" : t instanceof Map ? "map" : t instanceof File ? "file" : "unknown" -} -function nt(t) { - return Array.isArray(t) ? "array" : typeof t == "string" ? "string" : "unknown" -} -function zn(...t) { - const [r, n, i] = t - return typeof r == "string" ? { message: r, code: "custom", input: n, inst: i } : { ...r } -} -function il(t) { - return Object.entries(t) - .filter(([r, n]) => Number.isNaN(Number.parseInt(r, 10))) - .map((r) => r[1]) -} -var fn = class { - constructor(...r) {} -} -var ws = (t, r) => { - ;(t.name = "$ZodError"), - Object.defineProperty(t, "_zod", { value: t._zod, enumerable: !1 }), - Object.defineProperty(t, "issues", { value: r, enumerable: !1 }), - Object.defineProperty(t, "message", { - get() { - return JSON.stringify(r, gn, 2) - }, - enumerable: !0, - }), - Object.defineProperty(t, "toString", { value: () => t.message, enumerable: !1 }) - }, - ot = u("$ZodError", ws), - Ze = u("$ZodError", ws, { Parent: Error }) -function it(t, r = (n) => n.message) { - const n = {}, - i = [] - for (const e of t.issues) - e.path.length > 0 ? ((n[e.path[0]] = n[e.path[0]] || []), n[e.path[0]].push(r(e))) : i.push(r(e)) - return { formErrors: i, fieldErrors: n } -} -function at(t, r) { - const n = r || ((o) => o.message), - i = { _errors: [] }, - e = (o) => { - for (const a of o.issues) - if (a.code === "invalid_union" && a.errors.length) a.errors.map((c) => e({ issues: c })) - else if (a.code === "invalid_key") e({ issues: a.issues }) - else if (a.code === "invalid_element") e({ issues: a.issues }) - else if (a.path.length === 0) i._errors.push(n(a)) - else { - let c = i, - p = 0 - while (p < a.path.length) { - const h = a.path[p] - p === a.path.length - 1 - ? ((c[h] = c[h] || { _errors: [] }), c[h]._errors.push(n(a))) - : (c[h] = c[h] || { _errors: [] }), - (c = c[h]), - p++ - } - } - } - return e(t), i -} -function kn(t, r) { - const n = r || ((o) => o.message), - i = { errors: [] }, - e = (o, a = []) => { - var c, p - for (const h of o.issues) - if (h.code === "invalid_union" && h.errors.length) h.errors.map((g) => e({ issues: g }, h.path)) - else if (h.code === "invalid_key") e({ issues: h.issues }, h.path) - else if (h.code === "invalid_element") e({ issues: h.issues }, h.path) - else { - const g = [...a, ...h.path] - if (g.length === 0) { - i.errors.push(n(h)) - continue - } - let m = i, - $ = 0 - while ($ < g.length) { - const b = g[$], - d = $ === g.length - 1 - typeof b == "string" - ? (m.properties ?? (m.properties = {}), - (c = m.properties)[b] ?? (c[b] = { errors: [] }), - (m = m.properties[b])) - : (m.items ?? (m.items = []), (p = m.items)[b] ?? (p[b] = { errors: [] }), (m = m.items[b])), - d && m.errors.push(n(h)), - $++ - } - } - } - return e(t), i -} -function Is(t) { - const r = [] - for (const n of t) - typeof n == "number" - ? r.push(`[${n}]`) - : typeof n == "symbol" - ? r.push(`[${JSON.stringify(String(n))}]`) - : /[^\w$]/.test(n) - ? r.push(`[${JSON.stringify(n)}]`) - : (r.length && r.push("."), r.push(n)) - return r.join("") -} -function Sn(t) { - const r = [], - n = [...t.issues].sort((i, e) => i.path.length - e.path.length) - for (const i of n) r.push(`\u2716 ${i.message}`), i.path?.length && r.push(` \u2192 at ${Is(i.path)}`) - return r.join(` -`) -} -var Xt = (t) => (r, n, i, e) => { - const o = i ? Object.assign(i, { async: !1 }) : { async: !1 }, - a = r._zod.run({ value: n, issues: [] }, o) - if (a instanceof Promise) throw new se() - if (a.issues.length) { - const c = new (e?.Err ?? t)(a.issues.map((p) => re(p, o, F()))) - throw (Qt(c, e?.callee), c) - } - return a.value - }, - st = Xt(Ze), - Yt = (t) => async (r, n, i, e) => { - let o = i ? Object.assign(i, { async: !0 }) : { async: !0 }, - a = r._zod.run({ value: n, issues: [] }, o) - if ((a instanceof Promise && (a = await a), a.issues.length)) { - const c = new (e?.Err ?? t)(a.issues.map((p) => re(p, o, F()))) - throw (Qt(c, e?.callee), c) - } - return a.value - }, - ct = Yt(Ze), - er = (t) => (r, n, i) => { - const e = i ? { ...i, async: !1 } : { async: !1 }, - o = r._zod.run({ value: n, issues: [] }, e) - if (o instanceof Promise) throw new se() - return o.issues.length - ? { success: !1, error: new (t ?? ot)(o.issues.map((a) => re(a, e, F()))) } - : { success: !0, data: o.value } - }, - De = er(Ze), - tr = (t) => async (r, n, i) => { - let e = i ? Object.assign(i, { async: !0 }) : { async: !0 }, - o = r._zod.run({ value: n, issues: [] }, e) - return ( - o instanceof Promise && (o = await o), - o.issues.length - ? { success: !1, error: new t(o.issues.map((a) => re(a, e, F()))) } - : { success: !0, data: o.value } - ) - }, - ut = tr(Ze) -var Se = {} -$e(Se, { - _emoji: () => js, - base64: () => Ln, - base64url: () => rr, - bigint: () => Bn, - boolean: () => Kn, - browserEmail: () => fl, - cidrv4: () => An, - cidrv6: () => Cn, - cuid: () => wn, - cuid2: () => In, - date: () => Vn, - datetime: () => Hn, - domain: () => gl, - duration: () => Nn, - e164: () => qn, - email: () => Zn, - emoji: () => Dn, - extendedDuration: () => sl, - guid: () => Un, - hostname: () => Mn, - html5Email: () => ml, - integer: () => Wn, - ipv4: () => Rn, - ipv6: () => En, - ksuid: () => Tn, - lowercase: () => Yn, - nanoid: () => On, - null: () => Qn, - number: () => Gn, - rfc5322Email: () => pl, - string: () => Jn, - time: () => Fn, - ulid: () => jn, - undefined: () => Xn, - unicodeEmail: () => dl, - uppercase: () => eo, - uuid: () => ke, - uuid4: () => cl, - uuid6: () => ul, - uuid7: () => ll, - xid: () => Pn, -}) -var wn = /^[cC][^\s-]{8,}$/, - In = /^[0-9a-z]+$/, - jn = /^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$/, - Pn = /^[0-9a-vA-V]{20}$/, - Tn = /^[A-Za-z0-9]{27}$/, - On = /^[a-zA-Z0-9_-]{21}$/, - Nn = /^P(?:(\d+W)|(?!.*W)(?=\d|T\d)(\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+([.,]\d+)?S)?)?)$/, - sl = - /^[-+]?P(?!$)(?:(?:[-+]?\d+Y)|(?:[-+]?\d+[.,]\d+Y$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:(?:[-+]?\d+W)|(?:[-+]?\d+[.,]\d+W$))?(?:(?:[-+]?\d+D)|(?:[-+]?\d+[.,]\d+D$))?(?:T(?=[\d+-])(?:(?:[-+]?\d+H)|(?:[-+]?\d+[.,]\d+H$))?(?:(?:[-+]?\d+M)|(?:[-+]?\d+[.,]\d+M$))?(?:[-+]?\d+(?:[.,]\d+)?S)?)??$/, - Un = /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/, - ke = (t) => - t - ? new RegExp(`^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-${t}[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$`) - : /^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$/, - cl = ke(4), - ul = ke(6), - ll = ke(7), - Zn = /^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$/, - ml = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, - pl = - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, - dl = /^[^\s@"]{1,64}@[^\s@]{1,255}$/u, - fl = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/, - js = "^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$" -function Dn() { - return new RegExp(js, "u") -} -var Rn = - /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/, - En = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})$/, - An = - /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/, - Cn = - /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:?){0,6})\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/, - Ln = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/, - rr = /^[A-Za-z0-9_-]*$/, - Mn = /^([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/, - gl = /^([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, - qn = /^\+(?:[0-9]){6,14}[0-9]$/, - Ps = - "(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))", - Vn = new RegExp(`^${Ps}$`) -function Ts(t) { - const r = "(?:[01]\\d|2[0-3]):[0-5]\\d" - return typeof t.precision == "number" - ? t.precision === -1 - ? `${r}` - : t.precision === 0 - ? `${r}:[0-5]\\d` - : `${r}:[0-5]\\d\\.\\d{${t.precision}}` - : `${r}(?::[0-5]\\d(?:\\.\\d+)?)?` -} -function Fn(t) { - return new RegExp(`^${Ts(t)}$`) -} -function Hn(t) { - const r = Ts({ precision: t.precision }), - n = ["Z"] - t.local && n.push(""), t.offset && n.push("([+-]\\d{2}:\\d{2})") - const i = `${r}(?:${n.join("|")})` - return new RegExp(`^${Ps}T(?:${i})$`) -} -var Jn = (t) => { - const r = t ? `[\\s\\S]{${t?.minimum ?? 0},${t?.maximum ?? ""}}` : "[\\s\\S]*" - return new RegExp(`^${r}$`) - }, - Bn = /^\d+n?$/, - Wn = /^\d+$/, - Gn = /^-?\d+(?:\.\d+)?/i, - Kn = /true|false/i, - Qn = /null/i -var Xn = /undefined/i -var Yn = /^[^A-Z]*$/, - eo = /^[^a-z]*$/ -var V = u("$ZodCheck", (t, r) => { - var n - t._zod ?? (t._zod = {}), (t._zod.def = r), (n = t._zod).onattach ?? (n.onattach = []) - }), - Ns = { number: "number", bigint: "bigint", object: "date" }, - nr = u("$ZodCheckLessThan", (t, r) => { - V.init(t, r) - const n = Ns[typeof r.value] - t._zod.onattach.push((i) => { - const e = i._zod.bag, - o = (r.inclusive ? e.maximum : e.exclusiveMaximum) ?? Number.POSITIVE_INFINITY - r.value < o && (r.inclusive ? (e.maximum = r.value) : (e.exclusiveMaximum = r.value)) - }), - (t._zod.check = (i) => { - ;(r.inclusive ? i.value <= r.value : i.value < r.value) || - i.issues.push({ - origin: n, - code: "too_big", - maximum: r.value, - input: i.value, - inclusive: r.inclusive, - inst: t, - continue: !r.abort, - }) - }) - }), - or = u("$ZodCheckGreaterThan", (t, r) => { - V.init(t, r) - const n = Ns[typeof r.value] - t._zod.onattach.push((i) => { - const e = i._zod.bag, - o = (r.inclusive ? e.minimum : e.exclusiveMinimum) ?? Number.NEGATIVE_INFINITY - r.value > o && (r.inclusive ? (e.minimum = r.value) : (e.exclusiveMinimum = r.value)) - }), - (t._zod.check = (i) => { - ;(r.inclusive ? i.value >= r.value : i.value > r.value) || - i.issues.push({ - origin: n, - code: "too_small", - minimum: r.value, - input: i.value, - inclusive: r.inclusive, - inst: t, - continue: !r.abort, - }) - }) - }), - to = u("$ZodCheckMultipleOf", (t, r) => { - V.init(t, r), - t._zod.onattach.push((n) => { - var i - ;(i = n._zod.bag).multipleOf ?? (i.multipleOf = r.value) - }), - (t._zod.check = (n) => { - if (typeof n.value != typeof r.value) throw new Error("Cannot mix number and bigint in multiple_of check.") - ;(typeof n.value == "bigint" ? n.value % r.value === BigInt(0) : hn(n.value, r.value) === 0) || - n.issues.push({ - origin: typeof n.value, - code: "not_multiple_of", - divisor: r.value, - input: n.value, - inst: t, - continue: !r.abort, - }) - }) - }), - ro = u("$ZodCheckNumberFormat", (t, r) => { - V.init(t, r), (r.format = r.format || "float64") - const n = r.format?.includes("int"), - i = n ? "int" : "number", - [e, o] = $n[r.format] - t._zod.onattach.push((a) => { - const c = a._zod.bag - ;(c.format = r.format), (c.minimum = e), (c.maximum = o), n && (c.pattern = Wn) - }), - (t._zod.check = (a) => { - const c = a.value - if (n) { - if (!Number.isInteger(c)) { - a.issues.push({ expected: i, format: r.format, code: "invalid_type", input: c, inst: t }) - return - } - if (!Number.isSafeInteger(c)) { - c > 0 - ? a.issues.push({ - input: c, - code: "too_big", - maximum: Number.MAX_SAFE_INTEGER, - note: "Integers must be within the safe integer range.", - inst: t, - origin: i, - continue: !r.abort, - }) - : a.issues.push({ - input: c, - code: "too_small", - minimum: Number.MIN_SAFE_INTEGER, - note: "Integers must be within the safe integer range.", - inst: t, - origin: i, - continue: !r.abort, - }) - return - } - } - c < e && - a.issues.push({ - origin: "number", - input: c, - code: "too_small", - minimum: e, - inclusive: !0, - inst: t, - continue: !r.abort, - }), - c > o && a.issues.push({ origin: "number", input: c, code: "too_big", maximum: o, inst: t }) - }) - }), - no = u("$ZodCheckBigIntFormat", (t, r) => { - V.init(t, r) - const [n, i] = xn[r.format] - t._zod.onattach.push((e) => { - const o = e._zod.bag - ;(o.format = r.format), (o.minimum = n), (o.maximum = i) - }), - (t._zod.check = (e) => { - const o = e.value - o < n && - e.issues.push({ - origin: "bigint", - input: o, - code: "too_small", - minimum: n, - inclusive: !0, - inst: t, - continue: !r.abort, - }), - o > i && e.issues.push({ origin: "bigint", input: o, code: "too_big", maximum: i, inst: t }) - }) - }), - oo = u("$ZodCheckMaxSize", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.size !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag.maximum ?? Number.POSITIVE_INFINITY - r.maximum < e && (i._zod.bag.maximum = r.maximum) - }), - (t._zod.check = (i) => { - const e = i.value - e.size <= r.maximum || - i.issues.push({ origin: rt(e), code: "too_big", maximum: r.maximum, input: e, inst: t, continue: !r.abort }) - }) - }), - io = u("$ZodCheckMinSize", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.size !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag.minimum ?? Number.NEGATIVE_INFINITY - r.minimum > e && (i._zod.bag.minimum = r.minimum) - }), - (t._zod.check = (i) => { - const e = i.value - e.size >= r.minimum || - i.issues.push({ origin: rt(e), code: "too_small", minimum: r.minimum, input: e, inst: t, continue: !r.abort }) - }) - }), - ao = u("$ZodCheckSizeEquals", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.size !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag - ;(e.minimum = r.size), (e.maximum = r.size), (e.size = r.size) - }), - (t._zod.check = (i) => { - const e = i.value, - o = e.size - if (o === r.size) return - const a = o > r.size - i.issues.push({ - origin: rt(e), - ...(a ? { code: "too_big", maximum: r.size } : { code: "too_small", minimum: r.size }), - inclusive: !0, - exact: !0, - input: i.value, - inst: t, - continue: !r.abort, - }) - }) - }), - so = u("$ZodCheckMaxLength", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.length !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag.maximum ?? Number.POSITIVE_INFINITY - r.maximum < e && (i._zod.bag.maximum = r.maximum) - }), - (t._zod.check = (i) => { - const e = i.value - if (e.length <= r.maximum) return - const a = nt(e) - i.issues.push({ - origin: a, - code: "too_big", - maximum: r.maximum, - inclusive: !0, - input: e, - inst: t, - continue: !r.abort, - }) - }) - }), - co = u("$ZodCheckMinLength", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.length !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag.minimum ?? Number.NEGATIVE_INFINITY - r.minimum > e && (i._zod.bag.minimum = r.minimum) - }), - (t._zod.check = (i) => { - const e = i.value - if (e.length >= r.minimum) return - const a = nt(e) - i.issues.push({ - origin: a, - code: "too_small", - minimum: r.minimum, - inclusive: !0, - input: e, - inst: t, - continue: !r.abort, - }) - }) - }), - uo = u("$ZodCheckLengthEquals", (t, r) => { - var n - V.init(t, r), - (n = t._zod.def).when ?? - (n.when = (i) => { - const e = i.value - return !he(e) && e.length !== void 0 - }), - t._zod.onattach.push((i) => { - const e = i._zod.bag - ;(e.minimum = r.length), (e.maximum = r.length), (e.length = r.length) - }), - (t._zod.check = (i) => { - const e = i.value, - o = e.length - if (o === r.length) return - const a = nt(e), - c = o > r.length - i.issues.push({ - origin: a, - ...(c ? { code: "too_big", maximum: r.length } : { code: "too_small", minimum: r.length }), - inclusive: !0, - exact: !0, - input: i.value, - inst: t, - continue: !r.abort, - }) - }) - }), - Re = u("$ZodCheckStringFormat", (t, r) => { - var n, i - V.init(t, r), - t._zod.onattach.push((e) => { - const o = e._zod.bag - ;(o.format = r.format), r.pattern && (o.patterns ?? (o.patterns = new Set()), o.patterns.add(r.pattern)) - }), - r.pattern - ? ((n = t._zod).check ?? - (n.check = (e) => { - ;(r.pattern.lastIndex = 0), - !r.pattern.test(e.value) && - e.issues.push({ - origin: "string", - code: "invalid_format", - format: r.format, - input: e.value, - ...(r.pattern ? { pattern: r.pattern.toString() } : {}), - inst: t, - continue: !r.abort, - }) - })) - : ((i = t._zod).check ?? (i.check = () => {})) - }), - lo = u("$ZodCheckRegex", (t, r) => { - Re.init(t, r), - (t._zod.check = (n) => { - ;(r.pattern.lastIndex = 0), - !r.pattern.test(n.value) && - n.issues.push({ - origin: "string", - code: "invalid_format", - format: "regex", - input: n.value, - pattern: r.pattern.toString(), - inst: t, - continue: !r.abort, - }) - }) - }), - mo = u("$ZodCheckLowerCase", (t, r) => { - r.pattern ?? (r.pattern = Yn), Re.init(t, r) - }), - po = u("$ZodCheckUpperCase", (t, r) => { - r.pattern ?? (r.pattern = eo), Re.init(t, r) - }), - fo = u("$ZodCheckIncludes", (t, r) => { - V.init(t, r) - const n = ue(r.includes), - i = new RegExp(typeof r.position == "number" ? `^.{${r.position}}${n}` : n) - ;(r.pattern = i), - t._zod.onattach.push((e) => { - const o = e._zod.bag - o.patterns ?? (o.patterns = new Set()), o.patterns.add(i) - }), - (t._zod.check = (e) => { - e.value.includes(r.includes, r.position) || - e.issues.push({ - origin: "string", - code: "invalid_format", - format: "includes", - includes: r.includes, - input: e.value, - inst: t, - continue: !r.abort, - }) - }) - }), - go = u("$ZodCheckStartsWith", (t, r) => { - V.init(t, r) - const n = new RegExp(`^${ue(r.prefix)}.*`) - r.pattern ?? (r.pattern = n), - t._zod.onattach.push((i) => { - const e = i._zod.bag - e.patterns ?? (e.patterns = new Set()), e.patterns.add(n) - }), - (t._zod.check = (i) => { - i.value.startsWith(r.prefix) || - i.issues.push({ - origin: "string", - code: "invalid_format", - format: "starts_with", - prefix: r.prefix, - input: i.value, - inst: t, - continue: !r.abort, - }) - }) - }), - ho = u("$ZodCheckEndsWith", (t, r) => { - V.init(t, r) - const n = new RegExp(`.*${ue(r.suffix)}$`) - r.pattern ?? (r.pattern = n), - t._zod.onattach.push((i) => { - const e = i._zod.bag - e.patterns ?? (e.patterns = new Set()), e.patterns.add(n) - }), - (t._zod.check = (i) => { - i.value.endsWith(r.suffix) || - i.issues.push({ - origin: "string", - code: "invalid_format", - format: "ends_with", - suffix: r.suffix, - input: i.value, - inst: t, - continue: !r.abort, - }) - }) - }) -function Os(t, r, n) { - t.issues.length && r.issues.push(...Y(n, t.issues)) -} -var vo = u("$ZodCheckProperty", (t, r) => { - V.init(t, r), - (t._zod.check = (n) => { - const i = r.schema._zod.run({ value: n.value[r.property], issues: [] }, {}) - if (i instanceof Promise) return i.then((e) => Os(e, n, r.property)) - Os(i, n, r.property) - }) - }), - bo = u("$ZodCheckMimeType", (t, r) => { - V.init(t, r) - const n = new Set(r.mime) - t._zod.onattach.push((i) => { - i._zod.bag.mime = r.mime - }), - (t._zod.check = (i) => { - n.has(i.value.type) || i.issues.push({ code: "invalid_value", values: r.mime, input: i.value.type, inst: t }) - }) - }), - _o = u("$ZodCheckOverwrite", (t, r) => { - V.init(t, r), - (t._zod.check = (n) => { - n.value = r.tx(n.value) - }) - }) -var lt = class { - constructor(r = []) { - ;(this.content = []), (this.indent = 0), this && (this.args = r) - } - indented(r) { - ;(this.indent += 1), r(this), (this.indent -= 1) - } - write(r) { - if (typeof r == "function") { - r(this, { execution: "sync" }), r(this, { execution: "async" }) - return - } - const i = r - .split(` -`) - .filter((a) => a), - e = Math.min(...i.map((a) => a.length - a.trimStart().length)), - o = i.map((a) => a.slice(e)).map((a) => " ".repeat(this.indent * 2) + a) - for (const a of o) this.content.push(a) - } - compile() { - const r = Function, - n = this?.args, - e = [...(this?.content ?? [""]).map((o) => ` ${o}`)] - return new r( - ...n, - e.join(` -`), - ) - } -} -var yo = { major: 4, minor: 0, patch: 0 } -var j = u("$ZodType", (t, r) => { - var n - t ?? (t = {}), (t._zod.def = r), (t._zod.bag = t._zod.bag || {}), (t._zod.version = yo) - const i = [...(t._zod.def.checks ?? [])] - t._zod.traits.has("$ZodCheck") && i.unshift(t) - for (const e of i) for (const o of e._zod.onattach) o(t) - if (i.length === 0) - (n = t._zod).deferred ?? (n.deferred = []), - t._zod.deferred?.push(() => { - t._zod.run = t._zod.parse - }) - else { - const e = (o, a, c) => { - let p = ze(o), - h - for (const g of a) { - if (g._zod.def.when) { - if (!g._zod.def.when(o)) continue - } else if (p) continue - const m = o.issues.length, - $ = g._zod.check(o) - if ($ instanceof Promise && c?.async === !1) throw new se() - if (h || $ instanceof Promise) - h = (h ?? Promise.resolve()).then(async () => { - await $, o.issues.length !== m && (p || (p = ze(o, m))) - }) - else { - if (o.issues.length === m) continue - p || (p = ze(o, m)) - } - } - return h ? h.then(() => o) : o - } - t._zod.run = (o, a) => { - const c = t._zod.parse(o, a) - if (c instanceof Promise) { - if (a.async === !1) throw new se() - return c.then((p) => e(p, i, a)) - } - return e(c, i, a) - } - } - t["~standard"] = { - validate: (e) => { - try { - const o = De(t, e) - return o.success ? { value: o.data } : { issues: o.error?.issues } - } catch { - return ut(t, e).then((a) => (a.success ? { value: a.data } : { issues: a.error?.issues })) - } - }, - vendor: "zod", - version: 1, - } - }), - we = u("$ZodString", (t, r) => { - j.init(t, r), - (t._zod.pattern = [...(t?._zod.bag?.patterns ?? [])].pop() ?? Jn(t._zod.bag)), - (t._zod.parse = (n, i) => { - if (r.coerce) - try { - n.value = String(n.value) - } catch {} - return ( - typeof n.value == "string" || - n.issues.push({ expected: "string", code: "invalid_type", input: n.value, inst: t }), - n - ) - }) - }), - A = u("$ZodStringFormat", (t, r) => { - Re.init(t, r), we.init(t, r) - }), - xo = u("$ZodGUID", (t, r) => { - r.pattern ?? (r.pattern = Un), A.init(t, r) - }), - zo = u("$ZodUUID", (t, r) => { - if (r.version) { - const i = { v1: 1, v2: 2, v3: 3, v4: 4, v5: 5, v6: 6, v7: 7, v8: 8 }[r.version] - if (i === void 0) throw new Error(`Invalid UUID version: "${r.version}"`) - r.pattern ?? (r.pattern = ke(i)) - } else r.pattern ?? (r.pattern = ke()) - A.init(t, r) - }), - ko = u("$ZodEmail", (t, r) => { - r.pattern ?? (r.pattern = Zn), A.init(t, r) - }), - So = u("$ZodURL", (t, r) => { - A.init(t, r), - (t._zod.check = (n) => { - try { - const i = n.value, - e = new URL(i), - o = e.href - r.hostname && - ((r.hostname.lastIndex = 0), - r.hostname.test(e.hostname) || - n.issues.push({ - code: "invalid_format", - format: "url", - note: "Invalid hostname", - pattern: Mn.source, - input: n.value, - inst: t, - continue: !r.abort, - })), - r.protocol && - ((r.protocol.lastIndex = 0), - r.protocol.test(e.protocol.endsWith(":") ? e.protocol.slice(0, -1) : e.protocol) || - n.issues.push({ - code: "invalid_format", - format: "url", - note: "Invalid protocol", - pattern: r.protocol.source, - input: n.value, - inst: t, - continue: !r.abort, - })), - !i.endsWith("/") && o.endsWith("/") ? (n.value = o.slice(0, -1)) : (n.value = o) - return - } catch { - n.issues.push({ code: "invalid_format", format: "url", input: n.value, inst: t, continue: !r.abort }) - } - }) - }), - wo = u("$ZodEmoji", (t, r) => { - r.pattern ?? (r.pattern = Dn()), A.init(t, r) - }), - Io = u("$ZodNanoID", (t, r) => { - r.pattern ?? (r.pattern = On), A.init(t, r) - }), - jo = u("$ZodCUID", (t, r) => { - r.pattern ?? (r.pattern = wn), A.init(t, r) - }), - Po = u("$ZodCUID2", (t, r) => { - r.pattern ?? (r.pattern = In), A.init(t, r) - }), - To = u("$ZodULID", (t, r) => { - r.pattern ?? (r.pattern = jn), A.init(t, r) - }), - Oo = u("$ZodXID", (t, r) => { - r.pattern ?? (r.pattern = Pn), A.init(t, r) - }), - No = u("$ZodKSUID", (t, r) => { - r.pattern ?? (r.pattern = Tn), A.init(t, r) - }), - Uo = u("$ZodISODateTime", (t, r) => { - r.pattern ?? (r.pattern = Hn(r)), A.init(t, r) - }), - Zo = u("$ZodISODate", (t, r) => { - r.pattern ?? (r.pattern = Vn), A.init(t, r) - }), - Do = u("$ZodISOTime", (t, r) => { - r.pattern ?? (r.pattern = Fn(r)), A.init(t, r) - }), - Ro = u("$ZodISODuration", (t, r) => { - r.pattern ?? (r.pattern = Nn), A.init(t, r) - }), - Eo = u("$ZodIPv4", (t, r) => { - r.pattern ?? (r.pattern = Rn), - A.init(t, r), - t._zod.onattach.push((n) => { - const i = n._zod.bag - i.format = "ipv4" - }) - }), - Ao = u("$ZodIPv6", (t, r) => { - r.pattern ?? (r.pattern = En), - A.init(t, r), - t._zod.onattach.push((n) => { - const i = n._zod.bag - i.format = "ipv6" - }), - (t._zod.check = (n) => { - try { - new URL(`http://[${n.value}]`) - } catch { - n.issues.push({ code: "invalid_format", format: "ipv6", input: n.value, inst: t, continue: !r.abort }) - } - }) - }), - Co = u("$ZodCIDRv4", (t, r) => { - r.pattern ?? (r.pattern = An), A.init(t, r) - }), - Lo = u("$ZodCIDRv6", (t, r) => { - r.pattern ?? (r.pattern = Cn), - A.init(t, r), - (t._zod.check = (n) => { - const [i, e] = n.value.split("/") - try { - if (!e) throw new Error() - const o = Number(e) - if (`${o}` !== e) throw new Error() - if (o < 0 || o > 128) throw new Error() - new URL(`http://[${i}]`) - } catch { - n.issues.push({ code: "invalid_format", format: "cidrv6", input: n.value, inst: t, continue: !r.abort }) - } - }) - }) -function Mo(t) { - if (t === "") return !0 - if (t.length % 4 !== 0) return !1 - try { - return atob(t), !0 - } catch { - return !1 - } -} -var qo = u("$ZodBase64", (t, r) => { - r.pattern ?? (r.pattern = Ln), - A.init(t, r), - t._zod.onattach.push((n) => { - n._zod.bag.contentEncoding = "base64" - }), - (t._zod.check = (n) => { - Mo(n.value) || - n.issues.push({ code: "invalid_format", format: "base64", input: n.value, inst: t, continue: !r.abort }) - }) -}) -function Hs(t) { - if (!rr.test(t)) return !1 - const r = t.replace(/[-_]/g, (i) => (i === "-" ? "+" : "/")), - n = r.padEnd(Math.ceil(r.length / 4) * 4, "=") - return Mo(n) -} -var Vo = u("$ZodBase64URL", (t, r) => { - r.pattern ?? (r.pattern = rr), - A.init(t, r), - t._zod.onattach.push((n) => { - n._zod.bag.contentEncoding = "base64url" - }), - (t._zod.check = (n) => { - Hs(n.value) || - n.issues.push({ code: "invalid_format", format: "base64url", input: n.value, inst: t, continue: !r.abort }) - }) - }), - Fo = u("$ZodE164", (t, r) => { - r.pattern ?? (r.pattern = qn), A.init(t, r) - }) -function Js(t, r = null) { - try { - const n = t.split(".") - if (n.length !== 3) return !1 - const [i] = n - if (!i) return !1 - const e = JSON.parse(atob(i)) - return !(("typ" in e && e?.typ !== "JWT") || !e.alg || (r && (!("alg" in e) || e.alg !== r))) - } catch { - return !1 - } -} -var Ho = u("$ZodJWT", (t, r) => { - A.init(t, r), - (t._zod.check = (n) => { - Js(n.value, r.alg) || - n.issues.push({ code: "invalid_format", format: "jwt", input: n.value, inst: t, continue: !r.abort }) - }) - }), - Jo = u("$ZodCustomStringFormat", (t, r) => { - A.init(t, r), - (t._zod.check = (n) => { - r.fn(n.value) || - n.issues.push({ code: "invalid_format", format: r.format, input: n.value, inst: t, continue: !r.abort }) - }) - }), - sr = u("$ZodNumber", (t, r) => { - j.init(t, r), - (t._zod.pattern = t._zod.bag.pattern ?? Gn), - (t._zod.parse = (n, i) => { - if (r.coerce) - try { - n.value = Number(n.value) - } catch {} - const e = n.value - if (typeof e == "number" && !Number.isNaN(e) && Number.isFinite(e)) return n - const o = typeof e == "number" ? (Number.isNaN(e) ? "NaN" : Number.isFinite(e) ? void 0 : "Infinity") : void 0 - return ( - n.issues.push({ expected: "number", code: "invalid_type", input: e, inst: t, ...(o ? { received: o } : {}) }), - n - ) - }) - }), - Bo = u("$ZodNumber", (t, r) => { - ro.init(t, r), sr.init(t, r) - }), - mt = u("$ZodBoolean", (t, r) => { - j.init(t, r), - (t._zod.pattern = Kn), - (t._zod.parse = (n, i) => { - if (r.coerce) - try { - n.value = !!n.value - } catch {} - const e = n.value - return ( - typeof e == "boolean" || n.issues.push({ expected: "boolean", code: "invalid_type", input: e, inst: t }), n - ) - }) - }), - cr = u("$ZodBigInt", (t, r) => { - j.init(t, r), - (t._zod.pattern = Bn), - (t._zod.parse = (n, i) => { - if (r.coerce) - try { - n.value = BigInt(n.value) - } catch {} - return ( - typeof n.value == "bigint" || - n.issues.push({ expected: "bigint", code: "invalid_type", input: n.value, inst: t }), - n - ) - }) - }), - Wo = u("$ZodBigInt", (t, r) => { - no.init(t, r), cr.init(t, r) - }), - Go = u("$ZodSymbol", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - return typeof e == "symbol" || n.issues.push({ expected: "symbol", code: "invalid_type", input: e, inst: t }), n - }) - }), - Ko = u("$ZodUndefined", (t, r) => { - j.init(t, r), - (t._zod.pattern = Xn), - (t._zod.values = new Set([void 0])), - (t._zod.optin = "optional"), - (t._zod.optout = "optional"), - (t._zod.parse = (n, i) => { - const e = n.value - return typeof e > "u" || n.issues.push({ expected: "undefined", code: "invalid_type", input: e, inst: t }), n - }) - }), - Qo = u("$ZodNull", (t, r) => { - j.init(t, r), - (t._zod.pattern = Qn), - (t._zod.values = new Set([null])), - (t._zod.parse = (n, i) => { - const e = n.value - return e === null || n.issues.push({ expected: "null", code: "invalid_type", input: e, inst: t }), n - }) - }), - Xo = u("$ZodAny", (t, r) => { - j.init(t, r), (t._zod.parse = (n) => n) - }), - Ee = u("$ZodUnknown", (t, r) => { - j.init(t, r), (t._zod.parse = (n) => n) - }), - Yo = u("$ZodNever", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => ( - n.issues.push({ expected: "never", code: "invalid_type", input: n.value, inst: t }), n - )) - }), - ei = u("$ZodVoid", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - return typeof e > "u" || n.issues.push({ expected: "void", code: "invalid_type", input: e, inst: t }), n - }) - }), - ti = u("$ZodDate", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - if (r.coerce) - try { - n.value = new Date(n.value) - } catch {} - const e = n.value, - o = e instanceof Date - return ( - (o && !Number.isNaN(e.getTime())) || - n.issues.push({ - expected: "date", - code: "invalid_type", - input: e, - ...(o ? { received: "Invalid Date" } : {}), - inst: t, - }), - n - ) - }) - }) -function Zs(t, r, n) { - t.issues.length && r.issues.push(...Y(n, t.issues)), (r.value[n] = t.value) -} -var pt = u("$ZodArray", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - if (!Array.isArray(e)) return n.issues.push({ expected: "array", code: "invalid_type", input: e, inst: t }), n - n.value = Array(e.length) - const o = [] - for (let a = 0; a < e.length; a++) { - const c = e[a], - p = r.element._zod.run({ value: c, issues: [] }, i) - p instanceof Promise ? o.push(p.then((h) => Zs(h, n, a))) : Zs(p, n, a) - } - return o.length ? Promise.all(o).then(() => n) : n - }) -}) -function ir(t, r, n) { - t.issues.length && r.issues.push(...Y(n, t.issues)), (r.value[n] = t.value) -} -function Ds(t, r, n, i) { - t.issues.length - ? i[n] === void 0 - ? n in i - ? (r.value[n] = void 0) - : (r.value[n] = t.value) - : r.issues.push(...Y(n, t.issues)) - : t.value === void 0 - ? n in i && (r.value[n] = void 0) - : (r.value[n] = t.value) -} -var ri = u("$ZodObject", (t, r) => { - j.init(t, r) - const n = Ye(() => { - const m = Object.keys(r.shape) - for (const b of m) - if (!(r.shape[b] instanceof j)) throw new Error(`Invalid element at key "${b}": expected a Zod schema`) - const $ = yn(r.shape) - return { shape: r.shape, keys: m, keySet: new Set(m), numKeys: m.length, optionalKeys: new Set($) } - }) - U(t._zod, "propValues", () => { - const m = r.shape, - $ = {} - for (const b in m) { - const d = m[b]._zod - if (d.values) { - $[b] ?? ($[b] = new Set()) - for (const x of d.values) $[b].add(x) - } - } - return $ - }) - let i = (m) => { - const $ = new lt(["shape", "payload", "ctx"]), - b = n.value, - d = (S) => { - const I = xe(S) - return `shape[${I}]._zod.run({ value: input[${I}], issues: [] }, ctx)` - } - $.write("const input = payload.value;") - let x = Object.create(null), - k = 0 - for (const S of b.keys) x[S] = `key_${k++}` - $.write("const newResult = {}") - for (const S of b.keys) - if (b.optionalKeys.has(S)) { - const I = x[S] - $.write(`const ${I} = ${d(S)};`) - const O = xe(S) - $.write(` - if (${I}.issues.length) { - if (input[${O}] === undefined) { - if (${O} in input) { - newResult[${O}] = undefined; - } - } else { - payload.issues = payload.issues.concat( - ${I}.issues.map((iss) => ({ - ...iss, - path: iss.path ? [${O}, ...iss.path] : [${O}], - })) - ); - } - } else if (${I}.value === undefined) { - if (${O} in input) newResult[${O}] = undefined; - } else { - newResult[${O}] = ${I}.value; - } - `) - } else { - const I = x[S] - $.write(`const ${I} = ${d(S)};`), - $.write(` - if (${I}.issues.length) payload.issues = payload.issues.concat(${I}.issues.map(iss => ({ - ...iss, - path: iss.path ? [${xe(S)}, ...iss.path] : [${xe(S)}] - })));`), - $.write(`newResult[${xe(S)}] = ${I}.value`) - } - $.write("payload.value = newResult;"), $.write("return payload;") - const D = $.compile() - return (S, I) => D(m, S, I) - }, - e, - o = Ne, - a = !Ke.jitless, - p = a && bn.value, - h = r.catchall, - g - t._zod.parse = (m, $) => { - g ?? (g = n.value) - const b = m.value - if (!o(b)) return m.issues.push({ expected: "object", code: "invalid_type", input: b, inst: t }), m - const d = [] - if (a && p && $?.async === !1 && $.jitless !== !0) e || (e = i(r.shape)), (m = e(m, $)) - else { - m.value = {} - const I = g.shape - for (const O of g.keys) { - const ye = I[O], - Kt = ye._zod.run({ value: b[O], issues: [] }, $), - ks = ye._zod.optin === "optional" && ye._zod.optout === "optional" - Kt instanceof Promise - ? d.push(Kt.then((Ss) => (ks ? Ds(Ss, m, O, b) : ir(Ss, m, O)))) - : ks - ? Ds(Kt, m, O, b) - : ir(Kt, m, O) - } - } - if (!h) return d.length ? Promise.all(d).then(() => m) : m - const x = [], - k = g.keySet, - D = h._zod, - S = D.def.type - for (const I of Object.keys(b)) { - if (k.has(I)) continue - if (S === "never") { - x.push(I) - continue - } - const O = D.run({ value: b[I], issues: [] }, $) - O instanceof Promise ? d.push(O.then((ye) => ir(ye, m, I))) : ir(O, m, I) - } - return ( - x.length && m.issues.push({ code: "unrecognized_keys", keys: x, input: b, inst: t }), - d.length ? Promise.all(d).then(() => m) : m - ) - } -}) -function Rs(t, r, n, i) { - for (const e of t) if (e.issues.length === 0) return (r.value = e.value), r - return ( - r.issues.push({ - code: "invalid_union", - input: r.value, - inst: n, - errors: t.map((e) => e.issues.map((o) => re(o, i, F()))), - }), - r - ) -} -var ur = u("$ZodUnion", (t, r) => { - j.init(t, r), - U(t._zod, "optin", () => (r.options.some((n) => n._zod.optin === "optional") ? "optional" : void 0)), - U(t._zod, "optout", () => (r.options.some((n) => n._zod.optout === "optional") ? "optional" : void 0)), - U(t._zod, "values", () => { - if (r.options.every((n) => n._zod.values)) return new Set(r.options.flatMap((n) => Array.from(n._zod.values))) - }), - U(t._zod, "pattern", () => { - if (r.options.every((n) => n._zod.pattern)) { - const n = r.options.map((i) => i._zod.pattern) - return new RegExp(`^(${n.map((i) => et(i.source)).join("|")})$`) - } - }), - (t._zod.parse = (n, i) => { - let e = !1, - o = [] - for (const a of r.options) { - const c = a._zod.run({ value: n.value, issues: [] }, i) - if (c instanceof Promise) o.push(c), (e = !0) - else { - if (c.issues.length === 0) return c - o.push(c) - } - } - return e ? Promise.all(o).then((a) => Rs(a, n, t, i)) : Rs(o, n, t, i) - }) - }), - ni = u("$ZodDiscriminatedUnion", (t, r) => { - ur.init(t, r) - const n = t._zod.parse - U(t._zod, "propValues", () => { - const e = {} - for (const o of r.options) { - const a = o._zod.propValues - if (!a || Object.keys(a).length === 0) - throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(o)}"`) - for (const [c, p] of Object.entries(a)) { - e[c] || (e[c] = new Set()) - for (const h of p) e[c].add(h) - } - } - return e - }) - const i = Ye(() => { - const e = r.options, - o = new Map() - for (const a of e) { - const c = a._zod.propValues[r.discriminator] - if (!c || c.size === 0) throw new Error(`Invalid discriminated union option at index "${r.options.indexOf(a)}"`) - for (const p of c) { - if (o.has(p)) throw new Error(`Duplicate discriminator value "${String(p)}"`) - o.set(p, a) - } - } - return o - }) - t._zod.parse = (e, o) => { - const a = e.value - if (!Ne(a)) return e.issues.push({ code: "invalid_type", expected: "object", input: a, inst: t }), e - const c = i.value.get(a?.[r.discriminator]) - return c - ? c._zod.run(e, o) - : r.unionFallback - ? n(e, o) - : (e.issues.push({ - code: "invalid_union", - errors: [], - note: "No matching discriminator", - input: a, - path: [r.discriminator], - inst: t, - }), - e) - } - }), - oi = u("$ZodIntersection", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value, - o = r.left._zod.run({ value: e, issues: [] }, i), - a = r.right._zod.run({ value: e, issues: [] }, i) - return o instanceof Promise || a instanceof Promise - ? Promise.all([o, a]).then(([p, h]) => Es(n, p, h)) - : Es(n, o, a) - }) - }) -function $o(t, r) { - if (t === r) return { valid: !0, data: t } - if (t instanceof Date && r instanceof Date && +t == +r) return { valid: !0, data: t } - if (Ue(t) && Ue(r)) { - const n = Object.keys(r), - i = Object.keys(t).filter((o) => n.indexOf(o) !== -1), - e = { ...t, ...r } - for (const o of i) { - const a = $o(t[o], r[o]) - if (!a.valid) return { valid: !1, mergeErrorPath: [o, ...a.mergeErrorPath] } - e[o] = a.data - } - return { valid: !0, data: e } - } - if (Array.isArray(t) && Array.isArray(r)) { - if (t.length !== r.length) return { valid: !1, mergeErrorPath: [] } - const n = [] - for (let i = 0; i < t.length; i++) { - const e = t[i], - o = r[i], - a = $o(e, o) - if (!a.valid) return { valid: !1, mergeErrorPath: [i, ...a.mergeErrorPath] } - n.push(a.data) - } - return { valid: !0, data: n } - } - return { valid: !1, mergeErrorPath: [] } -} -function Es(t, r, n) { - if ((r.issues.length && t.issues.push(...r.issues), n.issues.length && t.issues.push(...n.issues), ze(t))) return t - const i = $o(r.value, n.value) - if (!i.valid) throw new Error(`Unmergable intersection. Error path: ${JSON.stringify(i.mergeErrorPath)}`) - return (t.value = i.data), t -} -var Ie = u("$ZodTuple", (t, r) => { - j.init(t, r) - const n = r.items, - i = n.length - [...n].reverse().findIndex((e) => e._zod.optin !== "optional") - t._zod.parse = (e, o) => { - const a = e.value - if (!Array.isArray(a)) return e.issues.push({ input: a, inst: t, expected: "tuple", code: "invalid_type" }), e - e.value = [] - const c = [] - if (!r.rest) { - const h = a.length > n.length, - g = a.length < i - 1 - if (h || g) - return ( - e.issues.push({ - input: a, - inst: t, - origin: "array", - ...(h ? { code: "too_big", maximum: n.length } : { code: "too_small", minimum: n.length }), - }), - e - ) - } - let p = -1 - for (const h of n) { - if ((p++, p >= a.length && p >= i)) continue - const g = h._zod.run({ value: a[p], issues: [] }, o) - g instanceof Promise ? c.push(g.then((m) => ar(m, e, p))) : ar(g, e, p) - } - if (r.rest) { - const h = a.slice(n.length) - for (const g of h) { - p++ - const m = r.rest._zod.run({ value: g, issues: [] }, o) - m instanceof Promise ? c.push(m.then(($) => ar($, e, p))) : ar(m, e, p) - } - } - return c.length ? Promise.all(c).then(() => e) : e - } -}) -function ar(t, r, n) { - t.issues.length && r.issues.push(...Y(n, t.issues)), (r.value[n] = t.value) -} -var ii = u("$ZodRecord", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - if (!Ue(e)) return n.issues.push({ expected: "record", code: "invalid_type", input: e, inst: t }), n - const o = [] - if (r.keyType._zod.values) { - const a = r.keyType._zod.values - n.value = {} - for (const p of a) - if (typeof p == "string" || typeof p == "number" || typeof p == "symbol") { - const h = r.valueType._zod.run({ value: e[p], issues: [] }, i) - h instanceof Promise - ? o.push( - h.then((g) => { - g.issues.length && n.issues.push(...Y(p, g.issues)), (n.value[p] = g.value) - }), - ) - : (h.issues.length && n.issues.push(...Y(p, h.issues)), (n.value[p] = h.value)) - } - let c - for (const p in e) a.has(p) || ((c = c ?? []), c.push(p)) - c && c.length > 0 && n.issues.push({ code: "unrecognized_keys", input: e, inst: t, keys: c }) - } else { - n.value = {} - for (const a of Reflect.ownKeys(e)) { - if (a === "__proto__") continue - const c = r.keyType._zod.run({ value: a, issues: [] }, i) - if (c instanceof Promise) throw new Error("Async schemas not supported in object keys currently") - if (c.issues.length) { - n.issues.push({ - origin: "record", - code: "invalid_key", - issues: c.issues.map((h) => re(h, i, F())), - input: a, - path: [a], - inst: t, - }), - (n.value[c.value] = c.value) - continue - } - const p = r.valueType._zod.run({ value: e[a], issues: [] }, i) - p instanceof Promise - ? o.push( - p.then((h) => { - h.issues.length && n.issues.push(...Y(a, h.issues)), (n.value[c.value] = h.value) - }), - ) - : (p.issues.length && n.issues.push(...Y(a, p.issues)), (n.value[c.value] = p.value)) - } - } - return o.length ? Promise.all(o).then(() => n) : n - }) - }), - ai = u("$ZodMap", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - if (!(e instanceof Map)) return n.issues.push({ expected: "map", code: "invalid_type", input: e, inst: t }), n - const o = [] - n.value = new Map() - for (const [a, c] of e) { - const p = r.keyType._zod.run({ value: a, issues: [] }, i), - h = r.valueType._zod.run({ value: c, issues: [] }, i) - p instanceof Promise || h instanceof Promise - ? o.push( - Promise.all([p, h]).then(([g, m]) => { - As(g, m, n, a, e, t, i) - }), - ) - : As(p, h, n, a, e, t, i) - } - return o.length ? Promise.all(o).then(() => n) : n - }) - }) -function As(t, r, n, i, e, o, a) { - t.issues.length && - (tt.has(typeof i) - ? n.issues.push(...Y(i, t.issues)) - : n.issues.push({ - origin: "map", - code: "invalid_key", - input: e, - inst: o, - issues: t.issues.map((c) => re(c, a, F())), - })), - r.issues.length && - (tt.has(typeof i) - ? n.issues.push(...Y(i, r.issues)) - : n.issues.push({ - origin: "map", - code: "invalid_element", - input: e, - inst: o, - key: i, - issues: r.issues.map((c) => re(c, a, F())), - })), - n.value.set(t.value, r.value) -} -var si = u("$ZodSet", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - if (!(e instanceof Set)) return n.issues.push({ input: e, inst: t, expected: "set", code: "invalid_type" }), n - const o = [] - n.value = new Set() - for (const a of e) { - const c = r.valueType._zod.run({ value: a, issues: [] }, i) - c instanceof Promise ? o.push(c.then((p) => Cs(p, n))) : Cs(c, n) - } - return o.length ? Promise.all(o).then(() => n) : n - }) -}) -function Cs(t, r) { - t.issues.length && r.issues.push(...t.issues), r.value.add(t.value) -} -var ci = u("$ZodEnum", (t, r) => { - j.init(t, r) - const n = Xe(r.entries) - ;(t._zod.values = new Set(n)), - (t._zod.pattern = new RegExp( - `^(${n - .filter((i) => tt.has(typeof i)) - .map((i) => (typeof i == "string" ? ue(i) : i.toString())) - .join("|")})$`, - )), - (t._zod.parse = (i, e) => { - const o = i.value - return t._zod.values.has(o) || i.issues.push({ code: "invalid_value", values: n, input: o, inst: t }), i - }) - }), - ui = u("$ZodLiteral", (t, r) => { - j.init(t, r), - (t._zod.values = new Set(r.values)), - (t._zod.pattern = new RegExp( - `^(${r.values.map((n) => (typeof n == "string" ? ue(n) : n ? n.toString() : String(n))).join("|")})$`, - )), - (t._zod.parse = (n, i) => { - const e = n.value - return t._zod.values.has(e) || n.issues.push({ code: "invalid_value", values: r.values, input: e, inst: t }), n - }) - }), - li = u("$ZodFile", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = n.value - return e instanceof File || n.issues.push({ expected: "file", code: "invalid_type", input: e, inst: t }), n - }) - }), - dt = u("$ZodTransform", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = r.transform(n.value, n) - if (i.async) return (e instanceof Promise ? e : Promise.resolve(e)).then((a) => ((n.value = a), n)) - if (e instanceof Promise) throw new se() - return (n.value = e), n - }) - }), - mi = u("$ZodOptional", (t, r) => { - j.init(t, r), - (t._zod.optin = "optional"), - (t._zod.optout = "optional"), - U(t._zod, "values", () => (r.innerType._zod.values ? new Set([...r.innerType._zod.values, void 0]) : void 0)), - U(t._zod, "pattern", () => { - const n = r.innerType._zod.pattern - return n ? new RegExp(`^(${et(n.source)})?$`) : void 0 - }), - (t._zod.parse = (n, i) => - r.innerType._zod.optin === "optional" - ? r.innerType._zod.run(n, i) - : n.value === void 0 - ? n - : r.innerType._zod.run(n, i)) - }), - pi = u("$ZodNullable", (t, r) => { - j.init(t, r), - U(t._zod, "optin", () => r.innerType._zod.optin), - U(t._zod, "optout", () => r.innerType._zod.optout), - U(t._zod, "pattern", () => { - const n = r.innerType._zod.pattern - return n ? new RegExp(`^(${et(n.source)}|null)$`) : void 0 - }), - U(t._zod, "values", () => (r.innerType._zod.values ? new Set([...r.innerType._zod.values, null]) : void 0)), - (t._zod.parse = (n, i) => (n.value === null ? n : r.innerType._zod.run(n, i))) - }), - di = u("$ZodDefault", (t, r) => { - j.init(t, r), - (t._zod.optin = "optional"), - U(t._zod, "values", () => r.innerType._zod.values), - (t._zod.parse = (n, i) => { - if (n.value === void 0) return (n.value = r.defaultValue), n - const e = r.innerType._zod.run(n, i) - return e instanceof Promise ? e.then((o) => Ls(o, r)) : Ls(e, r) - }) - }) -function Ls(t, r) { - return t.value === void 0 && (t.value = r.defaultValue), t -} -var fi = u("$ZodPrefault", (t, r) => { - j.init(t, r), - (t._zod.optin = "optional"), - U(t._zod, "values", () => r.innerType._zod.values), - (t._zod.parse = (n, i) => (n.value === void 0 && (n.value = r.defaultValue), r.innerType._zod.run(n, i))) - }), - gi = u("$ZodNonOptional", (t, r) => { - j.init(t, r), - U(t._zod, "values", () => { - const n = r.innerType._zod.values - return n ? new Set([...n].filter((i) => i !== void 0)) : void 0 - }), - (t._zod.parse = (n, i) => { - const e = r.innerType._zod.run(n, i) - return e instanceof Promise ? e.then((o) => Ms(o, t)) : Ms(e, t) - }) - }) -function Ms(t, r) { - return ( - !t.issues.length && - t.value === void 0 && - t.issues.push({ code: "invalid_type", expected: "nonoptional", input: t.value, inst: r }), - t - ) -} -var hi = u("$ZodSuccess", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => { - const e = r.innerType._zod.run(n, i) - return e instanceof Promise - ? e.then((o) => ((n.value = o.issues.length === 0), n)) - : ((n.value = e.issues.length === 0), n) - }) - }), - vi = u("$ZodCatch", (t, r) => { - j.init(t, r), - (t._zod.optin = "optional"), - U(t._zod, "optout", () => r.innerType._zod.optout), - U(t._zod, "values", () => r.innerType._zod.values), - (t._zod.parse = (n, i) => { - const e = r.innerType._zod.run(n, i) - return e instanceof Promise - ? e.then( - (o) => ( - (n.value = o.value), - o.issues.length && - ((n.value = r.catchValue({ - ...n, - error: { issues: o.issues.map((a) => re(a, i, F())) }, - input: n.value, - })), - (n.issues = [])), - n - ), - ) - : ((n.value = e.value), - e.issues.length && - ((n.value = r.catchValue({ - ...n, - error: { issues: e.issues.map((o) => re(o, i, F())) }, - input: n.value, - })), - (n.issues = [])), - n) - }) - }), - bi = u("$ZodNaN", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => ( - (typeof n.value != "number" || !Number.isNaN(n.value)) && - n.issues.push({ input: n.value, inst: t, expected: "nan", code: "invalid_type" }), - n - )) - }), - ft = u("$ZodPipe", (t, r) => { - j.init(t, r), - U(t._zod, "values", () => r.in._zod.values), - U(t._zod, "optin", () => r.in._zod.optin), - U(t._zod, "optout", () => r.out._zod.optout), - (t._zod.parse = (n, i) => { - const e = r.in._zod.run(n, i) - return e instanceof Promise ? e.then((o) => qs(o, r, i)) : qs(e, r, i) - }) - }) -function qs(t, r, n) { - return ze(t) ? t : r.out._zod.run({ value: t.value, issues: t.issues }, n) -} -var _i = u("$ZodReadonly", (t, r) => { - j.init(t, r), - U(t._zod, "propValues", () => r.innerType._zod.propValues), - U(t._zod, "values", () => r.innerType._zod.values), - U(t._zod, "optin", () => r.innerType._zod.optin), - U(t._zod, "optout", () => r.innerType._zod.optout), - (t._zod.parse = (n, i) => { - const e = r.innerType._zod.run(n, i) - return e instanceof Promise ? e.then(Vs) : Vs(e) - }) -}) -function Vs(t) { - return (t.value = Object.freeze(t.value)), t -} -var yi = u("$ZodTemplateLiteral", (t, r) => { - j.init(t, r) - const n = [] - for (const i of r.parts) - if (i instanceof j) { - if (!i._zod.pattern) - throw new Error(`Invalid template literal part, no pattern found: ${[...i._zod.traits].shift()}`) - const e = i._zod.pattern instanceof RegExp ? i._zod.pattern.source : i._zod.pattern - if (!e) throw new Error(`Invalid template literal part: ${i._zod.traits}`) - const o = e.startsWith("^") ? 1 : 0, - a = e.endsWith("$") ? e.length - 1 : e.length - n.push(e.slice(o, a)) - } else if (i === null || _n.has(typeof i)) n.push(ue(`${i}`)) - else throw new Error(`Invalid template literal part: ${i}`) - ;(t._zod.pattern = new RegExp(`^${n.join("")}$`)), - (t._zod.parse = (i, e) => - typeof i.value != "string" - ? (i.issues.push({ input: i.value, inst: t, expected: "template_literal", code: "invalid_type" }), i) - : ((t._zod.pattern.lastIndex = 0), - t._zod.pattern.test(i.value) || - i.issues.push({ - input: i.value, - inst: t, - code: "invalid_format", - format: "template_literal", - pattern: t._zod.pattern.source, - }), - i)) - }), - $i = u("$ZodPromise", (t, r) => { - j.init(t, r), - (t._zod.parse = (n, i) => Promise.resolve(n.value).then((e) => r.innerType._zod.run({ value: e, issues: [] }, i))) - }), - xi = u("$ZodLazy", (t, r) => { - j.init(t, r), - U(t._zod, "innerType", () => r.getter()), - U(t._zod, "pattern", () => t._zod.innerType._zod.pattern), - U(t._zod, "propValues", () => t._zod.innerType._zod.propValues), - U(t._zod, "optin", () => t._zod.innerType._zod.optin), - U(t._zod, "optout", () => t._zod.innerType._zod.optout), - (t._zod.parse = (n, i) => t._zod.innerType._zod.run(n, i)) - }), - zi = u("$ZodCustom", (t, r) => { - V.init(t, r), - j.init(t, r), - (t._zod.parse = (n, i) => n), - (t._zod.check = (n) => { - const i = n.value, - e = r.fn(i) - if (e instanceof Promise) return e.then((o) => Fs(o, n, i, t)) - Fs(e, n, i, t) - }) - }) -function Fs(t, r, n, i) { - if (!t) { - const e = { code: "custom", input: n, inst: i, path: [...(i._zod.def.path ?? [])], continue: !i._zod.def.abort } - i._zod.def.params && (e.params = i._zod.def.params), r.issues.push(zn(e)) - } -} -var gt = {} -$e(gt, { - ar: () => Ws, - az: () => Gs, - be: () => Qs, - ca: () => Xs, - cs: () => Ys, - de: () => ec, - en: () => lr, - eo: () => tc, - es: () => rc, - fa: () => nc, - fi: () => oc, - fr: () => ic, - frCA: () => ac, - he: () => sc, - hu: () => cc, - id: () => uc, - it: () => lc, - ja: () => mc, - kh: () => pc, - ko: () => dc, - mk: () => fc, - ms: () => gc, - nl: () => hc, - no: () => vc, - ota: () => bc, - pl: () => yc, - ps: () => _c, - pt: () => $c, - ru: () => zc, - sl: () => kc, - sv: () => Sc, - ta: () => wc, - th: () => Ic, - tr: () => jc, - ua: () => Pc, - ur: () => Tc, - vi: () => Oc, - zhCN: () => Nc, - zhTW: () => Uc, -}) -var hl = () => { - const t = { - string: { unit: "\u062D\u0631\u0641", verb: "\u0623\u0646 \u064A\u062D\u0648\u064A" }, - file: { unit: "\u0628\u0627\u064A\u062A", verb: "\u0623\u0646 \u064A\u062D\u0648\u064A" }, - array: { unit: "\u0639\u0646\u0635\u0631", verb: "\u0623\u0646 \u064A\u062D\u0648\u064A" }, - set: { unit: "\u0639\u0646\u0635\u0631", verb: "\u0623\u0646 \u064A\u062D\u0648\u064A" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0645\u062F\u062E\u0644", - email: "\u0628\u0631\u064A\u062F \u0625\u0644\u0643\u062A\u0631\u0648\u0646\u064A", - url: "\u0631\u0627\u0628\u0637", - emoji: "\u0625\u064A\u0645\u0648\u062C\u064A", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u062A\u0627\u0631\u064A\u062E \u0648\u0648\u0642\u062A \u0628\u0645\u0639\u064A\u0627\u0631 ISO", - date: "\u062A\u0627\u0631\u064A\u062E \u0628\u0645\u0639\u064A\u0627\u0631 ISO", - time: "\u0648\u0642\u062A \u0628\u0645\u0639\u064A\u0627\u0631 ISO", - duration: "\u0645\u062F\u0629 \u0628\u0645\u0639\u064A\u0627\u0631 ISO", - ipv4: "\u0639\u0646\u0648\u0627\u0646 IPv4", - ipv6: "\u0639\u0646\u0648\u0627\u0646 IPv6", - cidrv4: "\u0645\u062F\u0649 \u0639\u0646\u0627\u0648\u064A\u0646 \u0628\u0635\u064A\u063A\u0629 IPv4", - cidrv6: "\u0645\u062F\u0649 \u0639\u0646\u0627\u0648\u064A\u0646 \u0628\u0635\u064A\u063A\u0629 IPv6", - base64: "\u0646\u064E\u0635 \u0628\u062A\u0631\u0645\u064A\u0632 base64-encoded", - base64url: "\u0646\u064E\u0635 \u0628\u062A\u0631\u0645\u064A\u0632 base64url-encoded", - json_string: "\u0646\u064E\u0635 \u0639\u0644\u0649 \u0647\u064A\u0626\u0629 JSON", - e164: "\u0631\u0642\u0645 \u0647\u0627\u062A\u0641 \u0628\u0645\u0639\u064A\u0627\u0631 E.164", - jwt: "JWT", - template_literal: "\u0645\u062F\u062E\u0644", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0645\u062F\u062E\u0644\u0627\u062A \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644\u0629: \u064A\u0641\u062A\u0631\u0636 \u0625\u062F\u062E\u0627\u0644 ${e.expected}\u060C \u0648\u0644\u0643\u0646 \u062A\u0645 \u0625\u062F\u062E\u0627\u0644 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u0645\u062F\u062E\u0644\u0627\u062A \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644\u0629: \u064A\u0641\u062A\u0631\u0636 \u0625\u062F\u062E\u0627\u0644 ${_(e.values[0])}` - : `\u0627\u062E\u062A\u064A\u0627\u0631 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062A\u0648\u0642\u0639 \u0627\u0646\u062A\u0642\u0627\u0621 \u0623\u062D\u062F \u0647\u0630\u0647 \u0627\u0644\u062E\u064A\u0627\u0631\u0627\u062A: ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? ` \u0623\u0643\u0628\u0631 \u0645\u0646 \u0627\u0644\u0644\u0627\u0632\u0645: \u064A\u0641\u062A\u0631\u0636 \u0623\u0646 \u062A\u0643\u0648\u0646 ${e.origin ?? "\u0627\u0644\u0642\u064A\u0645\u0629"} ${o} ${e.maximum.toString()} ${a.unit ?? "\u0639\u0646\u0635\u0631"}` - : `\u0623\u0643\u0628\u0631 \u0645\u0646 \u0627\u0644\u0644\u0627\u0632\u0645: \u064A\u0641\u062A\u0631\u0636 \u0623\u0646 \u062A\u0643\u0648\u0646 ${e.origin ?? "\u0627\u0644\u0642\u064A\u0645\u0629"} ${o} ${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u0623\u0635\u063A\u0631 \u0645\u0646 \u0627\u0644\u0644\u0627\u0632\u0645: \u064A\u0641\u062A\u0631\u0636 \u0644\u0640 ${e.origin} \u0623\u0646 \u064A\u0643\u0648\u0646 ${o} ${e.minimum.toString()} ${a.unit}` - : `\u0623\u0635\u063A\u0631 \u0645\u0646 \u0627\u0644\u0644\u0627\u0632\u0645: \u064A\u0641\u062A\u0631\u0636 \u0644\u0640 ${e.origin} \u0623\u0646 \u064A\u0643\u0648\u0646 ${o} ${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u0646\u064E\u0635 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062C\u0628 \u0623\u0646 \u064A\u0628\u062F\u0623 \u0628\u0640 "${e.prefix}"` - : o.format === "ends_with" - ? `\u0646\u064E\u0635 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062C\u0628 \u0623\u0646 \u064A\u0646\u062A\u0647\u064A \u0628\u0640 "${o.suffix}"` - : o.format === "includes" - ? `\u0646\u064E\u0635 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062C\u0628 \u0623\u0646 \u064A\u062A\u0636\u0645\u0651\u064E\u0646 "${o.includes}"` - : o.format === "regex" - ? `\u0646\u064E\u0635 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062C\u0628 \u0623\u0646 \u064A\u0637\u0627\u0628\u0642 \u0627\u0644\u0646\u0645\u0637 ${o.pattern}` - : `${i[o.format] ?? e.format} \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644` - } - case "not_multiple_of": - return `\u0631\u0642\u0645 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644: \u064A\u062C\u0628 \u0623\u0646 \u064A\u0643\u0648\u0646 \u0645\u0646 \u0645\u0636\u0627\u0639\u0641\u0627\u062A ${e.divisor}` - case "unrecognized_keys": - return `\u0645\u0639\u0631\u0641${e.keys.length > 1 ? "\u0627\u062A" : ""} \u063A\u0631\u064A\u0628${e.keys.length > 1 ? "\u0629" : ""}: ${f(e.keys, "\u060C ")}` - case "invalid_key": - return `\u0645\u0639\u0631\u0641 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644 \u0641\u064A ${e.origin}` - case "invalid_union": - return "\u0645\u062F\u062E\u0644 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644" - case "invalid_element": - return `\u0645\u062F\u062E\u0644 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644 \u0641\u064A ${e.origin}` - default: - return "\u0645\u062F\u062E\u0644 \u063A\u064A\u0631 \u0645\u0642\u0628\u0648\u0644" - } - } -} -function Ws() { - return { localeError: hl() } -} -var vl = () => { - const t = { - string: { unit: "simvol", verb: "olmal\u0131d\u0131r" }, - file: { unit: "bayt", verb: "olmal\u0131d\u0131r" }, - array: { unit: "element", verb: "olmal\u0131d\u0131r" }, - set: { unit: "element", verb: "olmal\u0131d\u0131r" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "input", - email: "email address", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datetime", - date: "ISO date", - time: "ISO time", - duration: "ISO duration", - ipv4: "IPv4 address", - ipv6: "IPv6 address", - cidrv4: "IPv4 range", - cidrv6: "IPv6 range", - base64: "base64-encoded string", - base64url: "base64url-encoded string", - json_string: "JSON string", - e164: "E.164 number", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Yanl\u0131\u015F d\u0259y\u0259r: g\xF6zl\u0259nil\u0259n ${e.expected}, daxil olan ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Yanl\u0131\u015F d\u0259y\u0259r: g\xF6zl\u0259nil\u0259n ${_(e.values[0])}` - : `Yanl\u0131\u015F se\xE7im: a\u015Fa\u011F\u0131dak\u0131lardan biri olmal\u0131d\u0131r: ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\xC7ox b\xF6y\xFCk: g\xF6zl\u0259nil\u0259n ${e.origin ?? "d\u0259y\u0259r"} ${o}${e.maximum.toString()} ${a.unit ?? "element"}` - : `\xC7ox b\xF6y\xFCk: g\xF6zl\u0259nil\u0259n ${e.origin ?? "d\u0259y\u0259r"} ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\xC7ox ki\xE7ik: g\xF6zl\u0259nil\u0259n ${e.origin} ${o}${e.minimum.toString()} ${a.unit}` - : `\xC7ox ki\xE7ik: g\xF6zl\u0259nil\u0259n ${e.origin} ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Yanl\u0131\u015F m\u0259tn: "${o.prefix}" il\u0259 ba\u015Flamal\u0131d\u0131r` - : o.format === "ends_with" - ? `Yanl\u0131\u015F m\u0259tn: "${o.suffix}" il\u0259 bitm\u0259lidir` - : o.format === "includes" - ? `Yanl\u0131\u015F m\u0259tn: "${o.includes}" daxil olmal\u0131d\u0131r` - : o.format === "regex" - ? `Yanl\u0131\u015F m\u0259tn: ${o.pattern} \u015Fablonuna uy\u011Fun olmal\u0131d\u0131r` - : `Yanl\u0131\u015F ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Yanl\u0131\u015F \u0259d\u0259d: ${e.divisor} il\u0259 b\xF6l\xFCn\u0259 bil\u0259n olmal\u0131d\u0131r` - case "unrecognized_keys": - return `Tan\u0131nmayan a\xE7ar${e.keys.length > 1 ? "lar" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `${e.origin} daxilind\u0259 yanl\u0131\u015F a\xE7ar` - case "invalid_union": - return "Yanl\u0131\u015F d\u0259y\u0259r" - case "invalid_element": - return `${e.origin} daxilind\u0259 yanl\u0131\u015F d\u0259y\u0259r` - default: - return "Yanl\u0131\u015F d\u0259y\u0259r" - } - } -} -function Gs() { - return { localeError: vl() } -} -function Ks(t, r, n, i) { - const e = Math.abs(t), - o = e % 10, - a = e % 100 - return a >= 11 && a <= 19 ? i : o === 1 ? r : o >= 2 && o <= 4 ? n : i -} -var bl = () => { - const t = { - string: { - unit: { - one: "\u0441\u0456\u043C\u0432\u0430\u043B", - few: "\u0441\u0456\u043C\u0432\u0430\u043B\u044B", - many: "\u0441\u0456\u043C\u0432\u0430\u043B\u0430\u045E", - }, - verb: "\u043C\u0435\u0446\u044C", - }, - array: { - unit: { - one: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442", - few: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u044B", - many: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u0430\u045E", - }, - verb: "\u043C\u0435\u0446\u044C", - }, - set: { - unit: { - one: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442", - few: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u044B", - many: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u0430\u045E", - }, - verb: "\u043C\u0435\u0446\u044C", - }, - file: { - unit: { - one: "\u0431\u0430\u0439\u0442", - few: "\u0431\u0430\u0439\u0442\u044B", - many: "\u0431\u0430\u0439\u0442\u0430\u045E", - }, - verb: "\u043C\u0435\u0446\u044C", - }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u043B\u0456\u043A" - case "object": { - if (Array.isArray(e)) return "\u043C\u0430\u0441\u0456\u045E" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0443\u0432\u043E\u0434", - email: "email \u0430\u0434\u0440\u0430\u0441", - url: "URL", - emoji: "\u044D\u043C\u043E\u0434\u0437\u0456", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \u0434\u0430\u0442\u0430 \u0456 \u0447\u0430\u0441", - date: "ISO \u0434\u0430\u0442\u0430", - time: "ISO \u0447\u0430\u0441", - duration: "ISO \u043F\u0440\u0430\u0446\u044F\u0433\u043B\u0430\u0441\u0446\u044C", - ipv4: "IPv4 \u0430\u0434\u0440\u0430\u0441", - ipv6: "IPv6 \u0430\u0434\u0440\u0430\u0441", - cidrv4: "IPv4 \u0434\u044B\u044F\u043F\u0430\u0437\u043E\u043D", - cidrv6: "IPv6 \u0434\u044B\u044F\u043F\u0430\u0437\u043E\u043D", - base64: "\u0440\u0430\u0434\u043E\u043A \u0443 \u0444\u0430\u0440\u043C\u0430\u0446\u0435 base64", - base64url: "\u0440\u0430\u0434\u043E\u043A \u0443 \u0444\u0430\u0440\u043C\u0430\u0446\u0435 base64url", - json_string: "JSON \u0440\u0430\u0434\u043E\u043A", - e164: "\u043D\u0443\u043C\u0430\u0440 E.164", - jwt: "JWT", - template_literal: "\u0443\u0432\u043E\u0434", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u045E\u0432\u043E\u0434: \u0447\u0430\u043A\u0430\u045E\u0441\u044F ${e.expected}, \u0430\u0442\u0440\u044B\u043C\u0430\u043D\u0430 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u045E\u0432\u043E\u0434: \u0447\u0430\u043A\u0430\u043B\u0430\u0441\u044F ${_(e.values[0])}` - : `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u0432\u0430\u0440\u044B\u044F\u043D\u0442: \u0447\u0430\u043A\u0430\u045E\u0441\u044F \u0430\u0434\u0437\u0456\u043D \u0437 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - if (a) { - const c = Number(e.maximum), - p = Ks(c, a.unit.one, a.unit.few, a.unit.many) - return `\u0417\u0430\u043D\u0430\u0434\u0442\u0430 \u0432\u044F\u043B\u0456\u043A\u0456: \u0447\u0430\u043A\u0430\u043B\u0430\u0441\u044F, \u0448\u0442\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u044D\u043D\u043D\u0435"} \u043F\u0430\u0432\u0456\u043D\u043D\u0430 ${a.verb} ${o}${e.maximum.toString()} ${p}` - } - return `\u0417\u0430\u043D\u0430\u0434\u0442\u0430 \u0432\u044F\u043B\u0456\u043A\u0456: \u0447\u0430\u043A\u0430\u043B\u0430\u0441\u044F, \u0448\u0442\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u044D\u043D\u043D\u0435"} \u043F\u0430\u0432\u0456\u043D\u043D\u0430 \u0431\u044B\u0446\u044C ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - if (a) { - const c = Number(e.minimum), - p = Ks(c, a.unit.one, a.unit.few, a.unit.many) - return `\u0417\u0430\u043D\u0430\u0434\u0442\u0430 \u043C\u0430\u043B\u044B: \u0447\u0430\u043A\u0430\u043B\u0430\u0441\u044F, \u0448\u0442\u043E ${e.origin} \u043F\u0430\u0432\u0456\u043D\u043D\u0430 ${a.verb} ${o}${e.minimum.toString()} ${p}` - } - return `\u0417\u0430\u043D\u0430\u0434\u0442\u0430 \u043C\u0430\u043B\u044B: \u0447\u0430\u043A\u0430\u043B\u0430\u0441\u044F, \u0448\u0442\u043E ${e.origin} \u043F\u0430\u0432\u0456\u043D\u043D\u0430 \u0431\u044B\u0446\u044C ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u0440\u0430\u0434\u043E\u043A: \u043F\u0430\u0432\u0456\u043D\u0435\u043D \u043F\u0430\u0447\u044B\u043D\u0430\u0446\u0446\u0430 \u0437 "${o.prefix}"` - : o.format === "ends_with" - ? `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u0440\u0430\u0434\u043E\u043A: \u043F\u0430\u0432\u0456\u043D\u0435\u043D \u0437\u0430\u043A\u0430\u043D\u0447\u0432\u0430\u0446\u0446\u0430 \u043D\u0430 "${o.suffix}"` - : o.format === "includes" - ? `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u0440\u0430\u0434\u043E\u043A: \u043F\u0430\u0432\u0456\u043D\u0435\u043D \u0437\u043C\u044F\u0448\u0447\u0430\u0446\u044C "${o.includes}"` - : o.format === "regex" - ? `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u0440\u0430\u0434\u043E\u043A: \u043F\u0430\u0432\u0456\u043D\u0435\u043D \u0430\u0434\u043F\u0430\u0432\u044F\u0434\u0430\u0446\u044C \u0448\u0430\u0431\u043B\u043E\u043D\u0443 ${o.pattern}` - : `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u043B\u0456\u043A: \u043F\u0430\u0432\u0456\u043D\u0435\u043D \u0431\u044B\u0446\u044C \u043A\u0440\u0430\u0442\u043D\u044B\u043C ${e.divisor}` - case "unrecognized_keys": - return `\u041D\u0435\u0440\u0430\u0441\u043F\u0430\u0437\u043D\u0430\u043D\u044B ${e.keys.length > 1 ? "\u043A\u043B\u044E\u0447\u044B" : "\u043A\u043B\u044E\u0447"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u043A\u043B\u044E\u0447 \u0443 ${e.origin}` - case "invalid_union": - return "\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u045E\u0432\u043E\u0434" - case "invalid_element": - return `\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u0430\u0435 \u0437\u043D\u0430\u0447\u044D\u043D\u043D\u0435 \u045E ${e.origin}` - default: - return "\u041D\u044F\u043F\u0440\u0430\u0432\u0456\u043B\u044C\u043D\u044B \u045E\u0432\u043E\u0434" - } - } -} -function Qs() { - return { localeError: bl() } -} -var _l = () => { - const t = { - string: { unit: "car\xE0cters", verb: "contenir" }, - file: { unit: "bytes", verb: "contenir" }, - array: { unit: "elements", verb: "contenir" }, - set: { unit: "elements", verb: "contenir" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "entrada", - email: "adre\xE7a electr\xF2nica", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data i hora ISO", - date: "data ISO", - time: "hora ISO", - duration: "durada ISO", - ipv4: "adre\xE7a IPv4", - ipv6: "adre\xE7a IPv6", - cidrv4: "rang IPv4", - cidrv6: "rang IPv6", - base64: "cadena codificada en base64", - base64url: "cadena codificada en base64url", - json_string: "cadena JSON", - e164: "n\xFAmero E.164", - jwt: "JWT", - template_literal: "entrada", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Tipus inv\xE0lid: s'esperava ${e.expected}, s'ha rebut ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Valor inv\xE0lid: s'esperava ${_(e.values[0])}` - : `Opci\xF3 inv\xE0lida: s'esperava una de ${f(e.values, " o ")}` - case "too_big": { - const o = e.inclusive ? "com a m\xE0xim" : "menys de", - a = r(e.origin) - return a - ? `Massa gran: s'esperava que ${e.origin ?? "el valor"} contingu\xE9s ${o} ${e.maximum.toString()} ${a.unit ?? "elements"}` - : `Massa gran: s'esperava que ${e.origin ?? "el valor"} fos ${o} ${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? "com a m\xEDnim" : "m\xE9s de", - a = r(e.origin) - return a - ? `Massa petit: s'esperava que ${e.origin} contingu\xE9s ${o} ${e.minimum.toString()} ${a.unit}` - : `Massa petit: s'esperava que ${e.origin} fos ${o} ${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Format inv\xE0lid: ha de comen\xE7ar amb "${o.prefix}"` - : o.format === "ends_with" - ? `Format inv\xE0lid: ha d'acabar amb "${o.suffix}"` - : o.format === "includes" - ? `Format inv\xE0lid: ha d'incloure "${o.includes}"` - : o.format === "regex" - ? `Format inv\xE0lid: ha de coincidir amb el patr\xF3 ${o.pattern}` - : `Format inv\xE0lid per a ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `N\xFAmero inv\xE0lid: ha de ser m\xFAltiple de ${e.divisor}` - case "unrecognized_keys": - return `Clau${e.keys.length > 1 ? "s" : ""} no reconeguda${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Clau inv\xE0lida a ${e.origin}` - case "invalid_union": - return "Entrada inv\xE0lida" - case "invalid_element": - return `Element inv\xE0lid a ${e.origin}` - default: - return "Entrada inv\xE0lida" - } - } -} -function Xs() { - return { localeError: _l() } -} -var yl = () => { - const t = { - string: { unit: "znak\u016F", verb: "m\xEDt" }, - file: { unit: "bajt\u016F", verb: "m\xEDt" }, - array: { unit: "prvk\u016F", verb: "m\xEDt" }, - set: { unit: "prvk\u016F", verb: "m\xEDt" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u010D\xEDslo" - case "string": - return "\u0159et\u011Bzec" - case "boolean": - return "boolean" - case "bigint": - return "bigint" - case "function": - return "funkce" - case "symbol": - return "symbol" - case "undefined": - return "undefined" - case "object": { - if (Array.isArray(e)) return "pole" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "regul\xE1rn\xED v\xFDraz", - email: "e-mailov\xE1 adresa", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "datum a \u010Das ve form\xE1tu ISO", - date: "datum ve form\xE1tu ISO", - time: "\u010Das ve form\xE1tu ISO", - duration: "doba trv\xE1n\xED ISO", - ipv4: "IPv4 adresa", - ipv6: "IPv6 adresa", - cidrv4: "rozsah IPv4", - cidrv6: "rozsah IPv6", - base64: "\u0159et\u011Bzec zak\xF3dovan\xFD ve form\xE1tu base64", - base64url: "\u0159et\u011Bzec zak\xF3dovan\xFD ve form\xE1tu base64url", - json_string: "\u0159et\u011Bzec ve form\xE1tu JSON", - e164: "\u010D\xEDslo E.164", - jwt: "JWT", - template_literal: "vstup", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Neplatn\xFD vstup: o\u010Dek\xE1v\xE1no ${e.expected}, obdr\u017Eeno ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Neplatn\xFD vstup: o\u010Dek\xE1v\xE1no ${_(e.values[0])}` - : `Neplatn\xE1 mo\u017Enost: o\u010Dek\xE1v\xE1na jedna z hodnot ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Hodnota je p\u0159\xEDli\u0161 velk\xE1: ${e.origin ?? "hodnota"} mus\xED m\xEDt ${o}${e.maximum.toString()} ${a.unit ?? "prvk\u016F"}` - : `Hodnota je p\u0159\xEDli\u0161 velk\xE1: ${e.origin ?? "hodnota"} mus\xED b\xFDt ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Hodnota je p\u0159\xEDli\u0161 mal\xE1: ${e.origin ?? "hodnota"} mus\xED m\xEDt ${o}${e.minimum.toString()} ${a.unit ?? "prvk\u016F"}` - : `Hodnota je p\u0159\xEDli\u0161 mal\xE1: ${e.origin ?? "hodnota"} mus\xED b\xFDt ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Neplatn\xFD \u0159et\u011Bzec: mus\xED za\u010D\xEDnat na "${o.prefix}"` - : o.format === "ends_with" - ? `Neplatn\xFD \u0159et\u011Bzec: mus\xED kon\u010Dit na "${o.suffix}"` - : o.format === "includes" - ? `Neplatn\xFD \u0159et\u011Bzec: mus\xED obsahovat "${o.includes}"` - : o.format === "regex" - ? `Neplatn\xFD \u0159et\u011Bzec: mus\xED odpov\xEDdat vzoru ${o.pattern}` - : `Neplatn\xFD form\xE1t ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Neplatn\xE9 \u010D\xEDslo: mus\xED b\xFDt n\xE1sobkem ${e.divisor}` - case "unrecognized_keys": - return `Nezn\xE1m\xE9 kl\xED\u010De: ${f(e.keys, ", ")}` - case "invalid_key": - return `Neplatn\xFD kl\xED\u010D v ${e.origin}` - case "invalid_union": - return "Neplatn\xFD vstup" - case "invalid_element": - return `Neplatn\xE1 hodnota v ${e.origin}` - default: - return "Neplatn\xFD vstup" - } - } -} -function Ys() { - return { localeError: yl() } -} -var $l = () => { - const t = { - string: { unit: "Zeichen", verb: "zu haben" }, - file: { unit: "Bytes", verb: "zu haben" }, - array: { unit: "Elemente", verb: "zu haben" }, - set: { unit: "Elemente", verb: "zu haben" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "Zahl" - case "object": { - if (Array.isArray(e)) return "Array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "Eingabe", - email: "E-Mail-Adresse", - url: "URL", - emoji: "Emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-Datum und -Uhrzeit", - date: "ISO-Datum", - time: "ISO-Uhrzeit", - duration: "ISO-Dauer", - ipv4: "IPv4-Adresse", - ipv6: "IPv6-Adresse", - cidrv4: "IPv4-Bereich", - cidrv6: "IPv6-Bereich", - base64: "Base64-codierter String", - base64url: "Base64-URL-codierter String", - json_string: "JSON-String", - e164: "E.164-Nummer", - jwt: "JWT", - template_literal: "Eingabe", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Ung\xFCltige Eingabe: erwartet ${e.expected}, erhalten ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Ung\xFCltige Eingabe: erwartet ${_(e.values[0])}` - : `Ung\xFCltige Option: erwartet eine von ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Zu gro\xDF: erwartet, dass ${e.origin ?? "Wert"} ${o}${e.maximum.toString()} ${a.unit ?? "Elemente"} hat` - : `Zu gro\xDF: erwartet, dass ${e.origin ?? "Wert"} ${o}${e.maximum.toString()} ist` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Zu klein: erwartet, dass ${e.origin} ${o}${e.minimum.toString()} ${a.unit} hat` - : `Zu klein: erwartet, dass ${e.origin} ${o}${e.minimum.toString()} ist` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Ung\xFCltiger String: muss mit "${o.prefix}" beginnen` - : o.format === "ends_with" - ? `Ung\xFCltiger String: muss mit "${o.suffix}" enden` - : o.format === "includes" - ? `Ung\xFCltiger String: muss "${o.includes}" enthalten` - : o.format === "regex" - ? `Ung\xFCltiger String: muss dem Muster ${o.pattern} entsprechen` - : `Ung\xFCltig: ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Ung\xFCltige Zahl: muss ein Vielfaches von ${e.divisor} sein` - case "unrecognized_keys": - return `${e.keys.length > 1 ? "Unbekannte Schl\xFCssel" : "Unbekannter Schl\xFCssel"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Ung\xFCltiger Schl\xFCssel in ${e.origin}` - case "invalid_union": - return "Ung\xFCltige Eingabe" - case "invalid_element": - return `Ung\xFCltiger Wert in ${e.origin}` - default: - return "Ung\xFCltige Eingabe" - } - } -} -function ec() { - return { localeError: $l() } -} -var xl = (t) => { - const r = typeof t - switch (r) { - case "number": - return Number.isNaN(t) ? "NaN" : "number" - case "object": { - if (Array.isArray(t)) return "array" - if (t === null) return "null" - if (Object.getPrototypeOf(t) !== Object.prototype && t.constructor) return t.constructor.name - } - } - return r - }, - zl = () => { - const t = { - string: { unit: "characters", verb: "to have" }, - file: { unit: "bytes", verb: "to have" }, - array: { unit: "items", verb: "to have" }, - set: { unit: "items", verb: "to have" }, - } - function r(i) { - return t[i] ?? null - } - const n = { - regex: "input", - email: "email address", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datetime", - date: "ISO date", - time: "ISO time", - duration: "ISO duration", - ipv4: "IPv4 address", - ipv6: "IPv6 address", - cidrv4: "IPv4 range", - cidrv6: "IPv6 range", - base64: "base64-encoded string", - base64url: "base64url-encoded string", - json_string: "JSON string", - e164: "E.164 number", - jwt: "JWT", - template_literal: "input", - } - return (i) => { - switch (i.code) { - case "invalid_type": - return `Invalid input: expected ${i.expected}, received ${xl(i.input)}` - case "invalid_value": - return i.values.length === 1 - ? `Invalid input: expected ${_(i.values[0])}` - : `Invalid option: expected one of ${f(i.values, "|")}` - case "too_big": { - const e = i.inclusive ? "<=" : "<", - o = r(i.origin) - return o - ? `Too big: expected ${i.origin ?? "value"} to have ${e}${i.maximum.toString()} ${o.unit ?? "elements"}` - : `Too big: expected ${i.origin ?? "value"} to be ${e}${i.maximum.toString()}` - } - case "too_small": { - const e = i.inclusive ? ">=" : ">", - o = r(i.origin) - return o - ? `Too small: expected ${i.origin} to have ${e}${i.minimum.toString()} ${o.unit}` - : `Too small: expected ${i.origin} to be ${e}${i.minimum.toString()}` - } - case "invalid_format": { - const e = i - return e.format === "starts_with" - ? `Invalid string: must start with "${e.prefix}"` - : e.format === "ends_with" - ? `Invalid string: must end with "${e.suffix}"` - : e.format === "includes" - ? `Invalid string: must include "${e.includes}"` - : e.format === "regex" - ? `Invalid string: must match pattern ${e.pattern}` - : `Invalid ${n[e.format] ?? i.format}` - } - case "not_multiple_of": - return `Invalid number: must be a multiple of ${i.divisor}` - case "unrecognized_keys": - return `Unrecognized key${i.keys.length > 1 ? "s" : ""}: ${f(i.keys, ", ")}` - case "invalid_key": - return `Invalid key in ${i.origin}` - case "invalid_union": - return "Invalid input" - case "invalid_element": - return `Invalid value in ${i.origin}` - default: - return "Invalid input" - } - } - } -function lr() { - return { localeError: zl() } -} -var kl = (t) => { - const r = typeof t - switch (r) { - case "number": - return Number.isNaN(t) ? "NaN" : "nombro" - case "object": { - if (Array.isArray(t)) return "tabelo" - if (t === null) return "senvalora" - if (Object.getPrototypeOf(t) !== Object.prototype && t.constructor) return t.constructor.name - } - } - return r - }, - Sl = () => { - const t = { - string: { unit: "karaktrojn", verb: "havi" }, - file: { unit: "bajtojn", verb: "havi" }, - array: { unit: "elementojn", verb: "havi" }, - set: { unit: "elementojn", verb: "havi" }, - } - function r(i) { - return t[i] ?? null - } - const n = { - regex: "enigo", - email: "retadreso", - url: "URL", - emoji: "emo\u011Dio", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-datotempo", - date: "ISO-dato", - time: "ISO-tempo", - duration: "ISO-da\u016Dro", - ipv4: "IPv4-adreso", - ipv6: "IPv6-adreso", - cidrv4: "IPv4-rango", - cidrv6: "IPv6-rango", - base64: "64-ume kodita karaktraro", - base64url: "URL-64-ume kodita karaktraro", - json_string: "JSON-karaktraro", - e164: "E.164-nombro", - jwt: "JWT", - template_literal: "enigo", - } - return (i) => { - switch (i.code) { - case "invalid_type": - return `Nevalida enigo: atendi\u011Dis ${i.expected}, ricevi\u011Dis ${kl(i.input)}` - case "invalid_value": - return i.values.length === 1 - ? `Nevalida enigo: atendi\u011Dis ${_(i.values[0])}` - : `Nevalida opcio: atendi\u011Dis unu el ${f(i.values, "|")}` - case "too_big": { - const e = i.inclusive ? "<=" : "<", - o = r(i.origin) - return o - ? `Tro granda: atendi\u011Dis ke ${i.origin ?? "valoro"} havu ${e}${i.maximum.toString()} ${o.unit ?? "elementojn"}` - : `Tro granda: atendi\u011Dis ke ${i.origin ?? "valoro"} havu ${e}${i.maximum.toString()}` - } - case "too_small": { - const e = i.inclusive ? ">=" : ">", - o = r(i.origin) - return o - ? `Tro malgranda: atendi\u011Dis ke ${i.origin} havu ${e}${i.minimum.toString()} ${o.unit}` - : `Tro malgranda: atendi\u011Dis ke ${i.origin} estu ${e}${i.minimum.toString()}` - } - case "invalid_format": { - const e = i - return e.format === "starts_with" - ? `Nevalida karaktraro: devas komenci\u011Di per "${e.prefix}"` - : e.format === "ends_with" - ? `Nevalida karaktraro: devas fini\u011Di per "${e.suffix}"` - : e.format === "includes" - ? `Nevalida karaktraro: devas inkluzivi "${e.includes}"` - : e.format === "regex" - ? `Nevalida karaktraro: devas kongrui kun la modelo ${e.pattern}` - : `Nevalida ${n[e.format] ?? i.format}` - } - case "not_multiple_of": - return `Nevalida nombro: devas esti oblo de ${i.divisor}` - case "unrecognized_keys": - return `Nekonata${i.keys.length > 1 ? "j" : ""} \u015Dlosilo${i.keys.length > 1 ? "j" : ""}: ${f(i.keys, ", ")}` - case "invalid_key": - return `Nevalida \u015Dlosilo en ${i.origin}` - case "invalid_union": - return "Nevalida enigo" - case "invalid_element": - return `Nevalida valoro en ${i.origin}` - default: - return "Nevalida enigo" - } - } - } -function tc() { - return { localeError: Sl() } -} -var wl = () => { - const t = { - string: { unit: "caracteres", verb: "tener" }, - file: { unit: "bytes", verb: "tener" }, - array: { unit: "elementos", verb: "tener" }, - set: { unit: "elementos", verb: "tener" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "n\xFAmero" - case "object": { - if (Array.isArray(e)) return "arreglo" - if (e === null) return "nulo" - if (Object.getPrototypeOf(e) !== Object.prototype) return e.constructor.name - } - } - return o - }, - i = { - regex: "entrada", - email: "direcci\xF3n de correo electr\xF3nico", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "fecha y hora ISO", - date: "fecha ISO", - time: "hora ISO", - duration: "duraci\xF3n ISO", - ipv4: "direcci\xF3n IPv4", - ipv6: "direcci\xF3n IPv6", - cidrv4: "rango IPv4", - cidrv6: "rango IPv6", - base64: "cadena codificada en base64", - base64url: "URL codificada en base64", - json_string: "cadena JSON", - e164: "n\xFAmero E.164", - jwt: "JWT", - template_literal: "entrada", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Entrada inv\xE1lida: se esperaba ${e.expected}, recibido ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Entrada inv\xE1lida: se esperaba ${_(e.values[0])}` - : `Opci\xF3n inv\xE1lida: se esperaba una de ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Demasiado grande: se esperaba que ${e.origin ?? "valor"} tuviera ${o}${e.maximum.toString()} ${a.unit ?? "elementos"}` - : `Demasiado grande: se esperaba que ${e.origin ?? "valor"} fuera ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Demasiado peque\xF1o: se esperaba que ${e.origin} tuviera ${o}${e.minimum.toString()} ${a.unit}` - : `Demasiado peque\xF1o: se esperaba que ${e.origin} fuera ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Cadena inv\xE1lida: debe comenzar con "${o.prefix}"` - : o.format === "ends_with" - ? `Cadena inv\xE1lida: debe terminar en "${o.suffix}"` - : o.format === "includes" - ? `Cadena inv\xE1lida: debe incluir "${o.includes}"` - : o.format === "regex" - ? `Cadena inv\xE1lida: debe coincidir con el patr\xF3n ${o.pattern}` - : `Inv\xE1lido ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `N\xFAmero inv\xE1lido: debe ser m\xFAltiplo de ${e.divisor}` - case "unrecognized_keys": - return `Llave${e.keys.length > 1 ? "s" : ""} desconocida${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Llave inv\xE1lida en ${e.origin}` - case "invalid_union": - return "Entrada inv\xE1lida" - case "invalid_element": - return `Valor inv\xE1lido en ${e.origin}` - default: - return "Entrada inv\xE1lida" - } - } -} -function rc() { - return { localeError: wl() } -} -var Il = () => { - const t = { - string: { - unit: "\u06A9\u0627\u0631\u0627\u06A9\u062A\u0631", - verb: "\u062F\u0627\u0634\u062A\u0647 \u0628\u0627\u0634\u062F", - }, - file: { unit: "\u0628\u0627\u06CC\u062A", verb: "\u062F\u0627\u0634\u062A\u0647 \u0628\u0627\u0634\u062F" }, - array: { unit: "\u0622\u06CC\u062A\u0645", verb: "\u062F\u0627\u0634\u062A\u0647 \u0628\u0627\u0634\u062F" }, - set: { unit: "\u0622\u06CC\u062A\u0645", verb: "\u062F\u0627\u0634\u062A\u0647 \u0628\u0627\u0634\u062F" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0639\u062F\u062F" - case "object": { - if (Array.isArray(e)) return "\u0622\u0631\u0627\u06CC\u0647" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0648\u0631\u0648\u062F\u06CC", - email: "\u0622\u062F\u0631\u0633 \u0627\u06CC\u0645\u06CC\u0644", - url: "URL", - emoji: "\u0627\u06CC\u0645\u0648\u062C\u06CC", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u062A\u0627\u0631\u06CC\u062E \u0648 \u0632\u0645\u0627\u0646 \u0627\u06CC\u0632\u0648", - date: "\u062A\u0627\u0631\u06CC\u062E \u0627\u06CC\u0632\u0648", - time: "\u0632\u0645\u0627\u0646 \u0627\u06CC\u0632\u0648", - duration: "\u0645\u062F\u062A \u0632\u0645\u0627\u0646 \u0627\u06CC\u0632\u0648", - ipv4: "IPv4 \u0622\u062F\u0631\u0633", - ipv6: "IPv6 \u0622\u062F\u0631\u0633", - cidrv4: "IPv4 \u062F\u0627\u0645\u0646\u0647", - cidrv6: "IPv6 \u062F\u0627\u0645\u0646\u0647", - base64: "base64-encoded \u0631\u0634\u062A\u0647", - base64url: "base64url-encoded \u0631\u0634\u062A\u0647", - json_string: "JSON \u0631\u0634\u062A\u0647", - e164: "E.164 \u0639\u062F\u062F", - jwt: "JWT", - template_literal: "\u0648\u0631\u0648\u062F\u06CC", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0648\u0631\u0648\u062F\u06CC \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0645\u06CC\u200C\u0628\u0627\u06CC\u0633\u062A ${e.expected} \u0645\u06CC\u200C\u0628\u0648\u062F\u060C ${n(e.input)} \u062F\u0631\u06CC\u0627\u0641\u062A \u0634\u062F` - case "invalid_value": - return e.values.length === 1 - ? `\u0648\u0631\u0648\u062F\u06CC \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0645\u06CC\u200C\u0628\u0627\u06CC\u0633\u062A ${_(e.values[0])} \u0645\u06CC\u200C\u0628\u0648\u062F` - : `\u06AF\u0632\u06CC\u0646\u0647 \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0645\u06CC\u200C\u0628\u0627\u06CC\u0633\u062A \u06CC\u06A9\u06CC \u0627\u0632 ${f(e.values, "|")} \u0645\u06CC\u200C\u0628\u0648\u062F` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u062E\u06CC\u0644\u06CC \u0628\u0632\u0631\u06AF: ${e.origin ?? "\u0645\u0642\u062F\u0627\u0631"} \u0628\u0627\u06CC\u062F ${o}${e.maximum.toString()} ${a.unit ?? "\u0639\u0646\u0635\u0631"} \u0628\u0627\u0634\u062F` - : `\u062E\u06CC\u0644\u06CC \u0628\u0632\u0631\u06AF: ${e.origin ?? "\u0645\u0642\u062F\u0627\u0631"} \u0628\u0627\u06CC\u062F ${o}${e.maximum.toString()} \u0628\u0627\u0634\u062F` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u062E\u06CC\u0644\u06CC \u06A9\u0648\u0686\u06A9: ${e.origin} \u0628\u0627\u06CC\u062F ${o}${e.minimum.toString()} ${a.unit} \u0628\u0627\u0634\u062F` - : `\u062E\u06CC\u0644\u06CC \u06A9\u0648\u0686\u06A9: ${e.origin} \u0628\u0627\u06CC\u062F ${o}${e.minimum.toString()} \u0628\u0627\u0634\u062F` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u0631\u0634\u062A\u0647 \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0628\u0627\u06CC\u062F \u0628\u0627 "${o.prefix}" \u0634\u0631\u0648\u0639 \u0634\u0648\u062F` - : o.format === "ends_with" - ? `\u0631\u0634\u062A\u0647 \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0628\u0627\u06CC\u062F \u0628\u0627 "${o.suffix}" \u062A\u0645\u0627\u0645 \u0634\u0648\u062F` - : o.format === "includes" - ? `\u0631\u0634\u062A\u0647 \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0628\u0627\u06CC\u062F \u0634\u0627\u0645\u0644 "${o.includes}" \u0628\u0627\u0634\u062F` - : o.format === "regex" - ? `\u0631\u0634\u062A\u0647 \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0628\u0627\u06CC\u062F \u0628\u0627 \u0627\u0644\u06AF\u0648\u06CC ${o.pattern} \u0645\u0637\u0627\u0628\u0642\u062A \u062F\u0627\u0634\u062A\u0647 \u0628\u0627\u0634\u062F` - : `${i[o.format] ?? e.format} \u0646\u0627\u0645\u0639\u062A\u0628\u0631` - } - case "not_multiple_of": - return `\u0639\u062F\u062F \u0646\u0627\u0645\u0639\u062A\u0628\u0631: \u0628\u0627\u06CC\u062F \u0645\u0636\u0631\u0628 ${e.divisor} \u0628\u0627\u0634\u062F` - case "unrecognized_keys": - return `\u06A9\u0644\u06CC\u062F${e.keys.length > 1 ? "\u0647\u0627\u06CC" : ""} \u0646\u0627\u0634\u0646\u0627\u0633: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u06A9\u0644\u06CC\u062F \u0646\u0627\u0634\u0646\u0627\u0633 \u062F\u0631 ${e.origin}` - case "invalid_union": - return "\u0648\u0631\u0648\u062F\u06CC \u0646\u0627\u0645\u0639\u062A\u0628\u0631" - case "invalid_element": - return `\u0645\u0642\u062F\u0627\u0631 \u0646\u0627\u0645\u0639\u062A\u0628\u0631 \u062F\u0631 ${e.origin}` - default: - return "\u0648\u0631\u0648\u062F\u06CC \u0646\u0627\u0645\u0639\u062A\u0628\u0631" - } - } -} -function nc() { - return { localeError: Il() } -} -var jl = () => { - const t = { - string: { unit: "merkki\xE4", subject: "merkkijonon" }, - file: { unit: "tavua", subject: "tiedoston" }, - array: { unit: "alkiota", subject: "listan" }, - set: { unit: "alkiota", subject: "joukon" }, - number: { unit: "", subject: "luvun" }, - bigint: { unit: "", subject: "suuren kokonaisluvun" }, - int: { unit: "", subject: "kokonaisluvun" }, - date: { unit: "", subject: "p\xE4iv\xE4m\xE4\xE4r\xE4n" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "s\xE4\xE4nn\xF6llinen lauseke", - email: "s\xE4hk\xF6postiosoite", - url: "URL-osoite", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-aikaleima", - date: "ISO-p\xE4iv\xE4m\xE4\xE4r\xE4", - time: "ISO-aika", - duration: "ISO-kesto", - ipv4: "IPv4-osoite", - ipv6: "IPv6-osoite", - cidrv4: "IPv4-alue", - cidrv6: "IPv6-alue", - base64: "base64-koodattu merkkijono", - base64url: "base64url-koodattu merkkijono", - json_string: "JSON-merkkijono", - e164: "E.164-luku", - jwt: "JWT", - template_literal: "templaattimerkkijono", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Virheellinen tyyppi: odotettiin ${e.expected}, oli ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Virheellinen sy\xF6te: t\xE4ytyy olla ${_(e.values[0])}` - : `Virheellinen valinta: t\xE4ytyy olla yksi seuraavista: ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Liian suuri: ${a.subject} t\xE4ytyy olla ${o}${e.maximum.toString()} ${a.unit}`.trim() - : `Liian suuri: arvon t\xE4ytyy olla ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Liian pieni: ${a.subject} t\xE4ytyy olla ${o}${e.minimum.toString()} ${a.unit}`.trim() - : `Liian pieni: arvon t\xE4ytyy olla ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Virheellinen sy\xF6te: t\xE4ytyy alkaa "${o.prefix}"` - : o.format === "ends_with" - ? `Virheellinen sy\xF6te: t\xE4ytyy loppua "${o.suffix}"` - : o.format === "includes" - ? `Virheellinen sy\xF6te: t\xE4ytyy sis\xE4lt\xE4\xE4 "${o.includes}"` - : o.format === "regex" - ? `Virheellinen sy\xF6te: t\xE4ytyy vastata s\xE4\xE4nn\xF6llist\xE4 lauseketta ${o.pattern}` - : `Virheellinen ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Virheellinen luku: t\xE4ytyy olla luvun ${e.divisor} monikerta` - case "unrecognized_keys": - return `${e.keys.length > 1 ? "Tuntemattomat avaimet" : "Tuntematon avain"}: ${f(e.keys, ", ")}` - case "invalid_key": - return "Virheellinen avain tietueessa" - case "invalid_union": - return "Virheellinen unioni" - case "invalid_element": - return "Virheellinen arvo joukossa" - default: - return "Virheellinen sy\xF6te" - } - } -} -function oc() { - return { localeError: jl() } -} -var Pl = () => { - const t = { - string: { unit: "caract\xE8res", verb: "avoir" }, - file: { unit: "octets", verb: "avoir" }, - array: { unit: "\xE9l\xE9ments", verb: "avoir" }, - set: { unit: "\xE9l\xE9ments", verb: "avoir" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "nombre" - case "object": { - if (Array.isArray(e)) return "tableau" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "entr\xE9e", - email: "adresse e-mail", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "date et heure ISO", - date: "date ISO", - time: "heure ISO", - duration: "dur\xE9e ISO", - ipv4: "adresse IPv4", - ipv6: "adresse IPv6", - cidrv4: "plage IPv4", - cidrv6: "plage IPv6", - base64: "cha\xEEne encod\xE9e en base64", - base64url: "cha\xEEne encod\xE9e en base64url", - json_string: "cha\xEEne JSON", - e164: "num\xE9ro E.164", - jwt: "JWT", - template_literal: "entr\xE9e", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Entr\xE9e invalide : ${e.expected} attendu, ${n(e.input)} re\xE7u` - case "invalid_value": - return e.values.length === 1 - ? `Entr\xE9e invalide : ${_(e.values[0])} attendu` - : `Option invalide : une valeur parmi ${f(e.values, "|")} attendue` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Trop grand : ${e.origin ?? "valeur"} doit ${a.verb} ${o}${e.maximum.toString()} ${a.unit ?? "\xE9l\xE9ment(s)"}` - : `Trop grand : ${e.origin ?? "valeur"} doit \xEAtre ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Trop petit : ${e.origin} doit ${a.verb} ${o}${e.minimum.toString()} ${a.unit}` - : `Trop petit : ${e.origin} doit \xEAtre ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Cha\xEEne invalide : doit commencer par "${o.prefix}"` - : o.format === "ends_with" - ? `Cha\xEEne invalide : doit se terminer par "${o.suffix}"` - : o.format === "includes" - ? `Cha\xEEne invalide : doit inclure "${o.includes}"` - : o.format === "regex" - ? `Cha\xEEne invalide : doit correspondre au mod\xE8le ${o.pattern}` - : `${i[o.format] ?? e.format} invalide` - } - case "not_multiple_of": - return `Nombre invalide : doit \xEAtre un multiple de ${e.divisor}` - case "unrecognized_keys": - return `Cl\xE9${e.keys.length > 1 ? "s" : ""} non reconnue${e.keys.length > 1 ? "s" : ""} : ${f(e.keys, ", ")}` - case "invalid_key": - return `Cl\xE9 invalide dans ${e.origin}` - case "invalid_union": - return "Entr\xE9e invalide" - case "invalid_element": - return `Valeur invalide dans ${e.origin}` - default: - return "Entr\xE9e invalide" - } - } -} -function ic() { - return { localeError: Pl() } -} -var Tl = () => { - const t = { - string: { unit: "caract\xE8res", verb: "avoir" }, - file: { unit: "octets", verb: "avoir" }, - array: { unit: "\xE9l\xE9ments", verb: "avoir" }, - set: { unit: "\xE9l\xE9ments", verb: "avoir" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "entr\xE9e", - email: "adresse courriel", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "date-heure ISO", - date: "date ISO", - time: "heure ISO", - duration: "dur\xE9e ISO", - ipv4: "adresse IPv4", - ipv6: "adresse IPv6", - cidrv4: "plage IPv4", - cidrv6: "plage IPv6", - base64: "cha\xEEne encod\xE9e en base64", - base64url: "cha\xEEne encod\xE9e en base64url", - json_string: "cha\xEEne JSON", - e164: "num\xE9ro E.164", - jwt: "JWT", - template_literal: "entr\xE9e", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Entr\xE9e invalide : attendu ${e.expected}, re\xE7u ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Entr\xE9e invalide : attendu ${_(e.values[0])}` - : `Option invalide : attendu l'une des valeurs suivantes ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "\u2264" : "<", - a = r(e.origin) - return a - ? `Trop grand : attendu que ${e.origin ?? "la valeur"} ait ${o}${e.maximum.toString()} ${a.unit}` - : `Trop grand : attendu que ${e.origin ?? "la valeur"} soit ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? "\u2265" : ">", - a = r(e.origin) - return a - ? `Trop petit : attendu que ${e.origin} ait ${o}${e.minimum.toString()} ${a.unit}` - : `Trop petit : attendu que ${e.origin} soit ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Cha\xEEne invalide : doit commencer par "${o.prefix}"` - : o.format === "ends_with" - ? `Cha\xEEne invalide : doit se terminer par "${o.suffix}"` - : o.format === "includes" - ? `Cha\xEEne invalide : doit inclure "${o.includes}"` - : o.format === "regex" - ? `Cha\xEEne invalide : doit correspondre au motif ${o.pattern}` - : `${i[o.format] ?? e.format} invalide` - } - case "not_multiple_of": - return `Nombre invalide : doit \xEAtre un multiple de ${e.divisor}` - case "unrecognized_keys": - return `Cl\xE9${e.keys.length > 1 ? "s" : ""} non reconnue${e.keys.length > 1 ? "s" : ""} : ${f(e.keys, ", ")}` - case "invalid_key": - return `Cl\xE9 invalide dans ${e.origin}` - case "invalid_union": - return "Entr\xE9e invalide" - case "invalid_element": - return `Valeur invalide dans ${e.origin}` - default: - return "Entr\xE9e invalide" - } - } -} -function ac() { - return { localeError: Tl() } -} -var Ol = () => { - const t = { - string: { unit: "\u05D0\u05D5\u05EA\u05D9\u05D5\u05EA", verb: "\u05DC\u05DB\u05DC\u05D5\u05DC" }, - file: { unit: "\u05D1\u05D9\u05D9\u05D8\u05D9\u05DD", verb: "\u05DC\u05DB\u05DC\u05D5\u05DC" }, - array: { unit: "\u05E4\u05E8\u05D9\u05D8\u05D9\u05DD", verb: "\u05DC\u05DB\u05DC\u05D5\u05DC" }, - set: { unit: "\u05E4\u05E8\u05D9\u05D8\u05D9\u05DD", verb: "\u05DC\u05DB\u05DC\u05D5\u05DC" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u05E7\u05DC\u05D8", - email: "\u05DB\u05EA\u05D5\u05D1\u05EA \u05D0\u05D9\u05DE\u05D9\u05D9\u05DC", - url: "\u05DB\u05EA\u05D5\u05D1\u05EA \u05E8\u05E9\u05EA", - emoji: "\u05D0\u05D9\u05DE\u05D5\u05D2'\u05D9", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u05EA\u05D0\u05E8\u05D9\u05DA \u05D5\u05D6\u05DE\u05DF ISO", - date: "\u05EA\u05D0\u05E8\u05D9\u05DA ISO", - time: "\u05D6\u05DE\u05DF ISO", - duration: "\u05DE\u05E9\u05DA \u05D6\u05DE\u05DF ISO", - ipv4: "\u05DB\u05EA\u05D5\u05D1\u05EA IPv4", - ipv6: "\u05DB\u05EA\u05D5\u05D1\u05EA IPv6", - cidrv4: "\u05D8\u05D5\u05D5\u05D7 IPv4", - cidrv6: "\u05D8\u05D5\u05D5\u05D7 IPv6", - base64: "\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05D1\u05D1\u05E1\u05D9\u05E1 64", - base64url: - "\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05D1\u05D1\u05E1\u05D9\u05E1 64 \u05DC\u05DB\u05EA\u05D5\u05D1\u05D5\u05EA \u05E8\u05E9\u05EA", - json_string: "\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA JSON", - e164: "\u05DE\u05E1\u05E4\u05E8 E.164", - jwt: "JWT", - template_literal: "\u05E7\u05DC\u05D8", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF: \u05E6\u05E8\u05D9\u05DA ${e.expected}, \u05D4\u05EA\u05E7\u05D1\u05DC ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF: \u05E6\u05E8\u05D9\u05DA ${_(e.values[0])}` - : `\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF: \u05E6\u05E8\u05D9\u05DA \u05D0\u05D7\u05EA \u05DE\u05D4\u05D0\u05E4\u05E9\u05E8\u05D5\u05D9\u05D5\u05EA ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u05D2\u05D3\u05D5\u05DC \u05DE\u05D3\u05D9: ${e.origin ?? "value"} \u05E6\u05E8\u05D9\u05DA \u05DC\u05D4\u05D9\u05D5\u05EA ${o}${e.maximum.toString()} ${a.unit ?? "elements"}` - : `\u05D2\u05D3\u05D5\u05DC \u05DE\u05D3\u05D9: ${e.origin ?? "value"} \u05E6\u05E8\u05D9\u05DA \u05DC\u05D4\u05D9\u05D5\u05EA ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u05E7\u05D8\u05DF \u05DE\u05D3\u05D9: ${e.origin} \u05E6\u05E8\u05D9\u05DA \u05DC\u05D4\u05D9\u05D5\u05EA ${o}${e.minimum.toString()} ${a.unit}` - : `\u05E7\u05D8\u05DF \u05DE\u05D3\u05D9: ${e.origin} \u05E6\u05E8\u05D9\u05DA \u05DC\u05D4\u05D9\u05D5\u05EA ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05E0\u05D4: \u05D7\u05D9\u05D9\u05D1\u05EA \u05DC\u05D4\u05EA\u05D7\u05D9\u05DC \u05D1"${o.prefix}"` - : o.format === "ends_with" - ? `\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05E0\u05D4: \u05D7\u05D9\u05D9\u05D1\u05EA \u05DC\u05D4\u05E1\u05EA\u05D9\u05D9\u05DD \u05D1 "${o.suffix}"` - : o.format === "includes" - ? `\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05E0\u05D4: \u05D7\u05D9\u05D9\u05D1\u05EA \u05DC\u05DB\u05DC\u05D5\u05DC "${o.includes}"` - : o.format === "regex" - ? `\u05DE\u05D7\u05E8\u05D5\u05D6\u05EA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05E0\u05D4: \u05D7\u05D9\u05D9\u05D1\u05EA \u05DC\u05D4\u05EA\u05D0\u05D9\u05DD \u05DC\u05EA\u05D1\u05E0\u05D9\u05EA ${o.pattern}` - : `${i[o.format] ?? e.format} \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF` - } - case "not_multiple_of": - return `\u05DE\u05E1\u05E4\u05E8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF: \u05D7\u05D9\u05D9\u05D1 \u05DC\u05D4\u05D9\u05D5\u05EA \u05DE\u05DB\u05E4\u05DC\u05D4 \u05E9\u05DC ${e.divisor}` - case "unrecognized_keys": - return `\u05DE\u05E4\u05EA\u05D7${e.keys.length > 1 ? "\u05D5\u05EA" : ""} \u05DC\u05D0 \u05DE\u05D6\u05D5\u05D4${e.keys.length > 1 ? "\u05D9\u05DD" : "\u05D4"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u05DE\u05E4\u05EA\u05D7 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF \u05D1${e.origin}` - case "invalid_union": - return "\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF" - case "invalid_element": - return `\u05E2\u05E8\u05DA \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF \u05D1${e.origin}` - default: - return "\u05E7\u05DC\u05D8 \u05DC\u05D0 \u05EA\u05E7\u05D9\u05DF" - } - } -} -function sc() { - return { localeError: Ol() } -} -var Nl = () => { - const t = { - string: { unit: "karakter", verb: "legyen" }, - file: { unit: "byte", verb: "legyen" }, - array: { unit: "elem", verb: "legyen" }, - set: { unit: "elem", verb: "legyen" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "sz\xE1m" - case "object": { - if (Array.isArray(e)) return "t\xF6mb" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "bemenet", - email: "email c\xEDm", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO id\u0151b\xE9lyeg", - date: "ISO d\xE1tum", - time: "ISO id\u0151", - duration: "ISO id\u0151intervallum", - ipv4: "IPv4 c\xEDm", - ipv6: "IPv6 c\xEDm", - cidrv4: "IPv4 tartom\xE1ny", - cidrv6: "IPv6 tartom\xE1ny", - base64: "base64-k\xF3dolt string", - base64url: "base64url-k\xF3dolt string", - json_string: "JSON string", - e164: "E.164 sz\xE1m", - jwt: "JWT", - template_literal: "bemenet", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\xC9rv\xE9nytelen bemenet: a v\xE1rt \xE9rt\xE9k ${e.expected}, a kapott \xE9rt\xE9k ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\xC9rv\xE9nytelen bemenet: a v\xE1rt \xE9rt\xE9k ${_(e.values[0])}` - : `\xC9rv\xE9nytelen opci\xF3: valamelyik \xE9rt\xE9k v\xE1rt ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `T\xFAl nagy: ${e.origin ?? "\xE9rt\xE9k"} m\xE9rete t\xFAl nagy ${o}${e.maximum.toString()} ${a.unit ?? "elem"}` - : `T\xFAl nagy: a bemeneti \xE9rt\xE9k ${e.origin ?? "\xE9rt\xE9k"} t\xFAl nagy: ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `T\xFAl kicsi: a bemeneti \xE9rt\xE9k ${e.origin} m\xE9rete t\xFAl kicsi ${o}${e.minimum.toString()} ${a.unit}` - : `T\xFAl kicsi: a bemeneti \xE9rt\xE9k ${e.origin} t\xFAl kicsi ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\xC9rv\xE9nytelen string: "${o.prefix}" \xE9rt\xE9kkel kell kezd\u0151dnie` - : o.format === "ends_with" - ? `\xC9rv\xE9nytelen string: "${o.suffix}" \xE9rt\xE9kkel kell v\xE9gz\u0151dnie` - : o.format === "includes" - ? `\xC9rv\xE9nytelen string: "${o.includes}" \xE9rt\xE9ket kell tartalmaznia` - : o.format === "regex" - ? `\xC9rv\xE9nytelen string: ${o.pattern} mint\xE1nak kell megfelelnie` - : `\xC9rv\xE9nytelen ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\xC9rv\xE9nytelen sz\xE1m: ${e.divisor} t\xF6bbsz\xF6r\xF6s\xE9nek kell lennie` - case "unrecognized_keys": - return `Ismeretlen kulcs${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\xC9rv\xE9nytelen kulcs ${e.origin}` - case "invalid_union": - return "\xC9rv\xE9nytelen bemenet" - case "invalid_element": - return `\xC9rv\xE9nytelen \xE9rt\xE9k: ${e.origin}` - default: - return "\xC9rv\xE9nytelen bemenet" - } - } -} -function cc() { - return { localeError: Nl() } -} -var Ul = () => { - const t = { - string: { unit: "karakter", verb: "memiliki" }, - file: { unit: "byte", verb: "memiliki" }, - array: { unit: "item", verb: "memiliki" }, - set: { unit: "item", verb: "memiliki" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "input", - email: "alamat email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "tanggal dan waktu format ISO", - date: "tanggal format ISO", - time: "jam format ISO", - duration: "durasi format ISO", - ipv4: "alamat IPv4", - ipv6: "alamat IPv6", - cidrv4: "rentang alamat IPv4", - cidrv6: "rentang alamat IPv6", - base64: "string dengan enkode base64", - base64url: "string dengan enkode base64url", - json_string: "string JSON", - e164: "angka E.164", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Input tidak valid: diharapkan ${e.expected}, diterima ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Input tidak valid: diharapkan ${_(e.values[0])}` - : `Pilihan tidak valid: diharapkan salah satu dari ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Terlalu besar: diharapkan ${e.origin ?? "value"} memiliki ${o}${e.maximum.toString()} ${a.unit ?? "elemen"}` - : `Terlalu besar: diharapkan ${e.origin ?? "value"} menjadi ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Terlalu kecil: diharapkan ${e.origin} memiliki ${o}${e.minimum.toString()} ${a.unit}` - : `Terlalu kecil: diharapkan ${e.origin} menjadi ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `String tidak valid: harus dimulai dengan "${o.prefix}"` - : o.format === "ends_with" - ? `String tidak valid: harus berakhir dengan "${o.suffix}"` - : o.format === "includes" - ? `String tidak valid: harus menyertakan "${o.includes}"` - : o.format === "regex" - ? `String tidak valid: harus sesuai pola ${o.pattern}` - : `${i[o.format] ?? e.format} tidak valid` - } - case "not_multiple_of": - return `Angka tidak valid: harus kelipatan dari ${e.divisor}` - case "unrecognized_keys": - return `Kunci tidak dikenali ${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Kunci tidak valid di ${e.origin}` - case "invalid_union": - return "Input tidak valid" - case "invalid_element": - return `Nilai tidak valid di ${e.origin}` - default: - return "Input tidak valid" - } - } -} -function uc() { - return { localeError: Ul() } -} -var Zl = () => { - const t = { - string: { unit: "caratteri", verb: "avere" }, - file: { unit: "byte", verb: "avere" }, - array: { unit: "elementi", verb: "avere" }, - set: { unit: "elementi", verb: "avere" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "numero" - case "object": { - if (Array.isArray(e)) return "vettore" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "input", - email: "indirizzo email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data e ora ISO", - date: "data ISO", - time: "ora ISO", - duration: "durata ISO", - ipv4: "indirizzo IPv4", - ipv6: "indirizzo IPv6", - cidrv4: "intervallo IPv4", - cidrv6: "intervallo IPv6", - base64: "stringa codificata in base64", - base64url: "URL codificata in base64", - json_string: "stringa JSON", - e164: "numero E.164", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Input non valido: atteso ${e.expected}, ricevuto ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Input non valido: atteso ${_(e.values[0])}` - : `Opzione non valida: atteso uno tra ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Troppo grande: ${e.origin ?? "valore"} deve avere ${o}${e.maximum.toString()} ${a.unit ?? "elementi"}` - : `Troppo grande: ${e.origin ?? "valore"} deve essere ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Troppo piccolo: ${e.origin} deve avere ${o}${e.minimum.toString()} ${a.unit}` - : `Troppo piccolo: ${e.origin} deve essere ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Stringa non valida: deve iniziare con "${o.prefix}"` - : o.format === "ends_with" - ? `Stringa non valida: deve terminare con "${o.suffix}"` - : o.format === "includes" - ? `Stringa non valida: deve includere "${o.includes}"` - : o.format === "regex" - ? `Stringa non valida: deve corrispondere al pattern ${o.pattern}` - : `Invalid ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Numero non valido: deve essere un multiplo di ${e.divisor}` - case "unrecognized_keys": - return `Chiav${e.keys.length > 1 ? "i" : "e"} non riconosciut${e.keys.length > 1 ? "e" : "a"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Chiave non valida in ${e.origin}` - case "invalid_union": - return "Input non valido" - case "invalid_element": - return `Valore non valido in ${e.origin}` - default: - return "Input non valido" - } - } -} -function lc() { - return { localeError: Zl() } -} -var Dl = () => { - const t = { - string: { unit: "\u6587\u5B57", verb: "\u3067\u3042\u308B" }, - file: { unit: "\u30D0\u30A4\u30C8", verb: "\u3067\u3042\u308B" }, - array: { unit: "\u8981\u7D20", verb: "\u3067\u3042\u308B" }, - set: { unit: "\u8981\u7D20", verb: "\u3067\u3042\u308B" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u6570\u5024" - case "object": { - if (Array.isArray(e)) return "\u914D\u5217" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u5165\u529B\u5024", - email: "\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9", - url: "URL", - emoji: "\u7D75\u6587\u5B57", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO\u65E5\u6642", - date: "ISO\u65E5\u4ED8", - time: "ISO\u6642\u523B", - duration: "ISO\u671F\u9593", - ipv4: "IPv4\u30A2\u30C9\u30EC\u30B9", - ipv6: "IPv6\u30A2\u30C9\u30EC\u30B9", - cidrv4: "IPv4\u7BC4\u56F2", - cidrv6: "IPv6\u7BC4\u56F2", - base64: "base64\u30A8\u30F3\u30B3\u30FC\u30C9\u6587\u5B57\u5217", - base64url: "base64url\u30A8\u30F3\u30B3\u30FC\u30C9\u6587\u5B57\u5217", - json_string: "JSON\u6587\u5B57\u5217", - e164: "E.164\u756A\u53F7", - jwt: "JWT", - template_literal: "\u5165\u529B\u5024", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u7121\u52B9\u306A\u5165\u529B: ${e.expected}\u304C\u671F\u5F85\u3055\u308C\u307E\u3057\u305F\u304C\u3001${n(e.input)}\u304C\u5165\u529B\u3055\u308C\u307E\u3057\u305F` - case "invalid_value": - return e.values.length === 1 - ? `\u7121\u52B9\u306A\u5165\u529B: ${_(e.values[0])}\u304C\u671F\u5F85\u3055\u308C\u307E\u3057\u305F` - : `\u7121\u52B9\u306A\u9078\u629E: ${f(e.values, "\u3001")}\u306E\u3044\u305A\u308C\u304B\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - case "too_big": { - const o = e.inclusive ? "\u4EE5\u4E0B\u3067\u3042\u308B" : "\u3088\u308A\u5C0F\u3055\u3044", - a = r(e.origin) - return a - ? `\u5927\u304D\u3059\u304E\u308B\u5024: ${e.origin ?? "\u5024"}\u306F${e.maximum.toString()}${a.unit ?? "\u8981\u7D20"}${o}\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : `\u5927\u304D\u3059\u304E\u308B\u5024: ${e.origin ?? "\u5024"}\u306F${e.maximum.toString()}${o}\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - } - case "too_small": { - const o = e.inclusive ? "\u4EE5\u4E0A\u3067\u3042\u308B" : "\u3088\u308A\u5927\u304D\u3044", - a = r(e.origin) - return a - ? `\u5C0F\u3055\u3059\u304E\u308B\u5024: ${e.origin}\u306F${e.minimum.toString()}${a.unit}${o}\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : `\u5C0F\u3055\u3059\u304E\u308B\u5024: ${e.origin}\u306F${e.minimum.toString()}${o}\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u7121\u52B9\u306A\u6587\u5B57\u5217: "${o.prefix}"\u3067\u59CB\u307E\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : o.format === "ends_with" - ? `\u7121\u52B9\u306A\u6587\u5B57\u5217: "${o.suffix}"\u3067\u7D42\u308F\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : o.format === "includes" - ? `\u7121\u52B9\u306A\u6587\u5B57\u5217: "${o.includes}"\u3092\u542B\u3080\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : o.format === "regex" - ? `\u7121\u52B9\u306A\u6587\u5B57\u5217: \u30D1\u30BF\u30FC\u30F3${o.pattern}\u306B\u4E00\u81F4\u3059\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - : `\u7121\u52B9\u306A${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u7121\u52B9\u306A\u6570\u5024: ${e.divisor}\u306E\u500D\u6570\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059` - case "unrecognized_keys": - return `\u8A8D\u8B58\u3055\u308C\u3066\u3044\u306A\u3044\u30AD\u30FC${e.keys.length > 1 ? "\u7FA4" : ""}: ${f(e.keys, "\u3001")}` - case "invalid_key": - return `${e.origin}\u5185\u306E\u7121\u52B9\u306A\u30AD\u30FC` - case "invalid_union": - return "\u7121\u52B9\u306A\u5165\u529B" - case "invalid_element": - return `${e.origin}\u5185\u306E\u7121\u52B9\u306A\u5024` - default: - return "\u7121\u52B9\u306A\u5165\u529B" - } - } -} -function mc() { - return { localeError: Dl() } -} -var Rl = () => { - const t = { - string: { unit: "\u178F\u17BD\u17A2\u1780\u17D2\u179F\u179A", verb: "\u1782\u17BD\u179A\u1798\u17B6\u1793" }, - file: { unit: "\u1794\u17C3", verb: "\u1782\u17BD\u179A\u1798\u17B6\u1793" }, - array: { unit: "\u1792\u17B6\u178F\u17BB", verb: "\u1782\u17BD\u179A\u1798\u17B6\u1793" }, - set: { unit: "\u1792\u17B6\u178F\u17BB", verb: "\u1782\u17BD\u179A\u1798\u17B6\u1793" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) - ? "\u1798\u17B7\u1793\u1798\u17C2\u1793\u1787\u17B6\u179B\u17C1\u1781 (NaN)" - : "\u179B\u17C1\u1781" - case "object": { - if (Array.isArray(e)) return "\u17A2\u17B6\u179A\u17C1 (Array)" - if (e === null) return "\u1782\u17D2\u1798\u17B6\u1793\u178F\u1798\u17D2\u179B\u17C3 (null)" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1794\u1789\u17D2\u1785\u17BC\u179B", - email: "\u17A2\u17B6\u179F\u1799\u178A\u17D2\u178B\u17B6\u1793\u17A2\u17CA\u17B8\u1798\u17C2\u179B", - url: "URL", - emoji: "\u179F\u1789\u17D2\u1789\u17B6\u17A2\u17B6\u179A\u1798\u17D2\u1798\u178E\u17CD", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: - "\u1780\u17B6\u179B\u1794\u179A\u17B7\u1785\u17D2\u1786\u17C1\u1791 \u1793\u17B7\u1784\u1798\u17C9\u17C4\u1784 ISO", - date: "\u1780\u17B6\u179B\u1794\u179A\u17B7\u1785\u17D2\u1786\u17C1\u1791 ISO", - time: "\u1798\u17C9\u17C4\u1784 ISO", - duration: "\u179A\u1799\u17C8\u1796\u17C1\u179B ISO", - ipv4: "\u17A2\u17B6\u179F\u1799\u178A\u17D2\u178B\u17B6\u1793 IPv4", - ipv6: "\u17A2\u17B6\u179F\u1799\u178A\u17D2\u178B\u17B6\u1793 IPv6", - cidrv4: "\u178A\u17C2\u1793\u17A2\u17B6\u179F\u1799\u178A\u17D2\u178B\u17B6\u1793 IPv4", - cidrv6: "\u178A\u17C2\u1793\u17A2\u17B6\u179F\u1799\u178A\u17D2\u178B\u17B6\u1793 IPv6", - base64: "\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u17A2\u17CA\u17B7\u1780\u17BC\u178A base64", - base64url: "\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u17A2\u17CA\u17B7\u1780\u17BC\u178A base64url", - json_string: "\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A JSON", - e164: "\u179B\u17C1\u1781 E.164", - jwt: "JWT", - template_literal: "\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1794\u1789\u17D2\u1785\u17BC\u179B", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1794\u1789\u17D2\u1785\u17BC\u179B\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${e.expected} \u1794\u17C9\u17BB\u1793\u17D2\u178F\u17C2\u1791\u1791\u17BD\u179B\u1794\u17B6\u1793 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1794\u1789\u17D2\u1785\u17BC\u179B\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${_(e.values[0])}` - : `\u1787\u1798\u17D2\u179A\u17BE\u179F\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1787\u17B6\u1798\u17BD\u1799\u1780\u17D2\u1793\u17BB\u1784\u1785\u17C6\u178E\u17C4\u1798 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u1792\u17C6\u1796\u17C1\u1780\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${e.origin ?? "\u178F\u1798\u17D2\u179B\u17C3"} ${o} ${e.maximum.toString()} ${a.unit ?? "\u1792\u17B6\u178F\u17BB"}` - : `\u1792\u17C6\u1796\u17C1\u1780\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${e.origin ?? "\u178F\u1798\u17D2\u179B\u17C3"} ${o} ${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u178F\u17BC\u1785\u1796\u17C1\u1780\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${e.origin} ${o} ${e.minimum.toString()} ${a.unit}` - : `\u178F\u17BC\u1785\u1796\u17C1\u1780\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1780\u17B6\u179A ${e.origin} ${o} ${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1785\u17B6\u1794\u17CB\u1795\u17D2\u178F\u17BE\u1798\u178A\u17C4\u1799 "${o.prefix}"` - : o.format === "ends_with" - ? `\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1794\u1789\u17D2\u1785\u1794\u17CB\u178A\u17C4\u1799 "${o.suffix}"` - : o.format === "includes" - ? `\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u1798\u17B6\u1793 "${o.includes}"` - : o.format === "regex" - ? `\u1781\u17D2\u179F\u17C2\u17A2\u1780\u17D2\u179F\u179A\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u178F\u17C2\u1795\u17D2\u1782\u17BC\u1795\u17D2\u1782\u1784\u1793\u17B9\u1784\u1791\u1798\u17D2\u179A\u1784\u17CB\u178A\u17C2\u179B\u1794\u17B6\u1793\u1780\u17C6\u178E\u178F\u17CB ${o.pattern}` - : `\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u179B\u17C1\u1781\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u17D6 \u178F\u17D2\u179A\u17BC\u179C\u178F\u17C2\u1787\u17B6\u1796\u17A0\u17BB\u1782\u17BB\u178E\u1793\u17C3 ${e.divisor}` - case "unrecognized_keys": - return `\u179A\u1780\u1783\u17BE\u1789\u179F\u17C4\u1798\u17B7\u1793\u179F\u17D2\u1782\u17B6\u179B\u17CB\u17D6 ${f(e.keys, ", ")}` - case "invalid_key": - return `\u179F\u17C4\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u1793\u17C5\u1780\u17D2\u1793\u17BB\u1784 ${e.origin}` - case "invalid_union": - return "\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C" - case "invalid_element": - return `\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C\u1793\u17C5\u1780\u17D2\u1793\u17BB\u1784 ${e.origin}` - default: - return "\u1791\u17B7\u1793\u17D2\u1793\u1793\u17D0\u1799\u1798\u17B7\u1793\u178F\u17D2\u179A\u17B9\u1798\u178F\u17D2\u179A\u17BC\u179C" - } - } -} -function pc() { - return { localeError: Rl() } -} -var El = () => { - const t = { - string: { unit: "\uBB38\uC790", verb: "to have" }, - file: { unit: "\uBC14\uC774\uD2B8", verb: "to have" }, - array: { unit: "\uAC1C", verb: "to have" }, - set: { unit: "\uAC1C", verb: "to have" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\uC785\uB825", - email: "\uC774\uBA54\uC77C \uC8FC\uC18C", - url: "URL", - emoji: "\uC774\uBAA8\uC9C0", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \uB0A0\uC9DC\uC2DC\uAC04", - date: "ISO \uB0A0\uC9DC", - time: "ISO \uC2DC\uAC04", - duration: "ISO \uAE30\uAC04", - ipv4: "IPv4 \uC8FC\uC18C", - ipv6: "IPv6 \uC8FC\uC18C", - cidrv4: "IPv4 \uBC94\uC704", - cidrv6: "IPv6 \uBC94\uC704", - base64: "base64 \uC778\uCF54\uB529 \uBB38\uC790\uC5F4", - base64url: "base64url \uC778\uCF54\uB529 \uBB38\uC790\uC5F4", - json_string: "JSON \uBB38\uC790\uC5F4", - e164: "E.164 \uBC88\uD638", - jwt: "JWT", - template_literal: "\uC785\uB825", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\uC798\uBABB\uB41C \uC785\uB825: \uC608\uC0C1 \uD0C0\uC785\uC740 ${e.expected}, \uBC1B\uC740 \uD0C0\uC785\uC740 ${n(e.input)}\uC785\uB2C8\uB2E4` - case "invalid_value": - return e.values.length === 1 - ? `\uC798\uBABB\uB41C \uC785\uB825: \uAC12\uC740 ${_(e.values[0])} \uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4` - : `\uC798\uBABB\uB41C \uC635\uC158: ${f(e.values, "\uB610\uB294 ")} \uC911 \uD558\uB098\uC5EC\uC57C \uD569\uB2C8\uB2E4` - case "too_big": { - const o = e.inclusive ? "\uC774\uD558" : "\uBBF8\uB9CC", - a = o === "\uBBF8\uB9CC" ? "\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" : "\uC5EC\uC57C \uD569\uB2C8\uB2E4", - c = r(e.origin), - p = c?.unit ?? "\uC694\uC18C" - return c - ? `${e.origin ?? "\uAC12"}\uC774 \uB108\uBB34 \uD07D\uB2C8\uB2E4: ${e.maximum.toString()}${p} ${o}${a}` - : `${e.origin ?? "\uAC12"}\uC774 \uB108\uBB34 \uD07D\uB2C8\uB2E4: ${e.maximum.toString()} ${o}${a}` - } - case "too_small": { - const o = e.inclusive ? "\uC774\uC0C1" : "\uCD08\uACFC", - a = o === "\uC774\uC0C1" ? "\uC774\uC5B4\uC57C \uD569\uB2C8\uB2E4" : "\uC5EC\uC57C \uD569\uB2C8\uB2E4", - c = r(e.origin), - p = c?.unit ?? "\uC694\uC18C" - return c - ? `${e.origin ?? "\uAC12"}\uC774 \uB108\uBB34 \uC791\uC2B5\uB2C8\uB2E4: ${e.minimum.toString()}${p} ${o}${a}` - : `${e.origin ?? "\uAC12"}\uC774 \uB108\uBB34 \uC791\uC2B5\uB2C8\uB2E4: ${e.minimum.toString()} ${o}${a}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\uC798\uBABB\uB41C \uBB38\uC790\uC5F4: "${o.prefix}"(\uC73C)\uB85C \uC2DC\uC791\uD574\uC57C \uD569\uB2C8\uB2E4` - : o.format === "ends_with" - ? `\uC798\uBABB\uB41C \uBB38\uC790\uC5F4: "${o.suffix}"(\uC73C)\uB85C \uB05D\uB098\uC57C \uD569\uB2C8\uB2E4` - : o.format === "includes" - ? `\uC798\uBABB\uB41C \uBB38\uC790\uC5F4: "${o.includes}"\uC744(\uB97C) \uD3EC\uD568\uD574\uC57C \uD569\uB2C8\uB2E4` - : o.format === "regex" - ? `\uC798\uBABB\uB41C \uBB38\uC790\uC5F4: \uC815\uADDC\uC2DD ${o.pattern} \uD328\uD134\uACFC \uC77C\uCE58\uD574\uC57C \uD569\uB2C8\uB2E4` - : `\uC798\uBABB\uB41C ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\uC798\uBABB\uB41C \uC22B\uC790: ${e.divisor}\uC758 \uBC30\uC218\uC5EC\uC57C \uD569\uB2C8\uB2E4` - case "unrecognized_keys": - return `\uC778\uC2DD\uD560 \uC218 \uC5C6\uB294 \uD0A4: ${f(e.keys, ", ")}` - case "invalid_key": - return `\uC798\uBABB\uB41C \uD0A4: ${e.origin}` - case "invalid_union": - return "\uC798\uBABB\uB41C \uC785\uB825" - case "invalid_element": - return `\uC798\uBABB\uB41C \uAC12: ${e.origin}` - default: - return "\uC798\uBABB\uB41C \uC785\uB825" - } - } -} -function dc() { - return { localeError: El() } -} -var Al = () => { - const t = { - string: { unit: "\u0437\u043D\u0430\u0446\u0438", verb: "\u0434\u0430 \u0438\u043C\u0430\u0430\u0442" }, - file: { unit: "\u0431\u0430\u0458\u0442\u0438", verb: "\u0434\u0430 \u0438\u043C\u0430\u0430\u0442" }, - array: { unit: "\u0441\u0442\u0430\u0432\u043A\u0438", verb: "\u0434\u0430 \u0438\u043C\u0430\u0430\u0442" }, - set: { unit: "\u0441\u0442\u0430\u0432\u043A\u0438", verb: "\u0434\u0430 \u0438\u043C\u0430\u0430\u0442" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0431\u0440\u043E\u0458" - case "object": { - if (Array.isArray(e)) return "\u043D\u0438\u0437\u0430" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0432\u043D\u0435\u0441", - email: "\u0430\u0434\u0440\u0435\u0441\u0430 \u043D\u0430 \u0435-\u043F\u043E\u0448\u0442\u0430", - url: "URL", - emoji: "\u0435\u043C\u043E\u045F\u0438", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \u0434\u0430\u0442\u0443\u043C \u0438 \u0432\u0440\u0435\u043C\u0435", - date: "ISO \u0434\u0430\u0442\u0443\u043C", - time: "ISO \u0432\u0440\u0435\u043C\u0435", - duration: "ISO \u0432\u0440\u0435\u043C\u0435\u0442\u0440\u0430\u0435\u045A\u0435", - ipv4: "IPv4 \u0430\u0434\u0440\u0435\u0441\u0430", - ipv6: "IPv6 \u0430\u0434\u0440\u0435\u0441\u0430", - cidrv4: "IPv4 \u043E\u043F\u0441\u0435\u0433", - cidrv6: "IPv6 \u043E\u043F\u0441\u0435\u0433", - base64: "base64-\u0435\u043D\u043A\u043E\u0434\u0438\u0440\u0430\u043D\u0430 \u043D\u0438\u0437\u0430", - base64url: "base64url-\u0435\u043D\u043A\u043E\u0434\u0438\u0440\u0430\u043D\u0430 \u043D\u0438\u0437\u0430", - json_string: "JSON \u043D\u0438\u0437\u0430", - e164: "E.164 \u0431\u0440\u043E\u0458", - jwt: "JWT", - template_literal: "\u0432\u043D\u0435\u0441", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0413\u0440\u0435\u0448\u0435\u043D \u0432\u043D\u0435\u0441: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 ${e.expected}, \u043F\u0440\u0438\u043C\u0435\u043D\u043E ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Invalid input: expected ${_(e.values[0])}` - : `\u0413\u0440\u0435\u0448\u0430\u043D\u0430 \u043E\u043F\u0446\u0438\u0458\u0430: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 \u0435\u0434\u043D\u0430 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u041F\u0440\u0435\u043C\u043D\u043E\u0433\u0443 \u0433\u043E\u043B\u0435\u043C: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 ${e.origin ?? "\u0432\u0440\u0435\u0434\u043D\u043E\u0441\u0442\u0430"} \u0434\u0430 \u0438\u043C\u0430 ${o}${e.maximum.toString()} ${a.unit ?? "\u0435\u043B\u0435\u043C\u0435\u043D\u0442\u0438"}` - : `\u041F\u0440\u0435\u043C\u043D\u043E\u0433\u0443 \u0433\u043E\u043B\u0435\u043C: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 ${e.origin ?? "\u0432\u0440\u0435\u0434\u043D\u043E\u0441\u0442\u0430"} \u0434\u0430 \u0431\u0438\u0434\u0435 ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u041F\u0440\u0435\u043C\u043D\u043E\u0433\u0443 \u043C\u0430\u043B: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 ${e.origin} \u0434\u0430 \u0438\u043C\u0430 ${o}${e.minimum.toString()} ${a.unit}` - : `\u041F\u0440\u0435\u043C\u043D\u043E\u0433\u0443 \u043C\u0430\u043B: \u0441\u0435 \u043E\u0447\u0435\u043A\u0443\u0432\u0430 ${e.origin} \u0434\u0430 \u0431\u0438\u0434\u0435 ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u041D\u0435\u0432\u0430\u0436\u0435\u0447\u043A\u0430 \u043D\u0438\u0437\u0430: \u043C\u043E\u0440\u0430 \u0434\u0430 \u0437\u0430\u043F\u043E\u0447\u043D\u0443\u0432\u0430 \u0441\u043E "${o.prefix}"` - : o.format === "ends_with" - ? `\u041D\u0435\u0432\u0430\u0436\u0435\u0447\u043A\u0430 \u043D\u0438\u0437\u0430: \u043C\u043E\u0440\u0430 \u0434\u0430 \u0437\u0430\u0432\u0440\u0448\u0443\u0432\u0430 \u0441\u043E "${o.suffix}"` - : o.format === "includes" - ? `\u041D\u0435\u0432\u0430\u0436\u0435\u0447\u043A\u0430 \u043D\u0438\u0437\u0430: \u043C\u043E\u0440\u0430 \u0434\u0430 \u0432\u043A\u043B\u0443\u0447\u0443\u0432\u0430 "${o.includes}"` - : o.format === "regex" - ? `\u041D\u0435\u0432\u0430\u0436\u0435\u0447\u043A\u0430 \u043D\u0438\u0437\u0430: \u043C\u043E\u0440\u0430 \u0434\u0430 \u043E\u0434\u0433\u043E\u0430\u0440\u0430 \u043D\u0430 \u043F\u0430\u0442\u0435\u0440\u043D\u043E\u0442 ${o.pattern}` - : `Invalid ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u0413\u0440\u0435\u0448\u0435\u043D \u0431\u0440\u043E\u0458: \u043C\u043E\u0440\u0430 \u0434\u0430 \u0431\u0438\u0434\u0435 \u0434\u0435\u043B\u0438\u0432 \u0441\u043E ${e.divisor}` - case "unrecognized_keys": - return `${e.keys.length > 1 ? "\u041D\u0435\u043F\u0440\u0435\u043F\u043E\u0437\u043D\u0430\u0435\u043D\u0438 \u043A\u043B\u0443\u0447\u0435\u0432\u0438" : "\u041D\u0435\u043F\u0440\u0435\u043F\u043E\u0437\u043D\u0430\u0435\u043D \u043A\u043B\u0443\u0447"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u0413\u0440\u0435\u0448\u0435\u043D \u043A\u043B\u0443\u0447 \u0432\u043E ${e.origin}` - case "invalid_union": - return "\u0413\u0440\u0435\u0448\u0435\u043D \u0432\u043D\u0435\u0441" - case "invalid_element": - return `\u0413\u0440\u0435\u0448\u043D\u0430 \u0432\u0440\u0435\u0434\u043D\u043E\u0441\u0442 \u0432\u043E ${e.origin}` - default: - return "\u0413\u0440\u0435\u0448\u0435\u043D \u0432\u043D\u0435\u0441" - } - } -} -function fc() { - return { localeError: Al() } -} -var Cl = () => { - const t = { - string: { unit: "aksara", verb: "mempunyai" }, - file: { unit: "bait", verb: "mempunyai" }, - array: { unit: "elemen", verb: "mempunyai" }, - set: { unit: "elemen", verb: "mempunyai" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "nombor" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "input", - email: "alamat e-mel", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "tarikh masa ISO", - date: "tarikh ISO", - time: "masa ISO", - duration: "tempoh ISO", - ipv4: "alamat IPv4", - ipv6: "alamat IPv6", - cidrv4: "julat IPv4", - cidrv6: "julat IPv6", - base64: "string dikodkan base64", - base64url: "string dikodkan base64url", - json_string: "string JSON", - e164: "nombor E.164", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Input tidak sah: dijangka ${e.expected}, diterima ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Input tidak sah: dijangka ${_(e.values[0])}` - : `Pilihan tidak sah: dijangka salah satu daripada ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Terlalu besar: dijangka ${e.origin ?? "nilai"} ${a.verb} ${o}${e.maximum.toString()} ${a.unit ?? "elemen"}` - : `Terlalu besar: dijangka ${e.origin ?? "nilai"} adalah ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Terlalu kecil: dijangka ${e.origin} ${a.verb} ${o}${e.minimum.toString()} ${a.unit}` - : `Terlalu kecil: dijangka ${e.origin} adalah ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `String tidak sah: mesti bermula dengan "${o.prefix}"` - : o.format === "ends_with" - ? `String tidak sah: mesti berakhir dengan "${o.suffix}"` - : o.format === "includes" - ? `String tidak sah: mesti mengandungi "${o.includes}"` - : o.format === "regex" - ? `String tidak sah: mesti sepadan dengan corak ${o.pattern}` - : `${i[o.format] ?? e.format} tidak sah` - } - case "not_multiple_of": - return `Nombor tidak sah: perlu gandaan ${e.divisor}` - case "unrecognized_keys": - return `Kunci tidak dikenali: ${f(e.keys, ", ")}` - case "invalid_key": - return `Kunci tidak sah dalam ${e.origin}` - case "invalid_union": - return "Input tidak sah" - case "invalid_element": - return `Nilai tidak sah dalam ${e.origin}` - default: - return "Input tidak sah" - } - } -} -function gc() { - return { localeError: Cl() } -} -var Ll = () => { - const t = { - string: { unit: "tekens" }, - file: { unit: "bytes" }, - array: { unit: "elementen" }, - set: { unit: "elementen" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "getal" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "invoer", - email: "emailadres", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datum en tijd", - date: "ISO datum", - time: "ISO tijd", - duration: "ISO duur", - ipv4: "IPv4-adres", - ipv6: "IPv6-adres", - cidrv4: "IPv4-bereik", - cidrv6: "IPv6-bereik", - base64: "base64-gecodeerde tekst", - base64url: "base64 URL-gecodeerde tekst", - json_string: "JSON string", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "invoer", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Ongeldige invoer: verwacht ${e.expected}, ontving ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Ongeldige invoer: verwacht ${_(e.values[0])}` - : `Ongeldige optie: verwacht \xE9\xE9n van ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Te lang: verwacht dat ${e.origin ?? "waarde"} ${o}${e.maximum.toString()} ${a.unit ?? "elementen"} bevat` - : `Te lang: verwacht dat ${e.origin ?? "waarde"} ${o}${e.maximum.toString()} is` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Te kort: verwacht dat ${e.origin} ${o}${e.minimum.toString()} ${a.unit} bevat` - : `Te kort: verwacht dat ${e.origin} ${o}${e.minimum.toString()} is` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Ongeldige tekst: moet met "${o.prefix}" beginnen` - : o.format === "ends_with" - ? `Ongeldige tekst: moet op "${o.suffix}" eindigen` - : o.format === "includes" - ? `Ongeldige tekst: moet "${o.includes}" bevatten` - : o.format === "regex" - ? `Ongeldige tekst: moet overeenkomen met patroon ${o.pattern}` - : `Ongeldig: ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Ongeldig getal: moet een veelvoud van ${e.divisor} zijn` - case "unrecognized_keys": - return `Onbekende key${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Ongeldige key in ${e.origin}` - case "invalid_union": - return "Ongeldige invoer" - case "invalid_element": - return `Ongeldige waarde in ${e.origin}` - default: - return "Ongeldige invoer" - } - } -} -function hc() { - return { localeError: Ll() } -} -var Ml = () => { - const t = { - string: { unit: "tegn", verb: "\xE5 ha" }, - file: { unit: "bytes", verb: "\xE5 ha" }, - array: { unit: "elementer", verb: "\xE5 inneholde" }, - set: { unit: "elementer", verb: "\xE5 inneholde" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "tall" - case "object": { - if (Array.isArray(e)) return "liste" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "input", - email: "e-postadresse", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO dato- og klokkeslett", - date: "ISO-dato", - time: "ISO-klokkeslett", - duration: "ISO-varighet", - ipv4: "IPv4-omr\xE5de", - ipv6: "IPv6-omr\xE5de", - cidrv4: "IPv4-spekter", - cidrv6: "IPv6-spekter", - base64: "base64-enkodet streng", - base64url: "base64url-enkodet streng", - json_string: "JSON-streng", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Ugyldig input: forventet ${e.expected}, fikk ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Ugyldig verdi: forventet ${_(e.values[0])}` - : `Ugyldig valg: forventet en av ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `For stor(t): forventet ${e.origin ?? "value"} til \xE5 ha ${o}${e.maximum.toString()} ${a.unit ?? "elementer"}` - : `For stor(t): forventet ${e.origin ?? "value"} til \xE5 ha ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `For lite(n): forventet ${e.origin} til \xE5 ha ${o}${e.minimum.toString()} ${a.unit}` - : `For lite(n): forventet ${e.origin} til \xE5 ha ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Ugyldig streng: m\xE5 starte med "${o.prefix}"` - : o.format === "ends_with" - ? `Ugyldig streng: m\xE5 ende med "${o.suffix}"` - : o.format === "includes" - ? `Ugyldig streng: m\xE5 inneholde "${o.includes}"` - : o.format === "regex" - ? `Ugyldig streng: m\xE5 matche m\xF8nsteret ${o.pattern}` - : `Ugyldig ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Ugyldig tall: m\xE5 v\xE6re et multiplum av ${e.divisor}` - case "unrecognized_keys": - return `${e.keys.length > 1 ? "Ukjente n\xF8kler" : "Ukjent n\xF8kkel"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Ugyldig n\xF8kkel i ${e.origin}` - case "invalid_union": - return "Ugyldig input" - case "invalid_element": - return `Ugyldig verdi i ${e.origin}` - default: - return "Ugyldig input" - } - } -} -function vc() { - return { localeError: Ml() } -} -var ql = () => { - const t = { - string: { unit: "harf", verb: "olmal\u0131d\u0131r" }, - file: { unit: "bayt", verb: "olmal\u0131d\u0131r" }, - array: { unit: "unsur", verb: "olmal\u0131d\u0131r" }, - set: { unit: "unsur", verb: "olmal\u0131d\u0131r" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "numara" - case "object": { - if (Array.isArray(e)) return "saf" - if (e === null) return "gayb" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "giren", - email: "epostag\xE2h", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO heng\xE2m\u0131", - date: "ISO tarihi", - time: "ISO zaman\u0131", - duration: "ISO m\xFCddeti", - ipv4: "IPv4 ni\u015F\xE2n\u0131", - ipv6: "IPv6 ni\u015F\xE2n\u0131", - cidrv4: "IPv4 menzili", - cidrv6: "IPv6 menzili", - base64: "base64-\u015Fifreli metin", - base64url: "base64url-\u015Fifreli metin", - json_string: "JSON metin", - e164: "E.164 say\u0131s\u0131", - jwt: "JWT", - template_literal: "giren", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `F\xE2sit giren: umulan ${e.expected}, al\u0131nan ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `F\xE2sit giren: umulan ${_(e.values[0])}` - : `F\xE2sit tercih: m\xFBteberler ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Fazla b\xFCy\xFCk: ${e.origin ?? "value"}, ${o}${e.maximum.toString()} ${a.unit ?? "elements"} sahip olmal\u0131yd\u0131.` - : `Fazla b\xFCy\xFCk: ${e.origin ?? "value"}, ${o}${e.maximum.toString()} olmal\u0131yd\u0131.` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Fazla k\xFC\xE7\xFCk: ${e.origin}, ${o}${e.minimum.toString()} ${a.unit} sahip olmal\u0131yd\u0131.` - : `Fazla k\xFC\xE7\xFCk: ${e.origin}, ${o}${e.minimum.toString()} olmal\u0131yd\u0131.` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `F\xE2sit metin: "${o.prefix}" ile ba\u015Flamal\u0131.` - : o.format === "ends_with" - ? `F\xE2sit metin: "${o.suffix}" ile bitmeli.` - : o.format === "includes" - ? `F\xE2sit metin: "${o.includes}" ihtiv\xE2 etmeli.` - : o.format === "regex" - ? `F\xE2sit metin: ${o.pattern} nak\u015F\u0131na uymal\u0131.` - : `F\xE2sit ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `F\xE2sit say\u0131: ${e.divisor} kat\u0131 olmal\u0131yd\u0131.` - case "unrecognized_keys": - return `Tan\u0131nmayan anahtar ${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `${e.origin} i\xE7in tan\u0131nmayan anahtar var.` - case "invalid_union": - return "Giren tan\u0131namad\u0131." - case "invalid_element": - return `${e.origin} i\xE7in tan\u0131nmayan k\u0131ymet var.` - default: - return "K\u0131ymet tan\u0131namad\u0131." - } - } -} -function bc() { - return { localeError: ql() } -} -var Vl = () => { - const t = { - string: { unit: "\u062A\u0648\u06A9\u064A", verb: "\u0648\u0644\u0631\u064A" }, - file: { unit: "\u0628\u0627\u06CC\u067C\u0633", verb: "\u0648\u0644\u0631\u064A" }, - array: { unit: "\u062A\u0648\u06A9\u064A", verb: "\u0648\u0644\u0631\u064A" }, - set: { unit: "\u062A\u0648\u06A9\u064A", verb: "\u0648\u0644\u0631\u064A" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0639\u062F\u062F" - case "object": { - if (Array.isArray(e)) return "\u0627\u0631\u06D0" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0648\u0631\u0648\u062F\u064A", - email: "\u0628\u0631\u06CC\u069A\u0646\u0627\u0644\u06CC\u06A9", - url: "\u06CC\u0648 \u0622\u0631 \u0627\u0644", - emoji: "\u0627\u06CC\u0645\u0648\u062C\u064A", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u0646\u06CC\u067C\u0647 \u0627\u0648 \u0648\u062E\u062A", - date: "\u0646\u06D0\u067C\u0647", - time: "\u0648\u062E\u062A", - duration: "\u0645\u0648\u062F\u0647", - ipv4: "\u062F IPv4 \u067E\u062A\u0647", - ipv6: "\u062F IPv6 \u067E\u062A\u0647", - cidrv4: "\u062F IPv4 \u0633\u0627\u062D\u0647", - cidrv6: "\u062F IPv6 \u0633\u0627\u062D\u0647", - base64: "base64-encoded \u0645\u062A\u0646", - base64url: "base64url-encoded \u0645\u062A\u0646", - json_string: "JSON \u0645\u062A\u0646", - e164: "\u062F E.164 \u0634\u0645\u06D0\u0631\u0647", - jwt: "JWT", - template_literal: "\u0648\u0631\u0648\u062F\u064A", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0646\u0627\u0633\u0645 \u0648\u0631\u0648\u062F\u064A: \u0628\u0627\u06CC\u062F ${e.expected} \u0648\u0627\u06CC, \u0645\u06AB\u0631 ${n(e.input)} \u062A\u0631\u0644\u0627\u0633\u0647 \u0634\u0648` - case "invalid_value": - return e.values.length === 1 - ? `\u0646\u0627\u0633\u0645 \u0648\u0631\u0648\u062F\u064A: \u0628\u0627\u06CC\u062F ${_(e.values[0])} \u0648\u0627\u06CC` - : `\u0646\u0627\u0633\u0645 \u0627\u0646\u062A\u062E\u0627\u0628: \u0628\u0627\u06CC\u062F \u06CC\u0648 \u0644\u0647 ${f(e.values, "|")} \u0685\u062E\u0647 \u0648\u0627\u06CC` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u0689\u06CC\u0631 \u0644\u0648\u06CC: ${e.origin ?? "\u0627\u0631\u0632\u069A\u062A"} \u0628\u0627\u06CC\u062F ${o}${e.maximum.toString()} ${a.unit ?? "\u0639\u0646\u0635\u0631\u0648\u0646\u0647"} \u0648\u0644\u0631\u064A` - : `\u0689\u06CC\u0631 \u0644\u0648\u06CC: ${e.origin ?? "\u0627\u0631\u0632\u069A\u062A"} \u0628\u0627\u06CC\u062F ${o}${e.maximum.toString()} \u0648\u064A` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u0689\u06CC\u0631 \u06A9\u0648\u0686\u0646\u06CC: ${e.origin} \u0628\u0627\u06CC\u062F ${o}${e.minimum.toString()} ${a.unit} \u0648\u0644\u0631\u064A` - : `\u0689\u06CC\u0631 \u06A9\u0648\u0686\u0646\u06CC: ${e.origin} \u0628\u0627\u06CC\u062F ${o}${e.minimum.toString()} \u0648\u064A` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u0646\u0627\u0633\u0645 \u0645\u062A\u0646: \u0628\u0627\u06CC\u062F \u062F "${o.prefix}" \u0633\u0631\u0647 \u067E\u06CC\u0644 \u0634\u064A` - : o.format === "ends_with" - ? `\u0646\u0627\u0633\u0645 \u0645\u062A\u0646: \u0628\u0627\u06CC\u062F \u062F "${o.suffix}" \u0633\u0631\u0647 \u067E\u0627\u06CC \u062A\u0647 \u0648\u0631\u0633\u064A\u0696\u064A` - : o.format === "includes" - ? `\u0646\u0627\u0633\u0645 \u0645\u062A\u0646: \u0628\u0627\u06CC\u062F "${o.includes}" \u0648\u0644\u0631\u064A` - : o.format === "regex" - ? `\u0646\u0627\u0633\u0645 \u0645\u062A\u0646: \u0628\u0627\u06CC\u062F \u062F ${o.pattern} \u0633\u0631\u0647 \u0645\u0637\u0627\u0628\u0642\u062A \u0648\u0644\u0631\u064A` - : `${i[o.format] ?? e.format} \u0646\u0627\u0633\u0645 \u062F\u06CC` - } - case "not_multiple_of": - return `\u0646\u0627\u0633\u0645 \u0639\u062F\u062F: \u0628\u0627\u06CC\u062F \u062F ${e.divisor} \u0645\u0636\u0631\u0628 \u0648\u064A` - case "unrecognized_keys": - return `\u0646\u0627\u0633\u0645 ${e.keys.length > 1 ? "\u06A9\u0644\u06CC\u0689\u0648\u0646\u0647" : "\u06A9\u0644\u06CC\u0689"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u0646\u0627\u0633\u0645 \u06A9\u0644\u06CC\u0689 \u067E\u0647 ${e.origin} \u06A9\u06D0` - case "invalid_union": - return "\u0646\u0627\u0633\u0645\u0647 \u0648\u0631\u0648\u062F\u064A" - case "invalid_element": - return `\u0646\u0627\u0633\u0645 \u0639\u0646\u0635\u0631 \u067E\u0647 ${e.origin} \u06A9\u06D0` - default: - return "\u0646\u0627\u0633\u0645\u0647 \u0648\u0631\u0648\u062F\u064A" - } - } -} -function _c() { - return { localeError: Vl() } -} -var Fl = () => { - const t = { - string: { unit: "znak\xF3w", verb: "mie\u0107" }, - file: { unit: "bajt\xF3w", verb: "mie\u0107" }, - array: { unit: "element\xF3w", verb: "mie\u0107" }, - set: { unit: "element\xF3w", verb: "mie\u0107" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "liczba" - case "object": { - if (Array.isArray(e)) return "tablica" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "wyra\u017Cenie", - email: "adres email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data i godzina w formacie ISO", - date: "data w formacie ISO", - time: "godzina w formacie ISO", - duration: "czas trwania ISO", - ipv4: "adres IPv4", - ipv6: "adres IPv6", - cidrv4: "zakres IPv4", - cidrv6: "zakres IPv6", - base64: "ci\u0105g znak\xF3w zakodowany w formacie base64", - base64url: "ci\u0105g znak\xF3w zakodowany w formacie base64url", - json_string: "ci\u0105g znak\xF3w w formacie JSON", - e164: "liczba E.164", - jwt: "JWT", - template_literal: "wej\u015Bcie", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Nieprawid\u0142owe dane wej\u015Bciowe: oczekiwano ${e.expected}, otrzymano ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Nieprawid\u0142owe dane wej\u015Bciowe: oczekiwano ${_(e.values[0])}` - : `Nieprawid\u0142owa opcja: oczekiwano jednej z warto\u015Bci ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Za du\u017Ca warto\u015B\u0107: oczekiwano, \u017Ce ${e.origin ?? "warto\u015B\u0107"} b\u0119dzie mie\u0107 ${o}${e.maximum.toString()} ${a.unit ?? "element\xF3w"}` - : `Zbyt du\u017C(y/a/e): oczekiwano, \u017Ce ${e.origin ?? "warto\u015B\u0107"} b\u0119dzie wynosi\u0107 ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Za ma\u0142a warto\u015B\u0107: oczekiwano, \u017Ce ${e.origin ?? "warto\u015B\u0107"} b\u0119dzie mie\u0107 ${o}${e.minimum.toString()} ${a.unit ?? "element\xF3w"}` - : `Zbyt ma\u0142(y/a/e): oczekiwano, \u017Ce ${e.origin ?? "warto\u015B\u0107"} b\u0119dzie wynosi\u0107 ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Nieprawid\u0142owy ci\u0105g znak\xF3w: musi zaczyna\u0107 si\u0119 od "${o.prefix}"` - : o.format === "ends_with" - ? `Nieprawid\u0142owy ci\u0105g znak\xF3w: musi ko\u0144czy\u0107 si\u0119 na "${o.suffix}"` - : o.format === "includes" - ? `Nieprawid\u0142owy ci\u0105g znak\xF3w: musi zawiera\u0107 "${o.includes}"` - : o.format === "regex" - ? `Nieprawid\u0142owy ci\u0105g znak\xF3w: musi odpowiada\u0107 wzorcowi ${o.pattern}` - : `Nieprawid\u0142ow(y/a/e) ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Nieprawid\u0142owa liczba: musi by\u0107 wielokrotno\u015Bci\u0105 ${e.divisor}` - case "unrecognized_keys": - return `Nierozpoznane klucze${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Nieprawid\u0142owy klucz w ${e.origin}` - case "invalid_union": - return "Nieprawid\u0142owe dane wej\u015Bciowe" - case "invalid_element": - return `Nieprawid\u0142owa warto\u015B\u0107 w ${e.origin}` - default: - return "Nieprawid\u0142owe dane wej\u015Bciowe" - } - } -} -function yc() { - return { localeError: Fl() } -} -var Hl = () => { - const t = { - string: { unit: "caracteres", verb: "ter" }, - file: { unit: "bytes", verb: "ter" }, - array: { unit: "itens", verb: "ter" }, - set: { unit: "itens", verb: "ter" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "n\xFAmero" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "nulo" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "padr\xE3o", - email: "endere\xE7o de e-mail", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "data e hora ISO", - date: "data ISO", - time: "hora ISO", - duration: "dura\xE7\xE3o ISO", - ipv4: "endere\xE7o IPv4", - ipv6: "endere\xE7o IPv6", - cidrv4: "faixa de IPv4", - cidrv6: "faixa de IPv6", - base64: "texto codificado em base64", - base64url: "URL codificada em base64", - json_string: "texto JSON", - e164: "n\xFAmero E.164", - jwt: "JWT", - template_literal: "entrada", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Tipo inv\xE1lido: esperado ${e.expected}, recebido ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Entrada inv\xE1lida: esperado ${_(e.values[0])}` - : `Op\xE7\xE3o inv\xE1lida: esperada uma das ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Muito grande: esperado que ${e.origin ?? "valor"} tivesse ${o}${e.maximum.toString()} ${a.unit ?? "elementos"}` - : `Muito grande: esperado que ${e.origin ?? "valor"} fosse ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Muito pequeno: esperado que ${e.origin} tivesse ${o}${e.minimum.toString()} ${a.unit}` - : `Muito pequeno: esperado que ${e.origin} fosse ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Texto inv\xE1lido: deve come\xE7ar com "${o.prefix}"` - : o.format === "ends_with" - ? `Texto inv\xE1lido: deve terminar com "${o.suffix}"` - : o.format === "includes" - ? `Texto inv\xE1lido: deve incluir "${o.includes}"` - : o.format === "regex" - ? `Texto inv\xE1lido: deve corresponder ao padr\xE3o ${o.pattern}` - : `${i[o.format] ?? e.format} inv\xE1lido` - } - case "not_multiple_of": - return `N\xFAmero inv\xE1lido: deve ser m\xFAltiplo de ${e.divisor}` - case "unrecognized_keys": - return `Chave${e.keys.length > 1 ? "s" : ""} desconhecida${e.keys.length > 1 ? "s" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Chave inv\xE1lida em ${e.origin}` - case "invalid_union": - return "Entrada inv\xE1lida" - case "invalid_element": - return `Valor inv\xE1lido em ${e.origin}` - default: - return "Campo inv\xE1lido" - } - } -} -function $c() { - return { localeError: Hl() } -} -function xc(t, r, n, i) { - const e = Math.abs(t), - o = e % 10, - a = e % 100 - return a >= 11 && a <= 19 ? i : o === 1 ? r : o >= 2 && o <= 4 ? n : i -} -var Jl = () => { - const t = { - string: { - unit: { - one: "\u0441\u0438\u043C\u0432\u043E\u043B", - few: "\u0441\u0438\u043C\u0432\u043E\u043B\u0430", - many: "\u0441\u0438\u043C\u0432\u043E\u043B\u043E\u0432", - }, - verb: "\u0438\u043C\u0435\u0442\u044C", - }, - file: { - unit: { - one: "\u0431\u0430\u0439\u0442", - few: "\u0431\u0430\u0439\u0442\u0430", - many: "\u0431\u0430\u0439\u0442", - }, - verb: "\u0438\u043C\u0435\u0442\u044C", - }, - array: { - unit: { - one: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442", - few: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u0430", - many: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432", - }, - verb: "\u0438\u043C\u0435\u0442\u044C", - }, - set: { - unit: { - one: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442", - few: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u0430", - many: "\u044D\u043B\u0435\u043C\u0435\u043D\u0442\u043E\u0432", - }, - verb: "\u0438\u043C\u0435\u0442\u044C", - }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0447\u0438\u0441\u043B\u043E" - case "object": { - if (Array.isArray(e)) return "\u043C\u0430\u0441\u0441\u0438\u0432" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0432\u0432\u043E\u0434", - email: "email \u0430\u0434\u0440\u0435\u0441", - url: "URL", - emoji: "\u044D\u043C\u043E\u0434\u0437\u0438", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \u0434\u0430\u0442\u0430 \u0438 \u0432\u0440\u0435\u043C\u044F", - date: "ISO \u0434\u0430\u0442\u0430", - time: "ISO \u0432\u0440\u0435\u043C\u044F", - duration: "ISO \u0434\u043B\u0438\u0442\u0435\u043B\u044C\u043D\u043E\u0441\u0442\u044C", - ipv4: "IPv4 \u0430\u0434\u0440\u0435\u0441", - ipv6: "IPv6 \u0430\u0434\u0440\u0435\u0441", - cidrv4: "IPv4 \u0434\u0438\u0430\u043F\u0430\u0437\u043E\u043D", - cidrv6: "IPv6 \u0434\u0438\u0430\u043F\u0430\u0437\u043E\u043D", - base64: "\u0441\u0442\u0440\u043E\u043A\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\u0435 base64", - base64url: "\u0441\u0442\u0440\u043E\u043A\u0430 \u0432 \u0444\u043E\u0440\u043C\u0430\u0442\u0435 base64url", - json_string: "JSON \u0441\u0442\u0440\u043E\u043A\u0430", - e164: "\u043D\u043E\u043C\u0435\u0440 E.164", - jwt: "JWT", - template_literal: "\u0432\u0432\u043E\u0434", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0432\u0432\u043E\u0434: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C ${e.expected}, \u043F\u043E\u043B\u0443\u0447\u0435\u043D\u043E ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0432\u0432\u043E\u0434: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C ${_(e.values[0])}` - : `\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u0432\u0430\u0440\u0438\u0430\u043D\u0442: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C \u043E\u0434\u043D\u043E \u0438\u0437 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - if (a) { - const c = Number(e.maximum), - p = xc(c, a.unit.one, a.unit.few, a.unit.many) - return `\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0431\u043E\u043B\u044C\u0448\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C, \u0447\u0442\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435"} \u0431\u0443\u0434\u0435\u0442 \u0438\u043C\u0435\u0442\u044C ${o}${e.maximum.toString()} ${p}` - } - return `\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0431\u043E\u043B\u044C\u0448\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C, \u0447\u0442\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435"} \u0431\u0443\u0434\u0435\u0442 ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - if (a) { - const c = Number(e.minimum), - p = xc(c, a.unit.one, a.unit.few, a.unit.many) - return `\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u0430\u043B\u0435\u043D\u044C\u043A\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C, \u0447\u0442\u043E ${e.origin} \u0431\u0443\u0434\u0435\u0442 \u0438\u043C\u0435\u0442\u044C ${o}${e.minimum.toString()} ${p}` - } - return `\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u0430\u043B\u0435\u043D\u044C\u043A\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435: \u043E\u0436\u0438\u0434\u0430\u043B\u043E\u0441\u044C, \u0447\u0442\u043E ${e.origin} \u0431\u0443\u0434\u0435\u0442 ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u041D\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u0441\u0442\u0440\u043E\u043A\u0430: \u0434\u043E\u043B\u0436\u043D\u0430 \u043D\u0430\u0447\u0438\u043D\u0430\u0442\u044C\u0441\u044F \u0441 "${o.prefix}"` - : o.format === "ends_with" - ? `\u041D\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u0441\u0442\u0440\u043E\u043A\u0430: \u0434\u043E\u043B\u0436\u043D\u0430 \u0437\u0430\u043A\u0430\u043D\u0447\u0438\u0432\u0430\u0442\u044C\u0441\u044F \u043D\u0430 "${o.suffix}"` - : o.format === "includes" - ? `\u041D\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u0441\u0442\u0440\u043E\u043A\u0430: \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u043E\u0434\u0435\u0440\u0436\u0430\u0442\u044C "${o.includes}"` - : o.format === "regex" - ? `\u041D\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u0441\u0442\u0440\u043E\u043A\u0430: \u0434\u043E\u043B\u0436\u043D\u0430 \u0441\u043E\u043E\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u043E\u0432\u0430\u0442\u044C \u0448\u0430\u0431\u043B\u043E\u043D\u0443 ${o.pattern}` - : `\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u041D\u0435\u0432\u0435\u0440\u043D\u043E\u0435 \u0447\u0438\u0441\u043B\u043E: \u0434\u043E\u043B\u0436\u043D\u043E \u0431\u044B\u0442\u044C \u043A\u0440\u0430\u0442\u043D\u044B\u043C ${e.divisor}` - case "unrecognized_keys": - return `\u041D\u0435\u0440\u0430\u0441\u043F\u043E\u0437\u043D\u0430\u043D\u043D${e.keys.length > 1 ? "\u044B\u0435" : "\u044B\u0439"} \u043A\u043B\u044E\u0447${e.keys.length > 1 ? "\u0438" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0439 \u043A\u043B\u044E\u0447 \u0432 ${e.origin}` - case "invalid_union": - return "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0435 \u0432\u0445\u043E\u0434\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435" - case "invalid_element": - return `\u041D\u0435\u0432\u0435\u0440\u043D\u043E\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u0438\u0435 \u0432 ${e.origin}` - default: - return "\u041D\u0435\u0432\u0435\u0440\u043D\u044B\u0435 \u0432\u0445\u043E\u0434\u043D\u044B\u0435 \u0434\u0430\u043D\u043D\u044B\u0435" - } - } -} -function zc() { - return { localeError: Jl() } -} -var Bl = () => { - const t = { - string: { unit: "znakov", verb: "imeti" }, - file: { unit: "bajtov", verb: "imeti" }, - array: { unit: "elementov", verb: "imeti" }, - set: { unit: "elementov", verb: "imeti" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0161tevilo" - case "object": { - if (Array.isArray(e)) return "tabela" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "vnos", - email: "e-po\u0161tni naslov", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO datum in \u010Das", - date: "ISO datum", - time: "ISO \u010Das", - duration: "ISO trajanje", - ipv4: "IPv4 naslov", - ipv6: "IPv6 naslov", - cidrv4: "obseg IPv4", - cidrv6: "obseg IPv6", - base64: "base64 kodiran niz", - base64url: "base64url kodiran niz", - json_string: "JSON niz", - e164: "E.164 \u0161tevilka", - jwt: "JWT", - template_literal: "vnos", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Neveljaven vnos: pri\u010Dakovano ${e.expected}, prejeto ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Neveljaven vnos: pri\u010Dakovano ${_(e.values[0])}` - : `Neveljavna mo\u017Enost: pri\u010Dakovano eno izmed ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Preveliko: pri\u010Dakovano, da bo ${e.origin ?? "vrednost"} imelo ${o}${e.maximum.toString()} ${a.unit ?? "elementov"}` - : `Preveliko: pri\u010Dakovano, da bo ${e.origin ?? "vrednost"} ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Premajhno: pri\u010Dakovano, da bo ${e.origin} imelo ${o}${e.minimum.toString()} ${a.unit}` - : `Premajhno: pri\u010Dakovano, da bo ${e.origin} ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Neveljaven niz: mora se za\u010Deti z "${o.prefix}"` - : o.format === "ends_with" - ? `Neveljaven niz: mora se kon\u010Dati z "${o.suffix}"` - : o.format === "includes" - ? `Neveljaven niz: mora vsebovati "${o.includes}"` - : o.format === "regex" - ? `Neveljaven niz: mora ustrezati vzorcu ${o.pattern}` - : `Neveljaven ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Neveljavno \u0161tevilo: mora biti ve\u010Dkratnik ${e.divisor}` - case "unrecognized_keys": - return `Neprepoznan${e.keys.length > 1 ? "i klju\u010Di" : " klju\u010D"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Neveljaven klju\u010D v ${e.origin}` - case "invalid_union": - return "Neveljaven vnos" - case "invalid_element": - return `Neveljavna vrednost v ${e.origin}` - default: - return "Neveljaven vnos" - } - } -} -function kc() { - return { localeError: Bl() } -} -var Wl = () => { - const t = { - string: { unit: "tecken", verb: "att ha" }, - file: { unit: "bytes", verb: "att ha" }, - array: { unit: "objekt", verb: "att inneh\xE5lla" }, - set: { unit: "objekt", verb: "att inneh\xE5lla" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "antal" - case "object": { - if (Array.isArray(e)) return "lista" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "regulj\xE4rt uttryck", - email: "e-postadress", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO-datum och tid", - date: "ISO-datum", - time: "ISO-tid", - duration: "ISO-varaktighet", - ipv4: "IPv4-intervall", - ipv6: "IPv6-intervall", - cidrv4: "IPv4-spektrum", - cidrv6: "IPv6-spektrum", - base64: "base64-kodad str\xE4ng", - base64url: "base64url-kodad str\xE4ng", - json_string: "JSON-str\xE4ng", - e164: "E.164-nummer", - jwt: "JWT", - template_literal: "mall-literal", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `Ogiltig inmatning: f\xF6rv\xE4ntat ${e.expected}, fick ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `Ogiltig inmatning: f\xF6rv\xE4ntat ${_(e.values[0])}` - : `Ogiltigt val: f\xF6rv\xE4ntade en av ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `F\xF6r stor(t): f\xF6rv\xE4ntade ${e.origin ?? "v\xE4rdet"} att ha ${o}${e.maximum.toString()} ${a.unit ?? "element"}` - : `F\xF6r stor(t): f\xF6rv\xE4ntat ${e.origin ?? "v\xE4rdet"} att ha ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `F\xF6r lite(t): f\xF6rv\xE4ntade ${e.origin ?? "v\xE4rdet"} att ha ${o}${e.minimum.toString()} ${a.unit}` - : `F\xF6r lite(t): f\xF6rv\xE4ntade ${e.origin ?? "v\xE4rdet"} att ha ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Ogiltig str\xE4ng: m\xE5ste b\xF6rja med "${o.prefix}"` - : o.format === "ends_with" - ? `Ogiltig str\xE4ng: m\xE5ste sluta med "${o.suffix}"` - : o.format === "includes" - ? `Ogiltig str\xE4ng: m\xE5ste inneh\xE5lla "${o.includes}"` - : o.format === "regex" - ? `Ogiltig str\xE4ng: m\xE5ste matcha m\xF6nstret "${o.pattern}"` - : `Ogiltig(t) ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `Ogiltigt tal: m\xE5ste vara en multipel av ${e.divisor}` - case "unrecognized_keys": - return `${e.keys.length > 1 ? "Ok\xE4nda nycklar" : "Ok\xE4nd nyckel"}: ${f(e.keys, ", ")}` - case "invalid_key": - return `Ogiltig nyckel i ${e.origin ?? "v\xE4rdet"}` - case "invalid_union": - return "Ogiltig input" - case "invalid_element": - return `Ogiltigt v\xE4rde i ${e.origin ?? "v\xE4rdet"}` - default: - return "Ogiltig input" - } - } -} -function Sc() { - return { localeError: Wl() } -} -var Gl = () => { - const t = { - string: { - unit: "\u0B8E\u0BB4\u0BC1\u0BA4\u0BCD\u0BA4\u0BC1\u0B95\u0BCD\u0B95\u0BB3\u0BCD", - verb: "\u0B95\u0BCA\u0BA3\u0BCD\u0B9F\u0BBF\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD", - }, - file: { - unit: "\u0BAA\u0BC8\u0B9F\u0BCD\u0B9F\u0BC1\u0B95\u0BB3\u0BCD", - verb: "\u0B95\u0BCA\u0BA3\u0BCD\u0B9F\u0BBF\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD", - }, - array: { - unit: "\u0B89\u0BB1\u0BC1\u0BAA\u0BCD\u0BAA\u0BC1\u0B95\u0BB3\u0BCD", - verb: "\u0B95\u0BCA\u0BA3\u0BCD\u0B9F\u0BBF\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD", - }, - set: { - unit: "\u0B89\u0BB1\u0BC1\u0BAA\u0BCD\u0BAA\u0BC1\u0B95\u0BB3\u0BCD", - verb: "\u0B95\u0BCA\u0BA3\u0BCD\u0B9F\u0BBF\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD", - }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) - ? "\u0B8E\u0BA3\u0BCD \u0B85\u0BB2\u0BCD\u0BB2\u0BBE\u0BA4\u0BA4\u0BC1" - : "\u0B8E\u0BA3\u0BCD" - case "object": { - if (Array.isArray(e)) return "\u0B85\u0BA3\u0BBF" - if (e === null) return "\u0BB5\u0BC6\u0BB1\u0BC1\u0BAE\u0BC8" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0B89\u0BB3\u0BCD\u0BB3\u0BC0\u0B9F\u0BC1", - email: "\u0BAE\u0BBF\u0BA9\u0BCD\u0BA9\u0B9E\u0BCD\u0B9A\u0BB2\u0BCD \u0BAE\u0BC1\u0B95\u0BB5\u0BB0\u0BBF", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \u0BA4\u0BC7\u0BA4\u0BBF \u0BA8\u0BC7\u0BB0\u0BAE\u0BCD", - date: "ISO \u0BA4\u0BC7\u0BA4\u0BBF", - time: "ISO \u0BA8\u0BC7\u0BB0\u0BAE\u0BCD", - duration: "ISO \u0B95\u0BBE\u0BB2 \u0B85\u0BB3\u0BB5\u0BC1", - ipv4: "IPv4 \u0BAE\u0BC1\u0B95\u0BB5\u0BB0\u0BBF", - ipv6: "IPv6 \u0BAE\u0BC1\u0B95\u0BB5\u0BB0\u0BBF", - cidrv4: "IPv4 \u0BB5\u0BB0\u0BAE\u0BCD\u0BAA\u0BC1", - cidrv6: "IPv6 \u0BB5\u0BB0\u0BAE\u0BCD\u0BAA\u0BC1", - base64: "base64-encoded \u0B9A\u0BB0\u0BAE\u0BCD", - base64url: "base64url-encoded \u0B9A\u0BB0\u0BAE\u0BCD", - json_string: "JSON \u0B9A\u0BB0\u0BAE\u0BCD", - e164: "E.164 \u0B8E\u0BA3\u0BCD", - jwt: "JWT", - template_literal: "input", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B89\u0BB3\u0BCD\u0BB3\u0BC0\u0B9F\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${e.expected}, \u0BAA\u0BC6\u0BB1\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B89\u0BB3\u0BCD\u0BB3\u0BC0\u0B9F\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${_(e.values[0])}` - : `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0BB5\u0BBF\u0BB0\u0BC1\u0BAA\u0BCD\u0BAA\u0BAE\u0BCD: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${f(e.values, "|")} \u0B87\u0BB2\u0BCD \u0B92\u0BA9\u0BCD\u0BB1\u0BC1` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u0BAE\u0BBF\u0B95 \u0BAA\u0BC6\u0BB0\u0BBF\u0BAF\u0BA4\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${e.origin ?? "\u0BAE\u0BA4\u0BBF\u0BAA\u0BCD\u0BAA\u0BC1"} ${o}${e.maximum.toString()} ${a.unit ?? "\u0B89\u0BB1\u0BC1\u0BAA\u0BCD\u0BAA\u0BC1\u0B95\u0BB3\u0BCD"} \u0B86\u0B95 \u0B87\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : `\u0BAE\u0BBF\u0B95 \u0BAA\u0BC6\u0BB0\u0BBF\u0BAF\u0BA4\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${e.origin ?? "\u0BAE\u0BA4\u0BBF\u0BAA\u0BCD\u0BAA\u0BC1"} ${o}${e.maximum.toString()} \u0B86\u0B95 \u0B87\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u0BAE\u0BBF\u0B95\u0B9A\u0BCD \u0B9A\u0BBF\u0BB1\u0BBF\u0BAF\u0BA4\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${e.origin} ${o}${e.minimum.toString()} ${a.unit} \u0B86\u0B95 \u0B87\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : `\u0BAE\u0BBF\u0B95\u0B9A\u0BCD \u0B9A\u0BBF\u0BB1\u0BBF\u0BAF\u0BA4\u0BC1: \u0B8E\u0BA4\u0BBF\u0BB0\u0BCD\u0BAA\u0BBE\u0BB0\u0BCD\u0B95\u0BCD\u0B95\u0BAA\u0BCD\u0BAA\u0B9F\u0BCD\u0B9F\u0BA4\u0BC1 ${e.origin} ${o}${e.minimum.toString()} \u0B86\u0B95 \u0B87\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B9A\u0BB0\u0BAE\u0BCD: "${o.prefix}" \u0B87\u0BB2\u0BCD \u0BA4\u0BCA\u0B9F\u0B99\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : o.format === "ends_with" - ? `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B9A\u0BB0\u0BAE\u0BCD: "${o.suffix}" \u0B87\u0BB2\u0BCD \u0BAE\u0BC1\u0B9F\u0BBF\u0BB5\u0B9F\u0BC8\u0BAF \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : o.format === "includes" - ? `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B9A\u0BB0\u0BAE\u0BCD: "${o.includes}" \u0B90 \u0B89\u0BB3\u0BCD\u0BB3\u0B9F\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : o.format === "regex" - ? `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B9A\u0BB0\u0BAE\u0BCD: ${o.pattern} \u0BAE\u0BC1\u0BB1\u0BC8\u0BAA\u0BBE\u0B9F\u0BCD\u0B9F\u0BC1\u0B9F\u0BA9\u0BCD \u0BAA\u0BCA\u0BB0\u0BC1\u0BA8\u0BCD\u0BA4 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - : `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B8E\u0BA3\u0BCD: ${e.divisor} \u0B87\u0BA9\u0BCD \u0BAA\u0BB2\u0BAE\u0BBE\u0B95 \u0B87\u0BB0\u0BC1\u0B95\u0BCD\u0B95 \u0BB5\u0BC7\u0BA3\u0BCD\u0B9F\u0BC1\u0BAE\u0BCD` - case "unrecognized_keys": - return `\u0B85\u0B9F\u0BC8\u0BAF\u0BBE\u0BB3\u0BAE\u0BCD \u0BA4\u0BC6\u0BB0\u0BBF\u0BAF\u0BBE\u0BA4 \u0BB5\u0BBF\u0B9A\u0BC8${e.keys.length > 1 ? "\u0B95\u0BB3\u0BCD" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `${e.origin} \u0B87\u0BB2\u0BCD \u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0BB5\u0BBF\u0B9A\u0BC8` - case "invalid_union": - return "\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B89\u0BB3\u0BCD\u0BB3\u0BC0\u0B9F\u0BC1" - case "invalid_element": - return `${e.origin} \u0B87\u0BB2\u0BCD \u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0BAE\u0BA4\u0BBF\u0BAA\u0BCD\u0BAA\u0BC1` - default: - return "\u0BA4\u0BB5\u0BB1\u0BBE\u0BA9 \u0B89\u0BB3\u0BCD\u0BB3\u0BC0\u0B9F\u0BC1" - } - } -} -function wc() { - return { localeError: Gl() } -} -var Kl = () => { - const t = { - string: { unit: "\u0E15\u0E31\u0E27\u0E2D\u0E31\u0E01\u0E29\u0E23", verb: "\u0E04\u0E27\u0E23\u0E21\u0E35" }, - file: { unit: "\u0E44\u0E1A\u0E15\u0E4C", verb: "\u0E04\u0E27\u0E23\u0E21\u0E35" }, - array: { unit: "\u0E23\u0E32\u0E22\u0E01\u0E32\u0E23", verb: "\u0E04\u0E27\u0E23\u0E21\u0E35" }, - set: { unit: "\u0E23\u0E32\u0E22\u0E01\u0E32\u0E23", verb: "\u0E04\u0E27\u0E23\u0E21\u0E35" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) - ? "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E48\u0E15\u0E31\u0E27\u0E40\u0E25\u0E02 (NaN)" - : "\u0E15\u0E31\u0E27\u0E40\u0E25\u0E02" - case "object": { - if (Array.isArray(e)) return "\u0E2D\u0E32\u0E23\u0E4C\u0E40\u0E23\u0E22\u0E4C (Array)" - if (e === null) return "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E04\u0E48\u0E32 (null)" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E1B\u0E49\u0E2D\u0E19", - email: "\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48\u0E2D\u0E35\u0E40\u0E21\u0E25", - url: "URL", - emoji: "\u0E2D\u0E34\u0E42\u0E21\u0E08\u0E34", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u0E27\u0E31\u0E19\u0E17\u0E35\u0E48\u0E40\u0E27\u0E25\u0E32\u0E41\u0E1A\u0E1A ISO", - date: "\u0E27\u0E31\u0E19\u0E17\u0E35\u0E48\u0E41\u0E1A\u0E1A ISO", - time: "\u0E40\u0E27\u0E25\u0E32\u0E41\u0E1A\u0E1A ISO", - duration: "\u0E0A\u0E48\u0E27\u0E07\u0E40\u0E27\u0E25\u0E32\u0E41\u0E1A\u0E1A ISO", - ipv4: "\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48 IPv4", - ipv6: "\u0E17\u0E35\u0E48\u0E2D\u0E22\u0E39\u0E48 IPv6", - cidrv4: "\u0E0A\u0E48\u0E27\u0E07 IP \u0E41\u0E1A\u0E1A IPv4", - cidrv6: "\u0E0A\u0E48\u0E27\u0E07 IP \u0E41\u0E1A\u0E1A IPv6", - base64: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E1A\u0E1A Base64", - base64url: - "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E1A\u0E1A Base64 \u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A URL", - json_string: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E41\u0E1A\u0E1A JSON", - e164: "\u0E40\u0E1A\u0E2D\u0E23\u0E4C\u0E42\u0E17\u0E23\u0E28\u0E31\u0E1E\u0E17\u0E4C\u0E23\u0E30\u0E2B\u0E27\u0E48\u0E32\u0E07\u0E1B\u0E23\u0E30\u0E40\u0E17\u0E28 (E.164)", - jwt: "\u0E42\u0E17\u0E40\u0E04\u0E19 JWT", - template_literal: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E1B\u0E49\u0E2D\u0E19", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0E1B\u0E23\u0E30\u0E40\u0E20\u0E17\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E04\u0E27\u0E23\u0E40\u0E1B\u0E47\u0E19 ${e.expected} \u0E41\u0E15\u0E48\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u0E04\u0E48\u0E32\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E04\u0E27\u0E23\u0E40\u0E1B\u0E47\u0E19 ${_(e.values[0])}` - : `\u0E15\u0E31\u0E27\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E04\u0E27\u0E23\u0E40\u0E1B\u0E47\u0E19\u0E2B\u0E19\u0E36\u0E48\u0E07\u0E43\u0E19 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive - ? "\u0E44\u0E21\u0E48\u0E40\u0E01\u0E34\u0E19" - : "\u0E19\u0E49\u0E2D\u0E22\u0E01\u0E27\u0E48\u0E32", - a = r(e.origin) - return a - ? `\u0E40\u0E01\u0E34\u0E19\u0E01\u0E33\u0E2B\u0E19\u0E14: ${e.origin ?? "\u0E04\u0E48\u0E32"} \u0E04\u0E27\u0E23\u0E21\u0E35${o} ${e.maximum.toString()} ${a.unit ?? "\u0E23\u0E32\u0E22\u0E01\u0E32\u0E23"}` - : `\u0E40\u0E01\u0E34\u0E19\u0E01\u0E33\u0E2B\u0E19\u0E14: ${e.origin ?? "\u0E04\u0E48\u0E32"} \u0E04\u0E27\u0E23\u0E21\u0E35${o} ${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive - ? "\u0E2D\u0E22\u0E48\u0E32\u0E07\u0E19\u0E49\u0E2D\u0E22" - : "\u0E21\u0E32\u0E01\u0E01\u0E27\u0E48\u0E32", - a = r(e.origin) - return a - ? `\u0E19\u0E49\u0E2D\u0E22\u0E01\u0E27\u0E48\u0E32\u0E01\u0E33\u0E2B\u0E19\u0E14: ${e.origin} \u0E04\u0E27\u0E23\u0E21\u0E35${o} ${e.minimum.toString()} ${a.unit}` - : `\u0E19\u0E49\u0E2D\u0E22\u0E01\u0E27\u0E48\u0E32\u0E01\u0E33\u0E2B\u0E19\u0E14: ${e.origin} \u0E04\u0E27\u0E23\u0E21\u0E35${o} ${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E15\u0E49\u0E2D\u0E07\u0E02\u0E36\u0E49\u0E19\u0E15\u0E49\u0E19\u0E14\u0E49\u0E27\u0E22 "${o.prefix}"` - : o.format === "ends_with" - ? `\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E15\u0E49\u0E2D\u0E07\u0E25\u0E07\u0E17\u0E49\u0E32\u0E22\u0E14\u0E49\u0E27\u0E22 "${o.suffix}"` - : o.format === "includes" - ? `\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E15\u0E49\u0E2D\u0E07\u0E21\u0E35 "${o.includes}" \u0E2D\u0E22\u0E39\u0E48\u0E43\u0E19\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21` - : o.format === "regex" - ? `\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E15\u0E49\u0E2D\u0E07\u0E15\u0E23\u0E07\u0E01\u0E31\u0E1A\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14 ${o.pattern}` - : `\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u0E15\u0E31\u0E27\u0E40\u0E25\u0E02\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E15\u0E49\u0E2D\u0E07\u0E40\u0E1B\u0E47\u0E19\u0E08\u0E33\u0E19\u0E27\u0E19\u0E17\u0E35\u0E48\u0E2B\u0E32\u0E23\u0E14\u0E49\u0E27\u0E22 ${e.divisor} \u0E44\u0E14\u0E49\u0E25\u0E07\u0E15\u0E31\u0E27` - case "unrecognized_keys": - return `\u0E1E\u0E1A\u0E04\u0E35\u0E22\u0E4C\u0E17\u0E35\u0E48\u0E44\u0E21\u0E48\u0E23\u0E39\u0E49\u0E08\u0E31\u0E01: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u0E04\u0E35\u0E22\u0E4C\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E43\u0E19 ${e.origin}` - case "invalid_union": - return "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07: \u0E44\u0E21\u0E48\u0E15\u0E23\u0E07\u0E01\u0E31\u0E1A\u0E23\u0E39\u0E1B\u0E41\u0E1A\u0E1A\u0E22\u0E39\u0E40\u0E19\u0E35\u0E22\u0E19\u0E17\u0E35\u0E48\u0E01\u0E33\u0E2B\u0E19\u0E14\u0E44\u0E27\u0E49" - case "invalid_element": - return `\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07\u0E43\u0E19 ${e.origin}` - default: - return "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E44\u0E21\u0E48\u0E16\u0E39\u0E01\u0E15\u0E49\u0E2D\u0E07" - } - } -} -function Ic() { - return { localeError: Kl() } -} -var Ql = (t) => { - const r = typeof t - switch (r) { - case "number": - return Number.isNaN(t) ? "NaN" : "number" - case "object": { - if (Array.isArray(t)) return "array" - if (t === null) return "null" - if (Object.getPrototypeOf(t) !== Object.prototype && t.constructor) return t.constructor.name - } - } - return r - }, - Xl = () => { - const t = { - string: { unit: "karakter", verb: "olmal\u0131" }, - file: { unit: "bayt", verb: "olmal\u0131" }, - array: { unit: "\xF6\u011Fe", verb: "olmal\u0131" }, - set: { unit: "\xF6\u011Fe", verb: "olmal\u0131" }, - } - function r(i) { - return t[i] ?? null - } - const n = { - regex: "girdi", - email: "e-posta adresi", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO tarih ve saat", - date: "ISO tarih", - time: "ISO saat", - duration: "ISO s\xFCre", - ipv4: "IPv4 adresi", - ipv6: "IPv6 adresi", - cidrv4: "IPv4 aral\u0131\u011F\u0131", - cidrv6: "IPv6 aral\u0131\u011F\u0131", - base64: "base64 ile \u015Fifrelenmi\u015F metin", - base64url: "base64url ile \u015Fifrelenmi\u015F metin", - json_string: "JSON dizesi", - e164: "E.164 say\u0131s\u0131", - jwt: "JWT", - template_literal: "\u015Eablon dizesi", - } - return (i) => { - switch (i.code) { - case "invalid_type": - return `Ge\xE7ersiz de\u011Fer: beklenen ${i.expected}, al\u0131nan ${Ql(i.input)}` - case "invalid_value": - return i.values.length === 1 - ? `Ge\xE7ersiz de\u011Fer: beklenen ${_(i.values[0])}` - : `Ge\xE7ersiz se\xE7enek: a\u015Fa\u011F\u0131dakilerden biri olmal\u0131: ${f(i.values, "|")}` - case "too_big": { - const e = i.inclusive ? "<=" : "<", - o = r(i.origin) - return o - ? `\xC7ok b\xFCy\xFCk: beklenen ${i.origin ?? "de\u011Fer"} ${e}${i.maximum.toString()} ${o.unit ?? "\xF6\u011Fe"}` - : `\xC7ok b\xFCy\xFCk: beklenen ${i.origin ?? "de\u011Fer"} ${e}${i.maximum.toString()}` - } - case "too_small": { - const e = i.inclusive ? ">=" : ">", - o = r(i.origin) - return o - ? `\xC7ok k\xFC\xE7\xFCk: beklenen ${i.origin} ${e}${i.minimum.toString()} ${o.unit}` - : `\xC7ok k\xFC\xE7\xFCk: beklenen ${i.origin} ${e}${i.minimum.toString()}` - } - case "invalid_format": { - const e = i - return e.format === "starts_with" - ? `Ge\xE7ersiz metin: "${e.prefix}" ile ba\u015Flamal\u0131` - : e.format === "ends_with" - ? `Ge\xE7ersiz metin: "${e.suffix}" ile bitmeli` - : e.format === "includes" - ? `Ge\xE7ersiz metin: "${e.includes}" i\xE7ermeli` - : e.format === "regex" - ? `Ge\xE7ersiz metin: ${e.pattern} desenine uymal\u0131` - : `Ge\xE7ersiz ${n[e.format] ?? i.format}` - } - case "not_multiple_of": - return `Ge\xE7ersiz say\u0131: ${i.divisor} ile tam b\xF6l\xFCnebilmeli` - case "unrecognized_keys": - return `Tan\u0131nmayan anahtar${i.keys.length > 1 ? "lar" : ""}: ${f(i.keys, ", ")}` - case "invalid_key": - return `${i.origin} i\xE7inde ge\xE7ersiz anahtar` - case "invalid_union": - return "Ge\xE7ersiz de\u011Fer" - case "invalid_element": - return `${i.origin} i\xE7inde ge\xE7ersiz de\u011Fer` - default: - return "Ge\xE7ersiz de\u011Fer" - } - } - } -function jc() { - return { localeError: Xl() } -} -var Yl = () => { - const t = { - string: { unit: "\u0441\u0438\u043C\u0432\u043E\u043B\u0456\u0432", verb: "\u043C\u0430\u0442\u0438\u043C\u0435" }, - file: { unit: "\u0431\u0430\u0439\u0442\u0456\u0432", verb: "\u043C\u0430\u0442\u0438\u043C\u0435" }, - array: { - unit: "\u0435\u043B\u0435\u043C\u0435\u043D\u0442\u0456\u0432", - verb: "\u043C\u0430\u0442\u0438\u043C\u0435", - }, - set: { - unit: "\u0435\u043B\u0435\u043C\u0435\u043D\u0442\u0456\u0432", - verb: "\u043C\u0430\u0442\u0438\u043C\u0435", - }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0447\u0438\u0441\u043B\u043E" - case "object": { - if (Array.isArray(e)) return "\u043C\u0430\u0441\u0438\u0432" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456", - email: - "\u0430\u0434\u0440\u0435\u0441\u0430 \u0435\u043B\u0435\u043A\u0442\u0440\u043E\u043D\u043D\u043E\u0457 \u043F\u043E\u0448\u0442\u0438", - url: "URL", - emoji: "\u0435\u043C\u043E\u0434\u0437\u0456", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "\u0434\u0430\u0442\u0430 \u0442\u0430 \u0447\u0430\u0441 ISO", - date: "\u0434\u0430\u0442\u0430 ISO", - time: "\u0447\u0430\u0441 ISO", - duration: "\u0442\u0440\u0438\u0432\u0430\u043B\u0456\u0441\u0442\u044C ISO", - ipv4: "\u0430\u0434\u0440\u0435\u0441\u0430 IPv4", - ipv6: "\u0430\u0434\u0440\u0435\u0441\u0430 IPv6", - cidrv4: "\u0434\u0456\u0430\u043F\u0430\u0437\u043E\u043D IPv4", - cidrv6: "\u0434\u0456\u0430\u043F\u0430\u0437\u043E\u043D IPv6", - base64: "\u0440\u044F\u0434\u043E\u043A \u0443 \u043A\u043E\u0434\u0443\u0432\u0430\u043D\u043D\u0456 base64", - base64url: - "\u0440\u044F\u0434\u043E\u043A \u0443 \u043A\u043E\u0434\u0443\u0432\u0430\u043D\u043D\u0456 base64url", - json_string: "\u0440\u044F\u0434\u043E\u043A JSON", - e164: "\u043D\u043E\u043C\u0435\u0440 E.164", - jwt: "JWT", - template_literal: "\u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0456 \u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F ${e.expected}, \u043E\u0442\u0440\u0438\u043C\u0430\u043D\u043E ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0456 \u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F ${_(e.values[0])}` - : `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0430 \u043E\u043F\u0446\u0456\u044F: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F \u043E\u0434\u043D\u0435 \u0437 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u0417\u0430\u043D\u0430\u0434\u0442\u043E \u0432\u0435\u043B\u0438\u043A\u0435: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F, \u0449\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044F"} ${a.verb} ${o}${e.maximum.toString()} ${a.unit ?? "\u0435\u043B\u0435\u043C\u0435\u043D\u0442\u0456\u0432"}` - : `\u0417\u0430\u043D\u0430\u0434\u0442\u043E \u0432\u0435\u043B\u0438\u043A\u0435: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F, \u0449\u043E ${e.origin ?? "\u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044F"} \u0431\u0443\u0434\u0435 ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u0417\u0430\u043D\u0430\u0434\u0442\u043E \u043C\u0430\u043B\u0435: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F, \u0449\u043E ${e.origin} ${a.verb} ${o}${e.minimum.toString()} ${a.unit}` - : `\u0417\u0430\u043D\u0430\u0434\u0442\u043E \u043C\u0430\u043B\u0435: \u043E\u0447\u0456\u043A\u0443\u0454\u0442\u044C\u0441\u044F, \u0449\u043E ${e.origin} \u0431\u0443\u0434\u0435 ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 \u0440\u044F\u0434\u043E\u043A: \u043F\u043E\u0432\u0438\u043D\u0435\u043D \u043F\u043E\u0447\u0438\u043D\u0430\u0442\u0438\u0441\u044F \u0437 "${o.prefix}"` - : o.format === "ends_with" - ? `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 \u0440\u044F\u0434\u043E\u043A: \u043F\u043E\u0432\u0438\u043D\u0435\u043D \u0437\u0430\u043A\u0456\u043D\u0447\u0443\u0432\u0430\u0442\u0438\u0441\u044F \u043D\u0430 "${o.suffix}"` - : o.format === "includes" - ? `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 \u0440\u044F\u0434\u043E\u043A: \u043F\u043E\u0432\u0438\u043D\u0435\u043D \u043C\u0456\u0441\u0442\u0438\u0442\u0438 "${o.includes}"` - : o.format === "regex" - ? `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 \u0440\u044F\u0434\u043E\u043A: \u043F\u043E\u0432\u0438\u043D\u0435\u043D \u0432\u0456\u0434\u043F\u043E\u0432\u0456\u0434\u0430\u0442\u0438 \u0448\u0430\u0431\u043B\u043E\u043D\u0443 ${o.pattern}` - : `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0435 \u0447\u0438\u0441\u043B\u043E: \u043F\u043E\u0432\u0438\u043D\u043D\u043E \u0431\u0443\u0442\u0438 \u043A\u0440\u0430\u0442\u043D\u0438\u043C ${e.divisor}` - case "unrecognized_keys": - return `\u041D\u0435\u0440\u043E\u0437\u043F\u0456\u0437\u043D\u0430\u043D\u0438\u0439 \u043A\u043B\u044E\u0447${e.keys.length > 1 ? "\u0456" : ""}: ${f(e.keys, ", ")}` - case "invalid_key": - return `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0438\u0439 \u043A\u043B\u044E\u0447 \u0443 ${e.origin}` - case "invalid_union": - return "\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0456 \u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456" - case "invalid_element": - return `\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0435 \u0437\u043D\u0430\u0447\u0435\u043D\u043D\u044F \u0443 ${e.origin}` - default: - return "\u041D\u0435\u043F\u0440\u0430\u0432\u0438\u043B\u044C\u043D\u0456 \u0432\u0445\u0456\u0434\u043D\u0456 \u0434\u0430\u043D\u0456" - } - } -} -function Pc() { - return { localeError: Yl() } -} -var em = () => { - const t = { - string: { unit: "\u062D\u0631\u0648\u0641", verb: "\u06C1\u0648\u0646\u0627" }, - file: { unit: "\u0628\u0627\u0626\u0679\u0633", verb: "\u06C1\u0648\u0646\u0627" }, - array: { unit: "\u0622\u0626\u0679\u0645\u0632", verb: "\u06C1\u0648\u0646\u0627" }, - set: { unit: "\u0622\u0626\u0679\u0645\u0632", verb: "\u06C1\u0648\u0646\u0627" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "\u0646\u0645\u0628\u0631" - case "object": { - if (Array.isArray(e)) return "\u0622\u0631\u06D2" - if (e === null) return "\u0646\u0644" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0627\u0646 \u067E\u0679", - email: "\u0627\u06CC \u0645\u06CC\u0644 \u0627\u06CC\u0688\u0631\u06CC\u0633", - url: "\u06CC\u0648 \u0622\u0631 \u0627\u06CC\u0644", - emoji: "\u0627\u06CC\u0645\u0648\u062C\u06CC", - uuid: "\u06CC\u0648 \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC", - uuidv4: "\u06CC\u0648 \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC \u0648\u06CC 4", - uuidv6: "\u06CC\u0648 \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC \u0648\u06CC 6", - nanoid: "\u0646\u06CC\u0646\u0648 \u0622\u0626\u06CC \u0688\u06CC", - guid: "\u062C\u06CC \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC", - cuid: "\u0633\u06CC \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC", - cuid2: "\u0633\u06CC \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC 2", - ulid: "\u06CC\u0648 \u0627\u06CC\u0644 \u0622\u0626\u06CC \u0688\u06CC", - xid: "\u0627\u06CC\u06A9\u0633 \u0622\u0626\u06CC \u0688\u06CC", - ksuid: "\u06A9\u06D2 \u0627\u06CC\u0633 \u06CC\u0648 \u0622\u0626\u06CC \u0688\u06CC", - datetime: "\u0622\u0626\u06CC \u0627\u06CC\u0633 \u0627\u0648 \u0688\u06CC\u0679 \u0679\u0627\u0626\u0645", - date: "\u0622\u0626\u06CC \u0627\u06CC\u0633 \u0627\u0648 \u062A\u0627\u0631\u06CC\u062E", - time: "\u0622\u0626\u06CC \u0627\u06CC\u0633 \u0627\u0648 \u0648\u0642\u062A", - duration: "\u0622\u0626\u06CC \u0627\u06CC\u0633 \u0627\u0648 \u0645\u062F\u062A", - ipv4: "\u0622\u0626\u06CC \u067E\u06CC \u0648\u06CC 4 \u0627\u06CC\u0688\u0631\u06CC\u0633", - ipv6: "\u0622\u0626\u06CC \u067E\u06CC \u0648\u06CC 6 \u0627\u06CC\u0688\u0631\u06CC\u0633", - cidrv4: "\u0622\u0626\u06CC \u067E\u06CC \u0648\u06CC 4 \u0631\u06CC\u0646\u062C", - cidrv6: "\u0622\u0626\u06CC \u067E\u06CC \u0648\u06CC 6 \u0631\u06CC\u0646\u062C", - base64: "\u0628\u06CC\u0633 64 \u0627\u0646 \u06A9\u0648\u0688\u0688 \u0633\u0679\u0631\u0646\u06AF", - base64url: - "\u0628\u06CC\u0633 64 \u06CC\u0648 \u0622\u0631 \u0627\u06CC\u0644 \u0627\u0646 \u06A9\u0648\u0688\u0688 \u0633\u0679\u0631\u0646\u06AF", - json_string: "\u062C\u06D2 \u0627\u06CC\u0633 \u0627\u0648 \u0627\u06CC\u0646 \u0633\u0679\u0631\u0646\u06AF", - e164: "\u0627\u06CC 164 \u0646\u0645\u0628\u0631", - jwt: "\u062C\u06D2 \u0688\u0628\u0644\u06CC\u0648 \u0679\u06CC", - template_literal: "\u0627\u0646 \u067E\u0679", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u063A\u0644\u0637 \u0627\u0646 \u067E\u0679: ${e.expected} \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u0627\u060C ${n(e.input)} \u0645\u0648\u0635\u0648\u0644 \u06C1\u0648\u0627` - case "invalid_value": - return e.values.length === 1 - ? `\u063A\u0644\u0637 \u0627\u0646 \u067E\u0679: ${_(e.values[0])} \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u0627` - : `\u063A\u0644\u0637 \u0622\u067E\u0634\u0646: ${f(e.values, "|")} \u0645\u06CC\u06BA \u0633\u06D2 \u0627\u06CC\u06A9 \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u0627` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u0628\u06C1\u062A \u0628\u0691\u0627: ${e.origin ?? "\u0648\u06CC\u0644\u06CC\u0648"} \u06A9\u06D2 ${o}${e.maximum.toString()} ${a.unit ?? "\u0639\u0646\u0627\u0635\u0631"} \u06C1\u0648\u0646\u06D2 \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u06D2` - : `\u0628\u06C1\u062A \u0628\u0691\u0627: ${e.origin ?? "\u0648\u06CC\u0644\u06CC\u0648"} \u06A9\u0627 ${o}${e.maximum.toString()} \u06C1\u0648\u0646\u0627 \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u0627` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u0628\u06C1\u062A \u0686\u06BE\u0648\u0679\u0627: ${e.origin} \u06A9\u06D2 ${o}${e.minimum.toString()} ${a.unit} \u06C1\u0648\u0646\u06D2 \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u06D2` - : `\u0628\u06C1\u062A \u0686\u06BE\u0648\u0679\u0627: ${e.origin} \u06A9\u0627 ${o}${e.minimum.toString()} \u06C1\u0648\u0646\u0627 \u0645\u062A\u0648\u0642\u0639 \u062A\u06BE\u0627` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u063A\u0644\u0637 \u0633\u0679\u0631\u0646\u06AF: "${o.prefix}" \u0633\u06D2 \u0634\u0631\u0648\u0639 \u06C1\u0648\u0646\u0627 \u0686\u0627\u06C1\u06CC\u06D2` - : o.format === "ends_with" - ? `\u063A\u0644\u0637 \u0633\u0679\u0631\u0646\u06AF: "${o.suffix}" \u067E\u0631 \u062E\u062A\u0645 \u06C1\u0648\u0646\u0627 \u0686\u0627\u06C1\u06CC\u06D2` - : o.format === "includes" - ? `\u063A\u0644\u0637 \u0633\u0679\u0631\u0646\u06AF: "${o.includes}" \u0634\u0627\u0645\u0644 \u06C1\u0648\u0646\u0627 \u0686\u0627\u06C1\u06CC\u06D2` - : o.format === "regex" - ? `\u063A\u0644\u0637 \u0633\u0679\u0631\u0646\u06AF: \u067E\u06CC\u0679\u0631\u0646 ${o.pattern} \u0633\u06D2 \u0645\u06CC\u0686 \u06C1\u0648\u0646\u0627 \u0686\u0627\u06C1\u06CC\u06D2` - : `\u063A\u0644\u0637 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u063A\u0644\u0637 \u0646\u0645\u0628\u0631: ${e.divisor} \u06A9\u0627 \u0645\u0636\u0627\u0639\u0641 \u06C1\u0648\u0646\u0627 \u0686\u0627\u06C1\u06CC\u06D2` - case "unrecognized_keys": - return `\u063A\u06CC\u0631 \u062A\u0633\u0644\u06CC\u0645 \u0634\u062F\u06C1 \u06A9\u06CC${e.keys.length > 1 ? "\u0632" : ""}: ${f(e.keys, "\u060C ")}` - case "invalid_key": - return `${e.origin} \u0645\u06CC\u06BA \u063A\u0644\u0637 \u06A9\u06CC` - case "invalid_union": - return "\u063A\u0644\u0637 \u0627\u0646 \u067E\u0679" - case "invalid_element": - return `${e.origin} \u0645\u06CC\u06BA \u063A\u0644\u0637 \u0648\u06CC\u0644\u06CC\u0648` - default: - return "\u063A\u0644\u0637 \u0627\u0646 \u067E\u0679" - } - } -} -function Tc() { - return { localeError: em() } -} -var tm = () => { - const t = { - string: { unit: "k\xFD t\u1EF1", verb: "c\xF3" }, - file: { unit: "byte", verb: "c\xF3" }, - array: { unit: "ph\u1EA7n t\u1EED", verb: "c\xF3" }, - set: { unit: "ph\u1EA7n t\u1EED", verb: "c\xF3" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "s\u1ED1" - case "object": { - if (Array.isArray(e)) return "m\u1EA3ng" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u0111\u1EA7u v\xE0o", - email: "\u0111\u1ECBa ch\u1EC9 email", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ng\xE0y gi\u1EDD ISO", - date: "ng\xE0y ISO", - time: "gi\u1EDD ISO", - duration: "kho\u1EA3ng th\u1EDDi gian ISO", - ipv4: "\u0111\u1ECBa ch\u1EC9 IPv4", - ipv6: "\u0111\u1ECBa ch\u1EC9 IPv6", - cidrv4: "d\u1EA3i IPv4", - cidrv6: "d\u1EA3i IPv6", - base64: "chu\u1ED7i m\xE3 h\xF3a base64", - base64url: "chu\u1ED7i m\xE3 h\xF3a base64url", - json_string: "chu\u1ED7i JSON", - e164: "s\u1ED1 E.164", - jwt: "JWT", - template_literal: "\u0111\u1EA7u v\xE0o", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u0110\u1EA7u v\xE0o kh\xF4ng h\u1EE3p l\u1EC7: mong \u0111\u1EE3i ${e.expected}, nh\u1EADn \u0111\u01B0\u1EE3c ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u0110\u1EA7u v\xE0o kh\xF4ng h\u1EE3p l\u1EC7: mong \u0111\u1EE3i ${_(e.values[0])}` - : `T\xF9y ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7: mong \u0111\u1EE3i m\u1ED9t trong c\xE1c gi\xE1 tr\u1ECB ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `Qu\xE1 l\u1EDBn: mong \u0111\u1EE3i ${e.origin ?? "gi\xE1 tr\u1ECB"} ${a.verb} ${o}${e.maximum.toString()} ${a.unit ?? "ph\u1EA7n t\u1EED"}` - : `Qu\xE1 l\u1EDBn: mong \u0111\u1EE3i ${e.origin ?? "gi\xE1 tr\u1ECB"} ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `Qu\xE1 nh\u1ECF: mong \u0111\u1EE3i ${e.origin} ${a.verb} ${o}${e.minimum.toString()} ${a.unit}` - : `Qu\xE1 nh\u1ECF: mong \u0111\u1EE3i ${e.origin} ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `Chu\u1ED7i kh\xF4ng h\u1EE3p l\u1EC7: ph\u1EA3i b\u1EAFt \u0111\u1EA7u b\u1EB1ng "${o.prefix}"` - : o.format === "ends_with" - ? `Chu\u1ED7i kh\xF4ng h\u1EE3p l\u1EC7: ph\u1EA3i k\u1EBFt th\xFAc b\u1EB1ng "${o.suffix}"` - : o.format === "includes" - ? `Chu\u1ED7i kh\xF4ng h\u1EE3p l\u1EC7: ph\u1EA3i bao g\u1ED3m "${o.includes}"` - : o.format === "regex" - ? `Chu\u1ED7i kh\xF4ng h\u1EE3p l\u1EC7: ph\u1EA3i kh\u1EDBp v\u1EDBi m\u1EABu ${o.pattern}` - : `${i[o.format] ?? e.format} kh\xF4ng h\u1EE3p l\u1EC7` - } - case "not_multiple_of": - return `S\u1ED1 kh\xF4ng h\u1EE3p l\u1EC7: ph\u1EA3i l\xE0 b\u1ED9i s\u1ED1 c\u1EE7a ${e.divisor}` - case "unrecognized_keys": - return `Kh\xF3a kh\xF4ng \u0111\u01B0\u1EE3c nh\u1EADn d\u1EA1ng: ${f(e.keys, ", ")}` - case "invalid_key": - return `Kh\xF3a kh\xF4ng h\u1EE3p l\u1EC7 trong ${e.origin}` - case "invalid_union": - return "\u0110\u1EA7u v\xE0o kh\xF4ng h\u1EE3p l\u1EC7" - case "invalid_element": - return `Gi\xE1 tr\u1ECB kh\xF4ng h\u1EE3p l\u1EC7 trong ${e.origin}` - default: - return "\u0110\u1EA7u v\xE0o kh\xF4ng h\u1EE3p l\u1EC7" - } - } -} -function Oc() { - return { localeError: tm() } -} -var rm = () => { - const t = { - string: { unit: "\u5B57\u7B26", verb: "\u5305\u542B" }, - file: { unit: "\u5B57\u8282", verb: "\u5305\u542B" }, - array: { unit: "\u9879", verb: "\u5305\u542B" }, - set: { unit: "\u9879", verb: "\u5305\u542B" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "\u975E\u6570\u5B57(NaN)" : "\u6570\u5B57" - case "object": { - if (Array.isArray(e)) return "\u6570\u7EC4" - if (e === null) return "\u7A7A\u503C(null)" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u8F93\u5165", - email: "\u7535\u5B50\u90AE\u4EF6", - url: "URL", - emoji: "\u8868\u60C5\u7B26\u53F7", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO\u65E5\u671F\u65F6\u95F4", - date: "ISO\u65E5\u671F", - time: "ISO\u65F6\u95F4", - duration: "ISO\u65F6\u957F", - ipv4: "IPv4\u5730\u5740", - ipv6: "IPv6\u5730\u5740", - cidrv4: "IPv4\u7F51\u6BB5", - cidrv6: "IPv6\u7F51\u6BB5", - base64: "base64\u7F16\u7801\u5B57\u7B26\u4E32", - base64url: "base64url\u7F16\u7801\u5B57\u7B26\u4E32", - json_string: "JSON\u5B57\u7B26\u4E32", - e164: "E.164\u53F7\u7801", - jwt: "JWT", - template_literal: "\u8F93\u5165", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u65E0\u6548\u8F93\u5165\uFF1A\u671F\u671B ${e.expected}\uFF0C\u5B9E\u9645\u63A5\u6536 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u65E0\u6548\u8F93\u5165\uFF1A\u671F\u671B ${_(e.values[0])}` - : `\u65E0\u6548\u9009\u9879\uFF1A\u671F\u671B\u4EE5\u4E0B\u4E4B\u4E00 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u6570\u503C\u8FC7\u5927\uFF1A\u671F\u671B ${e.origin ?? "\u503C"} ${o}${e.maximum.toString()} ${a.unit ?? "\u4E2A\u5143\u7D20"}` - : `\u6570\u503C\u8FC7\u5927\uFF1A\u671F\u671B ${e.origin ?? "\u503C"} ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u6570\u503C\u8FC7\u5C0F\uFF1A\u671F\u671B ${e.origin} ${o}${e.minimum.toString()} ${a.unit}` - : `\u6570\u503C\u8FC7\u5C0F\uFF1A\u671F\u671B ${e.origin} ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u65E0\u6548\u5B57\u7B26\u4E32\uFF1A\u5FC5\u987B\u4EE5 "${o.prefix}" \u5F00\u5934` - : o.format === "ends_with" - ? `\u65E0\u6548\u5B57\u7B26\u4E32\uFF1A\u5FC5\u987B\u4EE5 "${o.suffix}" \u7ED3\u5C3E` - : o.format === "includes" - ? `\u65E0\u6548\u5B57\u7B26\u4E32\uFF1A\u5FC5\u987B\u5305\u542B "${o.includes}"` - : o.format === "regex" - ? `\u65E0\u6548\u5B57\u7B26\u4E32\uFF1A\u5FC5\u987B\u6EE1\u8DB3\u6B63\u5219\u8868\u8FBE\u5F0F ${o.pattern}` - : `\u65E0\u6548${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u65E0\u6548\u6570\u5B57\uFF1A\u5FC5\u987B\u662F ${e.divisor} \u7684\u500D\u6570` - case "unrecognized_keys": - return `\u51FA\u73B0\u672A\u77E5\u7684\u952E(key): ${f(e.keys, ", ")}` - case "invalid_key": - return `${e.origin} \u4E2D\u7684\u952E(key)\u65E0\u6548` - case "invalid_union": - return "\u65E0\u6548\u8F93\u5165" - case "invalid_element": - return `${e.origin} \u4E2D\u5305\u542B\u65E0\u6548\u503C(value)` - default: - return "\u65E0\u6548\u8F93\u5165" - } - } -} -function Nc() { - return { localeError: rm() } -} -var nm = () => { - const t = { - string: { unit: "\u5B57\u5143", verb: "\u64C1\u6709" }, - file: { unit: "\u4F4D\u5143\u7D44", verb: "\u64C1\u6709" }, - array: { unit: "\u9805\u76EE", verb: "\u64C1\u6709" }, - set: { unit: "\u9805\u76EE", verb: "\u64C1\u6709" }, - } - function r(e) { - return t[e] ?? null - } - const n = (e) => { - const o = typeof e - switch (o) { - case "number": - return Number.isNaN(e) ? "NaN" : "number" - case "object": { - if (Array.isArray(e)) return "array" - if (e === null) return "null" - if (Object.getPrototypeOf(e) !== Object.prototype && e.constructor) return e.constructor.name - } - } - return o - }, - i = { - regex: "\u8F38\u5165", - email: "\u90F5\u4EF6\u5730\u5740", - url: "URL", - emoji: "emoji", - uuid: "UUID", - uuidv4: "UUIDv4", - uuidv6: "UUIDv6", - nanoid: "nanoid", - guid: "GUID", - cuid: "cuid", - cuid2: "cuid2", - ulid: "ULID", - xid: "XID", - ksuid: "KSUID", - datetime: "ISO \u65E5\u671F\u6642\u9593", - date: "ISO \u65E5\u671F", - time: "ISO \u6642\u9593", - duration: "ISO \u671F\u9593", - ipv4: "IPv4 \u4F4D\u5740", - ipv6: "IPv6 \u4F4D\u5740", - cidrv4: "IPv4 \u7BC4\u570D", - cidrv6: "IPv6 \u7BC4\u570D", - base64: "base64 \u7DE8\u78BC\u5B57\u4E32", - base64url: "base64url \u7DE8\u78BC\u5B57\u4E32", - json_string: "JSON \u5B57\u4E32", - e164: "E.164 \u6578\u503C", - jwt: "JWT", - template_literal: "\u8F38\u5165", - } - return (e) => { - switch (e.code) { - case "invalid_type": - return `\u7121\u6548\u7684\u8F38\u5165\u503C\uFF1A\u9810\u671F\u70BA ${e.expected}\uFF0C\u4F46\u6536\u5230 ${n(e.input)}` - case "invalid_value": - return e.values.length === 1 - ? `\u7121\u6548\u7684\u8F38\u5165\u503C\uFF1A\u9810\u671F\u70BA ${_(e.values[0])}` - : `\u7121\u6548\u7684\u9078\u9805\uFF1A\u9810\u671F\u70BA\u4EE5\u4E0B\u5176\u4E2D\u4E4B\u4E00 ${f(e.values, "|")}` - case "too_big": { - const o = e.inclusive ? "<=" : "<", - a = r(e.origin) - return a - ? `\u6578\u503C\u904E\u5927\uFF1A\u9810\u671F ${e.origin ?? "\u503C"} \u61C9\u70BA ${o}${e.maximum.toString()} ${a.unit ?? "\u500B\u5143\u7D20"}` - : `\u6578\u503C\u904E\u5927\uFF1A\u9810\u671F ${e.origin ?? "\u503C"} \u61C9\u70BA ${o}${e.maximum.toString()}` - } - case "too_small": { - const o = e.inclusive ? ">=" : ">", - a = r(e.origin) - return a - ? `\u6578\u503C\u904E\u5C0F\uFF1A\u9810\u671F ${e.origin} \u61C9\u70BA ${o}${e.minimum.toString()} ${a.unit}` - : `\u6578\u503C\u904E\u5C0F\uFF1A\u9810\u671F ${e.origin} \u61C9\u70BA ${o}${e.minimum.toString()}` - } - case "invalid_format": { - const o = e - return o.format === "starts_with" - ? `\u7121\u6548\u7684\u5B57\u4E32\uFF1A\u5FC5\u9808\u4EE5 "${o.prefix}" \u958B\u982D` - : o.format === "ends_with" - ? `\u7121\u6548\u7684\u5B57\u4E32\uFF1A\u5FC5\u9808\u4EE5 "${o.suffix}" \u7D50\u5C3E` - : o.format === "includes" - ? `\u7121\u6548\u7684\u5B57\u4E32\uFF1A\u5FC5\u9808\u5305\u542B "${o.includes}"` - : o.format === "regex" - ? `\u7121\u6548\u7684\u5B57\u4E32\uFF1A\u5FC5\u9808\u7B26\u5408\u683C\u5F0F ${o.pattern}` - : `\u7121\u6548\u7684 ${i[o.format] ?? e.format}` - } - case "not_multiple_of": - return `\u7121\u6548\u7684\u6578\u5B57\uFF1A\u5FC5\u9808\u70BA ${e.divisor} \u7684\u500D\u6578` - case "unrecognized_keys": - return `\u7121\u6CD5\u8B58\u5225\u7684\u9375\u503C${e.keys.length > 1 ? "\u5011" : ""}\uFF1A${f(e.keys, "\u3001")}` - case "invalid_key": - return `${e.origin} \u4E2D\u6709\u7121\u6548\u7684\u9375\u503C` - case "invalid_union": - return "\u7121\u6548\u7684\u8F38\u5165\u503C" - case "invalid_element": - return `${e.origin} \u4E2D\u6709\u7121\u6548\u7684\u503C` - default: - return "\u7121\u6548\u7684\u8F38\u5165\u503C" - } - } -} -function Uc() { - return { localeError: nm() } -} -var ki = Symbol("ZodOutput"), - Si = Symbol("ZodInput"), - Ae = class { - constructor() { - ;(this._map = new Map()), (this._idmap = new Map()) - } - add(r, ...n) { - const i = n[0] - if ((this._map.set(r, i), i && typeof i == "object" && "id" in i)) { - if (this._idmap.has(i.id)) throw new Error(`ID ${i.id} already exists in the registry`) - this._idmap.set(i.id, r) - } - return this - } - clear() { - return (this._map = new Map()), (this._idmap = new Map()), this - } - remove(r) { - const n = this._map.get(r) - return n && typeof n == "object" && "id" in n && this._idmap.delete(n.id), this._map.delete(r), this - } - get(r) { - const n = r._zod.parent - if (n) { - const i = { ...(this.get(n) ?? {}) } - return delete i.id, { ...i, ...this._map.get(r) } - } - return this._map.get(r) - } - has(r) { - return this._map.has(r) - } - } -function mr() { - return new Ae() -} -var ce = mr() -function wi(t, r) { - return new t({ type: "string", ...v(r) }) -} -function Ii(t, r) { - return new t({ type: "string", coerce: !0, ...v(r) }) -} -function pr(t, r) { - return new t({ type: "string", format: "email", check: "string_format", abort: !1, ...v(r) }) -} -function ht(t, r) { - return new t({ type: "string", format: "guid", check: "string_format", abort: !1, ...v(r) }) -} -function dr(t, r) { - return new t({ type: "string", format: "uuid", check: "string_format", abort: !1, ...v(r) }) -} -function fr(t, r) { - return new t({ type: "string", format: "uuid", check: "string_format", abort: !1, version: "v4", ...v(r) }) -} -function gr(t, r) { - return new t({ type: "string", format: "uuid", check: "string_format", abort: !1, version: "v6", ...v(r) }) -} -function hr(t, r) { - return new t({ type: "string", format: "uuid", check: "string_format", abort: !1, version: "v7", ...v(r) }) -} -function vr(t, r) { - return new t({ type: "string", format: "url", check: "string_format", abort: !1, ...v(r) }) -} -function br(t, r) { - return new t({ type: "string", format: "emoji", check: "string_format", abort: !1, ...v(r) }) -} -function _r(t, r) { - return new t({ type: "string", format: "nanoid", check: "string_format", abort: !1, ...v(r) }) -} -function yr(t, r) { - return new t({ type: "string", format: "cuid", check: "string_format", abort: !1, ...v(r) }) -} -function $r(t, r) { - return new t({ type: "string", format: "cuid2", check: "string_format", abort: !1, ...v(r) }) -} -function xr(t, r) { - return new t({ type: "string", format: "ulid", check: "string_format", abort: !1, ...v(r) }) -} -function zr(t, r) { - return new t({ type: "string", format: "xid", check: "string_format", abort: !1, ...v(r) }) -} -function kr(t, r) { - return new t({ type: "string", format: "ksuid", check: "string_format", abort: !1, ...v(r) }) -} -function Sr(t, r) { - return new t({ type: "string", format: "ipv4", check: "string_format", abort: !1, ...v(r) }) -} -function wr(t, r) { - return new t({ type: "string", format: "ipv6", check: "string_format", abort: !1, ...v(r) }) -} -function Ir(t, r) { - return new t({ type: "string", format: "cidrv4", check: "string_format", abort: !1, ...v(r) }) -} -function jr(t, r) { - return new t({ type: "string", format: "cidrv6", check: "string_format", abort: !1, ...v(r) }) -} -function Pr(t, r) { - return new t({ type: "string", format: "base64", check: "string_format", abort: !1, ...v(r) }) -} -function Tr(t, r) { - return new t({ type: "string", format: "base64url", check: "string_format", abort: !1, ...v(r) }) -} -function Or(t, r) { - return new t({ type: "string", format: "e164", check: "string_format", abort: !1, ...v(r) }) -} -function Nr(t, r) { - return new t({ type: "string", format: "jwt", check: "string_format", abort: !1, ...v(r) }) -} -var ji = { Any: null, Minute: -1, Second: 0, Millisecond: 3, Microsecond: 6 } -function Pi(t, r) { - return new t({ - type: "string", - format: "datetime", - check: "string_format", - offset: !1, - local: !1, - precision: null, - ...v(r), - }) -} -function Ti(t, r) { - return new t({ type: "string", format: "date", check: "string_format", ...v(r) }) -} -function Oi(t, r) { - return new t({ type: "string", format: "time", check: "string_format", precision: null, ...v(r) }) -} -function Ni(t, r) { - return new t({ type: "string", format: "duration", check: "string_format", ...v(r) }) -} -function Ui(t, r) { - return new t({ type: "number", checks: [], ...v(r) }) -} -function Zi(t, r) { - return new t({ type: "number", coerce: !0, checks: [], ...v(r) }) -} -function Di(t, r) { - return new t({ type: "number", check: "number_format", abort: !1, format: "safeint", ...v(r) }) -} -function Ri(t, r) { - return new t({ type: "number", check: "number_format", abort: !1, format: "float32", ...v(r) }) -} -function Ei(t, r) { - return new t({ type: "number", check: "number_format", abort: !1, format: "float64", ...v(r) }) -} -function Ai(t, r) { - return new t({ type: "number", check: "number_format", abort: !1, format: "int32", ...v(r) }) -} -function Ci(t, r) { - return new t({ type: "number", check: "number_format", abort: !1, format: "uint32", ...v(r) }) -} -function Li(t, r) { - return new t({ type: "boolean", ...v(r) }) -} -function Mi(t, r) { - return new t({ type: "boolean", coerce: !0, ...v(r) }) -} -function qi(t, r) { - return new t({ type: "bigint", ...v(r) }) -} -function Vi(t, r) { - return new t({ type: "bigint", coerce: !0, ...v(r) }) -} -function Fi(t, r) { - return new t({ type: "bigint", check: "bigint_format", abort: !1, format: "int64", ...v(r) }) -} -function Hi(t, r) { - return new t({ type: "bigint", check: "bigint_format", abort: !1, format: "uint64", ...v(r) }) -} -function Ji(t, r) { - return new t({ type: "symbol", ...v(r) }) -} -function Bi(t, r) { - return new t({ type: "undefined", ...v(r) }) -} -function Wi(t, r) { - return new t({ type: "null", ...v(r) }) -} -function Gi(t) { - return new t({ type: "any" }) -} -function Ce(t) { - return new t({ type: "unknown" }) -} -function Ki(t, r) { - return new t({ type: "never", ...v(r) }) -} -function Qi(t, r) { - return new t({ type: "void", ...v(r) }) -} -function Xi(t, r) { - return new t({ type: "date", ...v(r) }) -} -function Yi(t, r) { - return new t({ type: "date", coerce: !0, ...v(r) }) -} -function ea(t, r) { - return new t({ type: "nan", ...v(r) }) -} -function le(t, r) { - return new nr({ check: "less_than", ...v(r), value: t, inclusive: !1 }) -} -function oe(t, r) { - return new nr({ check: "less_than", ...v(r), value: t, inclusive: !0 }) -} -function me(t, r) { - return new or({ check: "greater_than", ...v(r), value: t, inclusive: !1 }) -} -function ee(t, r) { - return new or({ check: "greater_than", ...v(r), value: t, inclusive: !0 }) -} -function ta(t) { - return me(0, t) -} -function ra(t) { - return le(0, t) -} -function na(t) { - return oe(0, t) -} -function oa(t) { - return ee(0, t) -} -function je(t, r) { - return new to({ check: "multiple_of", ...v(r), value: t }) -} -function Le(t, r) { - return new oo({ check: "max_size", ...v(r), maximum: t }) -} -function Pe(t, r) { - return new io({ check: "min_size", ...v(r), minimum: t }) -} -function vt(t, r) { - return new ao({ check: "size_equals", ...v(r), size: t }) -} -function Me(t, r) { - return new so({ check: "max_length", ...v(r), maximum: t }) -} -function ve(t, r) { - return new co({ check: "min_length", ...v(r), minimum: t }) -} -function qe(t, r) { - return new uo({ check: "length_equals", ...v(r), length: t }) -} -function bt(t, r) { - return new lo({ check: "string_format", format: "regex", ...v(r), pattern: t }) -} -function _t(t) { - return new mo({ check: "string_format", format: "lowercase", ...v(t) }) -} -function yt(t) { - return new po({ check: "string_format", format: "uppercase", ...v(t) }) -} -function $t(t, r) { - return new fo({ check: "string_format", format: "includes", ...v(r), includes: t }) -} -function xt(t, r) { - return new go({ check: "string_format", format: "starts_with", ...v(r), prefix: t }) -} -function zt(t, r) { - return new ho({ check: "string_format", format: "ends_with", ...v(r), suffix: t }) -} -function ia(t, r, n) { - return new vo({ check: "property", property: t, schema: r, ...v(n) }) -} -function kt(t, r) { - return new bo({ check: "mime_type", mime: t, ...v(r) }) -} -function pe(t) { - return new _o({ check: "overwrite", tx: t }) -} -function St(t) { - return pe((r) => r.normalize(t)) -} -function wt() { - return pe((t) => t.trim()) -} -function It() { - return pe((t) => t.toLowerCase()) -} -function jt() { - return pe((t) => t.toUpperCase()) -} -function Pt(t, r, n) { - return new t({ type: "array", element: r, ...v(n) }) -} -function om(t, r, n) { - return new t({ type: "union", options: r, ...v(n) }) -} -function im(t, r, n, i) { - return new t({ type: "union", options: n, discriminator: r, ...v(i) }) -} -function am(t, r, n) { - return new t({ type: "intersection", left: r, right: n }) -} -function aa(t, r, n, i) { - const e = n instanceof j, - o = e ? i : n, - a = e ? n : null - return new t({ type: "tuple", items: r, rest: a, ...v(o) }) -} -function sm(t, r, n, i) { - return new t({ type: "record", keyType: r, valueType: n, ...v(i) }) -} -function cm(t, r, n, i) { - return new t({ type: "map", keyType: r, valueType: n, ...v(i) }) -} -function um(t, r, n) { - return new t({ type: "set", valueType: r, ...v(n) }) -} -function lm(t, r, n) { - const i = Array.isArray(r) ? Object.fromEntries(r.map((e) => [e, e])) : r - return new t({ type: "enum", entries: i, ...v(n) }) -} -function mm(t, r, n) { - return new t({ type: "enum", entries: r, ...v(n) }) -} -function pm(t, r, n) { - return new t({ type: "literal", values: Array.isArray(r) ? r : [r], ...v(n) }) -} -function sa(t, r) { - return new t({ type: "file", ...v(r) }) -} -function dm(t, r) { - return new t({ type: "transform", transform: r }) -} -function fm(t, r) { - return new t({ type: "optional", innerType: r }) -} -function gm(t, r) { - return new t({ type: "nullable", innerType: r }) -} -function hm(t, r, n) { - return new t({ - type: "default", - innerType: r, - get defaultValue() { - return typeof n == "function" ? n() : n - }, - }) -} -function vm(t, r, n) { - return new t({ type: "nonoptional", innerType: r, ...v(n) }) -} -function bm(t, r) { - return new t({ type: "success", innerType: r }) -} -function _m(t, r, n) { - return new t({ type: "catch", innerType: r, catchValue: typeof n == "function" ? n : () => n }) -} -function ym(t, r, n) { - return new t({ type: "pipe", in: r, out: n }) -} -function $m(t, r) { - return new t({ type: "readonly", innerType: r }) -} -function xm(t, r, n) { - return new t({ type: "template_literal", parts: r, ...v(n) }) -} -function zm(t, r) { - return new t({ type: "lazy", getter: r }) -} -function km(t, r) { - return new t({ type: "promise", innerType: r }) -} -function ca(t, r, n) { - const i = v(n) - return i.abort ?? (i.abort = !0), new t({ type: "custom", check: "custom", fn: r, ...i }) -} -function ua(t, r, n) { - return new t({ type: "custom", check: "custom", fn: r, ...v(n) }) -} -function la(t, r) { - let n = v(r), - i = n.truthy ?? ["true", "1", "yes", "on", "y", "enabled"], - e = n.falsy ?? ["false", "0", "no", "off", "n", "disabled"] - n.case !== "sensitive" && - ((i = i.map((d) => (typeof d == "string" ? d.toLowerCase() : d))), - (e = e.map((d) => (typeof d == "string" ? d.toLowerCase() : d)))) - const o = new Set(i), - a = new Set(e), - c = t.Pipe ?? ft, - p = t.Boolean ?? mt, - h = t.String ?? we, - g = t.Transform ?? dt, - m = new g({ - type: "transform", - transform: (d, x) => { - let k = d - return ( - n.case !== "sensitive" && (k = k.toLowerCase()), - o.has(k) - ? !0 - : a.has(k) - ? !1 - : (x.issues.push({ - code: "invalid_value", - expected: "stringbool", - values: [...o, ...a], - input: x.value, - inst: m, - }), - {}) - ) - }, - error: n.error, - }), - $ = new c({ type: "pipe", in: new h({ type: "string", error: n.error }), out: m, error: n.error }) - return new c({ type: "pipe", in: $, out: new p({ type: "boolean", error: n.error }), error: n.error }) -} -function ma(t, r, n, i = {}) { - const e = v(i), - o = { - ...v(i), - check: "string_format", - type: "string", - format: r, - fn: typeof n == "function" ? n : (c) => n.test(c), - ...e, - } - return n instanceof RegExp && (o.pattern = n), new t(o) -} -var Ur = class { - constructor(r) { - ;(this._def = r), (this.def = r) - } - implement(r) { - if (typeof r != "function") throw new Error("implement() must be called with a function") - const n = (...i) => { - const e = this._def.input ? st(this._def.input, i, void 0, { callee: n }) : i - if (!Array.isArray(e)) throw new Error("Invalid arguments schema: not an array or tuple schema.") - const o = r(...e) - return this._def.output ? st(this._def.output, o, void 0, { callee: n }) : o - } - return n - } - implementAsync(r) { - if (typeof r != "function") throw new Error("implement() must be called with a function") - const n = async (...i) => { - const e = this._def.input ? await ct(this._def.input, i, void 0, { callee: n }) : i - if (!Array.isArray(e)) throw new Error("Invalid arguments schema: not an array or tuple schema.") - const o = await r(...e) - return this._def.output ? ct(this._def.output, o, void 0, { callee: n }) : o - } - return n - } - input(...r) { - const n = this.constructor - return Array.isArray(r[0]) - ? new n({ type: "function", input: new Ie({ type: "tuple", items: r[0], rest: r[1] }), output: this._def.output }) - : new n({ type: "function", input: r[0], output: this._def.output }) - } - output(r) { - const n = this.constructor - return new n({ type: "function", input: this._def.input, output: r }) - } -} -function pa(t) { - return new Ur({ - type: "function", - input: Array.isArray(t?.input) ? aa(Ie, t?.input) : (t?.input ?? Pt(pt, Ce(Ee))), - output: t?.output ?? Ce(Ee), - }) -} -var Tt = class { - constructor(r) { - ;(this.counter = 0), - (this.metadataRegistry = r?.metadata ?? ce), - (this.target = r?.target ?? "draft-2020-12"), - (this.unrepresentable = r?.unrepresentable ?? "throw"), - (this.override = r?.override ?? (() => {})), - (this.io = r?.io ?? "output"), - (this.seen = new Map()) - } - process(r, n = { path: [], schemaPath: [] }) { - var i - const e = r._zod.def, - o = { guid: "uuid", url: "uri", datetime: "date-time", json_string: "json-string", regex: "" }, - a = this.seen.get(r) - if (a) return a.count++, n.schemaPath.includes(r) && (a.cycle = n.path), a.schema - const c = { schema: {}, count: 1, cycle: void 0, path: n.path } - this.seen.set(r, c) - const p = r._zod.toJSONSchema?.() - if (p) c.schema = p - else { - const m = { ...n, schemaPath: [...n.schemaPath, r], path: n.path }, - $ = r._zod.parent - if ($) (c.ref = $), this.process($, m), (this.seen.get($).isParent = !0) - else { - const b = c.schema - switch (e.type) { - case "string": { - const d = b - d.type = "string" - const { minimum: x, maximum: k, format: D, patterns: S, contentEncoding: I } = r._zod.bag - if ( - (typeof x == "number" && (d.minLength = x), - typeof k == "number" && (d.maxLength = k), - D && ((d.format = o[D] ?? D), d.format === "" && delete d.format), - I && (d.contentEncoding = I), - S && S.size > 0) - ) { - const O = [...S] - O.length === 1 - ? (d.pattern = O[0].source) - : O.length > 1 && - (c.schema.allOf = [ - ...O.map((ye) => ({ - ...(this.target === "draft-7" ? { type: "string" } : {}), - pattern: ye.source, - })), - ]) - } - break - } - case "number": { - const d = b, - { - minimum: x, - maximum: k, - format: D, - multipleOf: S, - exclusiveMaximum: I, - exclusiveMinimum: O, - } = r._zod.bag - typeof D == "string" && D.includes("int") ? (d.type = "integer") : (d.type = "number"), - typeof O == "number" && (d.exclusiveMinimum = O), - typeof x == "number" && - ((d.minimum = x), typeof O == "number" && (O >= x ? delete d.minimum : delete d.exclusiveMinimum)), - typeof I == "number" && (d.exclusiveMaximum = I), - typeof k == "number" && - ((d.maximum = k), typeof I == "number" && (I <= k ? delete d.maximum : delete d.exclusiveMaximum)), - typeof S == "number" && (d.multipleOf = S) - break - } - case "boolean": { - const d = b - d.type = "boolean" - break - } - case "bigint": { - if (this.unrepresentable === "throw") throw new Error("BigInt cannot be represented in JSON Schema") - break - } - case "symbol": { - if (this.unrepresentable === "throw") throw new Error("Symbols cannot be represented in JSON Schema") - break - } - case "null": { - b.type = "null" - break - } - case "any": - break - case "unknown": - break - case "undefined": { - if (this.unrepresentable === "throw") throw new Error("Undefined cannot be represented in JSON Schema") - break - } - case "void": { - if (this.unrepresentable === "throw") throw new Error("Void cannot be represented in JSON Schema") - break - } - case "never": { - b.not = {} - break - } - case "date": { - if (this.unrepresentable === "throw") throw new Error("Date cannot be represented in JSON Schema") - break - } - case "array": { - const d = b, - { minimum: x, maximum: k } = r._zod.bag - typeof x == "number" && (d.minItems = x), - typeof k == "number" && (d.maxItems = k), - (d.type = "array"), - (d.items = this.process(e.element, { ...m, path: [...m.path, "items"] })) - break - } - case "object": { - const d = b - ;(d.type = "object"), (d.properties = {}) - const x = e.shape - for (const S in x) d.properties[S] = this.process(x[S], { ...m, path: [...m.path, "properties", S] }) - const k = new Set(Object.keys(x)), - D = new Set( - [...k].filter((S) => { - const I = e.shape[S]._zod - return this.io === "input" ? I.optin === void 0 : I.optout === void 0 - }), - ) - D.size > 0 && (d.required = Array.from(D)), - e.catchall?._zod.def.type === "never" - ? (d.additionalProperties = !1) - : e.catchall - ? e.catchall && - (d.additionalProperties = this.process(e.catchall, { - ...m, - path: [...m.path, "additionalProperties"], - })) - : this.io === "output" && (d.additionalProperties = !1) - break - } - case "union": { - const d = b - d.anyOf = e.options.map((x, k) => this.process(x, { ...m, path: [...m.path, "anyOf", k] })) - break - } - case "intersection": { - const d = b, - x = this.process(e.left, { ...m, path: [...m.path, "allOf", 0] }), - k = this.process(e.right, { ...m, path: [...m.path, "allOf", 1] }), - D = (I) => "allOf" in I && Object.keys(I).length === 1, - S = [...(D(x) ? x.allOf : [x]), ...(D(k) ? k.allOf : [k])] - d.allOf = S - break - } - case "tuple": { - const d = b - d.type = "array" - const x = e.items.map((S, I) => this.process(S, { ...m, path: [...m.path, "prefixItems", I] })) - if ((this.target === "draft-2020-12" ? (d.prefixItems = x) : (d.items = x), e.rest)) { - const S = this.process(e.rest, { ...m, path: [...m.path, "items"] }) - this.target === "draft-2020-12" ? (d.items = S) : (d.additionalItems = S) - } - e.rest && (d.items = this.process(e.rest, { ...m, path: [...m.path, "items"] })) - const { minimum: k, maximum: D } = r._zod.bag - typeof k == "number" && (d.minItems = k), typeof D == "number" && (d.maxItems = D) - break - } - case "record": { - const d = b - ;(d.type = "object"), - (d.propertyNames = this.process(e.keyType, { ...m, path: [...m.path, "propertyNames"] })), - (d.additionalProperties = this.process(e.valueType, { ...m, path: [...m.path, "additionalProperties"] })) - break - } - case "map": { - if (this.unrepresentable === "throw") throw new Error("Map cannot be represented in JSON Schema") - break - } - case "set": { - if (this.unrepresentable === "throw") throw new Error("Set cannot be represented in JSON Schema") - break - } - case "enum": { - const d = b, - x = Xe(e.entries) - x.every((k) => typeof k == "number") && (d.type = "number"), - x.every((k) => typeof k == "string") && (d.type = "string"), - (d.enum = x) - break - } - case "literal": { - const d = b, - x = [] - for (const k of e.values) - if (k === void 0) { - if (this.unrepresentable === "throw") - throw new Error("Literal `undefined` cannot be represented in JSON Schema") - } else if (typeof k == "bigint") { - if (this.unrepresentable === "throw") - throw new Error("BigInt literals cannot be represented in JSON Schema") - x.push(Number(k)) - } else x.push(k) - if (x.length !== 0) - if (x.length === 1) { - const k = x[0] - ;(d.type = k === null ? "null" : typeof k), (d.const = k) - } else - x.every((k) => typeof k == "number") && (d.type = "number"), - x.every((k) => typeof k == "string") && (d.type = "string"), - x.every((k) => typeof k == "boolean") && (d.type = "string"), - x.every((k) => k === null) && (d.type = "null"), - (d.enum = x) - break - } - case "file": { - const d = b, - x = { type: "string", format: "binary", contentEncoding: "binary" }, - { minimum: k, maximum: D, mime: S } = r._zod.bag - k !== void 0 && (x.minLength = k), - D !== void 0 && (x.maxLength = D), - S - ? S.length === 1 - ? ((x.contentMediaType = S[0]), Object.assign(d, x)) - : (d.anyOf = S.map((I) => ({ ...x, contentMediaType: I }))) - : Object.assign(d, x) - break - } - case "transform": { - if (this.unrepresentable === "throw") throw new Error("Transforms cannot be represented in JSON Schema") - break - } - case "nullable": { - const d = this.process(e.innerType, m) - b.anyOf = [d, { type: "null" }] - break - } - case "nonoptional": { - this.process(e.innerType, m), (c.ref = e.innerType) - break - } - case "success": { - const d = b - d.type = "boolean" - break - } - case "default": { - this.process(e.innerType, m), - (c.ref = e.innerType), - (b.default = JSON.parse(JSON.stringify(e.defaultValue))) - break - } - case "prefault": { - this.process(e.innerType, m), - (c.ref = e.innerType), - this.io === "input" && (b._prefault = JSON.parse(JSON.stringify(e.defaultValue))) - break - } - case "catch": { - this.process(e.innerType, m), (c.ref = e.innerType) - let d - try { - d = e.catchValue(void 0) - } catch { - throw new Error("Dynamic catch values are not supported in JSON Schema") - } - b.default = d - break - } - case "nan": { - if (this.unrepresentable === "throw") throw new Error("NaN cannot be represented in JSON Schema") - break - } - case "template_literal": { - const d = b, - x = r._zod.pattern - if (!x) throw new Error("Pattern not found in template literal") - ;(d.type = "string"), (d.pattern = x.source) - break - } - case "pipe": { - const d = this.io === "input" ? (e.in._zod.def.type === "transform" ? e.out : e.in) : e.out - this.process(d, m), (c.ref = d) - break - } - case "readonly": { - this.process(e.innerType, m), (c.ref = e.innerType), (b.readOnly = !0) - break - } - case "promise": { - this.process(e.innerType, m), (c.ref = e.innerType) - break - } - case "optional": { - this.process(e.innerType, m), (c.ref = e.innerType) - break - } - case "lazy": { - const d = r._zod.innerType - this.process(d, m), (c.ref = d) - break - } - case "custom": { - if (this.unrepresentable === "throw") throw new Error("Custom types cannot be represented in JSON Schema") - break - } - default: - } - } - } - const h = this.metadataRegistry.get(r) - return ( - h && Object.assign(c.schema, h), - this.io === "input" && J(r) && (delete c.schema.examples, delete c.schema.default), - this.io === "input" && c.schema._prefault && ((i = c.schema).default ?? (i.default = c.schema._prefault)), - delete c.schema._prefault, - this.seen.get(r).schema - ) - } - emit(r, n) { - const i = { cycles: n?.cycles ?? "ref", reused: n?.reused ?? "inline", external: n?.external ?? void 0 }, - e = this.seen.get(r) - if (!e) throw new Error("Unprocessed schema. This is a bug in Zod.") - const o = (g) => { - const m = this.target === "draft-2020-12" ? "$defs" : "definitions" - if (i.external) { - const x = i.external.registry.get(g[0])?.id, - k = i.external.uri ?? ((S) => S) - if (x) return { ref: k(x) } - const D = g[1].defId ?? g[1].schema.id ?? `schema${this.counter++}` - return (g[1].defId = D), { defId: D, ref: `${k("__shared")}#/${m}/${D}` } - } - if (g[1] === e) return { ref: "#" } - const b = `#/${m}/`, - d = g[1].schema.id ?? `__schema${this.counter++}` - return { defId: d, ref: b + d } - }, - a = (g) => { - if (g[1].schema.$ref) return - const m = g[1], - { ref: $, defId: b } = o(g) - ;(m.def = { ...m.schema }), b && (m.defId = b) - const d = m.schema - for (const x in d) delete d[x] - d.$ref = $ - } - if (i.cycles === "throw") - for (const g of this.seen.entries()) { - const m = g[1] - if (m.cycle) - throw new Error(`Cycle detected: #/${m.cycle?.join("/")}/ - -Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.`) - } - for (const g of this.seen.entries()) { - const m = g[1] - if (r === g[0]) { - a(g) - continue - } - if (i.external) { - const b = i.external.registry.get(g[0])?.id - if (r !== g[0] && b) { - a(g) - continue - } - } - if (this.metadataRegistry.get(g[0])?.id) { - a(g) - continue - } - if (m.cycle) { - a(g) - continue - } - if (m.count > 1 && i.reused === "ref") { - a(g) - continue - } - } - const c = (g, m) => { - const $ = this.seen.get(g), - b = $.def ?? $.schema, - d = { ...b } - if ($.ref === null) return - const x = $.ref - if ((($.ref = null), x)) { - c(x, m) - const k = this.seen.get(x).schema - k.$ref && m.target === "draft-7" - ? ((b.allOf = b.allOf ?? []), b.allOf.push(k)) - : (Object.assign(b, k), Object.assign(b, d)) - } - $.isParent || this.override({ zodSchema: g, jsonSchema: b, path: $.path ?? [] }) - } - for (const g of [...this.seen.entries()].reverse()) c(g[0], { target: this.target }) - const p = {} - if ( - (this.target === "draft-2020-12" - ? (p.$schema = "https://json-schema.org/draft/2020-12/schema") - : this.target === "draft-7" - ? (p.$schema = "http://json-schema.org/draft-07/schema#") - : console.warn(`Invalid target: ${this.target}`), - i.external?.uri) - ) { - const g = i.external.registry.get(r)?.id - if (!g) throw new Error("Schema is missing an `id` property") - p.$id = i.external.uri(g) - } - Object.assign(p, e.def) - const h = i.external?.defs ?? {} - for (const g of this.seen.entries()) { - const m = g[1] - m.def && m.defId && (h[m.defId] = m.def) - } - i.external || (Object.keys(h).length > 0 && (this.target === "draft-2020-12" ? (p.$defs = h) : (p.definitions = h))) - try { - return JSON.parse(JSON.stringify(p)) - } catch { - throw new Error("Error converting schema to JSON.") - } - } -} -function da(t, r) { - if (t instanceof Ae) { - const i = new Tt(r), - e = {} - for (const c of t._idmap.entries()) { - const [p, h] = c - i.process(h) - } - const o = {}, - a = { registry: t, uri: r?.uri, defs: e } - for (const c of t._idmap.entries()) { - const [p, h] = c - o[p] = i.emit(h, { ...r, external: a }) - } - if (Object.keys(e).length > 0) { - const c = i.target === "draft-2020-12" ? "$defs" : "definitions" - o.__shared = { [c]: e } - } - return { schemas: o } - } - const n = new Tt(r) - return n.process(t), n.emit(t, r) -} -function J(t, r) { - const n = r ?? { seen: new Set() } - if (n.seen.has(t)) return !1 - n.seen.add(t) - const e = t._zod.def - switch (e.type) { - case "string": - case "number": - case "bigint": - case "boolean": - case "date": - case "symbol": - case "undefined": - case "null": - case "any": - case "unknown": - case "never": - case "void": - case "literal": - case "enum": - case "nan": - case "file": - case "template_literal": - return !1 - case "array": - return J(e.element, n) - case "object": { - for (const o in e.shape) if (J(e.shape[o], n)) return !0 - return !1 - } - case "union": { - for (const o of e.options) if (J(o, n)) return !0 - return !1 - } - case "intersection": - return J(e.left, n) || J(e.right, n) - case "tuple": { - for (const o of e.items) if (J(o, n)) return !0 - return !!(e.rest && J(e.rest, n)) - } - case "record": - return J(e.keyType, n) || J(e.valueType, n) - case "map": - return J(e.keyType, n) || J(e.valueType, n) - case "set": - return J(e.valueType, n) - case "promise": - case "optional": - case "nonoptional": - case "nullable": - case "readonly": - return J(e.innerType, n) - case "lazy": - return J(e.getter(), n) - case "default": - return J(e.innerType, n) - case "prefault": - return J(e.innerType, n) - case "custom": - return !1 - case "transform": - return !0 - case "pipe": - return J(e.in, n) || J(e.out, n) - case "success": - return !1 - case "catch": - return !1 - default: - } - throw new Error(`Unknown schema type: ${e.type}`) -} -var Zc = {} -var Ve = {} -$e(Ve, { - ZodISODate: () => Dr, - ZodISODateTime: () => Zr, - ZodISODuration: () => Er, - ZodISOTime: () => Rr, - date: () => ga, - datetime: () => fa, - duration: () => va, - time: () => ha, -}) -var Zr = u("ZodISODateTime", (t, r) => { - Uo.init(t, r), C.init(t, r) -}) -function fa(t) { - return Pi(Zr, t) -} -var Dr = u("ZodISODate", (t, r) => { - Zo.init(t, r), C.init(t, r) -}) -function ga(t) { - return Ti(Dr, t) -} -var Rr = u("ZodISOTime", (t, r) => { - Do.init(t, r), C.init(t, r) -}) -function ha(t) { - return Oi(Rr, t) -} -var Er = u("ZodISODuration", (t, r) => { - Ro.init(t, r), C.init(t, r) -}) -function va(t) { - return Ni(Er, t) -} -var Rc = (t, r) => { - ot.init(t, r), - (t.name = "ZodError"), - Object.defineProperties(t, { - format: { value: (n) => at(t, n) }, - flatten: { value: (n) => it(t, n) }, - addIssue: { value: (n) => t.issues.push(n) }, - addIssues: { value: (n) => t.issues.push(...n) }, - isEmpty: { - get() { - return t.issues.length === 0 - }, - }, - }) - }, - wm = u("ZodError", Rc), - Fe = u("ZodError", Rc, { Parent: Error }) -var ba = Xt(Fe), - _a = Yt(Fe), - ya = er(Fe), - $a = tr(Fe) -var P = u( - "ZodType", - (t, r) => ( - j.init(t, r), - (t.def = r), - Object.defineProperty(t, "_def", { value: r }), - (t.check = (...n) => - t.clone({ - ...r, - checks: [ - ...(r.checks ?? []), - ...n.map((i) => - typeof i == "function" ? { _zod: { check: i, def: { check: "custom" }, onattach: [] } } : i, - ), - ], - })), - (t.clone = (n, i) => te(t, n, i)), - (t.brand = () => t), - (t.register = (n, i) => (n.add(t, i), t)), - (t.parse = (n, i) => ba(t, n, i, { callee: t.parse })), - (t.safeParse = (n, i) => ya(t, n, i)), - (t.parseAsync = async (n, i) => _a(t, n, i, { callee: t.parseAsync })), - (t.safeParseAsync = async (n, i) => $a(t, n, i)), - (t.spa = t.safeParseAsync), - (t.refine = (n, i) => t.check(hu(n, i))), - (t.superRefine = (n) => t.check(vu(n))), - (t.overwrite = (n) => t.check(pe(n))), - (t.optional = () => q(t)), - (t.nullable = () => Cr(t)), - (t.nullish = () => q(Cr(t))), - (t.nonoptional = (n) => ou(t, n)), - (t.array = () => T(t)), - (t.or = (n) => R([t, n])), - (t.and = (n) => Et(t, n)), - (t.transform = (n) => Lr(t, Ha(n))), - (t.default = (n) => tu(t, n)), - (t.prefault = (n) => nu(t, n)), - (t.catch = (n) => su(t, n)), - (t.pipe = (n) => Lr(t, n)), - (t.readonly = () => lu(t)), - (t.describe = (n) => { - const i = t.clone() - return ce.add(i, { description: n }), i - }), - Object.defineProperty(t, "description", { - get() { - return ce.get(t)?.description - }, - configurable: !0, - }), - (t.meta = (...n) => { - if (n.length === 0) return ce.get(t) - const i = t.clone() - return ce.add(i, n[0]), i - }), - (t.isOptional = () => t.safeParse(void 0).success), - (t.isNullable = () => t.safeParse(null).success), - t - ), - ), - za = u("_ZodString", (t, r) => { - we.init(t, r), P.init(t, r) - const n = t._zod.bag - ;(t.format = n.format ?? null), - (t.minLength = n.minimum ?? null), - (t.maxLength = n.maximum ?? null), - (t.regex = (...i) => t.check(bt(...i))), - (t.includes = (...i) => t.check($t(...i))), - (t.startsWith = (...i) => t.check(xt(...i))), - (t.endsWith = (...i) => t.check(zt(...i))), - (t.min = (...i) => t.check(ve(...i))), - (t.max = (...i) => t.check(Me(...i))), - (t.length = (...i) => t.check(qe(...i))), - (t.nonempty = (...i) => t.check(ve(1, ...i))), - (t.lowercase = (i) => t.check(_t(i))), - (t.uppercase = (i) => t.check(yt(i))), - (t.trim = () => t.check(wt())), - (t.normalize = (...i) => t.check(St(...i))), - (t.toLowerCase = () => t.check(It())), - (t.toUpperCase = () => t.check(jt())) - }), - Nt = u("ZodString", (t, r) => { - we.init(t, r), - za.init(t, r), - (t.email = (n) => t.check(pr(ka, n))), - (t.url = (n) => t.check(vr(Sa, n))), - (t.jwt = (n) => t.check(Nr(La, n))), - (t.emoji = (n) => t.check(br(wa, n))), - (t.guid = (n) => t.check(ht(Ar, n))), - (t.uuid = (n) => t.check(dr(fe, n))), - (t.uuidv4 = (n) => t.check(fr(fe, n))), - (t.uuidv6 = (n) => t.check(gr(fe, n))), - (t.uuidv7 = (n) => t.check(hr(fe, n))), - (t.nanoid = (n) => t.check(_r(Ia, n))), - (t.guid = (n) => t.check(ht(Ar, n))), - (t.cuid = (n) => t.check(yr(ja, n))), - (t.cuid2 = (n) => t.check($r(Pa, n))), - (t.ulid = (n) => t.check(xr(Ta, n))), - (t.base64 = (n) => t.check(Pr(Ea, n))), - (t.base64url = (n) => t.check(Tr(Aa, n))), - (t.xid = (n) => t.check(zr(Oa, n))), - (t.ksuid = (n) => t.check(kr(Na, n))), - (t.ipv4 = (n) => t.check(Sr(Ua, n))), - (t.ipv6 = (n) => t.check(wr(Za, n))), - (t.cidrv4 = (n) => t.check(Ir(Da, n))), - (t.cidrv6 = (n) => t.check(jr(Ra, n))), - (t.e164 = (n) => t.check(Or(Ca, n))), - (t.datetime = (n) => t.check(fa(n))), - (t.date = (n) => t.check(ga(n))), - (t.time = (n) => t.check(ha(n))), - (t.duration = (n) => t.check(va(n))) - }) -function l(t) { - return wi(Nt, t) -} -var C = u("ZodStringFormat", (t, r) => { - A.init(t, r), za.init(t, r) - }), - ka = u("ZodEmail", (t, r) => { - ko.init(t, r), C.init(t, r) - }) -function jm(t) { - return pr(ka, t) -} -var Ar = u("ZodGUID", (t, r) => { - xo.init(t, r), C.init(t, r) -}) -function Pm(t) { - return ht(Ar, t) -} -var fe = u("ZodUUID", (t, r) => { - zo.init(t, r), C.init(t, r) -}) -function Tm(t) { - return dr(fe, t) -} -function Om(t) { - return fr(fe, t) -} -function Nm(t) { - return gr(fe, t) -} -function Um(t) { - return hr(fe, t) -} -var Sa = u("ZodURL", (t, r) => { - So.init(t, r), C.init(t, r) -}) -function Zm(t) { - return vr(Sa, t) -} -var wa = u("ZodEmoji", (t, r) => { - wo.init(t, r), C.init(t, r) -}) -function Dm(t) { - return br(wa, t) -} -var Ia = u("ZodNanoID", (t, r) => { - Io.init(t, r), C.init(t, r) -}) -function Rm(t) { - return _r(Ia, t) -} -var ja = u("ZodCUID", (t, r) => { - jo.init(t, r), C.init(t, r) -}) -function Em(t) { - return yr(ja, t) -} -var Pa = u("ZodCUID2", (t, r) => { - Po.init(t, r), C.init(t, r) -}) -function Am(t) { - return $r(Pa, t) -} -var Ta = u("ZodULID", (t, r) => { - To.init(t, r), C.init(t, r) -}) -function Cm(t) { - return xr(Ta, t) -} -var Oa = u("ZodXID", (t, r) => { - Oo.init(t, r), C.init(t, r) -}) -function Lm(t) { - return zr(Oa, t) -} -var Na = u("ZodKSUID", (t, r) => { - No.init(t, r), C.init(t, r) -}) -function Mm(t) { - return kr(Na, t) -} -var Ua = u("ZodIPv4", (t, r) => { - Eo.init(t, r), C.init(t, r) -}) -function qm(t) { - return Sr(Ua, t) -} -var Za = u("ZodIPv6", (t, r) => { - Ao.init(t, r), C.init(t, r) -}) -function Vm(t) { - return wr(Za, t) -} -var Da = u("ZodCIDRv4", (t, r) => { - Co.init(t, r), C.init(t, r) -}) -function Fm(t) { - return Ir(Da, t) -} -var Ra = u("ZodCIDRv6", (t, r) => { - Lo.init(t, r), C.init(t, r) -}) -function Hm(t) { - return jr(Ra, t) -} -var Ea = u("ZodBase64", (t, r) => { - qo.init(t, r), C.init(t, r) -}) -function Jm(t) { - return Pr(Ea, t) -} -var Aa = u("ZodBase64URL", (t, r) => { - Vo.init(t, r), C.init(t, r) -}) -function Bm(t) { - return Tr(Aa, t) -} -var Ca = u("ZodE164", (t, r) => { - Fo.init(t, r), C.init(t, r) -}) -function Wm(t) { - return Or(Ca, t) -} -var La = u("ZodJWT", (t, r) => { - Ho.init(t, r), C.init(t, r) -}) -function Gm(t) { - return Nr(La, t) -} -var Ec = u("ZodCustomStringFormat", (t, r) => { - Jo.init(t, r), C.init(t, r) -}) -function Km(t, r, n = {}) { - return ma(Ec, t, r, n) -} -var Ut = u("ZodNumber", (t, r) => { - sr.init(t, r), - P.init(t, r), - (t.gt = (i, e) => t.check(me(i, e))), - (t.gte = (i, e) => t.check(ee(i, e))), - (t.min = (i, e) => t.check(ee(i, e))), - (t.lt = (i, e) => t.check(le(i, e))), - (t.lte = (i, e) => t.check(oe(i, e))), - (t.max = (i, e) => t.check(oe(i, e))), - (t.int = (i) => t.check(xa(i))), - (t.safe = (i) => t.check(xa(i))), - (t.positive = (i) => t.check(me(0, i))), - (t.nonnegative = (i) => t.check(ee(0, i))), - (t.negative = (i) => t.check(le(0, i))), - (t.nonpositive = (i) => t.check(oe(0, i))), - (t.multipleOf = (i, e) => t.check(je(i, e))), - (t.step = (i, e) => t.check(je(i, e))), - (t.finite = () => t) - const n = t._zod.bag - ;(t.minValue = - Math.max(n.minimum ?? Number.NEGATIVE_INFINITY, n.exclusiveMinimum ?? Number.NEGATIVE_INFINITY) ?? null), - (t.maxValue = - Math.min(n.maximum ?? Number.POSITIVE_INFINITY, n.exclusiveMaximum ?? Number.POSITIVE_INFINITY) ?? null), - (t.isInt = (n.format ?? "").includes("int") || Number.isSafeInteger(n.multipleOf ?? 0.5)), - (t.isFinite = !0), - (t.format = n.format ?? null) -}) -function Z(t) { - return Ui(Ut, t) -} -var He = u("ZodNumberFormat", (t, r) => { - Bo.init(t, r), Ut.init(t, r) -}) -function xa(t) { - return Di(He, t) -} -function Qm(t) { - return Ri(He, t) -} -function Xm(t) { - return Ei(He, t) -} -function Ym(t) { - return Ai(He, t) -} -function ep(t) { - return Ci(He, t) -} -var Zt = u("ZodBoolean", (t, r) => { - mt.init(t, r), P.init(t, r) -}) -function H(t) { - return Li(Zt, t) -} -var Dt = u("ZodBigInt", (t, r) => { - cr.init(t, r), - P.init(t, r), - (t.gte = (i, e) => t.check(ee(i, e))), - (t.min = (i, e) => t.check(ee(i, e))), - (t.gt = (i, e) => t.check(me(i, e))), - (t.gte = (i, e) => t.check(ee(i, e))), - (t.min = (i, e) => t.check(ee(i, e))), - (t.lt = (i, e) => t.check(le(i, e))), - (t.lte = (i, e) => t.check(oe(i, e))), - (t.max = (i, e) => t.check(oe(i, e))), - (t.positive = (i) => t.check(me(BigInt(0), i))), - (t.negative = (i) => t.check(le(BigInt(0), i))), - (t.nonpositive = (i) => t.check(oe(BigInt(0), i))), - (t.nonnegative = (i) => t.check(ee(BigInt(0), i))), - (t.multipleOf = (i, e) => t.check(je(i, e))) - const n = t._zod.bag - ;(t.minValue = n.minimum ?? null), (t.maxValue = n.maximum ?? null), (t.format = n.format ?? null) -}) -function tp(t) { - return qi(Dt, t) -} -var Ma = u("ZodBigIntFormat", (t, r) => { - Wo.init(t, r), Dt.init(t, r) -}) -function rp(t) { - return Fi(Ma, t) -} -function np(t) { - return Hi(Ma, t) -} -var Ac = u("ZodSymbol", (t, r) => { - Go.init(t, r), P.init(t, r) -}) -function op(t) { - return Ji(Ac, t) -} -var Cc = u("ZodUndefined", (t, r) => { - Ko.init(t, r), P.init(t, r) -}) -function ip(t) { - return Bi(Cc, t) -} -var Lc = u("ZodNull", (t, r) => { - Qo.init(t, r), P.init(t, r) -}) -function Rt(t) { - return Wi(Lc, t) -} -var Mc = u("ZodAny", (t, r) => { - Xo.init(t, r), P.init(t, r) -}) -function ap() { - return Gi(Mc) -} -var qc = u("ZodUnknown", (t, r) => { - Ee.init(t, r), P.init(t, r) -}) -function M() { - return Ce(qc) -} -var Vc = u("ZodNever", (t, r) => { - Yo.init(t, r), P.init(t, r) -}) -function Mr(t) { - return Ki(Vc, t) -} -var Fc = u("ZodVoid", (t, r) => { - ei.init(t, r), P.init(t, r) -}) -function sp(t) { - return Qi(Fc, t) -} -var qr = u("ZodDate", (t, r) => { - ti.init(t, r), P.init(t, r), (t.min = (i, e) => t.check(ee(i, e))), (t.max = (i, e) => t.check(oe(i, e))) - const n = t._zod.bag - ;(t.minDate = n.minimum ? new Date(n.minimum) : null), (t.maxDate = n.maximum ? new Date(n.maximum) : null) -}) -function cp(t) { - return Xi(qr, t) -} -var Hc = u("ZodArray", (t, r) => { - pt.init(t, r), - P.init(t, r), - (t.element = r.element), - (t.min = (n, i) => t.check(ve(n, i))), - (t.nonempty = (n) => t.check(ve(1, n))), - (t.max = (n, i) => t.check(Me(n, i))), - (t.length = (n, i) => t.check(qe(n, i))), - (t.unwrap = () => t.element) -}) -function T(t, r) { - return Pt(Hc, t, r) -} -function up(t) { - const r = t._zod.def.shape - return w(Object.keys(r)) -} -var Vr = u("ZodObject", (t, r) => { - ri.init(t, r), - P.init(t, r), - y.defineLazy(t, "shape", () => r.shape), - (t.keyof = () => X(Object.keys(t._zod.def.shape))), - (t.catchall = (n) => t.clone({ ...t._zod.def, catchall: n })), - (t.passthrough = () => t.clone({ ...t._zod.def, catchall: M() })), - (t.loose = () => t.clone({ ...t._zod.def, catchall: M() })), - (t.strict = () => t.clone({ ...t._zod.def, catchall: Mr() })), - (t.strip = () => t.clone({ ...t._zod.def, catchall: void 0 })), - (t.extend = (n) => y.extend(t, n)), - (t.merge = (n) => y.merge(t, n)), - (t.pick = (n) => y.pick(t, n)), - (t.omit = (n) => y.omit(t, n)), - (t.partial = (...n) => y.partial(Ja, t, n[0])), - (t.required = (...n) => y.required(Ba, t, n[0])) -}) -function z(t, r) { - const n = { - type: "object", - get shape() { - return y.assignProp(this, "shape", { ...t }), this.shape - }, - ...y.normalizeParams(r), - } - return new Vr(n) -} -function lp(t, r) { - return new Vr({ - type: "object", - get shape() { - return y.assignProp(this, "shape", { ...t }), this.shape - }, - catchall: Mr(), - ...y.normalizeParams(r), - }) -} -function Q(t, r) { - return new Vr({ - type: "object", - get shape() { - return y.assignProp(this, "shape", { ...t }), this.shape - }, - catchall: M(), - ...y.normalizeParams(r), - }) -} -var qa = u("ZodUnion", (t, r) => { - ur.init(t, r), P.init(t, r), (t.options = r.options) -}) -function R(t, r) { - return new qa({ type: "union", options: t, ...y.normalizeParams(r) }) -} -var Jc = u("ZodDiscriminatedUnion", (t, r) => { - qa.init(t, r), ni.init(t, r) -}) -function Fr(t, r, n) { - return new Jc({ type: "union", options: r, discriminator: t, ...y.normalizeParams(n) }) -} -var Bc = u("ZodIntersection", (t, r) => { - oi.init(t, r), P.init(t, r) -}) -function Et(t, r) { - return new Bc({ type: "intersection", left: t, right: r }) -} -var Wc = u("ZodTuple", (t, r) => { - Ie.init(t, r), P.init(t, r), (t.rest = (n) => t.clone({ ...t._zod.def, rest: n })) -}) -function mp(t, r, n) { - const i = r instanceof j, - e = i ? n : r, - o = i ? r : null - return new Wc({ type: "tuple", items: t, rest: o, ...y.normalizeParams(e) }) -} -var Va = u("ZodRecord", (t, r) => { - ii.init(t, r), P.init(t, r), (t.keyType = r.keyType), (t.valueType = r.valueType) -}) -function L(t, r, n) { - return new Va({ type: "record", keyType: t, valueType: r, ...y.normalizeParams(n) }) -} -function pp(t, r, n) { - return new Va({ type: "record", keyType: R([t, Mr()]), valueType: r, ...y.normalizeParams(n) }) -} -var Gc = u("ZodMap", (t, r) => { - ai.init(t, r), P.init(t, r), (t.keyType = r.keyType), (t.valueType = r.valueType) -}) -function dp(t, r, n) { - return new Gc({ type: "map", keyType: t, valueType: r, ...y.normalizeParams(n) }) -} -var Kc = u("ZodSet", (t, r) => { - si.init(t, r), - P.init(t, r), - (t.min = (...n) => t.check(Pe(...n))), - (t.nonempty = (n) => t.check(Pe(1, n))), - (t.max = (...n) => t.check(Le(...n))), - (t.size = (...n) => t.check(vt(...n))) -}) -function fp(t, r) { - return new Kc({ type: "set", valueType: t, ...y.normalizeParams(r) }) -} -var Ot = u("ZodEnum", (t, r) => { - ci.init(t, r), P.init(t, r), (t.enum = r.entries), (t.options = Object.values(r.entries)) - const n = new Set(Object.keys(r.entries)) - ;(t.extract = (i, e) => { - const o = {} - for (const a of i) - if (n.has(a)) o[a] = r.entries[a] - else throw new Error(`Key ${a} not found in enum`) - return new Ot({ ...r, checks: [], ...y.normalizeParams(e), entries: o }) - }), - (t.exclude = (i, e) => { - const o = { ...r.entries } - for (const a of i) - if (n.has(a)) delete o[a] - else throw new Error(`Key ${a} not found in enum`) - return new Ot({ ...r, checks: [], ...y.normalizeParams(e), entries: o }) - }) -}) -function X(t, r) { - const n = Array.isArray(t) ? Object.fromEntries(t.map((i) => [i, i])) : t - return new Ot({ type: "enum", entries: n, ...y.normalizeParams(r) }) -} -function gp(t, r) { - return new Ot({ type: "enum", entries: t, ...y.normalizeParams(r) }) -} -var Qc = u("ZodLiteral", (t, r) => { - ui.init(t, r), - P.init(t, r), - (t.values = new Set(r.values)), - Object.defineProperty(t, "value", { - get() { - if (r.values.length > 1) - throw new Error("This schema contains multiple valid literal values. Use `.values` instead.") - return r.values[0] - }, - }) -}) -function w(t, r) { - return new Qc({ type: "literal", values: Array.isArray(t) ? t : [t], ...y.normalizeParams(r) }) -} -var Xc = u("ZodFile", (t, r) => { - li.init(t, r), - P.init(t, r), - (t.min = (n, i) => t.check(Pe(n, i))), - (t.max = (n, i) => t.check(Le(n, i))), - (t.mime = (n, i) => t.check(kt(Array.isArray(n) ? n : [n], i))) -}) -function hp(t) { - return sa(Xc, t) -} -var Fa = u("ZodTransform", (t, r) => { - dt.init(t, r), - P.init(t, r), - (t._zod.parse = (n, i) => { - n.addIssue = (o) => { - if (typeof o == "string") n.issues.push(y.issue(o, n.value, r)) - else { - const a = o - a.fatal && (a.continue = !1), - a.code ?? (a.code = "custom"), - a.input ?? (a.input = n.value), - a.inst ?? (a.inst = t), - a.continue ?? (a.continue = !0), - n.issues.push(y.issue(a)) - } - } - const e = r.transform(n.value, n) - return e instanceof Promise ? e.then((o) => ((n.value = o), n)) : ((n.value = e), n) - }) -}) -function Ha(t) { - return new Fa({ type: "transform", transform: t }) -} -var Ja = u("ZodOptional", (t, r) => { - mi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function q(t) { - return new Ja({ type: "optional", innerType: t }) -} -var Yc = u("ZodNullable", (t, r) => { - pi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function Cr(t) { - return new Yc({ type: "nullable", innerType: t }) -} -function vp(t) { - return q(Cr(t)) -} -var eu = u("ZodDefault", (t, r) => { - di.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType), (t.removeDefault = t.unwrap) -}) -function tu(t, r) { - return new eu({ - type: "default", - innerType: t, - get defaultValue() { - return typeof r == "function" ? r() : r - }, - }) -} -var ru = u("ZodPrefault", (t, r) => { - fi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function nu(t, r) { - return new ru({ - type: "prefault", - innerType: t, - get defaultValue() { - return typeof r == "function" ? r() : r - }, - }) -} -var Ba = u("ZodNonOptional", (t, r) => { - gi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function ou(t, r) { - return new Ba({ type: "nonoptional", innerType: t, ...y.normalizeParams(r) }) -} -var iu = u("ZodSuccess", (t, r) => { - hi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function bp(t) { - return new iu({ type: "success", innerType: t }) -} -var au = u("ZodCatch", (t, r) => { - vi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType), (t.removeCatch = t.unwrap) -}) -function su(t, r) { - return new au({ type: "catch", innerType: t, catchValue: typeof r == "function" ? r : () => r }) -} -var cu = u("ZodNaN", (t, r) => { - bi.init(t, r), P.init(t, r) -}) -function _p(t) { - return ea(cu, t) -} -var Wa = u("ZodPipe", (t, r) => { - ft.init(t, r), P.init(t, r), (t.in = r.in), (t.out = r.out) -}) -function Lr(t, r) { - return new Wa({ type: "pipe", in: t, out: r }) -} -var uu = u("ZodReadonly", (t, r) => { - _i.init(t, r), P.init(t, r) -}) -function lu(t) { - return new uu({ type: "readonly", innerType: t }) -} -var mu = u("ZodTemplateLiteral", (t, r) => { - yi.init(t, r), P.init(t, r) -}) -function yp(t, r) { - return new mu({ type: "template_literal", parts: t, ...y.normalizeParams(r) }) -} -var pu = u("ZodLazy", (t, r) => { - xi.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.getter()) -}) -function du(t) { - return new pu({ type: "lazy", getter: t }) -} -var fu = u("ZodPromise", (t, r) => { - $i.init(t, r), P.init(t, r), (t.unwrap = () => t._zod.def.innerType) -}) -function $p(t) { - return new fu({ type: "promise", innerType: t }) -} -var Hr = u("ZodCustom", (t, r) => { - zi.init(t, r), P.init(t, r) -}) -function gu(t) { - const r = new V({ check: "custom" }) - return (r._zod.check = t), r -} -function Ga(t, r) { - return ca(Hr, t ?? (() => !0), r) -} -function hu(t, r = {}) { - return ua(Hr, t, r) -} -function vu(t) { - const r = gu( - (n) => ( - (n.addIssue = (i) => { - if (typeof i == "string") n.issues.push(y.issue(i, n.value, r._zod.def)) - else { - const e = i - e.fatal && (e.continue = !1), - e.code ?? (e.code = "custom"), - e.input ?? (e.input = n.value), - e.inst ?? (e.inst = r), - e.continue ?? (e.continue = !r._zod.def.abort), - n.issues.push(y.issue(e)) - } - }), - t(n.value, n) - ), - ) - return r -} -function xp(t, r = { error: `Input not instance of ${t.name}` }) { - const n = new Hr({ type: "custom", check: "custom", fn: (i) => i instanceof t, abort: !0, ...y.normalizeParams(r) }) - return (n._zod.bag.Class = t), n -} -var zp = (...t) => la({ Pipe: Wa, Boolean: Zt, String: Nt, Transform: Fa }, ...t) -function kp(t) { - const r = du(() => R([l(t), Z(), H(), Rt(), T(r), L(l(), r)])) - return r -} -function Jr(t, r) { - return Lr(Ha(t), r) -} -var Sp = { - invalid_type: "invalid_type", - too_big: "too_big", - too_small: "too_small", - invalid_format: "invalid_format", - not_multiple_of: "not_multiple_of", - unrecognized_keys: "unrecognized_keys", - invalid_union: "invalid_union", - invalid_key: "invalid_key", - invalid_element: "invalid_element", - invalid_value: "invalid_value", - custom: "custom", -} -function wp(t) { - F({ customError: t }) -} -function Ip() { - return F().customError -} -var Ka = {} -$e(Ka, { bigint: () => Op, boolean: () => Tp, date: () => Np, number: () => Pp, string: () => jp }) -function jp(t) { - return Ii(Nt, t) -} -function Pp(t) { - return Zi(Ut, t) -} -function Tp(t) { - return Mi(Zt, t) -} -function Op(t) { - return Vi(Dt, t) -} -function Np(t) { - return Yi(qr, t) -} -F(lr()) -var be = "io.modelcontextprotocol/related-task", - Wr = "2.0", - B = Ga((t) => t !== null && (typeof t == "object" || typeof t == "function")), - bu = R([l(), Z().int()]), - _u = l(), - Gh = Q({ ttl: R([Z(), Rt()]).optional(), pollInterval: Z().optional() }), - Up = z({ ttl: Z().optional() }), - Zp = z({ taskId: l() }), - Xa = Q({ progressToken: bu.optional(), [be]: Zp.optional() }), - ne = z({ _meta: Xa.optional() }), - At = ne.extend({ task: Up.optional() }), - yu = (t) => At.safeParse(t).success, - W = z({ method: l(), params: ne.loose().optional() }), - ie = z({ _meta: Xa.optional() }), - ae = z({ method: l(), params: ie.loose().optional() }), - G = Q({ _meta: Xa.optional() }), - Be = R([l(), Z().int()]), - $u = z({ jsonrpc: w(Wr), id: Be, ...W.shape }).strict(), - Ya = (t) => $u.safeParse(t).success, - xu = z({ jsonrpc: w(Wr), ...ae.shape }).strict(), - zu = (t) => xu.safeParse(t).success, - es = z({ jsonrpc: w(Wr), id: Be, result: G }).strict(), - Ct = (t) => es.safeParse(t).success -var E -;((t) => { - ;(t[(t.ConnectionClosed = -32e3)] = "ConnectionClosed"), - (t[(t.RequestTimeout = -32001)] = "RequestTimeout"), - (t[(t.ParseError = -32700)] = "ParseError"), - (t[(t.InvalidRequest = -32600)] = "InvalidRequest"), - (t[(t.MethodNotFound = -32601)] = "MethodNotFound"), - (t[(t.InvalidParams = -32602)] = "InvalidParams"), - (t[(t.InternalError = -32603)] = "InternalError"), - (t[(t.UrlElicitationRequired = -32042)] = "UrlElicitationRequired") -})(E || (E = {})) -var ts = z({ - jsonrpc: w(Wr), - id: Be.optional(), - error: z({ code: Z().int(), message: l(), data: M().optional() }), -}).strict() -var ku = (t) => ts.safeParse(t).success -var Su = R([$u, xu, es, ts]), - Kh = R([es, ts]), - rs = G.strict(), - Dp = ie.extend({ requestId: Be.optional(), reason: l().optional() }), - Gr = ae.extend({ method: w("notifications/cancelled"), params: Dp }), - Rp = z({ src: l(), mimeType: l().optional(), sizes: T(l()).optional(), theme: X(["light", "dark"]).optional() }), - Lt = z({ icons: T(Rp).optional() }), - Je = z({ name: l(), title: l().optional() }), - Mt = Je.extend({ ...Je.shape, ...Lt.shape, version: l(), websiteUrl: l().optional(), description: l().optional() }), - Ep = Et(z({ applyDefaults: H().optional() }), L(l(), M())), - Ap = Jr( - (t) => (t && typeof t == "object" && !Array.isArray(t) && Object.keys(t).length === 0 ? { form: {} } : t), - Et(z({ form: Ep.optional(), url: B.optional() }), L(l(), M()).optional()), - ), - Cp = Q({ - list: B.optional(), - cancel: B.optional(), - requests: Q({ - sampling: Q({ createMessage: B.optional() }).optional(), - elicitation: Q({ create: B.optional() }).optional(), - }).optional(), - }), - Lp = Q({ - list: B.optional(), - cancel: B.optional(), - requests: Q({ tools: Q({ call: B.optional() }).optional() }).optional(), - }), - Mp = z({ - experimental: L(l(), B).optional(), - sampling: z({ context: B.optional(), tools: B.optional() }).optional(), - elicitation: Ap.optional(), - roots: z({ listChanged: H().optional() }).optional(), - tasks: Cp.optional(), - }), - qp = ne.extend({ protocolVersion: l(), capabilities: Mp, clientInfo: Mt }), - Vp = W.extend({ method: w("initialize"), params: qp }) -var Fp = z({ - experimental: L(l(), B).optional(), - logging: B.optional(), - completions: B.optional(), - prompts: z({ listChanged: H().optional() }).optional(), - resources: z({ subscribe: H().optional(), listChanged: H().optional() }).optional(), - tools: z({ listChanged: H().optional() }).optional(), - tasks: Lp.optional(), - }), - Hp = G.extend({ protocolVersion: l(), capabilities: Fp, serverInfo: Mt, instructions: l().optional() }), - Jp = ae.extend({ method: w("notifications/initialized"), params: ie.optional() }) -var Te = W.extend({ method: w("ping"), params: ne.optional() }), - Bp = z({ progress: Z(), total: q(Z()), message: q(l()) }), - Wp = z({ ...ie.shape, ...Bp.shape, progressToken: bu }), - Kr = ae.extend({ method: w("notifications/progress"), params: Wp }), - Gp = ne.extend({ cursor: _u.optional() }), - qt = W.extend({ params: Gp.optional() }), - Vt = G.extend({ nextCursor: _u.optional() }), - Kp = X(["working", "input_required", "completed", "failed", "cancelled"]), - Ft = z({ - taskId: l(), - status: Kp, - ttl: R([Z(), Rt()]), - createdAt: l(), - lastUpdatedAt: l(), - pollInterval: q(Z()), - statusMessage: q(l()), - }), - Qr = G.extend({ task: Ft }), - Qp = ie.merge(Ft), - Ht = ae.extend({ method: w("notifications/tasks/status"), params: Qp }), - Xr = W.extend({ method: w("tasks/get"), params: ne.extend({ taskId: l() }) }), - Yr = G.merge(Ft), - en = W.extend({ method: w("tasks/result"), params: ne.extend({ taskId: l() }) }), - Qh = G.loose(), - tn = qt.extend({ method: w("tasks/list") }), - rn = Vt.extend({ tasks: T(Ft) }), - nn = W.extend({ method: w("tasks/cancel"), params: ne.extend({ taskId: l() }) }), - wu = G.merge(Ft), - Iu = z({ uri: l(), mimeType: q(l()), _meta: L(l(), M()).optional() }), - ju = Iu.extend({ text: l() }), - ns = l().refine( - (t) => { - try { - return atob(t), !0 - } catch { - return !1 - } - }, - { message: "Invalid Base64 string" }, - ), - Pu = Iu.extend({ blob: ns }), - Jt = X(["user", "assistant"]), - We = z({ - audience: T(Jt).optional(), - priority: Z().min(0).max(1).optional(), - lastModified: Ve.datetime({ offset: !0 }).optional(), - }), - Tu = z({ - ...Je.shape, - ...Lt.shape, - uri: l(), - description: q(l()), - mimeType: q(l()), - annotations: We.optional(), - _meta: q(Q({})), - }), - Xp = z({ - ...Je.shape, - ...Lt.shape, - uriTemplate: l(), - description: q(l()), - mimeType: q(l()), - annotations: We.optional(), - _meta: q(Q({})), - }), - os = qt.extend({ method: w("resources/list") }), - on = Vt.extend({ resources: T(Tu) }), - is = qt.extend({ method: w("resources/templates/list") }), - as = Vt.extend({ resourceTemplates: T(Xp) }), - ss = ne.extend({ uri: l() }), - Yp = ss, - cs = W.extend({ method: w("resources/read"), params: Yp }), - an = G.extend({ contents: T(R([ju, Pu])) }), - us = ae.extend({ method: w("notifications/resources/list_changed"), params: ie.optional() }), - ed = ss, - td = W.extend({ method: w("resources/subscribe"), params: ed }), - rd = ss, - nd = W.extend({ method: w("resources/unsubscribe"), params: rd }), - od = ie.extend({ uri: l() }), - id = ae.extend({ method: w("notifications/resources/updated"), params: od }), - ad = z({ name: l(), description: q(l()), required: q(H()) }), - sd = z({ ...Je.shape, ...Lt.shape, description: q(l()), arguments: q(T(ad)), _meta: q(Q({})) }), - ls = qt.extend({ method: w("prompts/list") }), - ms = Vt.extend({ prompts: T(sd) }), - cd = ne.extend({ name: l(), arguments: L(l(), l()).optional() }), - ud = W.extend({ method: w("prompts/get"), params: cd }), - ps = z({ type: w("text"), text: l(), annotations: We.optional(), _meta: L(l(), M()).optional() }), - ds = z({ type: w("image"), data: ns, mimeType: l(), annotations: We.optional(), _meta: L(l(), M()).optional() }), - fs = z({ type: w("audio"), data: ns, mimeType: l(), annotations: We.optional(), _meta: L(l(), M()).optional() }), - ld = z({ type: w("tool_use"), name: l(), id: l(), input: L(l(), M()), _meta: L(l(), M()).optional() }), - gs = z({ type: w("resource"), resource: R([ju, Pu]), annotations: We.optional(), _meta: L(l(), M()).optional() }), - hs = Tu.extend({ type: w("resource_link") }), - Ge = R([ps, ds, fs, hs, gs]), - md = z({ role: Jt, content: Ge }), - pd = G.extend({ description: l().optional(), messages: T(md) }), - vs = ae.extend({ method: w("notifications/prompts/list_changed"), params: ie.optional() }), - dd = z({ - title: l().optional(), - readOnlyHint: H().optional(), - destructiveHint: H().optional(), - idempotentHint: H().optional(), - openWorldHint: H().optional(), - }), - fd = z({ taskSupport: X(["required", "optional", "forbidden"]).optional() }), - sn = z({ - ...Je.shape, - ...Lt.shape, - description: l().optional(), - inputSchema: z({ type: w("object"), properties: L(l(), B).optional(), required: T(l()).optional() }).catchall(M()), - outputSchema: z({ type: w("object"), properties: L(l(), B).optional(), required: T(l()).optional() }) - .catchall(M()) - .optional(), - annotations: dd.optional(), - execution: fd.optional(), - _meta: L(l(), M()).optional(), - }), - Ou = qt.extend({ method: w("tools/list") }), - gd = Vt.extend({ tools: T(sn) }), - Oe = G.extend({ content: T(Ge).default([]), structuredContent: L(l(), M()).optional(), isError: H().optional() }), - Xh = Oe.or(G.extend({ toolResult: M() })), - hd = At.extend({ name: l(), arguments: L(l(), M()).optional() }), - cn = W.extend({ method: w("tools/call"), params: hd }), - bs = ae.extend({ method: w("notifications/tools/list_changed"), params: ie.optional() }), - Yh = z({ autoRefresh: H().default(!0), debounceMs: Z().int().nonnegative().default(300) }), - Nu = X(["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]), - vd = ne.extend({ level: Nu }), - bd = W.extend({ method: w("logging/setLevel"), params: vd }), - _d = ie.extend({ level: Nu, logger: l().optional(), data: M() }), - _s = ae.extend({ method: w("notifications/message"), params: _d }), - yd = z({ name: l().optional() }), - $d = z({ - hints: T(yd).optional(), - costPriority: Z().min(0).max(1).optional(), - speedPriority: Z().min(0).max(1).optional(), - intelligencePriority: Z().min(0).max(1).optional(), - }), - xd = z({ mode: X(["auto", "required", "none"]).optional() }), - zd = z({ - type: w("tool_result"), - toolUseId: l().describe("The unique identifier for the corresponding tool call."), - content: T(Ge).default([]), - structuredContent: z({}).loose().optional(), - isError: H().optional(), - _meta: L(l(), M()).optional(), - }), - kd = Fr("type", [ps, ds, fs]), - Br = Fr("type", [ps, ds, fs, ld, zd]), - Sd = z({ role: Jt, content: R([Br, T(Br)]), _meta: L(l(), M()).optional() }), - wd = At.extend({ - messages: T(Sd), - modelPreferences: $d.optional(), - systemPrompt: l().optional(), - includeContext: X(["none", "thisServer", "allServers"]).optional(), - temperature: Z().optional(), - maxTokens: Z().int(), - stopSequences: T(l()).optional(), - metadata: B.optional(), - tools: T(sn).optional(), - toolChoice: xd.optional(), - }), - Id = W.extend({ method: w("sampling/createMessage"), params: wd }), - jd = G.extend({ - model: l(), - stopReason: q(X(["endTurn", "stopSequence", "maxTokens"]).or(l())), - role: Jt, - content: kd, - }), - Pd = G.extend({ - model: l(), - stopReason: q(X(["endTurn", "stopSequence", "maxTokens", "toolUse"]).or(l())), - role: Jt, - content: R([Br, T(Br)]), - }), - Td = z({ type: w("boolean"), title: l().optional(), description: l().optional(), default: H().optional() }), - Od = z({ - type: w("string"), - title: l().optional(), - description: l().optional(), - minLength: Z().optional(), - maxLength: Z().optional(), - format: X(["email", "uri", "date", "date-time"]).optional(), - default: l().optional(), - }), - Nd = z({ - type: X(["number", "integer"]), - title: l().optional(), - description: l().optional(), - minimum: Z().optional(), - maximum: Z().optional(), - default: Z().optional(), - }), - Ud = z({ - type: w("string"), - title: l().optional(), - description: l().optional(), - enum: T(l()), - default: l().optional(), - }), - Zd = z({ - type: w("string"), - title: l().optional(), - description: l().optional(), - oneOf: T(z({ const: l(), title: l() })), - default: l().optional(), - }), - Dd = z({ - type: w("string"), - title: l().optional(), - description: l().optional(), - enum: T(l()), - enumNames: T(l()).optional(), - default: l().optional(), - }), - Rd = R([Ud, Zd]), - Ed = z({ - type: w("array"), - title: l().optional(), - description: l().optional(), - minItems: Z().optional(), - maxItems: Z().optional(), - items: z({ type: w("string"), enum: T(l()) }), - default: T(l()).optional(), - }), - Ad = z({ - type: w("array"), - title: l().optional(), - description: l().optional(), - minItems: Z().optional(), - maxItems: Z().optional(), - items: z({ anyOf: T(z({ const: l(), title: l() })) }), - default: T(l()).optional(), - }), - Cd = R([Ed, Ad]), - Ld = R([Dd, Rd, Cd]), - Md = R([Ld, Td, Od, Nd]), - qd = At.extend({ - mode: w("form").optional(), - message: l(), - requestedSchema: z({ type: w("object"), properties: L(l(), Md), required: T(l()).optional() }), - }), - Vd = At.extend({ mode: w("url"), message: l(), elicitationId: l(), url: l().url() }), - Fd = R([qd, Vd]), - Hd = W.extend({ method: w("elicitation/create"), params: Fd }), - Jd = ie.extend({ elicitationId: l() }), - Bd = ae.extend({ method: w("notifications/elicitation/complete"), params: Jd }), - Wd = G.extend({ - action: X(["accept", "decline", "cancel"]), - content: Jr((t) => (t === null ? void 0 : t), L(l(), R([l(), Z(), H(), T(l())])).optional()), - }), - Gd = z({ type: w("ref/resource"), uri: l() }) -var Kd = z({ type: w("ref/prompt"), name: l() }), - Qd = ne.extend({ - ref: R([Kd, Gd]), - argument: z({ name: l(), value: l() }), - context: z({ arguments: L(l(), l()).optional() }).optional(), - }), - Xd = W.extend({ method: w("completion/complete"), params: Qd }) -var Yd = G.extend({ completion: Q({ values: T(l()).max(100), total: q(Z().int()), hasMore: q(H()) }) }), - ef = z({ uri: l().startsWith("file://"), name: l().optional(), _meta: L(l(), M()).optional() }), - tf = W.extend({ method: w("roots/list"), params: ne.optional() }), - rf = G.extend({ roots: T(ef) }), - nf = ae.extend({ method: w("notifications/roots/list_changed"), params: ie.optional() }), - ev = R([Te, Vp, Xd, bd, ud, ls, os, is, cs, td, nd, cn, Ou, Xr, en, tn, nn]), - tv = R([Gr, Kr, Jp, nf, Ht]), - rv = R([rs, jd, Pd, Wd, rf, Yr, rn, Qr]), - nv = R([Te, Id, Hd, tf, Xr, en, tn, nn]), - ov = R([Gr, Kr, _s, id, us, bs, vs, Ht, Bd]), - iv = R([rs, Hp, Yd, pd, ms, on, as, an, Oe, gd, Yr, rn, Qr]), - N = class t extends Error { - constructor(r, n, i) { - super(`MCP error ${r}: ${n}`), (this.code = r), (this.data = i), (this.name = "McpError") - } - static fromError(r, n, i) { - if (r === E.UrlElicitationRequired && i) { - const e = i - if (e.elicitations) return new Qa(e.elicitations, n) - } - return new t(r, n, i) - } - }, - Qa = class extends N { - constructor(r, n = `URL elicitation${r.length > 1 ? "s" : ""} required`) { - super(E.UrlElicitationRequired, n, { elicitations: r }) - } - get elicitations() { - return this.data?.elicitations ?? [] - } - } -function un(t) { - return !!t._zod -} -function ln(t, r) { - return un(t) ? De(t, r) : t.safeParse(r) -} -function Uu(t) { - if (!t) return - let r - if ((un(t) ? (r = t._zod?.def?.shape) : (r = t.shape), !!r)) { - if (typeof r == "function") - try { - return r() - } catch { - return - } - return r - } -} -function Zu(t) { - if (un(t)) { - const o = t._zod?.def - if (o) { - if (o.value !== void 0) return o.value - if (Array.isArray(o.values) && o.values.length > 0) return o.values[0] - } - } - const n = t._def - if (n) { - if (n.value !== void 0) return n.value - if (Array.isArray(n.values) && n.values.length > 0) return n.values[0] - } - const i = t.value - if (i !== void 0) return i -} -function _e(t) { - return t === "completed" || t === "failed" || t === "cancelled" -} -var Vv = new Set("ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvxyz0123456789") -function ys(t) { - const n = Uu(t)?.method - if (!n) throw new Error("Schema is missing a method literal") - const i = Zu(n) - if (typeof i != "string") throw new Error("Schema method literal must be a string") - return i -} -function $s(t, r) { - const n = ln(t, r) - if (!n.success) throw n.error - return n.data -} -var lf = 6e4, - Wt = class { - constructor(r) { - ;(this._options = r), - (this._requestMessageId = 0), - (this._requestHandlers = new Map()), - (this._requestHandlerAbortControllers = new Map()), - (this._notificationHandlers = new Map()), - (this._responseHandlers = new Map()), - (this._progressHandlers = new Map()), - (this._timeoutInfo = new Map()), - (this._pendingDebouncedNotifications = new Set()), - (this._taskProgressTokens = new Map()), - (this._requestResolvers = new Map()), - this.setNotificationHandler(Gr, (n) => { - this._oncancel(n) - }), - this.setNotificationHandler(Kr, (n) => { - this._onprogress(n) - }), - this.setRequestHandler(Te, (n) => ({})), - (this._taskStore = r?.taskStore), - (this._taskMessageQueue = r?.taskMessageQueue), - this._taskStore && - (this.setRequestHandler(Xr, async (n, i) => { - const e = await this._taskStore.getTask(n.params.taskId, i.sessionId) - if (!e) throw new N(E.InvalidParams, "Failed to retrieve task: Task not found") - return { ...e } - }), - this.setRequestHandler(en, async (n, i) => { - const e = async () => { - const o = n.params.taskId - if (this._taskMessageQueue) { - let c - while ((c = await this._taskMessageQueue.dequeue(o, i.sessionId))) { - if (c.type === "response" || c.type === "error") { - const p = c.message, - h = p.id, - g = this._requestResolvers.get(h) - if (g) - if ((this._requestResolvers.delete(h), c.type === "response")) g(p) - else { - const m = p, - $ = new N(m.error.code, m.error.message, m.error.data) - g($) - } - else { - const m = c.type === "response" ? "Response" : "Error" - this._onerror(new Error(`${m} handler missing for request ${h}`)) - } - continue - } - await this._transport?.send(c.message, { relatedRequestId: i.requestId }) - } - } - const a = await this._taskStore.getTask(o, i.sessionId) - if (!a) throw new N(E.InvalidParams, `Task not found: ${o}`) - if (!_e(a.status)) return await this._waitForTaskUpdate(o, i.signal), await e() - if (_e(a.status)) { - const c = await this._taskStore.getTaskResult(o, i.sessionId) - return this._clearTaskQueue(o), { ...c, _meta: { ...c._meta, [be]: { taskId: o } } } - } - return await e() - } - return await e() - }), - this.setRequestHandler(tn, async (n, i) => { - try { - const { tasks: e, nextCursor: o } = await this._taskStore.listTasks(n.params?.cursor, i.sessionId) - return { tasks: e, nextCursor: o, _meta: {} } - } catch (e) { - throw new N(E.InvalidParams, `Failed to list tasks: ${e instanceof Error ? e.message : String(e)}`) - } - }), - this.setRequestHandler(nn, async (n, i) => { - try { - const e = await this._taskStore.getTask(n.params.taskId, i.sessionId) - if (!e) throw new N(E.InvalidParams, `Task not found: ${n.params.taskId}`) - if (_e(e.status)) throw new N(E.InvalidParams, `Cannot cancel task in terminal status: ${e.status}`) - await this._taskStore.updateTaskStatus( - n.params.taskId, - "cancelled", - "Client cancelled task execution.", - i.sessionId, - ), - this._clearTaskQueue(n.params.taskId) - const o = await this._taskStore.getTask(n.params.taskId, i.sessionId) - if (!o) throw new N(E.InvalidParams, `Task not found after cancellation: ${n.params.taskId}`) - return { _meta: {}, ...o } - } catch (e) { - throw e instanceof N - ? e - : new N(E.InvalidRequest, `Failed to cancel task: ${e instanceof Error ? e.message : String(e)}`) - } - })) - } - async _oncancel(r) { - if (!r.params.requestId) return - this._requestHandlerAbortControllers.get(r.params.requestId)?.abort(r.params.reason) - } - _setupTimeout(r, n, i, e, o = !1) { - this._timeoutInfo.set(r, { - timeoutId: setTimeout(e, n), - startTime: Date.now(), - timeout: n, - maxTotalTimeout: i, - resetTimeoutOnProgress: o, - onTimeout: e, - }) - } - _resetTimeout(r) { - const n = this._timeoutInfo.get(r) - if (!n) return !1 - const i = Date.now() - n.startTime - if (n.maxTotalTimeout && i >= n.maxTotalTimeout) - throw ( - (this._timeoutInfo.delete(r), - N.fromError(E.RequestTimeout, "Maximum total timeout exceeded", { - maxTotalTimeout: n.maxTotalTimeout, - totalElapsed: i, - })) - ) - return clearTimeout(n.timeoutId), (n.timeoutId = setTimeout(n.onTimeout, n.timeout)), !0 - } - _cleanupTimeout(r) { - const n = this._timeoutInfo.get(r) - n && (clearTimeout(n.timeoutId), this._timeoutInfo.delete(r)) - } - async connect(r) { - this._transport = r - const n = this.transport?.onclose - this._transport.onclose = () => { - n?.(), this._onclose() - } - const i = this.transport?.onerror - this._transport.onerror = (o) => { - i?.(o), this._onerror(o) - } - const e = this._transport?.onmessage - ;(this._transport.onmessage = (o, a) => { - e?.(o, a), - Ct(o) || ku(o) - ? this._onresponse(o) - : Ya(o) - ? this._onrequest(o, a) - : zu(o) - ? this._onnotification(o) - : this._onerror(new Error(`Unknown message type: ${JSON.stringify(o)}`)) - }), - await this._transport.start() - } - _onclose() { - const r = this._responseHandlers - ;(this._responseHandlers = new Map()), - this._progressHandlers.clear(), - this._taskProgressTokens.clear(), - this._pendingDebouncedNotifications.clear() - const n = N.fromError(E.ConnectionClosed, "Connection closed") - ;(this._transport = void 0), this.onclose?.() - for (const i of r.values()) i(n) - } - _onerror(r) { - this.onerror?.(r) - } - _onnotification(r) { - const n = this._notificationHandlers.get(r.method) ?? this.fallbackNotificationHandler - n !== void 0 && - Promise.resolve() - .then(() => n(r)) - .catch((i) => this._onerror(new Error(`Uncaught error in notification handler: ${i}`))) - } - _onrequest(r, n) { - const i = this._requestHandlers.get(r.method) ?? this.fallbackRequestHandler, - e = this._transport, - o = r.params?._meta?.[be]?.taskId - if (i === void 0) { - const g = { jsonrpc: "2.0", id: r.id, error: { code: E.MethodNotFound, message: "Method not found" } } - o && this._taskMessageQueue - ? this._enqueueTaskMessage(o, { type: "error", message: g, timestamp: Date.now() }, e?.sessionId).catch((m) => - this._onerror(new Error(`Failed to enqueue error response: ${m}`)), - ) - : e?.send(g).catch((m) => this._onerror(new Error(`Failed to send an error response: ${m}`))) - return - } - const a = new AbortController() - this._requestHandlerAbortControllers.set(r.id, a) - const c = yu(r.params) ? r.params.task : void 0, - p = this._taskStore ? this.requestTaskStore(r, e?.sessionId) : void 0, - h = { - signal: a.signal, - sessionId: e?.sessionId, - _meta: r.params?._meta, - sendNotification: async (g) => { - const m = { relatedRequestId: r.id } - o && (m.relatedTask = { taskId: o }), await this.notification(g, m) - }, - sendRequest: async (g, m, $) => { - const b = { ...$, relatedRequestId: r.id } - o && !b.relatedTask && (b.relatedTask = { taskId: o }) - const d = b.relatedTask?.taskId ?? o - return d && p && (await p.updateTaskStatus(d, "input_required")), await this.request(g, m, b) - }, - authInfo: n?.authInfo, - requestId: r.id, - requestInfo: n?.requestInfo, - taskId: o, - taskStore: p, - taskRequestedTtl: c?.ttl, - closeSSEStream: n?.closeSSEStream, - closeStandaloneSSEStream: n?.closeStandaloneSSEStream, - } - Promise.resolve() - .then(() => { - c && this.assertTaskHandlerCapability(r.method) - }) - .then(() => i(r, h)) - .then( - async (g) => { - if (a.signal.aborted) return - const m = { result: g, jsonrpc: "2.0", id: r.id } - o && this._taskMessageQueue - ? await this._enqueueTaskMessage(o, { type: "response", message: m, timestamp: Date.now() }, e?.sessionId) - : await e?.send(m) - }, - async (g) => { - if (a.signal.aborted) return - const m = { - jsonrpc: "2.0", - id: r.id, - error: { - code: Number.isSafeInteger(g.code) ? g.code : E.InternalError, - message: g.message ?? "Internal error", - ...(g.data !== void 0 && { data: g.data }), - }, - } - o && this._taskMessageQueue - ? await this._enqueueTaskMessage(o, { type: "error", message: m, timestamp: Date.now() }, e?.sessionId) - : await e?.send(m) - }, - ) - .catch((g) => this._onerror(new Error(`Failed to send response: ${g}`))) - .finally(() => { - this._requestHandlerAbortControllers.delete(r.id) - }) - } - _onprogress(r) { - const { progressToken: n, ...i } = r.params, - e = Number(n), - o = this._progressHandlers.get(e) - if (!o) { - this._onerror(new Error(`Received a progress notification for an unknown token: ${JSON.stringify(r)}`)) - return - } - const a = this._responseHandlers.get(e), - c = this._timeoutInfo.get(e) - if (c && a && c.resetTimeoutOnProgress) - try { - this._resetTimeout(e) - } catch (p) { - this._responseHandlers.delete(e), this._progressHandlers.delete(e), this._cleanupTimeout(e), a(p) - return - } - o(i) - } - _onresponse(r) { - const n = Number(r.id), - i = this._requestResolvers.get(n) - if (i) { - if ((this._requestResolvers.delete(n), Ct(r))) i(r) - else { - const a = new N(r.error.code, r.error.message, r.error.data) - i(a) - } - return - } - const e = this._responseHandlers.get(n) - if (e === void 0) { - this._onerror(new Error(`Received a response for an unknown message ID: ${JSON.stringify(r)}`)) - return - } - this._responseHandlers.delete(n), this._cleanupTimeout(n) - let o = !1 - if (Ct(r) && r.result && typeof r.result == "object") { - const a = r.result - if (a.task && typeof a.task == "object") { - const c = a.task - typeof c.taskId == "string" && ((o = !0), this._taskProgressTokens.set(c.taskId, n)) - } - } - if ((o || this._progressHandlers.delete(n), Ct(r))) e(r) - else { - const a = N.fromError(r.error.code, r.error.message, r.error.data) - e(a) - } - } - get transport() { - return this._transport - } - async close() { - await this._transport?.close() - } - async *requestStream(r, n, i) { - const { task: e } = i ?? {} - if (!e) { - try { - yield { type: "result", result: await this.request(r, n, i) } - } catch (a) { - yield { type: "error", error: a instanceof N ? a : new N(E.InternalError, String(a)) } - } - return - } - let o - try { - const a = await this.request(r, Qr, i) - if (a.task) (o = a.task.taskId), yield { type: "taskCreated", task: a.task } - else throw new N(E.InternalError, "Task creation did not return a task") - for (;;) { - const c = await this.getTask({ taskId: o }, i) - if ((yield { type: "taskStatus", task: c }, _e(c.status))) { - c.status === "completed" - ? yield { type: "result", result: await this.getTaskResult({ taskId: o }, n, i) } - : c.status === "failed" - ? yield { type: "error", error: new N(E.InternalError, `Task ${o} failed`) } - : c.status === "cancelled" && - (yield { type: "error", error: new N(E.InternalError, `Task ${o} was cancelled`) }) - return - } - if (c.status === "input_required") { - yield { type: "result", result: await this.getTaskResult({ taskId: o }, n, i) } - return - } - const p = c.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3 - await new Promise((h) => setTimeout(h, p)), i?.signal?.throwIfAborted() - } - } catch (a) { - yield { type: "error", error: a instanceof N ? a : new N(E.InternalError, String(a)) } - } - } - request(r, n, i) { - const { relatedRequestId: e, resumptionToken: o, onresumptiontoken: a, task: c, relatedTask: p } = i ?? {} - return new Promise((h, g) => { - const m = (S) => { - g(S) - } - if (!this._transport) { - m(new Error("Not connected")) - return - } - if (this._options?.enforceStrictCapabilities === !0) - try { - this.assertCapabilityForMethod(r.method), c && this.assertTaskCapability(r.method) - } catch (S) { - m(S) - return - } - i?.signal?.throwIfAborted() - const $ = this._requestMessageId++, - b = { ...r, jsonrpc: "2.0", id: $ } - i?.onprogress && - (this._progressHandlers.set($, i.onprogress), - (b.params = { ...r.params, _meta: { ...(r.params?._meta || {}), progressToken: $ } })), - c && (b.params = { ...b.params, task: c }), - p && (b.params = { ...b.params, _meta: { ...(b.params?._meta || {}), [be]: p } }) - const d = (S) => { - this._responseHandlers.delete($), - this._progressHandlers.delete($), - this._cleanupTimeout($), - this._transport - ?.send( - { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: $, reason: String(S) } }, - { relatedRequestId: e, resumptionToken: o, onresumptiontoken: a }, - ) - .catch((O) => this._onerror(new Error(`Failed to send cancellation: ${O}`))) - const I = S instanceof N ? S : new N(E.RequestTimeout, String(S)) - g(I) - } - this._responseHandlers.set($, (S) => { - if (!i?.signal?.aborted) { - if (S instanceof Error) return g(S) - try { - const I = ln(n, S.result) - I.success ? h(I.data) : g(I.error) - } catch (I) { - g(I) - } - } - }), - i?.signal?.addEventListener("abort", () => { - d(i?.signal?.reason) - }) - const x = i?.timeout ?? lf, - k = () => d(N.fromError(E.RequestTimeout, "Request timed out", { timeout: x })) - this._setupTimeout($, x, i?.maxTotalTimeout, k, i?.resetTimeoutOnProgress ?? !1) - const D = p?.taskId - if (D) { - const S = (I) => { - const O = this._responseHandlers.get($) - O ? O(I) : this._onerror(new Error(`Response handler missing for side-channeled request ${$}`)) - } - this._requestResolvers.set($, S), - this._enqueueTaskMessage(D, { type: "request", message: b, timestamp: Date.now() }).catch((I) => { - this._cleanupTimeout($), g(I) - }) - } else - this._transport.send(b, { relatedRequestId: e, resumptionToken: o, onresumptiontoken: a }).catch((S) => { - this._cleanupTimeout($), g(S) - }) - }) - } - async getTask(r, n) { - return this.request({ method: "tasks/get", params: r }, Yr, n) - } - async getTaskResult(r, n, i) { - return this.request({ method: "tasks/result", params: r }, n, i) - } - async listTasks(r, n) { - return this.request({ method: "tasks/list", params: r }, rn, n) - } - async cancelTask(r, n) { - return this.request({ method: "tasks/cancel", params: r }, wu, n) - } - async notification(r, n) { - if (!this._transport) throw new Error("Not connected") - this.assertNotificationCapability(r.method) - const i = n?.relatedTask?.taskId - if (i) { - const c = { - ...r, - jsonrpc: "2.0", - params: { ...r.params, _meta: { ...(r.params?._meta || {}), [be]: n.relatedTask } }, - } - await this._enqueueTaskMessage(i, { type: "notification", message: c, timestamp: Date.now() }) - return - } - if ( - (this._options?.debouncedNotificationMethods ?? []).includes(r.method) && - !r.params && - !n?.relatedRequestId && - !n?.relatedTask - ) { - if (this._pendingDebouncedNotifications.has(r.method)) return - this._pendingDebouncedNotifications.add(r.method), - Promise.resolve().then(() => { - if ((this._pendingDebouncedNotifications.delete(r.method), !this._transport)) return - let c = { ...r, jsonrpc: "2.0" } - n?.relatedTask && - (c = { ...c, params: { ...c.params, _meta: { ...(c.params?._meta || {}), [be]: n.relatedTask } } }), - this._transport?.send(c, n).catch((p) => this._onerror(p)) - }) - return - } - let a = { ...r, jsonrpc: "2.0" } - n?.relatedTask && - (a = { ...a, params: { ...a.params, _meta: { ...(a.params?._meta || {}), [be]: n.relatedTask } } }), - await this._transport.send(a, n) - } - setRequestHandler(r, n) { - const i = ys(r) - this.assertRequestHandlerCapability(i), - this._requestHandlers.set(i, (e, o) => { - const a = $s(r, e) - return Promise.resolve(n(a, o)) - }) - } - removeRequestHandler(r) { - this._requestHandlers.delete(r) - } - assertCanSetRequestHandler(r) { - if (this._requestHandlers.has(r)) - throw new Error(`A request handler for ${r} already exists, which would be overridden`) - } - setNotificationHandler(r, n) { - const i = ys(r) - this._notificationHandlers.set(i, (e) => { - const o = $s(r, e) - return Promise.resolve(n(o)) - }) - } - removeNotificationHandler(r) { - this._notificationHandlers.delete(r) - } - _cleanupTaskProgressHandler(r) { - const n = this._taskProgressTokens.get(r) - n !== void 0 && (this._progressHandlers.delete(n), this._taskProgressTokens.delete(r)) - } - async _enqueueTaskMessage(r, n, i) { - if (!this._taskStore || !this._taskMessageQueue) - throw new Error("Cannot enqueue task message: taskStore and taskMessageQueue are not configured") - const e = this._options?.maxTaskQueueSize - await this._taskMessageQueue.enqueue(r, n, i, e) - } - async _clearTaskQueue(r, n) { - if (this._taskMessageQueue) { - const i = await this._taskMessageQueue.dequeueAll(r, n) - for (const e of i) - if (e.type === "request" && Ya(e.message)) { - const o = e.message.id, - a = this._requestResolvers.get(o) - a - ? (a(new N(E.InternalError, "Task cancelled or completed")), this._requestResolvers.delete(o)) - : this._onerror(new Error(`Resolver missing for request ${o} during task ${r} cleanup`)) - } - } - } - async _waitForTaskUpdate(r, n) { - let i = this._options?.defaultTaskPollInterval ?? 1e3 - try { - const e = await this._taskStore?.getTask(r) - e?.pollInterval && (i = e.pollInterval) - } catch {} - return new Promise((e, o) => { - if (n.aborted) { - o(new N(E.InvalidRequest, "Request cancelled")) - return - } - const a = setTimeout(e, i) - n.addEventListener( - "abort", - () => { - clearTimeout(a), o(new N(E.InvalidRequest, "Request cancelled")) - }, - { once: !0 }, - ) - }) - } - requestTaskStore(r, n) { - const i = this._taskStore - if (!i) throw new Error("No task store configured") - return { - createTask: async (e) => { - if (!r) throw new Error("No request provided") - return await i.createTask(e, r.id, { method: r.method, params: r.params }, n) - }, - getTask: async (e) => { - const o = await i.getTask(e, n) - if (!o) throw new N(E.InvalidParams, "Failed to retrieve task: Task not found") - return o - }, - storeTaskResult: async (e, o, a) => { - await i.storeTaskResult(e, o, a, n) - const c = await i.getTask(e, n) - if (c) { - const p = Ht.parse({ method: "notifications/tasks/status", params: c }) - await this.notification(p), _e(c.status) && this._cleanupTaskProgressHandler(e) - } - }, - getTaskResult: (e) => i.getTaskResult(e, n), - updateTaskStatus: async (e, o, a) => { - const c = await i.getTask(e, n) - if (!c) throw new N(E.InvalidParams, `Task "${e}" not found - it may have been cleaned up`) - if (_e(c.status)) - throw new N( - E.InvalidParams, - `Cannot update task "${e}" from terminal status "${c.status}" to "${o}". Terminal states (completed, failed, cancelled) cannot transition to other states.`, - ) - await i.updateTaskStatus(e, o, a, n) - const p = await i.getTask(e, n) - if (p) { - const h = Ht.parse({ method: "notifications/tasks/status", params: p }) - await this.notification(h), _e(p.status) && this._cleanupTaskProgressHandler(e) - } - }, - listTasks: (e) => i.listTasks(e, n), - } - } - } -var Cu = "2026-01-26", - Fy = "ui/open-link", - Hy = "ui/download-file", - Jy = "ui/message", - By = "ui/notifications/sandbox-proxy-ready", - Wy = "ui/notifications/sandbox-resource-ready", - Gy = "ui/notifications/size-changed", - Ky = "ui/notifications/tool-input", - mf = "ui/notifications/tool-input-partial", - Qy = "ui/notifications/tool-result", - Xy = "ui/notifications/tool-cancelled", - Yy = "ui/notifications/host-context-changed", - e$ = "ui/resource-teardown", - t$ = "ui/initialize", - r$ = "ui/notifications/initialized", - n$ = "ui/request-display-mode", - pf = s.union([s.literal("light"), s.literal("dark")]).describe("Color theme preference for the host environment."), - Gt = s - .union([s.literal("inline"), s.literal("fullscreen"), s.literal("pip")]) - .describe("Display mode for UI presentation."), - df = s - .union([ - s.literal("--color-background-primary"), - s.literal("--color-background-secondary"), - s.literal("--color-background-tertiary"), - s.literal("--color-background-inverse"), - s.literal("--color-background-ghost"), - s.literal("--color-background-info"), - s.literal("--color-background-danger"), - s.literal("--color-background-success"), - s.literal("--color-background-warning"), - s.literal("--color-background-disabled"), - s.literal("--color-text-primary"), - s.literal("--color-text-secondary"), - s.literal("--color-text-tertiary"), - s.literal("--color-text-inverse"), - s.literal("--color-text-ghost"), - s.literal("--color-text-info"), - s.literal("--color-text-danger"), - s.literal("--color-text-success"), - s.literal("--color-text-warning"), - s.literal("--color-text-disabled"), - s.literal("--color-border-primary"), - s.literal("--color-border-secondary"), - s.literal("--color-border-tertiary"), - s.literal("--color-border-inverse"), - s.literal("--color-border-ghost"), - s.literal("--color-border-info"), - s.literal("--color-border-danger"), - s.literal("--color-border-success"), - s.literal("--color-border-warning"), - s.literal("--color-border-disabled"), - s.literal("--color-ring-primary"), - s.literal("--color-ring-secondary"), - s.literal("--color-ring-inverse"), - s.literal("--color-ring-info"), - s.literal("--color-ring-danger"), - s.literal("--color-ring-success"), - s.literal("--color-ring-warning"), - s.literal("--font-sans"), - s.literal("--font-mono"), - s.literal("--font-weight-normal"), - s.literal("--font-weight-medium"), - s.literal("--font-weight-semibold"), - s.literal("--font-weight-bold"), - s.literal("--font-text-xs-size"), - s.literal("--font-text-sm-size"), - s.literal("--font-text-md-size"), - s.literal("--font-text-lg-size"), - s.literal("--font-heading-xs-size"), - s.literal("--font-heading-sm-size"), - s.literal("--font-heading-md-size"), - s.literal("--font-heading-lg-size"), - s.literal("--font-heading-xl-size"), - s.literal("--font-heading-2xl-size"), - s.literal("--font-heading-3xl-size"), - s.literal("--font-text-xs-line-height"), - s.literal("--font-text-sm-line-height"), - s.literal("--font-text-md-line-height"), - s.literal("--font-text-lg-line-height"), - s.literal("--font-heading-xs-line-height"), - s.literal("--font-heading-sm-line-height"), - s.literal("--font-heading-md-line-height"), - s.literal("--font-heading-lg-line-height"), - s.literal("--font-heading-xl-line-height"), - s.literal("--font-heading-2xl-line-height"), - s.literal("--font-heading-3xl-line-height"), - s.literal("--border-radius-xs"), - s.literal("--border-radius-sm"), - s.literal("--border-radius-md"), - s.literal("--border-radius-lg"), - s.literal("--border-radius-xl"), - s.literal("--border-radius-full"), - s.literal("--border-width-regular"), - s.literal("--shadow-hairline"), - s.literal("--shadow-sm"), - s.literal("--shadow-md"), - s.literal("--shadow-lg"), - ]) - .describe("CSS variable keys available to MCP apps for theming."), - ff = s - .record( - df.describe(`Style variables for theming MCP apps. - -Individual style keys are optional - hosts may provide any subset of these values. -Values are strings containing CSS values (colors, sizes, font stacks, etc.). - -Note: This type uses \`Record\` rather than \`Partial>\` -for compatibility with Zod schema generation. Both are functionally equivalent for validation.`), - s.union([s.string(), s.undefined()]).describe(`Style variables for theming MCP apps. - -Individual style keys are optional - hosts may provide any subset of these values. -Values are strings containing CSS values (colors, sizes, font stacks, etc.). - -Note: This type uses \`Record\` rather than \`Partial>\` -for compatibility with Zod schema generation. Both are functionally equivalent for validation.`), - ) - .describe(`Style variables for theming MCP apps. - -Individual style keys are optional - hosts may provide any subset of these values. -Values are strings containing CSS values (colors, sizes, font stacks, etc.). - -Note: This type uses \`Record\` rather than \`Partial>\` -for compatibility with Zod schema generation. Both are functionally equivalent for validation.`), - gf = s.object({ - method: s.literal("ui/open-link"), - params: s.object({ url: s.string().describe("URL to open in the host's browser") }), - }), - a$ = s - .object({ - isError: s - .boolean() - .optional() - .describe("True if the host failed to open the URL (e.g., due to security policy)."), - }) - .passthrough(), - s$ = s - .object({ - isError: s.boolean().optional().describe("True if the download failed (e.g., user cancelled or host denied)."), - }) - .passthrough(), - c$ = s - .object({ isError: s.boolean().optional().describe("True if the host rejected or failed to deliver the message.") }) - .passthrough(), - hf = s.object({ method: s.literal("ui/notifications/sandbox-proxy-ready"), params: s.object({}) }), - xs = s.object({ - connectDomains: s - .array(s.string()) - .optional() - .describe(`Origins for network requests (fetch/XHR/WebSocket). - -- Maps to CSP \`connect-src\` directive -- Empty or omitted \u2192 no network connections (secure default)`), - resourceDomains: s - .array(s.string()) - .optional() - .describe( - "Origins for static resources (images, scripts, stylesheets, fonts, media).\n\n- Maps to CSP `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives\n- Wildcard subdomains supported: `https://*.example.com`\n- Empty or omitted \u2192 no network resources (secure default)", - ), - frameDomains: s - .array(s.string()) - .optional() - .describe( - "Origins for nested iframes.\n\n- Maps to CSP `frame-src` directive\n- Empty or omitted \u2192 no nested iframes allowed (`frame-src 'none'`)", - ), - baseUriDomains: s - .array(s.string()) - .optional() - .describe( - "Allowed base URIs for the document.\n\n- Maps to CSP `base-uri` directive\n- Empty or omitted \u2192 only same origin allowed (`base-uri 'self'`)", - ), - }), - zs = s.object({ - camera: s.object({}).optional().describe("Request camera access.\n\nMaps to Permission Policy `camera` feature."), - microphone: s - .object({}) - .optional() - .describe("Request microphone access.\n\nMaps to Permission Policy `microphone` feature."), - geolocation: s - .object({}) - .optional() - .describe("Request geolocation access.\n\nMaps to Permission Policy `geolocation` feature."), - clipboardWrite: s - .object({}) - .optional() - .describe("Request clipboard write access.\n\nMaps to Permission Policy `clipboard-write` feature."), - }), - vf = s.object({ - method: s.literal("ui/notifications/size-changed"), - params: s.object({ - width: s.number().optional().describe("New width in pixels."), - height: s.number().optional().describe("New height in pixels."), - }), - }), - u$ = s.object({ - method: s.literal("ui/notifications/tool-input"), - params: s.object({ - arguments: s - .record(s.string(), s.unknown().describe("Complete tool call arguments as key-value pairs.")) - .optional() - .describe("Complete tool call arguments as key-value pairs."), - }), - }), - l$ = s.object({ - method: s.literal("ui/notifications/tool-input-partial"), - params: s.object({ - arguments: s - .record(s.string(), s.unknown().describe("Partial tool call arguments (incomplete, may change).")) - .optional() - .describe("Partial tool call arguments (incomplete, may change)."), - }), - }), - m$ = s.object({ - method: s.literal("ui/notifications/tool-cancelled"), - params: s.object({ - reason: s.string().optional().describe('Optional reason for the cancellation (e.g., "user action", "timeout").'), - }), - }), - bf = s.object({ fonts: s.string().optional() }), - _f = s.object({ - variables: ff.optional().describe("CSS variables for theming the app."), - css: bf.optional().describe("CSS blocks that apps can inject."), - }), - p$ = s.object({ method: s.literal("ui/resource-teardown"), params: s.object({}) }), - yf = s.record(s.string(), s.unknown()), - Du = s.object({ - text: s.object({}).optional().describe("Host supports text content blocks."), - image: s.object({}).optional().describe("Host supports image content blocks."), - audio: s.object({}).optional().describe("Host supports audio content blocks."), - resource: s.object({}).optional().describe("Host supports resource content blocks."), - resourceLink: s.object({}).optional().describe("Host supports resource link content blocks."), - structuredContent: s.object({}).optional().describe("Host supports structured content."), - }), - $f = s.object({ - experimental: s.object({}).optional().describe("Experimental features (structure TBD)."), - openLinks: s.object({}).optional().describe("Host supports opening external URLs."), - downloadFile: s.object({}).optional().describe("Host supports file downloads via ui/download-file."), - serverTools: s - .object({ listChanged: s.boolean().optional().describe("Host supports tools/list_changed notifications.") }) - .optional() - .describe("Host can proxy tool calls to the MCP server."), - serverResources: s - .object({ listChanged: s.boolean().optional().describe("Host supports resources/list_changed notifications.") }) - .optional() - .describe("Host can proxy resource reads to the MCP server."), - logging: s.object({}).optional().describe("Host accepts log messages."), - sandbox: s - .object({ - permissions: zs.optional().describe("Permissions granted by the host (camera, microphone, geolocation)."), - csp: xs.optional().describe("CSP domains approved by the host."), - }) - .optional() - .describe("Sandbox configuration applied by the host."), - updateModelContext: Du.optional().describe( - "Host accepts context updates (ui/update-model-context) to be included in the model's context for future turns.", - ), - message: Du.optional().describe("Host supports receiving content messages (ui/message) from the view."), - }), - xf = s.object({ - experimental: s.object({}).optional().describe("Experimental features (structure TBD)."), - tools: s - .object({ listChanged: s.boolean().optional().describe("App supports tools/list_changed notifications.") }) - .optional() - .describe("App exposes MCP-style tools that the host can call."), - availableDisplayModes: s.array(Gt).optional().describe("Display modes the app supports."), - }), - zf = s.object({ method: s.literal("ui/notifications/initialized"), params: s.object({}).optional() }), - d$ = s.object({ - csp: xs.optional().describe("Content Security Policy configuration for UI resources."), - permissions: zs.optional().describe("Sandbox permissions requested by the UI resource."), - domain: s - .string() - .optional() - .describe(`Dedicated origin for view sandbox. - -Useful when views need stable, dedicated origins for OAuth callbacks, CORS policies, or API key allowlists. - -**Host-dependent:** The format and validation rules for this field are determined by each host. Servers MUST consult host-specific documentation for the expected domain format. Common patterns include: -- Hash-based subdomains (e.g., \`{hash}.claudemcpcontent.com\`) -- URL-derived subdomains (e.g., \`www-example-com.oaiusercontent.com\`) - -If omitted, host uses default sandbox origin (typically per-conversation).`), - prefersBorder: s - .boolean() - .optional() - .describe(`Visual boundary preference - true if view prefers a visible border. - -Boolean requesting whether a visible border and background is provided by the host. Specifying an explicit value for this is recommended because hosts' defaults may vary. - -- \`true\`: request visible border + background -- \`false\`: request no visible border + background -- omitted: host decides border`), - }), - Ru = s.object({ - method: s.literal("ui/request-display-mode"), - params: s.object({ mode: Gt.describe("The display mode being requested.") }), - }), - f$ = s - .object({ - mode: Gt.describe("The display mode that was actually set. May differ from requested if not supported."), - }) - .passthrough(), - kf = s.union([s.literal("model"), s.literal("app")]).describe("Tool visibility scope - who can access the tool."), - g$ = s.object({ - resourceUri: s.string().optional(), - visibility: s - .array(kf) - .optional() - .describe(`Who can access this tool. Default: ["model", "app"] -- "model": Tool visible to and callable by the agent -- "app": Tool callable by the app from this server only`), - }), - h$ = s.object({ - mimeTypes: s - .array(s.string()) - .optional() - .describe( - 'Array of supported MIME types for UI resources.\nMust include `"text/html;profile=mcp-app"` for MCP Apps support.', - ), - }), - Sf = s.object({ - method: s.literal("ui/download-file"), - params: s.object({ - contents: s - .array(s.union([gs, hs])) - .describe( - "Resource contents to download \u2014 embedded (inline data) or linked (host fetches). Uses standard MCP resource types.", - ), - }), - }), - wf = s.object({ - method: s.literal("ui/message"), - params: s.object({ - role: s.literal("user").describe('Message role, currently only "user" is supported.'), - content: s.array(Ge).describe("Message content blocks (text, image, etc.)."), - }), - }), - v$ = s.object({ - method: s.literal("ui/notifications/sandbox-resource-ready"), - params: s.object({ - html: s.string().describe("HTML content to load into the inner iframe."), - sandbox: s.string().optional().describe("Optional override for the inner iframe's sandbox attribute."), - csp: xs.optional().describe("CSP configuration from resource metadata."), - permissions: zs.optional().describe("Sandbox permissions from resource metadata."), - }), - }), - b$ = s.object({ - method: s.literal("ui/notifications/tool-result"), - params: Oe.describe("Standard MCP tool execution result."), - }), - Lu = s - .object({ - toolInfo: s - .object({ - id: Be.optional().describe("JSON-RPC id of the tools/call request."), - tool: sn.describe("Tool definition including name, inputSchema, etc."), - }) - .optional() - .describe("Metadata of the tool call that instantiated this App."), - theme: pf.optional().describe("Current color theme preference."), - styles: _f.optional().describe("Style configuration for theming the app."), - displayMode: Gt.optional().describe("How the UI is currently displayed."), - availableDisplayModes: s.array(Gt).optional().describe("Display modes the host supports."), - containerDimensions: s - .union([ - s.object({ height: s.number().describe("Fixed container height in pixels.") }), - s.object({ - maxHeight: s.union([s.number(), s.undefined()]).optional().describe("Maximum container height in pixels."), - }), - ]) - .and( - s.union([ - s.object({ width: s.number().describe("Fixed container width in pixels.") }), - s.object({ - maxWidth: s.union([s.number(), s.undefined()]).optional().describe("Maximum container width in pixels."), - }), - ]), - ) - .optional() - .describe(`Container dimensions. Represents the dimensions of the iframe or other -container holding the app. Specify either width or maxWidth, and either height or maxHeight.`), - locale: s.string().optional().describe("User's language and region preference in BCP 47 format."), - timeZone: s.string().optional().describe("User's timezone in IANA format."), - userAgent: s.string().optional().describe("Host application identifier."), - platform: s - .union([s.literal("web"), s.literal("desktop"), s.literal("mobile")]) - .optional() - .describe("Platform type for responsive design decisions."), - deviceCapabilities: s - .object({ - touch: s.boolean().optional().describe("Whether the device supports touch input."), - hover: s.boolean().optional().describe("Whether the device supports hover interactions."), - }) - .optional() - .describe("Device input capabilities."), - safeAreaInsets: s - .object({ - top: s.number().describe("Top safe area inset in pixels."), - right: s.number().describe("Right safe area inset in pixels."), - bottom: s.number().describe("Bottom safe area inset in pixels."), - left: s.number().describe("Left safe area inset in pixels."), - }) - .optional() - .describe("Mobile safe area boundaries in pixels."), - }) - .passthrough(), - _$ = s.object({ - method: s.literal("ui/notifications/host-context-changed"), - params: Lu.describe("Partial context update containing only changed fields."), - }), - If = s.object({ - method: s.literal("ui/update-model-context"), - params: s.object({ - content: s.array(Ge).optional().describe("Context content blocks (text, image, etc.)."), - structuredContent: s - .record(s.string(), s.unknown().describe("Structured content for machine-readable context data.")) - .optional() - .describe("Structured content for machine-readable context data."), - }), - }), - jf = s.object({ - method: s.literal("ui/initialize"), - params: s.object({ - appInfo: Mt.describe("App identification (name and version)."), - appCapabilities: xf.describe("Features and capabilities this app provides."), - protocolVersion: s.string().describe("Protocol version this app supports."), - }), - }), - y$ = s - .object({ - protocolVersion: s.string().describe('Negotiated protocol version string (e.g., "2025-11-21").'), - hostInfo: Mt.describe("Host application identification and version."), - hostCapabilities: $f.describe("Features and capabilities provided by the host."), - hostContext: Lu.describe("Rich context about the host environment."), - }) - .passthrough(), - Eu = class { - eventTarget - eventSource - messageListener - constructor(r = window.parent, n) { - ;(this.eventTarget = r), - (this.eventSource = n), - (this.messageListener = (i) => { - if (n && i.source !== this.eventSource) { - console.debug("Ignoring message from unknown source", i) - return - } - const e = Su.safeParse(i.data) - e.success - ? (console.debug("Parsed message", e.data), this.onmessage?.(e.data)) - : i.data?.jsonrpc !== "2.0" - ? console.debug("Ignoring non-JSON-RPC message", e.error.message, i) - : (console.error("Failed to parse message", e.error.message, i), - this.onerror?.(Error("Invalid JSON-RPC message received: " + e.error.message))) - }) - } - async start() { - window.addEventListener("message", this.messageListener) - } - async send(r, n) { - r.method !== mf && console.debug("Sending message", r), this.eventTarget.postMessage(r, "*") - } - async close() { - window.removeEventListener("message", this.messageListener), this.onclose?.() - } - onclose - onerror - onmessage - sessionId - setProtocolVersion - }, - Pf = "ui/resourceUri", - k$ = "text/html;profile=mcp-app" -function S$(t) { - let r = t._meta?.ui?.resourceUri - if ((r === void 0 && (r = t._meta?.[Pf]), typeof r == "string" && r.startsWith("ui://"))) return r - if (r !== void 0) throw Error(`Invalid UI resource URI: ${JSON.stringify(r)}`) -} -function w$(t) { - const r = t._meta?.ui?.visibility - return r ? r.length === 1 && r[0] === "model" : !1 -} -function I$(t) { - const r = t._meta?.ui?.visibility - return r ? r.length === 1 && r[0] === "app" : !1 -} -function j$(t) { - if (!t) return "" - const r = [] - return ( - t.camera && r.push("camera"), - t.microphone && r.push("microphone"), - t.geolocation && r.push("geolocation"), - t.clipboardWrite && r.push("clipboard-write"), - r.join("; ") - ) -} -var Tf = [Cu], - Au = class extends Wt { - _client - _hostInfo - _capabilities - _appCapabilities - _hostContext = {} - _appInfo - constructor(r, n, i, e) { - super(e), - (this._client = r), - (this._hostInfo = n), - (this._capabilities = i), - (this._hostContext = e?.hostContext || {}), - this.setRequestHandler(jf, (o) => this._oninitialize(o)), - this.setRequestHandler(Te, (o, a) => (this.onping?.(o.params, a), {})), - this.setRequestHandler(Ru, (o) => ({ mode: this._hostContext.displayMode ?? "inline" })) - } - getAppCapabilities() { - return this._appCapabilities - } - getAppVersion() { - return this._appInfo - } - onping - set onsizechange(r) { - this.setNotificationHandler(vf, (n) => r(n.params)) - } - set onsandboxready(r) { - this.setNotificationHandler(hf, (n) => r(n.params)) - } - set oninitialized(r) { - this.setNotificationHandler(zf, (n) => r(n.params)) - } - set onmessage(r) { - this.setRequestHandler(wf, async (n, i) => r(n.params, i)) - } - set onopenlink(r) { - this.setRequestHandler(gf, async (n, i) => r(n.params, i)) - } - set ondownloadfile(r) { - this.setRequestHandler(Sf, async (n, i) => r(n.params, i)) - } - set onrequestdisplaymode(r) { - this.setRequestHandler(Ru, async (n, i) => r(n.params, i)) - } - set onloggingmessage(r) { - this.setNotificationHandler(_s, async (n) => { - r(n.params) - }) - } - set onupdatemodelcontext(r) { - this.setRequestHandler(If, async (n, i) => r(n.params, i)) - } - set oncalltool(r) { - this.setRequestHandler(cn, async (n, i) => r(n.params, i)) - } - sendToolListChanged(r = {}) { - return this.notification({ method: "notifications/tools/list_changed", params: r }) - } - set onlistresources(r) { - this.setRequestHandler(os, async (n, i) => r(n.params, i)) - } - set onlistresourcetemplates(r) { - this.setRequestHandler(is, async (n, i) => r(n.params, i)) - } - set onreadresource(r) { - this.setRequestHandler(cs, async (n, i) => r(n.params, i)) - } - sendResourceListChanged(r = {}) { - return this.notification({ method: "notifications/resources/list_changed", params: r }) - } - set onlistprompts(r) { - this.setRequestHandler(ls, async (n, i) => r(n.params, i)) - } - sendPromptListChanged(r = {}) { - return this.notification({ method: "notifications/prompts/list_changed", params: r }) - } - assertCapabilityForMethod(r) {} - assertRequestHandlerCapability(r) {} - assertNotificationCapability(r) {} - assertTaskCapability(r) { - throw Error("Tasks are not supported in MCP Apps") - } - assertTaskHandlerCapability(r) { - throw Error("Task handlers are not supported in MCP Apps") - } - getCapabilities() { - return this._capabilities - } - async _oninitialize(r) { - const n = r.params.protocolVersion - return ( - (this._appCapabilities = r.params.appCapabilities), - (this._appInfo = r.params.appInfo), - { - protocolVersion: Tf.includes(n) ? n : Cu, - hostCapabilities: this.getCapabilities(), - hostInfo: this._hostInfo, - hostContext: this._hostContext, - } - ) - } - setHostContext(r) { - let n = {}, - i = !1 - for (const e of Object.keys(r)) { - const o = this._hostContext[e], - a = r[e] - Of(o, a) || ((n[e] = a), (i = !0)) - } - i && ((this._hostContext = r), this.sendHostContextChange(n)) - } - sendHostContextChange(r) { - return this.notification({ method: "ui/notifications/host-context-changed", params: r }) - } - sendToolInput(r) { - return this.notification({ method: "ui/notifications/tool-input", params: r }) - } - sendToolInputPartial(r) { - return this.notification({ method: "ui/notifications/tool-input-partial", params: r }) - } - sendToolResult(r) { - return this.notification({ method: "ui/notifications/tool-result", params: r }) - } - sendToolCancelled(r) { - return this.notification({ method: "ui/notifications/tool-cancelled", params: r }) - } - sendSandboxResourceReady(r) { - return this.notification({ method: "ui/notifications/sandbox-resource-ready", params: r }) - } - teardownResource(r, n) { - return this.request({ method: "ui/resource-teardown", params: r }, yf, n) - } - sendResourceTeardown = this.teardownResource - async connect(r) { - if (this.transport) throw Error("AppBridge is already connected. Call close() before connecting again.") - if (this._client) { - const n = this._client.getServerCapabilities() - if (!n) throw Error("Client server capabilities not available") - n.tools && - ((this.oncalltool = async (i, e) => - this._client.request({ method: "tools/call", params: i }, Oe, { signal: e.signal })), - n.tools.listChanged && this._client.setNotificationHandler(bs, (i) => this.sendToolListChanged(i.params))), - n.resources && - ((this.onlistresources = async (i, e) => - this._client.request({ method: "resources/list", params: i }, on, { signal: e.signal })), - (this.onlistresourcetemplates = async (i, e) => - this._client.request({ method: "resources/templates/list", params: i }, as, { signal: e.signal })), - (this.onreadresource = async (i, e) => - this._client.request({ method: "resources/read", params: i }, an, { signal: e.signal })), - n.resources.listChanged && - this._client.setNotificationHandler(us, (i) => this.sendResourceListChanged(i.params))), - n.prompts && - ((this.onlistprompts = async (i, e) => - this._client.request({ method: "prompts/list", params: i }, ms, { signal: e.signal })), - n.prompts.listChanged && - this._client.setNotificationHandler(vs, (i) => this.sendPromptListChanged(i.params))) - } - return super.connect(r) - } - } -function Of(t, r) { - return JSON.stringify(t) === JSON.stringify(r) -} -export { - Au as AppBridge, - Hy as DOWNLOAD_FILE_METHOD, - Yy as HOST_CONTEXT_CHANGED_METHOD, - r$ as INITIALIZED_METHOD, - t$ as INITIALIZE_METHOD, - Cu as LATEST_PROTOCOL_VERSION, - Jy as MESSAGE_METHOD, - xf as McpUiAppCapabilitiesSchema, - Gt as McpUiDisplayModeSchema, - Sf as McpUiDownloadFileRequestSchema, - s$ as McpUiDownloadFileResultSchema, - $f as McpUiHostCapabilitiesSchema, - _$ as McpUiHostContextChangedNotificationSchema, - Lu as McpUiHostContextSchema, - bf as McpUiHostCssSchema, - _f as McpUiHostStylesSchema, - jf as McpUiInitializeRequestSchema, - y$ as McpUiInitializeResultSchema, - zf as McpUiInitializedNotificationSchema, - wf as McpUiMessageRequestSchema, - c$ as McpUiMessageResultSchema, - gf as McpUiOpenLinkRequestSchema, - a$ as McpUiOpenLinkResultSchema, - Ru as McpUiRequestDisplayModeRequestSchema, - f$ as McpUiRequestDisplayModeResultSchema, - xs as McpUiResourceCspSchema, - d$ as McpUiResourceMetaSchema, - zs as McpUiResourcePermissionsSchema, - p$ as McpUiResourceTeardownRequestSchema, - yf as McpUiResourceTeardownResultSchema, - hf as McpUiSandboxProxyReadyNotificationSchema, - v$ as McpUiSandboxResourceReadyNotificationSchema, - vf as McpUiSizeChangedNotificationSchema, - Du as McpUiSupportedContentBlockModalitiesSchema, - pf as McpUiThemeSchema, - m$ as McpUiToolCancelledNotificationSchema, - u$ as McpUiToolInputNotificationSchema, - l$ as McpUiToolInputPartialNotificationSchema, - g$ as McpUiToolMetaSchema, - b$ as McpUiToolResultNotificationSchema, - kf as McpUiToolVisibilitySchema, - If as McpUiUpdateModelContextRequestSchema, - Fy as OPEN_LINK_METHOD, - Eu as PostMessageTransport, - n$ as REQUEST_DISPLAY_MODE_METHOD, - k$ as RESOURCE_MIME_TYPE, - e$ as RESOURCE_TEARDOWN_METHOD, - Pf as RESOURCE_URI_META_KEY, - By as SANDBOX_PROXY_READY_METHOD, - Wy as SANDBOX_RESOURCE_READY_METHOD, - Gy as SIZE_CHANGED_METHOD, - Tf as SUPPORTED_PROTOCOL_VERSIONS, - Xy as TOOL_CANCELLED_METHOD, - Ky as TOOL_INPUT_METHOD, - mf as TOOL_INPUT_PARTIAL_METHOD, - Qy as TOOL_RESULT_METHOD, - j$ as buildAllowAttribute, - S$ as getToolUiResourceUri, - I$ as isToolVisibilityAppOnly, - w$ as isToolVisibilityModelOnly, -} diff --git a/src/extensions/mcp-adapter/bm25.ts b/src/extensions/mcp-adapter/bm25.ts deleted file mode 100644 index 5dbab36be..000000000 --- a/src/extensions/mcp-adapter/bm25.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { ToolMetadata } from "./types.js" - -// ---- Types ----------------------------------------------------------------- - -export interface ToolEntry { - name: string - server: string - description: string - schemaKeys: string[] - requiredKeys: string[] -} - -export interface SearchResult { - entry: ToolEntry - score: number -} - -export interface SearchStrategy { - search(query: string, limit: number): SearchResult[] -} - -export interface BM25Config { - strategy: "bm25" | "regex" - k1: number - b: number - fieldWeights: { name: number; description: number; schemaKey: number } -} - -export const BM25_DEFAULTS: BM25Config = { - strategy: "bm25", - k1: 1.2, - b: 0.75, - fieldWeights: { name: 6, description: 2, schemaKey: 1 }, -} - -// ---- Tokenizer ------------------------------------------------------------- - -function tokenize(text: string): string[] { - return text - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[^a-zA-Z0-9]+/g, " ") - .toLowerCase() - .trim() - .split(/\s+/) - .filter((t) => t.length > 0) -} - -// ---- BM25 ------------------------------------------------------------------ - -interface BM25Doc { - entry: ToolEntry - termFreq: Map - length: number -} - -interface BM25Index { - docs: BM25Doc[] - docFreq: Map - avgLength: number -} - -function addWeightedTokens(tf: Map, text: string, weight: number): void { - for (const token of tokenize(text)) { - tf.set(token, (tf.get(token) ?? 0) + weight) - } -} - -function buildDoc(entry: ToolEntry, weights: BM25Config["fieldWeights"]): BM25Doc { - const tf = new Map() - addWeightedTokens(tf, entry.name, weights.name) - addWeightedTokens(tf, entry.description, weights.description) - for (const key of entry.schemaKeys) { - addWeightedTokens(tf, key, weights.schemaKey) - } - const length = Array.from(tf.values()).reduce((s, v) => s + v, 0) - return { entry, termFreq: tf, length } -} - -function buildBM25Index(entries: ToolEntry[], weights: BM25Config["fieldWeights"]): BM25Index { - const docs = entries.map((e) => buildDoc(e, weights)) - const avgLength = docs.length > 0 ? docs.reduce((s, d) => s + d.length, 0) / docs.length : 1 - const docFreq = new Map() - for (const doc of docs) { - for (const term of doc.termFreq.keys()) { - docFreq.set(term, (docFreq.get(term) ?? 0) + 1) - } - } - return { docs, docFreq, avgLength } -} - -function scoreBM25(index: BM25Index, query: string, k1: number, b: number): SearchResult[] { - const queryTokens = tokenize(query) - if (queryTokens.length === 0 || index.docs.length === 0) return [] - - const N = index.docs.length - return index.docs - .map((doc) => { - let score = 0 - for (const token of queryTokens) { - const tf = doc.termFreq.get(token) ?? 0 - if (tf === 0) continue - const df = index.docFreq.get(token) ?? 0 - const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5)) - const norm = k1 * (1 - b + b * (doc.length / index.avgLength)) - score += (idf * (tf * (k1 + 1))) / (tf + norm) - } - return { entry: doc.entry, score } - }) - .filter((r) => r.score > 0) - .sort((x, y) => y.score - x.score || x.entry.name.localeCompare(y.entry.name)) -} - -class BM25Strategy implements SearchStrategy { - constructor( - private readonly index: BM25Index, - private readonly k1: number, - private readonly b: number, - ) {} - - search(query: string, limit: number): SearchResult[] { - return scoreBM25(this.index, query, this.k1, this.b).slice(0, limit) - } -} - -// ---- Regex Strategy -------------------------------------------------------- - -class RegexStrategy implements SearchStrategy { - constructor(private readonly entries: ToolEntry[]) {} - - search(query: string, limit: number): SearchResult[] { - if (!query) return [] - let re: RegExp - try { - re = new RegExp(query, "i") - } catch { - return [] - } - return this.entries - .map((entry) => ({ entry, score: this.score(entry, re) })) - .filter((r) => r.score > 0) - .sort((x, y) => y.score - x.score || x.entry.name.localeCompare(y.entry.name)) - .slice(0, limit) - } - - private score(entry: ToolEntry, re: RegExp): number { - let s = 0 - if (re.test(entry.name)) s += 2 - if (re.test(entry.description)) s += 1 - for (const key of entry.schemaKeys) if (re.test(key)) s += 0.5 - return s - } -} - -// ---- Factory --------------------------------------------------------------- - -export function buildStrategy(entries: ToolEntry[], cfg: BM25Config): SearchStrategy { - if (cfg.strategy === "regex") return new RegexStrategy(entries) - return new BM25Strategy(buildBM25Index(entries, cfg.fieldWeights), cfg.k1, cfg.b) -} - -// ---- Tool Entry Builder ---------------------------------------------------- - -function extractSchemaKeys(inputSchema: unknown): string[] { - const schema = inputSchema as { properties?: Record } | null - return schema?.properties ? Object.keys(schema.properties) : [] -} - -function extractRequiredKeys(inputSchema: unknown): string[] { - const schema = inputSchema as { required?: string[] } | null - return Array.isArray(schema?.required) ? schema.required : [] -} - -export function buildToolEntries(toolMetadata: Map): ToolEntry[] { - const entries: ToolEntry[] = [] - for (const [server, tools] of toolMetadata) { - for (const tool of tools) { - entries.push({ - name: tool.name, - server, - description: tool.description ?? "", - schemaKeys: extractSchemaKeys(tool.inputSchema), - requiredKeys: extractRequiredKeys(tool.inputSchema), - }) - } - } - return entries -} diff --git a/src/extensions/mcp-adapter/cache-resolver.integration.test.ts b/src/extensions/mcp-adapter/cache-resolver.integration.test.ts deleted file mode 100644 index 2ced03f24..000000000 --- a/src/extensions/mcp-adapter/cache-resolver.integration.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/** - * Cache and resolver integration tests. - * - * Exercises the on-disk metadata cache + `resolveDirectTools` pipeline that the - * MCP direct-tools registration depends on. Each test gets a fresh temp - * `KIMCHI_CODING_AGENT_DIR` and re-imports the cache module so the memoized - * cache path (`metadata-cache.ts:13`) picks up the per-test env override. - * - * Scope intentionally stops at the cache + resolver layer: - * - End-to-end `initializeMcp` bootstrap requires either a real MCP server - * subprocess or a `McpServerManager` injection refactor; both are out of - * scope for this PR. - * - `registerAndActivate(..., { markDynamic: false })` needs a stub - * `ExtensionAPI`; that helper warrants its own file and follow-up. - */ - -import { mkdtempSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import type { McpConfig, ServerEntry } from "./types.js" - -// Module surface — typed via import type so the dynamic imports below stay -// strongly typed without locking in a single module instance. -type CacheModule = typeof import("./metadata-cache.js") -type DirectToolsModule = typeof import("./direct-tools.js") - -// ─── Test harness ───────────────────────────────────────────────────────────── - -interface Harness { - cacheModule: CacheModule - directToolsModule: DirectToolsModule - cachePath: string -} - -let tempHome: string | undefined -let originalEnv: string | undefined - -/** - * Set up a fresh temp agent directory and re-import the cache + resolver - * modules so their module-level memoization picks up the new - * `KIMCHI_CODING_AGENT_DIR`. - */ -async function buildHarness(): Promise { - tempHome = mkdtempSync(join(tmpdir(), "mcp-cache-test-")) - process.env.KIMCHI_CODING_AGENT_DIR = tempHome - - vi.resetModules() - const cacheModule = await import("./metadata-cache.js") - const directToolsModule = await import("./direct-tools.js") - return { - cacheModule, - directToolsModule, - cachePath: join(tempHome, "mcp-cache.json"), - } -} - -beforeEach(() => { - originalEnv = process.env.KIMCHI_CODING_AGENT_DIR -}) - -afterEach(() => { - if (originalEnv === undefined) delete process.env.KIMCHI_CODING_AGENT_DIR - else process.env.KIMCHI_CODING_AGENT_DIR = originalEnv - if (tempHome) { - rmSync(tempHome, { recursive: true, force: true }) - tempHome = undefined - } -}) - -// ─── Fixtures ───────────────────────────────────────────────────────────────── - -const JETBRAINS_DEF: ServerEntry = { - url: "http://127.0.0.1:64342/sse", - headers: {}, - directTools: true, -} - -const SUPABASE_DEF: ServerEntry = { - command: "npx", - args: ["-y", "supabase-mcp"], - directTools: true, -} - -function cacheEntry(opts: { configHash: string; tools?: Array<{ name: string; description?: string }> }) { - return { - configHash: opts.configHash, - tools: opts.tools ?? [], - resources: [], - cachedAt: Date.now(), - } -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -describe("purgeStaleEntries", () => { - it("drops mismatched-hash entries for configured servers, preserves orphans", async () => { - const { cacheModule } = await buildHarness() - const { purgeStaleEntries, computeServerHash } = cacheModule - - const orphanDef: ServerEntry = { - command: "stale-orphan", - args: ["from", "another", "project"], - } - - const cache = { - version: 1, - servers: { - jetbrains: cacheEntry({ configHash: "stale-deadbeef" }), - supabase: cacheEntry({ configHash: computeServerHash(SUPABASE_DEF) }), - // 'orphan' is in the on-disk cache but NOT in the current project's - // mcp.json — it belongs to another project that shares the same - // cache file. Must survive the purge. - orphan: cacheEntry({ configHash: computeServerHash(orphanDef) }), - }, - } - - const { cleaned, removed } = purgeStaleEntries(cache, { - jetbrains: JETBRAINS_DEF, - supabase: SUPABASE_DEF, - }) - - expect(removed).toEqual(["jetbrains"]) - expect(Object.keys(cleaned.servers).sort()).toEqual(["orphan", "supabase"]) - }) - - it("returns an empty cleaned cache for null or empty input", async () => { - const { cacheModule } = await buildHarness() - const { purgeStaleEntries } = cacheModule - - const fromNull = purgeStaleEntries(null, { jetbrains: JETBRAINS_DEF }) - expect(fromNull.removed).toEqual([]) - expect(fromNull.cleaned).toEqual({ version: 1, servers: {} }) - - const fromEmpty = purgeStaleEntries({ version: 1, servers: {} }, { jetbrains: JETBRAINS_DEF }) - expect(fromEmpty.removed).toEqual([]) - expect(fromEmpty.cleaned).toEqual({ version: 1, servers: {} }) - }) -}) - -describe("overwriteMetadataCache vs saveMetadataCache", () => { - it("overwriteMetadataCache replaces the on-disk content (no merge)", async () => { - const { cacheModule } = await buildHarness() - const { saveMetadataCache, overwriteMetadataCache, loadMetadataCache } = cacheModule - - saveMetadataCache({ - version: 1, - servers: { a: cacheEntry({ configHash: "hash-a" }) }, - }) - overwriteMetadataCache({ - version: 1, - servers: { b: cacheEntry({ configHash: "hash-b" }) }, - }) - - const cache = loadMetadataCache() - expect(Object.keys(cache?.servers ?? {})).toEqual(["b"]) - }) - - it("saveMetadataCache still merges (regression guard for existing callers)", async () => { - const { cacheModule } = await buildHarness() - const { saveMetadataCache, overwriteMetadataCache, loadMetadataCache } = cacheModule - - overwriteMetadataCache({ - version: 1, - servers: { a: cacheEntry({ configHash: "hash-a" }) }, - }) - saveMetadataCache({ - version: 1, - servers: { b: cacheEntry({ configHash: "hash-b" }) }, - }) - - const cache = loadMetadataCache() - expect(Object.keys(cache?.servers ?? {}).sort()).toEqual(["a", "b"]) - }) -}) - -describe("end-to-end stale → purge → resolve", () => { - it("rejects stale cache, regenerates, then resolves direct-tool specs", async () => { - const { cacheModule, directToolsModule } = await buildHarness() - const { saveMetadataCache, overwriteMetadataCache, loadMetadataCache, purgeStaleEntries, computeServerHash } = - cacheModule - const { resolveDirectTools } = directToolsModule - - const config: McpConfig = { mcpServers: { jetbrains: JETBRAINS_DEF } } - - // (a) Seed a stale entry for jetbrains. - saveMetadataCache({ - version: 1, - servers: { - jetbrains: cacheEntry({ - configHash: "stale-from-an-older-config", - tools: [{ name: "get_all_open_file_paths" }, { name: "build_project" }], - }), - }, - }) - - // (b) Resolver rejects the stale entry — this is the user-visible bug. - expect(resolveDirectTools(config, loadMetadataCache(), "server")).toEqual([]) - - // (c) Purge + overwrite removes it from disk. - const { cleaned, removed } = purgeStaleEntries(loadMetadataCache(), config.mcpServers) - expect(removed).toEqual(["jetbrains"]) - overwriteMetadataCache(cleaned) - expect(loadMetadataCache()?.servers?.jetbrains).toBeUndefined() - - // (d) Bootstrap-equivalent: write a fresh entry with the correct hash. - saveMetadataCache({ - version: 1, - servers: { - jetbrains: cacheEntry({ - configHash: computeServerHash(JETBRAINS_DEF), - tools: [ - { name: "get_all_open_file_paths", description: "list open files" }, - { name: "build_project", description: "build" }, - ], - }), - }, - }) - - // (e) Resolver now produces direct-tool specs with prefixed names — these - // are exactly what `pi.registerTool` would receive at module load. - const specs = resolveDirectTools(config, loadMetadataCache(), "server") - const names = specs.map((s) => s.prefixedName).sort() - expect(names).toEqual(["jetbrains_build_project", "jetbrains_get_all_open_file_paths"]) - for (const spec of specs) { - expect(spec.serverName).toBe("jetbrains") - expect(spec.originalName).toBe(spec.prefixedName.replace(/^jetbrains_/, "")) - } - }) -}) - -describe("resolveDirectTools — interaction with config knobs", () => { - it("honors excludeTools", async () => { - const { cacheModule, directToolsModule } = await buildHarness() - const { saveMetadataCache, computeServerHash, loadMetadataCache } = cacheModule - const { resolveDirectTools } = directToolsModule - - const def: ServerEntry = { ...JETBRAINS_DEF, excludeTools: ["banned"] } - saveMetadataCache({ - version: 1, - servers: { - jetbrains: cacheEntry({ - configHash: computeServerHash(def), - tools: [{ name: "get_status" }, { name: "banned" }, { name: "list_open" }], - }), - }, - }) - - const specs = resolveDirectTools({ mcpServers: { jetbrains: def } }, loadMetadataCache(), "server") - const names = specs.map((s) => s.originalName).sort() - expect(names).toEqual(["get_status", "list_open"]) - }) - - it("ignores cache entries with directTools=false but purge keeps them (hash-keyed)", async () => { - const { cacheModule, directToolsModule } = await buildHarness() - const { saveMetadataCache, computeServerHash, loadMetadataCache, purgeStaleEntries } = cacheModule - const { resolveDirectTools } = directToolsModule - - const def: ServerEntry = { ...JETBRAINS_DEF, directTools: false } - saveMetadataCache({ - version: 1, - servers: { - jetbrains: cacheEntry({ - configHash: computeServerHash(def), - tools: [{ name: "get_open_files" }], - }), - }, - }) - - // Resolver short-circuits because directTools=false — even though the - // hash is valid and tools are cached. - expect(resolveDirectTools({ mcpServers: { jetbrains: def } }, loadMetadataCache(), "server")).toEqual([]) - - // Purge keys off configHash, not directTools. The entry stays. - const { cleaned, removed } = purgeStaleEntries(loadMetadataCache(), { jetbrains: def }) - expect(removed).toEqual([]) - expect(cleaned.servers.jetbrains).toBeDefined() - }) -}) diff --git a/src/extensions/mcp-adapter/caller-servers.test.ts b/src/extensions/mcp-adapter/caller-servers.test.ts deleted file mode 100644 index fc6adeb84..000000000 --- a/src/extensions/mcp-adapter/caller-servers.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest" -import { - clearCallerMcpServers, - consumeCallerMcpServers, - peekCallerMcpServers, - removePendingEntry, - setCallerMcpServers, -} from "./caller-servers.js" -import type { ServerEntry } from "./types.js" - -afterEach(() => { - clearCallerMcpServers() -}) - -const stdioEntry: ServerEntry = { command: "/path", args: [] } -const httpEntry: ServerEntry = { url: "https://example.com" } - -describe("caller-servers registry (session-id-keyed)", () => { - describe("setCallerMcpServers / consumeCallerMcpServers", () => { - it("consume returns servers for the given sessionId and drains on next call", () => { - setCallerMcpServers("s1", { foo: stdioEntry }) - expect(consumeCallerMcpServers("s1")).toEqual({ foo: stdioEntry }) - expect(consumeCallerMcpServers("s1")).toEqual({}) - }) - - it("consume on unknown sessionId returns {}", () => { - expect(consumeCallerMcpServers("nonexistent")).toEqual({}) - }) - - it("set with empty object → consume returns {}", () => { - setCallerMcpServers("s1", {}) - expect(consumeCallerMcpServers("s1")).toEqual({}) - }) - - it("two sessions consume independently with no cross-contamination", () => { - setCallerMcpServers("s1", { a: stdioEntry }) - setCallerMcpServers("s2", { b: httpEntry }) - expect(consumeCallerMcpServers("s1")).toEqual({ a: stdioEntry }) - expect(consumeCallerMcpServers("s2")).toEqual({ b: httpEntry }) - expect(consumeCallerMcpServers("s1")).toEqual({}) - expect(consumeCallerMcpServers("s2")).toEqual({}) - }) - }) - - describe("peekCallerMcpServers", () => { - it("returns the entry without draining", () => { - setCallerMcpServers("s1", { foo: stdioEntry }) - expect(peekCallerMcpServers("s1")).toEqual({ foo: stdioEntry }) - expect(peekCallerMcpServers("s1")).toEqual({ foo: stdioEntry }) - // Still consumable after peek - expect(consumeCallerMcpServers("s1")).toEqual({ foo: stdioEntry }) - }) - - it("returns undefined on unknown sessionId", () => { - expect(peekCallerMcpServers("nonexistent")).toBeUndefined() - }) - }) - - describe("removePendingEntry", () => { - it("removes a pending entry that has not been consumed", () => { - setCallerMcpServers("s1", { foo: stdioEntry }) - removePendingEntry("s1") - expect(peekCallerMcpServers("s1")).toBeUndefined() - expect(consumeCallerMcpServers("s1")).toEqual({}) - }) - - it("is a no-op if the entry was already consumed", () => { - setCallerMcpServers("s1", { foo: stdioEntry }) - consumeCallerMcpServers("s1") - // Already consumed — should not throw - removePendingEntry("s1") - expect(peekCallerMcpServers("s1")).toBeUndefined() - }) - - it("only removes the specified session, not others", () => { - setCallerMcpServers("s1", { a: stdioEntry }) - setCallerMcpServers("s2", { b: httpEntry }) - removePendingEntry("s1") - expect(peekCallerMcpServers("s1")).toBeUndefined() - expect(consumeCallerMcpServers("s2")).toEqual({ b: httpEntry }) - }) - - it("is a no-op on unknown sessionId", () => { - // Should not throw - removePendingEntry("nonexistent") - }) - }) - - describe("clearCallerMcpServers", () => { - it("clears all entries", () => { - setCallerMcpServers("s1", { a: stdioEntry }) - setCallerMcpServers("s2", { b: httpEntry }) - clearCallerMcpServers() - expect(peekCallerMcpServers("s1")).toBeUndefined() - expect(peekCallerMcpServers("s2")).toBeUndefined() - }) - }) - - describe("concurrent session isolation", () => { - it("out-of-order consumption: session B can consume before session A", () => { - // Simulate the race: A is set first, B second, but B consumes first - setCallerMcpServers("A", { first: stdioEntry }) - setCallerMcpServers("B", { second: httpEntry }) - - // B consumes first — gets B's servers, not A's - expect(consumeCallerMcpServers("B")).toEqual({ second: httpEntry }) - - // A consumes second — gets A's servers, not B's - expect(consumeCallerMcpServers("A")).toEqual({ first: stdioEntry }) - - // Both drained - expect(consumeCallerMcpServers("A")).toEqual({}) - expect(consumeCallerMcpServers("B")).toEqual({}) - }) - }) -}) diff --git a/src/extensions/mcp-adapter/caller-servers.ts b/src/extensions/mcp-adapter/caller-servers.ts deleted file mode 100644 index f1c11f9d0..000000000 --- a/src/extensions/mcp-adapter/caller-servers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { ServerEntry } from "./types.js" - -/** - * Session-id-keyed registry of caller-supplied MCP servers. - * - * The ACP server (`src/modes/acp/server.ts`) receives `mcpServers` on - * `session/new` and `session/load` and needs to pass them to the MCP adapter - * extension, which runs inside pi's `session_start` handler. Since pi's - * `ExtensionContext` has no session-scoped channel for this, we use a - * module-level map keyed by session ID. - * - * Keyed by sessionId (not a FIFO queue) so concurrent sessions can't consume - * each other's entries. The ACP server calls `setCallerMcpServers(sessionId, - * servers)` after the session is created (sessionId is known), and - * `initializeMcp` calls `consumeCallerMcpServers(sessionId)` using - * `ctx.sessionManager.getSessionId()`. - */ - -const registry = new Map>() - -/** - * Store caller-supplied MCP servers keyed by session ID. - * Called by the ACP server after `sessionFactory`/`sessionLoader` returns - * (when the session ID is known), before `bindAcpExtensions`. - */ -export function setCallerMcpServers(sessionId: string, servers: Record): void { - registry.set(sessionId, servers) -} - -/** - * Pop and return the caller-supplied servers for the given session ID. - * Called by `initializeMcp` during `session_start`. After consumption, the - * entry is deleted — subsequent calls for the same sessionId return `{}`. - */ -export function consumeCallerMcpServers(sessionId: string): Record { - const servers = registry.get(sessionId) - if (servers) { - registry.delete(sessionId) - return servers - } - return {} -} - -/** - * Return the entry for a sessionId without removing it (for tests/debugging). - */ -export function peekCallerMcpServers(sessionId: string): Record | undefined { - return registry.get(sessionId) -} - -/** - * Remove a specific session's entry if it hasn't been consumed yet. - * Used by the ACP server's catch blocks to clean up after a session failure: - * if `initializeMcp` already consumed the entry, this is a no-op. - */ -export function removePendingEntry(sessionId: string): void { - registry.delete(sessionId) -} - -/** - * Clear the registry. Exposed for tests to ensure isolation between test cases. - */ -export function clearCallerMcpServers(): void { - registry.clear() -} diff --git a/src/extensions/mcp-adapter/commands.ts b/src/extensions/mcp-adapter/commands.ts deleted file mode 100644 index 76e426507..000000000 --- a/src/extensions/mcp-adapter/commands.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent" -import { getServerProvenance, writeDirectToolsConfig } from "./config.js" -import { getFailureAgeSeconds, lazyConnect, updateMetadataCache, updateStatusBar } from "./init.js" -import { hasStoredTokens } from "./mcp-auth.js" -import { authenticate, supportsOAuth } from "./mcp-auth-flow.js" -import { loadMetadataCache } from "./metadata-cache.js" -import type { McpExtensionState } from "./state.js" -import { buildToolMetadata } from "./tool-metadata.js" -import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerEntry } from "./types.js" - -export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return - - const lines: string[] = ["MCP Server Status:", ""] - - for (const name of Object.keys(state.config.mcpServers)) { - const connection = state.manager.getConnection(name) - const metadata = state.toolMetadata.get(name) - const toolCount = metadata?.length ?? 0 - const failedAgo = getFailureAgeSeconds(state, name) - let status = "not connected" - let statusIcon = "○" - let failed = false - - if (connection?.status === "connected") { - status = "connected" - statusIcon = "✓" - } else if (connection?.status === "needs-auth") { - status = "needs auth" - statusIcon = "⚠" - } else if (failedAgo !== null) { - status = `failed ${failedAgo}s ago` - statusIcon = "✗" - failed = true - } else if (metadata !== undefined) { - status = "cached" - } - - const toolSuffix = failed ? "" : ` (${toolCount} tools${status === "cached" ? ", cached" : ""})` - lines.push(`${statusIcon} ${name}: ${status}${toolSuffix}`) - } - - if (Object.keys(state.config.mcpServers).length === 0) { - lines.push("No MCP servers configured") - } - - ctx.ui.notify(lines.join("\n"), "info") -} - -export async function showTools(state: McpExtensionState, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return - - const allTools = [...state.toolMetadata.values()].flat().map((m) => m.name) - - if (allTools.length === 0) { - ctx.ui.notify("No MCP tools available", "info") - return - } - - const lines = ["MCP Tools:", "", ...allTools.map((t) => ` ${t}`), "", `Total: ${allTools.length} tools`] - - ctx.ui.notify(lines.join("\n"), "info") -} - -export async function reconnectServers( - state: McpExtensionState, - ctx: ExtensionContext, - targetServer?: string, -): Promise { - if (targetServer && !state.config.mcpServers[targetServer]) { - if (ctx.hasUI) { - ctx.ui.notify(`Server "${targetServer}" not found in config`, "error") - } - return - } - - const entries = targetServer - ? [[targetServer, state.config.mcpServers[targetServer]] as [string, ServerEntry]] - : Object.entries(state.config.mcpServers) - - for (const [name, definition] of entries) { - try { - await state.manager.close(name) - - const connection = await state.manager.connect(name, definition) - if (connection.status === "needs-auth") { - if (ctx.hasUI) { - ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning") - } - continue - } - const prefix = state.config.settings?.toolPrefix ?? "server" - - const { metadata, failedTools } = buildToolMetadata( - connection.tools, - connection.resources, - definition, - name, - prefix, - ) - state.toolMetadata.set(name, metadata) - updateMetadataCache(state, name) - state.failureTracker.delete(name) - - if (ctx.hasUI) { - ctx.ui.notify( - `MCP: Reconnected to ${name} (${connection.tools.length} tools, ${connection.resources.length} resources)`, - "info", - ) - if (failedTools.length > 0) { - ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning") - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - state.failureTracker.set(name, Date.now()) - if (ctx.hasUI) { - ctx.ui.notify(`MCP: Failed to reconnect to ${name}: ${message}`, "error") - } - } - } - - updateStatusBar(state) -} - -export async function authenticateServer(serverName: string, config: McpConfig, ctx: ExtensionContext): Promise { - if (!ctx.hasUI) return - - const definition = config.mcpServers[serverName] - if (!definition) { - ctx.ui.notify(`Server "${serverName}" not found in config`, "error") - return - } - - if (!supportsOAuth(definition)) { - ctx.ui.notify( - `Server "${serverName}" does not use OAuth authentication.\n` + - `Set "auth": "oauth" or omit auth for auto-detection.`, - "error", - ) - return - } - - if (!definition.url) { - ctx.ui.notify(`Server "${serverName}" has no URL configured (OAuth requires HTTP transport)`, "error") - return - } - - // Full automatic OAuth flow using SDK - try { - ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`) - - // Runs the configured OAuth flow (interactive browser or non-interactive client_credentials) - const status = await authenticate(serverName, definition.url, definition) - - if (status === "authenticated") { - ctx.ui.notify( - `OAuth authentication successful for "${serverName}"!\n` + - `Run /mcp reconnect ${serverName} to connect with the new token.`, - "info", - ) - } else { - ctx.ui.notify(`OAuth authentication failed for "${serverName}".`, "error") - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error") - } finally { - ctx.ui.setStatus("mcp-auth", undefined) - } -} - -export async function openMcpPanel( - state: McpExtensionState, - pi: ExtensionAPI, - ctx: ExtensionContext, - configOverridePath?: string, -): Promise { - const config = state.config - const cache = loadMetadataCache() - const provenanceMap = getServerProvenance((pi.getFlag("mcp-config") as string | undefined) ?? configOverridePath) - - const callbacks: McpPanelCallbacks = { - reconnect: async (serverName: string) => { - return lazyConnect(state, serverName) - }, - getConnectionStatus: (serverName: string) => { - const definition = config.mcpServers[serverName] - const connection = state.manager.getConnection(serverName) - if (connection?.status === "needs-auth") { - return "needs-auth" - } - if ( - definition?.auth === "oauth" && - definition.oauth !== false && - definition.oauth?.grantType !== "client_credentials" && - !hasStoredTokens(serverName) - ) { - return "needs-auth" - } - if (connection?.status === "connected") return "connected" - if (getFailureAgeSeconds(state, serverName) !== null) return "failed" - return "idle" - }, - refreshCacheAfterReconnect: (serverName: string) => { - const freshCache = loadMetadataCache() - return freshCache?.servers?.[serverName] ?? null - }, - onSave: (changes) => { - writeDirectToolsConfig(changes, provenanceMap, config) - ctx.ui.notify("Direct tools updated. Restart kimchi to apply.", "info") - }, - } - - const { createMcpPanel } = await import("./mcp-panel.js") - - return new Promise((resolve) => { - ctx.ui.custom( - (tui, _theme, _keybindings, done) => { - return createMcpPanel(config, cache, provenanceMap, callbacks, tui, (result: McpPanelResult) => { - if (!result.cancelled && result.changes.size > 0) { - writeDirectToolsConfig(result.changes, provenanceMap, config) - ctx.ui.notify("Direct tools updated. Restart kimchi to apply.", "info") - } - done(undefined) - // Force a clean redraw so any overlay artifacts are cleared from - // scrollback when the panel closes. - tui.requestRender(true) - resolve() - }) - }, - { overlay: true, overlayOptions: { anchor: "center", width: 82, maxHeight: "100%" } }, - ) - }) -} diff --git a/src/extensions/mcp-adapter/config.test.ts b/src/extensions/mcp-adapter/config.test.ts deleted file mode 100644 index 9a10f9cb2..000000000 --- a/src/extensions/mcp-adapter/config.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { writeDirectToolsConfig } from "./config.js" -import type { McpConfig, ServerProvenance } from "./types.js" - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -let tempDir: string - -beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "mcp-config-test-")) -}) - -afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }) -}) - -/** Write a minimal mcp.json to tempDir and return its path. */ -function writeMcpFile(servers: Record): string { - const filePath = join(tempDir, "mcp.json") - writeFileSync(filePath, JSON.stringify({ mcpServers: servers }, null, 2), "utf-8") - return filePath -} - -/** Read the mcpServers object back from a file written by writeDirectToolsConfig. */ -function readServers(filePath: string): Record { - const raw = JSON.parse(readFileSync(filePath, "utf-8")) as Record - return (raw.mcpServers ?? {}) as Record -} - -// ─── writeDirectToolsConfig ─────────────────────────────────────────────────── - -describe("writeDirectToolsConfig", () => { - describe("in-memory sync (fullConfig.mcpServers)", () => { - it("updates fullConfig.mcpServers[name].directTools after writing to disk", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server"] }, - }) - - const fullConfig: McpConfig = { - mcpServers: { - "my-server": { command: "npx", args: ["my-server"] }, - }, - } - const provenance = new Map([["my-server", { path: filePath, kind: "user" }]]) - const changes = new Map([["my-server", true]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - expect(fullConfig.mcpServers["my-server"].directTools).toBe(true) - }) - - it("syncs a tool list (string[]) into fullConfig", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server"] }, - }) - - const fullConfig: McpConfig = { - mcpServers: { "my-server": { command: "npx", args: ["my-server"] } }, - } - const provenance = new Map([["my-server", { path: filePath, kind: "user" }]]) - const changes = new Map([["my-server", ["tool_a", "tool_b"]]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - expect(fullConfig.mcpServers["my-server"].directTools).toEqual(["tool_a", "tool_b"]) - }) - - it("syncs false (disable all direct tools) into fullConfig", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server"], directTools: true }, - }) - - const fullConfig: McpConfig = { - mcpServers: { "my-server": { command: "npx", args: ["my-server"], directTools: true } }, - } - const provenance = new Map([["my-server", { path: filePath, kind: "user" }]]) - const changes = new Map([["my-server", false]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - expect(fullConfig.mcpServers["my-server"].directTools).toBe(false) - }) - - it("syncs multiple servers independently", () => { - const filePath = writeMcpFile({ - server_a: { command: "npx", args: ["a"] }, - server_b: { command: "npx", args: ["b"] }, - }) - - const fullConfig: McpConfig = { - mcpServers: { - server_a: { command: "npx", args: ["a"] }, - server_b: { command: "npx", args: ["b"] }, - }, - } - const provenance = new Map([ - ["server_a", { path: filePath, kind: "user" }], - ["server_b", { path: filePath, kind: "user" }], - ]) - const changes = new Map([ - ["server_a", true], - ["server_b", ["tool_x"]], - ]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - expect(fullConfig.mcpServers.server_a.directTools).toBe(true) - expect(fullConfig.mcpServers.server_b.directTools).toEqual(["tool_x"]) - }) - - it("does not sync a server that has no provenance entry", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server"] }, - }) - - const fullConfig: McpConfig = { - mcpServers: { "my-server": { command: "npx", args: ["my-server"] } }, - } - // Provenance map is empty — writeDirectToolsConfig should skip silently - const provenance = new Map() - const changes = new Map([["my-server", true]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - // fullConfig unchanged because prov lookup failed - expect(fullConfig.mcpServers["my-server"].directTools).toBeUndefined() - // File also unchanged - const onDisk = readServers(filePath) - expect((onDisk["my-server"] as Record).directTools).toBeUndefined() - }) - }) - - describe("disk writes", () => { - it("persists directTools to the target config file", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server"] }, - }) - - const fullConfig: McpConfig = { - mcpServers: { "my-server": { command: "npx", args: ["my-server"] } }, - } - const provenance = new Map([["my-server", { path: filePath, kind: "user" }]]) - const changes = new Map([["my-server", true]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - const onDisk = readServers(filePath) - expect((onDisk["my-server"] as Record).directTools).toBe(true) - }) - - it("preserves other server fields when updating directTools", () => { - const filePath = writeMcpFile({ - "my-server": { command: "npx", args: ["my-server", "--flag"], env: { FOO: "bar" } }, - }) - - const fullConfig: McpConfig = { - mcpServers: { - "my-server": { command: "npx", args: ["my-server", "--flag"], env: { FOO: "bar" } }, - }, - } - const provenance = new Map([["my-server", { path: filePath, kind: "user" }]]) - const changes = new Map([["my-server", true]]) - - writeDirectToolsConfig(changes, provenance, fullConfig) - - const onDisk = readServers(filePath) - const srv = onDisk["my-server"] as Record - expect(srv.command).toBe("npx") - expect(srv.args).toEqual(["my-server", "--flag"]) - expect(srv.env).toEqual({ FOO: "bar" }) - expect(srv.directTools).toBe(true) - }) - }) -}) - -// ─── loadMcpConfig ──────────────────────────────────────────────────────────── -// IMPORT_PATHS is computed once at module-eval time from homedir(), so each test -// that exercises imports must re-evaluate config.ts after pointing HOME at a temp -// dir. We do this with vi.resetModules() + a dynamic import. -describe("loadMcpConfig", () => { - describe("codex import", () => { - let originalHome: string | undefined - let tempHome: string - - beforeEach(() => { - originalHome = process.env.HOME - tempHome = mkdtempSync(join(tmpdir(), "mcp-codex-home-")) - process.env.HOME = tempHome - }) - - afterEach(() => { - if (originalHome === undefined) delete process.env.HOME - else process.env.HOME = originalHome - rmSync(tempHome, { recursive: true, force: true }) - }) - - it("merges codex MCP servers from ~/.codex/config.json when imports includes codex", async () => { - // 1. Write a ~/.codex/config.json containing at least one server entry. - mkdirSync(join(tempHome, ".codex"), { recursive: true }) - writeFileSync( - join(tempHome, ".codex", "config.json"), - JSON.stringify({ - mcpServers: { - "my-server": { command: "node", args: ["server.js"] }, - }, - }), - "utf-8", - ) - - // 2. Main config requests the codex import. - const mainConfigPath = join(tempHome, "mcp.json") - writeFileSync(mainConfigPath, JSON.stringify({ imports: ["codex"], mcpServers: {} }), "utf-8") - - // 3. Re-evaluate config.ts so IMPORT_PATHS resolves against the temp HOME. - vi.resetModules() - const { loadMcpConfig } = await import("./config.js") - const { config, warnings } = loadMcpConfig(mainConfigPath) - - // 4. Assert the codex MCP servers are merged into the result. - expect(warnings).toEqual([]) - expect(config.mcpServers["my-server"]).toBeDefined() - expect(config.mcpServers["my-server"]).toMatchObject({ - command: "node", - args: ["server.js"], - }) - }) - }) -}) diff --git a/src/extensions/mcp-adapter/config.ts b/src/extensions/mcp-adapter/config.ts deleted file mode 100644 index e6f316d60..000000000 --- a/src/extensions/mcp-adapter/config.ts +++ /dev/null @@ -1,234 +0,0 @@ -// config.ts - Config loading with import support -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" -import { homedir } from "node:os" -import { dirname, join, resolve } from "node:path" -import type { ImportKind, McpConfig, McpSettings, ServerEntry, ServerProvenance } from "./types.js" -import { getAgentDir } from "./utils.js" - -let _defaultConfigPath: string | undefined -function getDefaultConfigPath(): string { - // biome-ignore lint/suspicious/noAssignInExpressions: result is cached - return (_defaultConfigPath ??= join(getAgentDir(), "mcp.json")) -} -const PROJECT_CONFIG_NAME = ".kimchi/mcp.json" - -// Import source paths for other tools -const IMPORT_PATHS: Record = { - cursor: join(homedir(), ".cursor", "mcp.json"), - "claude-code": join(homedir(), ".claude", "claude_desktop_config.json"), - "claude-desktop": join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json"), - codex: join(homedir(), ".codex", "config.json"), - windsurf: join(homedir(), ".windsurf", "mcp.json"), - vscode: ".vscode/mcp.json", // Relative to project -} - -export function loadMcpConfig(overridePath?: string): { config: McpConfig; warnings: string[] } { - const configPath = overridePath ? resolve(overridePath) : getDefaultConfigPath() - const warnings: string[] = [] - - // Load base config - let config: McpConfig = { mcpServers: {} } - - if (existsSync(configPath)) { - try { - const raw = JSON.parse(readFileSync(configPath, "utf-8")) - config = validateConfig(raw) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - warnings.push(`Failed to load MCP config from ${configPath}: ${msg}`) - } - } - - // Process imports from other tools - if (config.imports?.length) { - for (const importKind of config.imports) { - const importPath = IMPORT_PATHS[importKind] - if (!importPath) continue - - const fullPath = importPath.startsWith(".") ? resolve(process.cwd(), importPath) : importPath - - if (!existsSync(fullPath)) continue - - try { - const imported = JSON.parse(readFileSync(fullPath, "utf-8")) - const servers = extractServers(imported, importKind) - - // Merge - local config takes precedence over imports - for (const [name, def] of Object.entries(servers)) { - if (!config.mcpServers[name]) { - config.mcpServers[name] = def - } - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - warnings.push(`Failed to import MCP config from ${importKind}: ${msg}`) - } - } - } - - // Check for project-local config (skip if it's the same as the main config) - const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME) - if (existsSync(projectPath) && projectPath !== configPath) { - try { - const projectConfig = JSON.parse(readFileSync(projectPath, "utf-8")) - const validated = validateConfig(projectConfig) - - // Project config overrides everything - config.mcpServers = { ...config.mcpServers, ...validated.mcpServers } - if (validated.settings) { - config.settings = { ...config.settings, ...validated.settings } - } - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - warnings.push(`Failed to load project MCP config: ${msg}`) - } - } - - return { config, warnings } -} - -function validateConfig(raw: unknown): McpConfig { - if (!raw || typeof raw !== "object") { - return { mcpServers: {} } - } - - const obj = raw as Record - const servers = obj.mcpServers ?? obj["mcp-servers"] ?? {} - - // Must be a plain object, not an array or null - if (typeof servers !== "object" || servers === null || Array.isArray(servers)) { - return { mcpServers: {} } - } - - return { - mcpServers: servers as Record, - imports: Array.isArray(obj.imports) ? (obj.imports as ImportKind[]) : undefined, - settings: obj.settings as McpSettings | undefined, - } -} - -function extractServers(config: unknown, kind: ImportKind): Record { - if (!config || typeof config !== "object") return {} - - const obj = config as Record - - let servers: unknown - switch (kind) { - case "claude-desktop": - case "claude-code": - case "codex": - servers = obj.mcpServers - break - case "cursor": - case "windsurf": - case "vscode": - servers = obj.mcpServers ?? obj["mcp-servers"] - break - default: - return {} - } - - if (!servers || typeof servers !== "object" || Array.isArray(servers)) { - return {} - } - - return servers as Record -} - -export function getServerProvenance(overridePath?: string): Map { - const provenance = new Map() - const userPath = overridePath ? resolve(overridePath) : getDefaultConfigPath() - - let userConfig: McpConfig = { mcpServers: {} } - if (existsSync(userPath)) { - try { - userConfig = validateConfig(JSON.parse(readFileSync(userPath, "utf-8"))) - } catch {} - } - for (const name of Object.keys(userConfig.mcpServers)) { - provenance.set(name, { path: userPath, kind: "user" }) - } - - if (userConfig.imports?.length) { - for (const importKind of userConfig.imports) { - const importPath = IMPORT_PATHS[importKind] - if (!importPath) continue - const fullPath = importPath.startsWith(".") ? resolve(process.cwd(), importPath) : importPath - if (!existsSync(fullPath)) continue - try { - const imported = JSON.parse(readFileSync(fullPath, "utf-8")) - const servers = extractServers(imported, importKind) - for (const name of Object.keys(servers)) { - if (!provenance.has(name)) { - provenance.set(name, { path: userPath, kind: "import", importKind }) - } - } - } catch {} - } - } - - const projectPath = resolve(process.cwd(), PROJECT_CONFIG_NAME) - if (existsSync(projectPath) && projectPath !== userPath) { - try { - const projectConfig = validateConfig(JSON.parse(readFileSync(projectPath, "utf-8"))) - for (const name of Object.keys(projectConfig.mcpServers)) { - provenance.set(name, { path: projectPath, kind: "project" }) - } - } catch {} - } - - return provenance -} - -export function writeDirectToolsConfig( - changes: Map, - provenance: Map, - fullConfig: McpConfig, -): void { - const byPath = new Map() - - for (const [serverName, value] of changes) { - const prov = provenance.get(serverName) - if (!prov) continue - - const targetPath = prov.path - - if (!byPath.has(targetPath)) byPath.set(targetPath, []) - byPath.get(targetPath)?.push({ name: serverName, value, prov }) - } - - for (const [filePath, entries] of byPath) { - let raw: Record = {} - if (existsSync(filePath)) { - try { - raw = JSON.parse(readFileSync(filePath, "utf-8")) - } catch {} - } - if (!raw || typeof raw !== "object") raw = {} - - const servers = (raw.mcpServers ?? raw["mcp-servers"] ?? {}) as Record - if (typeof servers !== "object" || Array.isArray(servers)) continue - - for (const { name, value, prov } of entries) { - if (prov.kind === "import") { - const fullDef = fullConfig.mcpServers[name] - if (fullDef) { - servers[name] = { ...fullDef, directTools: value } - } - } else if (servers[name]) { - servers[name] = { ...servers[name], directTools: value } - } - - // Sync in-memory config so /mcp panel shows current state on reopen. Tool availability still requires restart. - fullConfig.mcpServers[name] = servers[name] - } - - const key = raw["mcp-servers"] && !raw.mcpServers ? "mcp-servers" : "mcpServers" - raw[key] = servers - - mkdirSync(dirname(filePath), { recursive: true }) - const tmpPath = `${filePath}.${process.pid}.tmp` - writeFileSync(tmpPath, `${JSON.stringify(raw, null, 2)}\n`, "utf-8") - renameSync(tmpPath, filePath) - } -} diff --git a/src/extensions/mcp-adapter/consent-manager.ts b/src/extensions/mcp-adapter/consent-manager.ts deleted file mode 100644 index 2358ffe01..000000000 --- a/src/extensions/mcp-adapter/consent-manager.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ConsentError } from "./errors.js" -import { logger } from "./logger.js" - -export type ToolConsentMode = "never" | "once-per-server" | "always" - -export class ConsentManager { - private approvedServers = new Set() - private deniedServers = new Set() - private log = logger.child({ component: "ConsentManager" }) - - constructor(private mode: ToolConsentMode = "once-per-server") { - this.log.debug("Initialized", { mode }) - } - - requiresPrompt(serverName: string): boolean { - if (this.mode === "never") return false - if (this.deniedServers.has(serverName)) return true - if (this.mode === "always") return true - return !this.approvedServers.has(serverName) - } - - shouldCacheConsent(): boolean { - return this.mode !== "always" - } - - registerDecision(serverName: string, approved: boolean): void { - this.deniedServers.delete(serverName) - this.approvedServers.delete(serverName) - - if (approved) { - this.approvedServers.add(serverName) - this.log.debug("Consent granted", { server: serverName }) - return - } - - this.deniedServers.add(serverName) - this.log.debug("Consent denied", { server: serverName }) - } - - ensureApproved(serverName: string): void { - if (this.mode === "never") return - if (this.deniedServers.has(serverName)) { - throw new ConsentError(serverName, { denied: true }) - } - if (!this.approvedServers.has(serverName)) { - throw new ConsentError(serverName, { requiresApproval: true }) - } - if (this.mode === "always") { - this.approvedServers.delete(serverName) - } - } - - clear(serverName?: string): void { - if (serverName) { - this.approvedServers.delete(serverName) - this.deniedServers.delete(serverName) - this.log.debug("Cleared consent for server", { server: serverName }) - return - } - this.approvedServers.clear() - this.deniedServers.clear() - this.log.debug("Cleared all consent records") - } -} diff --git a/src/extensions/mcp-adapter/context-providers.test.ts b/src/extensions/mcp-adapter/context-providers.test.ts deleted file mode 100644 index a0ddff51a..000000000 --- a/src/extensions/mcp-adapter/context-providers.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import { CWD_REGEX, fillMissingRequired, PROJECT_PATH_REGEX } from "./context-providers.js" -import type { ToolMetadata } from "./types.js" - -function makeMeta(required: string[], additionalProperties = {}): ToolMetadata { - return { - name: "test_tool", - originalName: "test_tool", - description: "", - prefix: "", - serverName: "test", - inputSchema: { - type: "object", - properties: { - projectPath: { type: "string" }, - cwd: { type: "string" }, - optionalParam: { type: "string" }, - ...additionalProperties, - }, - required, - }, - } as ToolMetadata -} - -describe("fillMissingRequired", () => { - it("fills missing required projectPath from cwd", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, {}, { cwd: "/kimchi" }) - expect(result.projectPath).toBe("/kimchi") - }) - - it("does NOT overwrite a caller-supplied value", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, { projectPath: "/other" }, { cwd: "/kimchi" }) - expect(result.projectPath).toBe("/other") - }) - - it("does NOT fill optional params even if empty", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, {}, { cwd: "/kimchi" }) - expect("optionalParam" in result).toBe(false) - }) - - it("does NOT fill when inputSchema is missing", () => { - const result = fillMissingRequired(undefined, { a: 1 }, { cwd: "/" }) - expect(result).toEqual({ a: 1 }) - }) - - it("fills cwd param from cwd context", () => { - const meta = makeMeta(["cwd"]) - const result = fillMissingRequired(meta, {}, { cwd: "/work" }) - expect(result.cwd).toBe("/work") - }) - - it("logs each fill when logger provided", () => { - const log = vi.fn() - const meta = makeMeta(["projectPath"]) - fillMissingRequired(meta, {}, { cwd: "/kimchi" }, log) - expect(log).toHaveBeenCalledOnce() - expect(log.mock.calls[0][0]).toMatch(/auto-fill: projectPath.*provider:/) - }) - - it("does not fill empty string — preserves caller value", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, { projectPath: "" }, { cwd: "/kimchi" }) - expect(result.projectPath).toBe("") - }) - - it("does NOT fill when inputSchema has no required array", () => { - const meta = { - name: "test_tool", - originalName: "test_tool", - description: "", - prefix: "", - serverName: "test", - inputSchema: { type: "object", properties: { projectPath: { type: "string" } } }, - } as ToolMetadata - const result = fillMissingRequired(meta, {}, { cwd: "/kimchi" }) - expect("projectPath" in result).toBe(false) - }) - - it("fills missing required param with empty string when cwd is empty string", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, {}, { cwd: "" }) - expect(result.projectPath).toBe("") - }) - - it("does NOT overwrite a caller-supplied null value", () => { - const meta = makeMeta(["projectPath"]) - const result = fillMissingRequired(meta, { projectPath: null }, { cwd: "/kimchi" }) - expect(result.projectPath).toBeNull() - }) -}) - -describe("PROJECT_PATH_REGEX — matches project-path-like property names", () => { - it.each([ - "projectPath", - "project_path", - "project-path", - "projectRoot", - "project_root", - "project-root", - "repoRoot", - "repo_root", - "repo-root", - ])('matches "%s"', (name) => { - expect(PROJECT_PATH_REGEX.test(name)).toBe(true) - }) - - it.each([ - "path", - "projectDir", - "root", - "repo", - "project_path_extra", - "cwd", - "workingDirectory", - ])('does NOT match "%s"', (name) => { - expect(PROJECT_PATH_REGEX.test(name)).toBe(false) - }) -}) - -describe("CWD_REGEX — matches working-directory-like property names", () => { - it.each(["cwd", "workingDirectory", "working_directory"])('matches "%s"', (name) => { - expect(CWD_REGEX.test(name)).toBe(true) - }) - - it.each(["wd", "directory", "workDir", "workingDir", "projectPath"])('does NOT match "%s"', (name) => { - expect(CWD_REGEX.test(name)).toBe(false) - }) -}) diff --git a/src/extensions/mcp-adapter/context-providers.ts b/src/extensions/mcp-adapter/context-providers.ts deleted file mode 100644 index 003c011e8..000000000 --- a/src/extensions/mcp-adapter/context-providers.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent" -import type { ToolMetadata } from "./types.js" - -type ContextProvider = { - matchName: RegExp - resolve: (ctx: Pick) => string | undefined -} - -export const PROJECT_PATH_REGEX = /^(project[_-]?path|project[_-]?root|repo[_-]?root)$/i -export const CWD_REGEX = /^(cwd|working[_-]?directory)$/i - -const DEFAULT_PROVIDERS: ContextProvider[] = [ - { - matchName: PROJECT_PATH_REGEX, - resolve: (c) => c.cwd, - }, - { - matchName: CWD_REGEX, - resolve: (c) => c.cwd, - }, -] - -/** - * Merge caller-supplied args with auto-filled required params. - * Only fills required params that are missing or null. - * Never overwrites a caller-supplied value. - */ -export function fillMissingRequired( - metadata: ToolMetadata | undefined, - callerArgs: Record, - ctx: Pick, - log?: (msg: string) => void, -): Record { - const out: Record = { ...callerArgs } - if (!metadata?.inputSchema) return out - - // ToolMetadata.inputSchema is typed 'unknown' because JSON Schema shapes vary by MCP server. - const schema = metadata.inputSchema as { required?: string[] } | undefined - const required = schema?.required ?? [] - for (const key of required) { - // Skip if caller provided a value (even empty string — preserve explicit caller intent) - if (key in out) continue - - for (const p of DEFAULT_PROVIDERS) { - if (!p.matchName.test(key)) continue - const value = p.resolve(ctx) - if (value != null) { - out[key] = value - log?.(`[mcp-adapter] auto-fill: ${key} = "${value}" (provider: ${p.matchName.source})`) - break - } - } - } - return out -} diff --git a/src/extensions/mcp-adapter/direct-tool-visibility.test.ts b/src/extensions/mcp-adapter/direct-tool-visibility.test.ts deleted file mode 100644 index fa0942780..000000000 --- a/src/extensions/mcp-adapter/direct-tool-visibility.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent" -import { describe, expect, it, vi } from "vitest" -import { createDirectToolVisibility } from "./direct-tool-visibility.js" - -function makePi(toolNames: string[]): ExtensionAPI & { active: string[] } { - const tools = toolNames.map((name) => ({ name }) as ToolInfo) - const state = { - active: [...toolNames], - on: vi.fn(), - getAllTools: vi.fn(() => tools), - getActiveTools: vi.fn(() => state.active), - setActiveTools: vi.fn((names: string[]) => { - state.active = names - }), - } - return state as unknown as ExtensionAPI & { active: string[] } -} - -describe("direct MCP tool visibility", () => { - it("hides dynamic tools on input and re-exposes already registered dynamic tools", () => { - const pi = makePi(["jira_search"]) - const controller = createDirectToolVisibility(pi) - const dynamicToolNames = new Set(["jira_search"]) - - controller.hideDynamic(dynamicToolNames) - expect(pi.active).toEqual([]) - expect(dynamicToolNames.size).toBe(0) - - controller.expose(["jira_search"], { markDynamic: true, dynamicToolNames }) - expect(pi.active).toEqual(["jira_search"]) - expect([...dynamicToolNames]).toEqual(["jira_search"]) - }) - - it("does not treat permanent tools as transient when discovered through search", () => { - const pi = makePi(["jira_search"]) - const controller = createDirectToolVisibility(pi) - const dynamicToolNames = new Set() - - controller.markPermanent(["jira_search"], dynamicToolNames) - controller.expose(["jira_search"], { markDynamic: true, dynamicToolNames }) - controller.hideDynamic(dynamicToolNames) - - expect(pi.active).toEqual(["jira_search"]) - expect(dynamicToolNames.size).toBe(0) - }) - - it("promoting a previously dynamic tool to permanent releases the transient hide", () => { - const pi = makePi(["jira_search"]) - const controller = createDirectToolVisibility(pi) - const dynamicToolNames = new Set(["jira_search"]) - - controller.hideDynamic(dynamicToolNames) - expect(pi.active).toEqual([]) - - controller.expose(["jira_search"], { markDynamic: false, dynamicToolNames }) - controller.hideDynamic(dynamicToolNames) - - expect(pi.active).toEqual(["jira_search"]) - expect(dynamicToolNames.size).toBe(0) - }) -}) diff --git a/src/extensions/mcp-adapter/direct-tool-visibility.ts b/src/extensions/mcp-adapter/direct-tool-visibility.ts deleted file mode 100644 index de6de9ba1..000000000 --- a/src/extensions/mcp-adapter/direct-tool-visibility.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { createToolVisibility } from "../prompt-construction/tool-visibility.js" - -export interface DirectToolVisibilityController { - markPermanent(names: readonly string[], dynamicToolNames?: Set): void - expose(names: readonly string[], opts: { markDynamic: boolean; dynamicToolNames?: Set }): void - hideDynamic(dynamicToolNames: Set): void -} - -export function createDirectToolVisibility(pi: ExtensionAPI): DirectToolVisibilityController { - const visibility = createToolVisibility(pi) - const permanentToolNames = new Set() - - return { - markPermanent(names, dynamicToolNames) { - markPermanent(names, permanentToolNames, dynamicToolNames) - visibility.enable(names) - }, - expose(names, opts) { - if (names.length === 0) return - visibility.enable(names) - if (opts.markDynamic) { - for (const name of names) { - if (!permanentToolNames.has(name)) opts.dynamicToolNames?.add(name) - } - return - } - markPermanent(names, permanentToolNames, opts.dynamicToolNames) - }, - hideDynamic(dynamicToolNames) { - if (dynamicToolNames.size === 0) return - visibility.disable([...dynamicToolNames]) - dynamicToolNames.clear() - }, - } -} - -function markPermanent( - names: readonly string[], - permanentToolNames: Set, - dynamicToolNames: Set | undefined, -): void { - for (const name of names) { - permanentToolNames.add(name) - dynamicToolNames?.delete(name) - } -} diff --git a/src/extensions/mcp-adapter/direct-tools.ts b/src/extensions/mcp-adapter/direct-tools.ts deleted file mode 100644 index 40a5bae3e..000000000 --- a/src/extensions/mcp-adapter/direct-tools.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent" -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import { fillMissingRequired } from "./context-providers.js" -import { getFailureAgeSeconds, lazyConnect } from "./init.js" -import { authenticate, supportsOAuth } from "./mcp-auth-flow.js" -import type { MetadataCache } from "./metadata-cache.js" -import { isServerCacheValid } from "./metadata-cache.js" -import { resourceNameToToolName } from "./resource-tools.js" -import type { McpExtensionState } from "./state.js" -import { formatSchema } from "./tool-metadata.js" -import { transformMcpContent } from "./tool-registrar.js" -import type { DirectToolSpec, McpConfig, McpContent } from "./types.js" -import { formatToolName, isToolExcluded } from "./types.js" -import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.js" - -const BUILTIN_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "mcp"]) - -type DirectAutoAuthResult = { status: "skipped" } | { status: "success" } | { status: "failed"; message: string } - -async function attemptDirectAutoAuth(state: McpExtensionState, serverName: string): Promise { - if (state.config.settings?.autoAuth !== true) { - return { status: "skipped" } - } - - const definition = state.config.mcpServers[serverName] - if (!definition || !supportsOAuth(definition) || !definition.url) { - return { status: "skipped" } - } - - const grantType = - (definition.oauth && typeof definition.oauth === "object" && definition.oauth.grantType) || "authorization_code" - if (!state.ui && grantType !== "client_credentials") { - return { - status: "failed", - message: `MCP server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`, - } - } - - try { - await authenticate(serverName, definition.url, definition) - return { status: "success" } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - status: "failed", - message: `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`, - } - } -} - -export function resolveDirectTools( - config: McpConfig, - cache: MetadataCache | null, - prefix: "server" | "none" | "short", - envOverride?: string[], -): DirectToolSpec[] { - const specs: DirectToolSpec[] = [] - if (!cache) return specs - - const seenNames = new Set() - - const envServers = new Set() - const envTools = new Map>() - if (envOverride) { - for (let item of envOverride) { - item = item.replace(/\/+$/, "") - if (item.includes("/")) { - const [server, tool] = item.split("/", 2) - if (server && tool) { - if (!envTools.has(server)) envTools.set(server, new Set()) - envTools.get(server)?.add(tool) - } else if (server) { - envServers.add(server) - } - } else if (item) { - envServers.add(item) - } - } - } - - const globalDirect = config.settings?.directTools - - for (const [serverName, definition] of Object.entries(config.mcpServers)) { - const serverCache = cache.servers[serverName] - if (!serverCache || !isServerCacheValid(serverCache, definition)) continue - - let toolFilter: true | string[] | false = false - - if (envOverride) { - if (envServers.has(serverName)) { - toolFilter = true - } else if (envTools.has(serverName)) { - // biome-ignore lint/style/noNonNullAssertion: asserted above - toolFilter = [...envTools.get(serverName)!] - } - } else { - if (definition.directTools !== undefined) { - toolFilter = definition.directTools - } else if (globalDirect) { - toolFilter = globalDirect - } - } - - if (!toolFilter) continue - - for (const tool of serverCache.tools ?? []) { - if (toolFilter !== true && !toolFilter.includes(tool.name)) continue - if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) continue - const prefixedName = formatToolName(tool.name, serverName, prefix) - if (BUILTIN_NAMES.has(prefixedName)) { - console.warn(`MCP: skipping direct tool "${prefixedName}" (collides with builtin)`) - continue - } - if (seenNames.has(prefixedName)) { - console.warn(`MCP: skipping duplicate direct tool "${prefixedName}" from "${serverName}"`) - continue - } - seenNames.add(prefixedName) - specs.push({ - serverName, - originalName: tool.name, - prefixedName, - description: tool.description ?? "", - inputSchema: tool.inputSchema, - uiResourceUri: tool.uiResourceUri, - uiStreamMode: tool.uiStreamMode, - }) - } - - if (definition.exposeResources !== false) { - for (const resource of serverCache.resources ?? []) { - const baseName = `get_${resourceNameToToolName(resource.name)}` - if (toolFilter !== true && !toolFilter.includes(baseName)) continue - if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) continue - const prefixedName = formatToolName(baseName, serverName, prefix) - if (BUILTIN_NAMES.has(prefixedName)) { - console.warn(`MCP: skipping direct resource tool "${prefixedName}" (collides with builtin)`) - continue - } - if (seenNames.has(prefixedName)) { - console.warn(`MCP: skipping duplicate direct resource tool "${prefixedName}" from "${serverName}"`) - continue - } - seenNames.add(prefixedName) - specs.push({ - serverName, - originalName: baseName, - prefixedName, - description: resource.description ?? `Read resource: ${resource.uri}`, - resourceUri: resource.uri, - }) - } - } - } - - return specs -} - -export function getMissingConfiguredDirectToolServers(config: McpConfig, cache: MetadataCache | null): string[] { - const missing: string[] = [] - const globalDirect = config.settings?.directTools - - for (const [serverName, definition] of Object.entries(config.mcpServers)) { - const hasDirectTools = definition.directTools !== undefined ? !!definition.directTools : !!globalDirect - - if (!hasDirectTools) continue - - const serverCache = cache?.servers?.[serverName] - if (!serverCache || !isServerCacheValid(serverCache, definition)) { - missing.push(serverName) - } - } - - return missing -} - -export function buildProxyDescription( - config: McpConfig, - cache: MetadataCache | null, - directSpecs: DirectToolSpec[], -): string { - const prefix = config.settings?.toolPrefix ?? "server" - let desc = `MCP gateway - connect to MCP servers and call their tools.\n` - - const directByServer = new Map() - for (const spec of directSpecs) { - directByServer.set(spec.serverName, (directByServer.get(spec.serverName) ?? 0) + 1) - } - if (directByServer.size > 0) { - const parts = [...directByServer.entries()].map(([server, count]) => `${server} (${count})`) - desc += `\nDirect tools available (call as normal tools): ${parts.join(", ")}\n` - } - - const serverSummaries: string[] = [] - for (const serverName of Object.keys(config.mcpServers)) { - const entry = cache?.servers?.[serverName] - const definition = config.mcpServers[serverName] - const toolCount = (entry?.tools ?? []).filter( - (tool) => !isToolExcluded(tool.name, serverName, prefix, definition.excludeTools), - ).length - const resourceCount = - definition?.exposeResources !== false - ? (entry?.resources ?? []).filter((resource) => { - const baseName = `get_${resourceNameToToolName(resource.name)}` - return !isToolExcluded(baseName, serverName, prefix, definition.excludeTools) - }).length - : 0 - const totalItems = toolCount + resourceCount - if (totalItems === 0) continue - const directCount = directByServer.get(serverName) ?? 0 - const proxyCount = totalItems - directCount - if (proxyCount > 0) { - serverSummaries.push(`${serverName} (${proxyCount} tools)`) - } - } - - if (serverSummaries.length > 0) { - desc += `\nServers: ${serverSummaries.join(", ")}\n` - } - - desc += `\nUsage:\n` - desc += ` mcp({ search: "query" }) → ALWAYS START HERE. Search tools by name/description. Injects matched tool schemas into context so you can call them directly.\n` - desc += ` mcp({ describe: "tool_name" }) → Get full schema for a specific tool. Use when you know the tool name but need its parameters.\n` - desc += ` mcp({ tool: "name", args: '{"key": "value"}' }) → Call a tool by proxy (args is JSON string). Prefer calling injected tools directly after search/describe.\n` - desc += ` mcp({ connect: "server-name" }) → Connect to a server and refresh metadata\n` - desc += ` mcp({ action: "ui-messages" }) → Retrieve accumulated messages from completed UI sessions\n` - desc += `\nWorkflow: search → schemas injected → call tool directly (do NOT guess parameters without searching first)` - - return desc -} - -type DirectToolExecute = ToolDefinition["execute"] - -export function createDirectToolExecutor( - getState: () => McpExtensionState | null, - getInitPromise: () => Promise | null, - spec: DirectToolSpec, - ctx?: Pick, -): DirectToolExecute { - return async function execute(_toolCallId, params) { - let state = getState() - const initPromise = getInitPromise() - - if (!state && initPromise) { - try { - state = await initPromise - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }], - details: { error: "init_failed", message }, - } - } - } - if (!state) { - return { - content: [{ type: "text" as const, text: "MCP not initialized" }], - details: { error: "not_initialized" }, - } - } - - let connected = await lazyConnect(state, spec.serverName) - let autoAuthAttempted = false - - if (!connected && state.manager.getConnection(spec.serverName)?.status === "needs-auth") { - autoAuthAttempted = true - const autoAuth = await attemptDirectAutoAuth(state, spec.serverName) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { error: "auth_required", server: spec.serverName, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(spec.serverName) - state.failureTracker.delete(spec.serverName) - connected = await lazyConnect(state, spec.serverName) - } - } - - if (!connected) { - const authConnection = state.manager.getConnection(spec.serverName) - if (authConnection?.status === "needs-auth") { - const message = `MCP server "${spec.serverName}" requires OAuth authentication. Run /mcp-auth ${spec.serverName} first.` - return { - content: [{ type: "text" as const, text: message }], - details: { error: "auth_required", server: spec.serverName, message, autoAuthAttempted }, - } - } - const failedAgo = getFailureAgeSeconds(state, spec.serverName) - return { - content: [ - { - type: "text" as const, - text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}`, - }, - ], - details: { error: "server_unavailable", server: spec.serverName }, - } - } - - const connection = state.manager.getConnection(spec.serverName) - if (connection?.status !== "connected") { - return { - content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not connected` }], - details: { error: "not_connected", server: spec.serverName }, - } - } - - const mergedArgs = fillMissingRequired( - spec.metadata, - (params ?? {}) as Record, - ctx ?? { cwd: process.cwd() }, - (msg) => console.debug(msg), - ) - - let uiSession: UiSessionRuntime | null = null - - try { - state.manager.touch(spec.serverName) - state.manager.incrementInFlight(spec.serverName) - - if (spec.resourceUri) { - const result = await connection.client.readResource({ uri: spec.resourceUri }) - const content = (result.contents ?? []).map((c) => ({ - type: "text" as const, - text: - "text" in c - ? c.text - : "blob" in c - ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` - : JSON.stringify(c), - })) - return { - content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], - details: { server: spec.serverName, resourceUri: spec.resourceUri }, - } - } - - const hasUi = !!spec.uiResourceUri - uiSession = hasUi - ? await maybeStartUiSession(state, { - serverName: spec.serverName, - toolName: spec.originalName, - toolArgs: mergedArgs, - // biome-ignore lint/style/noNonNullAssertion: asserted above - uiResourceUri: spec.uiResourceUri!, - streamMode: spec.uiStreamMode, - }) - : null - - const result = await connection.client.callTool({ - name: spec.originalName, - arguments: mergedArgs, - _meta: uiSession?.requestMeta, - }) - uiSession?.sendToolResult(result as unknown as CallToolResult) - - const mcpContent = (result.content ?? []) as McpContent[] - const content = transformMcpContent(mcpContent) - - if (result.isError) { - let errorText = - content - .filter((c) => c.type === "text") - .map((c) => (c as { text: string }).text) - .join("\n") || "Tool execution failed" - if (spec.inputSchema) { - errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` - } - return { - content: [{ type: "text" as const, text: `Error: ${errorText}` }], - details: { error: "tool_error", server: spec.serverName }, - } - } - - const resultText = - content - .filter((c) => c.type === "text") - .map((c) => (c as { text: string }).text) - .join("\n") || "(empty result)" - if (hasUi) { - const uiMessage = uiSession?.reused - ? "Updated the open UI." - : "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it." - return { - content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }], - details: { server: spec.serverName, tool: spec.originalName, uiOpen: true }, - } - } - - return { - content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }], - details: { server: spec.serverName, tool: spec.originalName }, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - uiSession?.sendToolCancelled(message) - let errorText = `Failed to call tool: ${message}` - if (spec.inputSchema) { - errorText += `\n\nExpected parameters:\n${formatSchema(spec.inputSchema)}` - } - return { - content: [{ type: "text" as const, text: errorText }], - details: { error: "call_failed", server: spec.serverName }, - } - } finally { - if (uiSession?.reused) { - uiSession.close() - } - state.manager.decrementInFlight(spec.serverName) - state.manager.touch(spec.serverName) - } - } -} diff --git a/src/extensions/mcp-adapter/errors.ts b/src/extensions/mcp-adapter/errors.ts deleted file mode 100644 index 7f1f6fc54..000000000 --- a/src/extensions/mcp-adapter/errors.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Custom error types for MCP UI operations. - * Provides structured errors with context and recovery hints. - */ - -export interface McpUiErrorContext { - server?: string - tool?: string - uri?: string - session?: string - [key: string]: unknown -} - -/** - * Base error class for MCP UI errors. - */ -export class McpUiError extends Error { - readonly code: string - readonly context: McpUiErrorContext - readonly recoveryHint?: string - readonly cause?: Error - - constructor( - message: string, - options: { - code: string - context?: McpUiErrorContext - recoveryHint?: string - cause?: Error - }, - ) { - super(message) - this.name = "McpUiError" - this.code = options.code - this.context = options.context ?? {} - this.recoveryHint = options.recoveryHint - this.cause = options.cause - - // Maintain proper stack trace - if (Error.captureStackTrace) { - Error.captureStackTrace(this, this.constructor) - } - } - - toJSON(): Record { - return { - name: this.name, - code: this.code, - message: this.message, - context: this.context, - recoveryHint: this.recoveryHint, - stack: this.stack, - } - } -} - -/** - * Error fetching a UI resource from the MCP server. - */ -export class ResourceFetchError extends McpUiError { - constructor(uri: string, reason: string, options?: { server?: string; cause?: Error }) { - super(`Failed to fetch UI resource "${uri}": ${reason}`, { - code: "RESOURCE_FETCH_ERROR", - context: { uri, server: options?.server }, - recoveryHint: "Check that the MCP server is connected and the resource URI is valid.", - cause: options?.cause, - }) - this.name = "ResourceFetchError" - } -} - -/** - * Error parsing or validating UI resource content. - */ -export class ResourceParseError extends McpUiError { - constructor(uri: string, reason: string, options?: { server?: string; mimeType?: string }) { - super(`Invalid UI resource "${uri}": ${reason}`, { - code: "RESOURCE_PARSE_ERROR", - context: { uri, server: options?.server, mimeType: options?.mimeType }, - recoveryHint: "Ensure the resource returns valid HTML with the correct MIME type.", - }) - this.name = "ResourceParseError" - } -} - -/** - * Error connecting to the AppBridge. - */ -export class BridgeConnectionError extends McpUiError { - constructor(reason: string, options?: { session?: string; cause?: Error }) { - super(`AppBridge connection failed: ${reason}`, { - code: "BRIDGE_CONNECTION_ERROR", - context: { session: options?.session }, - recoveryHint: "Check browser console for detailed errors. The iframe may have failed to load.", - cause: options?.cause, - }) - this.name = "BridgeConnectionError" - } -} - -/** - * Error related to user consent for tool calls. - */ -export class ConsentError extends McpUiError { - readonly denied: boolean - - constructor(server: string, options: { denied?: boolean; requiresApproval?: boolean }) { - const message = options.denied - ? `Tool calls for "${server}" were denied for this session` - : `Tool call approval required for "${server}"` - - super(message, { - code: options.denied ? "CONSENT_DENIED" : "CONSENT_REQUIRED", - context: { server }, - recoveryHint: options.denied - ? "The user denied tool access. Start a new session to try again." - : "Prompt the user for consent before calling tools.", - }) - this.name = "ConsentError" - this.denied = options.denied ?? false - } -} - -/** - * Error with UI server session management. - */ -export class SessionError extends McpUiError { - constructor(reason: string, options?: { session?: string; cause?: Error }) { - super(`Session error: ${reason}`, { - code: "SESSION_ERROR", - context: { session: options?.session }, - recoveryHint: "The session may have expired or been closed. Try opening the UI again.", - cause: options?.cause, - }) - this.name = "SessionError" - } -} - -/** - * Error starting or operating the UI server. - */ -export class ServerError extends McpUiError { - constructor(reason: string, options?: { port?: number; cause?: Error }) { - super(`UI server error: ${reason}`, { - code: "SERVER_ERROR", - context: { port: options?.port }, - recoveryHint: "Check if the port is available. Another process may be using it.", - cause: options?.cause, - }) - this.name = "ServerError" - } -} - -/** - * Error communicating with the MCP server. - */ -export class McpServerError extends McpUiError { - constructor(server: string, reason: string, options?: { tool?: string; cause?: Error }) { - super(`MCP server "${server}" error: ${reason}`, { - code: "MCP_SERVER_ERROR", - context: { server, tool: options?.tool }, - recoveryHint: "Check that the MCP server is running and responsive.", - cause: options?.cause, - }) - this.name = "McpServerError" - } -} - -/** - * Wrap an unknown error into an McpUiError. - */ -export function wrapError(error: unknown, context?: McpUiErrorContext): McpUiError { - if (error instanceof McpUiError) { - // Merge contexts - return new McpUiError(error.message, { - code: error.code, - context: { ...error.context, ...context }, - recoveryHint: error.recoveryHint, - cause: error.cause, - }) - } - - const cause = error instanceof Error ? error : undefined - const message = error instanceof Error ? error.message : String(error) - - return new McpUiError(message, { - code: "UNKNOWN_ERROR", - context, - cause, - }) -} - -/** - * Check if an error is a specific MCP UI error type. - */ -export function isErrorCode(error: unknown, code: string): boolean { - return error instanceof McpUiError && error.code === code -} diff --git a/src/extensions/mcp-adapter/glimpse-ui.ts b/src/extensions/mcp-adapter/glimpse-ui.ts deleted file mode 100644 index 1e49fe08b..000000000 --- a/src/extensions/mcp-adapter/glimpse-ui.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { execFileSync } from "node:child_process" -import { existsSync } from "node:fs" -import { createRequire } from "node:module" -import { platform } from "node:os" -import { dirname, join } from "node:path" - -let glimpseAvailable: boolean | null = null -let resolvedBinaryPath: string | null = null - -export function isGlimpseAvailable(): boolean { - if (glimpseAvailable !== null) return glimpseAvailable - - if (platform() !== "darwin") { - glimpseAvailable = false - return false - } - - resolvedBinaryPath = getGlimpseBinaryPath() - glimpseAvailable = resolvedBinaryPath !== null - return glimpseAvailable -} - -function getGlimpseBinaryPath(): string | null { - if (process.env.GLIMPSE_BINARY && existsSync(process.env.GLIMPSE_BINARY)) { - return process.env.GLIMPSE_BINARY - } - - // Local node_modules - try { - const require = createRequire(import.meta.url) - const glimpseuiPath = require.resolve("glimpseui") - const binaryPath = join(dirname(glimpseuiPath), "glimpse") - if (existsSync(binaryPath)) return binaryPath - } catch {} - - // Global npm install - try { - const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf-8" }).trim() - const binaryPath = join(globalRoot, "glimpseui", "src", "glimpse") - if (existsSync(binaryPath)) return binaryPath - } catch {} - - return null -} - -export async function openGlimpseWindow( - html: string, - options: { - title: string - width?: number - height?: number - onClosed: () => void - }, -) { - const modulePath = resolvedBinaryPath ? join(dirname(resolvedBinaryPath), "glimpse.mjs") : "glimpseui" - const glimpse = await import(modulePath) - - let active = true - const win = glimpse.open(html, { - width: options.width ?? 900, - height: options.height ?? 700, - title: options.title, - }) - - win.on("closed", () => { - if (!active) return - active = false - options.onClosed() - }) - - return { - close: () => { - if (!active) return - active = false - win.close() - }, - } -} diff --git a/src/extensions/mcp-adapter/host-html-template.ts b/src/extensions/mcp-adapter/host-html-template.ts deleted file mode 100644 index 4264cc1f4..000000000 --- a/src/extensions/mcp-adapter/host-html-template.ts +++ /dev/null @@ -1,423 +0,0 @@ -import type { UiHostContext, UiResourceContent, UiResourceCsp } from "./types.js" - -// Use locally bundled AppBridge to avoid CDN Zod bundling issues -const DEFAULT_APP_BRIDGE_MODULE_URL = "/app-bridge.bundle.js" - -export interface HostHtmlTemplateInput { - sessionToken: string - serverName: string - toolName: string - toolArgs: Record - resource: UiResourceContent - allowAttribute: string - requireToolConsent: boolean - cacheToolConsent: boolean - hostContext?: UiHostContext - appBridgeModuleUrl?: string -} - -export function buildHostHtmlTemplate(input: HostHtmlTemplateInput): string { - const cspContent = buildCspMetaContent(input.resource.meta.csp) - const resourceHtml = applyCspMeta(input.resource.html, cspContent) - const hostContext = input.hostContext ?? {} - - const sessionToken = safeInlineJSON(input.sessionToken) - const toolArgs = safeInlineJSON(input.toolArgs) - const _uiHtml = safeInlineJSON(resourceHtml) - const serverName = safeInlineJSON(input.serverName) - const toolName = safeInlineJSON(input.toolName) - const hostContextJson = safeInlineJSON(hostContext) - const allowAttribute = safeInlineJSON(input.allowAttribute) - const requireToolConsent = safeInlineJSON(input.requireToolConsent) - const cacheToolConsent = safeInlineJSON(input.cacheToolConsent) - const moduleUrl = safeInlineJSON(input.appBridgeModuleUrl ?? DEFAULT_APP_BRIDGE_MODULE_URL) - - return ` - - - - - MCP UI - ${escapeHtml(input.serverName)} / ${escapeHtml(input.toolName)} - - - -
-
- MCP · - - Sandboxed -
-
- Loading UI... - - -
-
-
- -
-
-
-

UI Error

-

-
-
- - -` -} - -export function buildCspMetaContent(csp: UiResourceCsp | undefined): string | undefined { - if (!csp) return undefined - - const directives: string[] = [] - directives.push("default-src 'none'") - - const scriptSrc = toDirective("script-src", csp.scriptDomains) - const styleSrc = toDirective("style-src", csp.styleDomains) - const fontSrc = toDirective("font-src", csp.fontDomains) - const imgSrc = toDirective("img-src", csp.imgDomains) - const mediaSrc = toDirective("media-src", csp.mediaDomains) - const connectSrc = toDirective("connect-src", csp.connectDomains) - const frameSrc = toDirective("frame-src", csp.frameDomains) - const workerSrc = toDirective("worker-src", csp.workerDomains) - const baseUri = toDirective("base-uri", csp.baseUriDomains) - - if (scriptSrc) directives.push(scriptSrc) - if (styleSrc) directives.push(styleSrc) - if (fontSrc) directives.push(fontSrc) - if (imgSrc) directives.push(imgSrc) - if (mediaSrc) directives.push(mediaSrc) - if (connectSrc) directives.push(connectSrc) - if (frameSrc) directives.push(frameSrc) - if (workerSrc) directives.push(workerSrc) - if (baseUri) directives.push(baseUri) - - return directives.join("; ") -} - -function toDirective(name: string, domains: string[] | undefined): string | null { - if (!domains || domains.length === 0) return null - return `${name} ${domains.join(" ")}` -} - -export function applyCspMeta(html: string, cspContent: string | undefined): string { - if (!cspContent) return html - if (/http-equiv=["']Content-Security-Policy["']/i.test(html)) return html - const metaTag = `` - if (/]*>/i.test(html)) { - return html.replace(/]*>/i, (match) => `${match}\n${metaTag}`) - } - return `${metaTag}\n${html}` -} - -function safeInlineJSON(value: unknown): string { - return JSON.stringify(value) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026") - .replace(/\u2028/g, "\\u2028") - .replace(/\u2029/g, "\\u2029") -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") -} - -function escapeHtmlAttribute(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") -} diff --git a/src/extensions/mcp-adapter/index.test.ts b/src/extensions/mcp-adapter/index.test.ts deleted file mode 100644 index cd6f54ade..000000000 --- a/src/extensions/mcp-adapter/index.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import type { ExtensionAPI, ToolInfo } from "@earendil-works/pi-coding-agent" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { buildSystemPrompt, type EnvironmentInfo } from "../prompt-construction/system-prompt.js" -import { toolNamesFromSection } from "../prompt-construction/test-utils.js" -import mcpAdapter from "./index.js" -import { executeCall, executeDescribe, executeSearch } from "./proxy-modes.js" -import type { McpExtensionState } from "./state.js" -import type { DirectToolSpec, ToolMetadata } from "./types.js" - -// Gate tests need control over the REGISTERED -// proxy surface, which is derived from loadMcpConfig() at factory time. -// Mock it so the gate is deterministic and does not read (or purge) the -// developer machine's ambient mcp.json / mcp-cache.json. -const mcpConfigState = vi.hoisted(() => ({ - config: { - mcpServers: {} as Record, - settings: undefined as { disableProxyTool?: boolean } | undefined, - }, -})) -vi.mock("./config.js", async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - loadMcpConfig: () => ({ config: mcpConfigState.config, warnings: [] }), - } -}) -vi.mock("./metadata-cache.js", async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - // No ambient cache: prevents purgeStaleEntries from deleting and - // overwriteMetadataCache from rewriting the real user cache file. - loadMetadataCache: () => undefined, - overwriteMetadataCache: () => {}, - flushMetadataCache: () => {}, - } -}) - -const testEnv: EnvironmentInfo = { - os: "Linux", - rawPlatform: "linux", - cpuArchitecture: "x64", - shell: "/bin/bash", - osVersion: "#1 SMP PREEMPT_DYNAMIC Test", - username: "testuser", - homeDir: "/home/testuser", - cwd: "/home/testuser/project", - documentsDir: "/home/testuser/project/.kimchi/docs", - localDate: "2026-01-01", - isGitRepo: false, -} - -type Handler = (event: unknown, ctx: unknown) => unknown - -const TEST_SESSION_ID = "test-session" - -function makePi(): ExtensionAPI & { fireShutdown: () => Promise } { - const handlers = new Map() - const tools: ToolInfo[] = [] - let activeTools: string[] = [] - const sessionStartCtx = { sessionManager: { getSessionId: () => TEST_SESSION_ID } } - const pi = { - registerFlag: () => {}, - registerCommand: () => {}, - registerTool: (tool: ToolInfo) => { - tools.push(tool) - activeTools.push(tool.name) - }, - on: (event: string, handler: Handler) => { - const list = handlers.get(event) ?? [] - list.push(handler) - handlers.set(event, list) - // Fire session_start synchronously on registration so sessionIdByPi - // is populated before any render call (mirrors pi-mono behavior). - if (event === "session_start") handler({}, sessionStartCtx) - }, - getAllTools: () => tools, - getActiveTools: () => activeTools, - setActiveTools: (toolNames: string[]) => { - activeTools = toolNames - }, - getFlag: () => undefined, - fireShutdown: async () => { - for (const handler of handlers.get("session_shutdown") ?? []) { - await handler({}, {}) - } - }, - } - return pi as unknown as ExtensionAPI & { fireShutdown: () => Promise } -} - -beforeEach(() => { - // Default to one fake server: the registration gate requires at - // least one configured MCP server to register the proxy tool. Gate-off - // tests explicitly empty this map. - mcpConfigState.config.mcpServers = { "test-server": { command: "definitely-not-a-real-kimchi-test-binary" } } -}) - -afterEach(() => { - vi.unstubAllEnvs() - mcpConfigState.config.mcpServers = {} -}) - -// --------------------------------------------------------------------------- -// Helpers for inject-path tests -// --------------------------------------------------------------------------- - -function makeMetadata(rawName: string, serverName: string, prefix: "server" | "none" | "short"): ToolMetadata { - // Mirrors what buildToolMetadata in tool-metadata.ts produces - const p = - prefix === "none" - ? "" - : prefix === "short" - ? serverName.replace(/-?mcp$/i, "").replace(/-/g, "_") || "mcp" - : serverName.replace(/-/g, "_") - const prefixedName = p ? `${p}_${rawName}` : rawName - return { - name: prefixedName, - originalName: rawName, - description: "test tool", - inputSchema: { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }, - } -} - -function makeState(meta: ToolMetadata, serverName: string): McpExtensionState { - return { - manager: {} as McpExtensionState["manager"], - lifecycle: {} as McpExtensionState["lifecycle"], - toolMetadata: new Map([[serverName, [meta]]]), - config: { mcpServers: { [serverName]: {} as McpExtensionState["config"]["mcpServers"][string] } }, - failureTracker: new Map(), - uiResourceHandler: {} as McpExtensionState["uiResourceHandler"], - consentManager: {} as McpExtensionState["consentManager"], - uiServer: null, - completedUiSessions: [], - openBrowser: async () => {}, - dynamicToolNames: new Set(), - } as unknown as McpExtensionState -} - -describe("mcp proxy registration gate", () => { - it("registers the proxy tool when at least one MCP server is configured", async () => { - vi.stubEnv("MCP_DIRECT_TOOLS", "__none__") - const pi = makePi() - mcpAdapter(pi) - try { - expect(pi.getAllTools().map((t) => t.name)).toContain("mcp") - } finally { - await pi.fireShutdown() - } - }) - - it("skips registering the proxy tool when zero MCP servers are configured", async () => { - vi.stubEnv("MCP_DIRECT_TOOLS", "__none__") - mcpConfigState.config.mcpServers = {} - const pi = makePi() - mcpAdapter(pi) - try { - expect(pi.getAllTools().map((t) => t.name)).not.toContain("mcp") - } finally { - await pi.fireShutdown() - } - }) - - it("still skips the proxy when disableProxyTool is false but no servers exist", async () => { - vi.stubEnv("MCP_DIRECT_TOOLS", "__none__") - mcpConfigState.config.mcpServers = {} - mcpConfigState.config.settings = { disableProxyTool: false } - try { - const pi = makePi() - mcpAdapter(pi) - try { - expect(pi.getAllTools().map((t) => t.name)).not.toContain("mcp") - } finally { - await pi.fireShutdown() - } - } finally { - delete mcpConfigState.config.settings - } - }) -}) - -describe("mcp adapter system prompt block", () => { - it("does not inject a dedicated MCP discovery block (consolidated into core ## Tool Selection)", async () => { - vi.stubEnv("MCP_DIRECT_TOOLS", "__none__") - const pi = makePi() - mcpAdapter(pi) - - try { - const result = buildSystemPrompt({ - tools: pi.getAllTools(), - env: testEnv, - mode: "orchestrator", - sessionId: TEST_SESSION_ID, - }) - - // The MCP discovery guidance is now part of the consolidated - // `## Tool Selection` core section, not injected by the - // adapter. The adapter must not duplicate it. - expect(result).not.toContain("## Tool and MCP Discovery") - // Consolidated core section must still cover the MCP guidance. - expect(result).toContain("## Tool Selection") - expect(result).toContain("mcp({ search") - expect(toolNamesFromSection(result)).toContain("mcp") - } finally { - await pi.fireShutdown() - } - }) -}) - -describe("proxy native tool boundaries", () => { - it("labels active native tools as direct-only search results and does not inject them as MCP tools", () => { - const state = makeState(makeMetadata("pal_chat", "pal", "server"), "pal") - const onInject = vi.fn() - - const result = executeSearch( - state, - "propose_ferment_scoping", - undefined, - undefined, - true, - () => - [ - { - name: "propose_ferment_scoping", - description: "draft ferment scope", - parameters: {}, - }, - ] as ToolInfo[], - 5, - undefined, - onInject, - ) - - const block = result.content[0] - const text = block.type === "text" ? block.text : "" - expect(text).toContain("[native tool] propose_ferment_scoping") - expect(text).toContain("do not call it through mcp") - expect(onInject).not.toHaveBeenCalled() - expect(result.details).toMatchObject({ - matches: [{ server: "native", tool: "propose_ferment_scoping", dispatch: "direct" }], - }) - }) - - it("explains that native tools are not callable through mcp({ tool })", async () => { - const state = makeState(makeMetadata("pal_chat", "pal", "server"), "pal") - - const result = await executeCall( - state, - "propose_ferment_scoping", - undefined, - undefined, - undefined, - undefined, - () => ({ - tool: { name: "propose_ferment_scoping", description: "draft ferment scope", parameters: {} } as ToolInfo, - active: false, - }), - ) - - const block = result.content[0] - const text = block.type === "text" ? block.text : "" - expect(text).toContain('Tool "propose_ferment_scoping" is a native agent tool') - expect(text).toContain("not active in the current context") - expect(result.details).toMatchObject({ error: "native_tool_not_mcp", active: false }) - }) - - it("explains that native tools are described by the native tool surface, not MCP describe", () => { - const state = makeState(makeMetadata("pal_chat", "pal", "server"), "pal") - - const result = executeDescribe(state, "propose_ferment_scoping", undefined, () => ({ - tool: { name: "propose_ferment_scoping", description: "draft ferment scope", parameters: {} } as ToolInfo, - active: true, - })) - - const block = result.content[0] - const text = block.type === "text" ? block.text : "" - expect(text).toContain('Tool "propose_ferment_scoping" is a native agent tool') - expect(text).toContain("Call it directly as propose_ferment_scoping") - expect(result.details).toMatchObject({ error: "native_tool_not_mcp", active: true }) - }) -}) - -// --------------------------------------------------------------------------- -// inject-path: spec correctness for executeSearch / executeDescribe -// --------------------------------------------------------------------------- - -describe.each(["none", "server", "short"] as const)("inject-path (toolPrefix=%s)", (prefix) => { - const SERVER = "pal" - const RAW_NAME = "pal_chat" - - it("executeSearch: spec.originalName is raw, spec.prefixedName matches metadata.name", () => { - const meta = makeMetadata(RAW_NAME, SERVER, prefix) - const state = makeState(meta, SERVER) - const capturedSpecs: DirectToolSpec[] = [] - - executeSearch(state, "chat", undefined, undefined, undefined, undefined, 5, undefined, (specs) => { - capturedSpecs.push(...specs) - return specs.map((s) => s.prefixedName) - }) - - expect(capturedSpecs).toHaveLength(1) - expect(capturedSpecs[0].originalName).toBe(RAW_NAME) - expect(capturedSpecs[0].prefixedName).toBe(meta.name) - }) - - it("executeSearch: display name and injected name are the same string", () => { - const meta = makeMetadata(RAW_NAME, SERVER, prefix) - const state = makeState(meta, SERVER) - let injectedNames: string[] = [] - - const result = executeSearch(state, "chat", undefined, undefined, undefined, undefined, 5, undefined, (specs) => { - injectedNames = specs.map((s) => s.prefixedName) - return injectedNames - }) - - const block = result.content[0] - const text = block.type === "text" ? block.text : "" - expect(injectedNames).toHaveLength(1) - // The displayed name (metadata.name) appears in the output body - expect(text).toContain(meta.name) - // The injected name suffix references the exact same name - expect(text).toContain(injectedNames[0]) - expect(injectedNames[0]).toBe(meta.name) - }) - - it("executeDescribe: spec.originalName is raw, spec.prefixedName matches metadata.name", () => { - const meta = makeMetadata(RAW_NAME, SERVER, prefix) - const state = makeState(meta, SERVER) - const capturedSpecs: DirectToolSpec[] = [] - - // describe accepts either the prefixed or raw name via findToolByName - executeDescribe(state, meta.name, (specs) => { - capturedSpecs.push(...specs) - return specs.map((s) => s.prefixedName) - }) - - expect(capturedSpecs).toHaveLength(1) - expect(capturedSpecs[0].originalName).toBe(RAW_NAME) - expect(capturedSpecs[0].prefixedName).toBe(meta.name) - }) - - it("executeDescribe: display name and injected name are the same string", () => { - const meta = makeMetadata(RAW_NAME, SERVER, prefix) - const state = makeState(meta, SERVER) - let injectedNames: string[] = [] - - const result = executeDescribe(state, meta.name, (specs) => { - injectedNames = specs.map((s) => s.prefixedName) - return injectedNames - }) - - const block = result.content[0] - const text = block.type === "text" ? block.text : "" - expect(injectedNames).toHaveLength(1) - // Header shows metadata.name - expect(text).toContain(meta.name) - // Footer references the same name - expect(text).toContain(injectedNames[0]) - expect(injectedNames[0]).toBe(meta.name) - }) -}) diff --git a/src/extensions/mcp-adapter/index.ts b/src/extensions/mcp-adapter/index.ts deleted file mode 100644 index 6401133a8..000000000 --- a/src/extensions/mcp-adapter/index.ts +++ /dev/null @@ -1,636 +0,0 @@ -import type { ExtensionAPI, ExtensionContext, ToolInfo, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent" -import { keyHint, type Theme } from "@earendil-works/pi-coding-agent" -import { type Component, Text } from "@earendil-works/pi-tui" -import { Type } from "typebox" -import { loadConfig } from "../../config.js" -import { isToolExpanded, registerToolCall } from "../../expand-state.js" -import { registerReadOnlyToolProvider } from "../../shared/planning/read-only-tool-registry.js" -import { reapplyCurrentProfile } from "../../shared/planning/tool-profile-manager.js" -import { BM25_DEFAULTS, buildStrategy, buildToolEntries } from "./bm25.js" -import { authenticateServer, openMcpPanel, reconnectServers, showStatus, showTools } from "./commands.js" -import { loadMcpConfig } from "./config.js" -import { createDirectToolVisibility } from "./direct-tool-visibility.js" -import { - buildProxyDescription, - createDirectToolExecutor, - getMissingConfiguredDirectToolServers, - resolveDirectTools, -} from "./direct-tools.js" -import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.js" -import { initializeOAuth, shutdownOAuth } from "./mcp-auth-flow.js" -import { loadMetadataCache, overwriteMetadataCache, purgeStaleEntries } from "./metadata-cache.js" -import { - executeCall, - executeConnect, - executeDescribe, - executeSearch, - executeStatus, - executeUiMessages, -} from "./proxy-modes.js" -import type { McpExtensionState } from "./state.js" -import { isReadOnlyMcpTool } from "./tool-metadata.js" -import type { DirectToolSpec, ToolMetadata } from "./types.js" -import { getConfigPathFromArgv, truncateAtWord } from "./utils.js" - -export default function mcpAdapter(pi: ExtensionAPI) { - let state: McpExtensionState | null = null - let initPromise: Promise | null = null - let lifecycleGeneration = 0 - - async function shutdownState(currentState: McpExtensionState | null, reason: string): Promise { - if (!currentState) return - - if (currentState.uiServer) { - currentState.uiServer.close(reason) - currentState.uiServer = null - } - - let flushError: unknown - try { - flushMetadataCache(currentState) - } catch (error) { - flushError = error - } - - try { - await currentState.lifecycle.gracefulShutdown() - } catch (error) { - if (flushError) { - console.error("MCP: graceful shutdown failed after metadata flush error", error) - } else { - throw error - } - } - - if (flushError) { - throw flushError - } - } - - const earlyConfigPath = getConfigPathFromArgv() - const { config: earlyConfig } = loadMcpConfig(earlyConfigPath) - let earlyCache = loadMetadataCache() - - // Drop cache entries whose configHash no longer matches the configured server - // definition, or whose server has been removed from config. Otherwise stale - // entries silently block direct-tool registration on every startup. - if (earlyCache) { - const { cleaned, removed } = purgeStaleEntries(earlyCache, earlyConfig.mcpServers) - if (removed.length > 0) { - overwriteMetadataCache(cleaned) - earlyCache = cleaned - console.warn(`MCP: purged stale cache entries: ${removed.join(", ")}`) - } - } - - const prefix = earlyConfig.settings?.toolPrefix ?? "server" - - const envRaw = process.env.MCP_DIRECT_TOOLS - const directSpecs = - envRaw === "__none__" - ? [] - : resolveDirectTools( - earlyConfig, - earlyCache, - prefix, - envRaw - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean), - ) - const missingConfiguredDirectToolServers = getMissingConfiguredDirectToolServers(earlyConfig, earlyCache) - // With zero MCP servers configured - // the proxy gateway has nothing to connect to, so don't advertise `mcp` at - // all (~450 est off the surface). Static at registration: adding a server - // requires a restart to take effect (the config file is re-read on startup). - const hasAnyConfiguredServer = Object.keys(earlyConfig.mcpServers).length > 0 - const shouldRegisterProxyTool = - hasAnyConfiguredServer && - (earlyConfig.settings?.disableProxyTool !== true || - directSpecs.length === 0 || - missingConfiguredDirectToolServers.length > 0) - - // Track all registered tool names to avoid double-registration - const registeredToolNames = new Set() - const directToolVisibility = createDirectToolVisibility(pi) - - /** - * Read-only-tool provider for the planning-ferment (scoping) profile. - * - * Iterates the live `state.toolMetadata` map (keyed on server name) and - * returns the prefixed tool names (`meta.name`) for tools that qualify as - * read-only via `isReadOnlyMcpTool`. Called lazily by the shared/planning - * layer's `getReadOnlyToolNames` during `applyCore`, so it always reflects - * the current tool-metadata state — including direct tools registered after - * a cache bootstrap. Returns an empty array before MCP init completes, so - * the planning-ferment profile simply skips MCP tools during that window. - */ - const readOnlyToolProvider = (): string[] => { - if (!state) return [] - const names: string[] = [] - for (const tools of state.toolMetadata.values()) { - for (const meta of tools) { - if (isReadOnlyMcpTool(meta)) names.push(meta.name) - } - } - return names - } - registerReadOnlyToolProvider(pi, readOnlyToolProvider) - - for (const spec of directSpecs) { - const cachedServer = earlyCache?.servers?.[spec.serverName] - const cachedTool = cachedServer?.tools?.find((t) => t.name === spec.originalName) - const metadata: ToolMetadata | undefined = cachedTool - ? { - name: spec.prefixedName, - originalName: spec.originalName, - description: spec.description, - inputSchema: cachedTool.inputSchema, - uiResourceUri: cachedTool.uiResourceUri, - uiStreamMode: cachedTool.uiStreamMode, - annotations: cachedTool.annotations, - } - : undefined - pi.registerTool({ - name: spec.prefixedName, - label: `MCP: ${spec.originalName}`, - description: spec.description || "(no description)", - promptSnippet: truncateAtWord(spec.description, 100) || `MCP tool from ${spec.serverName}`, - parameters: Type.Unsafe>(spec.inputSchema || { type: "object", properties: {} }), - execute: createDirectToolExecutor( - () => state, - () => initPromise, - { ...spec, metadata }, - ), - }) - registeredToolNames.add(spec.prefixedName) - directToolVisibility.markPermanent([spec.prefixedName]) - } - - /** - * Register tool specs with the agent and expose them in the active set. - * - * `markDynamic` (default true) tags the new names in `state.dynamicToolNames` - * so the next user input clears them (used by proxy describe/search results). - * Pass `false` for tools that should persist across turns — e.g. direct tools - * registered after a successful cache bootstrap. `pi.registerTool` activates - * newly registered tools; the visibility controller releases any prior - * transient hide when a previously registered dynamic tool is injected again. - * - * When `state` is not yet ready (callback invoked from inside `initializeMcp` - * before its promise resolves), we only allow the permanent path through — - * registering dynamic tools without a state to track them in would leak them - * across turns because the `pi.on("input", …)` clear couldn't find them. - * Permanent tools (`markDynamic: false`) are safe to register early: the - * executor captures `state` lazily via the `() => state` closure and the - * tools are meant to persist anyway. - */ - function registerAndActivate( - specs: DirectToolSpec[], - opts?: { markDynamic?: boolean }, - ctx?: Pick, - ): string[] { - const markDynamic = opts?.markDynamic ?? true - if (!state && markDynamic) return [] - const newNames: string[] = [] - const alreadyRegistered: string[] = [] - for (const spec of specs) { - if (registeredToolNames.has(spec.prefixedName)) { - alreadyRegistered.push(spec.prefixedName) - continue - } - pi.registerTool({ - name: spec.prefixedName, - label: `MCP: ${spec.originalName}`, - description: spec.description || "(no description)", - parameters: Type.Unsafe>(spec.inputSchema || { type: "object", properties: {} }), - execute: createDirectToolExecutor( - () => state, - () => initPromise, - spec, - ctx, - ), - }) - registeredToolNames.add(spec.prefixedName) - newNames.push(spec.prefixedName) - } - const allInjected = [...alreadyRegistered, ...newNames] - directToolVisibility.expose(allInjected, { - markDynamic, - dynamicToolNames: state?.dynamicToolNames, - }) - // Re-snapshot the active tool profile so late-registered read-only MCP - // tools surface during planning. Without this, the cooperative-layer - // no-op guard (isSnapshotAppliedThisTurn) swallows the expose() call, - // and the snapshot was computed before the tool existed. Safe no-op when - // no profile has been applied yet (e.g. during early bootstrap). - reapplyCurrentProfile(pi) - return allInjected - } - - /** - * Register direct-tool specs produced by the cache bootstrap path - * (`init.ts` → `resolveDirectTools` after first connect). These tools - * are permanent for the session, so they must not be marked dynamic. - */ - function registerBootstrappedDirectTools(specs: DirectToolSpec[], ctx?: Pick): string[] { - return registerAndActivate(specs, { markDynamic: false }, ctx) - } - - pi.on("input", () => { - if (!state || state.dynamicToolNames.size === 0) return - directToolVisibility.hideDynamic(state.dynamicToolNames) - }) - - const getPiTools = (): ToolInfo[] => pi.getAllTools() - const getNativeToolStatus = (toolName: string): { tool: ToolInfo; active: boolean } | undefined => { - const tool = pi.getAllTools().find((candidate) => candidate.name === toolName) - if (!tool) return undefined - return { tool, active: pi.getActiveTools().includes(tool.name) } - } - - pi.registerFlag("mcp-config", { - description: "Path to MCP config file", - type: "string", - }) - - pi.on("session_start", async (_event, ctx) => { - const generation = ++lifecycleGeneration - const previousState = state - state = null - initPromise = null - - try { - await Promise.all([shutdownState(previousState, "session_restart"), shutdownOAuth()]) - } catch (error) { - console.error("MCP: failed to shut down previous session state", error) - } - - if (generation !== lifecycleGeneration) { - return - } - - await initializeOAuth().catch((err) => { - console.error("MCP OAuth initialization failed:", err) - }) - - const promise = initializeMcp(pi, ctx, registerBootstrappedDirectTools) - initPromise = promise - - promise - .then(async (nextState) => { - if (generation !== lifecycleGeneration || initPromise !== promise) { - try { - await shutdownState(nextState, "stale_session_start") - } catch (error) { - console.error("MCP: failed to clean stale session state", error) - } - return - } - - state = nextState - updateStatusBar(nextState) - initPromise = null - - // Re-snapshot the active tool profile now that state is populated. - // During planning, the initial snapshot ran before MCP init finished, - // so getReadOnlyToolNames returned [] and read-only direct tools - // (e.g. atlassian_getJiraIssue) were excluded. Re-applying the - // profile re-evaluates the read-only registry against the now- - // populated state.toolMetadata. Also picks up the `mcp` gateway if - // it was registered after the initial snapshot. - reapplyCurrentProfile(pi) - - // Build search strategy from live tool metadata - try { - const kimchiConfig = loadConfig() - const { strategy, bm25K1, bm25B, fieldWeights } = kimchiConfig.mcpSearch - const entries = buildToolEntries(nextState.toolMetadata) - state.searchStrategy = buildStrategy(entries, { strategy, k1: bm25K1, b: bm25B, fieldWeights }) - } catch { - // loadConfig throws if no API key; fall back to default strategy - const entries = buildToolEntries(nextState.toolMetadata) - state.searchStrategy = buildStrategy(entries, BM25_DEFAULTS) - } - }) - .catch((err) => { - if (generation !== lifecycleGeneration) { - return - } - if (initPromise !== promise && initPromise !== null) { - return - } - console.error("MCP initialization failed:", err) - initPromise = null - }) - }) - - pi.on("session_shutdown", async () => { - ++lifecycleGeneration - const currentState = state - state = null - initPromise = null - - try { - await Promise.all([shutdownState(currentState, "session_shutdown"), shutdownOAuth()]) - } catch (error) { - console.error("MCP: session shutdown cleanup failed", error) - } - }) - - pi.registerCommand("mcp", { - description: "Show MCP server status", - handler: async (args, ctx) => { - if (!state && initPromise) { - try { - state = await initPromise - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error") - return - } - } - if (!state) { - if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error") - return - } - - const parts = args?.trim()?.split(/\s+/) ?? [] - const subcommand = parts[0] ?? "" - const targetServer = parts[1] - - switch (subcommand) { - case "reconnect": - await reconnectServers(state, ctx, targetServer) - break - case "tools": - await showTools(state, ctx) - break - default: - if (ctx.mode === "tui") { - await openMcpPanel(state, pi, ctx, earlyConfigPath) - } else { - await showStatus(state, ctx) - } - break - } - }, - }) - - pi.registerCommand("mcp-auth", { - description: "Authenticate with an MCP server (OAuth)", - handler: async (args, ctx) => { - const serverName = args?.trim() - if (!serverName) { - if (ctx.hasUI) ctx.ui.notify("Usage: /mcp-auth ", "error") - return - } - - if (!state && initPromise) { - try { - state = await initPromise - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (ctx.hasUI) ctx.ui.notify(`MCP initialization failed: ${message}`, "error") - return - } - } - if (!state) { - if (ctx.hasUI) ctx.ui.notify("MCP not initialized", "error") - return - } - - await authenticateServer(serverName, state.config, ctx) - }, - }) - - if (shouldRegisterProxyTool) { - pi.registerTool({ - name: "mcp", - label: "MCP", - description: buildProxyDescription(earlyConfig, earlyCache, directSpecs), - promptSnippet: "MCP gateway - connect to MCP servers and call their tools", - parameters: Type.Object({ - tool: Type.Optional(Type.String({ description: "Tool name to call (e.g., 'xcodebuild_list_sims')" })), - args: Type.Optional(Type.String({ description: 'Arguments as JSON string (e.g., \'{"key": "value"}\')' })), - connect: Type.Optional( - Type.String({ description: "Server name to connect (lazy connect + metadata refresh)" }), - ), - describe: Type.Optional(Type.String({ description: "Tool name to describe (shows parameters)" })), - search: Type.Optional(Type.String({ description: "Search tools by name/description" })), - regex: Type.Optional(Type.Boolean({ description: "Treat search as regex (default: substring match)" })), - includeSchemas: Type.Optional( - Type.Boolean({ description: "Include parameter schemas in search results (default: true)" }), - ), - limit: Type.Optional(Type.Number({ description: "Max number of search results to return (default: 5)" })), - server: Type.Optional(Type.String({ description: "Filter search/describe/call to a specific server" })), - action: Type.Optional( - Type.String({ description: "Action: 'ui-messages' to retrieve prompts/intents from UI sessions" }), - ), - }), - async execute( - _toolCallId, - params: { - tool?: string - args?: string - connect?: string - describe?: string - search?: string - regex?: boolean - includeSchemas?: boolean - limit?: number - server?: string - action?: string - }, - _signal, - _onUpdate, - ctx, - ) { - let parsedArgs: Record | undefined - if (params.args) { - try { - parsedArgs = JSON.parse(params.args) - if (typeof parsedArgs !== "object" || parsedArgs === null || Array.isArray(parsedArgs)) { - const gotType = Array.isArray(parsedArgs) ? "array" : parsedArgs === null ? "null" : typeof parsedArgs - throw new Error(`Invalid args: expected a JSON object, got ${gotType}`) - } - } catch (error) { - if (error instanceof SyntaxError) { - throw new Error(`Invalid args JSON: ${error.message}`, { cause: error }) - } - throw error - } - } - - if (!state && initPromise) { - try { - state = await initPromise - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - content: [{ type: "text" as const, text: `MCP initialization failed: ${message}` }], - details: { error: "init_failed", message }, - } - } - } - if (!state) { - return { - content: [{ type: "text" as const, text: "MCP not initialized" }], - details: { error: "not_initialized" }, - } - } - - if (params.action === "ui-messages") { - return executeUiMessages(state) - } - let maxToolResultChars = 10_000 - try { - const kimchiConfig = loadConfig() - maxToolResultChars = kimchiConfig.maxToolResultChars - } catch { - // loadConfig throws when API key is missing; default is fine here - } - if (params.tool) { - return executeCall( - state, - params.tool, - parsedArgs, - params.server, - ctx, - maxToolResultChars, - getNativeToolStatus, - ) - } - if (params.connect) { - return executeConnect(state, params.connect) - } - if (params.describe) { - return executeDescribe( - state, - params.describe, - (specs) => registerAndActivate(specs, undefined, ctx), - getNativeToolStatus, - ) - } - if (params.search) { - let mcpSearchLimit = 5 - try { - const kimchiConfig = loadConfig() - mcpSearchLimit = kimchiConfig.mcpSearchLimit - } catch { - // no API key configured; default is fine - } - return executeSearch( - state, - params.search, - params.regex, - params.server, - params.includeSchemas, - getPiTools, - params.limit ?? mcpSearchLimit, - state.searchStrategy, - (specs) => registerAndActivate(specs, undefined, ctx), - ) - } - return executeStatus(state) - }, - renderCall( - args: { - tool?: string - args?: string - connect?: string - describe?: string - search?: string - limit?: number - server?: string - action?: string - }, - theme: Theme, - context: { lastComponent: Component | undefined }, - ) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0) - text.setText(formatMcpCall(args, theme)) - return text - }, - renderResult( - result: unknown, - _options: ToolRenderResultOptions, - theme: Theme, - context: { lastComponent: Component | undefined; toolCallId: string }, - ) { - const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0) - registerToolCall(context.toolCallId) - const expanded = isToolExpanded(context.toolCallId) - text.setText(formatMcpResult(result, expanded, theme)) - return text - }, - }) - } -} - -const COLLAPSED_LINES = 10 - -function formatMcpResult(result: unknown, expanded: boolean, theme: Theme): string { - const content = (result as { content?: Array<{ type: string; text?: string }> })?.content ?? [] - const textParts = content - .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string") - .map((c) => c.text) - const combined = textParts.join("\n") - if (!combined) return "" - - const lines = combined.split("\n") - const maxLines = expanded ? lines.length : COLLAPSED_LINES - const displayLines = lines.slice(0, maxLines) - const remaining = lines.length - maxLines - - let text = `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}` - if (remaining > 0) { - text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})` - } - return text -} - -function formatMcpCall( - params: { - tool?: string - args?: string - connect?: string - describe?: string - search?: string - limit?: number - server?: string - action?: string - }, - theme: Theme, -): string { - if (params.tool) { - // Parse server prefix from tool name: "grafana_prod_master_query_loki" -> server="grafana_prod_master", tool="query_loki" - // We display as-is since the full prefixed name is what the model uses - const toolDisplay = theme.fg("accent", params.tool) - let argsDisplay = "" - if (params.args) { - try { - const parsed = JSON.parse(params.args) as Record - const parts = Object.entries(parsed).map(([k, v]) => { - const val = typeof v === "string" ? v.slice(0, 60) + (v.length > 60 ? "…" : "") : String(v).slice(0, 60) - return `${theme.fg("muted", `${k}:`)} ${theme.fg("toolOutput", val)}` - }) - if (parts.length > 0) argsDisplay = `(${parts.join(", ")})` - } catch { - argsDisplay = `(${theme.fg("toolOutput", params.args.slice(0, 80))})` - } - } - return `${theme.bold("mcp")} ${toolDisplay}${argsDisplay}` - } - if (params.describe) - return `${theme.bold("mcp")} ${theme.fg("muted", "describe:")} ${theme.fg("accent", params.describe)}` - if (params.search) { - const limitSuffix = params.limit !== undefined ? theme.fg("muted", ` (limit:${params.limit})`) : "" - return `${theme.bold("mcp")} ${theme.fg("muted", "search:")} ${theme.fg("toolOutput", params.search)}${limitSuffix}` - } - if (params.connect) - return `${theme.bold("mcp")} ${theme.fg("muted", "connect:")} ${theme.fg("accent", params.connect)}` - if (params.server) return `${theme.bold("mcp")} ${theme.fg("muted", "server:")} ${theme.fg("accent", params.server)}` - if (params.action === "ui-messages") return `${theme.bold("mcp")} ${theme.fg("muted", "ui-messages")}` - return theme.bold("mcp") -} diff --git a/src/extensions/mcp-adapter/init.ts b/src/extensions/mcp-adapter/init.ts deleted file mode 100644 index 700c03d79..000000000 --- a/src/extensions/mcp-adapter/init.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { existsSync } from "node:fs" -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent" -import { consumeCallerMcpServers } from "./caller-servers.js" -import { loadMcpConfig } from "./config.js" -import { ConsentManager } from "./consent-manager.js" -import { getMissingConfiguredDirectToolServers, resolveDirectTools } from "./direct-tools.js" -import { McpLifecycleManager } from "./lifecycle.js" -import { logger } from "./logger.js" -import { - computeServerHash, - getMetadataCachePath, - isServerCacheValid, - loadMetadataCache, - overwriteMetadataCache, - purgeStaleEntries, - reconstructToolMetadata, - type ServerCacheEntry, - saveMetadataCache, - serializeResources, - serializeTools, -} from "./metadata-cache.js" -import { McpServerManager } from "./server-manager.js" -import type { McpExtensionState } from "./state.js" -import { buildToolMetadata, totalToolCount } from "./tool-metadata.js" -import type { DirectToolSpec, ServerDefinition, ToolMetadata } from "./types.js" -import { UiResourceHandler } from "./ui-resource-handler.js" -import { openUrl, parallelLimit } from "./utils.js" - -const FAILURE_BACKOFF_MS = 60 * 1000 - -export async function initializeMcp( - pi: ExtensionAPI, - ctx: ExtensionContext, - registerBootstrappedDirectTools?: (specs: DirectToolSpec[], ctx?: Pick) => string[], -): Promise { - const configPath = pi.getFlag("mcp-config") as string | undefined - const { config, warnings: configWarnings } = loadMcpConfig(configPath) - for (const warning of configWarnings) { - if (ctx.hasUI) { - ctx.ui.notify(warning, "warning") - } else { - console.warn(warning) - } - } - - const manager = new McpServerManager() - const lifecycle = new McpLifecycleManager(manager) - const toolMetadata = new Map() - const failureTracker = new Map() - const uiResourceHandler = new UiResourceHandler(manager) - const consentManager = new ConsentManager("once-per-server") - const ui = ctx.hasUI ? ctx.ui : undefined - const state: McpExtensionState = { - manager, - lifecycle, - toolMetadata, - config, - failureTracker, - uiResourceHandler, - consentManager, - uiServer: null, - completedUiSessions: [], - openBrowser: (url: string) => openUrl(pi, url, process.env.BROWSER), - ui, - sendMessage: (message, options) => pi.sendMessage(message, options), - dynamicToolNames: new Set(), - } - - // Merge caller-supplied MCP servers (from ACP session/new or session/load) - // with config-sourced servers. Caller-wins on name collision: the ACP - // client explicitly requested that server, so its definition takes - // precedence over a same-named entry in the config file. - const callerServers = consumeCallerMcpServers(ctx.sessionManager.getSessionId()) - const mergedServers: Record = { ...config.mcpServers } - for (const [name, definition] of Object.entries(callerServers)) { - if (name in mergedServers) { - logger.debug(`MCP: caller-supplied server "${name}" overrides config entry`) - } - mergedServers[name] = definition - } - // Update config.mcpServers so downstream code (status bar, tool metadata, - // purge) sees the merged set. - config.mcpServers = mergedServers - - const serverEntries = Object.entries(config.mcpServers) - if (serverEntries.length === 0) { - return state - } - - const idleSetting = typeof config.settings?.idleTimeout === "number" ? config.settings.idleTimeout : 10 - lifecycle.setGlobalIdleTimeout(idleSetting) - - const cachePath = getMetadataCachePath() - const cacheFileExists = existsSync(cachePath) - let cache = loadMetadataCache() - let bootstrapAll = false - - if (!cacheFileExists) { - bootstrapAll = true - saveMetadataCache({ version: 1, servers: {} }) - } else if (!cache) { - cache = { version: 1, servers: {} } - saveMetadataCache(cache) - } - - // Drop entries with stale configHash or no-longer-configured servers. - // Mirrors the load-time purge in index.ts so non-Pi entry points (tests, - // embedded use) get the same hygiene. Bootstrap below will repopulate - // anything we removed that's still in `config.mcpServers`. - if (cache) { - const { cleaned, removed } = purgeStaleEntries(cache, config.mcpServers) - if (removed.length > 0) { - overwriteMetadataCache(cleaned) - cache = cleaned - logger.debug(`MCP: purged stale cache entries: ${removed.join(", ")}`) - } - } - - const prefix = config.settings?.toolPrefix ?? "server" - - for (const [name, definition] of serverEntries) { - const lifecycleMode = definition.lifecycle ?? "lazy" - const idleOverride = definition.idleTimeout ?? (lifecycleMode === "eager" ? 0 : undefined) - lifecycle.registerServer(name, definition, idleOverride !== undefined ? { idleTimeout: idleOverride } : undefined) - if (lifecycleMode === "keep-alive") { - lifecycle.markKeepAlive(name, definition) - } - - if (cache?.servers?.[name] && isServerCacheValid(cache.servers[name], definition)) { - const metadata = reconstructToolMetadata(name, cache.servers[name], prefix, definition) - toolMetadata.set(name, metadata) - } - } - - const startupServers = bootstrapAll - ? serverEntries - : serverEntries.filter(([, definition]) => { - const mode = definition.lifecycle ?? "lazy" - return mode === "keep-alive" || mode === "eager" - }) - - if (ctx.hasUI && startupServers.length > 0) { - ctx.ui.setStatus("mcp", `MCP: connecting to ${startupServers.length} servers...`) - } - - const results = await parallelLimit(startupServers, 10, async ([name, definition]) => { - try { - const connection = await manager.connect(name, definition) - if (connection.status === "needs-auth") { - return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` } - } - return { name, definition, connection, error: null } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { name, definition, connection: null, error: message } - } - }) - - // Only retry transient errors that might succeed on second attempt - // (EBUSY, ECONNREFUSED, timeouts, npm lock contention, etc.) - const TRANSIENT_ERROR_CODES = ["EBUSY", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "ENOTFOUND"] - const isTransientError = (error: string): boolean => - TRANSIENT_ERROR_CODES.some((code) => error.includes(code)) || - /\btimes?\s*out\b/i.test(error) || - /\bnpm.*lock\b/i.test(error) - - const retryable = results.filter((r) => r.error && !r.error.includes("OAuth") && isTransientError(r.error)) - for (const entry of retryable) { - try { - const connection = await manager.connect(entry.name, entry.definition) - if (connection.status === "needs-auth") continue - entry.connection = connection - entry.error = null - } catch { - // keep original error - } - } - - for (const { name, definition, connection, error } of results) { - if (error || !connection) { - if (ctx.hasUI) { - ctx.ui.notify(`MCP: Failed to connect to ${name}: ${error}`, "error") - } - console.error(`MCP: Failed to connect to ${name}: ${error}`) - continue - } - - const { metadata, failedTools } = buildToolMetadata( - connection.tools, - connection.resources, - definition, - name, - prefix, - ) - toolMetadata.set(name, metadata) - updateMetadataCache(state, name) - - if (failedTools.length > 0 && ctx.hasUI) { - ctx.ui.notify(`MCP: ${name} - ${failedTools.length} tools skipped`, "warning") - } - } - - const connectedCount = results.filter((r) => r.connection).length - const failedCount = results.filter((r) => r.error).length - if (ctx.hasUI && connectedCount > 0) { - const totalTools = totalToolCount(state) - const msg = - failedCount > 0 - ? `MCP: ${connectedCount}/${startupServers.length} servers connected (${totalTools} tools)` - : `MCP: ${connectedCount} servers connected (${totalTools} tools)` - ctx.ui.notify(msg, "info") - } - - const envDirect = process.env.MCP_DIRECT_TOOLS - if (envDirect !== "__none__") { - const currentCache = loadMetadataCache() - const missingCacheServers = getMissingConfiguredDirectToolServers(config, currentCache) - - if (missingCacheServers.length > 0) { - const bootstrapResults = await parallelLimit( - missingCacheServers.filter((name) => !results.some((r) => r.name === name && r.connection)), - 10, - async (name) => { - const definition = config.mcpServers[name] - try { - const connection = await manager.connect(name, definition) - if (connection.status === "needs-auth") { - return { name, ok: false } - } - const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix) - toolMetadata.set(name, metadata) - updateMetadataCache(state, name) - return { name, ok: true } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`) - return { name, ok: false } - } - }, - ) - const bootstrapped = bootstrapResults.filter((r) => r.ok).map((r) => r.name) - if (bootstrapped.length > 0) { - // Try to register direct tools for the just-bootstrapped servers - // in the current session. Avoids the historical "restart required" - // dance — the index.ts callback registers the tools and releases any - // transient visibility hide, which exposes them to subsequent turns - // without reloading. - let injectedCount = 0 - if (registerBootstrappedDirectTools) { - const freshCache = loadMetadataCache() - const envOverride = envDirect - ?.split(",") - .map((s) => s.trim()) - .filter(Boolean) - const allSpecs = resolveDirectTools(config, freshCache, prefix, envOverride) - const newSpecs = allSpecs.filter((s) => bootstrapped.includes(s.serverName)) - if (newSpecs.length > 0) { - const injected = registerBootstrappedDirectTools(newSpecs, { cwd: ctx.cwd }) - injectedCount = injected.length - } - } - - if (ctx.hasUI) { - if (injectedCount > 0) { - ctx.ui.notify( - `MCP: ${injectedCount} direct tool(s) from ${bootstrapped.join(", ")} are now available`, - "info", - ) - } else { - ctx.ui.notify(`MCP: direct tools for ${bootstrapped.join(", ")} will be available after restart`, "info") - } - } - } - } - } - - lifecycle.setReconnectCallback((serverName) => { - updateServerMetadata(state, serverName) - updateMetadataCache(state, serverName) - state.failureTracker.delete(serverName) - updateStatusBar(state) - }) - - lifecycle.setIdleShutdownCallback((serverName) => { - const idleMinutes = getEffectiveIdleTimeoutMinutes(state, serverName) - logger.debug(`${serverName} shut down (idle ${idleMinutes}m)`) - updateStatusBar(state) - }) - - lifecycle.startHealthChecks() - - return state -} - -export function updateServerMetadata(state: McpExtensionState, serverName: string): void { - const connection = state.manager.getConnection(serverName) - if (connection?.status !== "connected") return - - const definition = state.config.mcpServers[serverName] - if (!definition) return - - const prefix = state.config.settings?.toolPrefix ?? "server" - - const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix) - state.toolMetadata.set(serverName, metadata) -} - -export function updateMetadataCache(state: McpExtensionState, serverName: string): void { - const connection = state.manager.getConnection(serverName) - if (connection?.status !== "connected") return - - const definition = state.config.mcpServers[serverName] - if (!definition) return - - const configHash = computeServerHash(definition) - const existing = loadMetadataCache() - const existingEntry = existing?.servers?.[serverName] - - const tools = serializeTools(connection.tools) - let resources = definition.exposeResources === false ? [] : serializeResources(connection.resources) - - if ( - definition.exposeResources !== false && - resources.length === 0 && - existingEntry?.resources?.length && - existingEntry.configHash === configHash - ) { - resources = existingEntry.resources - } - - const entry: ServerCacheEntry = { - configHash, - tools, - resources, - cachedAt: Date.now(), - } - - saveMetadataCache({ version: 1, servers: { [serverName]: entry } }) -} - -export function flushMetadataCache(state: McpExtensionState): void { - for (const [name, connection] of state.manager.getAllConnections()) { - if (connection.status === "connected") { - updateMetadataCache(state, name) - } - } -} - -export function updateStatusBar(state: McpExtensionState): void { - const ui = state.ui - if (!ui) return - const total = Object.keys(state.config.mcpServers).length - if (total === 0) { - ui.setStatus("mcp", undefined) - return - } - const connectedCount = state.manager.getAllConnections().size - ui.setStatus("mcp", ui.theme.fg("accent", `MCP: ${connectedCount}/${total} servers`)) -} - -export function getFailureAgeSeconds(state: McpExtensionState, serverName: string): number | null { - const failedAt = state.failureTracker.get(serverName) - if (!failedAt) return null - const ageMs = Date.now() - failedAt - if (ageMs > FAILURE_BACKOFF_MS) return null - return Math.round(ageMs / 1000) -} - -export async function lazyConnect(state: McpExtensionState, serverName: string): Promise { - const connection = state.manager.getConnection(serverName) - if (connection?.status === "needs-auth") { - return false - } - if (connection?.status === "connected") { - updateServerMetadata(state, serverName) - return true - } - - const failedAgo = getFailureAgeSeconds(state, serverName) - if (failedAgo !== null) return false - - const definition = state.config.mcpServers[serverName] - if (!definition) return false - - try { - if (state.ui) { - state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`) - } - const newConnection = await state.manager.connect(serverName, definition) - if (newConnection.status === "needs-auth") { - return false - } - state.failureTracker.delete(serverName) - updateServerMetadata(state, serverName) - updateMetadataCache(state, serverName) - updateStatusBar(state) - return true - } catch (error) { - state.failureTracker.set(serverName, Date.now()) - const message = error instanceof Error ? error.message : String(error) - logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`) - updateStatusBar(state) - return false - } -} - -function getEffectiveIdleTimeoutMinutes(state: McpExtensionState, serverName: string): number { - const definition = state.config.mcpServers[serverName] - if (!definition) { - return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10 - } - if (typeof definition.idleTimeout === "number") return definition.idleTimeout - const mode = definition.lifecycle ?? "lazy" - if (mode === "eager") return 0 - return typeof state.config.settings?.idleTimeout === "number" ? state.config.settings.idleTimeout : 10 -} diff --git a/src/extensions/mcp-adapter/lifecycle.ts b/src/extensions/mcp-adapter/lifecycle.ts deleted file mode 100644 index d27d95d87..000000000 --- a/src/extensions/mcp-adapter/lifecycle.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { logger } from "./logger.js" -import type { McpServerManager } from "./server-manager.js" -import type { ServerDefinition } from "./types.js" - -export type ReconnectCallback = (serverName: string) => void - -export class McpLifecycleManager { - private manager: McpServerManager - private keepAliveServers = new Map() - private allServers = new Map() - private serverSettings = new Map() - private globalIdleTimeout: number = 10 * 60 * 1000 - private healthCheckInterval?: NodeJS.Timeout - private onReconnect?: ReconnectCallback - private onIdleShutdown?: (serverName: string) => void - - constructor(manager: McpServerManager) { - this.manager = manager - } - - /** - * Set callback to be invoked after a successful auto-reconnect. - * Use this to update tool metadata when a server reconnects. - */ - setReconnectCallback(callback: ReconnectCallback): void { - this.onReconnect = callback - } - - markKeepAlive(name: string, definition: ServerDefinition): void { - this.keepAliveServers.set(name, definition) - } - - registerServer(name: string, definition: ServerDefinition, settings?: { idleTimeout?: number }): void { - this.allServers.set(name, definition) - if (settings?.idleTimeout !== undefined) { - this.serverSettings.set(name, settings) - } - } - - setGlobalIdleTimeout(minutes: number): void { - this.globalIdleTimeout = minutes * 60 * 1000 - } - - setIdleShutdownCallback(callback: (serverName: string) => void): void { - this.onIdleShutdown = callback - } - - startHealthChecks(intervalMs = 30000): void { - this.healthCheckInterval = setInterval(() => { - this.checkConnections() - }, intervalMs) - this.healthCheckInterval.unref() - } - - private async checkConnections(): Promise { - for (const [name, definition] of this.keepAliveServers) { - const connection = this.manager.getConnection(name) - - if (connection?.status !== "connected") { - try { - await this.manager.connect(name, definition) - logger.debug(`Reconnected to ${name}`) - // Notify extension to update metadata - this.onReconnect?.(name) - } catch (error) { - console.error(`MCP: Failed to reconnect to ${name}:`, error) - } - } - } - - for (const [name] of this.allServers) { - if (this.keepAliveServers.has(name)) continue - const timeout = this.getIdleTimeout(name) - if (timeout > 0 && this.manager.isIdle(name, timeout)) { - await this.manager.close(name) - this.onIdleShutdown?.(name) - } - } - } - - private getIdleTimeout(name: string): number { - const perServer = this.serverSettings.get(name)?.idleTimeout - if (perServer !== undefined) return perServer * 60 * 1000 - return this.globalIdleTimeout - } - - async gracefulShutdown(): Promise { - if (this.healthCheckInterval) { - clearInterval(this.healthCheckInterval) - } - await this.manager.closeAll() - } -} diff --git a/src/extensions/mcp-adapter/logger.ts b/src/extensions/mcp-adapter/logger.ts deleted file mode 100644 index d1c4dcf2f..000000000 --- a/src/extensions/mcp-adapter/logger.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Centralized logging for MCP UI operations. - * Provides structured, contextual logs with levels. - */ - -export type LogLevel = "debug" | "info" | "warn" | "error" - -export interface LogContext { - server?: string - session?: string - tool?: string - uri?: string - [key: string]: unknown -} - -export interface LogEntry { - level: LogLevel - message: string - context?: LogContext - error?: Error - timestamp: Date -} - -type LogHandler = (entry: LogEntry) => void - -const LEVEL_PRIORITY: Record = { - debug: 0, - info: 1, - warn: 2, - error: 3, -} - -const LEVEL_PREFIX: Record = { - debug: "[MCP-UI:DEBUG]", - info: "[MCP-UI]", - warn: "[MCP-UI:WARN]", - error: "[MCP-UI:ERROR]", -} - -class Logger { - private minLevel: LogLevel = "info" - private handlers: LogHandler[] = [] - private defaultContext: LogContext = {} - - setLevel(level: LogLevel): void { - this.minLevel = level - } - - setDefaultContext(context: LogContext): void { - this.defaultContext = context - } - - addHandler(handler: LogHandler): void { - this.handlers.push(handler) - } - - clearHandlers(): void { - this.handlers = [] - } - - private shouldLog(level: LogLevel): boolean { - return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.minLevel] - } - - private emit(level: LogLevel, message: string, context?: LogContext, error?: Error): void { - if (!this.shouldLog(level)) return - - const entry: LogEntry = { - level, - message, - context: { ...this.defaultContext, ...context }, - error, - timestamp: new Date(), - } - - // Default console output - const prefix = LEVEL_PREFIX[level] - const contextStr = formatContext(entry.context) - const fullMessage = contextStr ? `${prefix} ${message} ${contextStr}` : `${prefix} ${message}` - - if (level === "error") { - console.error(fullMessage, error ?? "") - } else if (level === "warn") { - console.warn(fullMessage) - } else if (level === "debug") { - console.debug(fullMessage) - } else { - console.log(fullMessage) - } - - // Custom handlers - for (const handler of this.handlers) { - try { - handler(entry) - } catch { - // Ignore handler errors - } - } - } - - debug(message: string, context?: LogContext): void { - this.emit("debug", message, context) - } - - info(message: string, context?: LogContext): void { - this.emit("info", message, context) - } - - warn(message: string, context?: LogContext): void { - this.emit("warn", message, context) - } - - error(message: string, error?: Error, context?: LogContext): void { - this.emit("error", message, context, error) - } - - /** - * Create a child logger with additional default context. - */ - child(context: LogContext): ChildLogger { - return new ChildLogger(this, context) - } -} - -class ChildLogger { - constructor( - private parent: Logger, - private context: LogContext, - ) {} - - debug(message: string, context?: LogContext): void { - this.parent.debug(message, { ...this.context, ...context }) - } - - info(message: string, context?: LogContext): void { - this.parent.info(message, { ...this.context, ...context }) - } - - warn(message: string, context?: LogContext): void { - this.parent.warn(message, { ...this.context, ...context }) - } - - error(message: string, error?: Error, context?: LogContext): void { - this.parent.error(message, error, { ...this.context, ...context }) - } - - child(context: LogContext): ChildLogger { - return new ChildLogger(this.parent, { ...this.context, ...context }) - } -} - -function formatContext(context?: LogContext): string { - if (!context || Object.keys(context).length === 0) return "" - const parts: string[] = [] - for (const [key, value] of Object.entries(context)) { - if (value !== undefined && value !== null) { - parts.push(`${key}=${typeof value === "string" ? value : JSON.stringify(value)}`) - } - } - return parts.length > 0 ? `(${parts.join(", ")})` : "" -} - -// Singleton instance -export const logger = new Logger() - -// Enable debug mode via environment variable -if (process.env.MCP_UI_DEBUG === "1" || process.env.MCP_UI_DEBUG === "true") { - logger.setLevel("debug") -} diff --git a/src/extensions/mcp-adapter/mcp-auth-flow.test.ts b/src/extensions/mcp-adapter/mcp-auth-flow.test.ts deleted file mode 100644 index d5631112d..000000000 --- a/src/extensions/mcp-adapter/mcp-auth-flow.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs" -import { createServer } from "node:http" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { afterEach, describe, expect, it, vi } from "vitest" - -async function getFreePort(): Promise { - const server = createServer() - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - const address = server.address() - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) - if (!address || typeof address === "string") throw new Error("Could not allocate a local test port") - return address.port -} - -async function expectPortCanBind(port: number): Promise { - const server = createServer() - await new Promise((resolve, reject) => { - server.once("error", reject) - server.listen(port, "127.0.0.1", resolve) - }) - await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) -} - -async function sendCallback(port: number, path: string, state: string, code: string): Promise { - const response = await fetch(`http://127.0.0.1:${port}${path}?code=${code}&state=${state}`) - expect(response.ok).toBe(true) -} - -async function loadAuthFlowForPort(port: number, options: { connectGate?: Promise } = {}) { - const authDir = mkdtempSync(join(tmpdir(), "kimchi-mcp-oauth-test-")) - vi.resetModules() - vi.stubEnv("MCP_OAUTH_CALLBACK_PORT", String(port)) - vi.stubEnv("MCP_OAUTH_DIR", authDir) - const openBrowser = vi.fn(async () => {}) - const connectStarted = vi.fn() - vi.doMock("open", () => ({ default: openBrowser })) - - vi.doMock("@modelcontextprotocol/sdk/client/auth.js", () => { - class UnauthorizedError extends Error {} - return { UnauthorizedError } - }) - - vi.doMock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => { - class StreamableHTTPClientTransport { - readonly authProvider?: { redirectToAuthorization?: (url: URL) => void | Promise } - - constructor( - _url: URL, - options?: { authProvider?: { redirectToAuthorization?: (url: URL) => void | Promise } }, - ) { - this.authProvider = options?.authProvider - } - - async finishAuth(): Promise {} - - async close(): Promise {} - } - - return { StreamableHTTPClientTransport } - }) - - vi.doMock("@modelcontextprotocol/sdk/client/index.js", async () => { - const { UnauthorizedError } = await import("@modelcontextprotocol/sdk/client/auth.js") - - class Client { - async connect(transport: { authProvider?: { redirectToAuthorization?: (url: URL) => void | Promise } }) { - connectStarted() - await options.connectGate - await transport.authProvider?.redirectToAuthorization?.(new URL("https://auth.example.test/authorize")) - throw new UnauthorizedError("authorization required") - } - - async close(): Promise {} - } - - return { Client } - }) - - const [flow, callbackServer, authStore, oauthProvider] = await Promise.all([ - import("./mcp-auth-flow.js"), - import("./mcp-callback-server.js"), - import("./mcp-auth.js"), - import("./mcp-oauth-provider.js"), - ]) - - return { authDir, authStore, callbackServer, connectStarted, flow, oauthProvider, openBrowser } -} - -afterEach(() => { - vi.unstubAllEnvs() - vi.doUnmock("@modelcontextprotocol/sdk/client/auth.js") - vi.doUnmock("@modelcontextprotocol/sdk/client/index.js") - vi.doUnmock("@modelcontextprotocol/sdk/client/streamableHttp.js") - vi.doUnmock("open") -}) - -describe("MCP OAuth callback lifecycle", () => { - it("does not bind the callback port during idle initialization", async () => { - const port = await getFreePort() - const { authDir, callbackServer, flow } = await loadAuthFlowForPort(port) - - try { - await flow.initializeOAuth() - - await expectPortCanBind(port) - expect(callbackServer.isCallbackServerRunning()).toBe(false) - } finally { - await flow.shutdownOAuth() - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("shares one callback listener across concurrent authentications and releases it after the last one", async () => { - const port = await getFreePort() - const { authDir, authStore, callbackServer, flow, oauthProvider, openBrowser } = await loadAuthFlowForPort(port) - - try { - const first = flow.authenticate("first", "https://first.example.test/mcp") - const second = flow.authenticate("second", "https://second.example.test/mcp") - await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledTimes(2)) - - const firstState = await authStore.getOAuthState("first") - if (!firstState) throw new Error("Missing first OAuth state") - await sendCallback(port, oauthProvider.OAUTH_CALLBACK_PATH, firstState, "first-code") - await expect(first).resolves.toBe("authenticated") - expect(callbackServer.isCallbackServerRunning()).toBe(true) - - const secondState = await authStore.getOAuthState("second") - if (!secondState) throw new Error("Missing second OAuth state") - await sendCallback(port, oauthProvider.OAUTH_CALLBACK_PATH, secondState, "second-code") - await expect(second).resolves.toBe("authenticated") - expect(callbackServer.isCallbackServerRunning()).toBe(false) - await expectPortCanBind(port) - } finally { - await flow.shutdownOAuth() - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("waits for an in-progress close before starting a new authentication", async () => { - const port = await getFreePort() - const { authDir, authStore, callbackServer, flow, oauthProvider, openBrowser } = await loadAuthFlowForPort(port) - - try { - await callbackServer.ensureCallbackServer({ strictPort: true }) - const stopping = callbackServer.stopCallbackServer() - const authenticating = flow.authenticate("rovo", "https://rovo.example.test/mcp") - - await stopping - await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()) - expect(callbackServer.isCallbackServerRunning()).toBe(true) - - const state = await authStore.getOAuthState("rovo") - if (!state) throw new Error("Missing Rovo OAuth state") - await sendCallback(port, oauthProvider.OAUTH_CALLBACK_PATH, state, "rovo-code") - await expect(authenticating).resolves.toBe("authenticated") - expect(callbackServer.isCallbackServerRunning()).toBe(false) - } finally { - await flow.shutdownOAuth() - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("cancels authentication when shutdown follows callback server startup", async () => { - const port = await getFreePort() - let resumeConnect = () => {} - const connectGate = new Promise((resolve) => { - resumeConnect = resolve - }) - const { authDir, callbackServer, connectStarted, flow } = await loadAuthFlowForPort(port, { connectGate }) - const authenticating = flow.authenticate("rovo", "https://rovo.example.test/mcp") - - try { - await vi.waitFor(() => expect(connectStarted).toHaveBeenCalledOnce()) - expect(callbackServer.isCallbackServerRunning()).toBe(true) - - await flow.shutdownOAuth() - resumeConnect() - - const outcome = await Promise.race([ - authenticating.then( - () => "authenticated", - (error: unknown) => (error instanceof Error ? error.message : String(error)), - ), - new Promise((resolve) => setTimeout(() => resolve("still pending"), 1_000)), - ]) - expect(outcome).toBe("OAuth callback server stopped") - expect(callbackServer.isCallbackServerRunning()).toBe(false) - } finally { - resumeConnect() - await flow.shutdownOAuth() - await authenticating.catch(() => {}) - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("releases callback ownership when the browser cannot open", async () => { - const port = await getFreePort() - const { authDir, callbackServer, flow, openBrowser } = await loadAuthFlowForPort(port) - openBrowser.mockRejectedValueOnce(new Error("browser unavailable")) - - try { - await expect(flow.authenticate("rovo", "https://rovo.example.test/mcp")).rejects.toThrow("Could not open browser") - expect(callbackServer.isCallbackServerRunning()).toBe(false) - await expectPortCanBind(port) - } finally { - await flow.shutdownOAuth() - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("uses non-strict port binding for dynamic registration (no clientId), scanning for a free port when busy", async () => { - const port = await getFreePort() - const { authDir, authStore, callbackServer, flow, oauthProvider, openBrowser } = await loadAuthFlowForPort(port) - - // Occupy the preferred callback port so the server must scan forward - const blocker = createServer() - await new Promise((resolve, reject) => { - blocker.once("error", reject) - blocker.listen(port, "127.0.0.1", resolve) - }) - - try { - const authPromise = flow.authenticate("slack", "https://slack.example.test/mcp") - await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce()) - - // Server should be running on a free port after the blocker - const actualPort = oauthProvider.getOAuthCallbackPort() - expect(actualPort).toBeGreaterThan(port) - expect(callbackServer.isCallbackServerRunning()).toBe(true) - - const state = await authStore.getOAuthState("slack") - if (!state) throw new Error("Missing Slack OAuth state") - await sendCallback(actualPort, oauthProvider.OAUTH_CALLBACK_PATH, state, "slack-code") - await expect(authPromise).resolves.toBe("authenticated") - } finally { - await flow.shutdownOAuth() - await new Promise((resolve) => blocker.close(() => resolve())) - rmSync(authDir, { recursive: true, force: true }) - } - }) - - it("uses strict port binding for pre-registered clients (clientId set), failing when the port is busy", async () => { - const port = await getFreePort() - const { authDir, flow } = await loadAuthFlowForPort(port) - - // Occupy the preferred callback port - const blocker = createServer() - await new Promise((resolve, reject) => { - blocker.once("error", reject) - blocker.listen(port, "127.0.0.1", resolve) - }) - - try { - const definition = { url: "https://slack.example.test/mcp", oauth: { clientId: "pre-registered-id" } } - await expect(flow.authenticate("slack", "https://slack.example.test/mcp", definition)).rejects.toThrow( - /already in use/, - ) - } finally { - await flow.shutdownOAuth() - await new Promise((resolve) => blocker.close(() => resolve())) - rmSync(authDir, { recursive: true, force: true }) - } - }) -}) diff --git a/src/extensions/mcp-adapter/mcp-auth-flow.ts b/src/extensions/mcp-adapter/mcp-auth-flow.ts deleted file mode 100644 index 98598f619..000000000 --- a/src/extensions/mcp-adapter/mcp-auth-flow.ts +++ /dev/null @@ -1,429 +0,0 @@ -/** - * MCP Auth Flow - * - * High-level OAuth flow management using the MCP SDK's built-in auth functions. - * Follows the OpenCode pattern: let the SDK handle discovery internally via transport. - */ - -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import open from "open" -import { - clearAllCredentials, - clearOAuthState, - getAuthForUrl, - getOAuthState, - hasStoredTokens, - isTokenExpired, - type StoredTokens, - updateOAuthState, -} from "./mcp-auth.js" -import { cancelPendingCallback, prepareCallback, stopCallbackServer } from "./mcp-callback-server.js" -import { type McpOAuthConfig, McpOAuthProvider } from "./mcp-oauth-provider.js" -import type { ServerEntry } from "./types.js" - -/** Auth status for a server */ -export type AuthStatus = "authenticated" | "expired" | "not_authenticated" - -// Track pending transports for auth completion -const pendingTransports = new Map() - -// Deduplicate concurrent authenticate() calls per server. -const pendingAuthentications = new Map>() - -async function stopCallbackServerIfIdle(): Promise { - if (pendingTransports.size === 0 && pendingAuthentications.size === 0) { - await stopCallbackServer() - } -} - -/** - * Generate a cryptographically secure random state parameter. - */ -function generateState(): string { - return Array.from(crypto.getRandomValues(new Uint8Array(32))) - .map((b) => b.toString(16).padStart(2, "0")) - .join("") -} - -/** - * Extract OAuth configuration from a ServerEntry. - */ -function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig { - // If oauth is explicitly false, return empty config - if (definition.oauth === false) { - return {} - } - return { - grantType: definition.oauth?.grantType, - clientId: definition.oauth?.clientId, - clientSecret: definition.oauth?.clientSecret, - scope: definition.oauth?.scope, - } -} - -/** - * Start OAuth authentication flow for a server. - * Returns the authorization URL that should be opened in a browser. - * - * This follows the OpenCode pattern: - * 1. Create transport with auth provider - * 2. Try to connect - SDK handles discovery internally - * 3. If UnauthorizedError, capture the auth URL from onRedirect - */ -export async function startAuth( - serverName: string, - serverUrl: string, - definition?: ServerEntry, -): Promise<{ - authorizationUrl: string - transport: StreamableHTTPClientTransport - oauthState?: string - callbackPromise?: Promise -}> { - const config = definition ? extractOAuthConfig(definition) : {} - - if (config.grantType === "client_credentials") { - const authProvider = new McpOAuthProvider(serverName, serverUrl, config, { - onRedirect: async () => { - throw new Error("Browser redirect is not used for client_credentials flow") - }, - }) - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - authProvider, - }) - const client = new Client({ - name: "pi-mcp", - version: "3.0.0", - }) - - try { - await client.connect(transport) - return { authorizationUrl: "", transport } - } finally { - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - } - } - - const oauthState = generateState() - - // Create the auth provider - let capturedUrl: URL | undefined - const authProvider = new McpOAuthProvider(serverName, serverUrl, config, { - onRedirect: async (url) => { - capturedUrl = url - }, - }) - - // Create transport with auth provider - // The SDK handles OAuth discovery internally when connecting - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - authProvider, - }) - const client = new Client({ - name: "pi-mcp", - version: "3.0.0", - }) - let callbackPromise: Promise | undefined - - // Try to connect - this triggers the OAuth flow - try { - // Start the callback server and register ownership in one serialized operation. - // This prevents shutdown from closing the listener between those two steps. - // Pre-registered clients (with a configured clientId) must bind the exact - // redirect URI port registered with the OAuth server. Dynamic registration - // (RFC 7591) sends the redirect URI at registration time, so the port can - // vary — RFC 8252 §7.3 requires authorization servers to accept any port - // for loopback redirect URIs. - const strictPort = !!config.clientId - const preparedCallback = await prepareCallback(oauthState, { strictPort }) - callbackPromise = preparedCallback.callbackPromise - void callbackPromise.catch(() => {}) - - // The SDK reads the stored state while constructing the authorization URL. - await updateOAuthState(serverName, oauthState) - - await client.connect(transport) - // If we get here, we're already authenticated - cancelPendingCallback(oauthState) - await callbackPromise.catch(() => {}) - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - await stopCallbackServerIfIdle() - return { authorizationUrl: "", transport } - } catch (error) { - if (error instanceof UnauthorizedError && capturedUrl && callbackPromise) { - await client.close().catch(() => {}) - // Store transport for later finishAuth - pendingTransports.set(serverName, transport) - return { authorizationUrl: capturedUrl.toString(), transport, oauthState, callbackPromise } - } - if (callbackPromise) { - cancelPendingCallback(oauthState) - await callbackPromise.catch(() => {}) - } - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - await stopCallbackServerIfIdle() - throw error - } -} - -/** - * Complete OAuth authentication with the authorization code. - */ -export async function completeAuth(serverName: string, authorizationCode: string): Promise { - const transport = pendingTransports.get(serverName) - if (!transport) { - throw new Error(`No pending OAuth flow for server: ${serverName}`) - } - - try { - // Complete the auth using the transport's finishAuth method - await transport.finishAuth(authorizationCode) - return "authenticated" - } finally { - pendingTransports.delete(serverName) - await transport.close().catch(() => {}) - await stopCallbackServerIfIdle() - } -} - -/** - * Perform the complete OAuth authentication flow for a server. - * - * @param serverName - The name of the MCP server - * @param serverUrl - The URL of the MCP server - * @param definition - The server definition (optional) - * @returns The final auth status - */ -export async function authenticate( - serverName: string, - serverUrl: string, - definition?: ServerEntry, -): Promise { - const inFlight = pendingAuthentications.get(serverName) - if (inFlight) { - return inFlight - } - - const operation = (async (): Promise => { - // Start auth flow - const { authorizationUrl, callbackPromise, oauthState } = await startAuth(serverName, serverUrl, definition) - - // If no auth URL needed, already authenticated - if (!authorizationUrl) { - return "authenticated" - } - - try { - if (!oauthState || !callbackPromise) { - throw new Error("OAuth callback was not registered during startAuth") - } - - // Open browser - console.log(`MCP Auth: Opening browser for ${serverName}`) - try { - await open(authorizationUrl) - } catch (error) { - console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error }) - throw new Error(`Could not open browser. Please open this URL manually: ${authorizationUrl}`, { - cause: error, - }) - } - - // Wait for callback - const code = await callbackPromise - - // Validate state - const storedState = await getOAuthState(serverName) - if (storedState !== oauthState) { - await clearOAuthState(serverName) - throw new Error("OAuth state mismatch - potential CSRF attack") - } - await clearOAuthState(serverName) - - // Complete the auth - return await completeAuth(serverName, code) - } catch (error) { - if (oauthState) { - cancelPendingCallback(oauthState) - } - await callbackPromise?.catch(() => {}) - const pendingTransport = pendingTransports.get(serverName) - if (pendingTransport) { - pendingTransports.delete(serverName) - await pendingTransport.close().catch(() => {}) - } - throw error - } - })() - - pendingAuthentications.set(serverName, operation) - - try { - return await operation - } finally { - if (pendingAuthentications.get(serverName) === operation) { - pendingAuthentications.delete(serverName) - } - await stopCallbackServerIfIdle() - } -} - -/** - * Get a valid access token for a server, refreshing if necessary. - * - * @param serverName - The name of the MCP server - * @param serverUrl - The URL of the MCP server - * @returns The valid tokens or null if not authenticated - */ -export async function getValidToken(serverName: string, serverUrl: string): Promise { - // Check if we have valid tokens - const entry = await getAuthForUrl(serverName, serverUrl) - if (!entry?.tokens) { - return null - } - - // Check expiration - const expired = await isTokenExpired(serverName) - if (expired === false) { - return entry.tokens - } - - if (expired === true && entry.tokens.refreshToken) { - // Token is expired, try to refresh - console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`) - - try { - // Create auth provider for token refresh - const authProvider = new McpOAuthProvider( - serverName, - serverUrl, - {}, - { - onRedirect: async () => {}, - }, - ) - - const clientInfo = await authProvider.clientInformation() - if (!clientInfo) { - console.log(`MCP Auth: No client info for refresh for ${serverName}`) - return null - } - - // Try to get tokens to find the token endpoint - const existingTokens = await authProvider.tokens() - if (!existingTokens) { - return null - } - - // Create transport to trigger refresh - const transport = new StreamableHTTPClientTransport(new URL(serverUrl), { - authProvider, - }) - - // Try to connect - SDK will attempt token refresh internally - const client = new Client({ name: "pi-mcp", version: "3.0.0" }) - try { - await client.connect(transport) - // Get refreshed tokens - const refreshed = await getAuthForUrl(serverName, serverUrl) - return refreshed?.tokens ?? null - } catch (error) { - console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error }) - return null - } finally { - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - } - } catch (error) { - console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error }) - return null - } - } - - // No expiration info or no refresh token, assume valid - return entry.tokens -} - -/** - * Check the authentication status for a server. - * - * If `serverUrl` is provided, stored tokens are validated against it — - * tokens saved for a different URL are treated as not_authenticated - * so the caller can re-authenticate. - * - * @param serverName - The name of the MCP server - * @param serverUrl - Optional URL to validate stored tokens against - * @returns The current auth status - */ -export async function getAuthStatus(serverName: string, serverUrl?: string): Promise { - if (serverUrl) { - const entry = getAuthForUrl(serverName, serverUrl) - if (!entry?.tokens) return "not_authenticated" - const expired = isTokenExpired(serverName) - return expired ? "expired" : "authenticated" - } - const hasTokens = await hasStoredTokens(serverName) - if (!hasTokens) return "not_authenticated" - - const expired = await isTokenExpired(serverName) - return expired ? "expired" : "authenticated" -} - -/** - * Remove all OAuth credentials for a server. - * - * @param serverName - The name of the MCP server - */ -export async function removeAuth(serverName: string): Promise { - const oauthState = await getOAuthState(serverName) - if (oauthState) { - cancelPendingCallback(oauthState) - } - const pendingTransport = pendingTransports.get(serverName) - if (pendingTransport) { - pendingTransports.delete(serverName) - await pendingTransport.close().catch(() => {}) - } - await stopCallbackServerIfIdle() - clearAllCredentials(serverName) - await clearOAuthState(serverName) - console.log(`MCP Auth: Removed credentials for ${serverName}`) -} - -/** - * Check if OAuth is supported for a server configuration. - * OAuth is supported for HTTP servers unless explicitly disabled. - * - * @param definition - The server definition - * @returns True if OAuth is supported - */ -export function supportsOAuth(definition: ServerEntry): boolean { - // OAuth requires a URL - if (!definition.url) return false - - // Explicitly disabled via auth: false or oauth: false - if (definition.auth === false) return false - if (definition.oauth === false) return false - - // OAuth is enabled if auth is 'oauth' or not specified (auto-detect) - return definition.auth === "oauth" || definition.auth === undefined -} - -/** - * Initialize the OAuth system on startup. - * OAuth callback binding is lazy and starts from startAuth() only. - */ -export async function initializeOAuth(): Promise {} - -/** - * Shutdown the OAuth system. - * Stops the callback server and cancels pending auths. - */ -export async function shutdownOAuth(): Promise { - await stopCallbackServer() -} diff --git a/src/extensions/mcp-adapter/mcp-auth.ts b/src/extensions/mcp-adapter/mcp-auth.ts deleted file mode 100644 index 262184b10..000000000 --- a/src/extensions/mcp-adapter/mcp-auth.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * MCP Auth Storage Module - * - * Handles secure storage of OAuth credentials, tokens, client information, - * and PKCE state for MCP servers. Maintains backward compatibility with - * per-server directory structure. - * - * Token storage location: /mcp-oauth//tokens.json - * (where is determined by getAgentDir(), typically - * ~/.config/kimchi/harness/ when run via the CLI entry point) - */ - -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { getAgentDir } from "./utils.js" - -/** OAuth token storage format */ -export interface StoredTokens { - accessToken: string - refreshToken?: string - expiresAt?: number // Unix timestamp in seconds - scope?: string -} - -/** OAuth client information from dynamic or static registration */ -export interface StoredClientInfo { - clientId: string - clientSecret?: string - clientIdIssuedAt?: number - clientSecretExpiresAt?: number -} - -/** Complete auth entry for a server */ -export interface AuthEntry { - tokens?: StoredTokens - clientInfo?: StoredClientInfo - codeVerifier?: string - oauthState?: string - serverUrl?: string // Track the URL these credentials are for -} - -// Base directory for auth storage - can be overridden via env var for testing -function getAuthBaseDir(): string { - return process.env.MCP_OAUTH_DIR ?? join(getAgentDir(), "mcp-oauth") -} - -/** - * Get the server-specific directory path. - */ -function getServerDir(serverName: string): string { - return join(getAuthBaseDir(), serverName) -} - -/** - * Get the tokens file path for a server. - */ -function getTokensFilePath(serverName: string): string { - return join(getServerDir(serverName), "tokens.json") -} - -/** - * Ensure the server directory exists with secure permissions. - */ -function ensureServerDir(serverName: string): void { - const dir = getServerDir(serverName) - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }) - } -} - -/** - * Read the auth entry for a server from disk. - * Returns undefined if file doesn't exist. - */ -function readAuthEntry(serverName: string): AuthEntry | undefined { - try { - const filePath = getTokensFilePath(serverName) - if (!existsSync(filePath)) { - return undefined - } - const data = readFileSync(filePath, "utf-8") - return JSON.parse(data) as AuthEntry - } catch (error) { - console.error(`Failed to read auth entry for ${serverName}:`, error) - return undefined - } -} - -/** - * Write the auth entry for a server to disk with secure permissions. - */ -function writeAuthEntry(serverName: string, entry: AuthEntry): void { - ensureServerDir(serverName) - const filePath = getTokensFilePath(serverName) - writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 }) -} - -/** - * Get auth entry for a server. - */ -export function getAuthEntry(serverName: string): AuthEntry | undefined { - return readAuthEntry(serverName) -} - -/** - * Get auth entry and validate it's for the correct URL. - * Returns undefined if URL has changed (credentials are invalid). - */ -export function getAuthForUrl(serverName: string, serverUrl: string): AuthEntry | undefined { - const entry = getAuthEntry(serverName) - if (!entry) return undefined - - // If no serverUrl is stored, this is from an old version - consider it invalid - if (!entry.serverUrl) return undefined - - // If URL has changed, credentials are invalid - if (entry.serverUrl !== serverUrl) return undefined - - return entry -} - -/** - * Save auth entry for a server. - */ -export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void { - // Always update serverUrl if provided - if (serverUrl) { - entry.serverUrl = serverUrl - } - writeAuthEntry(serverName, entry) -} - -/** - * Remove auth entry for a server. - * Also removes the server directory if empty. - */ -export function removeAuthEntry(serverName: string): void { - try { - const filePath = getTokensFilePath(serverName) - if (existsSync(filePath)) { - writeFileSync(filePath, "{}", { mode: 0o600 }) - } - // Try to remove the directory - const dir = getServerDir(serverName) - if (existsSync(dir)) { - try { - rmSync(dir, { recursive: true }) - } catch { - // Directory may not be empty, ignore - } - } - } catch (error) { - console.error(`Failed to remove auth entry for ${serverName}:`, error) - } -} - -/** - * Update tokens for a server. - */ -export function updateTokens(serverName: string, tokens: StoredTokens, serverUrl?: string): void { - const entry = getAuthEntry(serverName) ?? {} - entry.tokens = tokens - saveAuthEntry(serverName, entry, serverUrl) -} - -/** - * Update client info for a server. - */ -export function updateClientInfo(serverName: string, clientInfo: StoredClientInfo, serverUrl?: string): void { - const entry = getAuthEntry(serverName) ?? {} - entry.clientInfo = clientInfo - saveAuthEntry(serverName, entry, serverUrl) -} - -/** - * Update code verifier for a server. - */ -export function updateCodeVerifier(serverName: string, codeVerifier: string): void { - const entry = getAuthEntry(serverName) ?? {} - entry.codeVerifier = codeVerifier - saveAuthEntry(serverName, entry) -} - -/** - * Clear code verifier for a server. - */ -export function clearCodeVerifier(serverName: string): void { - const entry = getAuthEntry(serverName) - if (entry) { - // biome-ignore lint/performance/noDelete: - - delete entry.codeVerifier - saveAuthEntry(serverName, entry) - } -} - -/** - * Update OAuth state for a server. - */ -export function updateOAuthState(serverName: string, state: string): void { - const entry = getAuthEntry(serverName) ?? {} - entry.oauthState = state - saveAuthEntry(serverName, entry) -} - -/** - * Get OAuth state for a server. - */ -export function getOAuthState(serverName: string): string | undefined { - const entry = getAuthEntry(serverName) - return entry?.oauthState -} - -/** - * Clear OAuth state for a server. - */ -export function clearOAuthState(serverName: string): void { - const entry = getAuthEntry(serverName) - if (entry) { - // biome-ignore lint/performance/noDelete: - - delete entry.oauthState - saveAuthEntry(serverName, entry) - } -} - -/** - * Check if stored tokens are expired. - * Returns null if no tokens exist, false if no expiry or not expired, true if expired. - */ -export function isTokenExpired(serverName: string): boolean | null { - const entry = getAuthEntry(serverName) - if (!entry?.tokens) return null - if (!entry.tokens.expiresAt) return false - return entry.tokens.expiresAt < Date.now() / 1000 -} - -/** - * Check if a server has stored tokens. - */ -export function hasStoredTokens(serverName: string): boolean { - const entry = getAuthEntry(serverName) - return !!entry?.tokens -} - -/** - * Clear all credentials for a server. - */ -export function clearAllCredentials(serverName: string): void { - removeAuthEntry(serverName) -} - -/** - * Clear only client info for a server. - */ -export function clearClientInfo(serverName: string): void { - const entry = getAuthEntry(serverName) - if (entry) { - // biome-ignore lint/performance/noDelete: - - delete entry.clientInfo - saveAuthEntry(serverName, entry) - } -} - -/** - * Clear only tokens for a server. - */ -export function clearTokens(serverName: string): void { - const entry = getAuthEntry(serverName) - if (entry) { - // biome-ignore lint/performance/noDelete: - - delete entry.tokens - saveAuthEntry(serverName, entry) - } -} diff --git a/src/extensions/mcp-adapter/mcp-callback-server.preview.test.ts b/src/extensions/mcp-adapter/mcp-callback-server.preview.test.ts deleted file mode 100644 index 3d8379c77..000000000 --- a/src/extensions/mcp-adapter/mcp-callback-server.preview.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Manual preview of the OAuth callback page shown in the browser after an MCP - * server (Rovo, Slack, …) redirects back to the local listener. - * - * Disabled by default. Run explicitly with: - * - * KIMCHI_SHOW_OAUTH_PAGE=1 pnpm vitest run src/extensions/mcp-adapter/mcp-callback-server.preview.test.ts - * - * The test starts the real callback server and prints the URLs to open in a - * browser. It stays up until the success URL is visited (or the 5-minute - * callback timeout from mcp-callback-server.ts elapses). - */ -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { afterEach, describe, expect, it, vi } from "vitest" - -// Mirrors DEFAULT_OAUTH_CALLBACK_PORT in mcp-oauth-provider.ts. When the port -// is busy the callback server scans forward, so always use the printed URL. -const PREVIEW_PORT = 19876 - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..") - -afterEach(() => { - vi.unstubAllEnvs() -}) - -describe("MCP OAuth callback page preview", () => { - it.runIf(process.env.KIMCHI_SHOW_OAUTH_PAGE === "1")( - "serves the callback page for manual browser inspection", - async () => { - vi.resetModules() - vi.stubEnv("MCP_OAUTH_CALLBACK_PORT", String(PREVIEW_PORT)) - vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", resolve(repoRoot, "resources", "oauth")) - const callbackServer = await import("./mcp-callback-server.js") - const { getOAuthCallbackPort, OAUTH_CALLBACK_PATH } = await import("./mcp-oauth-provider.js") - - try { - const { callbackPromise } = await callbackServer.prepareCallback("demo-state") - const base = `http://127.0.0.1:${getOAuthCallbackPort()}${OAUTH_CALLBACK_PATH}` - - console.log("\n Open in your browser to view the callback pages:") - console.log(` success: ${base}?code=demo-code&state=demo-state`) - console.log( - ` error: ${base}?error=access_denied&error_description=The+user+denied+the+request&state=anything`, - ) - console.log(" The server shuts down once the success URL is visited (5-minute timeout otherwise).\n") - - const code = await callbackPromise - console.log(` Received authorization code "${code}" — callback flow completed.`) - expect(code).toBe("demo-code") - } finally { - await callbackServer.stopCallbackServer() - } - }, - 6 * 60 * 1000, - ) -}) diff --git a/src/extensions/mcp-adapter/mcp-callback-server.test.ts b/src/extensions/mcp-adapter/mcp-callback-server.test.ts deleted file mode 100644 index a17704e97..000000000 --- a/src/extensions/mcp-adapter/mcp-callback-server.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Tests for the OAuth callback page served to the browser after an MCP server - * (Rovo, Slack, …) redirects back to the local listener. - * - * The page is rendered by the shared Kimchi-branded renderer in - * `src/utils/oauth-page.ts` using the templates in resources/oauth/ - * (addressed via KIMCHI_OAUTH_TEMPLATE_DIR). Without that env var the callback - * server must fall back to a minimal *unbranded* page — never a Pi-branded one. - */ -import { dirname, resolve } from "node:path" -import { fileURLToPath } from "node:url" -import { afterEach, describe, expect, it, vi } from "vitest" - -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..") - -// The Kimchi logo SVG in both templates uses this exact orange — proof the -// branded template rendered rather than the unbranded fallback. -const KIMCHI_LOGO_ORANGE = "#FF521D" - -/** - * Import the callback server and build callback URLs from the port it actually - * bound. No port is pre-selected: the server's own scan-forward handles busy - * ports, and tests always read the bound port afterwards. - */ -async function loadCallbackServer() { - vi.resetModules() - const callbackServer = await import("./mcp-callback-server.js") - const oauthProvider = await import("./mcp-oauth-provider.js") - - function callbackUrl(params: Record): URL { - const url = new URL(oauthProvider.OAUTH_CALLBACK_PATH, `http://127.0.0.1:${oauthProvider.getOAuthCallbackPort()}`) - for (const [key, value] of Object.entries(params)) { - url.searchParams.set(key, value) - } - return url - } - - return { callbackServer, callbackUrl } -} - -afterEach(() => { - vi.unstubAllEnvs() -}) - -describe("MCP OAuth callback page", () => { - it("serves the branded authorization-success page after the provider redirects back", async () => { - vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", resolve(repoRoot, "resources", "oauth")) - const { callbackServer, callbackUrl } = await loadCallbackServer() - - try { - const { callbackPromise } = await callbackServer.prepareCallback("state-success") - - const response = await fetch(callbackUrl({ code: "test-code", state: "state-success" })) - const body = await response.text() - - expect(response.status).toBe(200) - expect(response.headers.get("content-type")).toBe("text/html") - await expect(callbackPromise).resolves.toBe("test-code") - expect(body).toContain("bg-svg") - expect(body).toContain(KIMCHI_LOGO_ORANGE) - expect(body).toContain("MCP Authorization Successful") - expect(body).toContain("

MCP Authorization Successful

") - expect(body).toContain("You can close this window and return to Kimchi.") - expect(body).not.toContain("Pi -") - } finally { - await callbackServer.stopCallbackServer() - } - }) - - it("serves the branded authorization-failure page when the provider reports an error", async () => { - vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", resolve(repoRoot, "resources", "oauth")) - const { callbackServer, callbackUrl } = await loadCallbackServer() - - try { - const { callbackPromise } = await callbackServer.prepareCallback("state-error") - // Attach the rejection assertion before the fetch: the server rejects the - // pending auth in a deferred setTimeout after sending the response. - const rejection = expect(callbackPromise).rejects.toThrow("The user denied the authorization request") - - const response = await fetch( - callbackUrl({ - error: "access_denied", - error_description: "The user denied the authorization request", - state: "state-error", - }), - ) - const body = await response.text() - - expect(response.status).toBe(200) - await rejection - expect(body).toContain("bg-svg") - expect(body).toContain(KIMCHI_LOGO_ORANGE) - expect(body).toContain("MCP Authorization Failed") - expect(body).toContain("

MCP Authorization Failed

") - expect(body).toContain("An error occurred during MCP authorization.") - expect(body).toContain("The user denied the authorization request") - expect(body).not.toContain("Pi -") - } finally { - await callbackServer.stopCallbackServer() - } - }) - - it("rejects the pending auth when the callback has a state but no authorization code", async () => { - const { callbackServer, callbackUrl } = await loadCallbackServer() - - try { - const { callbackPromise } = await callbackServer.prepareCallback("state-no-code") - // Fail fast instead of waiting for the 5-minute callback timeout. - const rejection = expect(callbackPromise).rejects.toThrow("No authorization code provided") - - const response = await fetch(callbackUrl({ state: "state-no-code" })) - const body = await response.text() - - expect(response.status).toBe(400) - expect(body).toContain("MCP Authorization Failed") - await rejection - } finally { - await callbackServer.stopCallbackServer() - } - }) - - it("HTML-escapes provider-controlled error text", async () => { - vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", resolve(repoRoot, "resources", "oauth")) - const { callbackServer, callbackUrl } = await loadCallbackServer() - - try { - const { callbackPromise } = await callbackServer.prepareCallback("state-xss") - const rejection = expect(callbackPromise).rejects.toThrow("alert(1)") - - const response = await fetch( - callbackUrl({ - error: "access_denied", - error_description: "", - state: "state-xss", - }), - ) - const body = await response.text() - - expect(response.status).toBe(200) - await rejection - expect(body).toContain("<script>alert(1)</script>") - expect(body).not.toContain("") - } finally { - await callbackServer.stopCallbackServer() - } - }) - - it("falls back to a minimal unbranded page when KIMCHI_OAUTH_TEMPLATE_DIR is unset", async () => { - vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", "") - const { callbackServer, callbackUrl } = await loadCallbackServer() - - try { - const { callbackPromise } = await callbackServer.prepareCallback("state-fallback") - - const response = await fetch(callbackUrl({ code: "test-code", state: "state-fallback" })) - const body = await response.text() - - expect(response.status).toBe(200) - await expect(callbackPromise).resolves.toBe("test-code") - expect(body).toContain("MCP Authorization Successful") - expect(body).not.toContain("bg-svg") - expect(body).not.toContain(KIMCHI_LOGO_ORANGE) - expect(body).not.toContain("Pi -") - } finally { - await callbackServer.stopCallbackServer() - } - }) -}) diff --git a/src/extensions/mcp-adapter/mcp-callback-server.ts b/src/extensions/mcp-adapter/mcp-callback-server.ts deleted file mode 100644 index 19ed4c5d9..000000000 --- a/src/extensions/mcp-adapter/mcp-callback-server.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * MCP OAuth Callback Server - * - * HTTP server that handles OAuth callbacks from the authorization server. - * Uses Node.js http module for compatibility. - */ - -import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http" -import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js" -import { - getConfiguredOAuthCallbackPort, - getOAuthCallbackPort, - OAUTH_CALLBACK_PATH, - setOAuthCallbackPort, -} from "./mcp-oauth-provider.js" - -// Branded OAuth callback pages come from the shared renderer (templates in -// resources/oauth/, via KIMCHI_OAUTH_TEMPLATE_DIR); it also HTML-escapes the -// provider-controlled error text. Falls back to a minimal unbranded page when -// the template dir is not configured. -const SUCCESS_MESSAGE = "You can close this window and return to Kimchi." -const SUCCESS_PAGE = { title: "MCP Authorization Successful", heading: "MCP Authorization Successful" } -const ERROR_MESSAGE = "An error occurred during MCP authorization." -const ERROR_PAGE = { title: "MCP Authorization Failed", heading: "MCP Authorization Failed" } - -/** Pending authorization request */ -interface PendingAuth { - resolve: (code: string) => void - reject: (error: Error) => void - timeout: ReturnType -} - -/** Server singleton state */ -let server: Server | undefined -const pendingAuths = new Map() -let serverLifecycle = Promise.resolve() - -function serializeServerLifecycle(operation: () => Promise): Promise { - const result = serverLifecycle.then(operation, operation) - serverLifecycle = result.catch(() => {}) - return result -} - -/** Timeout for callback completion (5 minutes) */ -const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 - -const MAX_PORT_SCAN_ATTEMPTS = 25 - -interface EnsureCallbackServerOptions { - strictPort?: boolean -} - -/** - * Handle incoming HTTP requests to the callback server. - */ -function handleRequest(req: IncomingMessage, res: ServerResponse): void { - const url = new URL(req.url || "/", `http://${req.headers.host}`) - - // Only handle the callback path - if (url.pathname !== OAUTH_CALLBACK_PATH) { - res.writeHead(404, { "Content-Type": "text/plain" }) - res.end("Not found") - return - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - const error = url.searchParams.get("error") - const errorDescription = url.searchParams.get("error_description") - - // Enforce state parameter presence for CSRF protection - if (!state) { - const errorMsg = "Missing required state parameter - potential CSRF attack" - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(oauthErrorHtml(ERROR_MESSAGE, errorMsg, ERROR_PAGE)) - return - } - - // Handle OAuth errors - if (error) { - const errorMsg = errorDescription || error - // Send HTTP response first before rejecting promise - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(oauthErrorHtml(ERROR_MESSAGE, errorMsg, ERROR_PAGE)) - // Reject promise after response is sent (defer to allow test to attach handler) - if (pendingAuths.has(state)) { - // biome-ignore lint/style/noNonNullAssertion: asserted above - const pending = pendingAuths.get(state)! - clearTimeout(pending.timeout) - pendingAuths.delete(state) - setTimeout(() => pending.reject(new Error(errorMsg)), 0) - } - return - } - - // Require authorization code - if (!code) { - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(oauthErrorHtml(ERROR_MESSAGE, "No authorization code provided", ERROR_PAGE)) - // Reject the pending auth so the flow fails fast instead of hanging until the timeout - if (pendingAuths.has(state)) { - // biome-ignore lint/style/noNonNullAssertion: asserted above - const pending = pendingAuths.get(state)! - clearTimeout(pending.timeout) - pendingAuths.delete(state) - setTimeout(() => pending.reject(new Error("No authorization code provided")), 0) - } - return - } - - // Validate state parameter - if (!pendingAuths.has(state)) { - const errorMsg = "Invalid or expired state parameter - potential CSRF attack" - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(oauthErrorHtml(ERROR_MESSAGE, errorMsg, ERROR_PAGE)) - return - } - - // biome-ignore lint/style/noNonNullAssertion: asserted above - const pending = pendingAuths.get(state)! - - // Clear timeout and resolve the pending promise - clearTimeout(pending.timeout) - pendingAuths.delete(state) - pending.resolve(code) - - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(oauthSuccessHtml(SUCCESS_MESSAGE, SUCCESS_PAGE)) -} - -/** - * Ensure the callback server is running. - * If strictPort is true, requires binding on the configured callback port. - * If strictPort is false, scans forward for an available local port. - */ -async function ensureCallbackServerLocked(options: EnsureCallbackServerOptions): Promise { - const configuredPort = getConfiguredOAuthCallbackPort() - const strictPort = options.strictPort === true - - if (server) { - if (!strictPort || getOAuthCallbackPort() === configuredPort) return - - if (pendingAuths.size > 0) { - throw new Error( - `OAuth callback server is running on port ${getOAuthCallbackPort()}, but strict callback port ${configuredPort} is required and cannot be switched while authorizations are pending`, - ) - } - - await stopCallbackServerLocked() - } - - const preferredPort = configuredPort - const maxAttempts = strictPort ? 1 : MAX_PORT_SCAN_ATTEMPTS - let lastError: Error | undefined - - for (let offset = 0; offset < maxAttempts; offset++) { - const candidatePort = preferredPort + offset - const candidateServer = createServer(handleRequest) - - try { - await new Promise((resolve, reject) => { - candidateServer.once("error", (err) => { - reject(err) - }) - - candidateServer.listen(candidatePort, "127.0.0.1", () => { - resolve() - }) - }) - - server = candidateServer - server.unref() - setOAuthCallbackPort(candidatePort) - return - } catch (error) { - const nodeError = error as NodeJS.ErrnoException - await new Promise((resolve) => { - candidateServer.close(() => resolve()) - }) - - if (nodeError.code !== "EADDRINUSE") { - throw error - } - - lastError = error instanceof Error ? error : new Error(String(error)) - } - } - - if (strictPort) { - throw new Error( - `OAuth callback port ${preferredPort} is already in use. Pre-registered OAuth clients require an exact redirect URI; set MCP_OAUTH_CALLBACK_PORT to your registered port or free port ${preferredPort}`, - { cause: lastError }, - ) - } - - throw new Error( - `OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + MAX_PORT_SCAN_ATTEMPTS - 1}`, - { cause: lastError }, - ) -} - -export function ensureCallbackServer(options: EnsureCallbackServerOptions = {}): Promise { - return serializeServerLifecycle(() => ensureCallbackServerLocked(options)) -} - -export async function prepareCallback( - oauthState: string, - options: EnsureCallbackServerOptions = {}, -): Promise<{ callbackPromise: Promise }> { - let callbackPromise: Promise | undefined - await serializeServerLifecycle(async () => { - await ensureCallbackServerLocked(options) - callbackPromise = waitForCallback(oauthState) - }) - if (!callbackPromise) throw new Error("OAuth callback registration failed") - return { callbackPromise } -} - -/** - * Wait for a callback with the given OAuth state. - * Returns a promise that resolves with the authorization code. - */ -export function waitForCallback(oauthState: string): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (pendingAuths.has(oauthState)) { - pendingAuths.delete(oauthState) - reject(new Error("OAuth callback timeout - authorization took too long")) - } - }, CALLBACK_TIMEOUT_MS) - - pendingAuths.set(oauthState, { resolve, reject, timeout }) - }) -} - -/** - * Cancel a pending authorization by state. - */ -export function cancelPendingCallback(oauthState: string): void { - const pending = pendingAuths.get(oauthState) - if (pending) { - clearTimeout(pending.timeout) - pendingAuths.delete(oauthState) - pending.reject(new Error("Authorization cancelled")) - } -} - -/** - * Stop the callback server and reject all pending authorizations. - */ -async function stopCallbackServerLocked(): Promise { - if (server) { - await new Promise((resolve) => { - server?.close(() => { - resolve() - }) - }) - server = undefined - } - - setOAuthCallbackPort(getConfiguredOAuthCallbackPort()) - - // Reject all pending auths (defer to allow any pending operations to complete) - const pendingList = Array.from(pendingAuths.entries()) - pendingAuths.clear() - setTimeout(() => { - for (const [, pending] of pendingList) { - clearTimeout(pending.timeout) - pending.reject(new Error("OAuth callback server stopped")) - } - }, 0) -} - -export function stopCallbackServer(): Promise { - return serializeServerLifecycle(stopCallbackServerLocked) -} - -/** - * Check if the callback server is running. - */ -export function isCallbackServerRunning(): boolean { - return server !== undefined -} - -/** - * Get the number of pending authorizations. - */ -export function getPendingAuthCount(): number { - return pendingAuths.size -} diff --git a/src/extensions/mcp-adapter/mcp-oauth-provider.ts b/src/extensions/mcp-adapter/mcp-oauth-provider.ts deleted file mode 100644 index 488b57650..000000000 --- a/src/extensions/mcp-adapter/mcp-oauth-provider.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * MCP OAuth Provider - * - * Implementation of the MCP SDK's OAuthClientProvider interface. - * Handles OAuth client registration, token storage, and authorization redirection. - */ - -import { randomUUID } from "node:crypto" -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" -import type { - OAuthClientInformation, - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthTokens, -} from "@modelcontextprotocol/sdk/shared/auth.js" -import { - clearAllCredentials, - clearClientInfo, - clearTokens, - getAuthEntry, - getAuthForUrl, - type StoredClientInfo, - type StoredTokens, - updateClientInfo, - updateCodeVerifier, - updateOAuthState, - updateTokens, -} from "./mcp-auth.js" - -// Callback server configuration -const DEFAULT_OAUTH_CALLBACK_PORT = 19876 -const OAUTH_CALLBACK_PATH = "/mcp/oauth/callback" - -let configuredOAuthCallbackPort = DEFAULT_OAUTH_CALLBACK_PORT - -if (process.env.MCP_OAUTH_CALLBACK_PORT) { - const parsedPort = Number.parseInt(process.env.MCP_OAUTH_CALLBACK_PORT, 10) - if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) { - configuredOAuthCallbackPort = parsedPort - } -} - -let oauthCallbackPort = configuredOAuthCallbackPort - -export function getConfiguredOAuthCallbackPort(): number { - return configuredOAuthCallbackPort -} - -export function getOAuthCallbackPort(): number { - return oauthCallbackPort -} - -export function setOAuthCallbackPort(port: number): void { - oauthCallbackPort = port -} - -/** Configuration options for OAuth */ -export interface McpOAuthConfig { - grantType?: "authorization_code" | "client_credentials" - clientId?: string - clientSecret?: string - scope?: string -} - -/** Callbacks for OAuth flow interactions */ -export interface McpOAuthCallbacks { - onRedirect: (url: URL) => void | Promise -} - -/** - * OAuth provider implementation for MCP servers. - * Implements the OAuthClientProvider interface from the MCP SDK. - */ -export class McpOAuthProvider implements OAuthClientProvider { - constructor( - private serverName: string, - private serverUrl: string, - private config: McpOAuthConfig, - private callbacks: McpOAuthCallbacks, - ) {} - - private get usesClientCredentials(): boolean { - return this.config.grantType === "client_credentials" - } - - /** - * The redirect URL for OAuth callbacks. - * This must match the redirect_uri in client metadata. - */ - get redirectUrl(): string | undefined { - if (this.usesClientCredentials) return undefined - return `http://127.0.0.1:${getOAuthCallbackPort()}${OAUTH_CALLBACK_PATH}` - } - - /** - * Client metadata for dynamic registration. - * Describes this client to the OAuth authorization server. - */ - get clientMetadata(): OAuthClientMetadata { - if (this.usesClientCredentials) { - return { - client_name: "Pi Coding Agent", - redirect_uris: [], - grant_types: ["client_credentials"], - token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none", - } - } - - const redirectUrl = this.redirectUrl - if (!redirectUrl) { - throw new Error("redirectUrl is required for authorization_code flow") - } - - return { - redirect_uris: [redirectUrl], - client_name: "Pi Coding Agent", - client_uri: "https://github.com/nicobailon/pi-mcp-adapter", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none", - } - } - - /** - * Get client information (for pre-registered or dynamically registered clients). - * Returns undefined if no client info exists or if the server URL has changed. - */ - async clientInformation(): Promise { - // Check config first (pre-registered client) - if (this.config.clientId) { - return { - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - } - } - - // Check stored client info (from dynamic registration) - // Use getAuthForUrl to validate credentials are for the current server URL - const entry = await getAuthForUrl(this.serverName, this.serverUrl) - if (entry?.clientInfo) { - // Check if client secret has expired - if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { - return undefined - } - return { - client_id: entry.clientInfo.clientId, - client_secret: entry.clientInfo.clientSecret, - } - } - - // No client info or URL changed - will trigger dynamic registration - return undefined - } - - /** - * Save client information from dynamic registration. - */ - async saveClientInformation(info: OAuthClientInformationFull): Promise { - const clientInfo: StoredClientInfo = { - clientId: info.client_id, - clientSecret: info.client_secret, - clientIdIssuedAt: info.client_id_issued_at, - clientSecretExpiresAt: info.client_secret_expires_at, - } - updateClientInfo(this.serverName, clientInfo, this.serverUrl) - } - - /** - * Get stored OAuth tokens. - * Returns undefined if no tokens exist or if the server URL has changed. - */ - async tokens(): Promise { - // Use getAuthForUrl to validate tokens are for the current server URL - const entry = await getAuthForUrl(this.serverName, this.serverUrl) - if (!entry?.tokens) return undefined - - return { - access_token: entry.tokens.accessToken, - token_type: "Bearer", - refresh_token: entry.tokens.refreshToken, - expires_in: entry.tokens.expiresAt - ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) - : undefined, - scope: entry.tokens.scope, - } - } - - /** - * Save OAuth tokens. - */ - async saveTokens(tokens: OAuthTokens): Promise { - const storedTokens: StoredTokens = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, - scope: tokens.scope, - } - updateTokens(this.serverName, storedTokens, this.serverUrl) - } - - /** - * Redirect the user to the authorization URL. - * This opens the browser for the user to authenticate. - */ - async redirectToAuthorization(authorizationUrl: URL): Promise { - if (this.usesClientCredentials) { - throw new Error("redirectToAuthorization is not used for client_credentials flow") - } - // URL is passed to callback, not logged (may contain sensitive params) - await this.callbacks.onRedirect(authorizationUrl) - } - - /** - * Save the PKCE code verifier. - */ - async saveCodeVerifier(codeVerifier: string): Promise { - updateCodeVerifier(this.serverName, codeVerifier) - } - - /** - * Get the stored PKCE code verifier. - * @throws Error if no code verifier is stored - */ - async codeVerifier(): Promise { - if (this.usesClientCredentials) { - throw new Error("codeVerifier is not used for client_credentials flow") - } - const entry = await getAuthEntry(this.serverName) - if (!entry?.codeVerifier) { - throw new Error(`No code verifier saved for MCP server: ${this.serverName}`) - } - return entry.codeVerifier - } - - /** - * Save the OAuth state parameter for CSRF protection. - */ - async saveState(state: string): Promise { - updateOAuthState(this.serverName, state) - } - - /** - * Get the stored OAuth state parameter. - * - * If no state is stored (e.g. when `probeTools()` creates a transport - * directly without going through `startAuth()`), generates and saves - * one on-the-fly so the SDK can include it in the authorization URL. - * - * @throws Error if `grantType` is `client_credentials` - */ - async state(): Promise { - if (this.usesClientCredentials) { - throw new Error("state is not used for client_credentials flow") - } - const entry = getAuthEntry(this.serverName) - if (entry?.oauthState) { - return entry.oauthState - } - // No state saved yet — generate one on-the-fly. - const state = randomUUID() - updateOAuthState(this.serverName, state) - return state - } - - /** - * Invalidate credentials when authentication fails. - * Clears tokens, client info, or all credentials based on the type. - */ - async invalidateCredentials(type: "all" | "client" | "tokens"): Promise { - switch (type) { - case "all": - clearAllCredentials(this.serverName) - break - case "client": - clearClientInfo(this.serverName) - break - case "tokens": - clearTokens(this.serverName) - break - } - } - - prepareTokenRequest(scope?: string): URLSearchParams | undefined { - if (!this.usesClientCredentials) { - return undefined - } - - const params = new URLSearchParams({ grant_type: "client_credentials" }) - const requestedScope = scope ?? this.config.scope - if (requestedScope) { - params.set("scope", requestedScope) - } - return params - } -} - -export { DEFAULT_OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } diff --git a/src/extensions/mcp-adapter/mcp-panel.test.ts b/src/extensions/mcp-adapter/mcp-panel.test.ts deleted file mode 100644 index d20b3e795..000000000 --- a/src/extensions/mcp-adapter/mcp-panel.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { visibleWidth } from "@earendil-works/pi-tui" -import { describe, expect, it, vi } from "vitest" -import { computeVisibleWindow, createMcpPanel } from "./mcp-panel.js" -import type { MetadataCache } from "./metadata-cache.js" -import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.js" - -// ─── Panel test helpers ─────────────────────────────────────────────────────── - -/** Minimal McpConfig with one server and one cached tool. */ -function makeConfig(serverName = "my-server"): McpConfig { - return { - mcpServers: { - [serverName]: { command: "npx", args: ["my-server"] }, - }, - } -} - -/** MetadataCache with one tool cached for the given server. */ -function makeCache(serverName = "my-server", toolName = "my_tool", description = "Does a thing"): MetadataCache { - return { - version: 1, - servers: { - [serverName]: { - configHash: "abc", - tools: [{ name: toolName, description }], - resources: [], - cachedAt: Date.now(), - }, - }, - } -} - -/** Stub callbacks — all no-ops except onSave which uses a vi.fn(). */ -function makeCallbacks(): { callbacks: McpPanelCallbacks; onSave: ReturnType } { - const onSave = vi.fn<(changes: Map) => void>() - const callbacks: McpPanelCallbacks = { - reconnect: () => Promise.resolve(true), - getConnectionStatus: () => "connected", - refreshCacheAfterReconnect: () => null, - onSave, - } - return { callbacks, onSave } -} - -/** Stub TUI — captures requestRender calls. */ -function makeTui(rows = 40): { requestRender: ReturnType; terminal: { rows: number } } { - return { requestRender: vi.fn(), terminal: { rows } } -} - -/** Empty provenance map (all servers treated as user-config). */ -function makeProvenance(serverName = "my-server", path = "/tmp/mcp-test.json"): Map { - return new Map([[serverName, { path, kind: "user" }]]) -} - -/** - * Create a panel and return it together with its callbacks and a done spy. - * The panel starts with the server row focused (cursorIndex = 0). - */ -function makePanel(opts?: { - config?: McpConfig - cache?: MetadataCache | null - provenance?: Map - serverName?: string -}) { - const serverName = opts?.serverName ?? "my-server" - const config = opts?.config ?? makeConfig(serverName) - const cache = opts?.cache !== undefined ? opts.cache : makeCache(serverName) - const provenance = opts?.provenance ?? makeProvenance(serverName) - const { callbacks, onSave } = makeCallbacks() - const tui = makeTui() - const done = vi.fn<(result: McpPanelResult) => void>() - const panel = createMcpPanel(config, cache, provenance, callbacks, tui, done) - return { panel, callbacks, onSave, tui, done } -} - -/** - * Strip ANSI escape sequences from a string so assertions on rendered text - * work without depending on exact color codes. - */ -function stripAnsi(s: string): string { - // biome-ignore lint/suspicious/noControlCharactersInRegex: needed to strip ANSI - return s.replace(/\x1b\[[\d;]*[A-Za-z]/g, "") -} - -/** Collect all rendered lines as plain text (ANSI stripped). */ -function renderText(panel: ReturnType, width = 80): string[] { - return panel.render(width).map(stripAnsi) -} - -const LIMITS = { maxVisible: 12, minVisible: 3, fixedOverheadRows: 16 } - -describe("computeVisibleWindow", () => { - describe("maxVis clamping by terminal height", () => { - it("clamps to MIN_VISIBLE when terminal is too small (rows < overhead)", () => { - const { maxVis } = computeVisibleWindow(5, 0, 50, LIMITS) - expect(maxVis).toBe(3) - }) - - it("clamps to MIN_VISIBLE when terminal exactly matches overhead", () => { - const { maxVis } = computeVisibleWindow(16, 0, 50, LIMITS) - expect(maxVis).toBe(3) - }) - - it("scales with terminal height between MIN and MAX", () => { - const { maxVis } = computeVisibleWindow(20, 0, 50, LIMITS) - expect(maxVis).toBe(4) - }) - - it("clamps to MAX_VISIBLE when terminal is large enough", () => { - const { maxVis } = computeVisibleWindow(28, 0, 50, LIMITS) - expect(maxVis).toBe(12) - }) - - it("stays at MAX_VISIBLE for very large terminals", () => { - const { maxVis } = computeVisibleWindow(100, 0, 50, LIMITS) - expect(maxVis).toBe(12) - }) - }) - - describe("startIdx/endIdx windowing", () => { - it("starts at 0 when cursor is at the top", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 0, 78, LIMITS) - expect(startIdx).toBe(0) - expect(endIdx).toBe(12) - }) - - it("centers cursor in the middle of the list", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 40, 78, LIMITS) - expect(startIdx).toBe(34) - expect(endIdx).toBe(46) - }) - - it("clamps startIdx so the window stays within total at end of list", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 77, 78, LIMITS) - expect(startIdx).toBe(66) - expect(endIdx).toBe(78) - }) - - it("does not exceed total when list is shorter than maxVis", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 0, 5, LIMITS) - expect(startIdx).toBe(0) - expect(endIdx).toBe(5) - }) - - it("does not exceed total when list is exactly maxVis", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 6, 12, LIMITS) - expect(startIdx).toBe(0) - expect(endIdx).toBe(12) - }) - - it("handles empty list gracefully", () => { - const { startIdx, endIdx } = computeVisibleWindow(100, 0, 0, LIMITS) - expect(startIdx).toBe(0) - expect(endIdx).toBe(0) - }) - }) - - describe("interaction between small terminal and large list", () => { - it("scrolls correctly with reduced maxVis on small terminal", () => { - const { maxVis, startIdx, endIdx } = computeVisibleWindow(20, 50, 78, LIMITS) - expect(maxVis).toBe(4) - expect(startIdx).toBe(48) - expect(endIdx).toBe(52) - }) - - it("end-of-list windowing works on small terminal", () => { - const { maxVis, startIdx, endIdx } = computeVisibleWindow(20, 77, 78, LIMITS) - expect(maxVis).toBe(4) - expect(startIdx).toBe(74) - expect(endIdx).toBe(78) - }) - }) -}) - -// ─── ctrl+s save behaviour ─────────────────────────────────────────────────────────────── - -describe("McpPanel ctrl+s", () => { - const CTRL_S = "\x13" - - it("does nothing when there are no changes", () => { - const { panel, onSave, tui } = makePanel() - panel.handleInput(CTRL_S) - expect(onSave).not.toHaveBeenCalled() - // render is still requested so the panel redraws (clears any stale notice) - expect(tui.requestRender).toHaveBeenCalled() - }) - - it("calls onSave with the correct changes map after toggling a tool", () => { - const { panel, onSave } = makePanel() - - // Expand the server (cursor is on server row, press return) - panel.handleInput("\r") - // Move down to the tool row - panel.handleInput("\x1b[B") - // Toggle the tool direct (space bar) - panel.handleInput(" ") - // Save - panel.handleInput(CTRL_S) - - expect(onSave).toHaveBeenCalledOnce() - const [changesArg] = onSave.mock.calls[0] as [Map] - // Tool was off (false) by default; toggled to on. With 1 out of 1 tools - // direct, buildResult emits `true` for the server. - expect(changesArg.get("my-server")).toBe(true) - }) - - it("commits the new baseline so a second ctrl+s does not call onSave again", () => { - const { panel, onSave } = makePanel() - - panel.handleInput("\r") // expand - panel.handleInput("\x1b[B") // move to tool - panel.handleInput(" ") // toggle - panel.handleInput(CTRL_S) // save — should fire onSave - panel.handleInput(CTRL_S) // save again — no new changes, should NOT fire again - - expect(onSave).toHaveBeenCalledOnce() - }) - - it("shows the save notice in the rendered output after saving", () => { - const { panel } = makePanel() - - panel.handleInput("\r") - panel.handleInput("\x1b[B") - panel.handleInput(" ") - panel.handleInput(CTRL_S) - - const lines = renderText(panel) - const hasSaveNotice = lines.some((l) => l.includes("Saved")) - expect(hasSaveNotice).toBe(true) - }) - - it("clears the save notice on the next keypress", () => { - const { panel } = makePanel() - - panel.handleInput("\r") - panel.handleInput("\x1b[B") - panel.handleInput(" ") - panel.handleInput(CTRL_S) - // Any subsequent key clears notices (handleInput resets them) - panel.handleInput("\x1b[A") // up arrow - - const lines = renderText(panel) - const hasSaveNotice = lines.some((l) => l.includes("Saved")) - expect(hasSaveNotice).toBe(false) - }) -}) - -// ─── return key toggles focusDescription on tool rows ───────────────────────────────── - -describe("McpPanel return on tool row", () => { - it("shows the description block in the render output after pressing return on a tool", () => { - const { panel } = makePanel() - - // Expand the server - panel.handleInput("\r") - // Move cursor to the tool row - panel.handleInput("\x1b[B") - // Press return on the tool - panel.handleInput("\r") - - const lines = renderText(panel) - // The description header contains “▼ server — toolname” - const hasDescHeader = lines.some((l) => l.includes("▼") && l.includes("my-server") && l.includes("my_tool")) - expect(hasDescHeader).toBe(true) - }) - - it("hides the description block on the second return press (toggle off)", () => { - const { panel } = makePanel() - - panel.handleInput("\r") // expand server - panel.handleInput("\x1b[B") // move to tool - panel.handleInput("\r") // open description - panel.handleInput("\r") // close description - - const lines = renderText(panel) - const hasDescHeader = lines.some((l) => l.includes("▼") && l.includes("my-server") && l.includes("my_tool")) - expect(hasDescHeader).toBe(false) - }) - - it("clears the description when the cursor moves to a different tool", () => { - // Two tools in the same server so we can navigate between them. - const config: McpConfig = { mcpServers: { "my-server": { command: "npx", args: [] } } } - const cache: MetadataCache = { - version: 1, - servers: { - "my-server": { - configHash: "abc", - tools: [ - { name: "tool_a", description: "Alpha" }, - { name: "tool_b", description: "Beta" }, - ], - resources: [], - cachedAt: Date.now(), - }, - }, - } - const { panel } = makePanel({ config, cache }) - - panel.handleInput("\r") // expand server - panel.handleInput("\x1b[B") // move to tool_a - panel.handleInput("\r") // open description for tool_a - // Navigate down to tool_b — moving the cursor clears focusDescription - panel.handleInput("\x1b[B") - - const lines = renderText(panel) - // Neither description header should be visible - const hasAnyDescHeader = lines.some((l) => l.includes("▼")) - expect(hasAnyDescHeader).toBe(false) - }) - - it("does not toggle isDirect when return is pressed on a tool (space still does)", () => { - const { panel, onSave } = makePanel() - - panel.handleInput("\r") // expand server - panel.handleInput("\x1b[B") // move to tool - panel.handleInput("\r") // open description (was: toggle isDirect in old code) - panel.handleInput("\x13") // ctrl+s - - // No changes because return did not toggle isDirect - expect(onSave).not.toHaveBeenCalled() - }) -}) - -describe("narrow terminals", () => { - // Regression: unclamped innerW and border title math produced negative - // "─".repeat counts at narrow widths, crashing with RangeError. - for (const width of [1, 2, 3, 4, 5, 8, 10, 16, 24]) { - it(`renders without crashing or overflowing at width ${width}`, () => { - const { panel } = makePanel() - let lines: string[] = [] - expect(() => { - lines = panel.render(width) - }).not.toThrow() - for (const line of lines) { - expect(visibleWidth(line)).toBeLessThanOrEqual(width) - } - }) - } -}) diff --git a/src/extensions/mcp-adapter/mcp-panel.ts b/src/extensions/mcp-adapter/mcp-panel.ts deleted file mode 100644 index 32b3c5aef..000000000 --- a/src/extensions/mcp-adapter/mcp-panel.ts +++ /dev/null @@ -1,829 +0,0 @@ -import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui" -import { fg } from "../../ansi.js" -import { truncateLinesToWidth } from "../../truncate-lines.js" -import type { CachedTool, MetadataCache, ServerCacheEntry } from "./metadata-cache.js" -import { resourceNameToToolName } from "./resource-tools.js" -import type { McpConfig, McpPanelCallbacks, McpPanelResult, ServerProvenance } from "./types.js" -import { isToolExcluded } from "./types.js" - -interface PanelTheme { - border: string - title: string - selected: string - direct: string - needsAuth: string - placeholder: string - description: string - hint: string - confirm: string - cancel: string -} - -const DEFAULT_THEME: PanelTheme = { - border: "2", - title: "2", - selected: "36", - direct: "32", - needsAuth: "33", - placeholder: "2;3", - description: "2", - hint: "2", - confirm: "32", - cancel: "31", -} - -const RAINBOW_COLORS = [ - "38;2;178;129;214", - "38;2;215;135;175", - "38;2;254;188;56", - "38;2;228;192;15", - "38;2;137;210;129", - "38;2;0;175;175", - "38;2;23;143;185", -] - -function rainbowProgress(filled: number, total: number): string { - const dots: string[] = [] - for (let i = 0; i < total; i++) { - const color = RAINBOW_COLORS[i % RAINBOW_COLORS.length] - dots.push(fg(color, i < filled ? "●" : "○")) - } - return dots.join(" ") -} - -function fuzzyScore(query: string, text: string): number { - const lq = query.toLowerCase() - const lt = text.toLowerCase() - if (lt.includes(lq)) return 100 + (lq.length / lt.length) * 50 - let score = 0 - let qi = 0 - let consecutive = 0 - for (let i = 0; i < lt.length && qi < lq.length; i++) { - if (lt[i] === lq[qi]) { - score += 10 + consecutive - consecutive += 5 - qi++ - } else { - consecutive = 0 - } - } - return qi === lq.length ? score : 0 -} - -function estimateTokens(tool: CachedTool): number { - const schemaLen = JSON.stringify(tool.inputSchema ?? {}).length - const descLen = tool.description?.length ?? 0 - return Math.ceil((tool.name.length + descLen + schemaLen) / 4) + 10 -} - -type ConnectionStatus = "connected" | "idle" | "failed" | "needs-auth" | "connecting" - -interface ToolState { - name: string - description: string - isDirect: boolean - wasDirect: boolean - estimatedTokens: number -} - -interface ServerState { - name: string - expanded: boolean - source: "user" | "project" | "import" - importKind?: string - excludeTools?: string[] - exposeResources: boolean - connectionStatus: ConnectionStatus - tools: ToolState[] - hasCachedData: boolean -} - -interface VisibleItem { - type: "server" | "tool" - serverIndex: number - toolIndex?: number -} - -/** - * Compute the visible-item window for the panel given the terminal height, - * cursor position, and total item count. - * - * - `maxVis` adapts to the terminal so the bottom controls stay on-screen - * on small terminals: it's clamped to [MIN_VISIBLE, MAX_VISIBLE] around - * `terminalRows - FIXED_OVERHEAD_ROWS`. - * - `startIdx`/`endIdx` slide the visible window so the cursor stays roughly - * centered while staying within `[0, total]`. - */ -export function computeVisibleWindow( - terminalRows: number, - cursorIndex: number, - total: number, - limits: { maxVisible: number; minVisible: number; fixedOverheadRows: number } = { - maxVisible: 12, - minVisible: 3, - fixedOverheadRows: 16, - }, -): { maxVis: number; startIdx: number; endIdx: number } { - const maxVis = Math.max(limits.minVisible, Math.min(limits.maxVisible, terminalRows - limits.fixedOverheadRows)) - const startIdx = Math.max(0, Math.min(cursorIndex - Math.floor(maxVis / 2), total - maxVis)) - const endIdx = Math.min(startIdx + maxVis, total) - return { maxVis, startIdx, endIdx } -} - -class McpPanel { - private prefix: "server" | "none" | "short" - private servers: ServerState[] = [] - private cursorIndex = 0 - private nameQuery = "" - private descSearchActive = false - private descQuery = "" - private dirty = false - private confirmingDiscard = false - private discardSelected = 1 - private importNotice: string | null = null - private authNotice: string | null = null - private saveNotice: string | null = null - private focusDescription: { serverName: string; toolName: string; text: string } | null = null - private inactivityTimeout: ReturnType | null = null - private visibleItems: VisibleItem[] = [] - private tui: { requestRender(force?: boolean): void; terminal: { rows: number } } - private t = DEFAULT_THEME - - private static readonly MAX_VISIBLE = 12 - // Borders, search row, dividers, empty rows, progress row, stats row, ~2 hint rows. - // Subtract from terminal rows to bound visible items so the bottom controls stay on-screen. - private static readonly FIXED_OVERHEAD_ROWS = 16 - private static readonly MIN_VISIBLE = 3 - private static readonly INACTIVITY_MS = 60_000 - - constructor( - config: McpConfig, - cache: MetadataCache | null, - provenance: Map, - private callbacks: McpPanelCallbacks, - tui: { requestRender(force?: boolean): void; terminal: { rows: number } }, - private done: (result: McpPanelResult) => void, - ) { - this.tui = tui - this.prefix = config.settings?.toolPrefix ?? "server" - - for (const [serverName, definition] of Object.entries(config.mcpServers)) { - const prov = provenance.get(serverName) - const serverCache = cache?.servers?.[serverName] - - const globalDirect = config.settings?.directTools - let toolFilter: true | string[] | false = false - if (definition.directTools !== undefined) { - toolFilter = definition.directTools - } else if (globalDirect) { - toolFilter = globalDirect - } - - const tools: ToolState[] = [] - if (serverCache) { - for (const tool of serverCache.tools ?? []) { - if (isToolExcluded(tool.name, serverName, this.prefix, definition.excludeTools)) { - continue - } - - const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(tool.name)) - tools.push({ - name: tool.name, - description: tool.description ?? "", - isDirect, - wasDirect: isDirect, - estimatedTokens: estimateTokens(tool), - }) - } - if (definition.exposeResources !== false) { - for (const resource of serverCache.resources ?? []) { - const baseName = `get_${resourceNameToToolName(resource.name)}` - if (isToolExcluded(baseName, serverName, this.prefix, definition.excludeTools)) { - continue - } - - const isDirect = toolFilter === true || (Array.isArray(toolFilter) && toolFilter.includes(baseName)) - const ct: CachedTool = { name: baseName, description: resource.description } - tools.push({ - name: baseName, - description: resource.description ?? `Read resource: ${resource.uri}`, - isDirect, - wasDirect: isDirect, - estimatedTokens: estimateTokens(ct), - }) - } - } - } - - const status = callbacks.getConnectionStatus(serverName) - - this.servers.push({ - name: serverName, - expanded: false, - source: prov?.kind ?? "user", - importKind: prov?.importKind, - excludeTools: definition.excludeTools, - exposeResources: definition.exposeResources !== false, - connectionStatus: status, - tools, - hasCachedData: !!serverCache, - }) - } - - this.rebuildVisibleItems() - this.resetInactivityTimeout() - } - - private resetInactivityTimeout(): void { - if (this.inactivityTimeout) clearTimeout(this.inactivityTimeout) - this.inactivityTimeout = setTimeout(() => { - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - }, McpPanel.INACTIVITY_MS) - } - - private cleanup(): void { - if (this.inactivityTimeout) { - clearTimeout(this.inactivityTimeout) - this.inactivityTimeout = null - } - } - - private rebuildVisibleItems(): void { - const query = this.descSearchActive ? this.descQuery : this.nameQuery - const mode = this.descSearchActive ? "desc" : "name" - - this.visibleItems = [] - for (let si = 0; si < this.servers.length; si++) { - const server = this.servers[si] - this.visibleItems.push({ type: "server", serverIndex: si }) - if (server.expanded || query) { - for (let ti = 0; ti < server.tools.length; ti++) { - const tool = server.tools[ti] - if (query) { - const score = - mode === "name" - ? Math.max(fuzzyScore(query, tool.name), fuzzyScore(query, server.name) * 0.6) - : fuzzyScore(query, tool.description) - if (score === 0) continue - } - this.visibleItems.push({ type: "tool", serverIndex: si, toolIndex: ti }) - } - } - } - - if (query) { - this.visibleItems = this.visibleItems.filter((item) => { - if (item.type === "server") { - return this.visibleItems.some((other) => other.type === "tool" && other.serverIndex === item.serverIndex) - } - return true - }) - } - } - - private updateDirty(): void { - this.dirty = this.servers.some((s) => s.tools.some((t) => t.isDirect !== t.wasDirect)) - } - - private buildResult(): McpPanelResult { - const changes = new Map() - for (const server of this.servers) { - const changed = server.tools.some((t) => t.isDirect !== t.wasDirect) - if (!changed) continue - const directTools = server.tools.filter((t) => t.isDirect) - if (directTools.length === server.tools.length && server.tools.length > 0) { - changes.set(server.name, true) - } else if (directTools.length === 0) { - changes.set(server.name, false) - } else { - changes.set( - server.name, - directTools.map((t) => t.name), - ) - } - } - return { changes, cancelled: false } - } - - handleInput(data: string): void { - this.resetInactivityTimeout() - this.importNotice = null - this.authNotice = null - this.saveNotice = null - - if (this.confirmingDiscard) { - this.handleDiscardInput(data) - return - } - - // Global shortcuts — always work, even during desc search - if (matchesKey(data, "ctrl+c")) { - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - return - } - - if (matchesKey(data, "ctrl+s")) { - const result = this.buildResult() - if (result.changes.size > 0) { - this.callbacks.onSave(result.changes) - // Commit saved values as the new baseline so dirty/unsaved clears. - this.servers.forEach((s) => { - s.tools.forEach((t) => { - t.wasDirect = t.isDirect - }) - }) - this.updateDirty() - this.saveNotice = "Saved ✓ — restart kimchi to apply" - } - this.tui.requestRender() - return - } - - // Modal description search mode - if (this.descSearchActive) { - if (matchesKey(data, "escape") || matchesKey(data, "return")) { - this.focusDescription = null - this.descSearchActive = false - this.descQuery = "" - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - return - } - if (matchesKey(data, "backspace")) { - this.focusDescription = null - if (this.descQuery.length > 0) { - this.descQuery = this.descQuery.slice(0, -1) - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - } - return - } - if (matchesKey(data, "up")) { - this.focusDescription = null - this.moveCursor(-1) - return - } - if (matchesKey(data, "down")) { - this.focusDescription = null - this.moveCursor(1) - return - } - if (matchesKey(data, "space")) { - this.focusDescription = null - // Toggle even while in desc search - const item = this.visibleItems[this.cursorIndex] - if (item) this.toggleItem(item) - return - } - if (data.length === 1 && data.charCodeAt(0) >= 32) { - this.focusDescription = null - this.descQuery += data - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - return - } - return - } - - if (matchesKey(data, "escape")) { - if (this.focusDescription) { - this.focusDescription = null - this.tui.requestRender() - return - } - if (this.nameQuery) { - this.nameQuery = "" - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - return - } - if (this.dirty) { - this.confirmingDiscard = true - this.discardSelected = 1 - return - } - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - return - } - - if (matchesKey(data, "up")) { - this.focusDescription = null - this.moveCursor(-1) - return - } - if (matchesKey(data, "down")) { - this.focusDescription = null - this.moveCursor(1) - return - } - - if (matchesKey(data, "space")) { - const item = this.visibleItems[this.cursorIndex] - if (item) this.toggleItem(item) - this.focusDescription = null - return - } - - if (matchesKey(data, "return")) { - const item = this.visibleItems[this.cursorIndex] - if (!item) return - const server = this.servers[item.serverIndex] - if (item.type === "server") { - if (server.connectionStatus === "needs-auth") { - this.authNotice = `OAuth required — run /mcp-auth ${server.name} after closing this panel` - return - } - server.expanded = !server.expanded - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - } else if (item.toolIndex !== undefined) { - const tool = server.tools[item.toolIndex] - const fd = this.focusDescription - if (fd && fd.serverName === server.name && fd.toolName === tool.name) { - this.focusDescription = null - } else { - this.focusDescription = { serverName: server.name, toolName: tool.name, text: tool.description ?? "" } - } - } - this.tui.requestRender() - return - } - - if (matchesKey(data, "ctrl+r")) { - this.focusDescription = null - const item = this.visibleItems[this.cursorIndex] - if (!item) return - const server = this.servers[item.serverIndex] - if (server.connectionStatus === "connecting") return - server.connectionStatus = "connecting" - this.callbacks - .reconnect(server.name) - .then(() => { - server.connectionStatus = this.callbacks.getConnectionStatus(server.name) - if (server.connectionStatus === "connected") { - const entry = this.callbacks.refreshCacheAfterReconnect(server.name) - if (entry) { - this.rebuildServerTools(server, entry) - } - server.hasCachedData = true - } - this.tui.requestRender() - }) - .catch((error) => { - server.connectionStatus = "failed" - const message = error instanceof Error ? error.message : String(error) - this.authNotice = `Reconnect failed for ${server.name}: ${message}` - this.tui.requestRender() - }) - return - } - - if (data === "?") { - this.focusDescription = null - this.descSearchActive = true - this.descQuery = "" - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - return - } - - // Backspace removes from name query - if (matchesKey(data, "backspace")) { - this.focusDescription = null - if (this.nameQuery.length > 0) { - this.nameQuery = this.nameQuery.slice(0, -1) - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - } - return - } - - // All other printable chars → always-on name search - if (data.length === 1 && data.charCodeAt(0) >= 32) { - this.focusDescription = null - this.nameQuery += data - this.rebuildVisibleItems() - this.cursorIndex = Math.min(this.cursorIndex, Math.max(0, this.visibleItems.length - 1)) - return - } - } - - private toggleItem(item: VisibleItem): void { - const server = this.servers[item.serverIndex] - if (item.type === "server") { - const newState = !server.tools.every((t) => t.isDirect) - if (server.source === "import" && newState) { - this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save` - } - for (const t of server.tools) t.isDirect = newState - } else if (item.toolIndex !== undefined) { - const tool = server.tools[item.toolIndex] - tool.isDirect = !tool.isDirect - if (tool.isDirect && server.source === "import") { - this.importNotice = `Imported from ${server.importKind ?? "external"} — will copy to user config on save` - } - } - this.updateDirty() - } - - private handleDiscardInput(data: string): void { - if (matchesKey(data, "ctrl+c")) { - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - return - } - if (matchesKey(data, "escape") || data === "n" || data === "N") { - this.confirmingDiscard = false - return - } - if (matchesKey(data, "return")) { - if (this.discardSelected === 0) { - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - } else { - this.confirmingDiscard = false - } - return - } - if (data === "y" || data === "Y") { - this.cleanup() - this.done({ cancelled: true, changes: new Map() }) - return - } - if (matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab")) { - this.discardSelected = this.discardSelected === 0 ? 1 : 0 - } - } - - private moveCursor(delta: number): void { - if (this.visibleItems.length === 0) return - this.cursorIndex = Math.max(0, Math.min(this.visibleItems.length - 1, this.cursorIndex + delta)) - } - - private rebuildServerTools(server: ServerState, entry: ServerCacheEntry): void { - const existingState = new Map() - for (const t of server.tools) existingState.set(t.name, t.isDirect) - - const newTools: ToolState[] = [] - for (const tool of entry.tools ?? []) { - if (isToolExcluded(tool.name, server.name, this.prefix, server.excludeTools)) { - continue - } - - const prev = existingState.get(tool.name) - const isDirect = prev !== undefined ? prev : false - newTools.push({ - name: tool.name, - description: tool.description ?? "", - isDirect, - wasDirect: prev !== undefined ? (server.tools.find((t) => t.name === tool.name)?.wasDirect ?? false) : false, - estimatedTokens: estimateTokens(tool), - }) - } - - if (server.exposeResources) { - for (const resource of entry.resources ?? []) { - const baseName = `get_${resourceNameToToolName(resource.name)}` - if (isToolExcluded(baseName, server.name, this.prefix, server.excludeTools)) { - continue - } - - const prev = existingState.get(baseName) - const isDirect = prev !== undefined ? prev : false - const ct: CachedTool = { name: baseName, description: resource.description } - newTools.push({ - name: baseName, - description: resource.description ?? `Read resource: ${resource.uri}`, - isDirect, - wasDirect: prev !== undefined ? (server.tools.find((t) => t.name === baseName)?.wasDirect ?? false) : false, - estimatedTokens: estimateTokens(ct), - }) - } - } - - server.tools = newTools - this.rebuildVisibleItems() - this.updateDirty() - } - - render(width: number): string[] { - const innerW = Math.max(1, width - 2) - const lines: string[] = [] - const t = this.t - const bold = (s: string) => `\x1b[1m${s}\x1b[22m` - const italic = (s: string) => `\x1b[3m${s}\x1b[23m` - const inverse = (s: string) => `\x1b[7m${s}\x1b[27m` - - const row = (content: string) => - fg(t.border, "│") + truncateToWidth(` ${content}`, innerW, "…", true) + fg(t.border, "│") - const emptyRow = () => fg(t.border, "│") + " ".repeat(innerW) + fg(t.border, "│") - const divider = () => fg(t.border, `├${"─".repeat(innerW)}┤`) - - const titleText = " MCP Servers " - const borderLen = Math.max(0, innerW - visibleWidth(titleText)) - const leftB = Math.floor(borderLen / 2) - const rightB = borderLen - leftB - lines.push(fg(t.border, `╭${"─".repeat(leftB)}`) + fg(t.title, titleText) + fg(t.border, `${"─".repeat(rightB)}╮`)) - - lines.push(emptyRow()) - - const cursor = fg(t.selected, "│") - const searchIcon = fg(t.border, "◎") - if (this.descSearchActive) { - lines.push(row(`${searchIcon} ${fg(t.needsAuth, "desc:")} ${this.descQuery}${cursor}`)) - } else if (this.nameQuery) { - lines.push(row(`${searchIcon} ${this.nameQuery}${cursor}`)) - } else { - lines.push(row(`${searchIcon} ${fg(t.placeholder, italic("search..."))}`)) - } - - lines.push(emptyRow()) - lines.push(divider()) - - if (this.servers.length === 0) { - lines.push(emptyRow()) - lines.push(row(fg(t.hint, italic("No MCP servers configured.")))) - lines.push(emptyRow()) - } else { - const total = this.visibleItems.length - const { maxVis, startIdx, endIdx } = computeVisibleWindow(this.tui.terminal.rows, this.cursorIndex, total, { - maxVisible: McpPanel.MAX_VISIBLE, - minVisible: McpPanel.MIN_VISIBLE, - fixedOverheadRows: McpPanel.FIXED_OVERHEAD_ROWS, - }) - - lines.push(emptyRow()) - - for (let i = startIdx; i < endIdx; i++) { - const item = this.visibleItems[i] - const isCursor = i === this.cursorIndex - const server = this.servers[item.serverIndex] - - if (item.type === "server") { - lines.push(row(this.renderServerRow(server, isCursor))) - } else if (item.toolIndex !== undefined) { - lines.push(row(this.renderToolRow(server.tools[item.toolIndex], isCursor, innerW))) - } - } - - lines.push(emptyRow()) - - if (total > maxVis) { - const prog = Math.round(((this.cursorIndex + 1) / total) * 10) - lines.push(row(`${rainbowProgress(prog, 10)} ${fg(t.hint, `${this.cursorIndex + 1}/${total}`)}`)) - lines.push(emptyRow()) - } - - if (this.importNotice) { - lines.push(row(fg(t.needsAuth, italic(this.importNotice)))) - lines.push(emptyRow()) - } - if (this.authNotice) { - lines.push(row(fg(t.needsAuth, italic(this.authNotice)))) - lines.push(emptyRow()) - } - if (this.saveNotice) { - lines.push(row(fg(t.direct, italic(this.saveNotice)))) - lines.push(emptyRow()) - } - if (this.focusDescription) { - const label = fg(t.description, `▼ ${this.focusDescription.serverName} — ${this.focusDescription.toolName}`) - lines.push(row(label)) - const wrapped = wrapTextWithAnsi(this.focusDescription.text, innerW - 6) - for (const wl of wrapped) { - lines.push(row(fg(t.description, ` ${wl}`))) - } - lines.push(emptyRow()) - } - } - - lines.push(divider()) - lines.push(emptyRow()) - - if (this.confirmingDiscard) { - const discardBtn = - this.discardSelected === 0 ? inverse(bold(fg(t.cancel, " Discard "))) : fg(t.hint, " Discard ") - const keepBtn = this.discardSelected === 1 ? inverse(bold(fg(t.confirm, " Keep "))) : fg(t.hint, " Keep ") - lines.push(row(`Discard unsaved changes? ${discardBtn} ${keepBtn}`)) - } else { - const directCount = this.servers.reduce((sum, s) => sum + s.tools.filter((t) => t.isDirect).length, 0) - const totalTokens = this.servers.reduce( - (sum, s) => sum + s.tools.filter((t) => t.isDirect).reduce((ts, t) => ts + t.estimatedTokens, 0), - 0, - ) - const stats = - directCount > 0 ? `${directCount} direct ~${totalTokens.toLocaleString()} tokens` : "no direct tools" - lines.push(row(fg(t.description, stats + (this.dirty ? fg(t.needsAuth, " (unsaved)") : "")))) - } - - lines.push(emptyRow()) - const hints = [ - `${italic("↑↓")} navigate`, - `${italic("space")} toggle`, - `${italic("⏎")} expand`, - `${italic("ctrl+r")} reconnect`, - `${italic("?")} desc search`, - `${italic("ctrl+s")} save`, - `${italic("esc")} clear/close`, - `${italic("ctrl+c")} quit`, - ] - const gap = " " - const gapW = 2 - const maxW = innerW - 2 - let curLine = "" - let curW = 0 - for (const hint of hints) { - const hw = visibleWidth(hint) - const needed = curW === 0 ? hw : gapW + hw - if (curW > 0 && curW + needed > maxW) { - lines.push(row(fg(t.hint, curLine))) - curLine = hint - curW = hw - } else { - curLine += (curW > 0 ? gap : "") + hint - curW += needed - } - } - if (curLine) lines.push(row(fg(t.hint, curLine))) - - lines.push(fg(t.border, `╰${"─".repeat(innerW)}╯`)) - - return truncateLinesToWidth(lines, width) - } - - private renderServerRow(server: ServerState, isCursor: boolean): string { - const t = this.t - const bold = (s: string) => `\x1b[1m${s}\x1b[22m` - - const expandIcon = server.expanded ? "▾" : "▸" - const prefix = isCursor ? fg(t.selected, expandIcon) : fg(t.border, server.expanded ? expandIcon : "·") - - const nameStr = isCursor ? bold(fg(t.selected, server.name)) : server.name - const importLabel = server.source === "import" ? fg(t.description, ` (${server.importKind ?? "import"})`) : "" - - if (!server.hasCachedData) { - return `${prefix} ${nameStr}${importLabel} ${fg(t.description, "(not cached)")}` - } - - const directCount = server.tools.filter((t) => t.isDirect).length - const totalCount = server.tools.length - let toggleIcon = fg(t.description, "○") - if (directCount === totalCount && totalCount > 0) { - toggleIcon = fg(t.direct, "●") - } else if (directCount > 0) { - toggleIcon = fg(t.needsAuth, "◐") - } - - let toolInfo = "" - if (totalCount > 0) { - toolInfo = `${directCount}/${totalCount}` - if (directCount > 0) { - const tokens = server.tools.filter((t) => t.isDirect).reduce((s, t) => s + t.estimatedTokens, 0) - toolInfo += ` ~${tokens.toLocaleString()}` - } - toolInfo = fg(t.description, toolInfo) - } - - return `${prefix} ${toggleIcon} ${nameStr}${importLabel} ${toolInfo}` - } - - private renderToolRow(tool: ToolState, isCursor: boolean, innerW: number): string { - const t = this.t - const bold = (s: string) => `\x1b[1m${s}\x1b[22m` - - const toggleIcon = tool.isDirect ? fg(t.direct, "●") : fg(t.description, "○") - const cursor = isCursor ? fg(t.selected, "▸") : " " - const nameStr = isCursor ? bold(fg(t.selected, tool.name)) : tool.name - - const prefixLen = 7 + visibleWidth(tool.name) - const maxDescLen = Math.max(0, innerW - prefixLen - 8) - // Tool descriptions are often multi-line docstrings (e.g. "Args:\n ..."). The - // newlines have visible width 0 but break terminal layout when emitted. Collapse - // any whitespace/control sequence to a single space before truncating. - const flatDesc = tool.description ? tool.description.replace(/\s+/g, " ").trim() : "" - const descStr = - maxDescLen > 5 && flatDesc ? fg(t.description, `— ${truncateToWidth(flatDesc, maxDescLen, "…")}`) : "" - - return ` ${cursor} ${toggleIcon} ${nameStr} ${descStr}` - } - - invalidate(): void {} - - dispose(): void { - this.cleanup() - } -} - -export function createMcpPanel( - config: McpConfig, - cache: MetadataCache | null, - provenance: Map, - callbacks: McpPanelCallbacks, - tui: { requestRender(force?: boolean): void; terminal: { rows: number } }, - done: (result: McpPanelResult) => void, -): McpPanel & { dispose(): void } { - return new McpPanel(config, cache, provenance, callbacks, tui, done) -} diff --git a/src/extensions/mcp-adapter/metadata-cache.test.ts b/src/extensions/mcp-adapter/metadata-cache.test.ts deleted file mode 100644 index 88ad9357e..000000000 --- a/src/extensions/mcp-adapter/metadata-cache.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Unit tests for the cache annotation round-trip. - * - * Verifies that `serializeTools` → `reconstructToolMetadata` preserves an - * MCP tool's `annotations`: - * - A tool annotated `{ readOnlyHint: true }` round-trips with that hint - * intact on the reconstructed `ToolMetadata`. - * - A tool with no `annotations` reconstructs with `annotations === undefined`. - * - * This covers the cache serialize→reconstruct path that backs both the proxy - * tool path and the direct-tools cached-metadata construction. - */ -import { describe, expect, it } from "vitest" -import type { ServerCacheEntry } from "./metadata-cache.js" -import { reconstructToolMetadata, serializeTools } from "./metadata-cache.js" -import type { McpTool } from "./types.js" - -const SERVER_NAME = "testserver" - -function reconstruct(serialized: ReturnType): ServerCacheEntry { - return { - configHash: "deadbeef", - tools: serialized, - resources: [], - cachedAt: Date.now(), - } -} - -describe("cache annotation round-trip (serializeTools → reconstructToolMetadata)", () => { - it("preserves readOnlyHint:true through serialize → reconstruct", () => { - const tools: McpTool[] = [ - { - name: "get_record", - description: "Fetch a record", - annotations: { readOnlyHint: true }, - }, - ] - - const serialized = serializeTools(tools) - // Sanity: the CachedTool carries annotations. - expect(serialized[0].annotations?.readOnlyHint).toBe(true) - - const [reconstructed] = reconstructToolMetadata(SERVER_NAME, reconstruct(serialized), "server", {}) - - expect(reconstructed.originalName).toBe("get_record") - expect(reconstructed.annotations).toBeDefined() - expect(reconstructed.annotations?.readOnlyHint).toBe(true) - }) - - it("reconstructs annotations === undefined for an un-annotated tool", () => { - const tools: McpTool[] = [ - { - name: "create_record", - description: "Create a record", - }, - ] - - const serialized = serializeTools(tools) - // Sanity: the CachedTool has no annotations key. - expect(serialized[0].annotations).toBeUndefined() - - const [reconstructed] = reconstructToolMetadata(SERVER_NAME, reconstruct(serialized), "server", {}) - - expect(reconstructed.originalName).toBe("create_record") - expect(reconstructed.annotations).toBeUndefined() - }) - - it("round-trips a destructive write tool's annotations", () => { - const tools: McpTool[] = [ - { - name: "delete_record", - description: "Delete a record", - annotations: { readOnlyHint: false, destructiveHint: true }, - }, - ] - - const serialized = serializeTools(tools) - const [reconstructed] = reconstructToolMetadata(SERVER_NAME, reconstruct(serialized), "server", {}) - - expect(reconstructed.annotations?.readOnlyHint).toBe(false) - expect(reconstructed.annotations?.destructiveHint).toBe(true) - }) -}) diff --git a/src/extensions/mcp-adapter/metadata-cache.ts b/src/extensions/mcp-adapter/metadata-cache.ts deleted file mode 100644 index 737533a51..000000000 --- a/src/extensions/mcp-adapter/metadata-cache.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { createHash } from "node:crypto" -// metadata-cache.ts - Persistent MCP metadata cache -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge" -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js" -import { logger } from "./logger.js" -import { resourceNameToToolName } from "./resource-tools.js" -import type { McpResource, McpTool, ServerEntry, ToolMetadata } from "./types.js" -import { formatToolName, isToolExcluded } from "./types.js" -import { extractToolUiStreamMode, getAgentDir } from "./utils.js" - -const CACHE_VERSION = 1 -const CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 -let _cachePath: string | undefined -function getCachePath(): string { - // biome-ignore lint/suspicious/noAssignInExpressions: result is cached - return (_cachePath ??= join(getAgentDir(), "mcp-cache.json")) -} - -export interface CachedTool { - name: string - description?: string - inputSchema?: unknown - uiResourceUri?: string - uiStreamMode?: "eager" | "stream-first" - annotations?: ToolAnnotations // Read-only/destructive hints from the MCP protocol -} - -export interface CachedResource { - uri: string - name: string - description?: string -} - -export interface ServerCacheEntry { - configHash: string - tools: CachedTool[] - resources: CachedResource[] - cachedAt: number -} - -export interface MetadataCache { - version: number - servers: Record -} - -export function getMetadataCachePath(): string { - return getCachePath() -} - -/** - * Type guard to validate cache structure. - */ -function isValidCache(cache: unknown): cache is MetadataCache { - return !!( - cache && - typeof cache === "object" && - "version" in cache && - cache.version === CACHE_VERSION && - "servers" in cache && - cache.servers && - typeof cache.servers === "object" - ) -} - -/** - * Safely load existing cache from disk, returning empty cache on any error. - */ -function loadExistingCache(): MetadataCache { - const cache: MetadataCache = { version: CACHE_VERSION, servers: {} } - - if (!existsSync(getCachePath())) return cache - - try { - const existing = JSON.parse(readFileSync(getCachePath(), "utf-8")) - if (isValidCache(existing)) { - cache.servers = { ...existing.servers } - } - } catch { - // Ignore parse errors and return empty cache - } - - return cache -} - -/** - * Atomically write cache data to disk using temp file + rename. - */ -function atomicWriteCache(data: MetadataCache): void { - const cachePath = getCachePath() - const tmpPath = `${cachePath}.${process.pid}.tmp` - - mkdirSync(dirname(cachePath), { recursive: true }) - writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8") - renameSync(tmpPath, cachePath) -} - -/** - * Best-effort cleanup of temporary file. Swallows all errors. - */ -function cleanupTempFile(tmpPath: string): void { - try { - if (existsSync(tmpPath)) unlinkSync(tmpPath) - } catch { - // nothing to do — the next run will overwrite or ignore stale temp files - } -} - -export function loadMetadataCache(): MetadataCache | null { - if (!existsSync(getCachePath())) return null - try { - const raw = JSON.parse(readFileSync(getCachePath(), "utf-8")) - if (!isValidCache(raw)) return null - return raw - } catch { - return null - } -} - -export function saveMetadataCache(cache: MetadataCache): void { - const merged = loadExistingCache() - merged.servers = { ...merged.servers, ...cache.servers } - atomicWriteCache(merged) -} - -/** - * Replace the on-disk cache with the provided content (no merge with existing). - * Use only when you need to delete entries; for adds/updates prefer - * `saveMetadataCache` so concurrent writers don't clobber each other. - * - * I/O failures (read-only filesystem, full disk, permission denied) are logged - * and swallowed — the cache is a derived artifact and must never crash the - * extension host during startup. Callers that need to know the write succeeded - * should re-read with `loadMetadataCache()`. - */ -export function overwriteMetadataCache(cache: MetadataCache): void { - const cachePath = getCachePath() - const tmpPath = `${cachePath}.${process.pid}.tmp` - const out: MetadataCache = { version: CACHE_VERSION, servers: cache.servers ?? {} } - - try { - atomicWriteCache(out) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - logger.debug(`MCP: failed to overwrite metadata cache at ${cachePath}: ${message}`) - // Best-effort cleanup of a half-written temp file so we don't accumulate - // `.pid.tmp` dotfiles on repeated failures. If this throws too, drop it. - cleanupTempFile(tmpPath) - } -} - -/** - * Drop cache entries whose configHash no longer matches the current server - * definition. Orphan entries (cached servers not in the current config) are - * kept by default because the cache file is shared across projects — a server - * absent from this project's `mcp.json` is likely configured by another. - * - * Returns the cleaned cache and the list of removed server names. Caller is - * responsible for persisting via `overwriteMetadataCache` when - * `removed.length > 0`. - */ -export function purgeStaleEntries( - cache: MetadataCache | null, - mcpServers: Record, -): { cleaned: MetadataCache; removed: string[] } { - const cleaned: MetadataCache = { version: CACHE_VERSION, servers: {} } - const removed: string[] = [] - if (!cache?.servers) return { cleaned, removed } - - for (const [name, entry] of Object.entries(cache.servers)) { - const definition = mcpServers[name] - if (!definition) { - // Orphan from another project's config — preserve. - cleaned.servers[name] = entry - continue - } - if (!entry?.configHash || entry.configHash !== computeServerHash(definition)) { - removed.push(name) - continue - } - cleaned.servers[name] = entry - } - - return { cleaned, removed } -} - -export function computeServerHash(definition: ServerEntry): string { - // Hash only fields that affect server identity and tool/resource output. - // Exclude lifecycle, idleTimeout, debug — those are runtime behavior settings - // that don't change which tools a server exposes. - const identity: Record = { - command: definition.command, - args: definition.args, - env: definition.env, - cwd: definition.cwd, - url: definition.url, - headers: definition.headers, - auth: definition.auth, - bearerToken: definition.bearerToken, - bearerTokenEnv: definition.bearerTokenEnv, - exposeResources: definition.exposeResources, - excludeTools: definition.excludeTools, - } - const normalized = stableStringify(identity) - return createHash("sha256").update(normalized).digest("hex") -} - -export function isServerCacheValid( - entry: ServerCacheEntry, - definition: ServerEntry, - maxAgeMs: number = CACHE_MAX_AGE_MS, -): boolean { - if (!entry || entry.configHash !== computeServerHash(definition)) return false - if (!entry.cachedAt || typeof entry.cachedAt !== "number") return false - if (maxAgeMs > 0 && Date.now() - entry.cachedAt > maxAgeMs) return false - return true -} - -/** - * Build tool metadata if not excluded, otherwise return null. - */ -function buildToolMetadata( - toolName: string, - serverName: string, - prefix: "server" | "none" | "short", - excludeTools: ServerEntry["excludeTools"], - additionalFields: Partial, -): ToolMetadata | null { - if (isToolExcluded(toolName, serverName, prefix, excludeTools)) { - return null - } - - return { - name: formatToolName(toolName, serverName, prefix), - originalName: toolName, - description: "", - ...additionalFields, - } -} - -export function reconstructToolMetadata( - serverName: string, - entry: ServerCacheEntry, - prefix: "server" | "none" | "short", - definition: Pick, -): ToolMetadata[] { - const metadata: ToolMetadata[] = [] - - for (const tool of entry.tools ?? []) { - if (!tool?.name) continue - - const toolMetadata = buildToolMetadata(tool.name, serverName, prefix, definition.excludeTools, { - description: tool.description ?? "", - inputSchema: tool.inputSchema, - uiResourceUri: tool.uiResourceUri, - uiStreamMode: tool.uiStreamMode, - annotations: tool.annotations, - }) - - if (toolMetadata) { - metadata.push(toolMetadata) - } - } - - if (definition.exposeResources !== false) { - for (const resource of entry.resources ?? []) { - if (!resource?.name || !resource?.uri) continue - - const baseName = `get_${resourceNameToToolName(resource.name)}` - const resourceMetadata = buildToolMetadata(baseName, serverName, prefix, definition.excludeTools, { - description: resource.description ?? `Read resource: ${resource.uri}`, - resourceUri: resource.uri, - }) - - if (resourceMetadata) { - metadata.push(resourceMetadata) - } - } - } - - return metadata -} - -export function serializeTools(tools: McpTool[]): CachedTool[] { - return tools - .filter((t) => t?.name) - .map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - uiResourceUri: tryGetToolUiResourceUri(t), - uiStreamMode: extractToolUiStreamMode(t._meta), - annotations: t.annotations, - })) -} - -export function serializeResources(resources: McpResource[]): CachedResource[] { - return resources - .filter((r) => r?.name && r?.uri) - .map((r) => ({ - uri: r.uri, - name: r.name, - description: r.description, - })) -} - -function stableStringify(value: unknown): string { - if (value === null || value === undefined || typeof value !== "object") { - const serialized = JSON.stringify(value) - return serialized === undefined ? "undefined" : serialized - } - if (Array.isArray(value)) { - return `[${value.map((v) => stableStringify(v)).join(",")}]` - } - const obj = value as Record - const keys = Object.keys(obj).sort() - return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}` -} - -function tryGetToolUiResourceUri(tool: McpTool): string | undefined { - try { - return getToolUiResourceUri({ _meta: tool._meta }) - } catch { - return undefined - } -} diff --git a/src/extensions/mcp-adapter/npx-resolver.ts b/src/extensions/mcp-adapter/npx-resolver.ts deleted file mode 100644 index d34cc6440..000000000 --- a/src/extensions/mcp-adapter/npx-resolver.ts +++ /dev/null @@ -1,430 +0,0 @@ -import { spawn, spawnSync } from "node:child_process" -// npx-resolver.ts - Resolve npx/npm exec binaries to avoid npm parent processes -import { - closeSync, - existsSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - readSync, - realpathSync, - renameSync, - statSync, - writeFileSync, -} from "node:fs" -import { dirname, extname, join, resolve, sep } from "node:path" -import { getAgentDir } from "./utils.js" - -const CACHE_VERSION = 1 -const CACHE_TTL_MS = 24 * 60 * 60 * 1000 -let _cachePath: string | undefined -function getCachePath(): string { - // biome-ignore lint/suspicious/noAssignInExpressions: result is cached - return (_cachePath ??= join(getAgentDir(), "mcp-npx-cache.json")) -} - -interface NpxCacheEntry { - resolvedBin: string - resolvedAt: number - packageVersion?: string - isJs: boolean -} - -interface NpxCache { - version: number - entries: Record -} - -export interface NpxResolution { - binPath: string - extraArgs: string[] - isJs: boolean -} - -interface ParsedInvocation { - packageSpec: string - binName?: string - extraArgs: string[] -} - -export async function resolveNpxBinary(command: string, args: string[]): Promise { - const parsed = command === "npx" ? parseNpxArgs(args) : command === "npm" ? parseNpmExecArgs(args) : null - - if (!parsed) return null - - const cacheKey = JSON.stringify([command, ...args]) - const cache = loadCache() - const cached = cache?.entries?.[cacheKey] - - if (cached && Date.now() - cached.resolvedAt < CACHE_TTL_MS && existsSync(cached.resolvedBin)) { - return { binPath: cached.resolvedBin, extraArgs: parsed.extraArgs, isJs: cached.isJs } - } - - const resolved = resolveFromNpmCache(parsed.packageSpec, parsed.binName) - if (resolved) { - saveCacheEntry(cacheKey, resolved) - return { binPath: resolved.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolved.isJs } - } - - // Slow path: force npx cache population - await forceNpxCache(parsed.packageSpec) - const resolvedAfterInstall = resolveFromNpmCache(parsed.packageSpec, parsed.binName) - if (resolvedAfterInstall) { - saveCacheEntry(cacheKey, resolvedAfterInstall) - return { binPath: resolvedAfterInstall.resolvedBin, extraArgs: parsed.extraArgs, isJs: resolvedAfterInstall.isJs } - } - - return null -} - -function parseNpxArgs(args: string[]): ParsedInvocation | null { - const separatorIndex = args.indexOf("--") - const before = separatorIndex >= 0 ? args.slice(0, separatorIndex) : args - const after = separatorIndex >= 0 ? args.slice(separatorIndex + 1) : [] - - const positionals: string[] = [] - let packageSpec: string | undefined - let sawPackageFlag = false - let foundFirstPositional = false - - for (let i = 0; i < before.length; i++) { - const arg = before[i] - if (foundFirstPositional) { - positionals.push(arg) - continue - } - if (arg === "-y" || arg === "--yes") continue - if (arg === "-p" || arg === "--package") { - const value = before[i + 1] - if (!value || value.startsWith("-")) return null - if (!packageSpec) packageSpec = value - sawPackageFlag = true - i++ - continue - } - if (arg.startsWith("--package=")) { - const value = arg.slice("--package=".length) - if (!value) return null - if (!packageSpec) packageSpec = value - sawPackageFlag = true - continue - } - if (arg.startsWith("-")) { - return null - } - positionals.push(arg) - foundFirstPositional = true - } - - if (sawPackageFlag) { - const binName = positionals[0] - if (!packageSpec || !binName) return null - const extraArgs = positionals.slice(1).concat(after) - return { packageSpec, binName, extraArgs } - } - - const packagePositional = positionals[0] - if (!packagePositional) return null - const extraArgs = positionals.slice(1).concat(after) - return { packageSpec: packagePositional, extraArgs } -} - -function parseNpmExecArgs(args: string[]): ParsedInvocation | null { - if (args[0] !== "exec") return null - const execArgs = args.slice(1) - const separatorIndex = execArgs.indexOf("--") - if (separatorIndex < 0) return null - - const before = execArgs.slice(0, separatorIndex) - const after = execArgs.slice(separatorIndex + 1) - - let packageSpec: string | undefined - for (let i = 0; i < before.length; i++) { - const arg = before[i] - if (arg === "-y" || arg === "--yes") continue - if (arg === "--package") { - const value = before[i + 1] - if (!value || value.startsWith("-")) return null - if (!packageSpec) packageSpec = value - i++ - continue - } - if (arg.startsWith("--package=")) { - const value = arg.slice("--package=".length) - if (!value) return null - if (!packageSpec) packageSpec = value - continue - } - if (arg.startsWith("-")) { - return null - } - } - - const binName = after[0] - if (!packageSpec || !binName) return null - const extraArgs = after.slice(1) - return { packageSpec, binName, extraArgs } -} - -function resolveFromNpmCache(packageSpec: string, binName?: string): NpxCacheEntry | null { - const cacheDir = getNpmCacheDir() - if (!cacheDir) return null - - const packageName = extractPackageName(packageSpec) - if (!packageName) return null - - const packageDir = findCachedPackageDir(cacheDir, packageName) - if (!packageDir) return null - - const packageJsonPath = join(packageDir, "package.json") - if (!existsSync(packageJsonPath)) return null - - let pkg: { bin?: string | Record; version?: string } | null = null - try { - pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { - bin?: string | Record - version?: string - } - } catch { - return null - } - - const binField = pkg?.bin - if (!binField) return null - - const candidates = buildBinCandidates(packageName, binName) - let chosenBinName: string | undefined - let binRel: string | undefined - - if (typeof binField === "string") { - chosenBinName = defaultBinName(packageName) - binRel = binField - } else { - for (const candidate of candidates) { - if (binField[candidate]) { - chosenBinName = candidate - binRel = binField[candidate] - break - } - } - if (!binRel) { - const firstEntry = Object.entries(binField)[0] - if (firstEntry) { - chosenBinName = firstEntry[0] - binRel = firstEntry[1] - } - } - } - - if (!binRel) return null - - const nodeModulesDir = findNodeModulesDir(packageDir) - const binLink = chosenBinName ? join(nodeModulesDir, ".bin", chosenBinName) : null - let resolvedBin = binLink && existsSync(binLink) ? safeRealpath(binLink) : "" - if (!resolvedBin) { - resolvedBin = resolve(packageDir, binRel) - if (!existsSync(resolvedBin)) return null - } - - const isJs = detectJsBinary(resolvedBin) - return { - resolvedBin, - resolvedAt: Date.now(), - packageVersion: pkg?.version, - isJs, - } -} - -const FORCE_CACHE_TIMEOUT_MS = 30_000 - -async function forceNpxCache(packageSpec: string): Promise { - try { - await new Promise((resolve, reject) => { - const proc = spawn("npm", ["exec", "--yes", "--package", packageSpec, "--", "node", "-e", "1"], { - stdio: "ignore", - }) - const timer = setTimeout(() => { - proc.kill() - reject(new Error("timeout")) - }, FORCE_CACHE_TIMEOUT_MS) - timer.unref() - proc.on("close", () => { - clearTimeout(timer) - resolve() - }) - proc.on("error", (err) => { - clearTimeout(timer) - reject(err) - }) - }) - } catch { - // Ignore failures, resolution will fall back to original command - } -} - -function buildBinCandidates(packageName: string, explicitBin?: string): string[] { - const candidates: string[] = [] - if (explicitBin) candidates.push(explicitBin) - - if (packageName.startsWith("@")) { - const namePart = packageName.split("/")[1] ?? "" - const scopePart = packageName.split("/")[0]?.replace("@", "") ?? "" - if (namePart) candidates.push(namePart) - if (scopePart && namePart) candidates.push(`${scopePart}-${namePart}`) - } else { - candidates.push(packageName) - } - - return [...new Set(candidates.filter(Boolean))] -} - -function extractPackageName(spec: string): string | null { - const trimmed = spec.trim() - if (!trimmed) return null - if (trimmed.startsWith("@")) { - const slashIndex = trimmed.indexOf("/") - if (slashIndex < 0) return null - const atIndex = trimmed.lastIndexOf("@") - if (atIndex > slashIndex) { - return trimmed.slice(0, atIndex) - } - return trimmed - } - const atIndex = trimmed.indexOf("@") - return atIndex >= 0 ? trimmed.slice(0, atIndex) : trimmed -} - -function defaultBinName(packageName: string): string { - if (packageName.startsWith("@")) { - const parts = packageName.split("/") - return parts[1] ?? packageName.replace("@", "").replace("/", "-") - } - return packageName -} - -function findCachedPackageDir(cacheDir: string, packageName: string): string | null { - const npxDir = join(cacheDir, "_npx") - if (!existsSync(npxDir)) return null - - const packagePathParts = packageName.startsWith("@") ? packageName.split("/") : [packageName] - - const candidates = readdirSync(npxDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => { - const full = join(npxDir, entry.name) - const mtime = safeStatMtime(full) - return { name: entry.name, mtime } - }) - .sort((a, b) => b.mtime - a.mtime) - - for (const entry of candidates) { - const pkgDir = join(npxDir, entry.name, "node_modules", ...packagePathParts) - if (existsSync(join(pkgDir, "package.json"))) { - return pkgDir - } - } - - return null -} - -function findNodeModulesDir(packageDir: string): string { - const parts = packageDir.split(sep) - const idx = parts.lastIndexOf("node_modules") - if (idx >= 0) { - return parts.slice(0, idx + 1).join(sep) - } - return join(packageDir, "..") -} - -function detectJsBinary(binPath: string): boolean { - const ext = extname(binPath).toLowerCase() - if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return true - try { - const fd = openSync(binPath, "r") - try { - const buf = Buffer.alloc(256) - readSync(fd, buf, 0, 256, 0) - const firstLine = buf.toString("utf-8").split("\n")[0] ?? "" - return firstLine.startsWith("#!") && firstLine.includes("node") - } finally { - closeSync(fd) - } - } catch { - return false - } -} - -let npmCacheDirCached: string | null | undefined - -function getNpmCacheDir(): string | null { - if (npmCacheDirCached !== undefined) return npmCacheDirCached - if (process.env.NPM_CONFIG_CACHE) { - npmCacheDirCached = process.env.NPM_CONFIG_CACHE - return npmCacheDirCached - } - try { - const result = spawnSync("npm", ["config", "get", "cache"], { encoding: "utf-8" }) - if (result.status === 0) { - const path = String(result.stdout).trim() - npmCacheDirCached = path || null - return npmCacheDirCached - } - } catch { - npmCacheDirCached = null - return null - } - npmCacheDirCached = null - return null -} - -function loadCache(): NpxCache | null { - if (!existsSync(getCachePath())) return null - try { - const raw = JSON.parse(readFileSync(getCachePath(), "utf-8")) - if (!raw || typeof raw !== "object") return null - if (raw.version !== CACHE_VERSION) return null - if (!raw.entries || typeof raw.entries !== "object") return null - return raw as NpxCache - } catch { - return null - } -} - -function saveCacheEntry(key: string, entry: NpxCacheEntry): void { - const dir = dirname(getCachePath()) - mkdirSync(dir, { recursive: true }) - - const merged: NpxCache = { version: CACHE_VERSION, entries: {} } - try { - if (existsSync(getCachePath())) { - const existing = JSON.parse(readFileSync(getCachePath(), "utf-8")) as NpxCache - if (existing && existing.version === CACHE_VERSION && existing.entries) { - merged.entries = { ...existing.entries } - } - } - } catch { - // Ignore parse errors - } - - merged.entries[key] = entry - const tmpPath = `${getCachePath()}.${process.pid}.tmp` - writeFileSync(tmpPath, JSON.stringify(merged, null, 2), "utf-8") - renameSync(tmpPath, getCachePath()) -} - -function safeRealpath(path: string): string { - try { - return realpathSync(path) - } catch { - return "" - } -} - -function safeStatMtime(path: string): number { - try { - return statSync(path).mtimeMs - } catch { - return 0 - } -} diff --git a/src/extensions/mcp-adapter/oauth-handler.ts b/src/extensions/mcp-adapter/oauth-handler.ts deleted file mode 100644 index 11bbf8594..000000000 --- a/src/extensions/mcp-adapter/oauth-handler.ts +++ /dev/null @@ -1,57 +0,0 @@ -// oauth-handler.ts - OAuth token management for MCP servers -import { existsSync, readFileSync } from "node:fs" -import { join } from "node:path" -import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" -import { getAgentDir } from "./utils.js" - -// Token storage path for a server -function getTokensPath(serverName: string): string { - return join(getAgentDir(), "mcp-oauth", serverName, "tokens.json") -} - -/** - * Get stored OAuth tokens for a server (if any). - * Returns undefined if no tokens or tokens are expired. - * - * Token file location: ~/.pi/agent/mcp-oauth//tokens.json - * - * Expected format: - * { - * "access_token": "...", - * "token_type": "bearer", - * "refresh_token": "...", // optional - * "expires_in": 3600, // optional, seconds - * "expiresAt": 1234567890 // optional, absolute timestamp ms - * } - */ -export function getStoredTokens(serverName: string): OAuthTokens | undefined { - const tokensPath = getTokensPath(serverName) - - if (!existsSync(tokensPath)) return undefined - - try { - const stored = JSON.parse(readFileSync(tokensPath, "utf-8")) - - // Validate required field - if (!stored.access_token || typeof stored.access_token !== "string") { - return undefined - } - - // Check expiration if expiresAt is set - if (stored.expiresAt && typeof stored.expiresAt === "number") { - if (Date.now() > stored.expiresAt) { - // Token expired - return undefined - } - } - - return { - access_token: stored.access_token, - token_type: stored.token_type ?? "bearer", - refresh_token: stored.refresh_token, - expires_in: stored.expires_in, - } - } catch { - return undefined - } -} diff --git a/src/extensions/mcp-adapter/proxy-modes.test.ts b/src/extensions/mcp-adapter/proxy-modes.test.ts deleted file mode 100644 index d23674e1b..000000000 --- a/src/extensions/mcp-adapter/proxy-modes.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Unit tests for the read-only marker surfaced in `executeSearch` and - * `executeDescribe` proxy output (proxy-modes.ts). - * - * Asserts that a tool whose `annotations.readOnlyHint` is `true` (or that - * qualifies via the name heuristic) shows a `[read-only]` tag in search - * results and a `Read-only:` line in describe output, while a write tool - * (explicit `readOnlyHint:false` with a non-matching name) shows neither. - */ -import { describe, expect, it } from "vitest" -import { executeDescribe, executeSearch } from "./proxy-modes.js" -import type { McpExtensionState } from "./state.js" -import type { ToolMetadata } from "./types.js" - -const SERVER = "testserver" - -function makeMetadata(originalName: string, annotations?: ToolMetadata["annotations"]): ToolMetadata { - return { - name: `${SERVER}_${originalName}`, - originalName, - description: `tool ${originalName}`, - inputSchema: { type: "object", properties: {} }, - annotations, - } -} - -function makeState(metadata: ToolMetadata[]): McpExtensionState { - return { - manager: {} as McpExtensionState["manager"], - lifecycle: {} as McpExtensionState["lifecycle"], - toolMetadata: new Map([[SERVER, metadata]]), - config: { mcpServers: { [SERVER]: {} as McpExtensionState["config"]["mcpServers"][string] } }, - failureTracker: new Map(), - uiResourceHandler: {} as McpExtensionState["uiResourceHandler"], - consentManager: {} as McpExtensionState["consentManager"], - uiServer: null, - completedUiSessions: [], - openBrowser: async () => {}, - dynamicToolNames: new Set(), - } as unknown as McpExtensionState -} - -function resultText(result: { content: Array<{ type: string; text?: string }> }): string { - const block = result.content[0] - return block?.type === "text" ? (block.text ?? "") : "" -} - -describe("executeSearch — read-only marker", () => { - it("appends [read-only] for an annotated read-only tool (readOnlyHint:true)", () => { - const meta = makeMetadata("get_record", { readOnlyHint: true }) - const state = makeState([meta]) - - const result = executeSearch(state, "record", undefined, undefined, undefined, undefined, 5) - const text = resultText(result) - - expect(text).toContain("[read-only]") - expect(text).toContain(meta.name) - }) - - it("appends [read-only] for an un-annotated heuristic-matching tool (get_ prefix)", () => { - const meta = makeMetadata("get_record") // no annotations -> heuristic - const state = makeState([meta]) - - const result = executeSearch(state, "record", undefined, undefined, undefined, undefined, 5) - const text = resultText(result) - - expect(text).toContain("[read-only]") - }) - - it("does not append [read-only] for an explicit write tool (readOnlyHint:false, non-matching name)", () => { - const meta = makeMetadata("create_record", { readOnlyHint: false }) - const state = makeState([meta]) - - const result = executeSearch(state, "record", undefined, undefined, undefined, undefined, 5) - const text = resultText(result) - - expect(text).not.toContain("[read-only]") - expect(text).toContain(meta.name) - }) - - it("does not append [read-only] for an un-annotated non-matching tool name", () => { - const meta = makeMetadata("create_record") // no annotations, non-matching prefix - const state = makeState([meta]) - - const result = executeSearch(state, "record", undefined, undefined, undefined, undefined, 5) - const text = resultText(result) - - expect(text).not.toContain("[read-only]") - }) - - it("surfaces the marker in compact (includeSchemas=false) output too", () => { - const meta = makeMetadata("get_record", { readOnlyHint: true }) - const state = makeState([meta]) - - const result = executeSearch(state, "record", undefined, undefined, false, undefined, 5) - const text = resultText(result) - - expect(text).toContain("[read-only]") - }) -}) - -describe("executeDescribe — read-only marker", () => { - it("includes a Read-only line for an annotated read-only tool (readOnlyHint:true)", () => { - const meta = makeMetadata("get_record", { readOnlyHint: true }) - const state = makeState([meta]) - - const result = executeDescribe(state, meta.name) - const text = resultText(result) - - expect(text).toContain("Read-only:") - expect(text).toContain("safe to call during planning/scoping") - }) - - it("includes a Read-only line for an un-annotated heuristic-matching tool", () => { - const meta = makeMetadata("get_record") - const state = makeState([meta]) - - const result = executeDescribe(state, meta.name) - const text = resultText(result) - - expect(text).toContain("Read-only:") - }) - - it("does not include a Read-only line for an explicit write tool", () => { - const meta = makeMetadata("create_record", { readOnlyHint: false }) - const state = makeState([meta]) - - const result = executeDescribe(state, meta.name) - const text = resultText(result) - - expect(text).not.toContain("Read-only:") - expect(text).toContain(meta.name) - }) - - it("does not include a Read-only line for an un-annotated non-matching tool name", () => { - const meta = makeMetadata("create_record") - const state = makeState([meta]) - - const result = executeDescribe(state, meta.name) - const text = resultText(result) - - expect(text).not.toContain("Read-only:") - }) -}) diff --git a/src/extensions/mcp-adapter/proxy-modes.ts b/src/extensions/mcp-adapter/proxy-modes.ts deleted file mode 100644 index 14c740f81..000000000 --- a/src/extensions/mcp-adapter/proxy-modes.ts +++ /dev/null @@ -1,1033 +0,0 @@ -import { randomUUID } from "node:crypto" -import { mkdirSync, writeFileSync } from "node:fs" -import { tmpdir, userInfo } from "node:os" -import { dirname, join } from "node:path" -import type { AgentToolResult, ExtensionContext, ToolInfo } from "@earendil-works/pi-coding-agent" -import { truncateTail } from "@earendil-works/pi-coding-agent" -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import type { SearchStrategy } from "./bm25.js" -import { - getFailureAgeSeconds, - lazyConnect, - updateMetadataCache, - updateServerMetadata, - updateStatusBar, -} from "./init.js" -import { authenticate, supportsOAuth } from "./mcp-auth-flow.js" -import type { McpExtensionState } from "./state.js" -import { buildToolMetadata, findToolByName, formatSchema, getToolNames, isReadOnlyMcpTool } from "./tool-metadata.js" -import { transformMcpContent } from "./tool-registrar.js" -import type { DirectToolSpec, McpContent, ToolMetadata } from "./types.js" -import { getServerPrefix, parseUiPromptHandoff } from "./types.js" -import { maybeStartUiSession, type UiSessionRuntime } from "./ui-session.js" -import { truncateAtWord } from "./utils.js" - -type ProxyToolResult = AgentToolResult> - -import type { ImageContent, TextContent } from "@earendil-works/pi-ai" - -type ContentBlock = TextContent | ImageContent - -interface NativeToolStatus { - tool: ToolInfo - active: boolean -} - -type NativeToolStatusLookup = (toolName: string) => NativeToolStatus | undefined - -function nativeToolResult(mode: "call" | "describe", toolName: string, status: NativeToolStatus): ProxyToolResult { - const activeInstruction = status.active - ? `Call it directly as ${toolName}; do not call it through mcp({ tool: "${toolName}" }).` - : "It is not active in the current context, so do not call it now and do not route it through MCP." - return { - content: [ - { - type: "text" as const, - text: `Tool "${toolName}" is a native agent tool, not an MCP tool.\n${activeInstruction}`, - }, - ], - details: { - mode, - error: "native_tool_not_mcp", - requestedTool: toolName, - active: status.active, - nativeTool: status.tool.name, - }, - } -} - -function applyTruncation(content: ContentBlock[]): ContentBlock[] { - const textItems = content.filter((c): c is TextContent => c.type === "text") - if (textItems.length === 0) return content - - const combined = textItems.map((c) => c.text).join("\n") - const result = truncateTail(combined) - if (!result.truncated) return content - - const notice = `\n[Truncated: showing last ${result.outputLines} of ${result.totalLines} lines (${result.totalBytes.toLocaleString()} bytes total). Use mcp({ describe: "tool_name" }) to check parameters if needed.]` - const nonText = content.filter((c): c is ImageContent => c.type !== "text") - return [{ type: "text" as const, text: result.content + notice }, ...nonText] -} - -function applyOffload( - content: ContentBlock[], - _toolName: string, - maxChars: number, - ctx: ExtensionContext, -): ContentBlock[] { - const textItems = content.filter((c): c is TextContent => c.type === "text") - if (textItems.length === 0) return content - - const combined = textItems.map((c) => c.text).join("\n") - if (combined.length <= maxChars) return content - - const nonText = content.filter((c) => c.type !== "text") - - // Lightweight format detection — avoids JSON.parse on large strings - const ext = /^\s*[{[]/.test(combined) ? "json" : "txt" - - // Derive output directory from session file - let dir: string - const sessionFile = ctx.sessionManager.getSessionFile() - if (sessionFile) { - dir = join(dirname(sessionFile), "tool-results") - } else { - dir = join(tmpdir(), `kimchi-tool-results-${userInfo().uid}`) - } - - let path: string - try { - mkdirSync(dir, { recursive: true }) - path = join(dir, `${randomUUID()}.${ext}`) - writeFileSync(path, combined, "utf-8") - } catch (err) { - console.warn(`[mcp-adapter] applyOffload: failed to write tool result to disk:`, err) - // Hard-slice fallback — do NOT use truncateTail; it fails on single-line blobs - const sliced = `${combined.slice(0, maxChars)}\n\n... [Truncated due to I/O error]` - return [...nonText, { type: "text" as const, text: sliced }] - } - - const format = ext === "json" ? "JSON" : "Plain text" - const message = `result (${combined.length.toLocaleString()} characters) exceeds limit. Full output saved to ${path}. -Format: ${format} -- To search: use bash with grep on the file directly -- To read in chunks: bash -c "python3 -c \\"print(open('${path}').read()[A:B])\\"" -- For analysis requiring full content: launch an Agent with the file path` - - return [...nonText, { type: "text" as const, text: message }] -} - -type AutoAuthResult = { status: "skipped" } | { status: "success" } | { status: "failed"; message: string } - -function getAuthRequiredMessage(serverName: string): string { - return `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} first.` -} - -async function attemptAutoAuth(state: McpExtensionState, serverName: string): Promise { - if (state.config.settings?.autoAuth !== true) { - return { status: "skipped" } - } - - const definition = state.config.mcpServers[serverName] - if (!definition || !supportsOAuth(definition) || !definition.url) { - return { status: "skipped" } - } - - const grantType = - (definition.oauth && typeof definition.oauth === "object" && definition.oauth.grantType) || "authorization_code" - if (!state.ui && grantType !== "client_credentials") { - return { - status: "failed", - message: `Server "${serverName}" requires OAuth authentication. Run /mcp-auth ${serverName} in an interactive session.`, - } - } - - try { - await authenticate(serverName, definition.url, definition) - return { status: "success" } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - return { - status: "failed", - message: `OAuth authentication failed for "${serverName}": ${message}. Run /mcp-auth ${serverName} first.`, - } - } -} - -export function executeUiMessages(state: McpExtensionState): ProxyToolResult { - const sessions = state.completedUiSessions - - if (sessions.length === 0) { - return { - content: [{ type: "text" as const, text: "No UI session messages available." }], - details: { sessions: 0 }, - } - } - - const output: string[] = [] - output.push(`UI Session Messages (${sessions.length} session${sessions.length > 1 ? "s" : ""}):\n`) - - const allPrompts: string[] = [] - const allIntents = sessions.flatMap((session) => session.messages.intents) - const parsedHandoffs: Array<{ intent: string; params: Record; raw: string }> = [] - - for (const session of sessions) { - const timestamp = session.completedAt.toLocaleTimeString() - output.push(`\n## ${session.serverName} / ${session.toolName} (${timestamp}, ${session.reason})`) - - const plainPrompts: string[] = [] - for (const prompt of session.messages.prompts) { - allPrompts.push(prompt) - const handoff = parseUiPromptHandoff(prompt) - if (handoff) { - parsedHandoffs.push(handoff) - } else { - plainPrompts.push(prompt) - } - } - - if (plainPrompts.length > 0) { - output.push("\n### Prompts:") - for (const prompt of plainPrompts) { - output.push(`- ${prompt}`) - } - } - - const intentsForSession = [ - ...session.messages.intents, - ...session.messages.prompts - .map((prompt) => parseUiPromptHandoff(prompt)) - .filter((handoff): handoff is NonNullable => !!handoff) - .map((handoff) => ({ intent: handoff.intent, params: handoff.params })), - ] - - if (intentsForSession.length > 0) { - output.push("\n### Intents:") - for (const intent of intentsForSession) { - const params = intent.params ? ` (${JSON.stringify(intent.params)})` : "" - output.push(`- ${intent.intent}${params}`) - } - } - - if (session.messages.notifications.length > 0) { - output.push("\n### Notifications:") - for (const notification of session.messages.notifications) { - output.push(`- ${notification}`) - } - } - } - - const count = sessions.length - state.completedUiSessions = [] - - return { - content: [{ type: "text" as const, text: output.join("\n") }], - details: { - sessions: count, - prompts: allPrompts, - intents: [...allIntents, ...parsedHandoffs.map(({ intent, params }) => ({ intent, params }))], - handoffs: parsedHandoffs, - cleared: true, - }, - } -} - -export function executeStatus(state: McpExtensionState): ProxyToolResult { - const servers: Array<{ name: string; status: string; toolCount: number; failedAgo: number | null }> = [] - - for (const name of Object.keys(state.config.mcpServers)) { - const connection = state.manager.getConnection(name) - const metadata = state.toolMetadata.get(name) - const toolCount = metadata?.length ?? 0 - const failedAgo = getFailureAgeSeconds(state, name) - let status = "not connected" - if (connection?.status === "connected") { - status = "connected" - } else if (connection?.status === "needs-auth") { - status = "needs-auth" - } else if (failedAgo !== null) { - status = "failed" - } else if (metadata !== undefined) { - status = "cached" - } - - servers.push({ name, status, toolCount, failedAgo }) - } - - const totalTools = servers.reduce((sum, s) => sum + s.toolCount, 0) - const connectedCount = servers.filter((s) => s.status === "connected").length - - let text = `MCP: ${connectedCount}/${servers.length} servers, ${totalTools} tools\n\n` - for (const server of servers) { - if (server.status === "connected") { - text += `✓ ${server.name} (${server.toolCount} tools)\n` - continue - } - if (server.status === "needs-auth") { - text += `⚠ ${server.name} (needs auth)\n` - continue - } - if (server.status === "cached") { - text += `○ ${server.name} (${server.toolCount} tools, cached)\n` - continue - } - if (server.status === "failed") { - text += `✗ ${server.name} (failed ${server.failedAgo ?? 0}s ago)\n` - continue - } - text += `○ ${server.name} (not connected)\n` - } - - if (servers.length > 0) { - text += `\nmcp({ search: "..." }) to find tools, mcp({ describe: "tool_name" }) to get schema` - } - - return { - content: [{ type: "text" as const, text: text.trim() }], - details: { mode: "status", servers, totalTools, connectedCount }, - } -} - -export function executeDescribe( - state: McpExtensionState, - toolName: string, - onInject?: (specs: DirectToolSpec[]) => string[], - getNativeToolStatus?: NativeToolStatusLookup, -): ProxyToolResult { - let serverName: string | undefined - let toolMeta: ToolMetadata | undefined - - for (const [server, metadata] of state.toolMetadata.entries()) { - const found = findToolByName(metadata, toolName) - if (found) { - serverName = server - toolMeta = found - break - } - } - - if (!serverName || !toolMeta) { - const nativeStatus = getNativeToolStatus?.(toolName) - if (nativeStatus) return nativeToolResult("describe", toolName, nativeStatus) - return { - content: [{ type: "text" as const, text: `Tool "${toolName}" not found. Use mcp({ search: "..." }) to search.` }], - details: { mode: "describe", error: "tool_not_found", requestedTool: toolName }, - } - } - - let injectedNames: string[] = [] - if (onInject && !toolMeta.resourceUri) { - injectedNames = onInject([ - { - serverName, - originalName: toolMeta.originalName, - prefixedName: toolMeta.name, - description: toolMeta.description ?? "", - inputSchema: toolMeta.inputSchema, - uiResourceUri: toolMeta.uiResourceUri, - uiStreamMode: toolMeta.uiStreamMode, - }, - ]) - } - - let text = `${toolMeta.name}\n` - text += `Server: ${serverName}\n` - if (toolMeta.resourceUri) { - text += `Type: Resource (reads from ${toolMeta.resourceUri})\n` - } - if (isReadOnlyMcpTool(toolMeta)) { - text += `Read-only: safe to call during planning/scoping\n` - } - text += `\n${toolMeta.description || "(no description)"}\n` - - if (toolMeta.inputSchema && !toolMeta.resourceUri) { - text += `\nParameters:\n${formatSchema(toolMeta.inputSchema)}` - } else if (toolMeta.resourceUri) { - text += `\nNo parameters required (resource tool).` - } else { - text += `\nNo parameters defined.` - } - - if (injectedNames.length > 0) { - text += `\n\nInjected into context. Call using the exact name shown above: ${injectedNames[0]}` - text += `\n(Available from the next turn. To call now: mcp({ tool: "${toolMeta.originalName}", args: "..." }).)` - } - - return { - content: [{ type: "text" as const, text: text.trim() }], - details: { mode: "describe", tool: toolMeta, server: serverName, injected: injectedNames }, - } -} - -export function executeSearch( - state: McpExtensionState, - query: string, - regex?: boolean, - server?: string, - includeSchemas?: boolean, - getPiTools?: () => ToolInfo[], - limit = 5, - strategy?: SearchStrategy, - onInject?: (specs: DirectToolSpec[]) => string[], -): ProxyToolResult { - const showSchemas = includeSchemas !== false - - // Validate query upfront for both paths - const trimmed = query.trim() - if (trimmed.length === 0) { - return { - content: [{ type: "text" as const, text: "Search query cannot be empty" }], - details: { mode: "search", error: "empty_query" }, - } - } - - // Native agent tools are not MCP tools. Surface only active native tools and - // label them as direct-call only so search and dispatch cannot disagree. - const piMatches: Array<{ name: string; description: string }> = [] - if (!server && getPiTools) { - let piPattern: RegExp - try { - if (regex) { - piPattern = new RegExp(trimmed, "i") - } else { - const escaped = trimmed - .split(/\s+/) - .filter((t) => t.length > 0) - .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - piPattern = new RegExp(escaped.join("|"), "i") - } - for (const tool of getPiTools()) { - if (tool.name === "mcp") continue - if (piPattern.test(tool.name) || piPattern.test(tool.description ?? "")) { - piMatches.push({ name: tool.name, description: tool.description ?? "" }) - } - } - } catch { - // invalid regex — skip pi tools - } - } - - // MCP tool search: use strategy (BM25/regex) unless regex flag forces legacy path - const matches: Array<{ server: string; tool: ToolMetadata }> = [] - - if (!regex && strategy) { - // Strategy-based search across all MCP tools, then filter by server if needed - const results = strategy.search(trimmed, server ? Number.MAX_SAFE_INTEGER : limit) - for (const result of results) { - if (server && result.entry.server !== server) continue - const serverMeta = state.toolMetadata.get(result.entry.server) - const toolMeta = serverMeta?.find((t) => t.name === result.entry.name) - if (toolMeta) { - matches.push({ server: result.entry.server, tool: toolMeta }) - } - } - } else { - // Legacy regex path (when regex=true or no strategy available) - let pattern: RegExp - try { - if (regex) { - pattern = new RegExp(trimmed, "i") - } else { - const escaped = trimmed - .split(/\s+/) - .filter((t) => t.length > 0) - .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) - pattern = new RegExp(escaped.join("|"), "i") - } - } catch { - return { - content: [{ type: "text" as const, text: `Invalid regex: ${query}` }], - details: { mode: "search", error: "invalid_pattern", query }, - } - } - for (const [serverName, metadata] of state.toolMetadata.entries()) { - if (server && serverName !== server) continue - for (const tool of metadata) { - if (pattern.test(tool.name) || pattern.test(tool.description)) { - matches.push({ server: serverName, tool }) - } - } - } - } - - const totalCount = piMatches.length + matches.length - - if (totalCount === 0) { - const msg = server ? `No tools matching "${query}" in "${server}"` : `No tools matching "${query}"` - return { - content: [{ type: "text" as const, text: msg }], - details: { mode: "search", matches: [], count: 0, query }, - } - } - - // Apply limit: fill from piMatches first, then MCP matches - const piLimit = Math.min(piMatches.length, limit) - const mcpLimit = Math.min(matches.length, limit - piLimit) - const limitedPiMatches = piMatches.slice(0, piLimit) - const limitedMatches = matches.slice(0, mcpLimit) - const shownCount = limitedPiMatches.length + limitedMatches.length - const truncated = totalCount > shownCount - - // Inject matched MCP tools as native pi tools for the next turn - let injectedNames: string[] = [] - if (onInject && limitedMatches.length > 0) { - const specs: DirectToolSpec[] = limitedMatches - .filter((m) => !m.tool.resourceUri) - .map((m) => ({ - serverName: m.server, - originalName: m.tool.originalName, - prefixedName: m.tool.name, - description: m.tool.description ?? "", - inputSchema: m.tool.inputSchema, - uiResourceUri: m.tool.uiResourceUri, - uiStreamMode: m.tool.uiStreamMode, - })) - if (specs.length > 0) injectedNames = onInject(specs) - } - - let text = truncated - ? `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}" (showing ${shownCount}, refine your query for more):\n\n` - : `Found ${totalCount} tool${totalCount === 1 ? "" : "s"} matching "${query}":\n\n` - - for (const match of limitedPiMatches) { - if (showSchemas) { - text += `[native tool] ${match.name}\n` - text += ` ${match.description || "(no description)"}\n` - text += ` Native agent tool. Call ${match.name} directly if it appears in Available Tools; do not call it through mcp({ tool: "${match.name}" }).\n` - text += "\n" - } else { - text += `[native tool] ${match.name}` - if (match.description) { - text += ` - ${truncateAtWord(match.description, 50)}` - } - text += "\n" - } - } - - for (const match of limitedMatches) { - if (showSchemas) { - text += `${match.tool.name}${isReadOnlyMcpTool(match.tool) ? " [read-only]" : ""}\n` - text += ` ${match.tool.description || "(no description)"}\n` - if (match.tool.inputSchema && !match.tool.resourceUri) { - text += `\n Parameters:\n${formatSchema(match.tool.inputSchema, " ")}\n` - } else if (match.tool.resourceUri) { - text += ` No parameters (resource tool).\n` - } - text += "\n" - } else { - text += `- ${match.tool.name}${isReadOnlyMcpTool(match.tool) ? " [read-only]" : ""}` - if (match.tool.description) { - text += ` - ${truncateAtWord(match.tool.description, 50)}` - } - text += "\n" - } - } - - if (injectedNames.length > 0) { - text += `\nInjected into context. Call using the exact name${injectedNames.length > 1 ? "s" : ""} shown above: ${injectedNames.join(", ")}` - text += `\n(Available from the next turn. To call now: mcp({ tool: "", args: "..." }).)` - } - - return { - content: [{ type: "text" as const, text: text.trim() }], - details: { - mode: "search", - matches: [ - ...limitedPiMatches.map((m) => ({ server: "native", tool: m.name, dispatch: "direct" })), - ...limitedMatches.map((m) => ({ server: m.server, tool: m.tool.name })), - ], - count: totalCount, - shown: shownCount, - query, - injected: injectedNames, - }, - } -} - -export function executeList(state: McpExtensionState, server: string): ProxyToolResult { - if (!state.config.mcpServers[server]) { - return { - content: [{ type: "text" as const, text: `Server "${server}" not found. Use mcp({}) to see available servers.` }], - details: { mode: "list", server, tools: [], count: 0, error: "not_found" }, - } - } - - const metadata = state.toolMetadata.get(server) - const toolNames = metadata?.map((m) => m.name) ?? [] - const connection = state.manager.getConnection(server) - - if (toolNames.length === 0) { - if (connection?.status === "connected") { - return { - content: [{ type: "text" as const, text: `Server "${server}" has no tools.` }], - details: { mode: "list", server, tools: [], count: 0 }, - } - } - if (metadata !== undefined) { - return { - content: [{ type: "text" as const, text: `Server "${server}" has no cached tools (not connected).` }], - details: { mode: "list", server, tools: [], count: 0, cached: true }, - } - } - return { - content: [ - { - type: "text" as const, - text: `Server "${server}" is configured but not connected. Use mcp({ connect: "${server}" }) or /mcp reconnect ${server} to retry.`, - }, - ], - details: { mode: "list", server, tools: [], count: 0, error: "not_connected" }, - } - } - - const cachedNote = connection?.status === "connected" ? "" : " (not connected, cached)" - let text = `${server} (${toolNames.length} tools${cachedNote}):\n\n` - - const descMap = new Map() - if (metadata) { - for (const m of metadata) { - descMap.set(m.name, m.description) - } - } - - for (const tool of toolNames) { - const desc = descMap.get(tool) ?? "" - const truncated = truncateAtWord(desc, 50) - text += `- ${tool}` - if (truncated) text += ` - ${truncated}` - text += "\n" - } - - return { - content: [{ type: "text" as const, text: text.trim() }], - details: { mode: "list", server, tools: toolNames, count: toolNames.length }, - } -} - -export async function executeConnect(state: McpExtensionState, serverName: string): Promise { - const definition = state.config.mcpServers[serverName] - if (!definition) { - return { - content: [ - { type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }, - ], - details: { mode: "connect", error: "not_found", server: serverName }, - } - } - - try { - if (state.ui) { - state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`) - } - let connection = await state.manager.connect(serverName, definition) - if (connection.status === "needs-auth") { - const autoAuth = await attemptAutoAuth(state, serverName) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { mode: "connect", error: "auth_required", server: serverName, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(serverName) - connection = await state.manager.connect(serverName, definition) - } - if (connection.status === "needs-auth") { - const message = getAuthRequiredMessage(serverName) - return { - content: [{ type: "text" as const, text: message }], - details: { mode: "connect", error: "auth_required", server: serverName, message }, - } - } - } - const prefix = state.config.settings?.toolPrefix ?? "server" - const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, serverName, prefix) - state.toolMetadata.set(serverName, metadata) - updateMetadataCache(state, serverName) - state.failureTracker.delete(serverName) - updateStatusBar(state) - return executeList(state, serverName) - } catch (error) { - state.failureTracker.set(serverName, Date.now()) - updateStatusBar(state) - const message = error instanceof Error ? error.message : String(error) - return { - content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }], - details: { mode: "connect", error: "connect_failed", server: serverName, message }, - } - } -} - -export async function executeCall( - state: McpExtensionState, - toolName: string, - args?: Record, - serverOverride?: string, - ctx?: ExtensionContext, - maxToolResultChars?: number, - getNativeToolStatus?: NativeToolStatusLookup, -): Promise { - let serverName: string | undefined = serverOverride - let toolMeta: ToolMetadata | undefined - let autoAuthAttempted = false - const prefixMode = state.config.settings?.toolPrefix ?? "server" - - if (serverName && !state.config.mcpServers[serverName]) { - return { - content: [ - { type: "text" as const, text: `Server "${serverName}" not found. Use mcp({}) to see available servers.` }, - ], - details: { mode: "call", error: "server_not_found", server: serverName }, - } - } - - if (serverName) { - toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName) - } else { - for (const [server, metadata] of state.toolMetadata.entries()) { - const found = findToolByName(metadata, toolName) - if (found) { - serverName = server - toolMeta = found - break - } - } - } - - if (serverName && !toolMeta) { - const connected = await lazyConnect(state, serverName) - if (connected) { - toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName) - } else { - const needsAuthConnection = state.manager.getConnection(serverName) - if (needsAuthConnection?.status === "needs-auth") { - if (!autoAuthAttempted) { - autoAuthAttempted = true - const autoAuth = await attemptAutoAuth(state, serverName) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(serverName) - state.failureTracker.delete(serverName) - const connectedAfterAuth = await lazyConnect(state, serverName) - if (connectedAfterAuth) { - toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName) - if (!toolMeta) { - throw new Error(`Tool "${toolName}" not found on "${serverName}" after reconnect.`) - } - } - } - } - - if (!toolMeta && state.manager.getConnection(serverName)?.status === "needs-auth") { - const message = getAuthRequiredMessage(serverName) - return { - content: [{ type: "text" as const, text: message }], - details: { mode: "call", error: "auth_required", server: serverName, message }, - } - } - } - - if (!toolMeta) { - const failedAgo = getFailureAgeSeconds(state, serverName) - if (failedAgo !== null) { - return { - content: [ - { type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }, - ], - details: { mode: "call", error: "server_backoff", server: serverName }, - } - } - } - } - } - - let prefixMatchedServer: string | undefined - - if (!serverName && !toolMeta && prefixMode !== "none") { - const candidates = Object.keys(state.config.mcpServers) - .map((name) => ({ name, prefix: getServerPrefix(name, prefixMode) })) - .filter((c) => c.prefix && toolName.startsWith(`${c.prefix}_`)) - .sort((a, b) => b.prefix.length - a.prefix.length) - - for (const { name: configuredServer } of candidates) { - const existingConnection = state.manager.getConnection(configuredServer) - const failedAgo = getFailureAgeSeconds(state, configuredServer) - if (failedAgo !== null && existingConnection?.status !== "needs-auth") continue - - let connected = await lazyConnect(state, configuredServer) - if (!connected && state.manager.getConnection(configuredServer)?.status === "needs-auth" && !autoAuthAttempted) { - autoAuthAttempted = true - const autoAuth = await attemptAutoAuth(state, configuredServer) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { mode: "call", error: "auth_required", server: configuredServer, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(configuredServer) - state.failureTracker.delete(configuredServer) - connected = await lazyConnect(state, configuredServer) - } - } - - if (!connected) continue - if (!prefixMatchedServer) prefixMatchedServer = configuredServer - toolMeta = findToolByName(state.toolMetadata.get(configuredServer), toolName) - if (toolMeta) { - serverName = configuredServer - break - } - } - } - - if (!serverName || !toolMeta) { - const nativeStatus = getNativeToolStatus?.(toolName) - if (nativeStatus) return nativeToolResult("call", toolName, nativeStatus) - const hintServer = serverName ?? prefixMatchedServer - const available = hintServer ? getToolNames(state, hintServer) : [] - let msg = `Tool "${toolName}" not found.` - if (available.length > 0) { - msg += ` Server "${hintServer}" has: ${available.join(", ")}` - } else { - msg += ` Use mcp({ search: "..." }) to search.` - } - throw new Error(msg) - } - - let connection = state.manager.getConnection(serverName) - if (connection?.status === "needs-auth") { - if (!autoAuthAttempted) { - autoAuthAttempted = true - const autoAuth = await attemptAutoAuth(state, serverName) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(serverName) - state.failureTracker.delete(serverName) - connection = state.manager.getConnection(serverName) - } - } - - if (connection?.status === "needs-auth") { - const message = getAuthRequiredMessage(serverName) - return { - content: [{ type: "text" as const, text: message }], - details: { mode: "call", error: "auth_required", server: serverName, message }, - } - } - } - if (connection?.status !== "connected") { - const failedAgo = getFailureAgeSeconds(state, serverName) - if (failedAgo !== null) { - return { - content: [ - { type: "text" as const, text: `Server "${serverName}" not available (last failed ${failedAgo}s ago)` }, - ], - details: { mode: "call", error: "server_backoff", server: serverName }, - } - } - - const definition = state.config.mcpServers[serverName] - if (!definition) { - return { - content: [{ type: "text" as const, text: `Server "${serverName}" not connected` }], - details: { mode: "call", error: "server_not_connected", server: serverName }, - } - } - - let toolNotFoundAfterReconnect: string | undefined - try { - if (state.ui) { - state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`) - } - connection = await state.manager.connect(serverName, definition) - if (connection.status === "needs-auth") { - if (!autoAuthAttempted) { - autoAuthAttempted = true - const autoAuth = await attemptAutoAuth(state, serverName) - if (autoAuth.status === "failed") { - return { - content: [{ type: "text" as const, text: autoAuth.message }], - details: { mode: "call", error: "auth_required", server: serverName, message: autoAuth.message }, - } - } - if (autoAuth.status === "success") { - await state.manager.close(serverName) - connection = await state.manager.connect(serverName, definition) - } - } - - if (connection.status === "needs-auth") { - const message = getAuthRequiredMessage(serverName) - return { - content: [{ type: "text" as const, text: message }], - details: { mode: "call", error: "auth_required", server: serverName, message }, - } - } - } - state.failureTracker.delete(serverName) - updateServerMetadata(state, serverName) - updateMetadataCache(state, serverName) - updateStatusBar(state) - toolMeta = findToolByName(state.toolMetadata.get(serverName), toolName) - if (!toolMeta) { - const available = getToolNames(state, serverName) - const hint = - available.length > 0 - ? `Available tools on "${serverName}": ${available.join(", ")}` - : `Server "${serverName}" has no tools.` - toolNotFoundAfterReconnect = `Tool "${toolName}" not found on "${serverName}" after reconnect. ${hint}` - } - } catch (error) { - state.failureTracker.set(serverName, Date.now()) - updateStatusBar(state) - const message = error instanceof Error ? error.message : String(error) - return { - content: [{ type: "text" as const, text: `Failed to connect to "${serverName}": ${message}` }], - details: { mode: "call", error: "connect_failed", message }, - } - } - if (toolNotFoundAfterReconnect) { - throw new Error(toolNotFoundAfterReconnect) - } - } - - if (!toolMeta) { - throw new Error(`Tool "${toolName}" not found.`) - } - - let uiSession: UiSessionRuntime | null = null - - try { - state.manager.touch(serverName) - state.manager.incrementInFlight(serverName) - - if (toolMeta.resourceUri) { - const result = await connection.client.readResource({ uri: toolMeta.resourceUri }) - const content = (result.contents ?? []).map((c) => ({ - type: "text" as const, - text: - "text" in c - ? c.text - : "blob" in c - ? `[Binary data: ${(c as { mimeType?: string }).mimeType ?? "unknown"}]` - : JSON.stringify(c), - })) - return { - content: content.length > 0 ? content : [{ type: "text" as const, text: "(empty resource)" }], - details: { mode: "call", resourceUri: toolMeta.resourceUri, server: serverName }, - } - } - - uiSession = toolMeta.uiResourceUri - ? await maybeStartUiSession(state, { - serverName, - toolName: toolMeta.originalName, - toolArgs: args ?? {}, - uiResourceUri: toolMeta.uiResourceUri, - streamMode: toolMeta.uiStreamMode, - }) - : null - - const resultPromise = connection.client.callTool({ - name: toolMeta.originalName, - arguments: args ?? {}, - _meta: uiSession?.requestMeta, - }) - - if (toolMeta.uiResourceUri) { - const result = await resultPromise - uiSession?.sendToolResult(result as unknown as CallToolResult) - const mcpContent = (result.content ?? []) as McpContent[] - const content = transformMcpContent(mcpContent) - - const mcpText = content - .filter((c) => c.type === "text") - .map((c) => (c as { text: string }).text) - .join("\n") - - if (result.isError) { - let errorWithSchema = `Error: ${mcpText || "Tool execution failed"}` - if (toolMeta.inputSchema) { - errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` - } - return { - content: [{ type: "text" as const, text: errorWithSchema }], - details: { mode: "call", error: "tool_error", mcpResult: result }, - } - } - - const resultText = mcpText || "(empty result)" - const uiMessage = uiSession?.reused - ? "Updated the open UI." - : "📺 Interactive UI is now open in your browser. I'll respond to your prompts and intents as you interact with it." - return { - content: [{ type: "text" as const, text: `${resultText}\n\n${uiMessage}` }], - details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName, uiOpen: true }, - } - } - - const result = await resultPromise - - const mcpContent = (result.content ?? []) as McpContent[] - const content = transformMcpContent(mcpContent) - - if (result.isError) { - const errorText = - content - .filter((c) => c.type === "text") - .map((c) => (c as { text: string }).text) - .join("\n") || "Tool execution failed" - - let errorWithSchema = `Error: ${errorText}` - if (toolMeta.inputSchema) { - errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` - } - - return { - content: [{ type: "text" as const, text: errorWithSchema }], - details: { mode: "call", error: "tool_error", mcpResult: result }, - } - } - - const finalContent = ( - content.length > 0 ? content : [{ type: "text" as const, text: "(empty result)" }] - ) as ContentBlock[] - const truncated = ctx - ? applyOffload(finalContent, toolName, maxToolResultChars ?? 10_000, ctx) - : applyTruncation(finalContent) - return { - content: truncated, - details: { mode: "call", mcpResult: result, server: serverName, tool: toolMeta.originalName }, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - uiSession?.sendToolCancelled(message) - - let errorWithSchema = `Failed to call tool: ${message}` - if (toolMeta.inputSchema) { - errorWithSchema += `\n\nExpected parameters:\n${formatSchema(toolMeta.inputSchema)}` - } - - return { - content: [{ type: "text" as const, text: errorWithSchema }], - details: { mode: "call", error: "call_failed", message }, - } - } finally { - if (uiSession?.reused) { - uiSession.close() - } - state.manager.decrementInFlight(serverName) - state.manager.touch(serverName) - } -} diff --git a/src/extensions/mcp-adapter/resolve-probe-name.test.ts b/src/extensions/mcp-adapter/resolve-probe-name.test.ts deleted file mode 100644 index fc731d3e2..000000000 --- a/src/extensions/mcp-adapter/resolve-probe-name.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" - -const mockGetAuthEntry = vi.fn() - -vi.mock("./mcp-auth.js", () => ({ - getAuthEntry: (...args: unknown[]) => mockGetAuthEntry(...args), -})) - -import { resolveProbeName } from "./resolve-probe-name.js" - -const URL_SERVER = { url: "https://example.com/mcp" } - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe("resolveProbeName", () => { - it("uses the real name when no auth entry exists (new server)", () => { - mockGetAuthEntry.mockReturnValue(undefined) - - expect(resolveProbeName("my-server", URL_SERVER)).toBe("my-server") - expect(mockGetAuthEntry).toHaveBeenCalledWith("my-server") - }) - - it("uses the real name when the entry is residue from an incomplete OAuth flow (no serverUrl)", () => { - // Only oauthState/codeVerifier were saved — no tokens, no serverUrl. - // The real name must be reused so the flow can complete and save - // tokens to the correct entry. - mockGetAuthEntry.mockReturnValue({ oauthState: "state-123", codeVerifier: "verifier-456" }) - - expect(resolveProbeName("my-server", URL_SERVER)).toBe("my-server") - }) - - it("uses the real name when the stored URL matches (repeat probe)", () => { - mockGetAuthEntry.mockReturnValue({ serverUrl: URL_SERVER.url, tokens: { accessToken: "tok" } }) - - expect(resolveProbeName("my-server", URL_SERVER)).toBe("my-server") - }) - - it("uses a throwaway name when the stored URL differs (server URL edited)", () => { - mockGetAuthEntry.mockReturnValue({ serverUrl: "https://old.example.com/mcp" }) - - const probeName = resolveProbeName("my-server", URL_SERVER) - - expect(probeName).toMatch(/^__probe_[0-9a-f-]{36}$/) - expect(probeName).not.toBe("my-server") - }) -}) diff --git a/src/extensions/mcp-adapter/resolve-probe-name.ts b/src/extensions/mcp-adapter/resolve-probe-name.ts deleted file mode 100644 index c312a5c18..000000000 --- a/src/extensions/mcp-adapter/resolve-probe-name.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { randomUUID } from "node:crypto" -import { getAuthEntry } from "./mcp-auth.js" -import type { ServerEntry } from "./types.js" - -/** - * Decide which name to use as the OAuth token-store key during a probe. - * - * The token store is keyed by server name. If an auth entry already exists - * under `name` for a *different* URL — e.g. the user edited the server's - * URL but kept the name — probing with the real name would overwrite the - * real server's stored credentials. To avoid that, fall back to a - * throwaway `__probe_` name; the caller cleans it up with - * removeAuthEntry() in its finally block, so the real server's tokens are - * never overwritten and the store never accumulates `__probe_*` entries. - * - * The real name is used in every other case: - * - No stored entry: new server. The first probe persists tokens under - * the real name so a repeat probe finds them. - * - Stored entry without a serverUrl: residue from an incomplete OAuth - * flow (only oauthState/codeVerifier were saved, no tokens). The real - * name lets the flow complete and save its tokens to the correct entry — - * a throwaway's tokens would be deleted by the caller's finally cleanup, - * looping every subsequent probe on needsAuth. - * - Stored URL matches: repeat probe of an authorized server. Stored - * tokens are found and the browser flow is skipped. - */ -export function resolveProbeName(name: string, definition: ServerEntry): string { - const existing = getAuthEntry(name) - // No stored entry: new server — use the real name so the first probe - // persists tokens under it and a repeat probe finds them. - if (!existing) return name - // No stored serverUrl: the entry is residue from an incomplete OAuth - // flow (only oauthState/codeVerifier were saved, no tokens). Use the - // real name so the flow can complete and save tokens to the correct - // entry — a throwaway name's tokens would be deleted by the caller's - // finally cleanup, leaving every subsequent probe with needsAuth: true. - if (!existing.serverUrl) return name - // URL matches: repeat probe of an authorized server — reuse the name so - // stored tokens are found and the browser flow is skipped. - if (existing.serverUrl === definition.url) return name - // Entry exists for a different URL — isolate the probe's credentials - // under a throwaway name so the real server's tokens are never - // overwritten. - return `__probe_${randomUUID()}` -} diff --git a/src/extensions/mcp-adapter/resource-tools.ts b/src/extensions/mcp-adapter/resource-tools.ts deleted file mode 100644 index e90f76bc1..000000000 --- a/src/extensions/mcp-adapter/resource-tools.ts +++ /dev/null @@ -1,17 +0,0 @@ -// resource-tools.ts - MCP resource name utilities - -export function resourceNameToToolName(name: string): string { - let result = name - .replace(/[^a-zA-Z0-9]/g, "_") - .replace(/_+/g, "_") - .replace(/^_+/, "") // Remove leading underscores - .replace(/_+$/, "") // Remove trailing underscores - .toLowerCase() - - // Ensure we have a valid name - if (!result || /^\d/.test(result)) { - result = `resource${result ? `_${result}` : ""}` - } - - return result -} diff --git a/src/extensions/mcp-adapter/server-manager.test.ts b/src/extensions/mcp-adapter/server-manager.test.ts deleted file mode 100644 index 77f752404..000000000 --- a/src/extensions/mcp-adapter/server-manager.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -// Shared mock functions — these persist across tests. The Client mock factory -// references them, so vi.clearAllMocks() won't strip the implementation. -const mockConnect = vi.fn() -const mockListTools = vi.fn() -const mockClose = vi.fn() -const mockSetNotificationHandler = vi.fn() - -vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ - Client: vi.fn().mockImplementation(() => ({ - connect: mockConnect, - listTools: mockListTools, - close: mockClose, - setNotificationHandler: mockSetNotificationHandler, - })), -})) - -// Mock StdioClientTransport and SSEClientTransport so they don't spawn processes -vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ - StdioClientTransport: vi.fn().mockImplementation(() => ({ - close: vi.fn().mockResolvedValue(undefined), - })), -})) - -vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: vi.fn().mockImplementation(() => ({ - close: vi.fn().mockResolvedValue(undefined), - })), -})) - -vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ - StreamableHTTPClientTransport: vi.fn().mockImplementation(() => ({ - close: vi.fn().mockResolvedValue(undefined), - })), -})) - -// Mock supportsOAuth -vi.mock("./mcp-auth-flow.js", () => ({ - supportsOAuth: vi.fn(), -})) - -// Mock McpOAuthProvider -vi.mock("./mcp-oauth-provider.js", () => ({ - McpOAuthProvider: vi.fn(), -})) - -// Mock resolveNpxBinary -vi.mock("./npx-resolver.js", () => ({ - resolveNpxBinary: vi.fn().mockResolvedValue(null), -})) - -// Mock logger -vi.mock("./logger.js", () => ({ - logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, -})) - -// Mock metadata-cache — capture calls to saveMetadataCache and computeServerHash -const { mockSaveMetadataCache, mockComputeServerHash } = vi.hoisted(() => ({ - mockSaveMetadataCache: vi.fn(), - mockComputeServerHash: vi.fn(), -})) -vi.mock("./metadata-cache.js", () => ({ - saveMetadataCache: mockSaveMetadataCache, - computeServerHash: mockComputeServerHash, -})) - -// Import after mocks -import { supportsOAuth } from "./mcp-auth-flow.js" -import { McpServerManager } from "./server-manager.js" - -describe("McpServerManager.probeTools", () => { - let manager: McpServerManager - - beforeEach(() => { - // Reset call history but keep implementations intact - mockConnect.mockReset() - mockListTools.mockReset() - mockClose.mockReset() - mockSetNotificationHandler.mockReset() - vi.mocked(supportsOAuth).mockReset() - - // Set defaults — most tests expect these - vi.mocked(supportsOAuth).mockReturnValue(false) - mockClose.mockResolvedValue(undefined) - - manager = new McpServerManager() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it("returns tools from a successful probe (stdio server)", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ - tools: [ - { name: "tool_a", description: "Does A", inputSchema: { type: "object" } }, - { name: "tool_b", title: "B Tool", description: "Does B" }, - ], - nextCursor: undefined, - }) - - const result = await manager.probeTools("test-server", { command: "echo", args: ["hello"] }) - - expect(result.tools).toHaveLength(2) - expect(result.tools[0].name).toBe("tool_a") - expect(result.tools[0].description).toBe("Does A") - expect(result.tools[0].inputSchema).toEqual({ type: "object" }) - expect(result.tools[1].name).toBe("tool_b") - expect(result.tools[1].title).toBe("B Tool") - expect(result.needsAuth).toBe(false) - expect(result.error).toBeNull() - - expect(mockClose).toHaveBeenCalled() - }) - - it("returns needsAuth when UnauthorizedError occurs during connect (OAuth server)", async () => { - vi.mocked(supportsOAuth).mockReturnValue(true) - mockConnect.mockRejectedValue(new UnauthorizedError("Unauthorized")) - - const result = await manager.probeTools("oauth-server", { - url: "https://mcp.example.com", - auth: "oauth", - }) - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(true) - expect(result.error).toBeNull() - // UnauthorizedError skips the SSE fallback and returns needsAuth directly. - // The finally block closes the client and transport. - }) - - it("returns error string when connect throws a non-OAuth error", async () => { - mockConnect.mockRejectedValue(new Error("Connection refused")) - - const result = await manager.probeTools("bad-server", { command: "nonexistent-cmd" }) - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toBe("Connection refused") - expect(mockClose).toHaveBeenCalled() - }) - - it("returns error string for non-Error exceptions", async () => { - mockConnect.mockRejectedValue("string error") - - const result = await manager.probeTools("bad-server", { command: "nonexistent-cmd" }) - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toBe("string error") - }) - - it("returns error when server has no command or url", async () => { - const result = await manager.probeTools("empty-server", {}) - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toContain("no command or url") - }) - - it("cleans up client and transport in finally block on success", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ tools: [], nextCursor: undefined }) - - await manager.probeTools("cleanup-test", { command: "echo" }) - - expect(mockClose).toHaveBeenCalled() - }) - - it("cleans up client and transport in finally block on error", async () => { - mockConnect.mockRejectedValue(new Error("boom")) - - await manager.probeTools("cleanup-error-test", { command: "echo" }) - - expect(mockClose).toHaveBeenCalled() - }) - - it("maps tool fields correctly from McpTool to ProbeMcpTool", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ - tools: [ - { - name: "complex_tool", - title: "Complex", - description: "A complex tool", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - _meta: { custom: "data" }, - }, - ], - nextCursor: undefined, - }) - - const result = await manager.probeTools("mapping-test", { command: "echo" }) - - expect(result.tools).toHaveLength(1) - expect(result.tools[0]).toEqual({ - name: "complex_tool", - title: "Complex", - description: "A complex tool", - inputSchema: { type: "object", properties: {} }, - annotations: { readOnlyHint: true }, - }) - // _meta should NOT be forwarded to ProbeMcpTool - expect("_meta" in result.tools[0]).toBe(false) - }) - - it("handles pagination in fetchAllTools (nextCursor)", async () => { - mockConnect.mockResolvedValue(undefined) - let callCount = 0 - mockListTools.mockImplementation(() => { - callCount++ - if (callCount === 1) { - return Promise.resolve({ - tools: [{ name: "page1_tool" }], - nextCursor: "cursor-1", - }) - } - return Promise.resolve({ - tools: [{ name: "page2_tool" }], - nextCursor: undefined, - }) - }) - - const result = await manager.probeTools("paginated-server", { command: "echo" }) - - expect(result.tools).toHaveLength(2) - expect(result.tools[0].name).toBe("page1_tool") - expect(result.tools[1].name).toBe("page2_tool") - expect(mockListTools).toHaveBeenCalledTimes(2) - }) -}) - -describe("McpServerManager.createTransport (via probeTools)", () => { - beforeEach(() => { - mockConnect.mockReset() - mockListTools.mockReset() - mockClose.mockReset() - vi.mocked(supportsOAuth).mockReset() - vi.mocked(supportsOAuth).mockReturnValue(false) - mockClose.mockResolvedValue(undefined) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it("throws when server has no command or url", async () => { - const manager = new McpServerManager() - const result = await manager.probeTools("bad", {}) - - expect(result.tools).toEqual([]) - expect(result.error).toContain("no command or url") - }) - - it("creates stdio transport for command-based servers", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ tools: [], nextCursor: undefined }) - - const manager = new McpServerManager() - const result = await manager.probeTools("stdio-test", { - command: "echo", - args: ["test"], - env: { FOO: "bar" }, - }) - - expect(result.error).toBeNull() - expect(mockConnect).toHaveBeenCalled() - }) -}) - -describe("withTimeout (indirectly via probeTools)", () => { - beforeEach(() => { - mockConnect.mockReset() - mockListTools.mockReset() - mockClose.mockReset() - vi.mocked(supportsOAuth).mockReset() - vi.mocked(supportsOAuth).mockReturnValue(false) - mockClose.mockResolvedValue(undefined) - }) - - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - it("resolves normally when the operation completes before the deadline", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ tools: [{ name: "fast_tool" }], nextCursor: undefined }) - - const manager = new McpServerManager() - const result = await manager.probeTools("fast-server", { command: "echo" }) - - expect(result.tools).toHaveLength(1) - expect(result.error).toBeNull() - }) - - it("times out when connect takes longer than the budget", async () => { - // Simulate a connect that never resolves — should time out after 15s - mockConnect.mockReturnValue(new Promise(() => {})) - - vi.useFakeTimers() - const manager = new McpServerManager() - const probePromise = manager.probeTools("slow-server", { command: "echo" }) - - // Advance past the 15s non-OAuth timeout - await vi.advanceTimersByTimeAsync(16_000) - - const result = await probePromise - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toContain("timed out") - expect(mockClose).toHaveBeenCalled() - }) - - it("uses a single deadline for both connect and tools/list", async () => { - // Verifies Finding 3: budget is per-probe, not per-operation. - // If connect consumes most of the 15s budget, tools/list gets the remainder. - vi.useFakeTimers() - - // Connect takes 14s (leaving 1s out of 15s budget) - let connectResolve!: () => void - const connectPromise = new Promise((resolve) => { - connectResolve = resolve - }) - mockConnect.mockReturnValue(connectPromise) - // tools/list returns a never-resolving promise so it can't win the race - mockListTools.mockReturnValue(new Promise(() => {})) - - const manager = new McpServerManager() - const probePromise = manager.probeTools("deadline-test", { command: "echo" }) - - // Advance 14s — connect resolves - await vi.advanceTimersByTimeAsync(14_000) - connectResolve() - await vi.waitFor(() => expect(mockListTools).toHaveBeenCalled()) - - // Only 1s left — advance 2s to trigger timeout on tools/list - await vi.advanceTimersByTimeAsync(2_000) - const result = await probePromise - - expect(result.error).toContain("timed out") - expect(mockClose).toHaveBeenCalled() - }) -}) - -describe("McpServerManager.probeTools SSE fallback", () => { - beforeEach(() => { - mockConnect.mockReset() - mockListTools.mockReset() - mockClose.mockReset() - mockSetNotificationHandler.mockReset() - vi.mocked(supportsOAuth).mockReset() - vi.mocked(supportsOAuth).mockReturnValue(false) - mockClose.mockResolvedValue(undefined) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it("falls back to SSE when StreamableHTTP connect fails with non-auth error", async () => { - // First connect (StreamableHTTP) fails, second (SSE) succeeds - mockConnect.mockRejectedValueOnce(new Error("Invalid content type")).mockResolvedValueOnce(undefined) - mockListTools.mockResolvedValue({ tools: [{ name: "sse_tool" }], nextCursor: undefined }) - - const manager = new McpServerManager() - const result = await manager.probeTools("sse-server", { - url: "https://mcp.example.com/sse", - }) - - expect(result.tools).toHaveLength(1) - expect(result.tools[0].name).toBe("sse_tool") - expect(result.needsAuth).toBe(false) - expect(mockConnect).toHaveBeenCalledTimes(2) - }) - - it("returns error when both StreamableHTTP and SSE fail", async () => { - mockConnect.mockRejectedValue(new Error("Connection refused")) - - const manager = new McpServerManager() - const result = await manager.probeTools("dual-fail", { - url: "https://mcp.example.com/sse", - }) - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toBe("Connection refused") - expect(mockConnect).toHaveBeenCalledTimes(2) - }) - - it("returns needsAuth when SSE fallback throws UnauthorizedError", async () => { - vi.mocked(supportsOAuth).mockReturnValue(true) - mockConnect - .mockRejectedValueOnce(new Error("Invalid content type")) - .mockRejectedValueOnce(new UnauthorizedError("Unauthorized")) - - const manager = new McpServerManager() - const result = await manager.probeTools("oauth-sse", { - url: "https://mcp.example.com/sse", - auth: "oauth", - }) - - expect(result.needsAuth).toBe(true) - expect(result.error).toBeNull() - expect(mockConnect).toHaveBeenCalledTimes(2) - }) - - it("does not fall back to SSE for stdio servers", async () => { - mockConnect.mockRejectedValue(new Error("spawn failed")) - - const manager = new McpServerManager() - const result = await manager.probeTools("stdio-fail", { - command: "nonexistent-binary", - }) - - expect(result.tools).toEqual([]) - expect(result.error).toBe("spawn failed") - expect(mockConnect).toHaveBeenCalledTimes(1) - }) -}) - -describe("McpServerManager.probeTools cache writing", () => { - beforeEach(() => { - mockConnect.mockReset() - mockListTools.mockReset() - mockClose.mockReset() - mockSetNotificationHandler.mockReset() - mockSaveMetadataCache.mockReset() - mockComputeServerHash.mockReset() - vi.mocked(supportsOAuth).mockReset() - vi.mocked(supportsOAuth).mockReturnValue(false) - mockClose.mockResolvedValue(undefined) - mockComputeServerHash.mockReturnValue("test-hash") - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it("writes probe results to metadata cache on successful probe", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ - tools: [ - { name: "tool_a", description: "Does A", inputSchema: { type: "object" } }, - { name: "tool_b", description: "Does B", annotations: { readOnlyHint: true } }, - ], - nextCursor: undefined, - }) - - const manager = new McpServerManager() - await manager.probeTools("cache-test", { command: "echo" }) - - expect(mockSaveMetadataCache).toHaveBeenCalledTimes(1) - const cacheArg = mockSaveMetadataCache.mock.calls[0][0] - expect(cacheArg.version).toBe(1) - expect(cacheArg.servers["cache-test"]).toBeDefined() - expect(cacheArg.servers["cache-test"].configHash).toBe("test-hash") - expect(cacheArg.servers["cache-test"].tools).toHaveLength(2) - expect(cacheArg.servers["cache-test"].tools[0]).toEqual({ - name: "tool_a", - description: "Does A", - inputSchema: { type: "object" }, - annotations: undefined, - }) - expect(cacheArg.servers["cache-test"].resources).toEqual([]) - expect(cacheArg.servers["cache-test"].cachedAt).toBeGreaterThan(0) - }) - - it("writes cache after SSE fallback succeeds", async () => { - mockConnect.mockRejectedValueOnce(new Error("Invalid content type")).mockResolvedValueOnce(undefined) - mockListTools.mockResolvedValue({ tools: [{ name: "sse_tool" }], nextCursor: undefined }) - - const manager = new McpServerManager() - await manager.probeTools("sse-cache-test", { url: "https://mcp.example.com/sse" }) - - expect(mockSaveMetadataCache).toHaveBeenCalledTimes(1) - expect(mockSaveMetadataCache.mock.calls[0][0].servers["sse-cache-test"]).toBeDefined() - }) - - it("does not write cache when probe returns needsAuth", async () => { - vi.mocked(supportsOAuth).mockReturnValue(true) - mockConnect.mockRejectedValue(new UnauthorizedError("Unauthorized")) - - const manager = new McpServerManager() - await manager.probeTools("auth-test", { url: "https://mcp.example.com", auth: "oauth" }) - - expect(mockSaveMetadataCache).not.toHaveBeenCalled() - }) - - it("does not write cache when probe returns an error", async () => { - mockConnect.mockRejectedValue(new Error("Connection refused")) - - const manager = new McpServerManager() - await manager.probeTools("err-test", { command: "echo" }) - - expect(mockSaveMetadataCache).not.toHaveBeenCalled() - }) - - it("saveMetadataCache merges with existing entries (does not overwrite)", async () => { - mockConnect.mockResolvedValue(undefined) - mockListTools.mockResolvedValue({ tools: [{ name: "tool_a" }], nextCursor: undefined }) - - const manager = new McpServerManager() - await manager.probeTools("new-server", { command: "echo" }) - - // saveMetadataCache already does a read-merge-write internally — verify - // it was called with only the new server, not a full cache overwrite. - expect(mockSaveMetadataCache).toHaveBeenCalledTimes(1) - const cacheArg = mockSaveMetadataCache.mock.calls[0][0] - expect(Object.keys(cacheArg.servers)).toEqual(["new-server"]) - expect(cacheArg.servers["new-server"]).toBeDefined() - }) -}) diff --git a/src/extensions/mcp-adapter/server-manager.ts b/src/extensions/mcp-adapter/server-manager.ts deleted file mode 100644 index 9a133bafb..000000000 --- a/src/extensions/mcp-adapter/server-manager.ts +++ /dev/null @@ -1,578 +0,0 @@ -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js" -import { logger } from "./logger.js" -import { supportsOAuth } from "./mcp-auth-flow.js" -import { McpOAuthProvider } from "./mcp-oauth-provider.js" -import { type CachedTool, computeServerHash, type ServerCacheEntry, saveMetadataCache } from "./metadata-cache.js" -import { resolveNpxBinary } from "./npx-resolver.js" -import type { - McpResource, - McpTool, - ProbeMcpTool, - ProbeResult, - ServerDefinition, - ServerEntry, - ServerStreamResultPatchNotification, - Transport, -} from "./types.js" -import { serverStreamResultPatchNotificationSchema } from "./types.js" - -interface ServerConnection { - client: Client - transport: Transport - definition: ServerDefinition - tools: McpTool[] - resources: McpResource[] - lastUsedAt: number - inFlight: number - status: "connected" | "closed" | "needs-auth" -} - -type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void - -export class McpServerManager { - private connections = new Map() - private connectPromises = new Map>() - private uiStreamListeners = new Map() - - async connect(name: string, definition: ServerDefinition): Promise { - // Dedupe concurrent connection attempts - if (this.connectPromises.has(name)) { - // biome-ignore lint/style/noNonNullAssertion: asserted above - return this.connectPromises.get(name)! - } - - // Reuse existing connection if healthy - const existing = this.connections.get(name) - if (existing?.status === "connected") { - existing.lastUsedAt = Date.now() - return existing - } - - const promise = this.createConnection(name, definition) - this.connectPromises.set(name, promise) - - try { - const connection = await promise - this.connections.set(name, connection) - return connection - } finally { - this.connectPromises.delete(name) - } - } - - /** - * Create the transport (stdio or HTTP) for a server definition. Shared by - * createConnection() and probeTools() so npx resolution, env interpolation, - * and OAuth/bearer setup live in exactly one place. - */ - private async createTransport(name: string, definition: ServerDefinition): Promise { - if (definition.command) { - let command = definition.command - let args = definition.args ?? [] - - if (command === "npx" || command === "npm") { - const resolved = await resolveNpxBinary(command, args) - if (resolved) { - command = resolved.isJs ? "node" : resolved.binPath - args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs - logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`) - } - } - - return new StdioClientTransport({ - command, - args, - env: resolveEnv(definition.env), - cwd: definition.cwd, - stderr: definition.debug ? "inherit" : "ignore", - }) - } - if (definition.url) { - return this.createHttpTransport(definition as ServerEntry & { url: string }, name) - } - throw new Error(`Server ${name} has no command or url`) - } - - private async createConnection(name: string, definition: ServerDefinition): Promise { - let client = new Client({ name: `pi-mcp-${name}`, version: "1.0.0" }) - let transport: Transport | undefined - - try { - transport = await this.createTransport(name, definition) - const { tools, resources } = await this.connectAndDiscover(client, transport, name) - - return { - client, - transport, - definition, - tools, - resources, - lastUsedAt: Date.now(), - inFlight: 0, - status: "connected", - } - } catch (error) { - // Check for UnauthorizedError - server requires OAuth - if (error instanceof UnauthorizedError && supportsOAuth(definition)) { - await client.close().catch(() => {}) - await transport?.close().catch(() => {}) - - if (!transport) throw error - - return this.buildNeedsAuthConnection(client, transport, definition) - } - - // SSE fallback for HTTP servers: if StreamableHTTP connect fails with a - // non-auth error, retry with the legacy SSE transport. - if (definition.url && transport && !(error instanceof UnauthorizedError)) { - await transport.close().catch(() => {}) - await client.close().catch(() => {}) - transport = this.createSseTransport(definition as ServerEntry & { url: string }, name) - client = new Client({ name: `pi-mcp-${name}`, version: "1.0.0" }) - - try { - const { tools, resources } = await this.connectAndDiscover(client, transport, name) - - return { - client, - transport, - definition, - tools, - resources, - lastUsedAt: Date.now(), - inFlight: 0, - status: "connected", - } - } catch (sseError) { - if (sseError instanceof UnauthorizedError && supportsOAuth(definition)) { - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - return this.buildNeedsAuthConnection(client, transport, definition) - } - await client.close().catch(() => {}) - await transport.close().catch(() => {}) - throw sseError - } - } - - await client.close().catch(() => {}) - await transport?.close().catch(() => {}) - throw error - } - } - - /** - * Connect a client to a transport and fetch tools + resources. - * Shared by createConnection() for both StreamableHTTP and SSE attempts. - */ - private async connectAndDiscover( - client: Client, - transport: Transport, - name: string, - ): Promise<{ tools: McpTool[]; resources: McpResource[] }> { - await client.connect(transport) - this.attachAdapterNotificationHandlers(name, client) - const [tools, resources] = await Promise.all([this.fetchAllTools(client), this.fetchAllResources(client)]) - return { tools, resources } - } - - /** - * Build a ServerConnection in the needs-auth state. - */ - private buildNeedsAuthConnection( - client: Client, - transport: Transport, - definition: ServerDefinition, - ): ServerConnection { - return { - client, - transport, - definition, - tools: [], - resources: [], - lastUsedAt: Date.now(), - inFlight: 0, - status: "needs-auth", - } - } - - /** - * Build the shared HTTP transport config (URL, headers, auth provider) - * used by both StreamableHTTP and SSE transports. - */ - private buildHttpConfig( - definition: ServerDefinition & { url: string }, - serverName: string, - ): { url: URL; requestInit: Record | undefined; authProvider: McpOAuthProvider | undefined } { - const url = new URL(definition.url) - - // Build headers first (including any bearer token) - const headers = resolveHeaders(definition.headers) ?? {} - - // For bearer auth, add the token to headers BEFORE creating requestInit - if (definition.auth === "bearer") { - const token = - definition.bearerToken ?? (definition.bearerTokenEnv ? process.env[definition.bearerTokenEnv] : undefined) - if (token) { - headers.Authorization = `Bearer ${token}` - } - } - - // Create request init with headers (Authorization now included for bearer auth) - const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined - - // For OAuth servers, create an auth provider - let authProvider: McpOAuthProvider | undefined - if (supportsOAuth(definition)) { - const oauthConfig = - definition.oauth === false - ? {} - : { - grantType: definition.oauth?.grantType, - clientId: definition.oauth?.clientId, - clientSecret: definition.oauth?.clientSecret, - scope: definition.oauth?.scope, - } - authProvider = new McpOAuthProvider(serverName, definition.url, oauthConfig, { - onRedirect: async (_authUrl) => { - // URL is captured by startAuth, no need to log - }, - }) - } - - return { url, requestInit, authProvider } - } - - private createHttpTransport(definition: ServerDefinition & { url: string }, serverName: string): Transport { - const { url, requestInit, authProvider } = this.buildHttpConfig(definition, serverName) - return new StreamableHTTPClientTransport(url, { requestInit, authProvider }) - } - - /** - * Create an SSE transport for HTTP servers that don't support StreamableHTTP. - * Shares the same config (URL, headers, auth provider) as the StreamableHTTP transport. - */ - private createSseTransport(definition: ServerDefinition & { url: string }, serverName: string): Transport { - const { url, requestInit, authProvider } = this.buildHttpConfig(definition, serverName) - return new SSEClientTransport(url, { requestInit, authProvider }) - } - - private async fetchAllTools(client: Client): Promise { - const allTools: McpTool[] = [] - let cursor: string | undefined - - do { - const result = await client.listTools(cursor ? { cursor } : undefined) - allTools.push(...(result.tools ?? [])) - cursor = result.nextCursor - } while (cursor) - - return allTools - } - - private async fetchAllResources(client: Client): Promise { - try { - const allResources: McpResource[] = [] - let cursor: string | undefined - - do { - const result = await client.listResources(cursor ? { cursor } : undefined) - allResources.push(...(result.resources ?? [])) - cursor = result.nextCursor - } while (cursor) - - return allResources - } catch { - // Server may not support resources - return [] - } - } - - private attachAdapterNotificationHandlers(serverName: string, client: Client): void { - client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => { - const listener = this.uiStreamListeners.get(notification.params.streamToken) - if (!listener) return - listener(serverName, notification.params) - }) - } - - registerUiStreamListener(streamToken: string, listener: UiStreamListener): void { - this.uiStreamListeners.set(streamToken, listener) - } - - removeUiStreamListener(streamToken: string): void { - this.uiStreamListeners.delete(streamToken) - } - - async readResource(name: string, uri: string): Promise { - const connection = this.connections.get(name) - if (connection?.status !== "connected") { - throw new Error(`Server "${name}" is not connected`) - } - - try { - this.touch(name) - this.incrementInFlight(name) - return await connection.client.readResource({ uri }) - } finally { - this.decrementInFlight(name) - this.touch(name) - } - } - - async close(name: string): Promise { - const connection = this.connections.get(name) - if (!connection) return - - // Delete from map BEFORE async cleanup to prevent a race where a - // concurrent connect() creates a new connection that our deferred - // delete() would then remove, orphaning the new server process. - connection.status = "closed" - this.connections.delete(name) - await connection.client.close().catch(() => {}) - await connection.transport.close().catch(() => {}) - } - - async closeAll(): Promise { - const names = [...this.connections.keys()] - await Promise.all(names.map((name) => this.close(name))) - } - - getConnection(name: string): ServerConnection | undefined { - return this.connections.get(name) - } - - getAllConnections(): Map { - return new Map(this.connections) - } - - touch(name: string): void { - const connection = this.connections.get(name) - if (connection) { - connection.lastUsedAt = Date.now() - } - } - - incrementInFlight(name: string): void { - const connection = this.connections.get(name) - if (connection) { - connection.inFlight = (connection.inFlight ?? 0) + 1 - } - } - - decrementInFlight(name: string): void { - const connection = this.connections.get(name) - if (connection?.inFlight) { - connection.inFlight-- - } - } - - isIdle(name: string, timeoutMs: number): boolean { - const connection = this.connections.get(name) - if (connection?.status !== "connected") return false - if (connection.inFlight > 0) return false - return Date.now() - connection.lastUsedAt > timeoutMs - } - - /** - * Probe an MCP server for available tools without persisting a connection. - * - * Creates a transient connection via the shared createTransport() helper, - * calls tools/list, handles OAuth flow if needed, closes the connection, and - * returns the tool list. - * - * - OAuth servers: 60s timeout (allows browser-based auth flow) - * - Non-OAuth servers: 15s timeout - * - * The transient connection is never registered in `this.connections`, so it - * doesn't interfere with the normal connection lifecycle. Both client and - * transport are closed in a finally block regardless of outcome. - */ - async probeTools(name: string, definition: ServerDefinition): Promise { - const isOAuth = supportsOAuth(definition) - const totalBudgetMs = isOAuth ? 60_000 : 15_000 - // Single deadline for the entire probe operation (connect + tools/list), - // not per-operation, so an OAuth probe can't run 120s (60s connect + 60s - // tools/list) — it gets a single 60s budget from start to finish. - const deadline = Date.now() + totalBudgetMs - - let client = new Client({ name: `pi-mcp-probe-${name}`, version: "1.0.0" }) - let transport: Transport | undefined - - try { - transport = await this.createTransport(name, definition) - } catch (error) { - if (error instanceof UnauthorizedError) return this.authProbeResult() - return this.errorProbeResult(error) - } - - try { - const result = await this.connectAndList(client, transport, name, deadline) - this.writeProbeToCache(name, definition, result.tools) - return result - } catch (error) { - // SSE fallback for HTTP servers: if StreamableHTTP connect fails with a - // non-auth error, retry with the legacy SSE transport. - if (definition.url && !(error instanceof UnauthorizedError)) { - await transport.close().catch(() => {}) - await client.close().catch(() => {}) - transport = this.createSseTransport(definition as ServerEntry & { url: string }, name) - client = new Client({ name: `pi-mcp-probe-${name}`, version: "1.0.0" }) - - try { - const result = await this.connectAndList(client, transport, name, deadline) - this.writeProbeToCache(name, definition, result.tools) - return result - } catch (sseError) { - if (sseError instanceof UnauthorizedError) return this.authProbeResult() - return this.errorProbeResult(sseError) - } - } - - if (error instanceof UnauthorizedError) return this.authProbeResult() - return this.errorProbeResult(error) - } finally { - await client.close().catch(() => {}) - await transport?.close().catch(() => {}) - } - } - - /** - * Connect a client to a transport, fetch tools, and return a ProbeResult. - * Shared by probeTools() for both StreamableHTTP and SSE attempts. - */ - private async connectAndList( - client: Client, - transport: Transport, - name: string, - deadline: number, - ): Promise { - await withTimeout(client.connect(transport), deadline) - this.attachAdapterNotificationHandlers(name, client) - - const tools = await withTimeout(this.fetchAllTools(client), deadline) - const probeTools: ProbeMcpTool[] = tools.map((t) => ({ - name: t.name, - title: t.title, - description: t.description, - inputSchema: t.inputSchema, - annotations: t.annotations, - })) - - return { tools: probeTools, needsAuth: false, error: null } - } - - /** - * Write discovered tools to the metadata cache after a successful probe. - * Best-effort — cache write failures are swallowed so they never fail the - * probe itself. - */ - private writeProbeToCache(name: string, definition: ServerDefinition, tools: ProbeMcpTool[]): void { - try { - const cachedTools: CachedTool[] = tools.map((t) => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - annotations: t.annotations, - })) - const entry: ServerCacheEntry = { - configHash: computeServerHash(definition), - tools: cachedTools, - resources: [], - cachedAt: Date.now(), - } - saveMetadataCache({ version: 1, servers: { [name]: entry } }) - } catch (error) { - logger.debug( - `MCP: failed to write probe cache for ${name}: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - - /** - * Build a ProbeResult indicating the server requires authentication. - */ - private authProbeResult(): ProbeResult { - return { tools: [], needsAuth: true, error: null } - } - - /** - * Build a ProbeResult from an error, extracting a human-readable message. - */ - private errorProbeResult(error: unknown): ProbeResult { - return { - tools: [], - needsAuth: false, - error: error instanceof Error ? error.message : String(error), - } - } -} - -/** - * Wrap a promise with a timeout. The `deadline` parameter is an absolute - * timestamp (Date.now() + budgetMs). Rejects with an Error if the promise - * doesn't settle before the deadline. - * - * The losing side of Promise.race is caught to prevent unhandled rejection - * warnings if the original promise rejects after the timeout fires. - */ -async function withTimeout(promise: Promise, deadline: number): Promise { - const remaining = Math.max(0, deadline - Date.now()) - let timer: ReturnType | undefined - try { - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Operation timed out after ${remaining}ms`)), remaining) - timer.unref?.() - }) - // Prevent unhandled rejection if the original promise rejects after - // the timeout wins the race. - promise.catch(() => {}) - return await Promise.race([promise, timeout]) - } finally { - if (timer) clearTimeout(timer) - } -} - -/** - * Resolve environment variables with interpolation. - */ -function resolveEnv(env?: Record): Record { - // Copy process.env, filtering out undefined values - const resolved: Record = {} - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - resolved[key] = value - } - } - - if (!env) return resolved - - for (const [key, value] of Object.entries(env)) { - // Support ${VAR} and $env:VAR interpolation - resolved[key] = value - .replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "") - .replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "") - } - - return resolved -} - -/** - * Resolve headers with environment variable interpolation. - */ -function resolveHeaders(headers?: Record): Record | undefined { - if (!headers) return undefined - - const resolved: Record = {} - for (const [key, value] of Object.entries(headers)) { - resolved[key] = value - .replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "") - .replace(/\$env:(\w+)/g, (_, name) => process.env[name] ?? "") - } - return resolved -} diff --git a/src/extensions/mcp-adapter/state.ts b/src/extensions/mcp-adapter/state.ts deleted file mode 100644 index 285affe09..000000000 --- a/src/extensions/mcp-adapter/state.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ExtensionContext } from "@earendil-works/pi-coding-agent" -import type { SearchStrategy } from "./bm25.js" -import type { ConsentManager } from "./consent-manager.js" -import type { McpLifecycleManager } from "./lifecycle.js" -import type { McpServerManager } from "./server-manager.js" -import type { McpConfig, ToolMetadata, UiSessionMessages, UiStreamSummary } from "./types.js" -import type { UiResourceHandler } from "./ui-resource-handler.js" -import type { UiServerHandle } from "./ui-server.js" - -export interface CompletedUiSession { - serverName: string - toolName: string - completedAt: Date - reason: string - messages: UiSessionMessages - stream?: UiStreamSummary -} - -export type SendMessageFn = ( - message: { - customType: string - content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> - display: boolean - details?: unknown - }, - options?: { triggerTurn?: boolean }, -) => void - -export interface McpExtensionState { - manager: McpServerManager - lifecycle: McpLifecycleManager - toolMetadata: Map - config: McpConfig - failureTracker: Map - uiResourceHandler: UiResourceHandler - consentManager: ConsentManager - uiServer: UiServerHandle | null - completedUiSessions: CompletedUiSession[] - openBrowser: (url: string) => Promise - ui?: ExtensionContext["ui"] - sendMessage?: SendMessageFn - searchStrategy?: SearchStrategy - /** Prefixed names of tools registered dynamically via search/describe injection */ - dynamicToolNames: Set -} diff --git a/src/extensions/mcp-adapter/tool-metadata.test.ts b/src/extensions/mcp-adapter/tool-metadata.test.ts deleted file mode 100644 index a19822f2c..000000000 --- a/src/extensions/mcp-adapter/tool-metadata.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Unit tests for `buildToolMetadata` and `isReadOnlyMcpTool`. - * - * Verifies that the live fetch path carries an MCP tool's `annotations` - * through to the resulting `ToolMetadata` — specifically that a tool - * annotated `{ readOnlyHint: true }` survives with that hint intact, and - * that un-annotated tools surface `annotations === undefined`. - * - * Also verifies the `isReadOnlyMcpTool` predicate: annotated read tools - * return true, un-annotated tools whose name matches the read-only heuristic - * prefixes (`get_`, `search_`, `list_`, `read_`, `fetch_`) return true, and - * explicit write tools (`readOnlyHint: false`) or non-matching names return - * false. - */ -import { describe, expect, it } from "vitest" -import { buildToolMetadata, isReadOnlyMcpTool } from "./tool-metadata.js" -import type { McpTool } from "./types.js" - -const SERVER_NAME = "testserver" -const DEFINITION = {} - -describe("buildToolMetadata — annotations", () => { - it("preserves readOnlyHint:true on an annotated tool", () => { - const tools: McpTool[] = [ - { - name: "get_record", - description: "Fetch a record", - annotations: { readOnlyHint: true }, - }, - ] - - const { metadata } = buildToolMetadata(tools, [], DEFINITION, SERVER_NAME, "server") - - expect(metadata).toHaveLength(1) - expect(metadata[0].originalName).toBe("get_record") - expect(metadata[0].annotations).toBeDefined() - expect(metadata[0].annotations?.readOnlyHint).toBe(true) - }) - - it("surfaces annotations === undefined for an un-annotated tool", () => { - const tools: McpTool[] = [ - { - name: "create_record", - description: "Create a record", - }, - ] - - const { metadata } = buildToolMetadata(tools, [], DEFINITION, SERVER_NAME, "server") - - expect(metadata).toHaveLength(1) - expect(metadata[0].annotations).toBeUndefined() - }) - - it("preserves a destructiveHint annotation alongside readOnlyHint", () => { - const tools: McpTool[] = [ - { - name: "delete_record", - description: "Delete a record", - annotations: { readOnlyHint: false, destructiveHint: true }, - }, - ] - - const { metadata } = buildToolMetadata(tools, [], DEFINITION, SERVER_NAME, "server") - - expect(metadata[0].annotations?.readOnlyHint).toBe(false) - expect(metadata[0].annotations?.destructiveHint).toBe(true) - }) -}) - -describe("isReadOnlyMcpTool", () => { - it("returns true for an annotated read tool (readOnlyHint:true)", () => { - expect( - isReadOnlyMcpTool({ - originalName: "get_issue", - annotations: { readOnlyHint: true }, - }), - ).toBe(true) - }) - - it("returns true for an un-annotated tool matching the get_ heuristic", () => { - expect(isReadOnlyMcpTool({ originalName: "get_issue" })).toBe(true) - }) - - it("returns true for un-annotated tools matching other heuristic prefixes", () => { - expect(isReadOnlyMcpTool({ originalName: "search_records" })).toBe(true) - expect(isReadOnlyMcpTool({ originalName: "list_projects" })).toBe(true) - expect(isReadOnlyMcpTool({ originalName: "read_file" })).toBe(true) - expect(isReadOnlyMcpTool({ originalName: "fetch_status" })).toBe(true) - }) - - it("returns false for an explicit write tool (readOnlyHint:false)", () => { - expect( - isReadOnlyMcpTool({ - originalName: "update_issue", - annotations: { readOnlyHint: false }, - }), - ).toBe(false) - }) - - it("returns false for an un-annotated tool with a non-matching name", () => { - expect(isReadOnlyMcpTool({ originalName: "create_issue" })).toBe(false) - expect(isReadOnlyMcpTool({ originalName: "delete_issue" })).toBe(false) - }) - - it("returns false for a read-prefixed name when annotations mark it as write", () => { - // The heuristic only applies when annotations are absent; an explicit - // readOnlyHint:false must win even for a get_-prefixed name. - expect( - isReadOnlyMcpTool({ - originalName: "get_secret", - annotations: { readOnlyHint: false }, - }), - ).toBe(false) - }) -}) diff --git a/src/extensions/mcp-adapter/tool-metadata.ts b/src/extensions/mcp-adapter/tool-metadata.ts deleted file mode 100644 index 78d022688..000000000 --- a/src/extensions/mcp-adapter/tool-metadata.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge" -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js" -import { resourceNameToToolName } from "./resource-tools.js" -import type { McpExtensionState } from "./state.js" -import type { McpResource, McpTool, ServerEntry, ToolMetadata } from "./types.js" -import { formatToolName, isToolExcluded } from "./types.js" -import { extractToolUiStreamMode } from "./utils.js" - -/** - * Heuristic name prefixes that indicate a read-only MCP tool when the server - * does not populate `annotations.readOnlyHint`. - */ -const READ_ONLY_NAME_PREFIXES = /^(get|search|list|read|fetch)/ - -/** - * Returns true when an MCP tool is safe to call during read-only (scoping) - * contexts. A tool qualifies when its `annotations.readOnlyHint` is explicitly - * `true`, OR when annotations are absent and the tool's original name matches a - * read-only heuristic prefix (`get_`, `search_`, `list_`, `read_`, `fetch_`). - * - * A tool with `readOnlyHint: false` (or any annotations present) is never - * promoted by the heuristic — the explicit annotation wins. - */ -export function isReadOnlyMcpTool(meta: { originalName: string; annotations?: ToolAnnotations }): boolean { - if (meta.annotations?.readOnlyHint === true) return true - if (meta.annotations === undefined && READ_ONLY_NAME_PREFIXES.test(meta.originalName)) { - // The name heuristic is best-effort: a tool with no annotations but a - // read-only-prefixed name (e.g. `get_reset_database`) cannot be proven - // safe, so we promote it and surface a warning so operators can audit - // the classification. Servers SHOULD set readOnlyHint explicitly. - console.warn(`[mcp] Tool "${meta.originalName}" promoted to read-only via name heuristic (no annotations)`) - return true - } - return false -} - -export function buildToolMetadata( - tools: McpTool[], - resources: McpResource[], - definition: ServerEntry, - serverName: string, - prefix: "server" | "none" | "short", -): { metadata: ToolMetadata[]; failedTools: string[] } { - const metadata: ToolMetadata[] = [] - const failedTools: string[] = [] - - for (const tool of tools) { - if (!tool?.name) { - failedTools.push("(unnamed)") - continue - } - if (isToolExcluded(tool.name, serverName, prefix, definition.excludeTools)) { - continue - } - - let uiResourceUri: string | undefined - try { - uiResourceUri = getToolUiResourceUri({ _meta: tool._meta }) - } catch { - failedTools.push(tool.name) - } - metadata.push({ - name: formatToolName(tool.name, serverName, prefix), - originalName: tool.name, - description: tool.description ?? "", - inputSchema: tool.inputSchema, - uiResourceUri, - uiStreamMode: extractToolUiStreamMode(tool._meta), - annotations: tool.annotations, - }) - } - - if (definition.exposeResources !== false) { - for (const resource of resources) { - const baseName = `get_${resourceNameToToolName(resource.name)}` - if (isToolExcluded(baseName, serverName, prefix, definition.excludeTools)) { - continue - } - - metadata.push({ - name: formatToolName(baseName, serverName, prefix), - originalName: baseName, - description: resource.description ?? `Read resource: ${resource.uri}`, - resourceUri: resource.uri, - }) - } - } - - return { metadata, failedTools } -} - -export function getToolNames(state: McpExtensionState, serverName: string): string[] { - return state.toolMetadata.get(serverName)?.map((m) => m.name) ?? [] -} - -export function totalToolCount(state: McpExtensionState): number { - let count = 0 - for (const metadata of state.toolMetadata.values()) { - count += metadata.length - } - return count -} - -export function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined { - if (!metadata) return undefined - const exact = metadata.find((m) => m.name === toolName) - if (exact) return exact - const normalized = toolName.replace(/-/g, "_") - return metadata.find((m) => m.name.replace(/-/g, "_") === normalized) -} - -export function formatSchema(schema: unknown, indent = " "): string { - if (!schema || typeof schema !== "object") { - return `${indent}(no schema)` - } - - const s = schema as Record - - if (s.type === "object" && s.properties && typeof s.properties === "object") { - const props = s.properties as Record - const required = Array.isArray(s.required) ? (s.required as string[]) : [] - - if (Object.keys(props).length === 0) { - return `${indent}(no parameters)` - } - - const lines: string[] = [] - for (const [name, propSchema] of Object.entries(props)) { - const isRequired = required.includes(name) - const propLine = formatProperty(name, propSchema, isRequired, indent) - lines.push(propLine) - } - return lines.join("\n") - } - - if (s.type) { - return `${indent}(${s.type})` - } - - return `${indent}(complex schema)` -} - -function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string { - if (!schema || typeof schema !== "object") { - return `${indent}${name}${required ? " *required*" : ""}` - } - - const s = schema as Record - const parts: string[] = [] - - let typeStr = "" - if (s.type) { - if (Array.isArray(s.type)) { - typeStr = s.type.join(" | ") - } else { - typeStr = String(s.type) - } - } else if (s.enum) { - typeStr = "enum" - } else if (s.anyOf || s.oneOf) { - typeStr = "union" - } - - if (Array.isArray(s.enum)) { - const enumVals = s.enum.map((v) => JSON.stringify(v)).join(", ") - typeStr = `enum: ${enumVals}` - } - - parts.push(`${indent}${name}`) - if (typeStr) parts.push(`(${typeStr})`) - if (required) parts.push("*required*") - - if (s.description && typeof s.description === "string") { - parts.push(`- ${s.description}`) - } - - if (s.default !== undefined) { - parts.push(`[default: ${JSON.stringify(s.default)}]`) - } - - return parts.join(" ") -} diff --git a/src/extensions/mcp-adapter/tool-registrar.ts b/src/extensions/mcp-adapter/tool-registrar.ts deleted file mode 100644 index 659e9254c..000000000 --- a/src/extensions/mcp-adapter/tool-registrar.ts +++ /dev/null @@ -1,46 +0,0 @@ -// tool-registrar.ts - MCP content transformation -// NOTE: Tools are NOT registered with Pi - only the unified `mcp` proxy tool is registered. -// This keeps the LLM context small (1 tool instead of 100s). - -import type { ContentBlock, McpContent } from "./types.js" - -/** - * Transform MCP content types to Pi content blocks. - */ -export function transformMcpContent(content: McpContent[]): ContentBlock[] { - return content.map((c) => { - if (c.type === "text") { - return { type: "text" as const, text: c.text ?? "" } - } - if (c.type === "image") { - return { - type: "image" as const, - data: c.data ?? "", - mimeType: c.mimeType ?? "image/png", - } - } - if (c.type === "resource") { - const resourceUri = c.resource?.uri ?? "(no URI)" - const resourceContent = c.resource?.text ?? (c.resource ? JSON.stringify(c.resource) : "(no content)") - return { - type: "text" as const, - text: `[Resource: ${resourceUri}]\n${resourceContent}`, - } - } - if (c.type === "resource_link") { - const linkName = c.name ?? c.uri ?? "unknown" - const linkUri = c.uri ?? "(no URI)" - return { - type: "text" as const, - text: `[Resource Link: ${linkName}]\nURI: ${linkUri}`, - } - } - if (c.type === "audio") { - return { - type: "text" as const, - text: `[Audio content: ${c.mimeType ?? "audio/*"}]`, - } - } - return { type: "text" as const, text: JSON.stringify(c) } - }) -} diff --git a/src/extensions/mcp-adapter/types.ts b/src/extensions/mcp-adapter/types.ts deleted file mode 100644 index 4a02bf91b..000000000 --- a/src/extensions/mcp-adapter/types.ts +++ /dev/null @@ -1,447 +0,0 @@ -import type { ImageContent, TextContent } from "@earendil-works/pi-ai" -import type { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" -// types.ts - Core type definitions -import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js" -import type { UiStreamMode } from "./ui-stream-types.js" - -// Transport type (stdio + HTTP) -export type Transport = StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport - -// Import sources for config -export type ImportKind = "cursor" | "claude-code" | "claude-desktop" | "codex" | "windsurf" | "vscode" - -/** - * A tool discovered during a probe (transient connection). - * Mirrors McpTool but is a standalone type so the ACP wire format is stable - * regardless of future McpTool changes. - */ -export interface ProbeMcpTool { - name: string - title?: string - description?: string - inputSchema?: unknown - annotations?: ToolAnnotations -} - -/** - * Result of probing an MCP server for available tools. - * Returned by McpServerManager.probeTools() and the _kimchi.dev/probe_mcp_server - * ACP extMethod handler. - */ -export interface ProbeResult { - tools: ProbeMcpTool[] - needsAuth: boolean - error: string | null -} - -// Tool definition from MCP server -export interface McpTool { - name: string - title?: string - description?: string - inputSchema?: unknown // JSON Schema - annotations?: ToolAnnotations // Read-only/destructive hints from the MCP protocol - _meta?: Record -} - -// Resource definition from MCP server -export interface McpResource { - uri: string - name: string - description?: string - mimeType?: string - _meta?: Record -} - -export interface UiResourceMeta { - csp?: UiResourceCsp - permissions?: UiResourcePermissions - domain?: string - prefersBorder?: boolean -} - -export interface UiResourceContent { - uri: string - html: string - mimeType?: string - meta: UiResourceMeta -} - -export interface UiProxyRequestBody { - token: string - params: TParams -} - -export interface UiProxyResult> { - ok: boolean - result?: T - error?: string -} - -export interface UiResourceCsp { - connectDomains?: string[] - scriptDomains?: string[] - styleDomains?: string[] - fontDomains?: string[] - imgDomains?: string[] - mediaDomains?: string[] - frameDomains?: string[] - workerDomains?: string[] - baseUriDomains?: string[] -} - -export interface UiResourcePermissions { - camera?: {} - microphone?: {} - geolocation?: {} - clipboardWrite?: {} -} - -export interface UiToolInfo { - id?: string | number - tool: { - name: string - description?: string - inputSchema?: unknown - } -} - -export interface UiHostContext { - toolInfo?: UiToolInfo - theme?: "light" | "dark" - styles?: Record - displayMode?: UiDisplayMode - availableDisplayModes?: UiDisplayMode[] - containerDimensions?: { - width?: number - maxWidth?: number - height?: number - maxHeight?: number - } - [key: string]: unknown -} - -export type UiDisplayMode = "inline" | "fullscreen" | "pip" - -// Re-export stream types from the shared lightweight module. -// This allows the example package to import stream schemas without pulling the full types.ts dependency graph. -export { - getUiStreamHostContext, - getVisualizationStreamEnvelope, - SERVER_STREAM_RESULT_PATCH_METHOD, - type ServerStreamResultPatchNotification, - serverStreamResultPatchNotificationSchema, - UI_STREAM_HOST_CONTEXT_KEY, - UI_STREAM_REQUEST_META_KEY, - UI_STREAM_RESULT_PATCH_METHOD, - UI_STREAM_STRUCTURED_CONTENT_KEY, - type UiStreamCallToolResult, - type UiStreamHostContext, - type UiStreamMode, - type UiStreamResultPatchNotification, - type UiStreamSummary, - uiStreamCallToolResultSchema, - uiStreamHostContextSchema, - uiStreamModeSchema, - uiStreamResultPatchNotificationSchema, - type VisualizationStreamEnvelope, - type VisualizationStreamFrameType, - type VisualizationStreamPhase, - type VisualizationStreamStatus, - visualizationStreamEnvelopeSchema, - visualizationStreamFrameTypeSchema, - visualizationStreamPhaseSchema, - visualizationStreamStatusSchema, -} from "./ui-stream-types.js" - -export interface UiMessageParams { - role?: string - content?: unknown[] - type?: "prompt" | "notify" | "intent" | "message" - message?: string - prompt?: string - intent?: string - params?: Record - [key: string]: unknown -} - -/** - * Extract prompt text from either legacy MCP UI message shapes or native AppBridge user messages. - */ -export function extractUiPromptText(params: UiMessageParams): string | undefined { - if (params.type === "prompt" || params.prompt) { - const prompt = params.prompt ?? String(params.message ?? "") - return prompt || undefined - } - - if (params.role === "user" && Array.isArray(params.content)) { - const text = params.content - .map((block) => - block && typeof block === "object" && "text" in block ? String((block as { text?: unknown }).text ?? "") : "", - ) - .filter(Boolean) - .join("\n\n") - return text || undefined - } - - return undefined -} - -/** - * Structured UI handoff recovered from a canonical prompt envelope. - */ -export interface UiPromptHandoff { - intent: string - params: Record - raw: string -} - -/** - * Parse a canonical named UI handoff encoded as `intent\n{json}`. - */ -export function parseUiPromptHandoff(prompt: string): UiPromptHandoff | undefined { - const newlineIndex = prompt.indexOf("\n") - if (newlineIndex <= 0) { - return undefined - } - - const intent = prompt.slice(0, newlineIndex).trim() - const payloadText = prompt.slice(newlineIndex + 1).trim() - if (!intent || !payloadText) { - return undefined - } - - if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(intent)) { - return undefined - } - - try { - const parsed = JSON.parse(payloadText) - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined - } - return { - intent, - params: parsed as Record, - raw: prompt, - } - } catch { - return undefined - } -} - -/** - * Accumulated messages from a UI session. - * Collected during the session and available when it ends. - */ -export interface UiSessionMessages { - prompts: string[] - notifications: string[] - intents: Array<{ intent: string; params?: Record }> -} - -export interface UiModelContextParams { - content?: unknown[] - structuredContent?: Record - [key: string]: unknown -} - -export interface UiOpenLinkResult { - isError?: boolean - [key: string]: unknown -} - -export interface UiDisplayModeRequest { - mode?: UiDisplayMode -} - -export interface UiDisplayModeResult { - mode: UiDisplayMode - [key: string]: unknown -} - -// Content types from MCP -export interface McpContent { - type: "text" | "image" | "audio" | "resource" | "resource_link" - text?: string - data?: string - mimeType?: string - resource?: { - uri: string - text?: string - blob?: string - } - uri?: string - name?: string - description?: string -} - -// Pi content block type -export type ContentBlock = TextContent | ImageContent - -// OAuth configuration (SDK handles auto-discovery and dynamic registration) -export interface OAuthConfig { - /** OAuth grant type (defaults to authorization_code) */ - grantType?: "authorization_code" | "client_credentials" - /** Pre-registered client ID (optional, dynamic registration used if not provided) */ - clientId?: string - /** Client secret for confidential clients */ - clientSecret?: string - /** Requested OAuth scopes */ - scope?: string -} - -// Server configuration -export interface ServerEntry { - command?: string - args?: string[] - env?: Record - cwd?: string - // HTTP fields - url?: string - headers?: Record - /** - * Authentication type: - * - 'oauth' - Use OAuth 2.1 (auto-discovers endpoints, supports dynamic client registration) - * - 'bearer' - Use static Bearer token - * - false - Disable authentication - * If not specified and url is present, OAuth will be auto-detected - */ - auth?: "oauth" | "bearer" | false - bearerToken?: string - bearerTokenEnv?: string - /** - * OAuth configuration (optional). - * If not provided, the SDK will attempt dynamic client registration. - * Set to false to explicitly disable OAuth for this server. - */ - oauth?: OAuthConfig | false - lifecycle?: "keep-alive" | "lazy" | "eager" - idleTimeout?: number // minutes, overrides global setting - // Resource handling - exposeResources?: boolean - // Direct tool registration - directTools?: boolean | string[] - // Exclude specific MCP tools/resources by original or prefixed name - excludeTools?: string[] - // Debug - debug?: boolean // Show server stderr (default: false) -} - -// Settings -export interface McpSettings { - toolPrefix?: "server" | "none" | "short" - idleTimeout?: number // minutes, default 10, 0 to disable - directTools?: boolean - disableProxyTool?: boolean - autoAuth?: boolean -} - -// Root config -export interface McpConfig { - mcpServers: Record - imports?: ImportKind[] - settings?: McpSettings -} - -// Alias for clarity -export type ServerDefinition = ServerEntry - -export interface ToolMetadata { - name: string // Prefixed tool name (e.g., "xcodebuild_list_sims") - originalName: string // Original MCP tool name (e.g., "list_sims") - description: string - resourceUri?: string // For resource tools: the URI to read - uiResourceUri?: string // For app-enabled tools: the UI resource URI - inputSchema?: unknown // JSON Schema for parameters (stored for describe/errors) - uiStreamMode?: UiStreamMode - annotations?: ToolAnnotations // Read-only/destructive hints from the MCP protocol -} - -export interface DirectToolSpec { - serverName: string - originalName: string - prefixedName: string - description: string - inputSchema?: unknown - resourceUri?: string - uiResourceUri?: string - uiStreamMode?: UiStreamMode - annotations?: ToolAnnotations // Read-only/destructive hints from the MCP protocol - /** Cached schema for auto-fill and retry decisions */ - metadata?: ToolMetadata -} - -export interface ServerProvenance { - path: string - kind: "user" | "project" | "import" - importKind?: string -} - -export interface McpPanelCallbacks { - reconnect: (serverName: string) => Promise - getConnectionStatus: (serverName: string) => "connected" | "idle" | "failed" | "needs-auth" - refreshCacheAfterReconnect: (serverName: string) => import("./metadata-cache.js").ServerCacheEntry | null - /** - * Persists the given changes to disk and shows a notification. - * The panel stays open after this is called. - */ - onSave: (changes: Map) => void -} - -export interface McpPanelResult { - changes: Map - cancelled: boolean -} - -/** - * Get server prefix based on tool prefix mode. - */ -export function getServerPrefix(serverName: string, mode: "server" | "none" | "short"): string { - if (mode === "none") return "" - if (mode === "short") { - let short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_") - if (!short) short = "mcp" - return short - } - return serverName.replace(/-/g, "_") -} - -/** - * Format a tool name with server prefix. - */ -export function formatToolName(toolName: string, serverName: string, prefix: "server" | "none" | "short"): string { - const p = getServerPrefix(serverName, prefix) - return p ? `${p}_${toolName}` : toolName -} - -function normalizeToolName(value: string): string { - return value.replace(/-/g, "_") -} - -export function isToolExcluded( - toolName: string, - serverName: string, - prefix: "server" | "none" | "short", - excludeTools?: unknown, -): boolean { - if (!Array.isArray(excludeTools) || excludeTools.length === 0) return false - - const candidates = new Set([ - normalizeToolName(toolName), - normalizeToolName(formatToolName(toolName, serverName, prefix)), - normalizeToolName(formatToolName(toolName, serverName, "server")), - normalizeToolName(formatToolName(toolName, serverName, "short")), - ]) - - for (const excluded of excludeTools) { - if (typeof excluded !== "string") continue - if (candidates.has(normalizeToolName(excluded))) { - return true - } - } - - return false -} diff --git a/src/extensions/mcp-adapter/ui-resource-handler.ts b/src/extensions/mcp-adapter/ui-resource-handler.ts deleted file mode 100644 index 287af05c5..000000000 --- a/src/extensions/mcp-adapter/ui-resource-handler.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge" -import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js" -import { ResourceFetchError, ResourceParseError } from "./errors.js" -import { logger } from "./logger.js" -import type { McpServerManager } from "./server-manager.js" -import type { UiResourceContent, UiResourceMeta } from "./types.js" - -interface ResourceContentRecord { - uri?: string - mimeType?: string - text?: string - blob?: string - _meta?: Record -} - -export class UiResourceHandler { - private log = logger.child({ component: "UiResourceHandler" }) - - constructor(private manager: McpServerManager) {} - - async readUiResource(serverName: string, uri: string): Promise { - const log = this.log.child({ server: serverName, uri }) - - if (!uri.startsWith("ui://")) { - throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName }) - } - - log.debug("Fetching UI resource") - - let result: ReadResourceResult - try { - result = await this.manager.readResource(serverName, uri) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log.error("Failed to read resource", error instanceof Error ? error : undefined) - throw new ResourceFetchError(uri, message, { - server: serverName, - cause: error instanceof Error ? error : undefined, - }) - } - - const content = selectContent(result, uri) - const mimeType = content.mimeType - - if (mimeType && !isHtmlMimeType(mimeType)) { - log.warn("Unsupported MIME type", { mimeType }) - throw new ResourceParseError( - uri, - `unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`, - { server: serverName, mimeType }, - ) - } - - const html = toHtml(content) - if (!html.trim()) { - log.warn("Resource content is empty") - throw new ResourceParseError(uri, "content is empty", { server: serverName }) - } - - const contentMeta = extractUiMeta(content._meta) - const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri)) - - log.debug("Resource loaded successfully", { - contentLength: html.length, - hasCsp: !!contentMeta.csp || !!listMeta.csp, - }) - - return { - uri: content.uri ?? uri, - html, - mimeType: mimeType ?? RESOURCE_MIME_TYPE, - meta: { - csp: contentMeta.csp ?? listMeta.csp, - permissions: contentMeta.permissions ?? listMeta.permissions, - domain: contentMeta.domain ?? listMeta.domain, - prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder, - }, - } - } - - private getListResourceMeta(serverName: string, uri: string): Record | undefined { - const connection = this.manager.getConnection(serverName) - if (!connection?.resources?.length) return undefined - const resource = connection.resources.find((entry) => entry.uri === uri) - if (!resource?._meta || typeof resource._meta !== "object") return undefined - return resource._meta - } -} - -function selectContent(result: ReadResourceResult, preferredUri: string): ResourceContentRecord { - const contents = (result.contents ?? []) as ResourceContentRecord[] - if (contents.length === 0) { - throw new Error(`No contents returned for UI resource: ${preferredUri}`) - } - - const byUri = contents.find((content) => content.uri === preferredUri) - if (byUri) return byUri - - const byHtmlMime = contents.find((content) => content.mimeType && isHtmlMimeType(content.mimeType)) - if (byHtmlMime) return byHtmlMime - - return contents[0] -} - -function isHtmlMimeType(mimeType: string): boolean { - const normalized = mimeType.toLowerCase() - return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase() -} - -function toHtml(content: ResourceContentRecord): string { - if (typeof content.text === "string") { - return content.text - } - - if (typeof content.blob === "string") { - return Buffer.from(content.blob, "base64").toString("utf-8") - } - - throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`) -} - -function extractUiMeta(meta: Record | undefined): UiResourceMeta { - if (!meta || typeof meta !== "object") return {} - const ui = meta.ui as Record | undefined - if (!ui || typeof ui !== "object") return {} - - const out: UiResourceMeta = {} - - if (ui.csp && typeof ui.csp === "object") { - out.csp = ui.csp as UiResourceMeta["csp"] - } - if (ui.permissions && typeof ui.permissions === "object") { - out.permissions = ui.permissions as UiResourceMeta["permissions"] - } - if (typeof ui.domain === "string") { - out.domain = ui.domain - } - if (typeof ui.prefersBorder === "boolean") { - out.prefersBorder = ui.prefersBorder - } - - return out -} diff --git a/src/extensions/mcp-adapter/ui-server.ts b/src/extensions/mcp-adapter/ui-server.ts deleted file mode 100644 index 119cfdb6d..000000000 --- a/src/extensions/mcp-adapter/ui-server.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { randomUUID } from "node:crypto" -import fs from "node:fs/promises" -import http, { type IncomingMessage, type ServerResponse } from "node:http" -import path from "node:path" -import { buildAllowAttribute } from "@modelcontextprotocol/ext-apps/app-bridge" -import type { CallToolRequest, CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import type { ConsentManager } from "./consent-manager.js" -import { ServerError, wrapError } from "./errors.js" -import { applyCspMeta, buildCspMetaContent, buildHostHtmlTemplate } from "./host-html-template.js" -import { logger } from "./logger.js" -import type { McpServerManager } from "./server-manager.js" -import { - extractUiPromptText, - getVisualizationStreamEnvelope, - type UiDisplayMode, - type UiDisplayModeRequest, - type UiDisplayModeResult, - type UiHostContext, - type UiMessageParams, - type UiModelContextParams, - type UiOpenLinkResult, - type UiProxyRequestBody, - type UiProxyResult, - type UiResourceContent, - type UiSessionMessages, - type UiStreamSummary, -} from "./types.js" - -const MAX_BODY_SIZE = 2 * 1024 * 1024 -const ABANDONED_GRACE_MS = 60_000 -const WATCHDOG_INTERVAL_MS = 5_000 -const MAX_EVENT_LOG = 128 - -export interface UiServerOptions { - serverName: string - toolName: string - toolArgs: Record - resource: UiResourceContent - manager: McpServerManager - consentManager: ConsentManager - hostContext?: UiHostContext - initialResultPromise?: Promise - sessionToken?: string - port?: number - onMessage?: (params: UiMessageParams) => Promise | void - onContextUpdate?: (params: UiModelContextParams) => Promise | void - onComplete?: (reason: string) => void -} - -export interface UiServerHandle { - url: string - port: number - sessionToken: string - serverName: string - toolName: string - close: (reason?: string) => void - sendToolInput: (args: Record) => void - sendToolResult: (result: CallToolResult) => void - sendResultPatch: (result: CallToolResult) => void - sendToolCancelled: (reason: string) => void - sendHostContext: (context: UiHostContext) => void - /** Get accumulated messages from this session */ - getSessionMessages: () => UiSessionMessages - getStreamSummary: () => UiStreamSummary | undefined -} - -export async function startUiServer(options: UiServerOptions): Promise { - const sessionToken = options.sessionToken ?? randomUUID() - const log = logger.child({ - component: "UiServer", - server: options.serverName, - tool: options.toolName, - session: sessionToken.slice(0, 8), - }) - - log.debug("Starting UI server") - - const sseClients = new Set() - let completed = false - let lastHeartbeatAt = Date.now() - let watchdog: NodeJS.Timeout | null = null - let currentDisplayMode: UiDisplayMode = options.hostContext?.displayMode ?? "inline" - let nextEventId = 1 - const eventLog: Array<{ id: number; name: string; payload: unknown }> = [] - let streamSummary: UiStreamSummary | undefined - - // Track messages from UI for retrieval - const sessionMessages: UiSessionMessages = { - prompts: [], - notifications: [], - intents: [], - } - - const hostContext: UiHostContext = { - displayMode: currentDisplayMode, - availableDisplayModes: ["inline", "fullscreen", "pip"], - platform: "desktop", - ...options.hostContext, - // Only include toolInfo if caller provides full tool definition with inputSchema - // The App validates toolInfo.tool.inputSchema as required object - } - - const initialStreamContext = hostContext["pi-mcp-adapter/stream"] - if (initialStreamContext && typeof initialStreamContext === "object") { - const streamId = (initialStreamContext as { streamId?: unknown }).streamId - const mode = (initialStreamContext as { mode?: unknown }).mode - if (typeof streamId === "string" && (mode === "eager" || mode === "stream-first")) { - streamSummary = { - streamId, - mode, - frames: 0, - phases: [], - } - } - } - - const touchHeartbeat = () => { - lastHeartbeatAt = Date.now() - } - - const updateStreamSummary = (payload: unknown) => { - const envelope = getVisualizationStreamEnvelope( - (payload as { structuredContent?: unknown } | null)?.structuredContent, - ) - if (!envelope) return - if (!streamSummary) { - streamSummary = { - streamId: envelope.streamId, - mode: "eager", - frames: 0, - phases: [], - } - } - streamSummary.frames += 1 - if (!streamSummary.phases.includes(envelope.phase)) { - streamSummary.phases.push(envelope.phase) - } - streamSummary.finalStatus = envelope.status - streamSummary.lastMessage = envelope.message - } - - const serializeEvent = (eventId: number, name: string, payload: unknown): string => { - return `id: ${eventId}\nevent: ${name}\ndata: ${JSON.stringify(payload)}\n\n` - } - - const getLatestCheckpointIndex = () => { - for (let index = eventLog.length - 1; index >= 0; index -= 1) { - const entry = eventLog[index] - const envelope = getVisualizationStreamEnvelope( - (entry.payload as { structuredContent?: unknown } | null)?.structuredContent, - ) - if (envelope?.frameType === "checkpoint" || envelope?.frameType === "final") { - return index - } - } - return -1 - } - - const pruneEventLog = () => { - if (eventLog.length <= MAX_EVENT_LOG) return - const latestCheckpointIndex = getLatestCheckpointIndex() - - if (latestCheckpointIndex > 0) { - eventLog.splice(0, latestCheckpointIndex) - } - - if (eventLog.length > MAX_EVENT_LOG) { - eventLog.splice(0, eventLog.length - MAX_EVENT_LOG) - } - } - - const pushEvent = (name: string, payload: unknown) => { - if (completed) return - const eventId = nextEventId++ - eventLog.push({ id: eventId, name, payload }) - updateStreamSummary(payload) - pruneEventLog() - const chunk = serializeEvent(eventId, name, payload) - for (const client of sseClients) { - try { - client.write(chunk) - } catch { - sseClients.delete(client) - } - } - } - - const replayEvents = (res: ServerResponse, lastEventIdHeader?: string | null) => { - const parsedLastId = lastEventIdHeader ? Number(lastEventIdHeader) : Number.NaN - const eventsToReplay = Number.isFinite(parsedLastId) - ? eventLog.filter((entry) => entry.id > parsedLastId) - : (() => { - const latestCheckpointIndex = getLatestCheckpointIndex() - return latestCheckpointIndex >= 0 ? eventLog.slice(latestCheckpointIndex) : eventLog - })() - - for (const entry of eventsToReplay) { - try { - res.write(serializeEvent(entry.id, entry.name, entry.payload)) - } catch { - sseClients.delete(res) - return - } - } - } - - const closeSse = () => { - for (const client of sseClients) { - try { - client.end() - } catch {} - } - sseClients.clear() - } - - const stopWatchdog = () => { - if (!watchdog) return - clearInterval(watchdog) - watchdog = null - } - - const markCompleted = (reason: string) => { - if (completed) return - log.debug("Session completed", { reason }) - pushEvent("session-complete", { reason }) - completed = true - stopWatchdog() - options.onComplete?.(reason) - } - - const server = http.createServer(async (req, res) => { - try { - const method = req.method || "GET" - const url = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`) - - if (method === "GET" && url.pathname === "/") { - if (!validateTokenQuery(url, sessionToken, res)) return - touchHeartbeat() - - const html = buildHostHtmlTemplate({ - sessionToken, - serverName: options.serverName, - toolName: options.toolName, - toolArgs: options.toolArgs, - resource: options.resource, - allowAttribute: buildAllowAttribute(options.resource.meta.permissions), - requireToolConsent: options.consentManager.requiresPrompt(options.serverName), - cacheToolConsent: options.consentManager.shouldCacheConsent(), - hostContext, - }) - - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - }) - res.end(html) - return - } - - if (method === "GET" && url.pathname === "/events") { - if (!validateTokenQuery(url, sessionToken, res)) return - touchHeartbeat() - log.debug("SSE client connected", { clientCount: sseClients.size + 1 }) - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }) - res.write(": connected\n\n") - sseClients.add(res) - replayEvents(res, req.headers["last-event-id"] ? String(req.headers["last-event-id"]) : null) - req.on("close", () => { - sseClients.delete(res) - }) - return - } - - if (method === "GET" && url.pathname === "/health") { - if (!validateTokenQuery(url, sessionToken, res)) return - sendJson(res, 200, { ok: true, result: { healthy: true } }) - return - } - - if (method === "GET" && url.pathname === "/ui-app") { - if (!validateTokenQuery(url, sessionToken, res)) return - touchHeartbeat() - // Serve the MCP app's UI HTML directly (avoids blob URL security issues) - // Apply CSP meta tag if specified in resource metadata - const cspContent = buildCspMetaContent(options.resource.meta.csp) - const appHtml = applyCspMeta(options.resource.html, cspContent) - res.writeHead(200, { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - }) - res.end(appHtml) - return - } - - if (method === "GET" && url.pathname === "/app-bridge.bundle.js") { - // Serve the pre-bundled AppBridge module - const bundlePath = path.join(import.meta.dirname, "app-bridge.bundle.js") - try { - const content = await fs.readFile(bundlePath, "utf-8") - res.writeHead(200, { - "Content-Type": "application/javascript", - "Cache-Control": "public, max-age=31536000", - }) - res.end(content) - } catch { - sendJson(res, 500, { ok: false, error: "Bundle not found" }) - } - return - } - - if (method !== "POST") { - sendJson(res, 404, { ok: false, error: "Not found" }) - return - } - - const body = await parseBody(req, res) - if (!body) return - if (!validateTokenBody(body, sessionToken, res)) return - const params = body.params ?? {} - touchHeartbeat() - - if (url.pathname === "/proxy/tools/call") { - options.consentManager.ensureApproved(options.serverName) - const callParams = params as CallToolRequest["params"] - if (!callParams || typeof callParams.name !== "string" || !callParams.name.trim()) { - sendJson(res, 400, { ok: false, error: "Invalid tools/call params" }) - return - } - - const connection = options.manager.getConnection(options.serverName) - if (connection?.status !== "connected") { - sendJson(res, 503, { ok: false, error: `Server "${options.serverName}" is not connected` }) - return - } - - try { - options.manager.touch(options.serverName) - options.manager.incrementInFlight(options.serverName) - const result = await connection.client.callTool({ - name: callParams.name, - arguments: - callParams.arguments && typeof callParams.arguments === "object" && !Array.isArray(callParams.arguments) - ? callParams.arguments - : {}, - }) - sendJson(res, 200, { ok: true, result }) - } finally { - options.manager.decrementInFlight(options.serverName) - options.manager.touch(options.serverName) - } - return - } - - if (url.pathname === "/proxy/ui/consent") { - const approved = !!(params as { approved?: boolean }).approved - options.consentManager.registerDecision(options.serverName, approved) - sendJson(res, 200, { ok: true, result: { approved } }) - return - } - - if (url.pathname === "/proxy/ui/message") { - const msgParams = params as UiMessageParams - const promptText = extractUiPromptText(msgParams) - - // Track messages by type (order: prompt → intent → notify) - // Must match the order in index.ts onMessage handler - if (promptText) { - sessionMessages.prompts.push(promptText) - log.debug("UI prompt received", { prompt: promptText.slice(0, 100) }) - } else if (msgParams.type === "intent" || msgParams.intent) { - const intentName = msgParams.intent ?? "" - if (intentName) { - sessionMessages.intents.push({ - intent: intentName, - params: msgParams.params, - }) - log.debug("UI intent received", { intent: intentName }) - } - } else if (msgParams.type === "notify" || msgParams.message) { - const notifyText = msgParams.message ?? "" - if (notifyText) { - sessionMessages.notifications.push(notifyText) - log.debug("UI notification", { message: notifyText.slice(0, 100) }) - } - } - - await options.onMessage?.(msgParams) - sendJson(res, 200, { ok: true, result: {} }) - return - } - - if (url.pathname === "/proxy/ui/context") { - const ctxParams = params as UiModelContextParams - log.debug("UI context update", { hasContent: !!ctxParams.content }) - await options.onContextUpdate?.(ctxParams) - sendJson(res, 200, { ok: true, result: {} }) - return - } - - if (url.pathname === "/proxy/ui/open-link") { - const openParams = params as { url?: string } - if (!openParams?.url || typeof openParams.url !== "string") { - sendJson(res, 400, { ok: false, error: "Invalid open-link params" }) - return - } - let result: UiOpenLinkResult = {} - try { - new URL(openParams.url) - } catch { - result = { isError: true } - } - sendJson(res, 200, { ok: true, result }) - return - } - - if (url.pathname === "/proxy/ui/download-file") { - sendJson(res, 200, { ok: true, result: { isError: true } }) - return - } - - if (url.pathname === "/proxy/ui/request-display-mode") { - const displayParams = params as UiDisplayModeRequest - const requested = displayParams?.mode - const available = hostContext.availableDisplayModes ?? ["inline"] - if (requested && available.includes(requested)) { - currentDisplayMode = requested - } - hostContext.displayMode = currentDisplayMode - pushEvent("host-context", { displayMode: currentDisplayMode }) - const result: UiDisplayModeResult = { mode: currentDisplayMode } - sendJson(res, 200, { ok: true, result }) - return - } - - if (url.pathname === "/proxy/ui/heartbeat") { - sendJson(res, 200, { ok: true, result: {} }) - return - } - - if (url.pathname === "/proxy/ui/complete") { - const reason = - typeof (params as { reason?: string }).reason === "string" ? (params as { reason: string }).reason : "done" - markCompleted(reason) - sendJson(res, 200, { ok: true, result: {} }) - setTimeout(() => { - try { - server.close() - } catch {} - closeSse() - }, 20).unref() - return - } - - sendJson(res, 404, { ok: false, error: "Not found" }) - } catch (error) { - const wrapped = wrapError(error, { server: options.serverName, tool: options.toolName }) - const status = /approval required|denied/i.test(wrapped.message) ? 403 : 500 - if (status === 500) { - log.error("Request handler error", error instanceof Error ? error : undefined) - } - sendJson(res, status, { ok: false, error: wrapped.message }) - } - }) - - if (options.initialResultPromise) { - options.initialResultPromise.then( - (result) => pushEvent("tool-result", result), - (error) => { - const reason = error instanceof Error ? error.message : String(error) - pushEvent("tool-cancelled", { reason }) - }, - ) - } - - watchdog = setInterval(() => { - if (completed) return - if (Date.now() - lastHeartbeatAt <= ABANDONED_GRACE_MS) return - markCompleted("stale") - try { - server.close() - } catch {} - closeSse() - }, WATCHDOG_INTERVAL_MS) - watchdog.unref() - - return new Promise((resolve, reject) => { - const onError = (error: Error) => { - log.error("Failed to start server", error) - reject(new ServerError(error.message, { port: options.port, cause: error })) - } - - server.once("error", onError) - server.listen(options.port ?? 0, "127.0.0.1", () => { - server.off("error", onError) - const address = server.address() - if (!address || typeof address === "string") { - const err = new ServerError("invalid address") - log.error("Invalid server address", err) - reject(err) - return - } - - log.debug("Server started", { port: address.port }) - - const handle: UiServerHandle = { - url: `http://localhost:${address.port}/?session=${sessionToken}`, - port: address.port, - sessionToken, - serverName: options.serverName, - toolName: options.toolName, - close: (reason?: string) => { - markCompleted(reason ?? "closed") - try { - server.close() - } catch {} - closeSse() - }, - sendToolInput: (args: Record) => { - pushEvent("tool-input", { arguments: args }) - }, - sendToolResult: (result: CallToolResult) => { - pushEvent("tool-result", result) - }, - sendResultPatch: (result: CallToolResult) => { - pushEvent("result-patch", result) - }, - sendToolCancelled: (reason: string) => { - pushEvent("tool-cancelled", { reason }) - }, - sendHostContext: (context: UiHostContext) => { - Object.assign(hostContext, context) - pushEvent("host-context", context) - }, - getSessionMessages: () => ({ ...sessionMessages }), - getStreamSummary: () => (streamSummary ? { ...streamSummary, phases: [...streamSummary.phases] } : undefined), - } - - resolve(handle) - }) - }) -} - -async function parseBody( - req: IncomingMessage, - res: ServerResponse, -): Promise> | null> { - try { - const body = await readBody(req) - if (!body || typeof body !== "object") { - sendJson(res, 400, { ok: false, error: "Invalid request body" }) - return null - } - return body as UiProxyRequestBody> - } catch (error) { - sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : "Invalid body" }) - return null - } -} - -function readBody(req: IncomingMessage): Promise { - return new Promise((resolve, reject) => { - let size = 0 - const chunks: Buffer[] = [] - - req.on("data", (chunk: Buffer) => { - size += chunk.length - if (size > MAX_BODY_SIZE) { - req.destroy() - reject(new Error("Request body too large")) - return - } - chunks.push(chunk) - }) - - req.on("end", () => { - try { - resolve(JSON.parse(Buffer.concat(chunks).toString("utf-8"))) - } catch (error) { - reject(error) - } - }) - - req.on("error", reject) - }) -} - -function validateTokenQuery(url: URL, expected: string, res: ServerResponse): boolean { - const token = url.searchParams.get("session") - if (token !== expected) { - sendJson(res, 403, { ok: false, error: "Invalid session" }) - return false - } - return true -} - -function validateTokenBody( - body: UiProxyRequestBody>, - expected: string, - res: ServerResponse, -): boolean { - if (body.token !== expected) { - sendJson(res, 403, { ok: false, error: "Invalid session" }) - return false - } - return true -} - -function sendJson(res: ServerResponse, status: number, payload: UiProxyResult): void { - res.writeHead(status, { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }) - res.end(JSON.stringify(payload)) -} diff --git a/src/extensions/mcp-adapter/ui-session.ts b/src/extensions/mcp-adapter/ui-session.ts deleted file mode 100644 index bc4fa025c..000000000 --- a/src/extensions/mcp-adapter/ui-session.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { randomUUID } from "node:crypto" -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js" -import { isGlimpseAvailable, openGlimpseWindow } from "./glimpse-ui.js" -import { logger } from "./logger.js" -import type { McpExtensionState } from "./state.js" -import { - extractUiPromptText, - UI_STREAM_HOST_CONTEXT_KEY, - UI_STREAM_REQUEST_META_KEY, - UI_STREAM_STRUCTURED_CONTENT_KEY, - type UiHostContext, - type UiMessageParams, - type UiModelContextParams, - type UiStreamMode, -} from "./types.js" -import { startUiServer, type UiServerHandle } from "./ui-server.js" - -let activeGlimpseWindow: { close(): void } | null = null - -export interface UiSessionRequest { - serverName: string - toolName: string - toolArgs: Record - uiResourceUri: string - streamMode?: UiStreamMode -} - -export interface UiSessionRuntime { - serverName: string - toolName: string - reused: boolean - streamId?: string - streamToken?: string - streamMode?: UiStreamMode - requestMeta?: Record - url: string - isActive: () => boolean - sendToolResult: (result: CallToolResult) => void - sendResultPatch: (result: CallToolResult) => void - sendToolCancelled: (reason: string) => void - close: (reason?: string) => void -} - -const MAX_COMPLETED_SESSIONS = 10 - -function withStreamEnvelope(result: CallToolResult, streamId: string | undefined, sequence: number): CallToolResult { - if (!streamId) { - return result - } - - const structuredContent = - result.structuredContent && typeof result.structuredContent === "object" && !Array.isArray(result.structuredContent) - ? { ...result.structuredContent } - : {} - - const rawEnvelope = structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY] - const envelope = - rawEnvelope && typeof rawEnvelope === "object" && !Array.isArray(rawEnvelope) - ? { ...(rawEnvelope as Record) } - : { - frameType: "final", - phase: "settled", - status: result.isError ? "error" : "ok", - } - - structuredContent[UI_STREAM_STRUCTURED_CONTENT_KEY] = { - ...envelope, - streamId, - sequence, - } - - return { - ...result, - structuredContent, - } -} - -async function openInBrowser(state: McpExtensionState, url: string): Promise { - try { - await state.openBrowser(url) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - state.ui?.notify(`MCP UI browser open failed: ${message}`, "warning") - state.ui?.notify(`Open manually: ${url}`, "info") - } -} - -export async function maybeStartUiSession( - state: McpExtensionState, - request: UiSessionRequest, -): Promise { - const log = logger.child({ - component: "UiSession", - server: request.serverName, - tool: request.toolName, - }) - - try { - if ( - state.uiServer && - state.uiServer.serverName === request.serverName && - state.uiServer.toolName === request.toolName - ) { - const existingHandle = state.uiServer - const streamMode = request.streamMode - const streamId = streamMode ? randomUUID() : undefined - const streamToken = streamMode ? randomUUID() : undefined - let active = true - let nextStreamSequence = 0 - - const cleanupStreamListener = () => { - if (streamToken) { - state.manager.removeUiStreamListener(streamToken) - } - } - - existingHandle.sendToolInput(request.toolArgs) - - if (streamToken) { - state.manager.registerUiStreamListener(streamToken, (serverName, notification) => { - if (!active || state.uiServer !== existingHandle) return - if (serverName !== request.serverName) return - nextStreamSequence += 1 - existingHandle.sendResultPatch( - withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence), - ) - }) - } - - return { - serverName: request.serverName, - toolName: request.toolName, - reused: true, - streamId, - streamToken, - streamMode, - requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined, - url: existingHandle.url, - isActive: () => active && state.uiServer === existingHandle, - sendToolResult: (result: CallToolResult) => { - if (!active || state.uiServer !== existingHandle) return - nextStreamSequence += 1 - existingHandle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence)) - }, - sendResultPatch: (result: CallToolResult) => { - if (!active || state.uiServer !== existingHandle) return - nextStreamSequence += 1 - existingHandle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence)) - }, - sendToolCancelled: (reason: string) => { - if (!active || state.uiServer !== existingHandle) return - nextStreamSequence += 1 - existingHandle.sendToolResult( - withStreamEnvelope( - { - isError: true, - content: [{ type: "text", text: reason }], - }, - streamId, - nextStreamSequence, - ), - ) - }, - close: () => { - active = false - cleanupStreamListener() - }, - } - } - - const resource = await state.uiResourceHandler.readUiResource(request.serverName, request.uiResourceUri) - - if (state.uiServer) { - state.uiServer.close("replaced") - state.uiServer = null - } - if (activeGlimpseWindow) { - activeGlimpseWindow.close() - activeGlimpseWindow = null - } - - const streamMode = request.streamMode - const streamId = streamMode ? randomUUID() : undefined - const streamToken = streamMode ? randomUUID() : undefined - const hostContext: UiHostContext | undefined = - streamMode && streamId - ? { - [UI_STREAM_HOST_CONTEXT_KEY]: { - mode: streamMode, - streamId, - intermediateResultPatches: streamMode === "stream-first", - partialInput: false, - }, - } - : undefined - - let active = true - let nextStreamSequence = 0 - let handle: UiServerHandle | null = null - - const cleanupStreamListener = () => { - if (streamToken) { - state.manager.removeUiStreamListener(streamToken) - } - } - - handle = await startUiServer({ - serverName: request.serverName, - toolName: request.toolName, - toolArgs: streamMode === "stream-first" ? {} : request.toolArgs, - resource, - manager: state.manager, - consentManager: state.consentManager, - hostContext, - - onMessage: (params: UiMessageParams) => { - const prompt = extractUiPromptText(params) - if (prompt) { - if (state.sendMessage) { - state.sendMessage( - { - customType: "mcp-ui-prompt", - content: [{ type: "text", text: `User sent prompt from ${request.serverName} UI: "${prompt}"` }], - display: true, - details: { server: request.serverName, tool: request.toolName, prompt }, - }, - { triggerTurn: true }, - ) - log.debug("Triggered agent turn for UI prompt", { prompt: prompt.slice(0, 50) }) - } - } else if (params.type === "intent" || params.intent) { - const intent = params.intent ?? "" - const intentParams = params.params - if (intent && state.sendMessage) { - const paramsStr = intentParams ? ` ${JSON.stringify(intentParams)}` : "" - state.sendMessage( - { - customType: "mcp-ui-intent", - content: [ - { type: "text", text: `User triggered intent from ${request.serverName} UI: ${intent}${paramsStr}` }, - ], - display: true, - details: { server: request.serverName, tool: request.toolName, intent, params: intentParams }, - }, - { triggerTurn: true }, - ) - log.debug("Triggered agent turn for UI intent", { intent }) - } - } else if (params.type === "notify" || params.message) { - const text = params.message ?? "" - if (text && state.ui) { - state.ui.notify(`[${request.serverName}] ${text}`, "info") - } - } - }, - - onContextUpdate: (params: UiModelContextParams) => { - log.debug("Model context update from UI", { - hasContent: !!params.content, - hasStructured: !!params.structuredContent, - }) - }, - - onComplete: (reason: string) => { - active = false - cleanupStreamListener() - - if (state.uiServer === handle && handle) { - const messages = handle.getSessionMessages() - const stream = handle.getStreamSummary() - const hasContent = - messages.prompts.length > 0 || messages.intents.length > 0 || messages.notifications.length > 0 || !!stream - - if (hasContent) { - state.completedUiSessions.push({ - serverName: handle.serverName, - toolName: handle.toolName, - completedAt: new Date(), - reason, - messages, - stream, - }) - - while (state.completedUiSessions.length > MAX_COMPLETED_SESSIONS) { - state.completedUiSessions.shift() - } - - log.debug("Session completed", { - reason, - prompts: messages.prompts.length, - intents: messages.intents.length, - notifications: messages.notifications.length, - streamFrames: stream?.frames ?? 0, - }) - } - - state.uiServer = null - if (activeGlimpseWindow) { - activeGlimpseWindow.close() - activeGlimpseWindow = null - } - } - }, - }) - - if (streamToken) { - state.manager.registerUiStreamListener(streamToken, (serverName, notification) => { - if (!active || state.uiServer !== handle) return - if (serverName !== request.serverName) return - nextStreamSequence += 1 - handle.sendResultPatch(withStreamEnvelope(notification.result as CallToolResult, streamId, nextStreamSequence)) - }) - } - - state.uiServer = handle - - const glimpseDetected = isGlimpseAvailable() - const viewerPref = process.env.MCP_UI_VIEWER?.toLowerCase() - const useGlimpse = viewerPref === "glimpse" || (viewerPref !== "browser" && glimpseDetected) - - if (useGlimpse) { - try { - const glimpseHtml = `` - activeGlimpseWindow = await openGlimpseWindow(glimpseHtml, { - title: `MCP · ${request.serverName} · ${request.toolName}`, - width: 1000, - height: 800, - onClosed: () => { - if (active) handle.close("glimpse-closed") - }, - }) - } catch (error) { - log.debug("Glimpse unavailable, using browser", { - error: error instanceof Error ? error.message : String(error), - }) - await openInBrowser(state, handle.url) - } - } else { - await openInBrowser(state, handle.url) - } - - return { - serverName: request.serverName, - toolName: request.toolName, - reused: false, - streamId, - streamToken, - streamMode, - requestMeta: streamToken ? { [UI_STREAM_REQUEST_META_KEY]: streamToken } : undefined, - url: handle.url, - isActive: () => active && state.uiServer === handle, - sendToolResult: (result: CallToolResult) => { - if (!active || state.uiServer !== handle) return - nextStreamSequence += 1 - handle.sendToolResult(withStreamEnvelope(result, streamId, nextStreamSequence)) - }, - sendResultPatch: (result: CallToolResult) => { - if (!active || state.uiServer !== handle) return - nextStreamSequence += 1 - handle.sendResultPatch(withStreamEnvelope(result, streamId, nextStreamSequence)) - }, - sendToolCancelled: (reason: string) => { - if (!active || state.uiServer !== handle) return - handle.sendToolCancelled(reason) - }, - close: (reason?: string) => { - active = false - cleanupStreamListener() - handle.close(reason) - }, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log.error("Failed to start UI session", error instanceof Error ? error : undefined) - state.ui?.notify(`MCP UI unavailable for ${request.toolName} (${request.serverName}): ${message}`, "warning") - return null - } -} diff --git a/src/extensions/mcp-adapter/ui-stream-types.ts b/src/extensions/mcp-adapter/ui-stream-types.ts deleted file mode 100644 index 73c470b76..000000000 --- a/src/extensions/mcp-adapter/ui-stream-types.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { z } from "zod" - -export const UI_STREAM_HOST_CONTEXT_KEY = "pi-mcp-adapter/stream" -export const UI_STREAM_REQUEST_META_KEY = "pi-mcp-adapter/stream-token" -export const UI_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/ui-result-patch" -export const SERVER_STREAM_RESULT_PATCH_METHOD = "notifications/pi-mcp-adapter/result-patch" -export const UI_STREAM_STRUCTURED_CONTENT_KEY = "pi-mcp-adapter/stream" - -export const uiStreamModeSchema = z.enum(["eager", "stream-first"]) -export type UiStreamMode = z.infer - -export const visualizationStreamPhaseSchema = z.enum(["shell", "narrative", "structure", "detail", "settled"]) -export type VisualizationStreamPhase = z.infer - -export const visualizationStreamFrameTypeSchema = z.enum(["patch", "checkpoint", "final"]) -export type VisualizationStreamFrameType = z.infer - -export const visualizationStreamStatusSchema = z.enum(["ok", "error"]) -export type VisualizationStreamStatus = z.infer - -const looseRecordSchema = z.record(z.string(), z.unknown()) -const looseArraySchema = z.array(z.unknown()) - -export const uiStreamHostContextSchema = z.object({ - mode: uiStreamModeSchema, - streamId: z.string().min(1), - intermediateResultPatches: z.boolean(), - partialInput: z.boolean(), -}) -export type UiStreamHostContext = z.infer - -export const visualizationStreamEnvelopeSchema = z.object({ - streamId: z.string().min(1), - sequence: z.number().int().nonnegative(), - frameType: visualizationStreamFrameTypeSchema, - phase: visualizationStreamPhaseSchema, - status: visualizationStreamStatusSchema, - message: z.string().optional(), - spec: looseRecordSchema.optional(), - checkpoint: looseRecordSchema.optional(), -}) -export type VisualizationStreamEnvelope = z.infer - -export const uiStreamCallToolResultSchema = z - .object({ - content: looseArraySchema.optional(), - structuredContent: looseRecordSchema.optional(), - isError: z.boolean().optional(), - _meta: looseRecordSchema.optional(), - }) - .passthrough() -export type UiStreamCallToolResult = z.infer - -export const uiStreamResultPatchNotificationSchema = z.object({ - method: z.literal(UI_STREAM_RESULT_PATCH_METHOD), - params: uiStreamCallToolResultSchema, -}) -export type UiStreamResultPatchNotification = z.infer - -export const serverStreamResultPatchNotificationSchema = z.object({ - method: z.literal(SERVER_STREAM_RESULT_PATCH_METHOD), - params: z.object({ - streamToken: z.string().min(1), - result: uiStreamCallToolResultSchema, - }), -}) -export type ServerStreamResultPatchNotification = z.infer - -export interface UiStreamSummary { - streamId: string - mode: UiStreamMode - frames: number - phases: VisualizationStreamPhase[] - finalStatus?: VisualizationStreamStatus - lastMessage?: string -} - -export function getUiStreamHostContext( - hostContext: Record | undefined, -): UiStreamHostContext | undefined { - const candidate = hostContext?.[UI_STREAM_HOST_CONTEXT_KEY] - const parsed = uiStreamHostContextSchema.safeParse(candidate) - return parsed.success ? parsed.data : undefined -} - -export function getVisualizationStreamEnvelope(structuredContent: unknown): VisualizationStreamEnvelope | undefined { - if (!structuredContent || typeof structuredContent !== "object" || Array.isArray(structuredContent)) { - return undefined - } - const candidate = (structuredContent as Record)[UI_STREAM_STRUCTURED_CONTENT_KEY] - const parsed = visualizationStreamEnvelopeSchema.safeParse(candidate) - return parsed.success ? parsed.data : undefined -} diff --git a/src/extensions/mcp-adapter/utils.ts b/src/extensions/mcp-adapter/utils.ts deleted file mode 100644 index 9c97e4a44..000000000 --- a/src/extensions/mcp-adapter/utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { platform } from "node:os" -import type { ExecResult, ExtensionAPI } from "@earendil-works/pi-coding-agent" - -export { getAgentDir } from "@earendil-works/pi-coding-agent" - -export async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise { - const os = platform() - let result: ExecResult | undefined - - if (os === "darwin") { - result = browser ? await pi.exec("open", ["-a", browser, url]) : await pi.exec("open", [url]) - } else if (os === "win32") { - result = browser - ? await pi.exec("cmd", ["/c", "start", "", browser, url]) - : await pi.exec("cmd", ["/c", "start", "", url]) - } else { - result = browser ? await pi.exec(browser, [url]) : await pi.exec("xdg-open", [url]) - } - - if (result.code !== 0) { - throw new Error(result.stderr || `Failed to open browser (exit code ${result.code})`) - } -} - -export async function parallelLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const results: R[] = [] - let index = 0 - - async function worker() { - while (index < items.length) { - const i = index++ - results[i] = await fn(items[i]) - } - } - - const workers = Array(Math.min(limit, items.length)) - .fill(null) - .map(() => worker()) - await Promise.all(workers) - return results -} - -export function getConfigPathFromArgv(): string | undefined { - const idx = process.argv.indexOf("--mcp-config") - if (idx >= 0 && idx + 1 < process.argv.length) { - return process.argv[idx + 1] - } - return undefined -} - -export function truncateAtWord(text: string, target: number): string { - if (!text || text.length <= target) return text - - const truncated = text.slice(0, target) - const lastSpace = truncated.lastIndexOf(" ") - - if (lastSpace > target * 0.6) { - return `${truncated.slice(0, lastSpace)}...` - } - - return `${truncated}...` -} - -/** - * Extract the adapter-owned UI stream mode from tool metadata. - */ -export function extractToolUiStreamMode( - toolMeta: Record | undefined, -): "eager" | "stream-first" | undefined { - const uiMeta = toolMeta?.ui - if (!uiMeta || typeof uiMeta !== "object") return undefined - const streamMode = (uiMeta as Record)["pi-mcp-adapter.streamMode"] - if (streamMode === "eager" || streamMode === "stream-first") { - return streamMode - } - return undefined -} diff --git a/src/extensions/mcp/acp-config.test.ts b/src/extensions/mcp/acp-config.test.ts new file mode 100644 index 000000000..0b2dd0cbd --- /dev/null +++ b/src/extensions/mcp/acp-config.test.ts @@ -0,0 +1,45 @@ +import type { McpServer } from "@agentclientprotocol/sdk" +import { describe, expect, it } from "vitest" +import { convertAcpMcpServer, convertAcpMcpServers } from "./acp-config.js" + +describe("ACP MCP configuration", () => { + it("converts stdio environment entries", () => { + const server: McpServer = { + name: "filesystem", + command: "/path/to/server", + args: ["--stdio"], + env: [{ name: "TOKEN", value: "secret" }], + } + expect(convertAcpMcpServer(server)).toEqual({ + command: "/path/to/server", + args: ["--stdio"], + env: { TOKEN: "secret" }, + }) + }) + + it("converts HTTP headers", () => { + const server: McpServer = { + name: "remote", + type: "http", + url: "https://example.test/mcp", + headers: [{ name: "Authorization", value: "Bearer secret" }], + } + expect(convertAcpMcpServer(server)).toEqual({ + url: "https://example.test/mcp", + headers: { Authorization: "Bearer secret" }, + }) + }) + + it("rejects the unadvertised SSE transport", () => { + const server: McpServer = { name: "events", type: "sse", url: "https://example.test/sse", headers: [] } + expect(() => convertAcpMcpServer(server)).toThrow("SSE transport is not supported") + }) + + it("uses the last duplicate name", () => { + const servers: McpServer[] = [ + { name: "duplicate", command: "first", args: [], env: [] }, + { name: "duplicate", command: "second", args: [], env: [] }, + ] + expect(convertAcpMcpServers(servers)).toEqual({ duplicate: { command: "second", args: [] } }) + }) +}) diff --git a/src/extensions/mcp/acp-config.ts b/src/extensions/mcp/acp-config.ts new file mode 100644 index 000000000..4694cbc89 --- /dev/null +++ b/src/extensions/mcp/acp-config.ts @@ -0,0 +1,39 @@ +import type { McpServer } from "@agentclientprotocol/sdk" +import type { ServerEntry } from "pi-mcp-adapter/types" + +function entriesToRecord( + entries: ReadonlyArray<{ name: string; value: string }> | undefined, +): Record | undefined { + if (!entries || entries.length === 0) return undefined + return Object.fromEntries(entries.map(({ name, value }) => [name, value])) +} + +export function convertAcpMcpServer(server: McpServer): ServerEntry { + const name = server.name + if ("command" in server) { + const env = entriesToRecord(server.env) + return { + command: server.command, + args: server.args, + ...(env ? { env } : {}), + } + } + + if ("type" in server && server.type === "sse") { + throw new Error(`SSE transport is not supported for server "${name}"`) + } + + if ("url" in server) { + const headers = entriesToRecord(server.headers) + return { + url: server.url, + ...(headers ? { headers } : {}), + } + } + + throw new Error(`Unrecognized ACP McpServer shape for server "${name}"`) +} + +export function convertAcpMcpServers(servers: ReadonlyArray): Record { + return Object.fromEntries(servers.map((server) => [server.name, convertAcpMcpServer(server)])) +} diff --git a/src/extensions/mcp/annotation-catalog.test.ts b/src/extensions/mcp/annotation-catalog.test.ts new file mode 100644 index 000000000..3a8295d6b --- /dev/null +++ b/src/extensions/mcp/annotation-catalog.test.ts @@ -0,0 +1,115 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { afterEach, describe, expect, it, vi } from "vitest" +import { McpAnnotationCatalog } from "./annotation-catalog.js" + +const temporaryDirectories: string[] = [] + +function createCatalog( + onChanged?: () => void, + sourceHash = "test-source", +): { catalog: McpAnnotationCatalog; cachePath: string } { + const directory = mkdtempSync(join(tmpdir(), "kimchi-mcp-annotations-")) + temporaryDirectories.push(directory) + const cachePath = join(directory, "annotations.json") + return { catalog: new McpAnnotationCatalog({ cachePath, onChanged, sourceHash }), cachePath } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }) +}) + +describe("McpAnnotationCatalog", () => { + it("uses explicit read-only annotations and rejects explicit false", () => { + const { catalog } = createCatalog() + catalog.record([ + { name: "mutate", description: "safe", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { + name: "get_reset", + description: "unsafe", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: false }, + }, + ]) + + expect(catalog.isReadOnly("mutate", "safe")).toBe(true) + expect(catalog.isReadOnly("get_reset", "unsafe")).toBe(false) + }) + + it("uses the name fallback only after observing that annotations are absent", () => { + const { catalog } = createCatalog() + + expect(catalog.isReadOnly("get_issue", "Read issue")).toBe(false) + catalog.record([ + { name: "get_issue", description: "Read issue", inputSchema: { type: "object" } }, + { + name: "list_issues", + description: "List issues", + inputSchema: { type: "object" }, + annotations: { destructiveHint: false }, + }, + ]) + expect(catalog.isReadOnly("get_issue", "Read issue")).toBe(true) + expect(catalog.isReadOnly("list_issues", "List issues")).toBe(true) + }) + + it("fails closed when indistinguishable tool observations conflict", () => { + const { catalog } = createCatalog() + catalog.record([ + { name: "lookup", description: "Lookup", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + ]) + catalog.record([ + { name: "lookup", description: "Lookup", inputSchema: { type: "object" }, annotations: { readOnlyHint: false } }, + ]) + + expect(catalog.isReadOnly("lookup", "Lookup")).toBe(false) + }) + + it("fails closed for gateway calls when same-named tools disagree across servers", () => { + const { catalog } = createCatalog() + catalog.record([ + { + name: "lookup", + description: "Safe lookup", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + { + name: "lookup", + description: "Mutating lookup", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: false }, + }, + ]) + + expect(catalog.isReadOnlyByName("lookup")).toBe(false) + expect(catalog.isReadOnlyByName("unknown")).toBe(false) + }) + + it("persists observations with private file permissions", () => { + const changed = vi.fn() + const { catalog, cachePath } = createCatalog(changed) + catalog.record([{ name: "list_items", description: "List", inputSchema: { type: "object" } }]) + + expect(changed).toHaveBeenCalledOnce() + expect(JSON.parse(readFileSync(cachePath, "utf8"))).toMatchObject({ version: 2, sourceHash: "test-source" }) + const restored = new McpAnnotationCatalog({ cachePath, sourceHash: "test-source" }) + expect(restored.isReadOnly("list_items", "List")).toBe(true) + }) + + it("does not trust annotations cached for a different server configuration", () => { + const { catalog, cachePath } = createCatalog(undefined, "old-config") + catalog.record([ + { + name: "get_reset", + description: "Reset data", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + ]) + + const changedConfig = new McpAnnotationCatalog({ cachePath, sourceHash: "new-config" }) + expect(changedConfig.isReadOnly("get_reset", "Reset data")).toBe(false) + }) +}) diff --git a/src/extensions/mcp/annotation-catalog.ts b/src/extensions/mcp/annotation-catalog.ts new file mode 100644 index 000000000..bf43feba5 --- /dev/null +++ b/src/extensions/mcp/annotation-catalog.ts @@ -0,0 +1,165 @@ +import { AsyncLocalStorage } from "node:async_hooks" +import { createHash, randomUUID } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { Client, type ListToolsResult } from "@modelcontextprotocol/client" +import { computeServerHash, getMetadataCachePath } from "pi-mcp-adapter/metadata-cache" +import type { McpConfig } from "pi-mcp-adapter/types" +import { isReadOnlyMcpToolName } from "./read-only-tools.js" + +type ListedTool = ListToolsResult["tools"][number] +type AnnotationState = "missing" | "read-only" | "not-read-only" | "conflict" + +interface AnnotationCacheFile { + version: 2 + sourceHash: string + tools: Record +} + +const CAPTURE_CONTEXT = new AsyncLocalStorage() +const PATCH_MARKER = Symbol.for("kimchi.mcp.annotation-capture") +const CACHE_FILE = "mcp-annotations.json" + +function toolKey(name: string, description = ""): string { + return `${name}\0${description}` +} + +function annotationState(tool: ListedTool): AnnotationState { + if (tool.annotations?.readOnlyHint === true) return "read-only" + if (tool.annotations?.readOnlyHint === false) return "not-read-only" + return "missing" +} + +function mergeState(current: AnnotationState | undefined, next: AnnotationState): AnnotationState { + if (current === undefined || current === next) return next + return "conflict" +} + +function defaultCachePath(): string { + return join(dirname(getMetadataCachePath()), CACHE_FILE) +} + +export function mcpAnnotationSourceHash(config: Pick): string { + const serverHashes = Object.entries(config.mcpServers) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, definition]) => [name, computeServerHash(definition)]) + return createHash("sha256").update(JSON.stringify(serverHashes)).digest("hex") +} + +export class McpAnnotationCatalog { + private readonly tools = new Map() + private readonly cachePath: string + private readonly sourceHash: string + + constructor( + options: { + cachePath?: string + onChanged?: () => void + sourceHash?: string + } = {}, + ) { + this.cachePath = options.cachePath ?? defaultCachePath() + this.onChanged = options.onChanged + this.sourceHash = options.sourceHash ?? "unscoped" + this.load() + } + + private readonly onChanged: (() => void) | undefined + + record(tools: ListedTool[]): void { + let changed = false + for (const tool of tools) { + if (!tool?.name) continue + const key = toolKey(tool.name, tool.description) + const next = mergeState(this.tools.get(key), annotationState(tool)) + if (this.tools.get(key) === next) continue + this.tools.set(key, next) + changed = true + } + if (!changed) return + this.save() + this.onChanged?.() + } + + isReadOnly(originalName: string, description = ""): boolean { + const state = this.tools.get(toolKey(originalName, description)) + return this.isReadOnlyState(originalName, state) + } + + isReadOnlyByName(originalName: string): boolean { + const prefix = `${originalName}\0` + const states = [...this.tools].filter(([key]) => key.startsWith(prefix)).map(([, state]) => state) + return states.length > 0 && states.every((state) => this.isReadOnlyState(originalName, state)) + } + + private isReadOnlyState(originalName: string, state: AnnotationState | undefined): boolean { + if (state === "read-only") return true + if (state === "missing") return isReadOnlyMcpToolName(originalName) + return false + } + + private load(): void { + if (!existsSync(this.cachePath)) return + try { + const parsed = JSON.parse(readFileSync(this.cachePath, "utf8")) as Partial + if ( + parsed.version !== 2 || + parsed.sourceHash !== this.sourceHash || + !parsed.tools || + typeof parsed.tools !== "object" + ) + return + for (const [key, state] of Object.entries(parsed.tools)) { + if (["missing", "read-only", "not-read-only", "conflict"].includes(state)) { + this.tools.set(key, state) + } + } + } catch { + // A damaged advisory cache must never block MCP startup. Unknown tools + // remain excluded from read-only profiles until observed again. + } + } + + private save(): void { + try { + mkdirSync(dirname(this.cachePath), { recursive: true }) + const temporaryPath = `${this.cachePath}.${process.pid}.${randomUUID()}.tmp` + const payload: AnnotationCacheFile = { + version: 2, + sourceHash: this.sourceHash, + tools: Object.fromEntries(this.tools), + } + writeFileSync(temporaryPath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 }) + renameSync(temporaryPath, this.cachePath) + } catch (error) { + console.warn( + `[mcp] Failed to persist annotation cache: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } +} + +export function runWithMcpAnnotationCatalog(catalog: McpAnnotationCatalog, callback: () => T): T { + return CAPTURE_CONTEXT.run(catalog, callback) +} + +/** + * Observe the MCP SDK result before pi-mcp-adapter intentionally narrows its + * public metadata. This changes neither requests nor responses; it only keeps + * the protocol's readOnlyHint for Kimchi's planning policy. + */ +export function installMcpAnnotationCapture(): void { + const prototype = Client.prototype as typeof Client.prototype & { [PATCH_MARKER]?: boolean } + if (prototype[PATCH_MARKER]) return + + const originalListTools = prototype.listTools + Object.defineProperty(prototype, "listTools", { + configurable: true, + value: async function (...args: Parameters): Promise { + const result = await originalListTools.apply(this, args) + CAPTURE_CONTEXT.getStore()?.record(result.tools) + return result + }, + }) + prototype[PATCH_MARKER] = true +} diff --git a/src/extensions/mcp/config.test.ts b/src/extensions/mcp/config.test.ts new file mode 100644 index 000000000..628a02f00 --- /dev/null +++ b/src/extensions/mcp/config.test.ts @@ -0,0 +1,76 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import type { McpConfig } from "pi-mcp-adapter/types" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const upstream = vi.hoisted(() => ({ + load: vi.fn<(overridePath?: string, cwd?: string) => McpConfig>(), +})) + +vi.mock("pi-mcp-adapter/config", () => ({ + loadMcpConfig: upstream.load, +})) + +import { LEGACY_PROJECT_MCP_CONFIG, loadKimchiMcpConfig } from "./config.js" + +describe("loadKimchiMcpConfig", () => { + let cwd: string + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), "kimchi-mcp-config-")) + upstream.load.mockReset() + upstream.load.mockReturnValue({ mcpServers: {} }) + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it("uses upstream's standard discovery when no legacy config exists", () => { + const config: McpConfig = { mcpServers: { docs: { url: "https://example.test/mcp" } } } + upstream.load.mockReturnValue(config) + + const result = loadKimchiMcpConfig({ cwd }) + + expect(result).toEqual({ config, warnings: [] }) + expect(upstream.load).toHaveBeenCalledWith(undefined, cwd) + }) + + it("uses the legacy project config as a file-backed upstream override", () => { + const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) + mkdirSync(dirname(legacyPath), { recursive: true }) + writeFileSync(legacyPath, JSON.stringify({ mcpServers: { local: { command: "local-server" } } })) + const config: McpConfig = { mcpServers: { local: { command: "local-server" } } } + upstream.load.mockReturnValue(config) + + const result = loadKimchiMcpConfig({ cwd }) + + expect(result).toEqual({ config, configPath: legacyPath, warnings: [] }) + expect(upstream.load).toHaveBeenCalledWith(legacyPath, cwd) + }) + + it("prefers an explicit config path over the legacy project file", () => { + const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) + mkdirSync(dirname(legacyPath), { recursive: true }) + writeFileSync(legacyPath, JSON.stringify({ mcpServers: { legacy: { command: "legacy" } } })) + + const result = loadKimchiMcpConfig({ cwd, overridePath: "/tmp/explicit-mcp.json" }) + + expect(result.configPath).toBe("/tmp/explicit-mcp.json") + expect(upstream.load).toHaveBeenCalledWith("/tmp/explicit-mcp.json", cwd) + }) + + it("does not inject the legacy path in exclusive config mode", () => { + const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) + mkdirSync(dirname(legacyPath), { recursive: true }) + writeFileSync(legacyPath, JSON.stringify({ mcpServers: { legacy: { command: "legacy" } } })) + vi.stubEnv("PI_MCP_CONFIG_MODE", "exclusive") + + const result = loadKimchiMcpConfig({ cwd }) + + expect(result.configPath).toBeUndefined() + expect(upstream.load).toHaveBeenCalledWith(undefined, cwd) + }) +}) diff --git a/src/extensions/mcp/config.ts b/src/extensions/mcp/config.ts new file mode 100644 index 000000000..51a93c058 --- /dev/null +++ b/src/extensions/mcp/config.ts @@ -0,0 +1,30 @@ +import { existsSync } from "node:fs" +import { resolve } from "node:path" +import { loadMcpConfig as loadUpstreamMcpConfig } from "pi-mcp-adapter/config" +import type { McpConfig } from "pi-mcp-adapter/types" + +export const LEGACY_PROJECT_MCP_CONFIG = ".kimchi/mcp.json" + +export interface KimchiMcpConfigResult { + config: McpConfig + configPath?: string + warnings: string[] +} + +/** + * Keep the adapter in its file-backed mode so its panels and persistence stay + * available. Kimchi's legacy project file becomes the highest-precedence + * configPath; upstream still merges every standard MCP source beneath it. + */ +export function loadKimchiMcpConfig(options: { cwd?: string; overridePath?: string } = {}): KimchiMcpConfigResult { + const cwd = options.cwd ?? process.cwd() + const exclusiveMode = process.env.PI_MCP_CONFIG_MODE?.trim().toLowerCase() === "exclusive" + const legacyPath = resolve(cwd, LEGACY_PROJECT_MCP_CONFIG) + const configPath = options.overridePath ?? (!exclusiveMode && existsSync(legacyPath) ? legacyPath : undefined) + + return { + config: loadUpstreamMcpConfig(configPath, cwd), + ...(configPath ? { configPath } : {}), + warnings: [], + } +} diff --git a/src/extensions/mcp/index.test.ts b/src/extensions/mcp/index.test.ts new file mode 100644 index 000000000..b374c8465 --- /dev/null +++ b/src/extensions/mcp/index.test.ts @@ -0,0 +1,243 @@ +import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent" +import type { McpAdapterOptions, McpConfig } from "pi-mcp-adapter/types" +import { Type } from "typebox" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { createContext } from "../__mocks__/context.js" +import { createExtensionApi } from "../__mocks__/extension-api.js" + +const upstream = vi.hoisted(() => ({ + api: undefined as ExtensionAPI | undefined, + options: undefined as McpAdapterOptions | undefined, +})) +const configState = vi.hoisted(() => ({ + config: { mcpServers: {} } as McpConfig, + configPath: undefined as string | undefined, + warnings: [] as string[], +})) +const cliState = vi.hoisted(() => ({ mcpConfig: undefined as string | undefined })) +const planning = vi.hoisted(() => ({ + provider: undefined as (() => string[]) | undefined, + applyCooperativeTweak: vi.fn(() => true), + currentProfile: undefined as "planning-adhoc" | "planning-ferment" | "idle" | undefined, + reapplyCurrentProfile: vi.fn(() => false), +})) +const oauthMigration = vi.hoisted(() => ({ warnings: [] as string[] })) +const annotations = vi.hoisted(() => ({ + readOnly: new Set(), +})) + +vi.mock("pi-mcp-adapter", () => ({ + MCP_STATUS_EVENT: "pi-mcp-adapter/status/v1", + createMcpAdapter: vi.fn((options: McpAdapterOptions) => (api: ExtensionAPI) => { + upstream.options = options + upstream.api = api + }), +})) + +vi.mock("../../cli-args.js", () => ({ + getParsedCliArgs: () => ({ options: { "mcp-config": cliState.mcpConfig }, positionals: [] }), +})) + +vi.mock("./config.js", () => ({ + loadKimchiMcpConfig: () => ({ + config: configState.config, + configPath: configState.configPath, + warnings: configState.warnings, + }), +})) + +vi.mock("../../shared/planning/read-only-tool-registry.js", () => ({ + registerReadOnlyToolProvider: (_pi: ExtensionAPI, provider: () => string[]) => { + planning.provider = provider + }, +})) + +vi.mock("../../shared/planning/tool-profile-manager.js", () => ({ + applyCooperativeTweak: planning.applyCooperativeTweak, + getCurrentProfile: () => planning.currentProfile, + reapplyCurrentProfile: planning.reapplyCurrentProfile, +})) + +vi.mock("./oauth-migration.js", () => ({ + migrateLegacyOAuthCredentials: vi.fn(() => ({ + migratedServerNames: [], + warnings: oauthMigration.warnings, + })), +})) + +vi.mock("./annotation-catalog.js", () => ({ + installMcpAnnotationCapture: vi.fn(), + mcpAnnotationSourceHash: vi.fn(() => "test-source"), + runWithMcpAnnotationCatalog: (_catalog: unknown, callback: () => unknown) => callback(), + McpAnnotationCatalog: class { + isReadOnly(originalName: string): boolean { + return annotations.readOnly.has(originalName) + } + isReadOnlyByName(originalName: string): boolean { + return annotations.readOnly.has(originalName) + } + }, +})) + +import mcpAdapterExtension, { createKimchiMcpAdapterExtension } from "./index.js" + +function tool(name: string, label: string): ToolDefinition { + return { + name, + label, + description: `${name} description`, + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + } +} + +describe("upstream MCP adapter facade", () => { + beforeEach(() => { + upstream.api = undefined + upstream.options = undefined + configState.config = { mcpServers: {} } + configState.configPath = undefined + configState.warnings = [] + oauthMigration.warnings = [] + annotations.readOnly.clear() + cliState.mcpConfig = undefined + planning.provider = undefined + planning.currentProfile = undefined + planning.applyCooperativeTweak.mockClear() + planning.reapplyCurrentProfile.mockClear() + }) + + it("keeps the upstream adapter in file-backed mode", () => { + configState.config = { + mcpServers: { docs: { url: "https://example.test/mcp" } }, + settings: { scriptMode: false }, + } + cliState.mcpConfig = "/tmp/mcp.json" + configState.configPath = "/tmp/mcp.json" + const { api } = createExtensionApi() + + mcpAdapterExtension(api) + + expect(upstream.options).toEqual({ configPath: "/tmp/mcp.json" }) + expect(upstream.api).toBeDefined() + }) + + it("suppresses all model-facing MCP tools for an empty configuration", () => { + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + + upstream.api?.registerTool(tool("mcp", "MCP")) + upstream.api?.registerTool(tool("mcpScript", "MCP Script")) + upstream.api?.setActiveTools(["read", "mcp", "mcpScript"]) + + expect(harness.getRegisteredTools()).toEqual([]) + expect(planning.applyCooperativeTweak).toHaveBeenCalledWith(harness.api, ["read"]) + }) + + it("registers direct tools and exposes only read-only names to planning", () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + annotations.readOnly.add("get_issue") + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + + upstream.api?.registerTool(tool("docs_get_issue", "MCP: get_issue")) + upstream.api?.registerTool(tool("docs_delete_issue", "MCP: delete_issue")) + + expect(harness.getRegisteredTools().map(({ name }) => name)).toEqual(["docs_get_issue", "docs_delete_issue"]) + expect(planning.provider?.()).toEqual(["docs_get_issue"]) + expect(planning.reapplyCurrentProfile).toHaveBeenCalledTimes(2) + }) + + it("keeps the current profile authoritative over upstream active-tool synchronization", () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + planning.reapplyCurrentProfile.mockReturnValue(true) + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + + upstream.api?.setActiveTools(["read", "docs_delete_issue"]) + + expect(planning.reapplyCurrentProfile).toHaveBeenCalledWith(harness.api) + expect(planning.applyCooperativeTweak).not.toHaveBeenCalled() + }) + + it("blocks direct and gateway writes in planning profiles", async () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + planning.currentProfile = "planning-adhoc" + annotations.readOnly.add("get_issue") + const directExecute = vi.fn(tool("docs_delete_issue", "MCP: delete_issue").execute) + const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + upstream.api?.registerTool({ ...tool("docs_delete_issue", "MCP: delete_issue"), execute: directExecute }) + upstream.api?.registerTool({ ...tool("mcp", "MCP"), execute: gatewayExecute }) + + const direct = harness.getRegisteredTools().find(({ name }) => name === "docs_delete_issue") + const gateway = harness.getRegisteredTools().find(({ name }) => name === "mcp") + const directResult = await direct?.execute("direct", {}, undefined, undefined, createContext()) + const gatewayResult = await gateway?.execute( + "gateway", + { tool: "delete_issue", args: {} }, + undefined, + undefined, + createContext(), + ) + + expect(directExecute).not.toHaveBeenCalled() + expect(gatewayExecute).not.toHaveBeenCalled() + expect(directResult).toMatchObject({ isError: true, details: { error: "plan_mode_write_blocked" } }) + expect(gatewayResult).toMatchObject({ isError: true, details: { error: "plan_mode_write_blocked" } }) + }) + + it("creates an isolated caller-wins configuration for ACP sessions", () => { + configState.config = { + mcpServers: { + shared: { command: "from-file" }, + fileOnly: { command: "file-only" }, + }, + settings: { scriptMode: false }, + } + const harness = createExtensionApi() + + createKimchiMcpAdapterExtension({ + cwd: "/workspace", + callerServers: { + shared: { command: "from-acp" }, + callerOnly: { command: "caller-only" }, + }, + })(harness.api) + + expect(upstream.options).toEqual({ + config: { + mcpServers: { + shared: { command: "from-acp" }, + fileOnly: { command: "file-only" }, + callerOnly: { command: "caller-only" }, + }, + settings: { scriptMode: false }, + }, + }) + }) + + it("reapplies the active profile after an upstream status update", () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + + harness.emitEvent("pi-mcp-adapter/status/v1", { servers: [] }) + + expect(planning.reapplyCurrentProfile).toHaveBeenCalledWith(harness.api) + }) + + it("surfaces compatibility warnings when the session starts", async () => { + configState.warnings = ["legacy config is malformed"] + oauthMigration.warnings = ["legacy OAuth entry conflicts with the upstream layout"] + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + const ctx = createContext() + + await harness.getHandler("session_start")({ type: "session_start", reason: "startup" }, ctx) + + expect(ctx.ui.notify).toHaveBeenCalledWith("legacy config is malformed", "warning") + expect(ctx.ui.notify).toHaveBeenCalledWith("legacy OAuth entry conflicts with the upstream layout", "warning") + }) +}) diff --git a/src/extensions/mcp/index.ts b/src/extensions/mcp/index.ts new file mode 100644 index 000000000..ac8b2cc7d --- /dev/null +++ b/src/extensions/mcp/index.ts @@ -0,0 +1,188 @@ +import type { ExtensionAPI, ExtensionContext, ExtensionFactory, ToolDefinition } from "@earendil-works/pi-coding-agent" +import { createMcpAdapter, MCP_STATUS_EVENT } from "pi-mcp-adapter" +import type { ServerEntry } from "pi-mcp-adapter/types" +import { getParsedCliArgs } from "../../cli-args.js" +import { registerReadOnlyToolProvider } from "../../shared/planning/read-only-tool-registry.js" +import { + applyCooperativeTweak, + getCurrentProfile, + reapplyCurrentProfile, +} from "../../shared/planning/tool-profile-manager.js" +import { getPermissionMode } from "../permissions/mode-controller.js" +import { + installMcpAnnotationCapture, + McpAnnotationCatalog, + mcpAnnotationSourceHash, + runWithMcpAnnotationCatalog, +} from "./annotation-catalog.js" +import { loadKimchiMcpConfig } from "./config.js" +import { installKeyringRequireBridge } from "./keyring-require-bridge.js" +import { migrateLegacyOAuthCredentials } from "./oauth-migration.js" + +const MCP_PROXY_TOOL = "mcp" +const MCP_SCRIPT_TOOL = "mcpScript" +const MCP_DIRECT_TOOL_LABEL_PREFIX = "MCP: " + +interface McpToolSurfacePolicy { + annotationCatalog: McpAnnotationCatalog + directTools: Map + suppressedToolNames: Set +} + +function createMcpToolSurfacePolicy( + hasConfiguredServers: boolean, + annotationCatalog: McpAnnotationCatalog, +): McpToolSurfacePolicy { + return { + annotationCatalog, + directTools: new Map(), + suppressedToolNames: new Set([MCP_SCRIPT_TOOL, ...(!hasConfiguredServers ? [MCP_PROXY_TOOL] : [])]), + } +} + +function getDirectToolOriginalName(tool: ToolDefinition): string | undefined { + if (tool.name === MCP_PROXY_TOOL || tool.name === MCP_SCRIPT_TOOL) return undefined + if (!tool.label.startsWith(MCP_DIRECT_TOOL_LABEL_PREFIX)) return undefined + const originalName = tool.label.slice(MCP_DIRECT_TOOL_LABEL_PREFIX.length).trim() + return originalName || undefined +} + +function planningBlockReason(originalName: string): string { + return `MCP tool "${originalName}" is not read-only according to its protocol annotations and is unavailable in plan mode.` +} + +function blockedPlanningResult(originalName: string) { + const reason = planningBlockReason(originalName) + return { + content: [{ type: "text" as const, text: reason }], + details: { error: "plan_mode_write_blocked", tool: originalName, message: reason }, + isError: true, + } +} + +function blockedMcpToolInPlanning( + pi: ExtensionAPI, + policy: McpToolSurfacePolicy, + registeredName: string, + originalName: string | undefined, + params: unknown, + ctx: ExtensionContext, +): string | undefined { + const profile = getCurrentProfile(pi) + const permissionMode = getPermissionMode(ctx.sessionManager.getSessionId())?.mode + const explicitPlan = getParsedCliArgs().options.plan === true + if (!explicitPlan && permissionMode !== "plan" && profile !== "planning-adhoc" && profile !== "planning-ferment") + return undefined + if (originalName) + return policy.annotationCatalog.isReadOnly(originalName, policy.directTools.get(registeredName)?.description) + ? undefined + : originalName + if (registeredName !== MCP_PROXY_TOOL || !params || typeof params !== "object" || Array.isArray(params)) + return undefined + const gatewayTool = (params as { tool?: unknown }).tool + if (typeof gatewayTool !== "string") return undefined + return policy.annotationCatalog.isReadOnlyByName(gatewayTool) ? undefined : gatewayTool +} + +function createUpstreamApi(pi: ExtensionAPI, policy: McpToolSurfacePolicy): ExtensionAPI { + return new Proxy(pi, { + get(target, property) { + if (property === "on") { + return (event: string, handler: (event: unknown, ctx: unknown) => unknown): void => { + const on = target.on as (event: string, handler: (event: unknown, ctx: unknown) => unknown) => void + on(event, (eventValue, ctx) => + runWithMcpAnnotationCatalog(policy.annotationCatalog, () => handler(eventValue, ctx)), + ) + } + } + if (property === "registerTool") { + return (tool: ToolDefinition): void => { + if (policy.suppressedToolNames.has(tool.name)) return + const originalName = getDirectToolOriginalName(tool) + if (originalName) { + policy.directTools.set(tool.name, { originalName, description: tool.description }) + } + const execute = tool.execute.bind(tool) + target.registerTool({ + ...tool, + execute: (...args: Parameters) => { + const blockedTool = blockedMcpToolInPlanning(target, policy, tool.name, originalName, args[1], args[4]) + if (blockedTool) return Promise.resolve(blockedPlanningResult(blockedTool)) + return runWithMcpAnnotationCatalog(policy.annotationCatalog, () => execute(...args)) + }, + }) + reapplyCurrentProfile(target) + } + } + if (property === "setActiveTools") { + return (toolNames: string[]): void => { + const allowedNames = toolNames.filter((name) => !policy.suppressedToolNames.has(name)) + if (!reapplyCurrentProfile(target)) applyCooperativeTweak(target, allowedNames) + } + } + + const value = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + +export interface KimchiMcpAdapterExtensionOptions { + cwd?: string + callerServers?: Record +} + +export function createKimchiMcpAdapterExtension(options: KimchiMcpAdapterExtensionOptions = {}): ExtensionFactory { + return (pi) => installMcpAdapterExtension(pi, options) +} + +function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterExtensionOptions): void { + installKeyringRequireBridge() + installMcpAnnotationCapture() + const overridePath = getParsedCliArgs().options["mcp-config"] + const { + config: fileConfig, + configPath, + warnings: configWarnings, + } = loadKimchiMcpConfig({ + cwd: options.cwd, + overridePath, + }) + const config = options.callerServers + ? { ...fileConfig, mcpServers: { ...fileConfig.mcpServers, ...options.callerServers } } + : fileConfig + const { warnings: oauthWarnings } = migrateLegacyOAuthCredentials(config) + const warnings = [...configWarnings, ...oauthWarnings] + const hasConfiguredServers = Object.keys(config.mcpServers).length > 0 + const annotationCatalog = new McpAnnotationCatalog({ + sourceHash: mcpAnnotationSourceHash(config), + onChanged: () => reapplyCurrentProfile(pi), + }) + const policy = createMcpToolSurfacePolicy(hasConfiguredServers, annotationCatalog) + + registerReadOnlyToolProvider(pi, () => + [...policy.directTools] + .filter(([, tool]) => annotationCatalog.isReadOnly(tool.originalName, tool.description)) + .map(([toolName]) => toolName), + ) + + pi.on("session_start", (_event, ctx) => { + for (const warning of warnings) { + if (ctx.hasUI) ctx.ui.notify(warning, "warning") + else console.warn(warning) + } + }) + + pi.events.on(MCP_STATUS_EVENT, () => { + reapplyCurrentProfile(pi) + }) + + const adapterOptions = options.callerServers ? { config } : configPath ? { configPath } : {} + runWithMcpAnnotationCatalog(policy.annotationCatalog, () => { + createMcpAdapter(adapterOptions)(createUpstreamApi(pi, policy)) + }) +} + +export default function mcpAdapterExtension(pi: ExtensionAPI): void { + installMcpAdapterExtension(pi, {}) +} diff --git a/src/extensions/mcp/keyring-require-bridge.ts b/src/extensions/mcp/keyring-require-bridge.ts new file mode 100644 index 000000000..c25e8c39b --- /dev/null +++ b/src/extensions/mcp/keyring-require-bridge.ts @@ -0,0 +1,105 @@ +import { createHash, randomUUID } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { createRequire, Module } from "node:module" +import { join } from "node:path" +import * as keyring from "@napi-rs/keyring" + +const KEYRING_PACKAGE = "@napi-rs/keyring" +const VIRTUAL_KEYRING_PATH = "/$bunfs/kimchi/@napi-rs/keyring/index.js" +const INSTALLED_MARKER = Symbol.for("kimchi.mcp.keyring-require-bridge") +const TEST_KEYRING_DIR_ENV = "KIMCHI_MCP_E2E_KEYRING_DIR" + +interface CommonJsModuleInternals { + _cache: Record + _resolveFilename(request: string, parent: unknown, isMain: boolean, options?: unknown): string + [INSTALLED_MARKER]?: boolean +} + +class FileBackedTestEntry { + private readonly baseDir: string + private readonly path: string + + constructor(service: string, account: string) { + const baseDir = process.env[TEST_KEYRING_DIR_ENV] + if (!baseDir) throw new Error(`${TEST_KEYRING_DIR_ENV} is not configured`) + const key = createHash("sha256").update(`${service}\0${account}`, "utf8").digest("hex") + this.baseDir = baseDir + this.path = join(baseDir, key) + } + + getPassword(): string | null { + return existsSync(this.path) ? readFileSync(this.path, "utf8") : null + } + + setPassword(value: string): void { + mkdirSync(this.baseDir, { recursive: true }) + writeFileSync(this.path, value, { encoding: "utf8", mode: 0o600 }) + } + + deleteCredential(): boolean { + if (!existsSync(this.path)) return false + rmSync(this.path) + return true + } +} + +function keyringExports(): unknown { + if (!process.env[TEST_KEYRING_DIR_ENV]) return keyring + return { ...keyring, Entry: FileBackedTestEntry } +} + +/** + * pi-mcp-adapter deliberately loads the native keyring with createRequire(). + * Bun's compiled filesystem cannot resolve that dynamic package request even + * though a static import can bundle and load the native addon. Bridge that one + * exact request to the statically bundled module namespace. + */ +export function installKeyringRequireBridge(): void { + const moduleInternals = Module as unknown as CommonJsModuleInternals + if (moduleInternals[INSTALLED_MARKER]) return + + const originalResolveFilename = moduleInternals._resolveFilename + moduleInternals._cache[VIRTUAL_KEYRING_PATH] = { exports: keyringExports() } + moduleInternals._resolveFilename = (request, parent, isMain, options) => + request === KEYRING_PACKAGE + ? VIRTUAL_KEYRING_PATH + : originalResolveFilename.call(moduleInternals, request, parent, isMain, options) + moduleInternals[INSTALLED_MARKER] = true +} + +export interface McpKeyringRuntimeCheck { + backend: "native" + platform: NodeJS.Platform + arch: NodeJS.Architecture + writable: true +} + +/** + * Exercise the exact dynamic-require path used by pi-mcp-adapter, including a + * write/read/delete round trip against the host operating system's credential + * store. Release builds call this from the compiled executable on every target. + */ +export function verifyMcpKeyringRuntime(): McpKeyringRuntimeCheck { + installKeyringRequireBridge() + const requiredKeyring = createRequire(import.meta.url)(KEYRING_PACKAGE) as typeof keyring + const account = `runtime-check-${randomUUID()}` + const password = randomUUID() + const entry = new requiredKeyring.Entry("dev.kimchi.mcp-adapter.runtime-check", account) + let stored = false + + try { + entry.setPassword(password) + stored = true + if (entry.getPassword() !== password) { + throw new Error("MCP keyring returned a different credential after writing it") + } + return { + backend: "native", + platform: process.platform, + arch: process.arch, + writable: true, + } + } finally { + if (stored) entry.deleteCredential() + } +} diff --git a/src/extensions/mcp/oauth-migration.test.ts b/src/extensions/mcp/oauth-migration.test.ts new file mode 100644 index 000000000..804637cd8 --- /dev/null +++ b/src/extensions/mcp/oauth-migration.test.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto" +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import type { McpConfig } from "pi-mcp-adapter/types" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { migrateLegacyOAuthCredentials } from "./oauth-migration.js" + +function hashedCredentialPath(baseDir: string, serverName: string): string { + const hash = createHash("sha256").update(serverName, "utf8").digest("hex") + return join(baseDir, `sha256-${hash}`, "tokens.json") +} + +describe("migrateLegacyOAuthCredentials", () => { + let agentDir: string + let cwd: string + const config: McpConfig = { + mcpServers: { fixture: { url: "https://example.test/mcp", auth: "oauth" } }, + } + + beforeEach(() => { + agentDir = mkdtempSync(join(tmpdir(), "kimchi-mcp-agent-")) + cwd = mkdtempSync(join(tmpdir(), "kimchi-mcp-cwd-")) + }) + + afterEach(() => { + rmSync(agentDir, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + }) + + it("copies a complete legacy entry into upstream's hashed import layout", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + const targetPath = hashedCredentialPath(join(agentDir, "mcp-oauth"), "fixture") + const entry = { + tokens: { accessToken: "access", refreshToken: "refresh", expiresAt: 2_000_000_000, scope: "mcp:tools" }, + clientInfo: { clientId: "client", clientSecret: "secret" }, + codeVerifier: "verifier", + oauthState: "state", + serverUrl: "https://example.test/mcp", + } + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify(entry), { mode: 0o600 }) + + const result = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + + expect(result).toEqual({ migratedServerNames: ["fixture"], warnings: [] }) + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(join(dirname(sourcePath), ".pi-mcp-adapter-migrated"))).toBe(true) + expect(JSON.parse(readFileSync(targetPath, "utf8"))).toEqual(entry) + expect(statSync(targetPath).mode & 0o777).toBe(0o600) + }) + + it("does not recreate an imported entry after upstream removes its import file", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + const targetPath = hashedCredentialPath(join(agentDir, "mcp-oauth"), "fixture") + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify({ tokens: { accessToken: "access" } })) + + const first = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + rmSync(dirname(targetPath), { recursive: true }) + const second = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + + expect(first).toEqual({ migratedServerNames: ["fixture"], warnings: [] }) + expect(second).toEqual({ migratedServerNames: [], warnings: [] }) + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(targetPath)).toBe(false) + }) + + it("does not overwrite a hashed entry when both layouts exist", () => { + const baseDir = join(agentDir, "mcp-oauth") + const sourcePath = join(baseDir, "fixture", "tokens.json") + const targetPath = hashedCredentialPath(baseDir, "fixture") + mkdirSync(dirname(sourcePath), { recursive: true }) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify({ tokens: { accessToken: "old" } })) + writeFileSync(targetPath, JSON.stringify({ tokens: { accessToken: "new" } })) + + const result = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + + expect(result.migratedServerNames).toEqual([]) + expect(result.warnings[0]).toContain("already exists") + expect(JSON.parse(readFileSync(sourcePath, "utf8"))).toEqual({ tokens: { accessToken: "old" } }) + expect(JSON.parse(readFileSync(targetPath, "utf8"))).toEqual({ tokens: { accessToken: "new" } }) + + rmSync(dirname(targetPath), { recursive: true }) + const retry = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + expect(retry.migratedServerNames).toEqual([]) + expect(retry.warnings[0]).toContain("previously conflicted") + expect(existsSync(sourcePath)).toBe(true) + }) + + it("leaves malformed credentials in place with an actionable warning", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify({ tokens: { accessToken: 42 } })) + + const result = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + + expect(result.migratedServerNames).toEqual([]) + expect(result.warnings[0]).toContain("invalid shape") + expect(existsSync(sourcePath)).toBe(true) + }) + + it("leaves invalid JSON in place with an actionable warning", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, "{not-json") + + const result = migrateLegacyOAuthCredentials(config, { agentDir, cwd, env: {} }) + + expect(result.migratedServerNames).toEqual([]) + expect(result.warnings[0]).toContain("failed to migrate") + expect(existsSync(sourcePath)).toBe(true) + }) + + it("moves legacy entries into a configured upstream OAuth directory", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + const targetBaseDir = join(agentDir, "secure-import") + const targetPath = hashedCredentialPath(targetBaseDir, "fixture") + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify({ tokens: { accessToken: "access" } })) + + const result = migrateLegacyOAuthCredentials( + { ...config, settings: { oauthDir: targetBaseDir } }, + { agentDir, cwd, env: {} }, + ) + + expect(result).toEqual({ migratedServerNames: ["fixture"], warnings: [] }) + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(targetPath)).toBe(true) + }) + + it("does not copy credentials into a project-controlled OAuth directory", () => { + const sourcePath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + const targetBaseDir = join(cwd, "project-oauth") + mkdirSync(dirname(sourcePath), { recursive: true }) + writeFileSync(sourcePath, JSON.stringify({ tokens: { accessToken: "access" } })) + + const result = migrateLegacyOAuthCredentials( + { ...config, settings: { oauthDir: "project-oauth" } }, + { agentDir, cwd, env: {} }, + ) + + expect(result.migratedServerNames).toEqual([]) + expect(result.warnings[0]).toContain("outside the Kimchi agent directory") + expect(existsSync(sourcePath)).toBe(true) + expect(existsSync(targetBaseDir)).toBe(false) + }) + + it("rejects server names that escape the legacy credential directory", () => { + const result = migrateLegacyOAuthCredentials( + { mcpServers: { "../outside": { url: "https://example.test/mcp", auth: "oauth" } } }, + { agentDir, cwd, env: {} }, + ) + + expect(result.migratedServerNames).toEqual([]) + expect(result.warnings[0]).toContain("resolves outside") + }) +}) diff --git a/src/extensions/mcp/oauth-migration.ts b/src/extensions/mcp/oauth-migration.ts new file mode 100644 index 000000000..22996b2e9 --- /dev/null +++ b/src/extensions/mcp/oauth-migration.ts @@ -0,0 +1,161 @@ +import { createHash } from "node:crypto" +import { chmodSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path" +import { getAgentDir } from "@earendil-works/pi-coding-agent" +import type { McpConfig } from "pi-mcp-adapter/types" + +interface LegacyOAuthMigrationOptions { + agentDir?: string + cwd?: string + env?: NodeJS.ProcessEnv +} + +export interface LegacyOAuthMigrationResult { + migratedServerNames: string[] + warnings: string[] +} + +const CONFLICT_MARKER = ".pi-mcp-adapter-migration-conflict" +const MIGRATED_MARKER = ".pi-mcp-adapter-migrated" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function hasOptionalType(value: unknown, type: "string" | "number"): boolean { + return value === undefined || typeof value === type +} + +function isValidLegacyAuthEntry(value: unknown): boolean { + if (!isRecord(value)) return false + if (!hasOptionalType(value.codeVerifier, "string")) return false + if (!hasOptionalType(value.oauthState, "string")) return false + if (!hasOptionalType(value.serverUrl, "string")) return false + + if (value.tokens !== undefined) { + if (!isRecord(value.tokens) || typeof value.tokens.accessToken !== "string") return false + if (!hasOptionalType(value.tokens.refreshToken, "string")) return false + if (!hasOptionalType(value.tokens.expiresAt, "number")) return false + if (!hasOptionalType(value.tokens.scope, "string")) return false + } + + if (value.clientInfo !== undefined) { + if (!isRecord(value.clientInfo) || typeof value.clientInfo.clientId !== "string") return false + if (!hasOptionalType(value.clientInfo.clientSecret, "string")) return false + if (!hasOptionalType(value.clientInfo.clientIdIssuedAt, "number")) return false + if (!hasOptionalType(value.clientInfo.clientSecretExpiresAt, "number")) return false + } + + return true +} + +function resolveWithin(baseDir: string, ...segments: string[]): string | undefined { + const path = resolve(baseDir, ...segments) + const fromBase = relative(resolve(baseDir), path) + return fromBase && isPathAtOrWithin(baseDir, path) ? path : undefined +} + +function isPathAtOrWithin(baseDir: string, path: string): boolean { + const fromBase = relative(resolve(baseDir), resolve(path)) + return fromBase !== ".." && !fromBase.startsWith(`..${sep}`) && !isAbsolute(fromBase) +} + +function resolveOAuthBaseDirs( + config: McpConfig, + options: Required>, +): { sourceBaseDir: string; targetBaseDir?: string; unsafeConfiguredTarget?: string } { + const envOverride = options.env.MCP_OAUTH_DIR?.trim() + if (envOverride) { + const baseDir = resolve(options.cwd, envOverride) + return { sourceBaseDir: baseDir, targetBaseDir: baseDir } + } + + const sourceBaseDir = join(options.agentDir, "mcp-oauth") + const configuredTarget = config.settings?.oauthDir?.trim() + if (!configuredTarget) return { sourceBaseDir, targetBaseDir: sourceBaseDir } + + const targetBaseDir = resolve(options.cwd, configuredTarget) + return isPathAtOrWithin(options.agentDir, targetBaseDir) + ? { sourceBaseDir, targetBaseDir } + : { sourceBaseDir, unsafeConfiguredTarget: targetBaseDir } +} + +function hashedServerDirectory(serverName: string): string { + return `sha256-${createHash("sha256").update(serverName, "utf8").digest("hex")}` +} + +/** + * Relocate Kimchi's complete plaintext OAuth entries into the hashed legacy + * layout that pi-mcp-adapter imports into the operating-system credential + * store. The copy is exclusive, and the source remains available to the + * vendored ACP adapter until that mode also moves upstream. A marker prevents + * repeated imports from restoring credentials after an interactive logout. + */ +export function migrateLegacyOAuthCredentials( + config: McpConfig, + options: LegacyOAuthMigrationOptions = {}, +): LegacyOAuthMigrationResult { + const resolvedOptions = { + agentDir: options.agentDir ?? getAgentDir(), + cwd: options.cwd ?? process.cwd(), + env: options.env ?? process.env, + } + const { sourceBaseDir, targetBaseDir, unsafeConfiguredTarget } = resolveOAuthBaseDirs(config, resolvedOptions) + const migratedServerNames: string[] = [] + const warnings: string[] = [] + + for (const serverName of Object.keys(config.mcpServers)) { + const sourcePath = resolveWithin(sourceBaseDir, serverName, "tokens.json") + if (!sourcePath) { + warnings.push( + `MCP OAuth: skipped legacy credentials for "${serverName}" because its name resolves outside the credential directory`, + ) + continue + } + if (!existsSync(sourcePath)) continue + const migratedMarkerPath = join(dirname(sourcePath), MIGRATED_MARKER) + if (existsSync(migratedMarkerPath)) continue + if (!targetBaseDir) { + warnings.push( + `MCP OAuth: legacy credentials for "${serverName}" were left at ${sourcePath} because settings.oauthDir resolves outside the Kimchi agent directory (${unsafeConfiguredTarget}); migrate them manually`, + ) + continue + } + + const targetPath = join(targetBaseDir, hashedServerDirectory(serverName), "tokens.json") + const conflictMarkerPath = join(dirname(sourcePath), CONFLICT_MARKER) + if (existsSync(conflictMarkerPath)) { + warnings.push( + `MCP OAuth: legacy credentials for "${serverName}" previously conflicted with upstream storage and remain at ${sourcePath}; resolve them manually, then remove ${conflictMarkerPath}`, + ) + continue + } + try { + const payload = readFileSync(sourcePath, "utf8") + if (!isValidLegacyAuthEntry(JSON.parse(payload))) { + warnings.push( + `MCP OAuth: legacy credentials for "${serverName}" have an invalid shape and were left at ${sourcePath}`, + ) + continue + } + if (existsSync(targetPath)) { + writeFileSync(conflictMarkerPath, targetPath, { encoding: "utf8", flag: "wx", mode: 0o600 }) + warnings.push( + `MCP OAuth: legacy credentials for "${serverName}" were not migrated because ${targetPath} already exists; the original remains at ${sourcePath}`, + ) + continue + } + + mkdirSync(dirname(targetPath), { recursive: true, mode: 0o700 }) + copyFileSync(sourcePath, targetPath, constants.COPYFILE_EXCL) + chmodSync(targetPath, 0o600) + writeFileSync(migratedMarkerPath, targetPath, { encoding: "utf8", flag: "wx", mode: 0o600 }) + migratedServerNames.push(serverName) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + warnings.push(`MCP OAuth: failed to migrate legacy credentials for "${serverName}": ${message}`) + } + } + + return { migratedServerNames, warnings } +} diff --git a/src/extensions/mcp/probe.ts b/src/extensions/mcp/probe.ts new file mode 100644 index 000000000..768acc75b --- /dev/null +++ b/src/extensions/mcp/probe.ts @@ -0,0 +1,279 @@ +import { spawn } from "node:child_process" +import { randomUUID } from "node:crypto" +import type { + ExtensionAPI, + ExtensionContext, + ExtensionHandler, + RegisteredCommand, + ToolDefinition, +} from "@earendil-works/pi-coding-agent" +import { createMcpAdapter } from "pi-mcp-adapter" +import { inspectMcpOAuthTokensForUrl } from "pi-mcp-adapter/oauth" +import type { ServerEntry } from "pi-mcp-adapter/types" +import { + installMcpAnnotationCapture, + McpAnnotationCatalog, + mcpAnnotationSourceHash, + runWithMcpAnnotationCatalog, +} from "./annotation-catalog.js" +import { installKeyringRequireBridge } from "./keyring-require-bridge.js" + +export interface ProbeTool { + name: string + title?: string + description?: string +} + +export interface ProbeResult { + tools: ProbeTool[] + needsAuth: boolean + error: string | null +} + +export interface McpProbeOptions { + authenticate?: boolean + cwd?: string + signal?: AbortSignal +} + +export interface McpProbe { + probeTools(name: string, definition: ServerEntry, options?: McpProbeOptions): Promise +} + +type Handler = ExtensionHandler +type Command = Omit +type GatewayResult = Awaited> + +interface ProbeHost { + api: ExtensionAPI + context: ExtensionContext + commands: Map + handlers: Map + tools: Map +} + +function executeProcess( + command: string, + args: string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal } = {}, +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + const abort = () => child.kill() + options.signal?.addEventListener("abort", abort, { once: true }) + child.stdout?.setEncoding("utf8").on("data", (chunk: string) => { + stdout += chunk + }) + child.stderr?.setEncoding("utf8").on("data", (chunk: string) => { + stderr += chunk + }) + child.once("error", (error) => { + options.signal?.removeEventListener("abort", abort) + resolve({ code: 1, stdout, stderr: stderr || error.message }) + }) + child.once("close", (code) => { + options.signal?.removeEventListener("abort", abort) + resolve({ code: code ?? 1, stdout, stderr }) + }) + }) +} + +function createProbeHost(cwd: string, signal: AbortSignal | undefined): ProbeHost { + const handlers = new Map() + const commands = new Map() + const tools = new Map() + const activeTools = new Set() + const eventHandlers = new Map void>>() + + const ui = { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + } + const context = { cwd, hasUI: true, mode: "tui", signal, ui } as unknown as ExtensionContext + + const api = { + on(event: string, handler: Handler) { + const current = handlers.get(event) ?? [] + current.push(handler) + handlers.set(event, current) + }, + registerCommand(name: string, command: Command) { + commands.set(name, command) + }, + registerFlag() {}, + getFlag() { + return undefined + }, + registerTool(tool: ToolDefinition) { + tools.set(tool.name, tool) + activeTools.add(tool.name) + }, + unregisterTool(name: string) { + activeTools.delete(name) + return tools.delete(name) + }, + getActiveTools() { + return [...activeTools] + }, + getAllTools() { + return [...tools.values()] + }, + setActiveTools(names: string[]) { + activeTools.clear() + for (const name of names) activeTools.add(name) + }, + events: { + on(channel: string, handler: (data: unknown) => void) { + const current = eventHandlers.get(channel) ?? [] + current.push(handler) + eventHandlers.set(channel, current) + return () => { + const index = current.indexOf(handler) + if (index >= 0) current.splice(index, 1) + } + }, + emit(channel: string, data: unknown) { + for (const handler of eventHandlers.get(channel) ?? []) handler(data) + }, + }, + exec: executeProcess, + sendMessage() {}, + sendUserMessage() {}, + appendEntry() {}, + registerShortcut() {}, + registerMessageRenderer() {}, + registerMarkdownTransformer() {}, + registerEntryRenderer() {}, + } as unknown as ExtensionAPI + + return { api, context, commands, handlers, tools } +} + +function resultDetails(result: GatewayResult): Record { + return result.details && typeof result.details === "object" ? (result.details as Record) : {} +} + +function resultMessage(result: GatewayResult): string { + return result.content + .filter((item): item is { type: "text"; text: string } => item.type === "text") + .map((item) => item.text) + .join("\n") +} + +function resolveProbeName(name: string, definition: ServerEntry): string { + if (!definition.url) return name + try { + const status = inspectMcpOAuthTokensForUrl(name, definition.url) as { status: string } + return status.status === "url-mismatch" ? `__probe_${randomUUID()}` : name + } catch { + return name + } +} + +async function emitHandlers( + host: ProbeHost, + event: "session_start" | "session_shutdown", + catalog: McpAnnotationCatalog, +): Promise { + const payload = event === "session_start" ? { type: event, reason: "startup" } : { type: event, reason: "shutdown" } + for (const handler of host.handlers.get(event) ?? []) { + await runWithMcpAnnotationCatalog(catalog, () => handler(payload, host.context)) + } +} + +async function executeGateway( + host: ProbeHost, + params: Record, + catalog: McpAnnotationCatalog, +): Promise { + const gateway = host.tools.get("mcp") + if (!gateway) throw new Error("pi-mcp-adapter did not register its MCP gateway") + return runWithMcpAnnotationCatalog(catalog, () => + gateway.execute(`probe-${randomUUID()}`, params, host.context.signal, undefined, host.context), + ) +} + +export class UpstreamMcpProbe implements McpProbe { + async probeTools(name: string, definition: ServerEntry, options: McpProbeOptions = {}): Promise { + installKeyringRequireBridge() + installMcpAnnotationCapture() + const cwd = options.cwd ?? process.cwd() + const probeName = resolveProbeName(name, definition) + const throwaway = probeName !== name + const host = createProbeHost(cwd, options.signal) + const config = { + mcpServers: { + [probeName]: { ...definition, directTools: false, lifecycle: "lazy" as const }, + }, + settings: { + toolPrefix: "none" as const, + directTools: false, + scriptMode: false, + autoAuth: options.authenticate === true, + sampling: false, + elicitation: false, + }, + } + const catalog = new McpAnnotationCatalog({ sourceHash: mcpAnnotationSourceHash(config) }) + + try { + runWithMcpAnnotationCatalog(catalog, () => createMcpAdapter({ config })(host.api)) + await emitHandlers(host, "session_start", catalog) + const connected = await executeGateway(host, { connect: probeName }, catalog) + const details = resultDetails(connected) + if (details.error === "auth_required") { + return { + tools: [], + needsAuth: true, + error: options.authenticate ? String(details.message ?? resultMessage(connected)) : null, + } + } + if (details.error) { + return { tools: [], needsAuth: false, error: String(details.message ?? resultMessage(connected)) } + } + + const names = Array.isArray(details.tools) + ? details.tools.filter((toolName): toolName is string => typeof toolName === "string") + : [] + const tools = await Promise.all( + names.map(async (toolName): Promise => { + const described = await executeGateway(host, { describe: toolName }, catalog) + const tool = resultDetails(described).tool + const description = + tool && typeof tool === "object" && typeof (tool as { description?: unknown }).description === "string" + ? (tool as { description: string }).description + : undefined + return { name: toolName, ...(description ? { description } : {}) } + }), + ) + return { tools, needsAuth: false, error: null } + } finally { + if (throwaway) { + const commandContext = host.context as Parameters[1] + await host.commands + .get("mcp") + ?.handler(`logout ${probeName}`, commandContext) + .catch(() => {}) + } + await emitHandlers(host, "session_shutdown", catalog).catch(() => {}) + } + } +} diff --git a/src/extensions/mcp/read-only-tools.test.ts b/src/extensions/mcp/read-only-tools.test.ts new file mode 100644 index 000000000..edcc40d42 --- /dev/null +++ b/src/extensions/mcp/read-only-tools.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest" +import { isReadOnlyMcpToolName } from "./read-only-tools.js" + +describe("isReadOnlyMcpToolName", () => { + it.each([ + "get_issue", + "search_docs", + "list_projects", + "read_file", + "fetch_url", + ])("classifies %s as read-only", (name) => { + expect(isReadOnlyMcpToolName(name)).toBe(true) + }) + + it.each([ + "create_issue", + "update_page", + "delete_project", + "reset_database", + ])("does not classify %s as read-only", (name) => { + expect(isReadOnlyMcpToolName(name)).toBe(false) + }) +}) diff --git a/src/extensions/mcp/read-only-tools.ts b/src/extensions/mcp/read-only-tools.ts new file mode 100644 index 000000000..d0e00e549 --- /dev/null +++ b/src/extensions/mcp/read-only-tools.ts @@ -0,0 +1,10 @@ +/** + * Conservative fallback used while upstream does not expose MCP annotations + * through its public tool-surface API. Explicit annotations will replace this + * name heuristic once the public API carries them. + */ +const READ_ONLY_NAME_PREFIXES = /^(get|search|list|read|fetch)/ + +export function isReadOnlyMcpToolName(originalName: string): boolean { + return READ_ONLY_NAME_PREFIXES.test(originalName) +} diff --git a/src/extensions/permissions/index.test.ts b/src/extensions/permissions/index.test.ts index 04d2fc1ea..9600121c7 100644 --- a/src/extensions/permissions/index.test.ts +++ b/src/extensions/permissions/index.test.ts @@ -288,6 +288,16 @@ describe("permissions plan-mode tool visibility", () => { } }) + it("refreshes the plan tool snapshot before every model request", async () => { + const harness = createPermissionsHarness(["read", "bash"], { plan: true }) + await harness.fire("session_start", {}, createMockContext([])) + const initialApplications = vi.mocked(harness.pi.setActiveTools).mock.calls.length + + await harness.fire("before_agent_start", {}, createMockContext([])) + + expect(harness.pi.setActiveTools).toHaveBeenCalledTimes(initialApplications + 1) + }) + it("allows the mcp gateway tool under explicit --plan", async () => { const harness = createPermissionsHarness(["read", "mcp"], { plan: true }) diff --git a/src/extensions/permissions/index.ts b/src/extensions/permissions/index.ts index e5a88ced9..41a9bb207 100644 --- a/src/extensions/permissions/index.ts +++ b/src/extensions/permissions/index.ts @@ -598,6 +598,12 @@ export default function permissionsExtension(pi: ExtensionAPI): void { // only written to the session log when the next agent run starts. pi.on("before_agent_start", (_event, ctx) => { maybePersistPermissionMode(ctx) + if (getRuntimePermissionMode().mode === "plan") { + // MCP direct tools can finish registering after session_start. Rebuild + // the snapshot immediately before every model request so protocol + // annotation policy, rather than registration timing, decides exposure. + ToolProfileManager.apply("planning-adhoc", "adhoc", pi) + } }) // Plan-mode stall recovery: when the model made tool calls in plan mode and diff --git a/src/extensions/tool-exposure.test.ts b/src/extensions/tool-exposure.test.ts index a5911ac00..1532eac0b 100644 --- a/src/extensions/tool-exposure.test.ts +++ b/src/extensions/tool-exposure.test.ts @@ -59,20 +59,11 @@ vi.mock("./multi-model.js", async (importOriginal) => { const mcpConfigState = vi.hoisted(() => ({ servers: {} as Record, })) -vi.mock("./mcp-adapter/config.js", async (importOriginal) => { - const original = await importOriginal() +vi.mock("./mcp/config.js", async (importOriginal) => { + const original = await importOriginal() return { ...original, - loadMcpConfig: () => ({ config: { mcpServers: mcpConfigState.servers }, warnings: [] }), - } -}) -vi.mock("./mcp-adapter/metadata-cache.js", async (importOriginal) => { - const original = await importOriginal() - return { - ...original, - loadMetadataCache: () => undefined, - overwriteMetadataCache: () => {}, - flushMetadataCache: () => {}, + loadKimchiMcpConfig: () => ({ config: { mcpServers: mcpConfigState.servers }, warnings: [] }), } }) @@ -195,6 +186,7 @@ function createExposureHarness(): ExposureHarness & { pi: ExtensionAPI } { {}, { get: (_target, prop) => { + if (prop === "getFlag") return () => undefined if (prop === "registerTool") { return (tool: { name: string; description?: string; execute: (...args: unknown[]) => Promise }) => { registered.set(tool.name, tool) diff --git a/src/modes/acp/ext-methods/mcp.test.ts b/src/modes/acp/ext-methods/mcp.test.ts index eb903108f..7e2977705 100644 --- a/src/modes/acp/ext-methods/mcp.test.ts +++ b/src/modes/acp/ext-methods/mcp.test.ts @@ -1,300 +1,66 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" -import type { McpServerManager } from "../../../extensions/mcp-adapter/server-manager.js" -import type { ProbeResult, ServerEntry } from "../../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" +import { describe, expect, it, vi } from "vitest" +import type { McpProbe, ProbeResult } from "../../../extensions/mcp/probe.js" import { handleProbeMcpServer, validateServerEntry } from "./mcp.js" -const mockGetAuthEntry = vi.fn() -const mockRemoveAuthEntry = vi.fn() -const mockAuthenticate = vi.fn() -const mockGetAuthStatus = vi.fn() -const mockSupportsOAuth = vi.fn() -const mockProbeTools = vi.fn() - -vi.mock("../../../extensions/mcp-adapter/mcp-auth.js", () => ({ - getAuthEntry: (...args: unknown[]) => mockGetAuthEntry(...args), - removeAuthEntry: (...args: unknown[]) => mockRemoveAuthEntry(...args), -})) - -vi.mock("../../../extensions/mcp-adapter/mcp-auth-flow.js", () => ({ - authenticate: (...args: unknown[]) => mockAuthenticate(...args), - getAuthStatus: (...args: unknown[]) => mockGetAuthStatus(...args), - supportsOAuth: (...args: unknown[]) => mockSupportsOAuth(...args), -})) - -function makeManager(overrides: Partial = {}): McpServerManager { - return { - probeTools: mockProbeTools, - ...overrides, - } as unknown as McpServerManager +function createProbe(result: ProbeResult): McpProbe { + return { probeTools: vi.fn().mockResolvedValue(result) } } -beforeEach(() => { - vi.clearAllMocks() - mockSupportsOAuth.mockReturnValue(false) -}) - describe("validateServerEntry", () => { - it("accepts a minimal stdio server entry", () => { - const entry = validateServerEntry({ command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"] }) - expect(entry).toEqual({ - command: "npx", - args: ["-y", "@modelcontextprotocol/server-filesystem"], - }) - }) - - it("accepts a minimal URL server entry", () => { - const entry = validateServerEntry({ url: "https://mcp.example.com/sse" }) - expect(entry).toEqual({ url: "https://mcp.example.com/sse" }) - }) - - it("accepts all optional stdio fields", () => { - const raw = { + it("accepts validated stdio and HTTP definitions", () => { + expect(validateServerEntry({ command: "node", args: ["server.js"], env: { TOKEN: "value" } })).toEqual({ command: "node", args: ["server.js"], - env: { FOO: "bar" }, - cwd: "/project", - auth: false, - debug: true, - } as const - expect(validateServerEntry(raw)).toEqual(raw) - }) - - it("accepts all optional URL fields", () => { - const raw = { - url: "https://mcp.example.com", - headers: { Authorization: "Bearer token" }, - auth: "bearer" as const, - debug: false, - } - expect(validateServerEntry(raw)).toEqual(raw) - }) - - it("throws invalidParams when server is not an object", () => { - for (const server of [null, undefined, "string", 123, []]) { - expect(() => validateServerEntry(server)).toThrow( - expect.objectContaining({ code: -32602, message: expect.stringContaining("'server' must be an object") }), - ) - } - }) - - it("throws invalidParams when server has neither command nor url", () => { - expect(() => validateServerEntry({})).toThrow( - expect.objectContaining({ - code: -32602, - message: expect.stringContaining("must have a 'command' or 'url' field"), - }), - ) - }) - - it("throws invalidParams when command is empty", () => { - expect(() => validateServerEntry({ command: "" })).toThrow(expect.objectContaining({ code: -32602 })) - }) - - it("throws invalidParams when url is empty", () => { - expect(() => validateServerEntry({ url: "" })).toThrow(expect.objectContaining({ code: -32602 })) - }) - - it("throws invalidParams when args is not an array", () => { - expect(() => validateServerEntry({ command: "node", args: "server.js" })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.args' must be an array") }), - ) - }) - - it("throws invalidParams when args contains a non-string", () => { - expect(() => validateServerEntry({ command: "node", args: ["server.js", 123] })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.args' must be an array of strings") }), - ) - }) - - it("throws invalidParams when env is not an object", () => { - expect(() => validateServerEntry({ command: "node", env: "FOO=bar" })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.env' must be an object") }), - ) - }) - - it("throws invalidParams when env value is not a string", () => { - expect(() => validateServerEntry({ command: "node", env: { FOO: 123 } })).toThrow( - expect.objectContaining({ message: expect.stringContaining("server.env['FOO'] must be a string") }), - ) - }) - - it("throws invalidParams when cwd is not a string", () => { - expect(() => validateServerEntry({ command: "node", cwd: 123 })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.cwd' must be a string") }), - ) + env: { TOKEN: "value" }, + }) + expect( + validateServerEntry({ url: "https://example.test/mcp", headers: { Authorization: "Bearer value" } }), + ).toEqual({ + url: "https://example.test/mcp", + headers: { Authorization: "Bearer value" }, + }) }) - it("throws invalidParams when headers is not an object", () => { - expect(() => validateServerEntry({ url: "https://example.com", headers: "Auth" })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.headers' must be an object") }), - ) + it.each([null, undefined, "string", 123, []])("rejects non-object definition %j", (server) => { + expect(() => validateServerEntry(server)).toThrow(expect.objectContaining({ code: -32602 })) }) - it("throws invalidParams when auth is not a recognized value", () => { - expect(() => validateServerEntry({ url: "https://example.com", auth: "basic" })).toThrow( - expect.objectContaining({ - message: expect.stringContaining("'server.auth' must be 'oauth', 'bearer', or false"), - }), + it("rejects missing transports and malformed optional fields", () => { + expect(() => validateServerEntry({})).toThrow("must have a 'command' or 'url' field") + expect(() => validateServerEntry({ command: "node", args: [123] })).toThrow("array of strings") + expect(() => validateServerEntry({ command: "node", env: { TOKEN: 123 } })).toThrow("must be a string") + expect(() => validateServerEntry({ url: "https://example.test", headers: "bad" })).toThrow( + "'server.headers' must be an object", ) - }) - - it("throws invalidParams when debug is not a boolean", () => { - expect(() => validateServerEntry({ command: "node", debug: "yes" })).toThrow( - expect.objectContaining({ message: expect.stringContaining("'server.debug' must be a boolean") }), + expect(() => validateServerEntry({ url: "https://example.test", auth: "basic" })).toThrow( + "must be 'oauth', 'bearer', or false", ) }) }) describe("handleProbeMcpServer", () => { - const stdioServer: ServerEntry = { command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem"] } - const urlServer: ServerEntry = { url: "https://mcp.example.com", auth: "oauth" } + const server: ServerEntry = { command: "node", args: ["server.js"] } + const result: ProbeResult = { + tools: [{ name: "read_file", description: "Read a file" }], + needsAuth: false, + error: null, + } - it("throws invalidParams when mcpServerManager is undefined", async () => { - await expect(handleProbeMcpServer(undefined, { server: stdioServer })).rejects.toThrow( - expect.objectContaining({ - code: -32602, - message: expect.stringContaining("MCP server manager is not available"), - }), - ) - }) - - it("throws invalidParams when server is missing", async () => { - const manager = makeManager() - await expect(handleProbeMcpServer(manager, {})).rejects.toThrow(expect.objectContaining({ code: -32602 })) - }) - - it("probes a stdio server and returns the result", async () => { - const manager = makeManager() - const probeResult: ProbeResult = { - tools: [{ name: "read_file", description: "Read a file" }], - needsAuth: false, - error: null, - } - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue(probeResult) - - const result = await handleProbeMcpServer(manager, { server: stdioServer, serverName: "stdio-server" }) - - expect(result).toEqual(probeResult) - expect(mockProbeTools).toHaveBeenCalledWith("stdio-server", stdioServer) - expect(mockAuthenticate).not.toHaveBeenCalled() - }) - - it("defaults serverName to 'probe' when omitted", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - - await handleProbeMcpServer(manager, { server: stdioServer }) - - expect(mockProbeTools).toHaveBeenCalledWith("probe", stdioServer) - }) - - it("authenticates OAuth URL servers before probing when not authenticated", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(true) - mockGetAuthStatus.mockResolvedValue("not_authenticated") - mockAuthenticate.mockResolvedValue(undefined) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - - await handleProbeMcpServer(manager, { server: urlServer, serverName: "oauth-server" }) - - expect(mockGetAuthStatus).toHaveBeenCalledWith("oauth-server", urlServer.url) - expect(mockAuthenticate).toHaveBeenCalledWith("oauth-server", urlServer.url, urlServer) - expect(mockProbeTools).toHaveBeenCalledWith("oauth-server", urlServer) - }) - - it("skips authenticate when OAuth server already has valid auth", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(true) - mockGetAuthStatus.mockResolvedValue("authenticated") - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - - await handleProbeMcpServer(manager, { server: urlServer, serverName: "oauth-server" }) - - expect(mockGetAuthStatus).toHaveBeenCalledWith("oauth-server", urlServer.url) - expect(mockAuthenticate).not.toHaveBeenCalled() - expect(mockProbeTools).toHaveBeenCalledWith("oauth-server", urlServer) - }) - - it("returns needsAuth with error message when authenticate fails", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(true) - mockGetAuthStatus.mockResolvedValue("not_authenticated") - mockAuthenticate.mockRejectedValue(new Error("User denied authorization")) - - const result = await handleProbeMcpServer(manager, { server: urlServer, serverName: "oauth-server" }) - - expect(result).toEqual({ tools: [], needsAuth: true, error: "User denied authorization" }) - expect(mockProbeTools).not.toHaveBeenCalled() - }) - - it("uses a throwaway probe name when an auth entry exists for a different URL", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - mockGetAuthEntry.mockReturnValue({ serverUrl: "https://old.example.com" }) - - await handleProbeMcpServer(manager, { server: { url: "https://new.example.com" }, serverName: "my-server" }) - - const probeName = mockProbeTools.mock.calls[0][0] as string - expect(probeName).toMatch(/^__probe_[\w-]+$/) - expect(probeName).not.toBe("my-server") - expect(mockRemoveAuthEntry).toHaveBeenCalledWith(probeName) - }) - - it("uses the real serverName when no auth entry exists", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - mockGetAuthEntry.mockReturnValue(undefined) - - await handleProbeMcpServer(manager, { server: stdioServer, serverName: "my-server" }) - - expect(mockProbeTools).toHaveBeenCalledWith("my-server", stdioServer) - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() - }) - - it("uses the real serverName when auth entry URL matches", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - mockGetAuthEntry.mockReturnValue({ serverUrl: "https://same.example.com" }) - - await handleProbeMcpServer(manager, { - server: { url: "https://same.example.com" }, - serverName: "my-server", - }) - - expect(mockProbeTools).toHaveBeenCalledWith("my-server", { url: "https://same.example.com" }) - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() + it("delegates to the isolated probe with authentication enabled", async () => { + const probe = createProbe(result) + expect(await handleProbeMcpServer(probe, { server, serverName: "fixture" })).toEqual(result) + expect(probe.probeTools).toHaveBeenCalledWith("fixture", server, { authenticate: true }) }) - it("uses the real serverName when the auth entry is from an incomplete OAuth flow", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockResolvedValue({ tools: [], needsAuth: false, error: null }) - // Incomplete OAuth flow residue: only oauthState/codeVerifier were - // saved — no serverUrl. The real name must be reused so the flow can - // complete and save tokens to the correct entry. - mockGetAuthEntry.mockReturnValue({ oauthState: "state-123", codeVerifier: "verifier-456" }) - - await handleProbeMcpServer(manager, { server: urlServer, serverName: "my-server" }) - - expect(mockProbeTools).toHaveBeenCalledWith("my-server", urlServer) - expect(mockRemoveAuthEntry).not.toHaveBeenCalled() + it("supports auth-free discovery and the default probe name", async () => { + const probe = createProbe(result) + await handleProbeMcpServer(probe, { server, skipAuth: true }) + expect(probe.probeTools).toHaveBeenCalledWith("probe", server, { authenticate: false }) }) - it("cleans up throwaway auth entries even when probeTools throws", async () => { - const manager = makeManager() - mockSupportsOAuth.mockReturnValue(false) - mockProbeTools.mockRejectedValue(new Error("probe failed")) - mockGetAuthEntry.mockReturnValue({ serverUrl: "https://old.example.com" }) - - await expect( - handleProbeMcpServer(manager, { server: { url: "https://new.example.com" }, serverName: "my-server" }), - ).rejects.toThrow("probe failed") - - expect(mockRemoveAuthEntry).toHaveBeenCalled() + it("rejects unavailable probes and missing server parameters", async () => { + await expect(handleProbeMcpServer(undefined, { server })).rejects.toThrow("MCP probe is not available") + await expect(handleProbeMcpServer(createProbe(result), {})).rejects.toMatchObject({ code: -32602 }) }) }) diff --git a/src/modes/acp/ext-methods/mcp.ts b/src/modes/acp/ext-methods/mcp.ts index 7d9fd4a63..a013e149a 100644 --- a/src/modes/acp/ext-methods/mcp.ts +++ b/src/modes/acp/ext-methods/mcp.ts @@ -6,11 +6,8 @@ // suitable for the ACP wire. import { RequestError } from "@agentclientprotocol/sdk" -import { removeAuthEntry } from "../../../extensions/mcp-adapter/mcp-auth.js" -import { authenticate, getAuthStatus, supportsOAuth } from "../../../extensions/mcp-adapter/mcp-auth-flow.js" -import { resolveProbeName } from "../../../extensions/mcp-adapter/resolve-probe-name.js" -import type { McpServerManager } from "../../../extensions/mcp-adapter/server-manager.js" -import type { ProbeResult, ServerEntry } from "../../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" +import type { McpProbe, ProbeResult } from "../../../extensions/mcp/probe.js" /** * Runtime validation for ServerEntry received over the ACP wire. @@ -87,57 +84,13 @@ export function validateServerEntry(raw: unknown): ServerEntry { * requests (HTTP servers) based on the ServerEntry provided by the client. */ export async function handleProbeMcpServer( - mcpServerManager: McpServerManager | undefined, + mcpProbe: McpProbe | undefined, params: Record, ): Promise { - if (!mcpServerManager) { - throw RequestError.invalidParams(undefined, "MCP server manager is not available") + if (!mcpProbe) { + throw RequestError.invalidParams(undefined, "MCP probe is not available") } const server = validateServerEntry(params.server) - // serverName is passed separately from the ServerEntry because ServerEntry - // itself has no name field — the name is the config key under - // `mcpServers` in the user's config. Desktop knows the key it's probing; - // we default to "probe" for ad-hoc calls. const serverName = (params.serverName as string | undefined) ?? "probe" - - // Resolve the OAuth token-store key. If an auth entry already exists - // under `serverName` for a *different* URL (e.g. the user edited the - // URL but kept the name), use a throwaway name so the real server's - // stored tokens are never overwritten. See `resolveProbeName` below. - const probeName = resolveProbeName(serverName, server) - const usedThrowaway = probeName !== serverName - const skipAuth = params.skipAuth === true - - try { - // For OAuth-capable URL servers, authenticate FIRST before probing. - // probeTools creates its own transport internally, which triggers the - // SDK's auth flow — if it runs before authenticate(), the state gets - // overwritten and the callback fails. By authenticating first, the - // stored tokens are available when probeTools connects, so it skips - // auth entirely. - // - // When skipAuth is true, skip the explicit authenticate() call and - // let probeTools() handle it: stored access tokens or refresh tokens - // are tried by the SDK internally; if both fail, probeTools() returns - // { needsAuth: true } without opening a browser. - if (!skipAuth && supportsOAuth(server) && server.url) { - const authStatus = await getAuthStatus(probeName, server.url) - if (authStatus !== "authenticated") { - try { - await authenticate(probeName, server.url, server) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - return { tools: [], needsAuth: true, error: message } - } - } - } - - return await mcpServerManager.probeTools(probeName, server) - } finally { - // Clean up throwaway probe credentials so the token store never - // accumulates `__probe_*` entries. - if (usedThrowaway) { - removeAuthEntry(probeName) - } - } + return mcpProbe.probeTools(serverName, server, { authenticate: params.skipAuth !== true }) } diff --git a/src/modes/acp/probe-mcp-server.test.ts b/src/modes/acp/probe-mcp-server.test.ts index 456587ade..09cd5aaad 100644 --- a/src/modes/acp/probe-mcp-server.test.ts +++ b/src/modes/acp/probe-mcp-server.test.ts @@ -1,432 +1,86 @@ import type { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk" import type { AgentSession } from "@earendil-works/pi-coding-agent" -import { beforeEach, describe, expect, it, vi } from "vitest" -import type { McpServerManager } from "../../extensions/mcp-adapter/server-manager.js" -import type { ProbeResult, ServerEntry } from "../../extensions/mcp-adapter/types.js" +import type { ServerEntry } from "pi-mcp-adapter/types" +import { describe, expect, it, vi } from "vitest" +import type { McpProbe, ProbeResult } from "../../extensions/mcp/probe.js" import { AVAILABLE_EXT_METHODS } from "./capabilities.js" import { type AcpSessionFactory, KimchiAcpAgent } from "./server.js" -// Mock the auth flow and auth store so tests don't touch real disk state. -vi.mock("../../extensions/mcp-adapter/mcp-auth-flow.js", () => ({ - supportsOAuth: vi.fn().mockReturnValue(false), - authenticate: vi.fn(), - getAuthStatus: vi.fn().mockResolvedValue("not_authenticated"), -})) -vi.mock("../../extensions/mcp-adapter/mcp-auth.js", () => ({ - getAuthEntry: vi.fn().mockReturnValue(null), - removeAuthEntry: vi.fn(), -})) - -import { getAuthEntry } from "../../extensions/mcp-adapter/mcp-auth.js" -import { authenticate, getAuthStatus, supportsOAuth } from "../../extensions/mcp-adapter/mcp-auth-flow.js" - -// Minimal fake — we only need sessionId/subscribe/dispose/prompt/abort for the -// ACP agent to accept a session. The probe_mcp_server extMethod doesn't touch -// the session at all. class FakeAgentSession { - sessionId: string - disposed = false - model = { provider: "test", id: "test-model" } - modelRegistry = { getAvailable: () => [{ provider: "test", id: "test-model", name: "Test" }] } - sessionManager = { + readonly sessionId = "probe-test-session" + readonly model = { provider: "test", id: "test-model" } + readonly modelRegistry = { getAvailable: () => [{ provider: "test", id: "test-model", name: "Test" }] } + readonly sessionManager = { getBranch: () => [], getSessionDir: () => "/tmp", getCwd: () => "/tmp", getEntries: () => [], appendCustomEntry: () => "entry-id", } - bindExtensionsImpl: ((opts: unknown) => Promise) | null = null - - constructor(id: string) { - this.sessionId = id - } - + readonly extensionRunner = { emit: async () => {} } subscribe = () => () => {} - async bindExtensions(opts: unknown): Promise { - if (this.bindExtensionsImpl) await this.bindExtensionsImpl(opts) - } + async bindExtensions(): Promise {} async prompt(): Promise {} async abort(): Promise {} - dispose(): void { - this.disposed = true - } - extensionRunner = { emit: async () => {} } + dispose(): void {} } -function asSession(fake: FakeAgentSession): AgentSession { - return fake as unknown as AgentSession +function createConnection(): AgentSideConnection { + return { sessionUpdate: async (_params: SessionNotification) => {} } as unknown as AgentSideConnection } -function makeConn(): AgentSideConnection { - const stub = { - sessionUpdate: async (_p: SessionNotification) => {}, - } - return stub as unknown as AgentSideConnection -} - -function makeFakeMcpServerManager(probeResult: ProbeResult): McpServerManager { - return { - probeTools: vi.fn().mockResolvedValue(probeResult), - } as unknown as McpServerManager +function createProbe(result: ProbeResult): McpProbe { + return { probeTools: vi.fn().mockResolvedValue(result) } } -function makeAgent(mcpServerManager?: McpServerManager): KimchiAcpAgent { - const fake = new FakeAgentSession("probe-test-session") - const sessionFactory: AcpSessionFactory = async () => asSession(fake) - return new KimchiAcpAgent(makeConn(), { +function createAgent(mcpProbe?: McpProbe): KimchiAcpAgent { + const sessionFactory: AcpSessionFactory = async () => new FakeAgentSession() as unknown as AgentSession + return new KimchiAcpAgent(createConnection(), { extensionFactories: [], agentDir: "/tmp/fake-agent-dir", sessionFactory, - mcpServerManager, + mcpProbe, }) } -describe("KimchiAcpAgent extMethod probe_mcp_server", () => { - it("routes _kimchi.dev/probe_mcp_server to mcpServerManager.probeTools", async () => { - const serverEntry: ServerEntry = { command: "echo", args: ["test"] } +describe("KimchiAcpAgent MCP probe extension method", () => { + it("routes probe requests through the configured upstream probe", async () => { + const server: ServerEntry = { command: "node", args: ["server.js"] } const probeResult: ProbeResult = { - tools: [ - { name: "tool_a", description: "Does A" }, - { name: "tool_b", description: "Does B" }, - ], + tools: [{ name: "tool_a", description: "Does A" }], needsAuth: false, error: null, } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) - - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "test-server", - }) - - expect(result).toEqual(probeResult) - expect(manager.probeTools).toHaveBeenCalledWith("test-server", serverEntry) - }) - - it("returns tools array, needsAuth flag, and error string", async () => { - const serverEntry: ServerEntry = { command: "echo", args: [] } - const probeResult: ProbeResult = { - tools: [{ name: "tool_x" }], - needsAuth: true, - error: null, - } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) - - const result = (await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - })) as unknown as ProbeResult - - expect(result.tools).toHaveLength(1) - expect(result.tools[0].name).toBe("tool_x") - expect(result.needsAuth).toBe(true) - expect(result.error).toBeNull() - }) - - it("passes through error from probeTools", async () => { - const serverEntry: ServerEntry = { command: "nonexistent-binary" } - const probeResult: ProbeResult = { - tools: [], - needsAuth: false, - error: "spawn nonexistent-binary ENOENT", - } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) + const probe = createProbe(probeResult) + const agent = createAgent(probe) - const result = (await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - })) as unknown as ProbeResult - - expect(result.tools).toEqual([]) - expect(result.needsAuth).toBe(false) - expect(result.error).toBe("spawn nonexistent-binary ENOENT") + expect(await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server, serverName: "fixture" })).toEqual( + probeResult, + ) + expect(probe.probeTools).toHaveBeenCalledWith("fixture", server, { authenticate: true }) }) - it("throws methodNotFound for unknown extMethod", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect(agent.extMethod("_kimchi.dev/unknown", {})).rejects.toMatchObject({ code: -32601 }) - }) + it("passes skipAuth through to the probe", async () => { + const server: ServerEntry = { url: "https://example.test/mcp" } + const probe = createProbe({ tools: [], needsAuth: true, error: null }) + const agent = createAgent(probe) - it("throws invalidParams when server parameter is missing", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect(agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, {})).rejects.toMatchObject({ code: -32602 }) + await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server, skipAuth: true }) + expect(probe.probeTools).toHaveBeenCalledWith("probe", server, { authenticate: false }) }) - it("throws invalidParams when mcpServerManager is not configured", async () => { - // No mcpServerManager injected — simulates a misconfigured agent - const fake = new FakeAgentSession("no-mgr-session") - const sessionFactory: AcpSessionFactory = async () => asSession(fake) - const agent = new KimchiAcpAgent(makeConn(), { - extensionFactories: [], - agentDir: "/tmp/fake-agent-dir", - sessionFactory, - }) + it("does not advertise or execute probing without the dependency", async () => { + const agent = createAgent() await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: "echo" } }), + agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: "node" } }), ).rejects.toMatchObject({ code: -32602 }) }) - it("defaults serverName to 'probe' when not provided", async () => { - const serverEntry: ServerEntry = { command: "echo", args: [] } - const probeResult: ProbeResult = { tools: [], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) - - await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - }) - - expect(manager.probeTools).toHaveBeenCalledWith("probe", serverEntry) - }) -}) - -describe("KimchiAcpAgent extMethod probe_mcp_server OAuth", () => { - beforeEach(() => { - vi.mocked(supportsOAuth).mockReturnValue(false) - vi.mocked(authenticate).mockReset() - vi.mocked(getAuthEntry).mockReturnValue(undefined) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - }) - - it("authenticates before probing for an OAuth-capable URL server", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const successResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(successResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - vi.mocked(authenticate).mockResolvedValue("authenticated" as never) - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - }) - - expect(result.tools).toHaveLength(1) - expect(result.needsAuth).toBe(false) - expect(vi.mocked(authenticate)).toHaveBeenCalledWith("my-server", "https://example.com/mcp", serverEntry) - expect(manager.probeTools).toHaveBeenCalledTimes(1) - }) - - it("skips authentication and probes directly when already authenticated", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const successResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(successResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("authenticated") - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - }) - - expect(result.tools).toHaveLength(1) - expect(vi.mocked(authenticate)).not.toHaveBeenCalled() - expect(manager.probeTools).toHaveBeenCalledTimes(1) - }) - - it("returns needsAuth with error message when authenticate fails", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const manager = makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null }) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - vi.mocked(authenticate).mockRejectedValue(new Error("Browser failed to open")) - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - }) - - expect(result.needsAuth).toBe(true) - expect(result.error).toBe("Browser failed to open") - expect(manager.probeTools).not.toHaveBeenCalled() - }) - - it("does not attempt OAuth for a stdio server (no url)", async () => { - const serverEntry: ServerEntry = { command: "echo", args: [] } - const needsAuthResult: ProbeResult = { tools: [], needsAuth: true, error: null } - const manager = makeFakeMcpServerManager(needsAuthResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - }) - - expect(result.needsAuth).toBe(true) - expect(vi.mocked(authenticate)).not.toHaveBeenCalled() - expect(manager.probeTools).toHaveBeenCalledTimes(1) - }) -}) - -describe("KimchiAcpAgent extMethod probe_mcp_server skipAuth", () => { - beforeEach(() => { - vi.mocked(supportsOAuth).mockReturnValue(false) - vi.mocked(authenticate).mockReset() - vi.mocked(getAuthEntry).mockReturnValue(undefined) - vi.mocked(getAuthStatus).mockReset() - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - }) - - it("skips authenticate() and probes directly when skipAuth is true", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const probeResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - skipAuth: true, - }) - - expect(result.tools).toHaveLength(1) - expect(vi.mocked(authenticate)).not.toHaveBeenCalled() - expect(vi.mocked(getAuthStatus)).not.toHaveBeenCalled() - expect(manager.probeTools).toHaveBeenCalledTimes(1) - expect(manager.probeTools).toHaveBeenCalledWith("my-server", serverEntry) - }) - - it("returns needsAuth without opening browser when skipAuth is true and tokens are invalid", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const needsAuthResult: ProbeResult = { tools: [], needsAuth: true, error: null } - const manager = makeFakeMcpServerManager(needsAuthResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("expired") - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - skipAuth: true, - }) - - expect(result.needsAuth).toBe(true) - expect(result.error).toBeNull() - expect(vi.mocked(authenticate)).not.toHaveBeenCalled() - expect(manager.probeTools).toHaveBeenCalledTimes(1) - }) - - it("still authenticates when skipAuth is false", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const probeResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - vi.mocked(authenticate).mockResolvedValue("authenticated" as never) - - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - skipAuth: false, - }) - - expect(result.tools).toHaveLength(1) - expect(vi.mocked(authenticate)).toHaveBeenCalledWith("my-server", "https://example.com/mcp", serverEntry) - expect(manager.probeTools).toHaveBeenCalledTimes(1) - }) - - it("still authenticates when skipAuth is omitted", async () => { - const serverEntry: ServerEntry = { url: "https://example.com/mcp" } - const probeResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - vi.mocked(supportsOAuth).mockReturnValue(true) - vi.mocked(getAuthStatus).mockResolvedValue("not_authenticated") - vi.mocked(authenticate).mockResolvedValue("authenticated" as never) - - const agent = makeAgent(manager) - await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: serverEntry, - serverName: "my-server", - }) - - expect(vi.mocked(authenticate)).toHaveBeenCalledWith("my-server", "https://example.com/mcp", serverEntry) - }) -}) - -describe("KimchiAcpAgent extMethod probe_mcp_server validation", () => { - it("rejects non-object server param", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) + it("rejects unknown extension methods", async () => { await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: "not-an-object" }), + createAgent(createProbe({ tools: [], needsAuth: false, error: null })).extMethod("unknown", {}), ).rejects.toMatchObject({ - code: -32602, + code: -32601, }) - await expect(agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: null })).rejects.toMatchObject({ - code: -32602, - }) - await expect(agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: [] })).rejects.toMatchObject({ - code: -32602, - }) - }) - - it("rejects server without command or url", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect(agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: {} })).rejects.toMatchObject({ - code: -32602, - }) - }) - - it("rejects non-string command", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: 123 } }), - ).rejects.toMatchObject({ code: -32602 }) - }) - - it("rejects non-array args", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: "echo", args: "not-array" } }), - ).rejects.toMatchObject({ code: -32602 }) - }) - - it("rejects non-string elements in args", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: "echo", args: ["ok", 42] } }), - ).rejects.toMatchObject({ code: -32602 }) - }) - - it("rejects env with non-string values", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - await expect( - agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { server: { command: "echo", env: { KEY: 123 } } }), - ).rejects.toMatchObject({ code: -32602 }) - }) - - it("accepts a valid stdio server entry", async () => { - const probeResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: { command: "echo", args: ["hello"], env: { FOO: "bar" } }, - }) - expect(result).toEqual(probeResult) - }) - - it("accepts a valid URL server entry", async () => { - const probeResult: ProbeResult = { tools: [{ name: "tool1" }], needsAuth: false, error: null } - const manager = makeFakeMcpServerManager(probeResult) - const agent = makeAgent(manager) - const result = await agent.extMethod(AVAILABLE_EXT_METHODS.probe_mcp_server, { - server: { url: "https://mcp.example.com/sse" }, - }) - expect(result).toEqual(probeResult) - }) -}) - -describe("probe_mcp_server capability advertisement", () => { - it("advertises probe_mcp_server in initialize response", async () => { - const agent = makeAgent(makeFakeMcpServerManager({ tools: [], needsAuth: false, error: null })) - const response = await agent.initialize({ protocolVersion: 1 }) - const meta = response.agentCapabilities?._meta?.["kimchi.dev"] as Record | undefined - expect(meta?.probe_mcp_server).toBe(true) }) }) diff --git a/src/modes/acp/server.test.ts b/src/modes/acp/server.test.ts index c86b0c5fd..066bce743 100644 --- a/src/modes/acp/server.test.ts +++ b/src/modes/acp/server.test.ts @@ -4,6 +4,8 @@ import { join, resolve } from "node:path" import type { AgentSideConnection, ListSessionsRequest, + LoadSessionRequest, + NewSessionRequest, RequestPermissionRequest, SessionNotification, TextContent, @@ -63,7 +65,6 @@ import { clearApiKey, writeApiKey } from "../../config.js" import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" import { PARENT_SESSION_ID_ENV_KEY } from "../../extensions/agents/manager/constants.js" import { setProcessOrchestratorRef } from "../../extensions/kimchi-process.js" -import { clearCallerMcpServers, peekCallerMcpServers } from "../../extensions/mcp-adapter/caller-servers.js" import { getMultiModelEnabled, setMultiModelEnabled } from "../../extensions/multi-model.js" import { PERMISSION_MODES, PERMISSIONS_ENV_KEY } from "../../extensions/permissions/constants.js" import { PERMISSION_MODE_SESSION_ENTRY_TYPE } from "../../extensions/permissions/mode.js" @@ -101,7 +102,6 @@ function cleanPermissionEnv(): void { // Reset the CLI args cache so permission mode flags (--plan/--auto/--yolo) // set by one test don't leak into another via the module-level cache. populateCliArgs([]) - clearCallerMcpServers() } beforeEach(cleanPermissionEnv) @@ -1669,14 +1669,12 @@ describe("KimchiAcpAgent turn lifecycle", () => { await localAgent.shutdown() }) - // mcpServers is now accepted per the ACP v1 spec. The caller-supplied - // servers are pushed onto the caller-servers registry and consumed by - // initializeMcp during session_start. This test verifies the session is - // created successfully (factory called) and the servers land in the registry. - it("accepts newSession with non-empty mcpServers and registers them", async () => { + it("passes newSession MCP servers to the session factory", async () => { const factoryCalled = { count: 0 } - const factory: AcpSessionFactory = async () => { + let receivedServers: NewSessionRequest["mcpServers"] | undefined + const factory: AcpSessionFactory = async (params) => { factoryCalled.count++ + receivedServers = params.mcpServers return asSession(new FakeAgentSession("with-mcp")) } const localAgent = new KimchiAcpAgent(makeConn(), { @@ -1684,19 +1682,14 @@ describe("KimchiAcpAgent turn lifecycle", () => { agentDir: "/tmp/fake-agent-dir", sessionFactory: factory, }) - // Clear any stale entries from beforeEach's newSession call so peek - // returns only this test's entry. - clearCallerMcpServers() + const mcpServers: NewSessionRequest["mcpServers"] = [{ name: "x", command: "x", args: [], env: [] }] const res = await localAgent.newSession({ cwd: "/tmp", - // biome-ignore lint/suspicious/noExplicitAny: only the shape we care about - mcpServers: [{ name: "x", command: "x", args: [], env: [] } as any], + mcpServers, }) expect(res.sessionId).toBe("with-mcp") expect(factoryCalled.count).toBe(1) - // The FakeAgentSession doesn't trigger real session_start/initializeMcp, - // so the caller-servers entry stays in the registry — verify it was set. - expect(peekCallerMcpServers("with-mcp")).toEqual({ x: { command: "x", args: [] } }) + expect(receivedServers).toEqual(mcpServers) }) // Empty array is fine — equivalent to "no per-session servers requested". @@ -5951,21 +5944,21 @@ describe("KimchiAcpAgent loadSession", () => { it("accepts loadSession with non-empty mcpServers (invokes loader)", async () => { const loaderCalls = { count: 0 } - const loader: AcpSessionLoader = async () => { + let receivedServers: LoadSessionRequest["mcpServers"] | undefined + const loader: AcpSessionLoader = async (params) => { loaderCalls.count++ + receivedServers = params.mcpServers return asSession(new FakeAgentSession("s1")) } const agent = makeAgent(loader) + const mcpServers: LoadSessionRequest["mcpServers"] = [{ name: "x", command: "x", args: [], env: [] }] await agent.loadSession({ sessionId: "s1", cwd: "/tmp", - // biome-ignore lint/suspicious/noExplicitAny: only the shape we care about - mcpServers: [{ name: "x", command: "x", args: [], env: [] } as any], + mcpServers, }) expect(loaderCalls.count).toBe(1) - // The FakeAgentSession doesn't trigger real session_start/initializeMcp, - // so the caller-servers entry stays in the registry — verify it was set. - expect(peekCallerMcpServers("s1")).toEqual({ x: { command: "x", args: [] } }) + expect(receivedServers).toEqual(mcpServers) }) it("replays and returns an already loaded session without reopening it", async () => { diff --git a/src/modes/acp/server.ts b/src/modes/acp/server.ts index 452e28c55..7361b86cc 100644 --- a/src/modes/acp/server.ts +++ b/src/modes/acp/server.ts @@ -26,6 +26,7 @@ import { type LoadSessionResponse, type LogoutRequest, type LogoutResponse, + type McpServer, type NewSessionRequest, type NewSessionResponse, ndJsonStream, @@ -64,10 +65,9 @@ import { authenticateViaBrowser } from "../../cli-auth/index.js" import { clearApiKey, writeApiKey } from "../../config.js" import { defaultFermentRuntime } from "../../extensions/ferment/runtime.js" import { KIMCHI_PROVIDER_ID } from "../../extensions/login/flow.js" -import { convertAcpMcpServers } from "../../extensions/mcp-adapter/acp-mcp-convert.js" -import { removePendingEntry, setCallerMcpServers } from "../../extensions/mcp-adapter/caller-servers.js" -import type { McpServerManager } from "../../extensions/mcp-adapter/server-manager.js" -import type { ProbeResult } from "../../extensions/mcp-adapter/types.js" +import { convertAcpMcpServers } from "../../extensions/mcp/acp-config.js" +import type { KimchiMcpAdapterExtensionOptions } from "../../extensions/mcp/index.js" +import type { McpProbe, ProbeResult } from "../../extensions/mcp/probe.js" import { refFromModel, splitModelRef } from "../../extensions/model-catalog/ref-utils.js" import { getMultiModelEnabled, setMultiModelEnabled } from "../../extensions/multi-model.js" import { getOrchestratorModel } from "../../extensions/orchestration/model-roles.js" @@ -154,6 +154,8 @@ export type AcpSessionLoader = (params: LoadSessionRequest) => Promise ExtensionFactory /** * Content of the `--append-system-prompt` CLI flag, forwarded verbatim to * every session's DefaultResourceLoader. When a client also sends @@ -167,11 +169,10 @@ export interface RunAcpOptions { /** Override for tests. Defaults to {@link defaultSessionLoader}. */ sessionLoader?: AcpSessionLoader /** - * MCP server manager used by the `_kimchi.dev/probe_mcp_server` extMethod - * handler to create transient probe connections. Injected so tests can stub - * it; production code constructs a real McpServerManager. + * Isolated MCP probe used by `_kimchi.dev/probe_mcp_server`. Injected so + * tests can stub it; production uses the upstream-adapter-backed probe. */ - mcpServerManager?: McpServerManager + mcpProbe?: McpProbe } /** @@ -356,7 +357,7 @@ export class KimchiAcpAgent implements Agent { private readonly agentDir: string private readonly sessionLister: AcpSessionLister private readonly sessionLoader: AcpSessionLoader - private readonly mcpServerManager: McpServerManager | undefined + private readonly mcpProbe: McpProbe | undefined private readonly permissionsEnvFlag = process.env[PERMISSIONS_ENV_KEY] private clientCapabilities: ClientCapabilities | undefined // Track non-text prompt block types we've already warned about so a @@ -391,7 +392,7 @@ export class KimchiAcpAgent implements Agent { this.agentDir = options.agentDir this.sessionLister = options.sessionLister ?? defaultSessionLister(options) this.sessionLoader = options.sessionLoader ?? defaultSessionLoader(options) - this.mcpServerManager = options.mcpServerManager + this.mcpProbe = options.mcpProbe } async initialize(request: InitializeRequest): Promise { @@ -451,7 +452,7 @@ export class KimchiAcpAgent implements Agent { _meta: { [CAPABILITIES_KEY]: { ...ADVERTISED_CAPABILITIES, - ...(this.mcpServerManager ? {} : { probe_mcp_server: false }), + ...(this.mcpProbe ? {} : { probe_mcp_server: false }), }, }, }, @@ -542,7 +543,6 @@ export class KimchiAcpAgent implements Agent { try { // Caller-supplied MCP servers, keyed by sessionId so concurrent // sessions can't consume each other's entries. - setCallerMcpServers(session.sessionId, convertAcpMcpServers(params.mcpServers ?? [])) const initialMode = this.getInitialPermissionMode(session) assertSessionHasModel(session) @@ -585,7 +585,6 @@ export class KimchiAcpAgent implements Agent { models: buildSessionModelState(configOptions), } } catch (err) { - removePendingEntry(session.sessionId) unregisterAcpPrompter(session.sessionId) unregisterSessionPermissionFlagController(session.sessionId) clearPermissionModeEnv(session.sessionId) @@ -803,7 +802,6 @@ export class KimchiAcpAgent implements Agent { `session header id ${sessionId} does not match requested sessionId ${params.sessionId}`, ) } - setCallerMcpServers(sessionId, convertAcpMcpServers(params.mcpServers ?? [])) assertSessionHasModel(loadedSession) const uiContext = this.createUiContext(loadedSession) @@ -860,7 +858,6 @@ export class KimchiAcpAgent implements Agent { models: buildSessionModelState(configOptions), } } catch (err) { - removePendingEntry(sessionId) unregisterAcpPrompter(sessionId) unregisterSessionPermissionFlagController(sessionId) clearPermissionModeEnv(sessionId) @@ -1006,7 +1003,7 @@ export class KimchiAcpAgent implements Agent { async extMethod(method: string, params: Record): Promise> { switch (method) { case AVAILABLE_EXT_METHODS.probe_mcp_server: { - const result = await handleProbeMcpServer(this.mcpServerManager, params) + const result = await handleProbeMcpServer(this.mcpProbe, params) return result as Record } case AVAILABLE_EXT_METHODS.set_session_title: @@ -1946,7 +1943,11 @@ function defaultSessionLister(options: RunAcpOptions): AcpSessionLister { * timeout, and load resources. Both the session loader and factory * diverge only in how they obtain a SessionManager. */ -async function createSessionSettings(cwd: string, options: RunAcpOptions, params: { _meta?: unknown }) { +async function createSessionSettings( + cwd: string, + options: RunAcpOptions, + params: { _meta?: unknown; mcpServers?: ReadonlyArray }, +) { // Construct untrusted first: pi's SettingsManager.create defaults // projectTrusted to TRUE, which would let an untrusted repo's // .pi/settings.json influence HTTP behavior (e.g. disable the idle @@ -1967,11 +1968,13 @@ async function createSessionSettings(cwd: string, options: RunAcpOptions, params // every turn's system prompt rebuild. Built lazily on first access; errors // during loader access fall back to an empty block. let cachedSkillListBlock: string | undefined + const callerServers = convertAcpMcpServers(params.mcpServers ?? []) + const mcpExtension = options.mcpExtensionFactory?.({ cwd, callerServers }) const resourceLoader = new DefaultResourceLoader({ cwd, agentDir: options.agentDir, settingsManager, - extensionFactories: options.extensionFactories, + extensionFactories: [...options.extensionFactories, ...(mcpExtension ? [mcpExtension] : [])], appendSystemPromptOverride: () => { if (cachedSkillListBlock === undefined) { try { diff --git a/src/setup-wizard.ts b/src/setup-wizard.ts index df872d300..a8e93a48f 100644 --- a/src/setup-wizard.ts +++ b/src/setup-wizard.ts @@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFile import { homedir } from "node:os" import { basename, dirname, join } from "node:path" import * as clack from "@clack/prompts" +import type { ServerEntry } from "pi-mcp-adapter/types" import { AGENT_DEFINITIONS, type AgentDiscovery, discoverAgent } from "./agent-discovery/index.js" import { buildSkillPathOptions, getAgentConfigDir } from "./config.js" -import type { ServerEntry } from "./extensions/mcp-adapter/types.js" export type MigrationState = "done" | "skip-forever" diff --git a/src/shared/planning/read-only-tool-registry.test.ts b/src/shared/planning/read-only-tool-registry.test.ts index 35b2c3677..3709f60b1 100644 --- a/src/shared/planning/read-only-tool-registry.test.ts +++ b/src/shared/planning/read-only-tool-registry.test.ts @@ -1,6 +1,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { describe, expect, it, vi } from "vitest" +import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" import { getReadOnlyToolNames, registerReadOnlyToolProvider } from "./read-only-tool-registry.js" /** @@ -8,17 +9,12 @@ import { getReadOnlyToolNames, registerReadOnlyToolProvider } from "./read-only- * WeakMap keys are distinct per test — providers registered in one test never * leak into another, even without an explicit clear(). */ -const makeMockPi = (): ExtensionAPI => { +const makeMockPi = (events = createMiniEventBus().events): ExtensionAPI => { const on = vi.fn() - return { on } as unknown as ExtensionAPI + return { events, on } as unknown as ExtensionAPI } describe("read-only-tool-registry", () => { - beforeEach(() => { - // Each test constructs its own mock pi, so the WeakMap starts empty for - // that key. No module-level reset is required. - }) - it("returns an empty array when no providers are registered", () => { const pi = makeMockPi() @@ -68,6 +64,15 @@ describe("read-only-tool-registry", () => { expect(getReadOnlyToolNames(piB)).toEqual([]) }) + it("shares providers across extension wrappers in the same session", () => { + const events = createMiniEventBus().events + const providerApi = makeMockPi(events) + const consumerApi = makeMockPi(events) + registerReadOnlyToolProvider(providerApi, () => ["server_get_record"]) + + expect(getReadOnlyToolNames(consumerApi)).toEqual(["server_get_record"]) + }) + it("registers a session_shutdown listener on first registration", () => { const pi = makeMockPi() registerReadOnlyToolProvider(pi, () => ["server_get_record"]) diff --git a/src/shared/planning/read-only-tool-registry.ts b/src/shared/planning/read-only-tool-registry.ts index 81388f523..3b21bf1aa 100644 --- a/src/shared/planning/read-only-tool-registry.ts +++ b/src/shared/planning/read-only-tool-registry.ts @@ -7,24 +7,25 @@ * to union these names into the active set during scoping — the only profile * where write tools are blocked by default. * - * The registry is keyed on the pi-mono `ExtensionAPI` instance via a WeakMap, - * mirroring the pattern in `tool-visibility.ts`. Providers are cleared - * automatically when the session shuts down (the `pi` reference is GC'd). + * The registry is keyed on a session identity shared through pi-mono's event + * bus. Each extension receives a distinct `ExtensionAPI` wrapper, so the + * wrapper itself cannot be used for cross-extension state. * * ## Why a registry? * - * The shared/planning layer must not import from `src/extensions/mcp-adapter` - * directly (that would invert the dependency). Instead, the mcp-adapter - * extension registers a provider at init time; `applyCore` calls + * The shared/planning layer must not import from `src/extensions/mcp` + * directly (that would invert the dependency). Instead, the MCP adapter + * wrapper registers a provider at init time; `applyCore` calls * `getReadOnlyToolNames` without knowing which extensions contributed. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { getToolSessionScope } from "./tool-session-scope.js" /** A function that returns the current set of read-only-qualified tool names. */ export type ReadOnlyToolProvider = () => string[] -let providersByPi = new WeakMap() +let providersByScope = new WeakMap() /** * Register a read-only-tool provider for the given session. @@ -39,14 +40,15 @@ let providersByPi = new WeakMap() * it always reflects the current tool-metadata state. */ export function registerReadOnlyToolProvider(pi: ExtensionAPI, provider: ReadOnlyToolProvider): void { - let providers = providersByPi.get(pi) + const scope = getToolSessionScope(pi) + let providers = providersByScope.get(scope) if (!providers) { providers = [] - providersByPi.set(pi, providers) + providersByScope.set(scope, providers) // Clean up on session shutdown. We never touch `pi` from inside the // handler — pi-mono marks the runtime stale at this point. pi.on("session_shutdown", () => { - providersByPi.delete(pi) + providersByScope.delete(scope) }) } if (providers.includes(provider)) return @@ -61,7 +63,7 @@ export function registerReadOnlyToolProvider(pi: ExtensionAPI, provider: ReadOnl * @param pi - The pi-mono `ExtensionAPI` instance for this session. */ export function getReadOnlyToolNames(pi: ExtensionAPI): string[] { - const providers = providersByPi.get(pi) + const providers = providersByScope.get(getToolSessionScope(pi)) if (!providers || providers.length === 0) return [] const seen = new Set() const result: string[] = [] @@ -94,5 +96,5 @@ export function getReadOnlyToolNames(pi: ExtensionAPI): string[] { * @internal — test-only. */ export function resetReadOnlyToolRegistry(): void { - providersByPi = new WeakMap() + providersByScope = new WeakMap() } diff --git a/src/shared/planning/tool-profile-manager.test.ts b/src/shared/planning/tool-profile-manager.test.ts index e15cf1e06..c317352b3 100644 --- a/src/shared/planning/tool-profile-manager.test.ts +++ b/src/shared/planning/tool-profile-manager.test.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { beforeEach, describe, expect, it, vi } from "vitest" - +import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" import { createToolVisibility } from "../../extensions/prompt-construction/tool-visibility.js" import { registerReadOnlyToolProvider, resetReadOnlyToolRegistry } from "./read-only-tool-registry.js" import { getToolsForProfile } from "./tool-catalog.js" @@ -35,7 +35,7 @@ const makeMockPi = ( on, getAllTools, getActiveTools, - events: overrides.events, + events: overrides.events ?? createMiniEventBus().events, } as unknown as ExtensionAPI } @@ -370,11 +370,10 @@ describe("reapplyCurrentProfile", () => { describe("read-only MCP filter integration (planning-ferment vs implementation-ferment)", () => { // These tests verify the registry + profile-manager behaviour (union, // inclusion, exclusion) using pre-filtered fixture arrays. They do NOT - // exercise `isReadOnlyMcpTool` — that predicate lives in - // src/extensions/mcp-adapter/tool-metadata.ts and is covered by - // src/extensions/mcp-adapter/tool-metadata.test.ts. Coupling to it here - // would invert the dependency direction (shared/planning must not import - // from src/extensions/mcp-adapter). + // exercise protocol-annotation capture — that policy lives in + // src/extensions/mcp/annotation-catalog.ts and is covered by its co-located + // tests. Coupling to it here would invert the dependency direction + // (shared/planning must not import from src/extensions/mcp). // // Fixture: three MCP tools behind a server. Only `server_get_record` is // read-only-qualified (annotated with readOnlyHint:true). diff --git a/src/shared/planning/tool-profile-manager.ts b/src/shared/planning/tool-profile-manager.ts index 594c561b1..048de1a7a 100644 --- a/src/shared/planning/tool-profile-manager.ts +++ b/src/shared/planning/tool-profile-manager.ts @@ -38,6 +38,7 @@ import { isFermentOnlyToolName } from "../../extensions/ferment/tool-names.js" import { getDisabledToolNames } from "../../extensions/prompt-construction/tool-visibility.js" import { getReadOnlyToolNames } from "./read-only-tool-registry.js" import { getToolsForProfile, isAdhocOnlyToolName, type ToolProfile } from "./tool-catalog.js" +import { getToolSessionScope } from "./tool-session-scope.js" // --------------------------------------------------------------------------- // Module-level state @@ -66,7 +67,7 @@ let turnListenerInstalled = false * after registration, and the snapshot itself was computed before init * finished so `getReadOnlyToolNames` returned `[]`. */ -let lastProfileByPi = new WeakMap() +let lastProfileByScope = new WeakMap() // --------------------------------------------------------------------------- // Public API @@ -153,7 +154,7 @@ export function applyCore(profile: ToolProfile, pi: ExtensionAPI): void { pi.setActiveTools(allowedNames) snapshotAppliedThisTurn = true - lastProfileByPi.set(pi, profile) + lastProfileByScope.set(getToolSessionScope(pi), profile) } /** @@ -210,7 +211,7 @@ export function resetSnapshotFlag(): void { export function resetAll(): void { snapshotAppliedThisTurn = false turnListenerInstalled = false - lastProfileByPi = new WeakMap() + lastProfileByScope = new WeakMap() } /** @@ -278,8 +279,13 @@ export function installTurnBoundaryReset(pi: ExtensionAPI): void { * stored for this session. */ export function reapplyCurrentProfile(pi: ExtensionAPI): boolean { - const profile = lastProfileByPi.get(pi) + const profile = lastProfileByScope.get(getToolSessionScope(pi)) if (!profile) return false applyCore(profile, pi) return true } + +/** Return the active snapshot profile for extension-level policy checks. */ +export function getCurrentProfile(pi: ExtensionAPI): ToolProfile | undefined { + return lastProfileByScope.get(getToolSessionScope(pi)) +} diff --git a/src/shared/planning/tool-session-scope.test.ts b/src/shared/planning/tool-session-scope.test.ts new file mode 100644 index 000000000..2ef89d57f --- /dev/null +++ b/src/shared/planning/tool-session-scope.test.ts @@ -0,0 +1,21 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { describe, expect, it } from "vitest" +import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" +import { getToolSessionScope } from "./tool-session-scope.js" + +describe("getToolSessionScope", () => { + it("shares an identity across extension APIs on the same event bus", () => { + const { events } = createMiniEventBus() + const first = { events } as unknown as ExtensionAPI + const second = { events } as unknown as ExtensionAPI + + expect(getToolSessionScope(second)).toBe(getToolSessionScope(first)) + }) + + it("does not share identities across sessions", () => { + const first = { events: createMiniEventBus().events } as unknown as ExtensionAPI + const second = { events: createMiniEventBus().events } as unknown as ExtensionAPI + + expect(getToolSessionScope(second)).not.toBe(getToolSessionScope(first)) + }) +}) diff --git a/src/shared/planning/tool-session-scope.ts b/src/shared/planning/tool-session-scope.ts new file mode 100644 index 000000000..d85c4aa0e --- /dev/null +++ b/src/shared/planning/tool-session-scope.ts @@ -0,0 +1,26 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +const TOOL_SESSION_SCOPE_EVENT = "kimchi:planning-tool-session-scope:v1" + +interface ScopeRequest { + scope?: object +} + +/** + * Resolve one identity shared by every ExtensionAPI wrapper in a session. + * pi-mono creates a distinct wrapper per extension, but all wrappers publish + * synchronously through the same session event bus. + */ +export function getToolSessionScope(pi: ExtensionAPI): object { + const request: ScopeRequest = {} + pi.events.emit(TOOL_SESSION_SCOPE_EVENT, request) + if (request.scope) return request.scope + + const scope = {} + pi.events.on(TOOL_SESSION_SCOPE_EVENT, (candidate: unknown) => { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return + const scopeRequest = candidate as ScopeRequest + scopeRequest.scope ??= scope + }) + return scope +} diff --git a/tests/e2e/mcp/fixture-server.mjs b/tests/e2e/mcp/fixture-server.mjs index 60310077e..678608a6d 100644 --- a/tests/e2e/mcp/fixture-server.mjs +++ b/tests/e2e/mcp/fixture-server.mjs @@ -20,6 +20,7 @@ const scenario = process.env.KIMCHI_MCP_FIXTURE_SCENARIO ?? "basic" const transportKind = process.env.KIMCHI_MCP_FIXTURE_TRANSPORT ?? "stdio" const expectedBearerToken = process.env.KIMCHI_MCP_FIXTURE_BEARER_TOKEN const oauthEnabled = process.env.KIMCHI_MCP_FIXTURE_OAUTH === "1" +const oauthPreauthorized = process.env.KIMCHI_MCP_FIXTURE_OAUTH_PREAUTHORIZED === "1" const oauthGrantType = process.env.KIMCHI_MCP_FIXTURE_OAUTH_GRANT_TYPE ?? "authorization_code" const oauthClientId = process.env.KIMCHI_MCP_FIXTURE_OAUTH_CLIENT_ID ?? "kimchi-e2e-client" const oauthClientSecret = process.env.KIMCHI_MCP_FIXTURE_OAUTH_CLIENT_SECRET ?? "kimchi-e2e-client-secret" @@ -102,6 +103,7 @@ function createFixtureServer() { description: "Wait for cancellation or a bounded delay", inputSchema: { type: "object", properties: {}, additionalProperties: false }, }, + ...(fixtureBehavior.catalogTools ?? []), ...uiTools, ], } @@ -207,7 +209,7 @@ function sendJson(response, statusCode, body) { async function runHttpFixture() { const sessions = new Map() const authorizationCodes = new Map() - let oauthAccessTokenExpiresAt = 0 + let oauthAccessTokenExpiresAt = oauthPreauthorized ? Date.now() + 3_600_000 : 0 let origin = "" const httpServer = createServer(async (request, response) => { try { diff --git a/tests/e2e/tui/mcp-failures.test.ts b/tests/e2e/tui/mcp-failures.test.ts index 8f0a01cb3..fa296c00d 100644 --- a/tests/e2e/tui/mcp-failures.test.ts +++ b/tests/e2e/tui/mcp-failures.test.ts @@ -133,11 +133,7 @@ test("completes a bounded slow MCP call without hanging the session", async ({ t ) }) -// Known product bug: the MCP gateway tool receives Pi's AbortSignal but currently ignores -// it, so cancelling an agent turn does not send MCP notifications/cancelled to the server. -// Fixed upstream in pi-mcp-adapter v2.11.0 by PR #159; remove test.fail once the bundled -// adapter includes that fix. -test.fail("propagates agent-turn cancellation to an in-flight MCP request", async ({ terminal }) => { +test("propagates agent-turn cancellation to an in-flight MCP request", async ({ terminal }) => { const slow = gatewayMcpCall("slow") await runMcpKimchiSession( terminal, diff --git a/tests/e2e/tui/mcp-lifecycle.test.ts b/tests/e2e/tui/mcp-lifecycle.test.ts index a4ee4fb6f..22425a2a1 100644 --- a/tests/e2e/tui/mcp-lifecycle.test.ts +++ b/tests/e2e/tui/mcp-lifecycle.test.ts @@ -45,9 +45,7 @@ test("starts a cached lazy MCP server only when a tool is called", async ({ term const beforeRestart = fixture.mcp.checkpoint() await session.restart() - terminal.write("/mcp tools") - await waitForText(terminal, "/mcp tools", { timeoutMs: STREAM_TIMEOUT_MS }) - terminal.submit("") + terminal.submit("/mcp tools") await waitForText(terminal, "MCP Tools:", { timeoutMs: STREAM_TIMEOUT_MS }) expect( fixture.mcp @@ -110,9 +108,7 @@ test("recovers a crashed MCP server through the reconnect command", async ({ ter }) const afterCrash = fixture.mcp.checkpoint() - terminal.write("/mcp reconnect fixture") - await waitForText(terminal, "/mcp reconnect fixture", { timeoutMs: STREAM_TIMEOUT_MS }) - terminal.submit("") + terminal.submit("/mcp reconnect fixture") await waitForText(terminal, "MCP: Reconnected to fixture", { timeoutMs: STREAM_TIMEOUT_MS }) const recoveryEvents = fixture.mcp.readEvents().slice(afterCrash) expect(recoveryEvents.filter((event) => event.type === "process_started")).toHaveLength(1) @@ -194,10 +190,7 @@ test("single-flights concurrent calls that start a cached lazy MCP server", asyn ) }) -// Fixed upstream in pi-mcp-adapter v2.12.0 by PR #194's client-close state handling. -// Remove test.fail once the bundled adapter includes that fix; the current adapter cannot -// reconnect after a stdio process exit. -test.fail("reconnects a keep-alive MCP server after its process crashes", async ({ terminal }) => { +test("reconnects a keep-alive MCP server after its process crashes", async ({ terminal }) => { const disconnect = gatewayMcpCall("disconnect") const afterRecovery = gatewayMcpCall("echo", { message: "keep-alive-recovered" }) const disconnectExitCode = 17 diff --git a/tests/e2e/tui/mcp-oauth.test.ts b/tests/e2e/tui/mcp-oauth.test.ts index a6ff8e296..63ea46075 100644 --- a/tests/e2e/tui/mcp-oauth.test.ts +++ b/tests/e2e/tui/mcp-oauth.test.ts @@ -1,11 +1,83 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" import { expect, test } from "@microsoft/tui-test" import { STREAM_TIMEOUT_MS, waitForText } from "./support/assertions.js" import { runMcpKimchiSession, runRestartableMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" -import { mcpToolResult } from "./support/mcp-fixture.js" +import { MCP_FIXTURE_OAUTH_ACCESS_TOKEN, mcpToolResult } from "./support/mcp-fixture.js" import { gatewayMcpCall, modelReply, toolResultText } from "./support/mcp-model-script.js" test.use(TUI_TEST_CONFIG) +test("migrates legacy plaintext OAuth credentials before the first connection", async ({ terminal }) => { + const echo = gatewayMcpCall("echo", { message: "legacy-oauth-migration" }) + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-oauth-legacy-migration", + mcp: { + transport: "oauth", + oauthPreauthorized: true, + behavior: { + tools: [ + mcpToolResult( + "echo", + { content: [{ type: "text", text: "fixture echo: legacy-oauth-migration" }] }, + { message: "legacy-oauth-migration" }, + ), + ], + }, + }, + responses: [echo.response, modelReply("The migrated OAuth credential worked without logging in again.")], + seedHome(homeDir) { + const agentDir = join(homeDir, ".config", "kimchi", "harness") + const config = JSON.parse(readFileSync(join(agentDir, "mcp.json"), "utf8")) as { + mcpServers?: { fixture?: { url?: unknown } } + } + const serverUrl = config.mcpServers?.fixture?.url + if (typeof serverUrl !== "string") throw new Error("OAuth fixture config is missing its URL") + const legacyPath = join(agentDir, "mcp-oauth", "fixture", "tokens.json") + mkdirSync(dirname(legacyPath), { recursive: true }) + writeFileSync( + legacyPath, + JSON.stringify({ + tokens: { accessToken: MCP_FIXTURE_OAUTH_ACCESS_TOKEN, expiresAt: 2_000_000_000 }, + clientInfo: { + clientId: "kimchi-e2e-oauth-client", + clientIdIssuedAt: Math.floor(Date.now() / 1_000), + }, + serverUrl, + }), + { mode: 0o600 }, + ) + }, + }, + async (fixture, trace) => { + await fixture.mcp.waitForEvent("http_session_initialized", { + description: "MCP connection using migrated OAuth credentials", + }) + const legacyPath = join(fixture.agentDir, "mcp-oauth", "fixture", "tokens.json") + expect(existsSync(legacyPath)).toBe(true) + expect(existsSync(join(dirname(legacyPath), ".pi-mcp-adapter-migrated"))).toBe(true) + const keyringDir = join(fixture.agentDir, "mcp-keyring") + const keyringPayloads = readdirSync(keyringDir).map((name) => readFileSync(join(keyringDir, name), "utf8")) + expect(keyringPayloads.some((payload) => payload.includes(MCP_FIXTURE_OAUTH_ACCESS_TOKEN))).toBe(true) + + terminal.submit("Call MCP using the credential stored by the previous Kimchi adapter") + await waitForText(terminal, "The migrated OAuth credential worked without logging in again.", { + timeoutMs: STREAM_TIMEOUT_MS, + }) + await fixture.mcp.waitForEvent("tool_called", { + where: { name: "echo", arguments: { message: "legacy-oauth-migration" } }, + }) + + expect(fixture.mcp.hasEvent("oauth_browser_opened")).toBe(false) + expect(fixture.mcp.hasEvent("oauth_token_issued")).toBe(false) + expect(toolResultText(fixture.fake.requests, echo)).toContain("fixture echo: legacy-oauth-migration") + trace.step("legacy plaintext credentials moved into and loaded from the upstream secure store") + }, + ) +}) + test("logs into an HTTP MCP server with OAuth authorization code and PKCE", async ({ terminal }) => { const echo = gatewayMcpCall("echo", { message: "oauth-login" }) await runMcpKimchiSession( @@ -33,17 +105,13 @@ test("logs into an HTTP MCP server with OAuth authorization code and PKCE", asyn trace.step("protected MCP endpoint challenged the unauthenticated client") terminal.submit("/mcp-auth fixture") - await waitForText(terminal, 'OAuth authentication successful for "fixture"!', { - timeoutMs: STREAM_TIMEOUT_MS, - }) await fixture.mcp.waitForEvent("oauth_token_issued", { where: { grantType: "authorization_code", pkceVerified: true }, description: "OAuth token exchange with verified PKCE", }) + await waitForText(terminal, "MCP: Reconnected to fixture", { timeoutMs: STREAM_TIMEOUT_MS }) trace.step("browser redirect, callback, and authorization-code exchange completed") - terminal.submit("/mcp reconnect fixture") - await waitForText(terminal, "MCP: Reconnected to fixture", { timeoutMs: STREAM_TIMEOUT_MS }) terminal.submit("Call the OAuth-protected MCP echo tool") await waitForText(terminal, "The OAuth-authenticated MCP tool returned successfully.", { timeoutMs: STREAM_TIMEOUT_MS, @@ -226,13 +294,9 @@ test("refreshes an expired MCP OAuth token after a real Kimchi process restart", }, async (fixture, session, trace) => { terminal.submit("/mcp-auth fixture") - await waitForText(terminal, 'OAuth authentication successful for "fixture"!', { - timeoutMs: STREAM_TIMEOUT_MS, - }) const initialToken = await fixture.mcp.waitForEvent("oauth_token_issued", { where: { grantType: "authorization_code", pkceVerified: true }, }) - terminal.submit("/mcp reconnect fixture") await waitForText(terminal, "MCP: Reconnected to fixture", { timeoutMs: STREAM_TIMEOUT_MS }) await session.turn("Call MCP before restarting", "The first OAuth MCP call succeeded.") expect(toolResultText(fixture.fake.requests, beforeRestart)).toContain("fixture echo: before-oauth-restart") diff --git a/tests/e2e/tui/mcp-panel.test.ts b/tests/e2e/tui/mcp-panel.test.ts index 086d981ae..6c6687a46 100644 --- a/tests/e2e/tui/mcp-panel.test.ts +++ b/tests/e2e/tui/mcp-panel.test.ts @@ -38,7 +38,7 @@ test("persists a direct-tool choice from the MCP panel and applies it after rest terminal.keyDown() terminal.keyPress(" ") terminal.keyPress("s", { ctrl: true }) - await waitForText(terminal, "Saved", { timeoutMs: STREAM_TIMEOUT_MS }) + await waitForText(terminal, "Direct tools updated for this session.", { timeoutMs: STREAM_TIMEOUT_MS }) trace.step("echo toggled to direct and saved") const saved = JSON.parse(readFileSync(fixture.mcp.configPath, "utf-8")) as { @@ -46,7 +46,6 @@ test("persists a direct-tool choice from the MCP panel and applies it after rest } expect(saved.mcpServers?.fixture?.directTools).toEqual(["echo"]) - terminal.keyEscape() await waitForText(terminal, PROMPT_READY, { timeoutMs: STREAM_TIMEOUT_MS }) await session.restart() await session.turn( diff --git a/tests/e2e/tui/mcp-restart.test.ts b/tests/e2e/tui/mcp-restart.test.ts index c56346fde..a1817fd94 100644 --- a/tests/e2e/tui/mcp-restart.test.ts +++ b/tests/e2e/tui/mcp-restart.test.ts @@ -1,8 +1,8 @@ import { readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" import { expect, test } from "@microsoft/tui-test" -import type { MetadataCache } from "../../../src/extensions/mcp-adapter/metadata-cache.js" -import type { McpConfig } from "../../../src/extensions/mcp-adapter/types.js" +import type { MetadataCache } from "pi-mcp-adapter/metadata-cache" +import type { McpConfig } from "pi-mcp-adapter/types" import { runRestartableMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" import { mcpToolResult } from "./support/mcp-fixture.js" import { @@ -106,6 +106,11 @@ test("invalidates cached MCP metadata when the server config changes", async ({ where: { name: "echo", arguments: { message: "warm-config-cache" } }, }) + const cachePath = join(fixture.agentDir, "mcp-cache.json") + const cacheBeforeChange = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache + const hashBeforeChange = cacheBeforeChange.servers.fixture?.configHash + expect(hashBeforeChange).toBeDefined() + const config = JSON.parse(readFileSync(fixture.mcp.configPath, "utf-8")) as McpConfig const definition = config.mcpServers.fixture if (!definition) throw new Error("Fixture MCP server is missing from its config") @@ -125,9 +130,9 @@ test("invalidates cached MCP metadata when the server config changes", async ({ .slice(afterRestart) .some((event) => event.type === "process_started"), ).toBe(false) - const cache = JSON.parse(readFileSync(join(fixture.agentDir, "mcp-cache.json"), "utf-8")) as MetadataCache - expect(cache.servers.fixture).toBeUndefined() - trace.step("stale metadata disappeared without starting the lazy server") + const cache = JSON.parse(readFileSync(cachePath, "utf-8")) as MetadataCache + expect(cache.servers.fixture?.configHash).toBe(hashBeforeChange) + trace.step("stale metadata was ignored without starting the lazy server or rewriting its cache") }, ) }) diff --git a/tests/e2e/tui/mcp-stdio.test.ts b/tests/e2e/tui/mcp-stdio.test.ts index c310ba481..c6510974a 100644 --- a/tests/e2e/tui/mcp-stdio.test.ts +++ b/tests/e2e/tui/mcp-stdio.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs" +import { join } from "node:path" import { expect, test } from "@microsoft/tui-test" import { STREAM_TIMEOUT_MS, waitForText } from "./support/assertions.js" import { runMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" @@ -58,11 +60,7 @@ test("calls a stdio MCP tool through the real Kimchi session", async ({ terminal ) }) -// Known product bug: asynchronous MCP bootstrap exposes a configured direct tool only after -// the first model request has already been built, so that request rejects the tool as unavailable. -// Fixed upstream in pi-mcp-adapter v2.26.1 by PR #374's pre-input initialization barrier; -// remove test.fail once the bundled adapter includes that fix. -test.fail("registers and calls a direct MCP tool on the first session", async ({ terminal }) => { +test("registers and calls a direct MCP tool on the first session", async ({ terminal }) => { const echo = directMcpCall("echo", { message: "direct-first-session" }) await runMcpKimchiSession( terminal, @@ -99,6 +97,46 @@ test.fail("registers and calls a direct MCP tool on the first session", async ({ ) }) +test("uses MCP read-only annotations in plan mode and fails closed for explicit false", async ({ terminal }) => { + const dangerousGatewayCall = gatewayMcpCall("get_danger") + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-stdio-plan-annotations", + extraArgs: ["--plan=true"], + mcp: { + directTools: ["echo", "get_danger"], + behavior: { + catalogTools: [ + { + name: "get_danger", + description: "Mutating tool with a read-looking name", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: false }, + }, + ], + }, + }, + responses: [dangerousGatewayCall.response, modelReply("Planning MCP annotations were applied.")], + }, + async (fixture, trace) => { + terminal.submit("Inspect the available planning tools") + await waitForText(terminal, "Planning MCP annotations were applied.", { timeoutMs: STREAM_TIMEOUT_MS }) + + const annotationCache = JSON.parse(readFileSync(join(fixture.agentDir, "mcp-annotations.json"), "utf8")) as { + tools: Record + } + expect(Object.values(annotationCache.tools)).toContain("not-read-only") + const request = requireRequestAdvertisingTool(fixture.fake.requests, "fixture_echo") + const tools = (request.body as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] + expect(tools.some((tool) => tool.function?.name === "fixture_get_danger")).toBe(false) + expect(toolResultText(fixture.fake.requests, dangerousGatewayCall)).toContain("unavailable in plan mode") + expect(fixture.mcp.hasEvent("tool_called", { name: "get_danger" })).toBe(false) + trace.step("plan profile and gateway both rejected explicit readOnlyHint false") + }, + ) +}) + test("delivers an MCP isError result to the next model turn", async ({ terminal }) => { const failure = gatewayMcpCall("fail") await runMcpKimchiSession( @@ -131,7 +169,7 @@ test("delivers an MCP isError result to the next model turn", async ({ terminal }) test("reads an MCP resource through the gateway", async ({ terminal }) => { - const resource = gatewayMcpCall("get_fixture_note") + const resource = gatewayMcpCall("read_fixture_note") await runMcpKimchiSession( terminal, { @@ -207,41 +245,37 @@ test("preserves MCP text and safely represents image content for a text-only mod ) }) -test("injects the correctly named direct tool after MCP gateway search", async ({ terminal }) => { +test("calls a discovered tool through the MCP gateway after search", async ({ terminal }) => { const search = searchMcpTools("fixture echo") - const echo = directMcpCall("echo", { message: "search-injected-direct-tool" }) + const echo = gatewayMcpCall("echo", { message: "search-then-gateway-call" }) await runMcpKimchiSession( terminal, { - artifactName: "mcp-search-direct-injection", + artifactName: "mcp-search-gateway-call", mcp: { behavior: { tools: [ mcpToolResult( "echo", - { content: [{ type: "text", text: "fixture echo: search-injected-direct-tool" }] }, - { message: "search-injected-direct-tool" }, + { content: [{ type: "text", text: "fixture echo: search-then-gateway-call" }] }, + { message: "search-then-gateway-call" }, ), ], }, }, - responses: [ - search.response, - echo.response, - modelReply("The MCP search-injected tool used the correct original name."), - ], + responses: [search.response, echo.response, modelReply("The MCP gateway called the discovered tool.")], }, async (fixture, trace) => { terminal.submit("Search MCP and then call the discovered echo tool") - await waitForText(terminal, "The MCP search-injected tool used the correct original name.", { + await waitForText(terminal, "The MCP gateway called the discovered tool.", { timeoutMs: STREAM_TIMEOUT_MS, }) await fixture.mcp.waitForEvent("tool_called", { - where: { name: "echo", arguments: { message: "search-injected-direct-tool" } }, + where: { name: "echo", arguments: { message: "search-then-gateway-call" } }, }) requireRequestAdvertisingTool(fixture.fake.requests, echo.modelToolName) - expect(toolResultText(fixture.fake.requests, echo)).toContain("fixture echo: search-injected-direct-tool") - trace.step("search injection, direct name mapping, and invocation verified") + expect(toolResultText(fixture.fake.requests, echo)).toContain("fixture echo: search-then-gateway-call") + trace.step("search followed by a gateway invocation of the discovered tool") }, ) }) diff --git a/tests/e2e/tui/support/mcp-fixture.ts b/tests/e2e/tui/support/mcp-fixture.ts index 9dc751ffc..c1c624e7b 100644 --- a/tests/e2e/tui/support/mcp-fixture.ts +++ b/tests/e2e/tui/support/mcp-fixture.ts @@ -4,13 +4,14 @@ import { resolve } from "node:path" import { setTimeout as delay } from "node:timers/promises" import { fileURLToPath } from "node:url" import { isDeepStrictEqual } from "node:util" -import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js" -import type { McpConfig, McpSettings, OAuthConfig, ServerEntry } from "../../../../src/extensions/mcp-adapter/types.js" +import type { CallToolResult, ReadResourceResult, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js" +import type { McpConfig, McpSettings, OAuthConfig, ServerEntry } from "pi-mcp-adapter/types" const REPO_ROOT = process.env.KIMCHI_REPO_ROOT ? resolve(process.env.KIMCHI_REPO_ROOT) : fileURLToPath(new URL("../../../../", import.meta.url)) const FIXTURE_SERVER_PATH = resolve(REPO_ROOT, "tests/e2e/mcp/fixture-server.mjs") +export const MCP_FIXTURE_OAUTH_ACCESS_TOKEN = "kimchi-e2e-oauth-access-token" interface McpFixtureEventBase { at: string @@ -111,6 +112,13 @@ export interface McpFixtureBehavior { startup?: { type: "exit"; code: number } /** MCP call outcomes declared by the test that exercises them. */ tools?: McpFixtureToolBehavior[] + /** Additional advertised tools used by tool-surface policy scenarios. */ + catalogTools?: Array<{ + name: string + description?: string + inputSchema: { type: "object"; properties?: Record; additionalProperties?: boolean } + annotations?: ToolAnnotations + }> /** MCP resource-read outcomes declared by the test that exercises them. */ resources?: McpFixtureResourceBehavior[] } @@ -139,6 +147,7 @@ export interface McpServerFixtureOptions { idleTimeout?: number toolPrefix?: McpSettings["toolPrefix"] autoAuth?: boolean + oauthPreauthorized?: boolean behavior?: McpFixtureBehavior } @@ -374,13 +383,14 @@ export async function createMcpFixture(agentDir: string, options: McpFixtureOpti KIMCHI_MCP_FIXTURE_TRANSPORT: transport, ...fixtureBehaviorEnv(options.behavior), ...(oauth ? { KIMCHI_MCP_FIXTURE_OAUTH: "1" } : {}), + ...(oauth && options.oauthPreauthorized ? { KIMCHI_MCP_FIXTURE_OAUTH_PREAUTHORIZED: "1" } : {}), ...(oauth && options.oauth?.grantType ? { KIMCHI_MCP_FIXTURE_OAUTH_GRANT_TYPE: options.oauth.grantType } : {}), ...(oauth && options.oauth?.clientId ? { KIMCHI_MCP_FIXTURE_OAUTH_CLIENT_ID: options.oauth.clientId } : {}), ...(oauth && options.oauth?.clientSecret ? { KIMCHI_MCP_FIXTURE_OAUTH_CLIENT_SECRET: options.oauth.clientSecret } : {}), ...(oauth - ? { KIMCHI_MCP_FIXTURE_BEARER_TOKEN: "kimchi-e2e-oauth-access-token" } + ? { KIMCHI_MCP_FIXTURE_BEARER_TOKEN: MCP_FIXTURE_OAUTH_ACCESS_TOKEN } : options.bearerToken ? { KIMCHI_MCP_FIXTURE_BEARER_TOKEN: options.bearerToken } : {}), @@ -401,13 +411,14 @@ export async function createMcpFixture(agentDir: string, options: McpFixtureOpti } const configPath = writeMcpConfig(agentDir, { [serverName]: serverDefinition }, options) const browserEnv = oauth ? createOAuthBrowserDriver(agentDir, eventPath) : {} + const authStoreEnv = oauth ? { KIMCHI_MCP_E2E_KEYRING_DIR: resolve(agentDir, "mcp-keyring") } : {} const serverFixture: McpServerFixture = { serverName, serverDefinition, configPath, eventPath, ...eventReader } return { ...serverFixture, transport: oauth ? "oauth" : transport, url: listening.url, - env: browserEnv, + env: { ...browserEnv, ...authStoreEnv }, servers: { [serverName]: serverFixture }, server(name) { if (name !== serverName) throw new Error(`MCP fixture server ${name} is not configured`) @@ -437,6 +448,8 @@ function seedExternalHttpFixture(agentDir: string, options: McpFixtureOptions): } const configPath = writeMcpConfig(agentDir, { [serverName]: serverDefinition }, options) const browserEnv = options.transport === "oauth" ? createOAuthBrowserDriver(agentDir, eventPath) : {} + const authStoreEnv = + options.transport === "oauth" ? { KIMCHI_MCP_E2E_KEYRING_DIR: resolve(agentDir, "mcp-keyring") } : {} const serverFixture: McpServerFixture = { serverName, serverDefinition, @@ -448,7 +461,7 @@ function seedExternalHttpFixture(agentDir: string, options: McpFixtureOptions): ...serverFixture, transport: options.transport === "oauth" ? "oauth" : "http", url: options.externalUrl, - env: browserEnv, + env: { ...browserEnv, ...authStoreEnv }, servers: { [serverName]: serverFixture }, server(name) { if (name !== serverName) throw new Error(`MCP fixture server ${name} is not configured`) diff --git a/tests/e2e/tui/support/mcp-model-script.ts b/tests/e2e/tui/support/mcp-model-script.ts index 8694a0fce..f93accb94 100644 --- a/tests/e2e/tui/support/mcp-model-script.ts +++ b/tests/e2e/tui/support/mcp-model-script.ts @@ -1,4 +1,4 @@ -import type { McpSettings } from "../../../../src/extensions/mcp-adapter/types.js" +import type { McpSettings } from "pi-mcp-adapter/types" import type { FakeResponseScript, RecordedRequest } from "./fake-openai-server.js" type JsonObject = Record diff --git a/tsconfig.json b/tsconfig.json index 5da49c56c..e355c8339 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, "lib": ["ES2023", "DOM", "DOM.Iterable"], "noEmit": true, "rootDir": ".", From 223e3e40325d61058070a830eb0c368748094e23 Mon Sep 17 00:00:00 2001 From: Mateusz Polnik Date: Fri, 4 Sep 2026 22:13:05 +0200 Subject: [PATCH 2/4] fix(mcp): restore selected-config precedence and gateway read-only matching After migrating to the published pi-mcp-adapter, the selected project config file lost its highest-precedence position over standard MCP sources, and server-prefixed gateway tool names were no longer matched against read-only annotations in planning mode. - config.ts: reapply the selected config layer last and surface a useProgrammaticConfig flag; switch to programmatic config ({ config }) only when the overlay changes the effective configuration, keeping file-backed mode otherwise. - annotation-catalog.ts: add isReadOnlyGatewayTool() that resolves server-prefixed names (e.g. docs_get_issue) back to catalog entries via resolveToolPrefix/getToolNameCandidates. - index.ts: thread the full McpConfig into the surface policy and use isReadOnlyGatewayTool for planning-mode gateway calls. - Add mcp-config e2e and extend unit tests for both behaviors. Co-Authored-By: Kimchi --- package.json | 2 +- src/extensions/mcp/annotation-catalog.test.ts | 18 ++++ src/extensions/mcp/annotation-catalog.ts | 22 ++++- src/extensions/mcp/config.test.ts | 27 ++++++ src/extensions/mcp/config.ts | 93 +++++++++++++++++-- src/extensions/mcp/index.test.ts | 40 ++++++++ src/extensions/mcp/index.ts | 29 +++--- tests/e2e/tui/mcp-config.test.ts | 83 +++++++++++++++++ tests/e2e/tui/mcp-stdio.test.ts | 38 ++++++++ 9 files changed, 330 insertions(+), 22 deletions(-) create mode 100644 tests/e2e/tui/mcp-config.test.ts diff --git a/package.json b/package.json index 88774a723..4abfd0d8e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:e2e:tui:trace:replay": "node scripts/run-tui-e2e.js replay", "test:e2e:acp": "pnpm run build:binary && vitest run --config tests/e2e/acp/vitest.config.ts", "test:e2e:mcp": "pnpm run build:binary && pnpm run test:e2e:mcp:tui && pnpm run test:e2e:mcp:acp && pnpm run test:e2e:mcp:conformance", - "test:e2e:mcp:tui": "node scripts/run-tui-e2e.js mcp-stdio && node scripts/run-tui-e2e.js mcp-failures && node scripts/run-tui-e2e.js mcp-http && node scripts/run-tui-e2e.js mcp-oauth && node scripts/run-tui-e2e.js mcp-restart && node scripts/run-tui-e2e.js mcp-panel && node scripts/run-tui-e2e.js mcp-lifecycle && node scripts/run-tui-e2e.js mcp-ui", + "test:e2e:mcp:tui": "node scripts/run-tui-e2e.js mcp-config && node scripts/run-tui-e2e.js mcp-stdio && node scripts/run-tui-e2e.js mcp-failures && node scripts/run-tui-e2e.js mcp-http && node scripts/run-tui-e2e.js mcp-oauth && node scripts/run-tui-e2e.js mcp-restart && node scripts/run-tui-e2e.js mcp-panel && node scripts/run-tui-e2e.js mcp-lifecycle && node scripts/run-tui-e2e.js mcp-ui", "test:e2e:mcp:acp": "vitest run --config tests/e2e/acp/vitest.config.ts tests/e2e/acp/mcp-workflow.test.ts tests/e2e/acp/mcp-probe-command.test.ts", "test:e2e:mcp:conformance": "conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario initialize --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario tools_call --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario sse-retry --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario auth/metadata-default --spec-version 2025-11-25 --timeout 60000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario auth/pre-registration --spec-version 2025-11-25 --timeout 60000 --output-dir .kimchi/mcp-conformance-results", "postinstall": "node scripts/patch-pi-ai-oauth.js && node scripts/copy-resources.js --dev", diff --git a/src/extensions/mcp/annotation-catalog.test.ts b/src/extensions/mcp/annotation-catalog.test.ts index 3a8295d6b..f84dd3410 100644 --- a/src/extensions/mcp/annotation-catalog.test.ts +++ b/src/extensions/mcp/annotation-catalog.test.ts @@ -87,6 +87,24 @@ describe("McpAnnotationCatalog", () => { expect(catalog.isReadOnlyByName("unknown")).toBe(false) }) + it("recognizes a server-prefixed read-only tool at the gateway boundary", () => { + const { catalog } = createCatalog() + catalog.record([ + { + name: "get_safe", + description: "Read a safe value", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, + ]) + + expect( + catalog.isReadOnlyGatewayTool("fixture_get_safe", "fixture", { + mcpServers: { fixture: { command: "fixture-server" } }, + }), + ).toBe(true) + }) + it("persists observations with private file permissions", () => { const changed = vi.fn() const { catalog, cachePath } = createCatalog(changed) diff --git a/src/extensions/mcp/annotation-catalog.ts b/src/extensions/mcp/annotation-catalog.ts index bf43feba5..48ad2edf9 100644 --- a/src/extensions/mcp/annotation-catalog.ts +++ b/src/extensions/mcp/annotation-catalog.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from " import { dirname, join } from "node:path" import { Client, type ListToolsResult } from "@modelcontextprotocol/client" import { computeServerHash, getMetadataCachePath } from "pi-mcp-adapter/metadata-cache" -import type { McpConfig } from "pi-mcp-adapter/types" +import { getToolNameCandidates, type McpConfig, resolveToolPrefix, type ServerEntry } from "pi-mcp-adapter/types" import { isReadOnlyMcpToolName } from "./read-only-tools.js" type ListedTool = ListToolsResult["tools"][number] @@ -92,6 +92,26 @@ export class McpAnnotationCatalog { return states.length > 0 && states.every((state) => this.isReadOnlyState(originalName, state)) } + isReadOnlyGatewayTool(gatewayName: string, serverName: string | undefined, config: McpConfig): boolean { + const servers: Array<[string, ServerEntry]> = serverName + ? config.mcpServers[serverName] + ? [[serverName, config.mcpServers[serverName]]] + : [] + : Object.entries(config.mcpServers) + const matches: Array<{ originalName: string; state: AnnotationState }> = [] + for (const [key, state] of this.tools) { + const originalName = key.slice(0, key.indexOf("\0")) + for (const [configuredServerName, definition] of servers) { + const prefix = resolveToolPrefix(definition, config.settings?.toolPrefix) + if (getToolNameCandidates(originalName, configuredServerName, prefix).has(gatewayName)) { + matches.push({ originalName, state }) + break + } + } + } + return matches.length > 0 && matches.every(({ originalName, state }) => this.isReadOnlyState(originalName, state)) + } + private isReadOnlyState(originalName: string, state: AnnotationState | undefined): boolean { if (state === "read-only") return true if (state === "missing") return isReadOnlyMcpToolName(originalName) diff --git a/src/extensions/mcp/config.test.ts b/src/extensions/mcp/config.test.ts index 628a02f00..4caf9437e 100644 --- a/src/extensions/mcp/config.test.ts +++ b/src/extensions/mcp/config.test.ts @@ -51,6 +51,33 @@ describe("loadKimchiMcpConfig", () => { expect(upstream.load).toHaveBeenCalledWith(legacyPath, cwd) }) + it("restores legacy precedence when a standard project source overrides the same server", () => { + const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) + mkdirSync(dirname(legacyPath), { recursive: true }) + writeFileSync(legacyPath, JSON.stringify({ mcpServers: { shared: { command: "legacy-server" } } })) + const discovered: McpConfig = { + mcpServers: { + shared: { command: "standard-project-server" }, + standardOnly: { command: "standard-only-server" }, + }, + } + upstream.load.mockReturnValue(discovered) + + const result = loadKimchiMcpConfig({ cwd }) + + expect(result).toEqual({ + config: { + mcpServers: { + shared: { command: "legacy-server" }, + standardOnly: { command: "standard-only-server" }, + }, + }, + configPath: legacyPath, + useProgrammaticConfig: true, + warnings: [], + }) + }) + it("prefers an explicit config path over the legacy project file", () => { const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) mkdirSync(dirname(legacyPath), { recursive: true }) diff --git a/src/extensions/mcp/config.ts b/src/extensions/mcp/config.ts index 51a93c058..76d9c01fc 100644 --- a/src/extensions/mcp/config.ts +++ b/src/extensions/mcp/config.ts @@ -1,30 +1,105 @@ import { existsSync } from "node:fs" import { resolve } from "node:path" +import { isDeepStrictEqual } from "node:util" import { loadMcpConfig as loadUpstreamMcpConfig } from "pi-mcp-adapter/config" -import type { McpConfig } from "pi-mcp-adapter/types" +import type { ImportKind, McpConfig, McpSettings, ServerEntry } from "pi-mcp-adapter/types" +import { readJson } from "../../config/json.js" export const LEGACY_PROJECT_MCP_CONFIG = ".kimchi/mcp.json" export interface KimchiMcpConfigResult { config: McpConfig configPath?: string + useProgrammaticConfig?: boolean warnings: string[] } -/** - * Keep the adapter in its file-backed mode so its panels and persistence stay - * available. Kimchi's legacy project file becomes the highest-precedence - * configPath; upstream still merges every standard MCP source beneath it. - */ +const IMPORT_KINDS = new Set([ + "cursor", + "claude-code", + "claude-desktop", + "codex", + "opencode", + "windsurf", + "vscode", +]) + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isServerEntry(value: unknown): value is ServerEntry { + return isRecord(value) +} + +function isMcpSettings(value: unknown): value is McpSettings { + return isRecord(value) +} + +function isImportKind(value: unknown): value is ImportKind { + return typeof value === "string" && IMPORT_KINDS.has(value) +} + +function loadSelectedConfig(configPath: string): { config: McpConfig; warnings: string[] } { + if (!existsSync(configPath)) return { config: { mcpServers: {} }, warnings: [] } + try { + const raw = readJson(configPath) + const rawServers = raw.mcpServers ?? raw["mcp-servers"] + const mcpServers: Record = {} + if (isRecord(rawServers)) { + for (const [name, entry] of Object.entries(rawServers)) { + if (isServerEntry(entry)) mcpServers[name] = entry + } + } + const imports = Array.isArray(raw.imports) ? raw.imports.filter(isImportKind) : undefined + const settings = isMcpSettings(raw.settings) ? raw.settings : undefined + return { + config: { + mcpServers, + ...(imports === undefined ? {} : { imports }), + ...(settings === undefined ? {} : { settings }), + }, + warnings: [], + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + config: { mcpServers: {} }, + warnings: [`Failed to load selected MCP config from ${configPath}: ${message}`], + } + } +} + +function applySelectedPrecedence(discovered: McpConfig, selected: McpConfig): McpConfig { + const imports = selected.imports ?? discovered.imports + const settings = selected.settings ? { ...discovered.settings, ...selected.settings } : discovered.settings + return { + mcpServers: { ...discovered.mcpServers, ...selected.mcpServers }, + ...(imports === undefined ? {} : { imports }), + ...(settings === undefined ? {} : { settings }), + } +} + export function loadKimchiMcpConfig(options: { cwd?: string; overridePath?: string } = {}): KimchiMcpConfigResult { const cwd = options.cwd ?? process.cwd() const exclusiveMode = process.env.PI_MCP_CONFIG_MODE?.trim().toLowerCase() === "exclusive" const legacyPath = resolve(cwd, LEGACY_PROJECT_MCP_CONFIG) const configPath = options.overridePath ?? (!exclusiveMode && existsSync(legacyPath) ? legacyPath : undefined) + const discovered = loadUpstreamMcpConfig(configPath, cwd) + if (!configPath) return { config: discovered, warnings: [] } + + // pi-mcp-adapter treats configPath as a global layer, below its standard + // project files. Kimchi historically treats an explicit or legacy project + // config as authoritative, so reapply just that selected layer last. Stay in + // file-backed mode when this does not change the effective configuration. + const selected = loadSelectedConfig(configPath) + const config = applySelectedPrecedence(discovered, selected.config) + const useProgrammaticConfig = !isDeepStrictEqual(config, discovered) return { - config: loadUpstreamMcpConfig(configPath, cwd), - ...(configPath ? { configPath } : {}), - warnings: [], + config, + configPath, + ...(useProgrammaticConfig ? { useProgrammaticConfig: true } : {}), + warnings: selected.warnings, } } diff --git a/src/extensions/mcp/index.test.ts b/src/extensions/mcp/index.test.ts index b374c8465..13b4cfc4a 100644 --- a/src/extensions/mcp/index.test.ts +++ b/src/extensions/mcp/index.test.ts @@ -12,6 +12,7 @@ const upstream = vi.hoisted(() => ({ const configState = vi.hoisted(() => ({ config: { mcpServers: {} } as McpConfig, configPath: undefined as string | undefined, + useProgrammaticConfig: false, warnings: [] as string[], })) const cliState = vi.hoisted(() => ({ mcpConfig: undefined as string | undefined })) @@ -42,6 +43,7 @@ vi.mock("./config.js", () => ({ loadKimchiMcpConfig: () => ({ config: configState.config, configPath: configState.configPath, + ...(configState.useProgrammaticConfig ? { useProgrammaticConfig: true } : {}), warnings: configState.warnings, }), })) @@ -76,6 +78,10 @@ vi.mock("./annotation-catalog.js", () => ({ isReadOnlyByName(originalName: string): boolean { return annotations.readOnly.has(originalName) } + isReadOnlyGatewayTool(toolName: string, serverName: string | undefined): boolean { + const originalName = serverName ? toolName.replace(`${serverName}_`, "") : toolName + return annotations.readOnly.has(originalName) + } }, })) @@ -97,6 +103,7 @@ describe("upstream MCP adapter facade", () => { upstream.options = undefined configState.config = { mcpServers: {} } configState.configPath = undefined + configState.useProgrammaticConfig = false configState.warnings = [] oauthMigration.warnings = [] annotations.readOnly.clear() @@ -122,6 +129,17 @@ describe("upstream MCP adapter facade", () => { expect(upstream.api).toBeDefined() }) + it("uses the resolved config when selected-file precedence requires an overlay", () => { + configState.config = { mcpServers: { docs: { command: "selected-server" } } } + configState.configPath = "/tmp/mcp.json" + configState.useProgrammaticConfig = true + const { api } = createExtensionApi() + + mcpAdapterExtension(api) + + expect(upstream.options).toEqual({ config: configState.config }) + }) + it("suppresses all model-facing MCP tools for an empty configuration", () => { const harness = createExtensionApi() mcpAdapterExtension(harness.api) @@ -188,6 +206,28 @@ describe("upstream MCP adapter facade", () => { expect(gatewayResult).toMatchObject({ isError: true, details: { error: "plan_mode_write_blocked" } }) }) + it("allows a server-prefixed read-only gateway call in planning profiles", async () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + planning.currentProfile = "planning-adhoc" + annotations.readOnly.add("get_issue") + const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + upstream.api?.registerTool({ ...tool("mcp", "MCP"), execute: gatewayExecute }) + + const gateway = harness.getRegisteredTools().find(({ name }) => name === "mcp") + const result = await gateway?.execute( + "gateway", + { tool: "docs_get_issue", server: "docs", args: {} }, + undefined, + undefined, + createContext(), + ) + + expect(gatewayExecute).toHaveBeenCalledOnce() + expect(result).toMatchObject({ content: [{ type: "text", text: "ok" }] }) + }) + it("creates an isolated caller-wins configuration for ACP sessions", () => { configState.config = { mcpServers: { diff --git a/src/extensions/mcp/index.ts b/src/extensions/mcp/index.ts index ac8b2cc7d..64287eb32 100644 --- a/src/extensions/mcp/index.ts +++ b/src/extensions/mcp/index.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI, ExtensionContext, ExtensionFactory, ToolDefinition } from "@earendil-works/pi-coding-agent" import { createMcpAdapter, MCP_STATUS_EVENT } from "pi-mcp-adapter" -import type { ServerEntry } from "pi-mcp-adapter/types" +import type { McpAdapterOptions, McpConfig, ServerEntry } from "pi-mcp-adapter/types" import { getParsedCliArgs } from "../../cli-args.js" import { registerReadOnlyToolProvider } from "../../shared/planning/read-only-tool-registry.js" import { @@ -25,18 +25,20 @@ const MCP_DIRECT_TOOL_LABEL_PREFIX = "MCP: " interface McpToolSurfacePolicy { annotationCatalog: McpAnnotationCatalog + config: McpConfig directTools: Map suppressedToolNames: Set } -function createMcpToolSurfacePolicy( - hasConfiguredServers: boolean, - annotationCatalog: McpAnnotationCatalog, -): McpToolSurfacePolicy { +function createMcpToolSurfacePolicy(config: McpConfig, annotationCatalog: McpAnnotationCatalog): McpToolSurfacePolicy { return { annotationCatalog, + config, directTools: new Map(), - suppressedToolNames: new Set([MCP_SCRIPT_TOOL, ...(!hasConfiguredServers ? [MCP_PROXY_TOOL] : [])]), + suppressedToolNames: new Set([ + MCP_SCRIPT_TOOL, + ...(Object.keys(config.mcpServers).length === 0 ? [MCP_PROXY_TOOL] : []), + ]), } } @@ -79,9 +81,13 @@ function blockedMcpToolInPlanning( : originalName if (registeredName !== MCP_PROXY_TOOL || !params || typeof params !== "object" || Array.isArray(params)) return undefined - const gatewayTool = (params as { tool?: unknown }).tool + const gatewayParams = params as { server?: unknown; tool?: unknown } + const gatewayTool = gatewayParams.tool if (typeof gatewayTool !== "string") return undefined - return policy.annotationCatalog.isReadOnlyByName(gatewayTool) ? undefined : gatewayTool + const gatewayServer = typeof gatewayParams.server === "string" ? gatewayParams.server : undefined + return policy.annotationCatalog.isReadOnlyGatewayTool(gatewayTool, gatewayServer, policy.config) + ? undefined + : gatewayTool } function createUpstreamApi(pi: ExtensionAPI, policy: McpToolSurfacePolicy): ExtensionAPI { @@ -143,6 +149,7 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE const { config: fileConfig, configPath, + useProgrammaticConfig, warnings: configWarnings, } = loadKimchiMcpConfig({ cwd: options.cwd, @@ -153,12 +160,11 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE : fileConfig const { warnings: oauthWarnings } = migrateLegacyOAuthCredentials(config) const warnings = [...configWarnings, ...oauthWarnings] - const hasConfiguredServers = Object.keys(config.mcpServers).length > 0 const annotationCatalog = new McpAnnotationCatalog({ sourceHash: mcpAnnotationSourceHash(config), onChanged: () => reapplyCurrentProfile(pi), }) - const policy = createMcpToolSurfacePolicy(hasConfiguredServers, annotationCatalog) + const policy = createMcpToolSurfacePolicy(config, annotationCatalog) registerReadOnlyToolProvider(pi, () => [...policy.directTools] @@ -177,7 +183,8 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE reapplyCurrentProfile(pi) }) - const adapterOptions = options.callerServers ? { config } : configPath ? { configPath } : {} + const adapterOptions: McpAdapterOptions = + options.callerServers || useProgrammaticConfig ? { config } : configPath ? { configPath } : {} runWithMcpAnnotationCatalog(policy.annotationCatalog, () => { createMcpAdapter(adapterOptions)(createUpstreamApi(pi, policy)) }) diff --git a/tests/e2e/tui/mcp-config.test.ts b/tests/e2e/tui/mcp-config.test.ts new file mode 100644 index 000000000..5e276aaa8 --- /dev/null +++ b/tests/e2e/tui/mcp-config.test.ts @@ -0,0 +1,83 @@ +import { mkdirSync, renameSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { expect, test } from "@microsoft/tui-test" +import { STREAM_TIMEOUT_MS, waitForText } from "./support/assertions.js" +import { runMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" +import { mcpToolResult } from "./support/mcp-fixture.js" +import { gatewayMcpCall, modelReply, toolResultText } from "./support/mcp-model-script.js" + +test.use(TUI_TEST_CONFIG) + +function moveFixtureConfig(homeDir: string, destination: string): void { + const source = join(homeDir, ".config", "kimchi", "harness", "mcp.json") + mkdirSync(dirname(destination), { recursive: true }) + renameSync(source, destination) +} + +function writeConflictingStandardConfig(workDir: string): void { + writeFileSync( + join(workDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { + fixture: { command: process.execPath, args: ["-e", "process.exit(19)"] }, + }, + }), + "utf8", + ) +} + +async function exerciseSelectedConfig( + terminal: Parameters[0], + options: { artifactName: string; destination: (workDir: string) => string; extraArgs?: string[] }, +): Promise { + const echo = gatewayMcpCall("echo", { message: options.artifactName }) + await runMcpKimchiSession( + terminal, + { + artifactName: options.artifactName, + extraArgs: options.extraArgs, + mcp: { + behavior: { + tools: [ + mcpToolResult( + "echo", + { content: [{ type: "text", text: `fixture echo: ${options.artifactName}` }] }, + { message: options.artifactName }, + ), + ], + }, + }, + responses: [echo.response, modelReply("The selected MCP configuration won the collision.")], + seedHome: (homeDir, workDir) => { + moveFixtureConfig(homeDir, options.destination(workDir)) + writeConflictingStandardConfig(workDir) + }, + }, + async (fixture, trace) => { + terminal.submit("Call the selected MCP fixture") + await waitForText(terminal, "The selected MCP configuration won the collision.", { + timeoutMs: STREAM_TIMEOUT_MS, + }) + await fixture.mcp.waitForEvent("tool_called", { + where: { name: "echo", arguments: { message: options.artifactName } }, + }) + expect(toolResultText(fixture.fake.requests, echo)).toContain(`fixture echo: ${options.artifactName}`) + trace.step("selected config server handled a same-name collision with the standard project source") + }, + ) +} + +test("legacy project MCP config wins a same-name standard project collision", async ({ terminal }) => { + await exerciseSelectedConfig(terminal, { + artifactName: "mcp-config-legacy-precedence", + destination: (workDir) => join(workDir, ".kimchi", "mcp.json"), + }) +}) + +test("explicit MCP config wins a same-name standard project collision", async ({ terminal }) => { + await exerciseSelectedConfig(terminal, { + artifactName: "mcp-config-explicit-precedence", + destination: (workDir) => join(workDir, "chosen-mcp.json"), + extraArgs: ["--mcp-config", "chosen-mcp.json"], + }) +}) diff --git a/tests/e2e/tui/mcp-stdio.test.ts b/tests/e2e/tui/mcp-stdio.test.ts index c6510974a..3b5e2e053 100644 --- a/tests/e2e/tui/mcp-stdio.test.ts +++ b/tests/e2e/tui/mcp-stdio.test.ts @@ -137,6 +137,44 @@ test("uses MCP read-only annotations in plan mode and fails closed for explicit ) }) +test("calls a server-prefixed read-only MCP tool through the gateway in plan mode", async ({ terminal }) => { + const safeGatewayCall = gatewayMcpCall("get_safe") + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-stdio-plan-read-only-gateway", + extraArgs: ["--plan=true"], + mcp: { + behavior: { + catalogTools: [ + { + name: "get_safe", + description: "Read a safe fixture value", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true }, + }, + ], + tools: [ + mcpToolResult("get_safe", { + content: [{ type: "text", text: "fixture safe value" }], + }), + ], + }, + }, + responses: [safeGatewayCall.response, modelReply("The read-only MCP gateway call succeeded in plan mode.")], + }, + async (fixture, trace) => { + terminal.submit("Read the safe MCP fixture value") + await waitForText(terminal, "The read-only MCP gateway call succeeded in plan mode.", { + timeoutMs: STREAM_TIMEOUT_MS, + }) + await fixture.mcp.waitForEvent("tool_called", { where: { name: "get_safe", arguments: {} } }) + expect(toolResultText(fixture.fake.requests, safeGatewayCall)).toContain("fixture safe value") + trace.step("prefixed gateway name was matched to its read-only protocol annotation") + }, + ) +}) + test("delivers an MCP isError result to the next model turn", async ({ terminal }) => { const failure = gatewayMcpCall("fail") await runMcpKimchiSession( From 303965eb9b4ecce4f6151bcc4704fa54ddbdeb9b Mon Sep 17 00:00:00 2001 From: Mateusz Polnik Date: Sat, 5 Sep 2026 06:59:27 +0200 Subject: [PATCH 3/4] feat(mcp): gate project sources behind trust decisions and brand OAuth callbacks The published pi-mcp-adapter can start configured stdio servers during its metadata-cache bootstrap, so a cold startup of a cloned repository could execute project-defined MCP commands before any trust decision. The adapter also renders its own OAuth callback pages, exposing unbranded or Pi-branded content to end users, and model-facing gateway text carried upstream "Pi" references. This commit closes both gaps at the Kimchi facade boundary: - project-trust.ts: resolve MCP project-trust by reusing Pi's persisted trust store and defaults, asking only for the standard .mcp.json case that Pi's resource detector does not cover. Explicit --approve / --no-approve CLI flags override the prompt; headless and untrusted sessions receive user-level config only via a programmatic adapter config loaded against a cwd outside the repository. - index.ts: defer adapter installation and session_start/input relay until trust is resolved, so no project server starts on an untrusted run. - oauth-callback-branding.ts: decorate the adapter's exact self-contained callback response with Kimchi's shared browser templates and MCP wording. State validation, PKCE, token exchange, and listener lifecycle stay in the package. The same boundary brands adapter tool results and model-facing text without rewriting server-originated content. - config.ts: add getConfiguredLegacyMcpKeys to surface only explicitly persisted obsolete MCP settings; telemetry now reports the adapter's actual weighted search provider rather than ignored legacy settings. - Add project-trust and browser-branding TUI e2e suites, focused unit tests, and update the adapter audit document. Co-Authored-By: Kimchi --- docs/mcp-adapter-audit.md | 83 ++++++- package.json | 2 +- src/cli-args.test.ts | 8 + src/cli-args.ts | 13 + src/config.test.ts | 16 ++ src/config.ts | 19 ++ src/extensions/__mocks__/extension-api.ts | 2 + src/extensions/mcp/config.test.ts | 11 + src/extensions/mcp/config.ts | 21 +- src/extensions/mcp/index.test.ts | 192 +++++++++++++-- src/extensions/mcp/index.ts | 194 +++++++++++---- .../mcp/oauth-callback-branding.test.ts | 140 +++++++++++ src/extensions/mcp/oauth-callback-branding.ts | 227 ++++++++++++++++++ src/extensions/mcp/project-trust.test.ts | 118 +++++++++ src/extensions/mcp/project-trust.ts | 77 ++++++ .../telemetry/config-snapshot.test.ts | 4 +- src/extensions/telemetry/config-snapshot.ts | 5 +- src/utils/session-metadata-store.test.ts | 6 +- tests/e2e/acp/mcp-workflow.test.ts | 36 ++- tests/e2e/tui/mcp-browser-branding.test.ts | 125 ++++++++++ tests/e2e/tui/mcp-config.test.ts | 1 + tests/e2e/tui/mcp-project-trust.test.ts | 74 ++++++ tests/e2e/tui/mcp-ui.test.ts | 4 +- tests/e2e/tui/support/mcp-fixture.ts | 43 +++- 24 files changed, 1333 insertions(+), 88 deletions(-) create mode 100644 src/extensions/mcp/oauth-callback-branding.test.ts create mode 100644 src/extensions/mcp/oauth-callback-branding.ts create mode 100644 src/extensions/mcp/project-trust.test.ts create mode 100644 src/extensions/mcp/project-trust.ts create mode 100644 tests/e2e/tui/mcp-browser-branding.test.ts create mode 100644 tests/e2e/tui/mcp-project-trust.test.ts diff --git a/docs/mcp-adapter-audit.md b/docs/mcp-adapter-audit.md index 10e0c4242..24e03818c 100644 --- a/docs/mcp-adapter-audit.md +++ b/docs/mcp-adapter-audit.md @@ -30,9 +30,20 @@ Kimchi continues to support the project configuration path facade constructs the effective configuration before creating the published adapter, so this compatibility does not require a copied config loader. +Standard project MCP sources, including `.mcp.json`, are gated by the same +persisted project-trust decisions and `--approve` / `--no-approve` overrides as +other executable project resources. Adapter installation is deferred until the +decision is known because a cold metadata-cache bootstrap can start configured +stdio servers during adapter initialization. A denied or headless-untrusted +project receives the complete user-level upstream configuration through the +adapter's programmatic API; ACP caller-supplied servers are still accepted as +trusted caller input. Trusted sessions retain normal file-backed adapter +discovery, setup, reload, and persistence behavior. + Relevant code: - [`src/extensions/mcp/config.ts`](../src/extensions/mcp/config.ts) +- [`src/extensions/mcp/project-trust.ts`](../src/extensions/mcp/project-trust.ts) - [`src/cli-args.ts`](../src/cli-args.ts) ### OAuth credential migration and compiled keyring support @@ -59,6 +70,30 @@ Relevant code: - [`.github/workflows/release.yml`](../.github/workflows/release.yml) - [`.github/workflows/canary.yml`](../.github/workflows/canary.yml) +### Kimchi-owned MCP branding + +Successful and failed MCP OAuth callbacks continue to use Kimchi's shared +browser templates and MCP-specific wording. The package does not expose a +callback-page renderer hook, so the facade decorates only the package's exact +self-contained callback response. State validation, PKCE, token exchange, +listener ownership, and callback cleanup remain in the published adapter. +Provider-controlled error details are decoded from the package page and then +escaped again by Kimchi's shared renderer. The package's success-page +auto-close behavior is preserved. + +The same narrow adapter boundary brands the MCP App host/landing pages, +adapter command UI, adapter-classified tool guidance, and the model-facing MCP +gateway. The obsolete `/pi-mcp` alias is not exposed, and gateway instructions +do not recommend the deliberately hidden `mcpScript` tool. MCP server names, +descriptions, successful content, and server-originated errors are never +rewritten; a server is allowed to use the word “Pi” as its own content. + +Relevant code: + +- [`src/extensions/mcp/oauth-callback-branding.ts`](../src/extensions/mcp/oauth-callback-branding.ts) +- [`src/utils/oauth-page.ts`](../src/utils/oauth-page.ts) +- [`tests/e2e/tui/mcp-browser-branding.test.ts`](../tests/e2e/tui/mcp-browser-branding.test.ts) + ### ACP caller-supplied servers ACP `session/new` and `session/load` continue to accept caller-supplied MCP @@ -128,7 +163,10 @@ Relevant code: The facade disables the model-facing `mcpScript` tool and omits the MCP gateway entirely when no server is configured. Direct-tool updates are folded back through Kimchi's active tool profile so the adapter cannot silently widen -a restricted profile. +a restricted profile. Explicit uses of the retired `mcpSearch`, +`mcpSearchLimit`, and `maxToolResultChars` Kimchi settings receive a migration +warning. Telemetry reports the adapter's actual weighted search provider rather +than the ignored legacy setting. ## Vendored patches deliberately dropped @@ -147,7 +185,7 @@ has equivalent or superseding behavior: - Invalid-config warnings and empty-status handling. - Panel display, reconnect, authentication, narrow-layout, and sanitization fixes. -- Host-name branding in OAuth callback pages and dynamic client registration. +- Host-name substitution in dynamic client registration. - Stale cache cleanup and hot direct-tool refresh. Keeping parallel copies of these fixes would require Kimchi to depend on @@ -174,8 +212,8 @@ regressions: - Resource operations use the package's `read_` spelling rather than the former `get_` spelling. - Saving the package MCP panel closes it and refreshes direct tools. -- OAuth callback and dynamic-client display names use package branding rather - than the old vendored Kimchi branding. +- Dynamic OAuth client registrations use the host package name `kimchi` rather + than the old `Pi Coding Agent` name. ## Highest-risk failure scenarios and required tests @@ -183,13 +221,16 @@ regressions: | --- | --- | --- | | Compiled native keyring loading | OAuth cannot read or persist credentials in a distributed binary | Build the binary and run `kimchi mcp keyring-check --json` on macOS, Linux under a Secret Service session, and Windows in release/canary CI | | OAuth layout migration | Existing users are prompted to authenticate again, lose dynamic registration, or have credentials overwritten | Compiled-process upgrade test plus invalid-record and destination-conflict unit cases | +| OAuth callback branding | Users finish authorization on an unbranded package page or provider errors render unsafe HTML | Compiled-browser success/denial scenarios plus renderer and real HTTP-response unit tests | +| Repository project trust | Opening a clone executes a project `.mcp.json` command during cache bootstrap | Compiled TUI accept/deny sentinel scenarios plus headless ACP denial and trust-resolution units | +| Product/model branding | Setup, MCP App pages, or model guidance identifies Kimchi as Pi or recommends a hidden tool | Compiled setup/browser/model-contract scenarios plus exact-boundary units | | Plan-mode race or classification leak | A write-capable direct or gateway MCP tool becomes callable during planning | TUI scenario with explicit `readOnlyHint: true` and `false`; assert the blocked call never reaches the fixture server; unit tests for unknown/conflicting annotations and multiple sessions | | ACP session isolation | One Desktop session sees another session's servers, or caller definitions lose precedence | ACP `session/new`/`session/load`, collision, direct-tool registration, and multi-session configuration tests | | Probe cleanup and OAuth isolation | Probe hangs, leaves a callback listener/process alive, or overwrites another server's credentials | CLI and compiled ACP probes for stdio, HTTP, timeout/failure, OAuth, and same-name/different-URL behavior | | Adapter startup and direct-tool synchronization | First request lacks tools, a restrictive profile is widened, or stale tools survive reconnect | TUI lifecycle, restart, stdio, failure, and planning scenarios | | Transport/OAuth lifecycle | Cancellation is ignored, keep-alive restart fails, or HTTP authentication loops | MCP TUI HTTP/OAuth/restart suites plus MCP conformance initialize, tools, SSE retry, discovery, and pre-registration suites | | UI replacement | Panel crashes on narrow output, fails to reconnect/save, or renders unsafe content | TUI panel/UI scenarios and focused facade tests | -| Config compatibility | `.kimchi/mcp.json`, `--mcp-config`, or caller-wins precedence silently changes | Config precedence and ACP conversion/merge tests | +| Config compatibility | `.kimchi/mcp.json`, `--mcp-config`, trust-filtered user config, or caller-wins precedence silently changes | Config precedence, trust denial, and ACP conversion/merge tests | ## Verification record @@ -197,8 +238,8 @@ The migration is accepted only when all of the following remain green: - Full Vitest unit/integration suite. - `pnpm run lint` and `pnpm run typecheck`. -- All MCP TUI suites: stdio, failures, HTTP, OAuth, restart, panel, - lifecycle, and UI. +- All MCP TUI suites: stdio, failures, HTTP, OAuth browser branding, OAuth, + restart, panel, lifecycle, and UI. - ACP caller-server and probe workflows. - MCP conformance: initialize, tool calls, SSE retry, OAuth metadata discovery, and OAuth pre-registration. @@ -214,16 +255,32 @@ Credential Manager runtime checks. Those two native backends remain an environment validation gate until a CI run executes the updated workflows; cross-compilation alone is not evidence that they work. +Current Linux x64 verification passes all 44 focused MCP TUI scenarios, +including project-trust denial/acceptance and four compiled browser/setup/model +branding contracts, all five focused ACP MCP scenarios, and the complete +conformance matrix. The full unit run has 9,541 passing and 11 skipped tests; +its sole failure is an unrelated ferment +auto-compaction expectation reproduced on the baseline. + The broader smoke suite has three known failures outside this migration: one live model request receives HTTP 401, and two agent-session tracking cases do not create their expected child session files. They are not MCP release signals. +The complete 170-scenario TUI run has 168 passing, one intentionally skipped +debugger scenario, and one environment-dependent failure: a multi-model test +expects one selected model, while the developer machine's four installed +Ollama models make the correctly rendered count five. Neither that test nor its +implementation area is changed by this migration. The complete ACP suite passes +27/27. Native Secret Service CRUD passes from the compiled Linux x64 binary in +a fresh D-Bus session using the release-workflow recipe. + ## Ongoing maintenance boundary -Kimchi owns only the facade contracts listed above. The published package owns -transport behavior, process lifecycle, callback servers, output protection, -cache mechanics, tool rendering, and panel implementation. Future adapter -upgrades must rerun this document's risk matrix. A package regression may be -worked around locally when necessary, but copying the package implementation -back into `src/extensions/` is explicitly out of scope. +Kimchi owns only the facade contracts listed above, including the browser-facing +callback page. The published package owns transport behavior, process lifecycle, +the callback server and its security/lifecycle logic, output protection, cache +mechanics, tool rendering, and panel implementation. Future adapter upgrades +must rerun this document's risk matrix. A package regression may be worked +around locally when necessary, but copying the package implementation back into +`src/extensions/` is explicitly out of scope. diff --git a/package.json b/package.json index 4abfd0d8e..d0f7677a0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:e2e:tui:trace:replay": "node scripts/run-tui-e2e.js replay", "test:e2e:acp": "pnpm run build:binary && vitest run --config tests/e2e/acp/vitest.config.ts", "test:e2e:mcp": "pnpm run build:binary && pnpm run test:e2e:mcp:tui && pnpm run test:e2e:mcp:acp && pnpm run test:e2e:mcp:conformance", - "test:e2e:mcp:tui": "node scripts/run-tui-e2e.js mcp-config && node scripts/run-tui-e2e.js mcp-stdio && node scripts/run-tui-e2e.js mcp-failures && node scripts/run-tui-e2e.js mcp-http && node scripts/run-tui-e2e.js mcp-oauth && node scripts/run-tui-e2e.js mcp-restart && node scripts/run-tui-e2e.js mcp-panel && node scripts/run-tui-e2e.js mcp-lifecycle && node scripts/run-tui-e2e.js mcp-ui", + "test:e2e:mcp:tui": "node scripts/run-tui-e2e.js mcp-config && node scripts/run-tui-e2e.js mcp-project-trust && node scripts/run-tui-e2e.js mcp-stdio && node scripts/run-tui-e2e.js mcp-failures && node scripts/run-tui-e2e.js mcp-http && node scripts/run-tui-e2e.js mcp-browser-branding && node scripts/run-tui-e2e.js mcp-oauth && node scripts/run-tui-e2e.js mcp-restart && node scripts/run-tui-e2e.js mcp-panel && node scripts/run-tui-e2e.js mcp-lifecycle && node scripts/run-tui-e2e.js mcp-ui", "test:e2e:mcp:acp": "vitest run --config tests/e2e/acp/vitest.config.ts tests/e2e/acp/mcp-workflow.test.ts tests/e2e/acp/mcp-probe-command.test.ts", "test:e2e:mcp:conformance": "conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario initialize --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario tools_call --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario sse-retry --spec-version 2025-11-25 --timeout 30000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario auth/metadata-default --spec-version 2025-11-25 --timeout 60000 --output-dir .kimchi/mcp-conformance-results && conformance client --command \"pnpm exec tsx tests/e2e/mcp/conformance-client.ts\" --scenario auth/pre-registration --spec-version 2025-11-25 --timeout 60000 --output-dir .kimchi/mcp-conformance-results", "postinstall": "node scripts/patch-pi-ai-oauth.js && node scripts/copy-resources.js --dev", diff --git a/src/cli-args.test.ts b/src/cli-args.test.ts index 2a3dc1b7a..aaa73dc0f 100644 --- a/src/cli-args.test.ts +++ b/src/cli-args.test.ts @@ -337,6 +337,14 @@ describe("populateCliArgs / getParsedCliArgs", () => { expect(getParsedCliArgs()).toEqual({ options: { provider: "kimchi-dev" }, positionals: ["fix tests"] }) }) + it("caches upstream project-trust overrides for trust-aware extensions", () => { + populateCliArgs(["--approve"]) + expect(getParsedCliArgs()).toEqual({ options: { approve: true }, positionals: [] }) + + populateCliArgs(["--no-approve"]) + expect(getParsedCliArgs()).toEqual({ options: { "no-approve": true }, positionals: [] }) + }) + it("reuses the cached parse across calls", () => { populateCliArgs(["--multi-model"]) expect(getParsedCliArgs()).toEqual({ options: { "multi-model": true }, positionals: [] }) diff --git a/src/cli-args.ts b/src/cli-args.ts index 144a00201..4fcca53e6 100644 --- a/src/cli-args.ts +++ b/src/cli-args.ts @@ -179,6 +179,15 @@ export const CLI_OPTIONS: Record = { type: "boolean", description: "Start in yolo mode (run freely, no classifier - DANGER)", }, + approve: { + type: "boolean", + short: "a", + description: "Trust project-local files for this run", + }, + "no-approve": { + type: "boolean", + description: "Ignore project-local files for this run", + }, "permissions-config": { type: "string", description: "Replace the merged permissions config with this file", @@ -225,6 +234,8 @@ export interface SessionCliArgs { plan?: boolean auto?: boolean yolo?: boolean + approve?: boolean + "no-approve"?: boolean "permissions-config"?: string "mcp-config"?: string verbose?: boolean @@ -269,6 +280,8 @@ const CACHEABLE_OPTION_NAMES = [ "plan", "auto", "yolo", + "approve", + "no-approve", "permissions-config", "mcp-config", "verbose", diff --git a/src/config.test.ts b/src/config.test.ts index ccdbac173..d4798a8d9 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -8,6 +8,7 @@ import { clearApiKey, ensureHideThinkingBlockDefault, ensureQuietStartupDefault, + getConfiguredLegacyMcpKeys, loadConfig, RETRY_DEFAULTS, readApiKeyFromConfigFile, @@ -129,6 +130,21 @@ describe("loadConfig", () => { rmSync(projectDir, { recursive: true, force: true }) }) + it("reports only explicitly persisted legacy MCP keys", () => { + const projectDir = join(tempDir, "project") + const projectPath = join(projectDir, ".kimchi", "config.json") + writeFileSync(configPath, JSON.stringify({ mcpSearchLimit: 7, unrelated: true })) + mkdirSync(dirname(projectPath), { recursive: true }) + writeFileSync(projectPath, JSON.stringify({ maxToolResultChars: 42_000, mcpSearch: { strategy: "regex" } })) + + expect(getConfiguredLegacyMcpKeys({ configPath, cwd: projectDir })).toEqual([ + "mcpSearchLimit", + "maxToolResultChars", + "mcpSearch", + ]) + expect(getConfiguredLegacyMcpKeys({ configPath: join(tempDir, "missing.json"), cwd: tempDir })).toEqual([]) + }) + it("falls back to global when .kimchi/config.json does not exist", () => { const globalDir = mkdtempSync(join(tmpdir(), "kimchi-test-")) const projectDir = mkdtempSync(join(tmpdir(), "kimchi-test-")) diff --git a/src/config.ts b/src/config.ts index 99303706d..9bdb9f1fb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -164,8 +164,11 @@ export interface KimchiConfig { llmEndpoint: string /** The user-configured endpoint, undefined if not explicitly set. Use this when passing to updateModelsConfig. */ customLlmEndpoint: string | undefined + /** @deprecated Parsed only so upgrades can identify obsolete MCP configuration. */ maxToolResultChars: number + /** @deprecated Parsed only so upgrades can identify obsolete MCP configuration. */ mcpSearchLimit: number + /** @deprecated Parsed only so upgrades can identify obsolete MCP configuration. */ mcpSearch: SearchStrategyConfig skillPaths?: string[] migrationState?: MigrationState @@ -484,6 +487,22 @@ export function loadConfig(options?: { configPath?: string; cwd?: string }): Kim } } +export type LegacyMcpConfigKey = "maxToolResultChars" | "mcpSearchLimit" | "mcpSearch" + +/** Return only legacy MCP keys the user actually persisted, excluding defaults. */ +export function getConfiguredLegacyMcpKeys(options?: { configPath?: string; cwd?: string }): LegacyMcpConfigKey[] { + const globalConfigPath = options?.configPath ?? KIMCHI_CONFIG_PATH + const projectConfigPath = resolve(options?.cwd ?? process.cwd(), ".kimchi", "config.json") + const sources = [readConfigExtras(globalConfigPath), readConfigExtras(projectConfigPath)] + const configured = new Set() + for (const source of sources) { + if (source.maxToolResultChars !== undefined) configured.add("maxToolResultChars") + if (source.mcpSearchLimit !== undefined) configured.add("mcpSearchLimit") + if (source.mcpSearch !== undefined && Object.keys(source.mcpSearch).length > 0) configured.add("mcpSearch") + } + return [...configured] +} + export function getAgentConfigDir(): string { return AGENT_CONFIG_DIR } diff --git a/src/extensions/__mocks__/extension-api.ts b/src/extensions/__mocks__/extension-api.ts index fcb37c48e..dc5285919 100644 --- a/src/extensions/__mocks__/extension-api.ts +++ b/src/extensions/__mocks__/extension-api.ts @@ -31,6 +31,7 @@ export function createExtensionApi(): { }) const setModel = vi.fn(async () => true) const registerCommand = vi.fn() + const registerFlag = vi.fn() const registeredTools = new Map() const activeToolNames = new Set() const registerTool = vi.fn((tool: ToolDefinition) => { @@ -49,6 +50,7 @@ export function createExtensionApi(): { api: { on, registerCommand, + registerFlag, registerTool, getAllTools, getActiveTools, diff --git a/src/extensions/mcp/config.test.ts b/src/extensions/mcp/config.test.ts index 4caf9437e..090171d84 100644 --- a/src/extensions/mcp/config.test.ts +++ b/src/extensions/mcp/config.test.ts @@ -1,6 +1,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join } from "node:path" +import { getAgentDir } from "@earendil-works/pi-coding-agent" import type { McpConfig } from "pi-mcp-adapter/types" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" @@ -38,6 +39,16 @@ describe("loadKimchiMcpConfig", () => { expect(upstream.load).toHaveBeenCalledWith(undefined, cwd) }) + it("loads user-only config outside the repository and forces programmatic mode", () => { + const config: McpConfig = { mcpServers: { personal: { command: "personal-server" } } } + upstream.load.mockReturnValue(config) + + const result = loadKimchiMcpConfig({ cwd, includeProjectSources: false }) + + expect(result).toEqual({ config, useProgrammaticConfig: true, warnings: [] }) + expect(upstream.load).toHaveBeenCalledWith(undefined, join(getAgentDir(), ".kimchi-mcp-user-config")) + }) + it("uses the legacy project config as a file-backed upstream override", () => { const legacyPath = join(cwd, LEGACY_PROJECT_MCP_CONFIG) mkdirSync(dirname(legacyPath), { recursive: true }) diff --git a/src/extensions/mcp/config.ts b/src/extensions/mcp/config.ts index 76d9c01fc..ccc6b3667 100644 --- a/src/extensions/mcp/config.ts +++ b/src/extensions/mcp/config.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs" -import { resolve } from "node:path" +import { join, resolve } from "node:path" import { isDeepStrictEqual } from "node:util" +import { getAgentDir } from "@earendil-works/pi-coding-agent" import { loadMcpConfig as loadUpstreamMcpConfig } from "pi-mcp-adapter/config" import type { ImportKind, McpConfig, McpSettings, ServerEntry } from "pi-mcp-adapter/types" import { readJson } from "../../config/json.js" @@ -80,8 +81,24 @@ function applySelectedPrecedence(discovered: McpConfig, selected: McpConfig): Mc } } -export function loadKimchiMcpConfig(options: { cwd?: string; overridePath?: string } = {}): KimchiMcpConfigResult { +export function loadKimchiMcpConfig( + options: { cwd?: string; overridePath?: string; includeProjectSources?: boolean } = {}, +): KimchiMcpConfigResult { const cwd = options.cwd ?? process.cwd() + if (options.includeProjectSources === false) { + // The upstream loader does not expose a source filter. Resolve its complete + // user-level behavior against a cwd outside the repository so standard + // project files, project host imports, and project package declarations + // cannot enter the effective config. The resulting config must be passed to + // createMcpAdapter() programmatically; otherwise it would rediscover the real + // session cwd during initialization. + const userConfigCwd = join(getAgentDir(), ".kimchi-mcp-user-config") + return { + config: loadUpstreamMcpConfig(undefined, userConfigCwd), + useProgrammaticConfig: true, + warnings: [], + } + } const exclusiveMode = process.env.PI_MCP_CONFIG_MODE?.trim().toLowerCase() === "exclusive" const legacyPath = resolve(cwd, LEGACY_PROJECT_MCP_CONFIG) const configPath = options.overridePath ?? (!exclusiveMode && existsSync(legacyPath) ? legacyPath : undefined) diff --git a/src/extensions/mcp/index.test.ts b/src/extensions/mcp/index.test.ts index 13b4cfc4a..b0fb7c443 100644 --- a/src/extensions/mcp/index.test.ts +++ b/src/extensions/mcp/index.test.ts @@ -8,14 +8,22 @@ import { createExtensionApi } from "../__mocks__/extension-api.js" const upstream = vi.hoisted(() => ({ api: undefined as ExtensionAPI | undefined, options: undefined as McpAdapterOptions | undefined, + input: vi.fn(), + sessionStart: vi.fn(), })) const configState = vi.hoisted(() => ({ config: { mcpServers: {} } as McpConfig, + userConfig: { mcpServers: {} } as McpConfig, configPath: undefined as string | undefined, useProgrammaticConfig: false, warnings: [] as string[], + legacyKeys: [] as string[], +})) +const cliState = vi.hoisted(() => ({ + mcpConfig: undefined as string | undefined, + approve: undefined as boolean | undefined, + noApprove: undefined as boolean | undefined, })) -const cliState = vi.hoisted(() => ({ mcpConfig: undefined as string | undefined })) const planning = vi.hoisted(() => ({ provider: undefined as (() => string[]) | undefined, applyCooperativeTweak: vi.fn(() => true), @@ -23,6 +31,8 @@ const planning = vi.hoisted(() => ({ reapplyCurrentProfile: vi.fn(() => false), })) const oauthMigration = vi.hoisted(() => ({ warnings: [] as string[] })) +const oauthBranding = vi.hoisted(() => ({ install: vi.fn() })) +const projectTrust = vi.hoisted(() => ({ trusted: true })) const annotations = vi.hoisted(() => ({ readOnly: new Set(), })) @@ -32,20 +42,36 @@ vi.mock("pi-mcp-adapter", () => ({ createMcpAdapter: vi.fn((options: McpAdapterOptions) => (api: ExtensionAPI) => { upstream.options = options upstream.api = api + api.on("session_start", upstream.sessionStart) + api.on("input", upstream.input) }), })) vi.mock("../../cli-args.js", () => ({ - getParsedCliArgs: () => ({ options: { "mcp-config": cliState.mcpConfig }, positionals: [] }), + getParsedCliArgs: () => ({ + options: { + "mcp-config": cliState.mcpConfig, + approve: cliState.approve, + "no-approve": cliState.noApprove, + }, + positionals: [], + }), })) vi.mock("./config.js", () => ({ - loadKimchiMcpConfig: () => ({ - config: configState.config, - configPath: configState.configPath, - ...(configState.useProgrammaticConfig ? { useProgrammaticConfig: true } : {}), - warnings: configState.warnings, - }), + loadKimchiMcpConfig: (options: { includeProjectSources?: boolean }) => + options.includeProjectSources === false + ? { config: configState.userConfig, useProgrammaticConfig: true, warnings: [] } + : { + config: configState.config, + configPath: configState.configPath, + ...(configState.useProgrammaticConfig ? { useProgrammaticConfig: true } : {}), + warnings: configState.warnings, + }, +})) + +vi.mock("../../config.js", () => ({ + getConfiguredLegacyMcpKeys: () => configState.legacyKeys, })) vi.mock("../../shared/planning/read-only-tool-registry.js", () => ({ @@ -67,6 +93,18 @@ vi.mock("./oauth-migration.js", () => ({ })), })) +vi.mock("./oauth-callback-branding.js", () => ({ + brandMcpAdapterOwnedToolResult: (result: unknown) => result, + brandMcpAdapterText: (text: string) => text.replaceAll("Pi", "Kimchi"), + createBrandedMcpContext: (ctx: unknown) => ctx, + installMcpOAuthCallbackBranding: oauthBranding.install, +})) + +vi.mock("./project-trust.js", () => ({ + MCP_PROJECT_TRUST_WARNING: "project MCP config is not trusted", + resolveMcpProjectTrust: vi.fn(async () => projectTrust.trusted), +})) + vi.mock("./annotation-catalog.js", () => ({ installMcpAnnotationCapture: vi.fn(), mcpAnnotationSourceHash: vi.fn(() => "test-source"), @@ -97,52 +135,95 @@ function tool(name: string, label: string): ToolDefinition { } } +async function start( + harness: ReturnType, + ctx = createContext({ isProjectTrusted: () => true }), +) { + await harness.getHandler("session_start")({ type: "session_start", reason: "startup" }, ctx) + return ctx +} + describe("upstream MCP adapter facade", () => { beforeEach(() => { upstream.api = undefined upstream.options = undefined + upstream.input.mockReset() + upstream.sessionStart.mockReset() configState.config = { mcpServers: {} } + configState.userConfig = { mcpServers: {} } configState.configPath = undefined configState.useProgrammaticConfig = false configState.warnings = [] + configState.legacyKeys = [] oauthMigration.warnings = [] + oauthBranding.install.mockClear() annotations.readOnly.clear() cliState.mcpConfig = undefined + cliState.approve = undefined + cliState.noApprove = undefined + projectTrust.trusted = true planning.provider = undefined planning.currentProfile = undefined planning.applyCooperativeTweak.mockClear() planning.reapplyCurrentProfile.mockClear() }) - it("keeps the upstream adapter in file-backed mode", () => { + it("installs the Kimchi OAuth callback-page decorator", () => { + const { api } = createExtensionApi() + + mcpAdapterExtension(api) + + expect(oauthBranding.install).toHaveBeenCalledOnce() + }) + + it("defers adapter installation until project trust is resolved", async () => { + const harness = createExtensionApi() + + mcpAdapterExtension(harness.api) + expect(upstream.api).toBeUndefined() + expect(harness.getHandlers("input")).toHaveLength(1) + + await start(harness) + await harness.getHandler("input")({ type: "input", text: "hello" }, createContext()) + + expect(upstream.api).toBeDefined() + expect(upstream.sessionStart).toHaveBeenCalledOnce() + expect(upstream.input).toHaveBeenCalledOnce() + expect(harness.getHandlers("input")).toHaveLength(1) + }) + + it("keeps the upstream adapter in file-backed mode", async () => { configState.config = { mcpServers: { docs: { url: "https://example.test/mcp" } }, settings: { scriptMode: false }, } cliState.mcpConfig = "/tmp/mcp.json" configState.configPath = "/tmp/mcp.json" - const { api } = createExtensionApi() + const harness = createExtensionApi() - mcpAdapterExtension(api) + mcpAdapterExtension(harness.api) + await start(harness) expect(upstream.options).toEqual({ configPath: "/tmp/mcp.json" }) expect(upstream.api).toBeDefined() }) - it("uses the resolved config when selected-file precedence requires an overlay", () => { + it("uses the resolved config when selected-file precedence requires an overlay", async () => { configState.config = { mcpServers: { docs: { command: "selected-server" } } } configState.configPath = "/tmp/mcp.json" configState.useProgrammaticConfig = true - const { api } = createExtensionApi() + const harness = createExtensionApi() - mcpAdapterExtension(api) + mcpAdapterExtension(harness.api) + await start(harness) expect(upstream.options).toEqual({ config: configState.config }) }) - it("suppresses all model-facing MCP tools for an empty configuration", () => { + it("suppresses all model-facing MCP tools for an empty configuration", async () => { const harness = createExtensionApi() mcpAdapterExtension(harness.api) + await start(harness) upstream.api?.registerTool(tool("mcp", "MCP")) upstream.api?.registerTool(tool("mcpScript", "MCP Script")) @@ -152,11 +233,12 @@ describe("upstream MCP adapter facade", () => { expect(planning.applyCooperativeTweak).toHaveBeenCalledWith(harness.api, ["read"]) }) - it("registers direct tools and exposes only read-only names to planning", () => { + it("registers direct tools and exposes only read-only names to planning", async () => { configState.config = { mcpServers: { docs: { command: "docs" } } } annotations.readOnly.add("get_issue") const harness = createExtensionApi() mcpAdapterExtension(harness.api) + await start(harness) upstream.api?.registerTool(tool("docs_get_issue", "MCP: get_issue")) upstream.api?.registerTool(tool("docs_delete_issue", "MCP: delete_issue")) @@ -166,11 +248,12 @@ describe("upstream MCP adapter facade", () => { expect(planning.reapplyCurrentProfile).toHaveBeenCalledTimes(2) }) - it("keeps the current profile authoritative over upstream active-tool synchronization", () => { + it("keeps the current profile authoritative over upstream active-tool synchronization", async () => { configState.config = { mcpServers: { docs: { command: "docs" } } } planning.reapplyCurrentProfile.mockReturnValue(true) const harness = createExtensionApi() mcpAdapterExtension(harness.api) + await start(harness) upstream.api?.setActiveTools(["read", "docs_delete_issue"]) @@ -186,6 +269,7 @@ describe("upstream MCP adapter facade", () => { const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) const harness = createExtensionApi() mcpAdapterExtension(harness.api) + await start(harness) upstream.api?.registerTool({ ...tool("docs_delete_issue", "MCP: delete_issue"), execute: directExecute }) upstream.api?.registerTool({ ...tool("mcp", "MCP"), execute: gatewayExecute }) @@ -213,6 +297,7 @@ describe("upstream MCP adapter facade", () => { const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) const harness = createExtensionApi() mcpAdapterExtension(harness.api) + await start(harness) upstream.api?.registerTool({ ...tool("mcp", "MCP"), execute: gatewayExecute }) const gateway = harness.getRegisteredTools().find(({ name }) => name === "mcp") @@ -228,7 +313,7 @@ describe("upstream MCP adapter facade", () => { expect(result).toMatchObject({ content: [{ type: "text", text: "ok" }] }) }) - it("creates an isolated caller-wins configuration for ACP sessions", () => { + it("creates an isolated caller-wins configuration for ACP sessions", async () => { configState.config = { mcpServers: { shared: { command: "from-file" }, @@ -245,6 +330,7 @@ describe("upstream MCP adapter facade", () => { callerOnly: { command: "caller-only" }, }, })(harness.api) + await start(harness, createContext({ cwd: "/workspace", isProjectTrusted: () => true })) expect(upstream.options).toEqual({ config: { @@ -258,6 +344,74 @@ describe("upstream MCP adapter facade", () => { }) }) + it("keeps ACP caller servers while excluding an untrusted project's servers", async () => { + projectTrust.trusted = false + configState.config = { mcpServers: { project: { command: "project-server" } } } + configState.userConfig = { mcpServers: { personal: { command: "personal-server" } } } + const harness = createExtensionApi() + + createKimchiMcpAdapterExtension({ + cwd: "/workspace", + callerServers: { ide: { command: "ide-server" } }, + })(harness.api) + const ctx = await start(harness, createContext({ cwd: "/workspace", isProjectTrusted: () => false })) + + expect(upstream.options).toEqual({ + config: { + mcpServers: { + personal: { command: "personal-server" }, + ide: { command: "ide-server" }, + }, + }, + }) + expect(ctx.ui.notify).toHaveBeenCalledWith("project MCP config is not trusted", "warning") + }) + + it("removes impossible mcpScript guidance and Pi product wording from the gateway", async () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + await start(harness) + + upstream.api?.registerTool({ + ...tool("mcp", "MCP"), + description: + "When one request needs several MCP calls with logic between them, use mcpScript. Non-MCP Pi tools should be called directly.", + }) + + const description = harness.getRegisteredTools().find(({ name }) => name === "mcp")?.description + expect(description).not.toContain("mcpScript") + expect(description).toContain("Non-MCP Kimchi tools") + }) + + it("does not rewrite MCP server-owned direct-tool descriptions", async () => { + configState.config = { mcpServers: { docs: { command: "docs" } } } + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + await start(harness) + + upstream.api?.registerTool({ + ...tool("docs_reference", "MCP: reference"), + description: "A server-owned guide for migrating from Pi", + }) + + expect(harness.getRegisteredTools().find(({ name }) => name === "docs_reference")?.description).toBe( + "A server-owned guide for migrating from Pi", + ) + }) + + it("does not expose the upstream /pi-mcp alias", async () => { + const harness = createExtensionApi() + mcpAdapterExtension(harness.api) + await start(harness) + + upstream.api?.registerCommand("mcp", { async handler() {} }) + upstream.api?.registerCommand("pi-mcp", { async handler() {} }) + + expect(harness.api.registerCommand).toHaveBeenCalledTimes(1) + expect(harness.api.registerCommand).toHaveBeenCalledWith("mcp", expect.anything()) + }) + it("reapplies the active profile after an upstream status update", () => { configState.config = { mcpServers: { docs: { command: "docs" } } } const harness = createExtensionApi() @@ -270,6 +424,7 @@ describe("upstream MCP adapter facade", () => { it("surfaces compatibility warnings when the session starts", async () => { configState.warnings = ["legacy config is malformed"] + configState.legacyKeys = ["mcpSearch"] oauthMigration.warnings = ["legacy OAuth entry conflicts with the upstream layout"] const harness = createExtensionApi() mcpAdapterExtension(harness.api) @@ -279,5 +434,6 @@ describe("upstream MCP adapter facade", () => { expect(ctx.ui.notify).toHaveBeenCalledWith("legacy config is malformed", "warning") expect(ctx.ui.notify).toHaveBeenCalledWith("legacy OAuth entry conflicts with the upstream layout", "warning") + expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("mcpSearch no longer controls"), "warning") }) }) diff --git a/src/extensions/mcp/index.ts b/src/extensions/mcp/index.ts index 64287eb32..f0ebde7dc 100644 --- a/src/extensions/mcp/index.ts +++ b/src/extensions/mcp/index.ts @@ -1,7 +1,14 @@ -import type { ExtensionAPI, ExtensionContext, ExtensionFactory, ToolDefinition } from "@earendil-works/pi-coding-agent" +import type { + ExtensionAPI, + ExtensionContext, + ExtensionFactory, + ExtensionHandler, + ToolDefinition, +} from "@earendil-works/pi-coding-agent" import { createMcpAdapter, MCP_STATUS_EVENT } from "pi-mcp-adapter" import type { McpAdapterOptions, McpConfig, ServerEntry } from "pi-mcp-adapter/types" import { getParsedCliArgs } from "../../cli-args.js" +import { getConfiguredLegacyMcpKeys } from "../../config.js" import { registerReadOnlyToolProvider } from "../../shared/planning/read-only-tool-registry.js" import { applyCooperativeTweak, @@ -17,11 +24,25 @@ import { } from "./annotation-catalog.js" import { loadKimchiMcpConfig } from "./config.js" import { installKeyringRequireBridge } from "./keyring-require-bridge.js" +import { + brandMcpAdapterOwnedToolResult, + brandMcpAdapterText, + createBrandedMcpContext, + installMcpOAuthCallbackBranding, +} from "./oauth-callback-branding.js" import { migrateLegacyOAuthCredentials } from "./oauth-migration.js" +import { MCP_PROJECT_TRUST_WARNING, resolveMcpProjectTrust } from "./project-trust.js" const MCP_PROXY_TOOL = "mcp" const MCP_SCRIPT_TOOL = "mcpScript" const MCP_DIRECT_TOOL_LABEL_PREFIX = "MCP: " +const MCP_SCRIPT_RECOMMENDATION = "When one request needs several MCP calls with logic between them, use mcpScript. " + +function legacyMcpConfigWarning(cwd: string): string | undefined { + const keys = getConfiguredLegacyMcpKeys({ cwd }) + if (keys.length === 0) return undefined + return `Kimchi MCP config ${keys.join(", ")} no longer controls MCP behavior. The MCP adapter's weighted search and output guard are now authoritative; remove the obsolete key${keys.length === 1 ? "" : "s"}.` +} interface McpToolSurfacePolicy { annotationCatalog: McpAnnotationCatalog @@ -90,36 +111,81 @@ function blockedMcpToolInPlanning( : gatewayTool } -function createUpstreamApi(pi: ExtensionAPI, policy: McpToolSurfacePolicy): ExtensionAPI { +type UpstreamLifecycleHandler = ExtensionHandler +type CapturedUpstreamEvent = "input" | "session_start" + +function createUpstreamApi( + pi: ExtensionAPI, + policy: McpToolSurfacePolicy, + captureHandler: (event: CapturedUpstreamEvent, handler: UpstreamLifecycleHandler) => void, +): ExtensionAPI { return new Proxy(pi, { get(target, property) { if (property === "on") { return (event: string, handler: (event: unknown, ctx: unknown) => unknown): void => { + const wrapped = (eventValue: unknown, ctx: unknown) => + runWithMcpAnnotationCatalog(policy.annotationCatalog, () => handler(eventValue, ctx)) + if (event === "session_start" || event === "input") { + captureHandler(event, (eventValue, ctx) => wrapped(eventValue, ctx)) + return + } const on = target.on as (event: string, handler: (event: unknown, ctx: unknown) => unknown) => void - on(event, (eventValue, ctx) => - runWithMcpAnnotationCatalog(policy.annotationCatalog, () => handler(eventValue, ctx)), - ) + on(event, wrapped) } } if (property === "registerTool") { return (tool: ToolDefinition): void => { if (policy.suppressedToolNames.has(tool.name)) return - const originalName = getDirectToolOriginalName(tool) + const brandedTool = { + ...tool, + description: + tool.name === MCP_PROXY_TOOL + ? brandMcpAdapterText(tool.description.replace(MCP_SCRIPT_RECOMMENDATION, "")) + : tool.description, + } + const originalName = getDirectToolOriginalName(brandedTool) if (originalName) { - policy.directTools.set(tool.name, { originalName, description: tool.description }) + policy.directTools.set(brandedTool.name, { originalName, description: brandedTool.description }) } - const execute = tool.execute.bind(tool) + const execute = brandedTool.execute.bind(brandedTool) target.registerTool({ - ...tool, + ...brandedTool, execute: (...args: Parameters) => { - const blockedTool = blockedMcpToolInPlanning(target, policy, tool.name, originalName, args[1], args[4]) + const blockedTool = blockedMcpToolInPlanning( + target, + policy, + brandedTool.name, + originalName, + args[1], + args[4], + ) if (blockedTool) return Promise.resolve(blockedPlanningResult(blockedTool)) - return runWithMcpAnnotationCatalog(policy.annotationCatalog, () => execute(...args)) + return runWithMcpAnnotationCatalog(policy.annotationCatalog, async () => + brandMcpAdapterOwnedToolResult(await execute(...args)), + ) }, }) reapplyCurrentProfile(target) } } + if (property === "registerCommand") { + return (name: string, command: Parameters[1]): void => { + if (name === "pi-mcp") return + const handler = command.handler + target.registerCommand(name, { + ...command, + ...(command.description === undefined || (name !== "mcp" && name !== "mcp-auth") + ? {} + : { description: brandMcpAdapterText(command.description) }), + handler: (args, ctx) => handler(args, createBrandedMcpContext(ctx)), + }) + } + } + if (property === "registerFlag") { + return (name: string, flag: Parameters[1]): void => { + if (name !== "mcp-config") target.registerFlag(name, flag) + } + } if (property === "setActiveTools") { return (toolNames: string[]): void => { const allowedNames = toolNames.filter((name) => !policy.suppressedToolNames.has(name)) @@ -144,50 +210,96 @@ export function createKimchiMcpAdapterExtension(options: KimchiMcpAdapterExtensi function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterExtensionOptions): void { installKeyringRequireBridge() + installMcpOAuthCallbackBranding() installMcpAnnotationCapture() - const overridePath = getParsedCliArgs().options["mcp-config"] - const { - config: fileConfig, - configPath, - useProgrammaticConfig, - warnings: configWarnings, - } = loadKimchiMcpConfig({ - cwd: options.cwd, - overridePath, + pi.registerFlag("mcp-config", { description: "Path to MCP config file", type: "string" }) + let policy: McpToolSurfacePolicy | undefined + const upstreamHandlers: Record = { + input: [], + session_start: [], + } + let warnings: string[] = [] + + registerReadOnlyToolProvider(pi, () => { + const currentPolicy = policy + if (!currentPolicy) return [] + return [...currentPolicy.directTools] + .filter(([, tool]) => currentPolicy.annotationCatalog.isReadOnly(tool.originalName, tool.description)) + .map(([toolName]) => toolName) }) - const config = options.callerServers - ? { ...fileConfig, mcpServers: { ...fileConfig.mcpServers, ...options.callerServers } } - : fileConfig - const { warnings: oauthWarnings } = migrateLegacyOAuthCredentials(config) - const warnings = [...configWarnings, ...oauthWarnings] - const annotationCatalog = new McpAnnotationCatalog({ - sourceHash: mcpAnnotationSourceHash(config), - onChanged: () => reapplyCurrentProfile(pi), + + // The adapter is installed after trust resolves, but its input readiness hook + // must exist before extension event dispatch begins. Forward through this + // eagerly registered handler so cold-cache direct tools and annotations are + // ready for the first model request. + pi.on("input", async (event, ctx) => { + for (const handler of upstreamHandlers.input) await handler(event, ctx) }) - const policy = createMcpToolSurfacePolicy(config, annotationCatalog) - registerReadOnlyToolProvider(pi, () => - [...policy.directTools] - .filter(([, tool]) => annotationCatalog.isReadOnly(tool.originalName, tool.description)) - .map(([toolName]) => toolName), - ) + pi.on("session_start", async (event, ctx) => { + if (!policy) { + const cliOptions = getParsedCliArgs().options + const overridePath = cliOptions["mcp-config"] + const cwd = options.cwd ?? ctx.cwd + const projectResult = loadKimchiMcpConfig({ cwd, overridePath }) + const userResult = loadKimchiMcpConfig({ cwd, includeProjectSources: false }) + const explicitTrust = + cliOptions["no-approve"] === true + ? false + : cliOptions.approve === true || overridePath !== undefined + ? true + : undefined + const projectTrusted = await resolveMcpProjectTrust(ctx, { + projectConfig: projectResult.config, + userConfig: userResult.config, + ...(explicitTrust === undefined ? {} : { explicitTrust }), + }) + const selectedResult = projectTrusted ? projectResult : userResult + const config = options.callerServers + ? { + ...selectedResult.config, + mcpServers: { ...selectedResult.config.mcpServers, ...options.callerServers }, + } + : selectedResult.config + const { warnings: oauthWarnings } = migrateLegacyOAuthCredentials(config, { cwd }) + const legacyConfigWarning = legacyMcpConfigWarning(cwd) + warnings = [ + ...selectedResult.warnings, + ...oauthWarnings, + ...(legacyConfigWarning === undefined ? [] : [legacyConfigWarning]), + ...(projectTrusted ? [] : [MCP_PROJECT_TRUST_WARNING]), + ] + const annotationCatalog = new McpAnnotationCatalog({ + sourceHash: mcpAnnotationSourceHash(config), + onChanged: () => reapplyCurrentProfile(pi), + }) + const installedPolicy = createMcpToolSurfacePolicy(config, annotationCatalog) + policy = installedPolicy + const adapterOptions: McpAdapterOptions = + options.callerServers || selectedResult.useProgrammaticConfig + ? { config } + : selectedResult.configPath + ? { configPath: selectedResult.configPath } + : {} + runWithMcpAnnotationCatalog(installedPolicy.annotationCatalog, () => { + createMcpAdapter(adapterOptions)( + createUpstreamApi(pi, installedPolicy, (upstreamEvent, handler) => { + upstreamHandlers[upstreamEvent].push(handler) + }), + ) + }) + } - pi.on("session_start", (_event, ctx) => { for (const warning of warnings) { if (ctx.hasUI) ctx.ui.notify(warning, "warning") else console.warn(warning) } + for (const handler of upstreamHandlers.session_start) await handler(event, ctx) }) pi.events.on(MCP_STATUS_EVENT, () => { reapplyCurrentProfile(pi) }) - - const adapterOptions: McpAdapterOptions = - options.callerServers || useProgrammaticConfig ? { config } : configPath ? { configPath } : {} - runWithMcpAnnotationCatalog(policy.annotationCatalog, () => { - createMcpAdapter(adapterOptions)(createUpstreamApi(pi, policy)) - }) } export default function mcpAdapterExtension(pi: ExtensionAPI): void { diff --git a/src/extensions/mcp/oauth-callback-branding.test.ts b/src/extensions/mcp/oauth-callback-branding.test.ts new file mode 100644 index 000000000..3b97d7c57 --- /dev/null +++ b/src/extensions/mcp/oauth-callback-branding.test.ts @@ -0,0 +1,140 @@ +import { createServer } from "node:http" +import { resolve } from "node:path" +import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { createContext } from "../__mocks__/context.js" +import { + brandMcpAdapterOwnedToolResult, + brandMcpAdapterText, + brandMcpBrowserHtml, + brandMcpOAuthCallbackHtml, + createBrandedMcpContext, + installMcpOAuthCallbackBranding, +} from "./oauth-callback-branding.js" + +const templateDir = resolve(import.meta.dirname, "../../../resources/oauth") + +function adapterPage(heading: string, extra = "", autoClose = false): string { + return `

${heading}

Return to kimchi.

${extra}
${ + autoClose ? "" : "" + }` +} + +describe("MCP OAuth callback branding", () => { + beforeEach(() => vi.stubEnv("KIMCHI_OAUTH_TEMPLATE_DIR", templateDir)) + afterEach(() => vi.unstubAllEnvs()) + + it("restores the Kimchi success page and keeps the adapter auto-close behavior", () => { + const html = brandMcpOAuthCallbackHtml(adapterPage("Authorization Successful", "", true)) + + expect(html).toContain('class="bg-svg"') + expect(html).toContain('fill="#FF521D"') + expect(html).toContain("MCP Authorization Successful") + expect(html).toContain("

MCP Authorization Successful

") + expect(html).toContain("You can close this window and return to Kimchi.") + expect(html).toContain("setTimeout(() => window.close(), 2000)") + expect(html).not.toContain('class="badge ok"') + }) + + it("restores the Kimchi error page and safely carries provider details across", () => { + const html = brandMcpOAuthCallbackHtml( + adapterPage( + "Authorization Failed", + "<script>alert("x")</script> & denied", + ), + ) + + expect(html).toContain('class="bg-svg"') + expect(html).toContain("MCP Authorization Failed") + expect(html).toContain("An error occurred during MCP authorization.") + expect(html).toContain("<script>alert("x")</script> & denied") + expect(html).not.toContain('') + }) + + it("brands the adapter manual-completion page without losing its instructions", () => { + const html = brandMcpOAuthCallbackHtml(adapterPage("Authorization Received")) + + expect(html).toContain("MCP Authorization Received") + expect(html).toContain("paste it back into Kimchi with auth-complete") + expect(html).toContain('fill="#FF521D"') + }) + + it("leaves unrelated HTTP pages untouched", () => { + const html = '

Authorization Successful

Another application

' + + expect(brandMcpOAuthCallbackHtml(html)).toBe(html) + }) + + it("brands adapter-owned MCP App browser pages", () => { + const unauthenticated = + "MCP UI

Open the authenticated MCP UI URL shown by Pi.

" + const completed = + '

MCP UI session finished. You can close this page and return to Pi.

' + + expect(brandMcpBrowserHtml(unauthenticated)).toContain("shown by Kimchi") + expect(brandMcpBrowserHtml(completed)).toContain("return to Kimchi") + }) + + it("brands known adapter UI phrases without broad product-name replacement", () => { + expect(brandMcpAdapterText("Pi-owned files; reload Pi; server named Pi remains available")).toBe( + "Kimchi-owned files; reload Kimchi; server named Pi remains available", + ) + }) + + it("brands adapter-owned tool errors without rewriting MCP server results", () => { + const adapterResult = brandMcpAdapterOwnedToolResult({ + content: [{ type: "text", text: '"read" is a native Pi tool.' }], + details: { error: "native_tool" }, + }) + const serverResult = brandMcpAdapterOwnedToolResult({ + content: [{ type: "text", text: "A server-owned Pi migration guide" }], + details: { error: "tool_error" }, + }) + + expect(adapterResult.content).toEqual([{ type: "text", text: '"read" is a native Kimchi tool.' }]) + expect(serverResult.content).toEqual([{ type: "text", text: "A server-owned Pi migration guide" }]) + }) + + it("brands command notifications and custom component rendering", async () => { + const rendered = { render: () => ["Pi found setup; return to Pi"], invalidate() {} } + const custom = vi.fn(async (factory: (...args: unknown[]) => unknown) => + factory(), + ) as unknown as ExtensionUIContext["custom"] + const notify = vi.fn() + const ctx = createContext({ ui: { custom, notify } }) + const branded = createBrandedMcpContext(ctx) + + branded.ui.notify("Pi-owned configuration", "info") + const component = (await branded.ui.custom(() => rendered)) as typeof rendered + + expect(notify).toHaveBeenCalledWith("Kimchi-owned configuration", "info") + expect(component.render()).toEqual(["Kimchi found setup; return to Kimchi"]) + }) + + it("decorates an adapter response after it has sent its HTTP headers", async () => { + installMcpOAuthCallbackBranding() + const server = createServer((_request, response) => { + response.writeHead(200, { "Content-Type": "text/html" }) + response.end(adapterPage("Authorization Successful", "", true)) + }) + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", resolve) + }) + + try { + const address = server.address() + if (!address || typeof address === "string") throw new Error("OAuth branding test server did not bind") + const response = await fetch(`http://127.0.0.1:${address.port}`) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html).toContain('fill="#FF521D"') + expect(html).toContain("MCP Authorization Successful") + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) +}) diff --git a/src/extensions/mcp/oauth-callback-branding.ts b/src/extensions/mcp/oauth-callback-branding.ts new file mode 100644 index 000000000..521ed31dd --- /dev/null +++ b/src/extensions/mcp/oauth-callback-branding.ts @@ -0,0 +1,227 @@ +import { ServerResponse } from "node:http" +import type { AgentToolResult, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent" +import type { Component } from "@earendil-works/pi-tui" +import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.js" + +const ADAPTER_PAGE_MARKER = '
' +const KIMCHI_APP_MARKER = 'kimchi' +const SUCCESS_HEADING = "

Authorization Successful

" +const MANUAL_SUCCESS_HEADING = "

Authorization Received

" +const ERROR_HEADING = "

Authorization Failed

" +const AUTO_CLOSE_SCRIPT = "" +const INSTALL_MARKER = Symbol.for("kimchi.mcp.oauth-callback-branding") + +const ADAPTER_TEXT_REPLACEMENTS = [ + ["Non-MCP Pi tools", "Non-MCP Kimchi tools"], + ["No Pi model is available", "No Kimchi model is available"], + ["interactive Pi session", "interactive Kimchi session"], + ["native Pi tool", "native Kimchi tool"], + ["Pi extension UI", "Kimchi MCP UI"], + ["Pi-owned", "Kimchi-owned"], + ["Pi agent dir", "Kimchi agent dir"], + ["Pi global override", "Kimchi global override"], + ["project Pi override", "project Kimchi override"], + ["reload Pi", "reload Kimchi"], + ["Pi will reload", "Kimchi will reload"], + ["Pi should import", "Kimchi should import"], + ["Pi found", "Kimchi found"], + ["active in Pi", "active in Kimchi"], + ["into Pi", "into Kimchi"], + ["where Pi writes", "where Kimchi writes"], + ["Pi only writes", "Kimchi only writes"], + ["Pi writes", "Kimchi writes"], + ["shown by Pi", "shown by Kimchi"], + ["return to Pi", "return to Kimchi"], + ["Start Pi", "Start Kimchi"], +] as const + +const BRANDED_ADAPTER_RESULT_ERRORS = new Set(["input_required_needs_ui", "native_tool"]) + +const SUCCESS_PAGE = { title: "MCP Authorization Successful", heading: "MCP Authorization Successful" } +const MANUAL_SUCCESS_PAGE = { title: "MCP Authorization Received", heading: "MCP Authorization Received" } +const ERROR_PAGE = { title: "MCP Authorization Failed", heading: "MCP Authorization Failed" } + +function isKimchiAdapterPage(html: string): boolean { + return html.includes(ADAPTER_PAGE_MARKER) && html.includes(KIMCHI_APP_MARKER) +} + +function decodeAdapterHtml(value: string): string { + return value + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&") +} + +function preserveAutoClose(html: string, brandedHtml: string): string { + if (!html.includes(AUTO_CLOSE_SCRIPT)) return brandedHtml + return brandedHtml.replace("", ` ${AUTO_CLOSE_SCRIPT}\n`) +} + +/** Replace only product-owned phrases emitted by pi-mcp-adapter. */ +export function brandMcpAdapterText(text: string): string { + let branded = text + for (const [upstream, kimchi] of ADAPTER_TEXT_REPLACEMENTS) branded = branded.replaceAll(upstream, kimchi) + return branded +} + +/** Brand only results positively identified as adapter-owned, never MCP server content. */ +export function brandMcpAdapterOwnedToolResult(result: AgentToolResult): AgentToolResult { + const details = result.details + if ( + typeof details !== "object" || + details === null || + !("error" in details) || + typeof details.error !== "string" || + !BRANDED_ADAPTER_RESULT_ERRORS.has(details.error) + ) { + return result + } + return { + ...result, + content: result.content.map((block) => + block.type === "text" ? { ...block, text: brandMcpAdapterText(block.text) } : block, + ), + } +} + +/** + * Replace only the self-contained callback pages emitted by the pinned + * pi-mcp-adapter. OAuth state validation and callback lifecycle remain owned by + * the adapter; Kimchi owns the browser-facing brand contract. + */ +export function brandMcpOAuthCallbackHtml(html: string): string { + if (!isKimchiAdapterPage(html)) return html + + if (html.includes(SUCCESS_HEADING)) { + return preserveAutoClose(html, oauthSuccessHtml("You can close this window and return to Kimchi.", SUCCESS_PAGE)) + } + + if (html.includes(MANUAL_SUCCESS_HEADING)) { + return oauthSuccessHtml( + "Copy the full callback URL from your browser address bar and paste it back into Kimchi with auth-complete.", + MANUAL_SUCCESS_PAGE, + ) + } + + if (html.includes(ERROR_HEADING)) { + const details = /([\s\S]*?)<\/code>/.exec(html)?.[1] + return oauthErrorHtml( + "An error occurred during MCP authorization.", + details === undefined ? undefined : decodeAdapterHtml(details), + ERROR_PAGE, + ) + } + + return html +} + +/** Brand the two other adapter-owned browser documents without touching MCP App content. */ +export function brandMcpBrowserHtml(html: string): string { + const callbackHtml = brandMcpOAuthCallbackHtml(html) + if (callbackHtml !== html) return callbackHtml + if ( + (html.includes("MCP UI") && html.includes("Open the authenticated MCP UI URL shown by Pi.")) || + (html.includes('id="completion-overlay"') && html.includes("return to Pi.")) + ) { + return brandMcpAdapterText(html) + } + return html +} + +function brandedChunk(chunk: unknown): unknown { + if (typeof chunk === "string") return brandMcpBrowserHtml(chunk) + if (!Buffer.isBuffer(chunk)) return chunk + const html = chunk.toString("utf8") + const brandedHtml = brandMcpBrowserHtml(html) + return brandedHtml === html ? chunk : Buffer.from(brandedHtml, "utf8") +} + +function isRenderableComponent(value: unknown): value is Pick { + return typeof value === "object" && value !== null && "render" in value && typeof value.render === "function" +} + +function brandComponent(component: unknown): unknown { + if (!isRenderableComponent(component)) return component + return new Proxy(component, { + get(target, property, receiver) { + if (property === "render") { + return (width: number): string[] => target.render(width).map(brandMcpAdapterText) + } + const value = Reflect.get(target, property, receiver) + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + +function createBrandedMcpUi(ui: ExtensionUIContext): ExtensionUIContext { + return new Proxy(ui, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) + if (typeof value !== "function") return value + if (property === "custom") { + return (factory: (...args: unknown[]) => unknown, options?: unknown) => + Reflect.apply(value, target, [ + async (...args: unknown[]) => brandComponent(await Reflect.apply(factory, undefined, args)), + options, + ]) + } + if (property === "notify" || property === "select" || property === "confirm" || property === "input") { + return (...args: unknown[]) => { + const brandedArgs = args.map((arg) => + typeof arg === "string" + ? brandMcpAdapterText(arg) + : Array.isArray(arg) + ? arg.map((entry) => (typeof entry === "string" ? brandMcpAdapterText(entry) : entry)) + : arg, + ) + return Reflect.apply(value, target, brandedArgs) + } + } + return value.bind(target) + }, + }) +} + +/** Brand adapter command UI while leaving the rest of the session context intact. */ +export function createBrandedMcpContext(ctx: T): T { + const brandedUi = createBrandedMcpUi(ctx.ui) + return new Proxy(ctx, { + get(target, property, receiver) { + if (property === "ui") return brandedUi + const value = Reflect.get(target, property, receiver) + return typeof value === "function" ? value.bind(target) : value + }, + }) +} + +/** + * The adapter has no public callback-page renderer hook. Decorate its exact + * localhost HTML response until one exists, without importing private package + * modules or restoring the vendored OAuth server. + */ +export function installMcpOAuthCallbackBranding(): void { + const prototype = ServerResponse.prototype + if (Object.hasOwn(prototype, INSTALL_MARKER)) return + + const originalEnd = prototype.end + Object.defineProperty(prototype, INSTALL_MARKER, { value: true }) + Object.defineProperty(prototype, "end", { + configurable: true, + writable: true, + value: function brandedEnd(this: ServerResponse, ...args: unknown[]): ServerResponse { + if (args.length > 0) { + const originalChunk = args[0] + const replacement = brandedChunk(originalChunk) + const hasSentContentLength = this.headersSent && this.getHeader("content-length") !== undefined + if (replacement !== originalChunk && !hasSentContentLength) { + args[0] = replacement + if (!this.headersSent) this.removeHeader("content-length") + } + } + Reflect.apply(originalEnd, this, args) + return this + }, + }) +} diff --git a/src/extensions/mcp/project-trust.test.ts b/src/extensions/mcp/project-trust.test.ts new file mode 100644 index 000000000..5b9434390 --- /dev/null +++ b/src/extensions/mcp/project-trust.test.ts @@ -0,0 +1,118 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { ProjectTrustStore } from "@earendil-works/pi-coding-agent" +import type { McpConfig } from "pi-mcp-adapter/types" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { createContext } from "../__mocks__/context.js" +import { resolveMcpProjectTrust } from "./project-trust.js" + +const USER_CONFIG: McpConfig = { mcpServers: { personal: { command: "personal-server" } } } +const PROJECT_CONFIG: McpConfig = { + mcpServers: { + ...USER_CONFIG.mcpServers, + project: { command: "project-server" }, + }, +} + +let root: string +let cwd: string +let agentDir: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "kimchi-mcp-project-trust-")) + cwd = join(root, "project") + agentDir = join(root, "agent") + mkdirSync(cwd, { recursive: true }) + mkdirSync(agentDir, { recursive: true }) +}) + +afterEach(() => rmSync(root, { recursive: true, force: true })) + +function context(overrides: Parameters[0] = {}) { + return createContext({ cwd, isProjectTrusted: () => true, ...overrides }) +} + +describe("MCP project trust", () => { + it("does not ask when project discovery has no effect", async () => { + const ctx = context() + + await expect( + resolveMcpProjectTrust(ctx, { + projectConfig: USER_CONFIG, + userConfig: USER_CONFIG, + agentDir, + }), + ).resolves.toBe(true) + expect(ctx.ui.select).not.toHaveBeenCalled() + }) + + it("honors the session's existing untrusted decision", async () => { + const ctx = context({ isProjectTrusted: () => false }) + + await expect( + resolveMcpProjectTrust(ctx, { projectConfig: PROJECT_CONFIG, userConfig: USER_CONFIG, agentDir }), + ).resolves.toBe(false) + expect(ctx.ui.select).not.toHaveBeenCalled() + }) + + it("prompts for MCP-only project configuration and persists trust", async () => { + const ctx = context({ ui: { select: async () => "Trust" } }) + + await expect( + resolveMcpProjectTrust(ctx, { projectConfig: PROJECT_CONFIG, userConfig: USER_CONFIG, agentDir }), + ).resolves.toBe(true) + expect(new ProjectTrustStore(agentDir).get(cwd)).toBe(true) + }) + + it("supports session-only trust without persisting it", async () => { + const ctx = context({ ui: { select: async () => "Trust (this session only)" } }) + + await expect( + resolveMcpProjectTrust(ctx, { projectConfig: PROJECT_CONFIG, userConfig: USER_CONFIG, agentDir }), + ).resolves.toBe(true) + expect(new ProjectTrustStore(agentDir).get(cwd)).toBeNull() + }) + + it("fails closed without a terminal UI", async () => { + const ctx = context({ hasUI: false, mode: "print" }) + + await expect( + resolveMcpProjectTrust(ctx, { + projectConfig: PROJECT_CONFIG, + userConfig: USER_CONFIG, + agentDir, + defaultProjectTrust: "ask", + }), + ).resolves.toBe(false) + expect(ctx.ui.select).not.toHaveBeenCalled() + }) + + it("honors explicit trust overrides and global defaults", async () => { + const ctx = context() + await expect( + resolveMcpProjectTrust(ctx, { + projectConfig: PROJECT_CONFIG, + userConfig: USER_CONFIG, + agentDir, + explicitTrust: true, + }), + ).resolves.toBe(true) + await expect( + resolveMcpProjectTrust(ctx, { + projectConfig: PROJECT_CONFIG, + userConfig: USER_CONFIG, + agentDir, + explicitTrust: false, + }), + ).resolves.toBe(false) + await expect( + resolveMcpProjectTrust(ctx, { + projectConfig: PROJECT_CONFIG, + userConfig: USER_CONFIG, + agentDir, + defaultProjectTrust: "always", + }), + ).resolves.toBe(true) + }) +}) diff --git a/src/extensions/mcp/project-trust.ts b/src/extensions/mcp/project-trust.ts new file mode 100644 index 000000000..1c56fb3de --- /dev/null +++ b/src/extensions/mcp/project-trust.ts @@ -0,0 +1,77 @@ +import { isDeepStrictEqual } from "node:util" +import type { ExtensionContext } from "@earendil-works/pi-coding-agent" +import { + type DefaultProjectTrust, + getAgentDir, + hasTrustRequiringProjectResources, + ProjectTrustStore, + SettingsManager, +} from "@earendil-works/pi-coding-agent" +import type { McpConfig } from "pi-mcp-adapter/types" + +const TRUST = "Trust" +const TRUST_ONCE = "Trust (this session only)" +const DO_NOT_TRUST = "Do not trust" + +export const MCP_PROJECT_TRUST_WARNING = + "Project MCP configuration is not trusted. Kimchi did not start project-defined MCP servers; user-level MCP configuration remains available. Use /trust and restart Kimchi to change this decision." + +interface McpProjectTrustOptions { + projectConfig: McpConfig + userConfig: McpConfig + explicitTrust?: boolean + agentDir?: string + defaultProjectTrust?: DefaultProjectTrust +} + +function readDefaultProjectTrust(cwd: string, agentDir: string): DefaultProjectTrust | undefined { + try { + return SettingsManager.create(cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() + } catch { + return undefined + } +} + +/** + * Resolve whether MCP may consume project-derived configuration. + * + * Pi's project-trust detector does not currently include standard `.mcp.json` + * files, so a repository containing only MCP configuration is otherwise marked + * trivially trusted. Reuse Pi's persisted trust store and defaults, and ask only + * for this missing case. Existing Pi trust decisions remain authoritative. + */ +export async function resolveMcpProjectTrust( + ctx: Pick, + options: McpProjectTrustOptions, +): Promise { + if (isDeepStrictEqual(options.projectConfig, options.userConfig)) return true + if (options.explicitTrust !== undefined) return options.explicitTrust + if (!ctx.isProjectTrusted()) return false + if (hasTrustRequiringProjectResources(ctx.cwd)) return true + + const agentDir = options.agentDir ?? getAgentDir() + try { + const trustStore = new ProjectTrustStore(agentDir) + const storedDecision = trustStore.get(ctx.cwd) + if (storedDecision !== null) return storedDecision + + const defaultProjectTrust = options.defaultProjectTrust ?? readDefaultProjectTrust(ctx.cwd, agentDir) + if (defaultProjectTrust === "always") return true + if (defaultProjectTrust === "never" || !ctx.hasUI || ctx.mode !== "tui") return false + + const selected = await ctx.ui.select( + `Trust project MCP configuration?\n${ctx.cwd}\n\nThis allows Kimchi to start MCP servers defined by this project.`, + [TRUST, TRUST_ONCE, DO_NOT_TRUST], + ) + if (selected === TRUST) { + trustStore.set(ctx.cwd, true) + return true + } + if (selected === TRUST_ONCE) return true + if (selected === DO_NOT_TRUST) trustStore.set(ctx.cwd, false) + return false + } catch { + // Trust resolution is a security boundary: unexpected failures fail closed. + return false + } +} diff --git a/src/extensions/telemetry/config-snapshot.test.ts b/src/extensions/telemetry/config-snapshot.test.ts index 87797e4b7..b365ed335 100644 --- a/src/extensions/telemetry/config-snapshot.test.ts +++ b/src/extensions/telemetry/config-snapshot.test.ts @@ -106,7 +106,7 @@ describe("buildConfigSnapshot", () => { it("reflects config + mocked accessors with telemetry enabled", () => { const snapshot = buildConfigSnapshot(makeConfig(), true) - expect(snapshot["config.search_provider"]).toBe("bm25") + expect(snapshot["config.search_provider"]).toBe("weighted") expect(snapshot["config.telemetry_enabled"]).toBe(true) expect(snapshot["config.permission_mode"]).toBe("plan") expect(snapshot["config.agents_enabled"]).toBe(true) @@ -122,7 +122,7 @@ describe("buildConfigSnapshot", () => { expect(snapshot["config.telemetry_enabled"]).toBe(false) expect(snapshot["config.permission_mode"]).toBe("yolo") expect(snapshot["config.agents_enabled"]).toBe(false) - expect(snapshot["config.search_provider"]).toBe("regex") + expect(snapshot["config.search_provider"]).toBe("weighted") }) it("mcp_server_count equals total mocked server count across agent definitions", () => { diff --git a/src/extensions/telemetry/config-snapshot.ts b/src/extensions/telemetry/config-snapshot.ts index f224475ed..b25ca897e 100644 --- a/src/extensions/telemetry/config-snapshot.ts +++ b/src/extensions/telemetry/config-snapshot.ts @@ -36,6 +36,7 @@ export interface ConfigSnapshot { /** Default provider for this harness. */ const DEFAULT_PROVIDER = "cast-ai" +const MCP_ADAPTER_SEARCH_PROVIDER = "weighted" /** * Parse pi's `settings.json` (under `KIMCHI_CODING_AGENT_DIR`) once. @@ -144,14 +145,14 @@ function fallbackSnapshot(telemetryEnabled: boolean): ConfigSnapshot { * `discoverAgent`) can never crash the CLI launch — returns a minimal safe * fallback snapshot on any error. */ -export function buildConfigSnapshot(config: KimchiConfig, telemetryEnabled: boolean): ConfigSnapshot { +export function buildConfigSnapshot(_config: KimchiConfig, telemetryEnabled: boolean): ConfigSnapshot { try { const settings = readAgentSettings() const roles = getModelRoles() return { "config.model": resolveModel(settings), "config.provider": resolveProvider(settings), - "config.search_provider": config.mcpSearch.strategy, + "config.search_provider": MCP_ADAPTER_SEARCH_PROVIDER, "config.telemetry_enabled": telemetryEnabled, "config.permission_mode": getDefaultPermissionMode(), "config.agents_enabled": getMultiModelEnabled(null), diff --git a/src/utils/session-metadata-store.test.ts b/src/utils/session-metadata-store.test.ts index c7b65cee5..53fcdd742 100644 --- a/src/utils/session-metadata-store.test.ts +++ b/src/utils/session-metadata-store.test.ts @@ -106,7 +106,7 @@ describe("session-metadata-store", () => { // Config is a ConfigSnapshot (the 7 config.* keys). expect(Object.keys(meta.config).sort()).toEqual(EXPECTED_CONFIG_KEYS) - expect(meta.config["config.search_provider"]).toBe("bm25") + expect(meta.config["config.search_provider"]).toBe("weighted") expect(meta.config["config.telemetry_enabled"]).toBe(true) // capturedAt is a finite numeric epoch-ms timestamp. @@ -127,7 +127,7 @@ describe("session-metadata-store", () => { captureSessionStart(makeConfig({ mcpSearch: { ...SEARCH_STRATEGY, strategy: "regex" } }), true) const first = getSessionStartMetadata() if (first === undefined) throw new Error("expected first capture to be defined") - expect(first.config["config.search_provider"]).toBe("regex") + expect(first.config["config.search_provider"]).toBe("weighted") expect(first.config["config.telemetry_enabled"]).toBe(true) const beforeSecond = Date.now() @@ -135,7 +135,7 @@ describe("session-metadata-store", () => { const second = getSessionStartMetadata() if (second === undefined) throw new Error("expected second capture to be defined") - expect(second.config["config.search_provider"]).toBe("bm25") + expect(second.config["config.search_provider"]).toBe("weighted") expect(second.config["config.telemetry_enabled"]).toBe(false) expect(second.capturedAt).toBeGreaterThanOrEqual(beforeSecond) expect(second.capturedAt).toBeGreaterThanOrEqual(first.capturedAt) diff --git a/tests/e2e/acp/mcp-workflow.test.ts b/tests/e2e/acp/mcp-workflow.test.ts index 7fcb0a04a..ade73d669 100644 --- a/tests/e2e/acp/mcp-workflow.test.ts +++ b/tests/e2e/acp/mcp-workflow.test.ts @@ -1,7 +1,10 @@ +import { existsSync, writeFileSync } from "node:fs" +import { join } from "node:path" +import { setTimeout as delay } from "node:timers/promises" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { mcpToolResult } from "../tui/support/mcp-fixture.js" import { gatewayMcpCall, modelReply, toolResultText } from "../tui/support/mcp-model-script.js" -import { type AcpMcpFixture, STARTUP_TIMEOUT_MS, startAcpMcpFixture } from "./support/acp-fixture.js" +import { type AcpMcpFixture, STARTUP_TIMEOUT_MS, startAcpFixture, startAcpMcpFixture } from "./support/acp-fixture.js" import { newSession, prompt } from "./support/scenarios.js" describe("ACP integration — MCP", () => { @@ -153,3 +156,34 @@ describe("ACP integration — OAuth MCP", () => { expect(toolResultText(fixture.fake.requests, echo)).toContain("fixture echo: acp-oauth-mcp") }) }) + +describe("ACP integration — project MCP trust", () => { + it( + "does not execute repository MCP configuration in a headless session without trust", + async () => { + const fixture = await startAcpFixture({ artifactName: "acp-mcp-project-trust", responses: [] }) + try { + const sentinel = join(fixture.workDir, "project-mcp-started") + writeFileSync( + join(fixture.workDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { + untrusted: { + command: process.execPath, + args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "started")`], + }, + }, + }), + ) + + await newSession(fixture, fixture.workDir) + await delay(500) + + expect(existsSync(sentinel)).toBe(false) + } finally { + await fixture.stop() + } + }, + STARTUP_TIMEOUT_MS, + ) +}) diff --git a/tests/e2e/tui/mcp-browser-branding.test.ts b/tests/e2e/tui/mcp-browser-branding.test.ts new file mode 100644 index 000000000..a6883e7f6 --- /dev/null +++ b/tests/e2e/tui/mcp-browser-branding.test.ts @@ -0,0 +1,125 @@ +import { expect, test } from "@microsoft/tui-test" +import { fullText, STREAM_TIMEOUT_MS, waitForText } from "./support/assertions.js" +import { runMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" +import { modelReply, requireRequestAdvertisingTool, toolResultText } from "./support/mcp-model-script.js" + +test.use(TUI_TEST_CONFIG) + +test("shows the Kimchi-branded browser page after MCP OAuth authorization", async ({ terminal }) => { + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-browser-branding", + mcp: { transport: "oauth" }, + responses: [], + }, + async (fixture, trace) => { + await fixture.mcp.waitForEvent("http_unauthorized", { + description: "initial OAuth challenge", + }) + + terminal.submit("/mcp-auth fixture") + const browser = await fixture.mcp.waitForEvent("oauth_browser_completed", { + description: "browser loaded the OAuth callback page", + }) + + expect(browser.status).toBe(200) + expect(browser.hasKimchiBranding).toBe(true) + expect(browser.hasMcpSuccessCopy).toBe(true) + expect(browser.hasGenericAdapterBadge).toBe(false) + await fixture.mcp.waitForEvent("oauth_token_issued", { + where: { grantType: "authorization_code", pkceVerified: true }, + }) + await waitForText(terminal, "MCP: Reconnected to fixture", { timeoutMs: STREAM_TIMEOUT_MS }) + trace.step("browser displayed the Kimchi-branded MCP authorization result") + }, + ) +}) + +test("shows the Kimchi-branded browser page when MCP OAuth is denied", async ({ terminal }) => { + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-oauth-denial-branding", + mcp: { transport: "oauth", scenario: "oauth-deny" }, + responses: [], + }, + async (fixture, trace) => { + await fixture.mcp.waitForEvent("http_unauthorized", { + description: "initial OAuth challenge", + }) + + terminal.submit("/mcp-auth fixture") + const browser = await fixture.mcp.waitForEvent("oauth_browser_completed", { + description: "browser loaded the denied OAuth callback page", + }) + + expect(browser.status).toBe(200) + expect(browser.hasKimchiBranding).toBe(true) + expect(browser.hasMcpErrorCopy).toBe(true) + expect(browser.hasGenericAdapterBadge).toBe(false) + await fixture.mcp.waitForEvent("oauth_authorization_denied") + await waitForText(terminal, 'Failed to authenticate "fixture": fixture authorization denied', { + timeoutMs: STREAM_TIMEOUT_MS, + }) + trace.step("browser displayed the Kimchi-branded MCP authorization failure") + }, + ) +}) + +test("uses Kimchi product language in MCP setup", async ({ terminal }) => { + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-setup-branding", + mcp: {}, + responses: [], + }, + async (_fixture, trace) => { + terminal.submit("/mcp setup") + await waitForText(terminal, "Kimchi-owned", { timeoutMs: STREAM_TIMEOUT_MS }) + + expect(fullText(terminal)).not.toContain("Pi-owned") + trace.step("MCP setup rendered Kimchi-owned configuration language") + terminal.keyEscape() + }, + ) +}) + +test("advertises a truthful Kimchi-branded MCP gateway to the model", async ({ terminal }) => { + const nativeToolCallId = "call_mcp_native_tool_branding" + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-model-contract-branding", + mcp: {}, + responses: [ + { + toolCalls: [ + { + id: nativeToolCallId, + function: { name: "mcp", arguments: JSON.stringify({ tool: "read" }) }, + }, + ], + }, + modelReply("The MCP contract is visible."), + ], + }, + async (fixture, trace) => { + terminal.submit("Inspect the available MCP tools") + await waitForText(terminal, "The MCP contract is visible.", { timeoutMs: STREAM_TIMEOUT_MS }) + + const request = requireRequestAdvertisingTool(fixture.fake.requests, "mcp") + const tools = + (request.body as { tools?: Array<{ function?: { name?: string; description?: string } }> }).tools ?? [] + const gateway = tools.find((tool) => tool.function?.name === "mcp") + expect(gateway?.function?.description).toContain("Non-MCP Kimchi tools") + expect(gateway?.function?.description).not.toContain("mcpScript") + expect(tools.some((tool) => tool.function?.name === "mcpScript")).toBe(false) + const adapterResult = toolResultText(fixture.fake.requests, nativeToolCallId) + expect(adapterResult).toContain("native Kimchi tool") + expect(adapterResult).not.toContain("native Pi tool") + trace.step("model received a gateway description matching Kimchi's actual tool surface") + }, + ) +}) diff --git a/tests/e2e/tui/mcp-config.test.ts b/tests/e2e/tui/mcp-config.test.ts index 5e276aaa8..b1f8e26d7 100644 --- a/tests/e2e/tui/mcp-config.test.ts +++ b/tests/e2e/tui/mcp-config.test.ts @@ -71,6 +71,7 @@ test("legacy project MCP config wins a same-name standard project collision", as await exerciseSelectedConfig(terminal, { artifactName: "mcp-config-legacy-precedence", destination: (workDir) => join(workDir, ".kimchi", "mcp.json"), + extraArgs: ["--approve"], }) }) diff --git a/tests/e2e/tui/mcp-project-trust.test.ts b/tests/e2e/tui/mcp-project-trust.test.ts new file mode 100644 index 000000000..685fac740 --- /dev/null +++ b/tests/e2e/tui/mcp-project-trust.test.ts @@ -0,0 +1,74 @@ +import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { setTimeout as delay } from "node:timers/promises" +import { expect, test } from "@microsoft/tui-test" +import { STARTUP_TIMEOUT_MS, waitForText } from "./support/assertions.js" +import { runKimchiSession, runMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" + +test.use(TUI_TEST_CONFIG) + +function moveFixtureToProject(homeDir: string, workDir: string): void { + const source = join(homeDir, ".config", "kimchi", "harness", "mcp.json") + const destination = join(workDir, ".mcp.json") + mkdirSync(dirname(destination), { recursive: true }) + renameSync(source, destination) +} + +test("does not execute an untrusted repository MCP server during startup", async ({ terminal }) => { + let sentinel = "" + await runKimchiSession( + terminal, + { + artifactName: "mcp-project-trust-denied", + responses: [], + seedHome: (_homeDir, workDir) => { + sentinel = join(workDir, "project-mcp-started") + writeFileSync( + join(workDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { + untrusted: { + command: process.execPath, + args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "started")`], + }, + }, + }), + ) + }, + beforeReady: async (t) => { + await waitForText(t, "Trust project MCP configuration?", { timeoutMs: STARTUP_TIMEOUT_MS, full: false }) + expect(existsSync(sentinel)).toBe(false) + t.keyDown(2) + t.submit("") + }, + }, + async (_fixture, trace) => { + await delay(500) + expect(existsSync(sentinel)).toBe(false) + await waitForText(terminal, "Project MCP configuration is not trusted") + trace.step("repository MCP process remained stopped after trust was denied") + }, + ) +}) + +test("starts a repository MCP server after the user trusts the project", async ({ terminal }) => { + await runMcpKimchiSession( + terminal, + { + artifactName: "mcp-project-trust-accepted", + mcp: {}, + responses: [], + seedHome: moveFixtureToProject, + beforeReady: async (t) => { + await waitForText(t, "Trust project MCP configuration?", { timeoutMs: STARTUP_TIMEOUT_MS, full: false }) + t.submit("") + }, + }, + async (fixture, trace) => { + await fixture.mcp.waitForEvent("initialized", { + description: "trusted repository MCP server initialization", + }) + trace.step("trusted repository MCP server initialized normally") + }, + ) +}) diff --git a/tests/e2e/tui/mcp-ui.test.ts b/tests/e2e/tui/mcp-ui.test.ts index 2268b76de..67762057e 100644 --- a/tests/e2e/tui/mcp-ui.test.ts +++ b/tests/e2e/tui/mcp-ui.test.ts @@ -51,7 +51,9 @@ test("bridges an MCP App tool call and prompt back into the agent", async ({ ter terminal.submit("Open the fixture MCP App") await waitForText(terminal, "The MCP App opened.", { timeoutMs: STREAM_TIMEOUT_MS }) await fixture.mcp.waitForEvent("resource_read", { where: { uri: "ui://fixture/app" } }) - await fixture.mcp.waitForEvent("ui_host_loaded", { where: { status: 200 } }) + const hostPage = await fixture.mcp.waitForEvent("ui_host_loaded", { where: { status: 200 } }) + expect(hostPage.hasKimchiCompletionCopy).toBe(true) + expect(hostPage.hasPiCompletionCopy).toBe(false) const ui = fixture.mcp.ui expect(ui).toBeDefined() if (!ui) throw new Error("MCP UI fixture was not configured") diff --git a/tests/e2e/tui/support/mcp-fixture.ts b/tests/e2e/tui/support/mcp-fixture.ts index c1c624e7b..9a2500a06 100644 --- a/tests/e2e/tui/support/mcp-fixture.ts +++ b/tests/e2e/tui/support/mcp-fixture.ts @@ -42,7 +42,13 @@ interface McpFixtureEventDetails { oauth_token_issued: { grantType: string; expiresIn: number; pkceVerified?: boolean } oauth_token_rejected: { grantType?: string } oauth_browser_opened: Record - oauth_browser_completed: { status: number } + oauth_browser_completed: { + status: number + hasKimchiBranding: boolean + hasMcpErrorCopy: boolean + hasMcpSuccessCopy: boolean + hasGenericAdapterBadge: boolean + } http_request: { method?: string path: string @@ -59,7 +65,7 @@ interface McpFixtureEventDetails { process_stopping: { signal: string } process_exited: { code: number } ui_browser_opened: Record - ui_host_loaded: { status: number } + ui_host_loaded: { status: number; hasKimchiCompletionCopy: boolean; hasPiCompletionCopy: boolean } } export type McpFixtureEventType = keyof McpFixtureEventDetails @@ -487,7 +493,16 @@ if (!target) throw new Error("MCP UI browser driver did not receive an HTTP URL" writeFileSync(targetPath, JSON.stringify({ target }), "utf-8") appendFileSync(eventPath, JSON.stringify({ type: "ui_browser_opened", at: new Date().toISOString(), pid: process.pid, scenario: "ui-app" }) + "\\n") const response = await fetch(target) -appendFileSync(eventPath, JSON.stringify({ type: "ui_host_loaded", at: new Date().toISOString(), pid: process.pid, scenario: "ui-app", status: response.status }) + "\\n") +const body = await response.text() +appendFileSync(eventPath, JSON.stringify({ + type: "ui_host_loaded", + at: new Date().toISOString(), + pid: process.pid, + scenario: "ui-app", + status: response.status, + hasKimchiCompletionCopy: body.includes("return to Kimchi"), + hasPiCompletionCopy: body.includes("return to Pi"), +}) + "\\n") if (!response.ok) throw new Error(\`MCP UI browser driver received HTTP \${response.status}\`) `, "utf-8", @@ -568,7 +583,21 @@ const target = process.argv.find((argument) => argument.startsWith("http://") || if (!target) throw new Error("OAuth browser driver did not receive an HTTP URL") appendFileSync(eventPath, JSON.stringify({ type: "oauth_browser_opened", at: new Date().toISOString(), pid: process.pid, scenario: "oauth" }) + "\\n") const response = await fetch(target, { redirect: "follow" }) -appendFileSync(eventPath, JSON.stringify({ type: "oauth_browser_completed", at: new Date().toISOString(), pid: process.pid, scenario: "oauth", status: response.status }) + "\\n") +const body = await Promise.race([ + response.text().catch(() => ""), + new Promise((resolve) => setTimeout(() => resolve(""), 1_000)), +]) +appendFileSync(eventPath, JSON.stringify({ + type: "oauth_browser_completed", + at: new Date().toISOString(), + pid: process.pid, + scenario: "oauth", + status: response.status, + hasKimchiBranding: body.includes('class="logo-wrap"') && body.includes('fill="#FF521D"'), + hasMcpErrorCopy: body.includes('MCP Authorization Failed') && body.includes('An error occurred during MCP authorization.'), + hasMcpSuccessCopy: body.includes('MCP Authorization Successful') && body.includes('You can close this window and return to Kimchi.'), + hasGenericAdapterBadge: body.includes('class="badge ok"') || body.includes('class="badge bad"'), +}) + "\\n") if (!response.ok) throw new Error(\`OAuth browser driver received HTTP \${response.status}\`) `, "utf-8", @@ -576,6 +605,12 @@ if (!response.ok) throw new Error(\`OAuth browser driver received HTTP \${respon chmodSync(browserPath, 0o755) return { BROWSER: browserPath, + // Force xdg-open through $BROWSER even when tests run inside a Linux desktop + // session; otherwise it bypasses the deterministic driver via the desktop's + // registered HTTP handler. + DISPLAY: "", PATH: `${browserBinDir}:${process.env.PATH ?? ""}`, + WAYLAND_DISPLAY: "", + XDG_CURRENT_DESKTOP: "X-Generic", } } From 897c240867102b92560f92125df5e14303ef3c05 Mon Sep 17 00:00:00 2001 From: Mateusz Polnik Date: Sat, 5 Sep 2026 15:08:23 +0200 Subject: [PATCH 4/4] refactor(mcp): remove annotation catalog and exclude MCP tools from plan mode Remove the MCP annotation catalog, read-only tool provider registry, and name-based read-only heuristic that selectively exposed read-only MCP tools during planning. All MCP tools (gateway and direct) are now excluded from both planning-adhoc and planning-ferment profiles. The permissions extension now derives the plan-mode tool set from the unified tool catalog (getToolsForProfile("planning-adhoc")) instead of a duplicated hardcoded list, and the MCP adapter wrapper is simplified by dropping annotation-catalog wrapping and read-only provider registration. Deleted modules: - src/extensions/mcp/annotation-catalog.ts (+ tests) - src/extensions/mcp/read-only-tools.ts (+ tests) - src/shared/planning/read-only-tool-registry.ts (+ tests) Co-Authored-By: Kimchi --- docs/mcp-adapter-audit.md | 41 +-- package.json | 1 - pnpm-lock.yaml | 3 - src/extensions/mcp/annotation-catalog.test.ts | 133 --------- src/extensions/mcp/annotation-catalog.ts | 185 ------------ src/extensions/mcp/index.test.ts | 60 ++-- src/extensions/mcp/index.ts | 121 ++------ src/extensions/mcp/probe.ts | 37 +-- src/extensions/mcp/read-only-tools.test.ts | 23 -- src/extensions/mcp/read-only-tools.ts | 10 - src/extensions/permissions/index.test.ts | 8 +- src/extensions/permissions/index.ts | 47 +-- .../planning/read-only-tool-registry.test.ts | 101 ------- .../planning/read-only-tool-registry.ts | 100 ------- src/shared/planning/tool-catalog.test.ts | 20 +- src/shared/planning/tool-catalog.ts | 16 +- .../planning/tool-profile-manager.test.ts | 268 +++--------------- src/shared/planning/tool-profile-manager.ts | 28 +- tests/e2e/tui/mcp-stdio.test.ts | 72 +---- 19 files changed, 150 insertions(+), 1124 deletions(-) delete mode 100644 src/extensions/mcp/annotation-catalog.test.ts delete mode 100644 src/extensions/mcp/annotation-catalog.ts delete mode 100644 src/extensions/mcp/read-only-tools.test.ts delete mode 100644 src/extensions/mcp/read-only-tools.ts delete mode 100644 src/shared/planning/read-only-tool-registry.test.ts delete mode 100644 src/shared/planning/read-only-tool-registry.ts diff --git a/docs/mcp-adapter-audit.md b/docs/mcp-adapter-audit.md index 24e03818c..72fa050fe 100644 --- a/docs/mcp-adapter-audit.md +++ b/docs/mcp-adapter-audit.md @@ -124,37 +124,24 @@ Relevant code: ### Planning-mode safety and tool-profile integration -Kimchi must not expose write-capable MCP tools while a session is in plan -mode. The published adapter's narrowed cache metadata does not expose MCP -`annotations`, so the facade observes the raw public MCP client's `tools/list` -response and stores only the classification needed by Kimchi. - -The rules are intentionally fail-closed: - -- `readOnlyHint: true` is read-only. -- `readOnlyHint: false` is not read-only. -- contradictory observations are a conflict and are not read-only. -- a missing annotation may use the existing `get`, `search`, `list`, `read`, - or `fetch` name heuristic, but only after a real tool observation. -- an unknown tool is not read-only. - -Cached classifications are bound to the effective server configuration hash, -so changing a command, URL, headers, environment, auth, or tool filters makes -the old annotation cache ineligible. The classification applies both to direct -tools and gateway calls. A write or unknown gateway call attempted in plan mode returns -`plan_mode_write_blocked` before it reaches the MCP server. Session-scoped -state keeps concurrent extension/API wrappers from leaking profiles or -classifications between sessions. A planning snapshot is refreshed before the -agent starts, closing the race where direct tools finish registering after the -initial profile selection. +Kimchi exposes no model-facing MCP tools while a session is in plan mode. This +applies equally to the gateway and direct tools, regardless of protocol +annotations or tool names. The planning catalogs exclude the `mcp` gateway and +do not admit dynamically registered direct tools. A planning snapshot is +refreshed before the agent starts, closing the race where direct tools finish +registering after the initial profile selection. + +The facade also checks the active planning state when any adapter-owned tool is +executed. A stale or forced call returns `plan_mode_mcp_blocked` before it can +reach the MCP server. Outside plan mode, the gateway and direct tools retain +their normal behavior. This blanket policy avoids local annotation capture, +classification, and cache machinery. Relevant code: -- [`src/extensions/mcp/annotation-catalog.ts`](../src/extensions/mcp/annotation-catalog.ts) -- [`src/extensions/mcp/read-only-tools.ts`](../src/extensions/mcp/read-only-tools.ts) - [`src/extensions/mcp/index.ts`](../src/extensions/mcp/index.ts) - [`src/shared/planning/tool-session-scope.ts`](../src/shared/planning/tool-session-scope.ts) -- [`src/shared/planning/read-only-tool-registry.ts`](../src/shared/planning/read-only-tool-registry.ts) +- [`src/shared/planning/tool-catalog.ts`](../src/shared/planning/tool-catalog.ts) - [`src/shared/planning/tool-profile-manager.ts`](../src/shared/planning/tool-profile-manager.ts) - [`src/extensions/permissions/index.ts`](../src/extensions/permissions/index.ts) @@ -224,7 +211,7 @@ regressions: | OAuth callback branding | Users finish authorization on an unbranded package page or provider errors render unsafe HTML | Compiled-browser success/denial scenarios plus renderer and real HTTP-response unit tests | | Repository project trust | Opening a clone executes a project `.mcp.json` command during cache bootstrap | Compiled TUI accept/deny sentinel scenarios plus headless ACP denial and trust-resolution units | | Product/model branding | Setup, MCP App pages, or model guidance identifies Kimchi as Pi or recommends a hidden tool | Compiled setup/browser/model-contract scenarios plus exact-boundary units | -| Plan-mode race or classification leak | A write-capable direct or gateway MCP tool becomes callable during planning | TUI scenario with explicit `readOnlyHint: true` and `false`; assert the blocked call never reaches the fixture server; unit tests for unknown/conflicting annotations and multiple sessions | +| Plan-mode MCP exposure leak | A direct or gateway MCP tool becomes visible or callable during planning | TUI scenario asserts neither surface is advertised and no call reaches the fixture server; unit tests verify the planning catalog and defensive execution block | | ACP session isolation | One Desktop session sees another session's servers, or caller definitions lose precedence | ACP `session/new`/`session/load`, collision, direct-tool registration, and multi-session configuration tests | | Probe cleanup and OAuth isolation | Probe hangs, leaves a callback listener/process alive, or overwrites another server's credentials | CLI and compiled ACP probes for stdio, HTTP, timeout/failure, OAuth, and same-name/different-URL behavior | | Adapter startup and direct-tool synchronization | First request lacks tools, a restrictive profile is widened, or stale tools survive reconnect | TUI lifecycle, restart, stdio, failure, and planning scenarios | diff --git a/package.json b/package.json index d0f7677a0..431e2eb2c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "@earendil-works/pi-coding-agent": "0.84.1", "@earendil-works/pi-tui": "0.84.1", "@kimchi-dev/kimchi-workflows": "0.0.8", - "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.1", "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f85de5004..4c0ad4177 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,9 +40,6 @@ importers: '@kimchi-dev/kimchi-workflows': specifier: 0.0.8 version: 0.0.8(@earendil-works/pi-coding-agent@0.84.1(patch_hash=d3074927a86746b8c663af0b071a0ec301579ca4016b50bb1f162a8ec99fa36d)(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(supports-color@10.2.2)(ws@8.20.1)(zod@4.4.3))(@earendil-works/pi-tui@0.84.1(patch_hash=994f8b20d3f066d88c967e3bcdc4c86cbd603b33c556843cc9445bdce98ee602))(@opentelemetry/api@1.9.0)(typebox@1.3.7)(vite@7.3.3(@types/node@22.19.18)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.4)) - '@modelcontextprotocol/client': - specifier: 2.0.0 - version: 2.0.0 '@modelcontextprotocol/ext-apps': specifier: ^1.7.1 version: 1.7.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) diff --git a/src/extensions/mcp/annotation-catalog.test.ts b/src/extensions/mcp/annotation-catalog.test.ts deleted file mode 100644 index f84dd3410..000000000 --- a/src/extensions/mcp/annotation-catalog.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import { afterEach, describe, expect, it, vi } from "vitest" -import { McpAnnotationCatalog } from "./annotation-catalog.js" - -const temporaryDirectories: string[] = [] - -function createCatalog( - onChanged?: () => void, - sourceHash = "test-source", -): { catalog: McpAnnotationCatalog; cachePath: string } { - const directory = mkdtempSync(join(tmpdir(), "kimchi-mcp-annotations-")) - temporaryDirectories.push(directory) - const cachePath = join(directory, "annotations.json") - return { catalog: new McpAnnotationCatalog({ cachePath, onChanged, sourceHash }), cachePath } -} - -afterEach(() => { - for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }) -}) - -describe("McpAnnotationCatalog", () => { - it("uses explicit read-only annotations and rejects explicit false", () => { - const { catalog } = createCatalog() - catalog.record([ - { name: "mutate", description: "safe", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, - { - name: "get_reset", - description: "unsafe", - inputSchema: { type: "object" }, - annotations: { readOnlyHint: false }, - }, - ]) - - expect(catalog.isReadOnly("mutate", "safe")).toBe(true) - expect(catalog.isReadOnly("get_reset", "unsafe")).toBe(false) - }) - - it("uses the name fallback only after observing that annotations are absent", () => { - const { catalog } = createCatalog() - - expect(catalog.isReadOnly("get_issue", "Read issue")).toBe(false) - catalog.record([ - { name: "get_issue", description: "Read issue", inputSchema: { type: "object" } }, - { - name: "list_issues", - description: "List issues", - inputSchema: { type: "object" }, - annotations: { destructiveHint: false }, - }, - ]) - expect(catalog.isReadOnly("get_issue", "Read issue")).toBe(true) - expect(catalog.isReadOnly("list_issues", "List issues")).toBe(true) - }) - - it("fails closed when indistinguishable tool observations conflict", () => { - const { catalog } = createCatalog() - catalog.record([ - { name: "lookup", description: "Lookup", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, - ]) - catalog.record([ - { name: "lookup", description: "Lookup", inputSchema: { type: "object" }, annotations: { readOnlyHint: false } }, - ]) - - expect(catalog.isReadOnly("lookup", "Lookup")).toBe(false) - }) - - it("fails closed for gateway calls when same-named tools disagree across servers", () => { - const { catalog } = createCatalog() - catalog.record([ - { - name: "lookup", - description: "Safe lookup", - inputSchema: { type: "object" }, - annotations: { readOnlyHint: true }, - }, - { - name: "lookup", - description: "Mutating lookup", - inputSchema: { type: "object" }, - annotations: { readOnlyHint: false }, - }, - ]) - - expect(catalog.isReadOnlyByName("lookup")).toBe(false) - expect(catalog.isReadOnlyByName("unknown")).toBe(false) - }) - - it("recognizes a server-prefixed read-only tool at the gateway boundary", () => { - const { catalog } = createCatalog() - catalog.record([ - { - name: "get_safe", - description: "Read a safe value", - inputSchema: { type: "object" }, - annotations: { readOnlyHint: true }, - }, - ]) - - expect( - catalog.isReadOnlyGatewayTool("fixture_get_safe", "fixture", { - mcpServers: { fixture: { command: "fixture-server" } }, - }), - ).toBe(true) - }) - - it("persists observations with private file permissions", () => { - const changed = vi.fn() - const { catalog, cachePath } = createCatalog(changed) - catalog.record([{ name: "list_items", description: "List", inputSchema: { type: "object" } }]) - - expect(changed).toHaveBeenCalledOnce() - expect(JSON.parse(readFileSync(cachePath, "utf8"))).toMatchObject({ version: 2, sourceHash: "test-source" }) - const restored = new McpAnnotationCatalog({ cachePath, sourceHash: "test-source" }) - expect(restored.isReadOnly("list_items", "List")).toBe(true) - }) - - it("does not trust annotations cached for a different server configuration", () => { - const { catalog, cachePath } = createCatalog(undefined, "old-config") - catalog.record([ - { - name: "get_reset", - description: "Reset data", - inputSchema: { type: "object" }, - annotations: { readOnlyHint: true }, - }, - ]) - - const changedConfig = new McpAnnotationCatalog({ cachePath, sourceHash: "new-config" }) - expect(changedConfig.isReadOnly("get_reset", "Reset data")).toBe(false) - }) -}) diff --git a/src/extensions/mcp/annotation-catalog.ts b/src/extensions/mcp/annotation-catalog.ts deleted file mode 100644 index 48ad2edf9..000000000 --- a/src/extensions/mcp/annotation-catalog.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks" -import { createHash, randomUUID } from "node:crypto" -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" -import { Client, type ListToolsResult } from "@modelcontextprotocol/client" -import { computeServerHash, getMetadataCachePath } from "pi-mcp-adapter/metadata-cache" -import { getToolNameCandidates, type McpConfig, resolveToolPrefix, type ServerEntry } from "pi-mcp-adapter/types" -import { isReadOnlyMcpToolName } from "./read-only-tools.js" - -type ListedTool = ListToolsResult["tools"][number] -type AnnotationState = "missing" | "read-only" | "not-read-only" | "conflict" - -interface AnnotationCacheFile { - version: 2 - sourceHash: string - tools: Record -} - -const CAPTURE_CONTEXT = new AsyncLocalStorage() -const PATCH_MARKER = Symbol.for("kimchi.mcp.annotation-capture") -const CACHE_FILE = "mcp-annotations.json" - -function toolKey(name: string, description = ""): string { - return `${name}\0${description}` -} - -function annotationState(tool: ListedTool): AnnotationState { - if (tool.annotations?.readOnlyHint === true) return "read-only" - if (tool.annotations?.readOnlyHint === false) return "not-read-only" - return "missing" -} - -function mergeState(current: AnnotationState | undefined, next: AnnotationState): AnnotationState { - if (current === undefined || current === next) return next - return "conflict" -} - -function defaultCachePath(): string { - return join(dirname(getMetadataCachePath()), CACHE_FILE) -} - -export function mcpAnnotationSourceHash(config: Pick): string { - const serverHashes = Object.entries(config.mcpServers) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, definition]) => [name, computeServerHash(definition)]) - return createHash("sha256").update(JSON.stringify(serverHashes)).digest("hex") -} - -export class McpAnnotationCatalog { - private readonly tools = new Map() - private readonly cachePath: string - private readonly sourceHash: string - - constructor( - options: { - cachePath?: string - onChanged?: () => void - sourceHash?: string - } = {}, - ) { - this.cachePath = options.cachePath ?? defaultCachePath() - this.onChanged = options.onChanged - this.sourceHash = options.sourceHash ?? "unscoped" - this.load() - } - - private readonly onChanged: (() => void) | undefined - - record(tools: ListedTool[]): void { - let changed = false - for (const tool of tools) { - if (!tool?.name) continue - const key = toolKey(tool.name, tool.description) - const next = mergeState(this.tools.get(key), annotationState(tool)) - if (this.tools.get(key) === next) continue - this.tools.set(key, next) - changed = true - } - if (!changed) return - this.save() - this.onChanged?.() - } - - isReadOnly(originalName: string, description = ""): boolean { - const state = this.tools.get(toolKey(originalName, description)) - return this.isReadOnlyState(originalName, state) - } - - isReadOnlyByName(originalName: string): boolean { - const prefix = `${originalName}\0` - const states = [...this.tools].filter(([key]) => key.startsWith(prefix)).map(([, state]) => state) - return states.length > 0 && states.every((state) => this.isReadOnlyState(originalName, state)) - } - - isReadOnlyGatewayTool(gatewayName: string, serverName: string | undefined, config: McpConfig): boolean { - const servers: Array<[string, ServerEntry]> = serverName - ? config.mcpServers[serverName] - ? [[serverName, config.mcpServers[serverName]]] - : [] - : Object.entries(config.mcpServers) - const matches: Array<{ originalName: string; state: AnnotationState }> = [] - for (const [key, state] of this.tools) { - const originalName = key.slice(0, key.indexOf("\0")) - for (const [configuredServerName, definition] of servers) { - const prefix = resolveToolPrefix(definition, config.settings?.toolPrefix) - if (getToolNameCandidates(originalName, configuredServerName, prefix).has(gatewayName)) { - matches.push({ originalName, state }) - break - } - } - } - return matches.length > 0 && matches.every(({ originalName, state }) => this.isReadOnlyState(originalName, state)) - } - - private isReadOnlyState(originalName: string, state: AnnotationState | undefined): boolean { - if (state === "read-only") return true - if (state === "missing") return isReadOnlyMcpToolName(originalName) - return false - } - - private load(): void { - if (!existsSync(this.cachePath)) return - try { - const parsed = JSON.parse(readFileSync(this.cachePath, "utf8")) as Partial - if ( - parsed.version !== 2 || - parsed.sourceHash !== this.sourceHash || - !parsed.tools || - typeof parsed.tools !== "object" - ) - return - for (const [key, state] of Object.entries(parsed.tools)) { - if (["missing", "read-only", "not-read-only", "conflict"].includes(state)) { - this.tools.set(key, state) - } - } - } catch { - // A damaged advisory cache must never block MCP startup. Unknown tools - // remain excluded from read-only profiles until observed again. - } - } - - private save(): void { - try { - mkdirSync(dirname(this.cachePath), { recursive: true }) - const temporaryPath = `${this.cachePath}.${process.pid}.${randomUUID()}.tmp` - const payload: AnnotationCacheFile = { - version: 2, - sourceHash: this.sourceHash, - tools: Object.fromEntries(this.tools), - } - writeFileSync(temporaryPath, JSON.stringify(payload), { encoding: "utf8", mode: 0o600 }) - renameSync(temporaryPath, this.cachePath) - } catch (error) { - console.warn( - `[mcp] Failed to persist annotation cache: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } -} - -export function runWithMcpAnnotationCatalog(catalog: McpAnnotationCatalog, callback: () => T): T { - return CAPTURE_CONTEXT.run(catalog, callback) -} - -/** - * Observe the MCP SDK result before pi-mcp-adapter intentionally narrows its - * public metadata. This changes neither requests nor responses; it only keeps - * the protocol's readOnlyHint for Kimchi's planning policy. - */ -export function installMcpAnnotationCapture(): void { - const prototype = Client.prototype as typeof Client.prototype & { [PATCH_MARKER]?: boolean } - if (prototype[PATCH_MARKER]) return - - const originalListTools = prototype.listTools - Object.defineProperty(prototype, "listTools", { - configurable: true, - value: async function (...args: Parameters): Promise { - const result = await originalListTools.apply(this, args) - CAPTURE_CONTEXT.getStore()?.record(result.tools) - return result - }, - }) - prototype[PATCH_MARKER] = true -} diff --git a/src/extensions/mcp/index.test.ts b/src/extensions/mcp/index.test.ts index b0fb7c443..f8440aa57 100644 --- a/src/extensions/mcp/index.test.ts +++ b/src/extensions/mcp/index.test.ts @@ -25,7 +25,6 @@ const cliState = vi.hoisted(() => ({ noApprove: undefined as boolean | undefined, })) const planning = vi.hoisted(() => ({ - provider: undefined as (() => string[]) | undefined, applyCooperativeTweak: vi.fn(() => true), currentProfile: undefined as "planning-adhoc" | "planning-ferment" | "idle" | undefined, reapplyCurrentProfile: vi.fn(() => false), @@ -33,9 +32,6 @@ const planning = vi.hoisted(() => ({ const oauthMigration = vi.hoisted(() => ({ warnings: [] as string[] })) const oauthBranding = vi.hoisted(() => ({ install: vi.fn() })) const projectTrust = vi.hoisted(() => ({ trusted: true })) -const annotations = vi.hoisted(() => ({ - readOnly: new Set(), -})) vi.mock("pi-mcp-adapter", () => ({ MCP_STATUS_EVENT: "pi-mcp-adapter/status/v1", @@ -74,12 +70,6 @@ vi.mock("../../config.js", () => ({ getConfiguredLegacyMcpKeys: () => configState.legacyKeys, })) -vi.mock("../../shared/planning/read-only-tool-registry.js", () => ({ - registerReadOnlyToolProvider: (_pi: ExtensionAPI, provider: () => string[]) => { - planning.provider = provider - }, -})) - vi.mock("../../shared/planning/tool-profile-manager.js", () => ({ applyCooperativeTweak: planning.applyCooperativeTweak, getCurrentProfile: () => planning.currentProfile, @@ -105,24 +95,6 @@ vi.mock("./project-trust.js", () => ({ resolveMcpProjectTrust: vi.fn(async () => projectTrust.trusted), })) -vi.mock("./annotation-catalog.js", () => ({ - installMcpAnnotationCapture: vi.fn(), - mcpAnnotationSourceHash: vi.fn(() => "test-source"), - runWithMcpAnnotationCatalog: (_catalog: unknown, callback: () => unknown) => callback(), - McpAnnotationCatalog: class { - isReadOnly(originalName: string): boolean { - return annotations.readOnly.has(originalName) - } - isReadOnlyByName(originalName: string): boolean { - return annotations.readOnly.has(originalName) - } - isReadOnlyGatewayTool(toolName: string, serverName: string | undefined): boolean { - const originalName = serverName ? toolName.replace(`${serverName}_`, "") : toolName - return annotations.readOnly.has(originalName) - } - }, -})) - import mcpAdapterExtension, { createKimchiMcpAdapterExtension } from "./index.js" function tool(name: string, label: string): ToolDefinition { @@ -157,12 +129,10 @@ describe("upstream MCP adapter facade", () => { configState.legacyKeys = [] oauthMigration.warnings = [] oauthBranding.install.mockClear() - annotations.readOnly.clear() cliState.mcpConfig = undefined cliState.approve = undefined cliState.noApprove = undefined projectTrust.trusted = true - planning.provider = undefined planning.currentProfile = undefined planning.applyCooperativeTweak.mockClear() planning.reapplyCurrentProfile.mockClear() @@ -233,9 +203,8 @@ describe("upstream MCP adapter facade", () => { expect(planning.applyCooperativeTweak).toHaveBeenCalledWith(harness.api, ["read"]) }) - it("registers direct tools and exposes only read-only names to planning", async () => { + it("registers direct tools and reapplies the active profile", async () => { configState.config = { mcpServers: { docs: { command: "docs" } } } - annotations.readOnly.add("get_issue") const harness = createExtensionApi() mcpAdapterExtension(harness.api) await start(harness) @@ -244,7 +213,6 @@ describe("upstream MCP adapter facade", () => { upstream.api?.registerTool(tool("docs_delete_issue", "MCP: delete_issue")) expect(harness.getRegisteredTools().map(({ name }) => name)).toEqual(["docs_get_issue", "docs_delete_issue"]) - expect(planning.provider?.()).toEqual(["docs_get_issue"]) expect(planning.reapplyCurrentProfile).toHaveBeenCalledTimes(2) }) @@ -261,24 +229,23 @@ describe("upstream MCP adapter facade", () => { expect(planning.applyCooperativeTweak).not.toHaveBeenCalled() }) - it("blocks direct and gateway writes in planning profiles", async () => { + it("blocks all direct and gateway MCP calls in planning profiles", async () => { configState.config = { mcpServers: { docs: { command: "docs" } } } planning.currentProfile = "planning-adhoc" - annotations.readOnly.add("get_issue") - const directExecute = vi.fn(tool("docs_delete_issue", "MCP: delete_issue").execute) + const directExecute = vi.fn(tool("docs_get_issue", "MCP: get_issue").execute) const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) const harness = createExtensionApi() mcpAdapterExtension(harness.api) await start(harness) - upstream.api?.registerTool({ ...tool("docs_delete_issue", "MCP: delete_issue"), execute: directExecute }) + upstream.api?.registerTool({ ...tool("docs_get_issue", "MCP: get_issue"), execute: directExecute }) upstream.api?.registerTool({ ...tool("mcp", "MCP"), execute: gatewayExecute }) - const direct = harness.getRegisteredTools().find(({ name }) => name === "docs_delete_issue") + const direct = harness.getRegisteredTools().find(({ name }) => name === "docs_get_issue") const gateway = harness.getRegisteredTools().find(({ name }) => name === "mcp") const directResult = await direct?.execute("direct", {}, undefined, undefined, createContext()) const gatewayResult = await gateway?.execute( "gateway", - { tool: "delete_issue", args: {} }, + { tool: "get_issue", args: {} }, undefined, undefined, createContext(), @@ -286,14 +253,19 @@ describe("upstream MCP adapter facade", () => { expect(directExecute).not.toHaveBeenCalled() expect(gatewayExecute).not.toHaveBeenCalled() - expect(directResult).toMatchObject({ isError: true, details: { error: "plan_mode_write_blocked" } }) - expect(gatewayResult).toMatchObject({ isError: true, details: { error: "plan_mode_write_blocked" } }) + expect(directResult).toMatchObject({ + isError: true, + details: { error: "plan_mode_mcp_blocked", tool: "docs_get_issue" }, + }) + expect(gatewayResult).toMatchObject({ + isError: true, + details: { error: "plan_mode_mcp_blocked", tool: "mcp" }, + }) }) - it("allows a server-prefixed read-only gateway call in planning profiles", async () => { + it("allows MCP calls outside planning profiles", async () => { configState.config = { mcpServers: { docs: { command: "docs" } } } - planning.currentProfile = "planning-adhoc" - annotations.readOnly.add("get_issue") + planning.currentProfile = "idle" const gatewayExecute = vi.fn(tool("mcp", "MCP").execute) const harness = createExtensionApi() mcpAdapterExtension(harness.api) diff --git a/src/extensions/mcp/index.ts b/src/extensions/mcp/index.ts index f0ebde7dc..5c644b2e0 100644 --- a/src/extensions/mcp/index.ts +++ b/src/extensions/mcp/index.ts @@ -9,19 +9,12 @@ import { createMcpAdapter, MCP_STATUS_EVENT } from "pi-mcp-adapter" import type { McpAdapterOptions, McpConfig, ServerEntry } from "pi-mcp-adapter/types" import { getParsedCliArgs } from "../../cli-args.js" import { getConfiguredLegacyMcpKeys } from "../../config.js" -import { registerReadOnlyToolProvider } from "../../shared/planning/read-only-tool-registry.js" import { applyCooperativeTweak, getCurrentProfile, reapplyCurrentProfile, } from "../../shared/planning/tool-profile-manager.js" import { getPermissionMode } from "../permissions/mode-controller.js" -import { - installMcpAnnotationCapture, - McpAnnotationCatalog, - mcpAnnotationSourceHash, - runWithMcpAnnotationCatalog, -} from "./annotation-catalog.js" import { loadKimchiMcpConfig } from "./config.js" import { installKeyringRequireBridge } from "./keyring-require-bridge.js" import { @@ -35,7 +28,6 @@ import { MCP_PROJECT_TRUST_WARNING, resolveMcpProjectTrust } from "./project-tru const MCP_PROXY_TOOL = "mcp" const MCP_SCRIPT_TOOL = "mcpScript" -const MCP_DIRECT_TOOL_LABEL_PREFIX = "MCP: " const MCP_SCRIPT_RECOMMENDATION = "When one request needs several MCP calls with logic between them, use mcpScript. " function legacyMcpConfigWarning(cwd: string): string | undefined { @@ -45,17 +37,11 @@ function legacyMcpConfigWarning(cwd: string): string | undefined { } interface McpToolSurfacePolicy { - annotationCatalog: McpAnnotationCatalog - config: McpConfig - directTools: Map suppressedToolNames: Set } -function createMcpToolSurfacePolicy(config: McpConfig, annotationCatalog: McpAnnotationCatalog): McpToolSurfacePolicy { +function createMcpToolSurfacePolicy(config: McpConfig): McpToolSurfacePolicy { return { - annotationCatalog, - config, - directTools: new Map(), suppressedToolNames: new Set([ MCP_SCRIPT_TOOL, ...(Object.keys(config.mcpServers).length === 0 ? [MCP_PROXY_TOOL] : []), @@ -63,54 +49,22 @@ function createMcpToolSurfacePolicy(config: McpConfig, annotationCatalog: McpAnn } } -function getDirectToolOriginalName(tool: ToolDefinition): string | undefined { - if (tool.name === MCP_PROXY_TOOL || tool.name === MCP_SCRIPT_TOOL) return undefined - if (!tool.label.startsWith(MCP_DIRECT_TOOL_LABEL_PREFIX)) return undefined - const originalName = tool.label.slice(MCP_DIRECT_TOOL_LABEL_PREFIX.length).trim() - return originalName || undefined -} - -function planningBlockReason(originalName: string): string { - return `MCP tool "${originalName}" is not read-only according to its protocol annotations and is unavailable in plan mode.` +function isPlanningMode(pi: ExtensionAPI, ctx: ExtensionContext): boolean { + const profile = getCurrentProfile(pi) + const permissionMode = getPermissionMode(ctx.sessionManager.getSessionId())?.mode + const explicitPlan = getParsedCliArgs().options.plan === true + return explicitPlan || permissionMode === "plan" || profile === "planning-adhoc" || profile === "planning-ferment" } -function blockedPlanningResult(originalName: string) { - const reason = planningBlockReason(originalName) +function blockedPlanningResult(toolName: string) { + const reason = `MCP tool "${toolName}" is unavailable in plan mode.` return { content: [{ type: "text" as const, text: reason }], - details: { error: "plan_mode_write_blocked", tool: originalName, message: reason }, + details: { error: "plan_mode_mcp_blocked", tool: toolName, message: reason }, isError: true, } } -function blockedMcpToolInPlanning( - pi: ExtensionAPI, - policy: McpToolSurfacePolicy, - registeredName: string, - originalName: string | undefined, - params: unknown, - ctx: ExtensionContext, -): string | undefined { - const profile = getCurrentProfile(pi) - const permissionMode = getPermissionMode(ctx.sessionManager.getSessionId())?.mode - const explicitPlan = getParsedCliArgs().options.plan === true - if (!explicitPlan && permissionMode !== "plan" && profile !== "planning-adhoc" && profile !== "planning-ferment") - return undefined - if (originalName) - return policy.annotationCatalog.isReadOnly(originalName, policy.directTools.get(registeredName)?.description) - ? undefined - : originalName - if (registeredName !== MCP_PROXY_TOOL || !params || typeof params !== "object" || Array.isArray(params)) - return undefined - const gatewayParams = params as { server?: unknown; tool?: unknown } - const gatewayTool = gatewayParams.tool - if (typeof gatewayTool !== "string") return undefined - const gatewayServer = typeof gatewayParams.server === "string" ? gatewayParams.server : undefined - return policy.annotationCatalog.isReadOnlyGatewayTool(gatewayTool, gatewayServer, policy.config) - ? undefined - : gatewayTool -} - type UpstreamLifecycleHandler = ExtensionHandler type CapturedUpstreamEvent = "input" | "session_start" @@ -123,14 +77,12 @@ function createUpstreamApi( get(target, property) { if (property === "on") { return (event: string, handler: (event: unknown, ctx: unknown) => unknown): void => { - const wrapped = (eventValue: unknown, ctx: unknown) => - runWithMcpAnnotationCatalog(policy.annotationCatalog, () => handler(eventValue, ctx)) if (event === "session_start" || event === "input") { - captureHandler(event, (eventValue, ctx) => wrapped(eventValue, ctx)) + captureHandler(event, handler) return } const on = target.on as (event: string, handler: (event: unknown, ctx: unknown) => unknown) => void - on(event, wrapped) + on(event, handler) } } if (property === "registerTool") { @@ -143,26 +95,12 @@ function createUpstreamApi( ? brandMcpAdapterText(tool.description.replace(MCP_SCRIPT_RECOMMENDATION, "")) : tool.description, } - const originalName = getDirectToolOriginalName(brandedTool) - if (originalName) { - policy.directTools.set(brandedTool.name, { originalName, description: brandedTool.description }) - } const execute = brandedTool.execute.bind(brandedTool) target.registerTool({ ...brandedTool, - execute: (...args: Parameters) => { - const blockedTool = blockedMcpToolInPlanning( - target, - policy, - brandedTool.name, - originalName, - args[1], - args[4], - ) - if (blockedTool) return Promise.resolve(blockedPlanningResult(blockedTool)) - return runWithMcpAnnotationCatalog(policy.annotationCatalog, async () => - brandMcpAdapterOwnedToolResult(await execute(...args)), - ) + execute: async (...args: Parameters) => { + if (isPlanningMode(target, args[4])) return blockedPlanningResult(brandedTool.name) + return brandMcpAdapterOwnedToolResult(await execute(...args)) }, }) reapplyCurrentProfile(target) @@ -211,7 +149,6 @@ export function createKimchiMcpAdapterExtension(options: KimchiMcpAdapterExtensi function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterExtensionOptions): void { installKeyringRequireBridge() installMcpOAuthCallbackBranding() - installMcpAnnotationCapture() pi.registerFlag("mcp-config", { description: "Path to MCP config file", type: "string" }) let policy: McpToolSurfacePolicy | undefined const upstreamHandlers: Record = { @@ -220,18 +157,10 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE } let warnings: string[] = [] - registerReadOnlyToolProvider(pi, () => { - const currentPolicy = policy - if (!currentPolicy) return [] - return [...currentPolicy.directTools] - .filter(([, tool]) => currentPolicy.annotationCatalog.isReadOnly(tool.originalName, tool.description)) - .map(([toolName]) => toolName) - }) - // The adapter is installed after trust resolves, but its input readiness hook // must exist before extension event dispatch begins. Forward through this - // eagerly registered handler so cold-cache direct tools and annotations are - // ready for the first model request. + // eagerly registered handler so cold-cache direct tools are ready for the + // first model request. pi.on("input", async (event, ctx) => { for (const handler of upstreamHandlers.input) await handler(event, ctx) }) @@ -269,11 +198,7 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE ...(legacyConfigWarning === undefined ? [] : [legacyConfigWarning]), ...(projectTrusted ? [] : [MCP_PROJECT_TRUST_WARNING]), ] - const annotationCatalog = new McpAnnotationCatalog({ - sourceHash: mcpAnnotationSourceHash(config), - onChanged: () => reapplyCurrentProfile(pi), - }) - const installedPolicy = createMcpToolSurfacePolicy(config, annotationCatalog) + const installedPolicy = createMcpToolSurfacePolicy(config) policy = installedPolicy const adapterOptions: McpAdapterOptions = options.callerServers || selectedResult.useProgrammaticConfig @@ -281,13 +206,11 @@ function installMcpAdapterExtension(pi: ExtensionAPI, options: KimchiMcpAdapterE : selectedResult.configPath ? { configPath: selectedResult.configPath } : {} - runWithMcpAnnotationCatalog(installedPolicy.annotationCatalog, () => { - createMcpAdapter(adapterOptions)( - createUpstreamApi(pi, installedPolicy, (upstreamEvent, handler) => { - upstreamHandlers[upstreamEvent].push(handler) - }), - ) - }) + createMcpAdapter(adapterOptions)( + createUpstreamApi(pi, installedPolicy, (upstreamEvent, handler) => { + upstreamHandlers[upstreamEvent].push(handler) + }), + ) } for (const warning of warnings) { diff --git a/src/extensions/mcp/probe.ts b/src/extensions/mcp/probe.ts index 768acc75b..b16b3c355 100644 --- a/src/extensions/mcp/probe.ts +++ b/src/extensions/mcp/probe.ts @@ -10,12 +10,6 @@ import type { import { createMcpAdapter } from "pi-mcp-adapter" import { inspectMcpOAuthTokensForUrl } from "pi-mcp-adapter/oauth" import type { ServerEntry } from "pi-mcp-adapter/types" -import { - installMcpAnnotationCapture, - McpAnnotationCatalog, - mcpAnnotationSourceHash, - runWithMcpAnnotationCatalog, -} from "./annotation-catalog.js" import { installKeyringRequireBridge } from "./keyring-require-bridge.js" export interface ProbeTool { @@ -188,33 +182,22 @@ function resolveProbeName(name: string, definition: ServerEntry): string { } } -async function emitHandlers( - host: ProbeHost, - event: "session_start" | "session_shutdown", - catalog: McpAnnotationCatalog, -): Promise { +async function emitHandlers(host: ProbeHost, event: "session_start" | "session_shutdown"): Promise { const payload = event === "session_start" ? { type: event, reason: "startup" } : { type: event, reason: "shutdown" } for (const handler of host.handlers.get(event) ?? []) { - await runWithMcpAnnotationCatalog(catalog, () => handler(payload, host.context)) + await handler(payload, host.context) } } -async function executeGateway( - host: ProbeHost, - params: Record, - catalog: McpAnnotationCatalog, -): Promise { +async function executeGateway(host: ProbeHost, params: Record): Promise { const gateway = host.tools.get("mcp") if (!gateway) throw new Error("pi-mcp-adapter did not register its MCP gateway") - return runWithMcpAnnotationCatalog(catalog, () => - gateway.execute(`probe-${randomUUID()}`, params, host.context.signal, undefined, host.context), - ) + return gateway.execute(`probe-${randomUUID()}`, params, host.context.signal, undefined, host.context) } export class UpstreamMcpProbe implements McpProbe { async probeTools(name: string, definition: ServerEntry, options: McpProbeOptions = {}): Promise { installKeyringRequireBridge() - installMcpAnnotationCapture() const cwd = options.cwd ?? process.cwd() const probeName = resolveProbeName(name, definition) const throwaway = probeName !== name @@ -232,12 +215,10 @@ export class UpstreamMcpProbe implements McpProbe { elicitation: false, }, } - const catalog = new McpAnnotationCatalog({ sourceHash: mcpAnnotationSourceHash(config) }) - try { - runWithMcpAnnotationCatalog(catalog, () => createMcpAdapter({ config })(host.api)) - await emitHandlers(host, "session_start", catalog) - const connected = await executeGateway(host, { connect: probeName }, catalog) + createMcpAdapter({ config })(host.api) + await emitHandlers(host, "session_start") + const connected = await executeGateway(host, { connect: probeName }) const details = resultDetails(connected) if (details.error === "auth_required") { return { @@ -255,7 +236,7 @@ export class UpstreamMcpProbe implements McpProbe { : [] const tools = await Promise.all( names.map(async (toolName): Promise => { - const described = await executeGateway(host, { describe: toolName }, catalog) + const described = await executeGateway(host, { describe: toolName }) const tool = resultDetails(described).tool const description = tool && typeof tool === "object" && typeof (tool as { description?: unknown }).description === "string" @@ -273,7 +254,7 @@ export class UpstreamMcpProbe implements McpProbe { ?.handler(`logout ${probeName}`, commandContext) .catch(() => {}) } - await emitHandlers(host, "session_shutdown", catalog).catch(() => {}) + await emitHandlers(host, "session_shutdown").catch(() => {}) } } } diff --git a/src/extensions/mcp/read-only-tools.test.ts b/src/extensions/mcp/read-only-tools.test.ts deleted file mode 100644 index edcc40d42..000000000 --- a/src/extensions/mcp/read-only-tools.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest" -import { isReadOnlyMcpToolName } from "./read-only-tools.js" - -describe("isReadOnlyMcpToolName", () => { - it.each([ - "get_issue", - "search_docs", - "list_projects", - "read_file", - "fetch_url", - ])("classifies %s as read-only", (name) => { - expect(isReadOnlyMcpToolName(name)).toBe(true) - }) - - it.each([ - "create_issue", - "update_page", - "delete_project", - "reset_database", - ])("does not classify %s as read-only", (name) => { - expect(isReadOnlyMcpToolName(name)).toBe(false) - }) -}) diff --git a/src/extensions/mcp/read-only-tools.ts b/src/extensions/mcp/read-only-tools.ts deleted file mode 100644 index d0e00e549..000000000 --- a/src/extensions/mcp/read-only-tools.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Conservative fallback used while upstream does not expose MCP annotations - * through its public tool-surface API. Explicit annotations will replace this - * name heuristic once the public API carries them. - */ -const READ_ONLY_NAME_PREFIXES = /^(get|search|list|read|fetch)/ - -export function isReadOnlyMcpToolName(originalName: string): boolean { - return READ_ONLY_NAME_PREFIXES.test(originalName) -} diff --git a/src/extensions/permissions/index.test.ts b/src/extensions/permissions/index.test.ts index 9600121c7..b74ca1dbf 100644 --- a/src/extensions/permissions/index.test.ts +++ b/src/extensions/permissions/index.test.ts @@ -298,17 +298,15 @@ describe("permissions plan-mode tool visibility", () => { expect(harness.pi.setActiveTools).toHaveBeenCalledTimes(initialApplications + 1) }) - it("allows the mcp gateway tool under explicit --plan", async () => { + it("hides and blocks the mcp gateway tool under explicit --plan", async () => { const harness = createPermissionsHarness(["read", "mcp"], { plan: true }) await harness.fire("session_start", {}, createMockContext([])) - // mcp must be in the active set (cataloged as shared core) - expect(harness.activeTools().sort()).toEqual(["mcp", "read"]) - // And the tool_call gate must not block it + expect(harness.activeTools()).toEqual(["read"]) await expect( harness.fire("tool_call", { toolName: "mcp", input: { search: "jira" } }, createMockContext([])), - ).resolves.toBeUndefined() + ).resolves.toEqual(expect.objectContaining({ block: true })) }) it("blocks read calls targeting directories before upstream read", async () => { diff --git a/src/extensions/permissions/index.ts b/src/extensions/permissions/index.ts index 41a9bb207..aebafa9dd 100644 --- a/src/extensions/permissions/index.ts +++ b/src/extensions/permissions/index.ts @@ -25,6 +25,7 @@ import { shouldNudge, } from "../../shared/planning/planning-stop-nudge.js" import * as PromptSupplementRegistry from "../../shared/planning/prompt-supplement-registry.js" +import { getToolsForProfile } from "../../shared/planning/tool-catalog.js" import * as ToolProfileManager from "../../shared/planning/tool-profile-manager.js" import { isAgentWorker } from "../agent-worker-context.js" import { createFerment } from "../ferment/create.js" @@ -114,45 +115,9 @@ const EMPTY_LOADED_CONFIG: LoadedConfig = { paths: {}, } -// bash is allowed but gated per-command by isReadOnlyBashCommand. -const PLAN_MODE_TOOLS = [ - "read", - "grep", - "find", - "ls", - "web_search", - "web_fetch", - "mcp", - "questionnaire", - "submit_plan", - "bash", - ...TODO_TOOL_NAMES, - // DAP debugger tools — available in plan mode by product decision: the - // debugger is the fastest way to investigate an issue the user is asking - // to plan a fix for. NOTE: this is NOT a read-only allowance — - // debug_launch executes the program (with args/env) and debug_eval runs - // arbitrary expressions in the debuggee, so plan mode can observe runtime - // behavior at the cost of executing user code. This mirrors how plan mode - // already permits read-only bash probing; side effects of the debuggee - // itself are out of scope for the gate. - "debug_launch", - "debug_set_breakpoint", - "debug_continue", - "debug_locals", - "debug_eval", - "debug_backtrace", - "debug_terminate", - "step_in", - "step_over", - "step_out", - "debug_state_at", - "debug_last_error", - "debug_trace_calls", - "debug_watch_change", - "debug_set_variable", - "debug_restart", -] -const PLAN_MODE_TOOL_SET = new Set(PLAN_MODE_TOOLS) +// The unified catalog owns plan-mode visibility, including deliberate +// exceptions such as read-only bash and debugger tools. +const PLAN_MODE_TOOL_SET = new Set(getToolsForProfile("planning-adhoc").map((tool) => tool.name)) // Tools that auto-approve in headless/auto modes without LLM classification. // `set_phase` is a kimchi built-in. `agent`/`get_subagent_result`/`steer_subagent` @@ -600,8 +565,8 @@ export default function permissionsExtension(pi: ExtensionAPI): void { maybePersistPermissionMode(ctx) if (getRuntimePermissionMode().mode === "plan") { // MCP direct tools can finish registering after session_start. Rebuild - // the snapshot immediately before every model request so protocol - // annotation policy, rather than registration timing, decides exposure. + // the snapshot immediately before every model request so late + // registration cannot widen the restricted tool surface. ToolProfileManager.apply("planning-adhoc", "adhoc", pi) } }) diff --git a/src/shared/planning/read-only-tool-registry.test.ts b/src/shared/planning/read-only-tool-registry.test.ts deleted file mode 100644 index 3709f60b1..000000000 --- a/src/shared/planning/read-only-tool-registry.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { describe, expect, it, vi } from "vitest" - -import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" -import { getReadOnlyToolNames, registerReadOnlyToolProvider } from "./read-only-tool-registry.js" - -/** - * Build a fresh mock ExtensionAPI. Each call returns a new object so the - * WeakMap keys are distinct per test — providers registered in one test never - * leak into another, even without an explicit clear(). - */ -const makeMockPi = (events = createMiniEventBus().events): ExtensionAPI => { - const on = vi.fn() - return { events, on } as unknown as ExtensionAPI -} - -describe("read-only-tool-registry", () => { - it("returns an empty array when no providers are registered", () => { - const pi = makeMockPi() - - expect(getReadOnlyToolNames(pi)).toEqual([]) - }) - - it("returns the union of names from two registered providers", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record", "server_search_items"]) - registerReadOnlyToolProvider(pi, () => ["server_list_things", "server_read_doc"]) - - const result = getReadOnlyToolNames(pi) - - expect(result).toEqual(["server_get_record", "server_search_items", "server_list_things", "server_read_doc"]) - }) - - it("deduplicates names that appear in multiple providers", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record", "server_shared_tool"]) - registerReadOnlyToolProvider(pi, () => ["server_shared_tool", "server_list_things"]) - - const result = getReadOnlyToolNames(pi) - - // "server_shared_tool" must appear exactly once. - expect(result.filter((n) => n === "server_shared_tool")).toHaveLength(1) - expect(result).toEqual(["server_get_record", "server_shared_tool", "server_list_things"]) - }) - - it("reflects the latest state when a provider is called lazily", () => { - const pi = makeMockPi() - let current: string[] = ["server_get_record"] - registerReadOnlyToolProvider(pi, () => current) - - expect(getReadOnlyToolNames(pi)).toEqual(["server_get_record"]) - - // Mutate the provider's data source — the next read should reflect it. - current = ["server_get_record", "server_search_items"] - expect(getReadOnlyToolNames(pi)).toEqual(["server_get_record", "server_search_items"]) - }) - - it("ignores providers registered under a different pi instance", () => { - const piA = makeMockPi() - const piB = makeMockPi() - registerReadOnlyToolProvider(piA, () => ["server_get_record"]) - - // piB has no providers — must return empty even though piA has one. - expect(getReadOnlyToolNames(piB)).toEqual([]) - }) - - it("shares providers across extension wrappers in the same session", () => { - const events = createMiniEventBus().events - const providerApi = makeMockPi(events) - const consumerApi = makeMockPi(events) - registerReadOnlyToolProvider(providerApi, () => ["server_get_record"]) - - expect(getReadOnlyToolNames(consumerApi)).toEqual(["server_get_record"]) - }) - - it("registers a session_shutdown listener on first registration", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record"]) - - expect(pi.on).toHaveBeenCalledWith("session_shutdown", expect.any(Function)) - }) - - it("does not double-register the same provider reference", () => { - const pi = makeMockPi() - const provider = (): string[] => ["server_get_record"] - registerReadOnlyToolProvider(pi, provider) - registerReadOnlyToolProvider(pi, provider) - - // Even after two registrations of the same fn, only one call site — - // but getReadOnlyToolNames should still return the names once. - expect(getReadOnlyToolNames(pi)).toEqual(["server_get_record"]) - }) - - it("handles a provider that returns an empty array", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => []) - registerReadOnlyToolProvider(pi, () => ["server_get_record"]) - - expect(getReadOnlyToolNames(pi)).toEqual(["server_get_record"]) - }) -}) diff --git a/src/shared/planning/read-only-tool-registry.ts b/src/shared/planning/read-only-tool-registry.ts deleted file mode 100644 index 3b21bf1aa..000000000 --- a/src/shared/planning/read-only-tool-registry.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * # Read-only tool provider registry - * - * Allows extensions to register a provider function that returns the names of - * read-only-qualified tools they own. The planning-ferment tool-profile layer - * (`applyCore` in `tool-profile-manager.ts`) consults `getReadOnlyToolNames` - * to union these names into the active set during scoping — the only profile - * where write tools are blocked by default. - * - * The registry is keyed on a session identity shared through pi-mono's event - * bus. Each extension receives a distinct `ExtensionAPI` wrapper, so the - * wrapper itself cannot be used for cross-extension state. - * - * ## Why a registry? - * - * The shared/planning layer must not import from `src/extensions/mcp` - * directly (that would invert the dependency). Instead, the MCP adapter - * wrapper registers a provider at init time; `applyCore` calls - * `getReadOnlyToolNames` without knowing which extensions contributed. - */ - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" -import { getToolSessionScope } from "./tool-session-scope.js" - -/** A function that returns the current set of read-only-qualified tool names. */ -export type ReadOnlyToolProvider = () => string[] - -let providersByScope = new WeakMap() - -/** - * Register a read-only-tool provider for the given session. - * - * Multiple providers may be registered per session; `getReadOnlyToolNames` - * unions all results. Registration is idempotent per function reference — - * registering the same provider twice has no effect. - * - * @param pi - The pi-mono `ExtensionAPI` instance for this session. - * @param provider - A function returning the read-only-qualified tool names. - * Called lazily on each `getReadOnlyToolNames` invocation so - * it always reflects the current tool-metadata state. - */ -export function registerReadOnlyToolProvider(pi: ExtensionAPI, provider: ReadOnlyToolProvider): void { - const scope = getToolSessionScope(pi) - let providers = providersByScope.get(scope) - if (!providers) { - providers = [] - providersByScope.set(scope, providers) - // Clean up on session shutdown. We never touch `pi` from inside the - // handler — pi-mono marks the runtime stale at this point. - pi.on("session_shutdown", () => { - providersByScope.delete(scope) - }) - } - if (providers.includes(provider)) return - providers.push(provider) -} - -/** - * Return the union of all read-only-qualified tool names from registered - * providers for this session. Returns an empty array when no providers are - * registered. Duplicates across providers are collapsed. - * - * @param pi - The pi-mono `ExtensionAPI` instance for this session. - */ -export function getReadOnlyToolNames(pi: ExtensionAPI): string[] { - const providers = providersByScope.get(getToolSessionScope(pi)) - if (!providers || providers.length === 0) return [] - const seen = new Set() - const result: string[] = [] - for (const provider of providers) { - // A misbehaving provider must not break the planning phase — log and - // skip it, then continue with the remaining providers. - let names: string[] - try { - names = provider() - } catch (err) { - console.error("read-only tool provider threw, skipping", err) - continue - } - for (const name of names) { - if (!seen.has(name)) { - seen.add(name) - result.push(name) - } - } - } - return result -} - -/** - * Reset the registry. Exported for test isolation so each test starts with a - * clean WeakMap. Replaces the underlying WeakMap so any references held by - * previously-registered providers (via `session_shutdown` listeners) cannot - * keep stale entries alive. - * - * @internal — test-only. - */ -export function resetReadOnlyToolRegistry(): void { - providersByScope = new WeakMap() -} diff --git a/src/shared/planning/tool-catalog.test.ts b/src/shared/planning/tool-catalog.test.ts index 9bedda644..184d4a52f 100644 --- a/src/shared/planning/tool-catalog.test.ts +++ b/src/shared/planning/tool-catalog.test.ts @@ -213,8 +213,8 @@ describe("getToolsForProfile", () => { const result = getToolsForProfile("planning-adhoc") const names = namesOf(result) - it("includes all SHARED_CORE_TOOLS", () => { - for (const name of TOOL_NAMES.sharedCore) { + it("includes shared core tools other than MCP", () => { + for (const name of TOOL_NAMES.sharedCore.filter((name) => name !== "mcp")) { expect(names).toContain(name) } }) @@ -229,8 +229,8 @@ describe("getToolsForProfile", () => { expect(names).toContain("bash") }) - it("includes the mcp gateway", () => { - expect(names).toContain("mcp") + it("does NOT include the mcp gateway", () => { + expect(names).not.toContain("mcp") }) it("does NOT include ferment-only tools", () => { @@ -247,10 +247,6 @@ describe("getToolsForProfile", () => { } }) - it("includes the mcp gateway", () => { - expect(names).toContain("mcp") - }) - it("does NOT include write tools other than bash", () => { const writeTools = ["edit", "write", "Agent", "get_subagent_result"] for (const name of writeTools) { @@ -263,12 +259,16 @@ describe("getToolsForProfile", () => { const result = getToolsForProfile("planning-ferment") const names = namesOf(result) - it("includes all SHARED_CORE_TOOLS", () => { - for (const name of TOOL_NAMES.sharedCore) { + it("includes shared core tools other than MCP", () => { + for (const name of TOOL_NAMES.sharedCore.filter((name) => name !== "mcp")) { expect(names).toContain(name) } }) + it("does NOT include the mcp gateway", () => { + expect(names).not.toContain("mcp") + }) + it("includes ferment tools visible in planning", () => { for (const name of TOOL_NAMES.fermentPlanningTools) { expect(names).toContain(name) diff --git a/src/shared/planning/tool-catalog.ts b/src/shared/planning/tool-catalog.ts index 3c47c6c02..d82428c5c 100644 --- a/src/shared/planning/tool-catalog.ts +++ b/src/shared/planning/tool-catalog.ts @@ -104,10 +104,8 @@ export const SHARED_CORE_TOOLS: ToolEntry[] = [ // planning profiles so the step can return its result or questions. { name: "workflow_submit_result", modes: ["shared"] }, { name: "workflow_submit_questions", modes: ["shared"] }, - // MCP gateway — discovery + proxy for MCP server tools. Treated as a - // shared discovery tool (analogous to read/grep/find): harmless when no - // servers are configured, and required during planning so the model can - // search/describe/call read-only MCP tools (e.g. Atlassian Jira). + // MCP gateway — discovery + proxy for MCP server tools. Available in normal + // and implementation modes, but filtered out of both planning profiles. { name: "mcp", modes: ["shared"] }, // DAP debugger tools — always available in every mode/profile so the agent // can inspect runtime state at any time. Registered by the dap extension. @@ -168,6 +166,8 @@ export function isAdhocOnlyToolName(name: string): boolean { */ export const SHARED_PLANNING_TOOLS: ToolEntry[] = [{ name: "submit_plan", modes: ["adhoc"] }] +const PLANNING_CORE_TOOLS = SHARED_CORE_TOOLS.filter((tool) => tool.name !== "mcp") + /** * Tools gated behind the ferment lifecycle. * @@ -262,8 +262,8 @@ export const WRITE_TOOLS: ToolEntry[] = [ * Profile encoding: * - `'idle'` → SHARED_CORE_TOOLS only (no write; no ferment tools) * - `'worker'` → [] (managed externally by the agents manager) - * - `'planning-adhoc'` → SHARED_CORE_TOOLS + ADHOC_MODE_TOOLS + bash - * - `'planning-ferment'` → SHARED_CORE_TOOLS + ferment tools visible in planning + * - `'planning-adhoc'` → SHARED_CORE_TOOLS minus MCP + ADHOC_MODE_TOOLS + bash + * - `'planning-ferment'` → SHARED_CORE_TOOLS minus MCP + ferment tools visible in planning * - `'implementation-ferment'` → SHARED_CORE_TOOLS + all ferment tools + all write tools * * TODO: Consider accepting a predicate/context (e.g. @@ -284,7 +284,7 @@ export function getToolsForProfile(profile: ToolProfile): ToolEntry[] { case "planning-adhoc": return [ - ...SHARED_CORE_TOOLS, + ...PLANNING_CORE_TOOLS, ...ADHOC_MODE_TOOLS, ...SHARED_PLANNING_TOOLS, // bash is the only write tool in adhoc planning mode @@ -293,7 +293,7 @@ export function getToolsForProfile(profile: ToolProfile): ToolEntry[] { case "planning-ferment": { const ferment = FERMENT_MODE_TOOLS.filter((t) => t.phases === undefined || t.phases.includes("planning")) - return [...SHARED_CORE_TOOLS, ...ferment] + return [...PLANNING_CORE_TOOLS, ...ferment] } case "implementation-ferment": diff --git a/src/shared/planning/tool-profile-manager.test.ts b/src/shared/planning/tool-profile-manager.test.ts index c317352b3..b8a51e55f 100644 --- a/src/shared/planning/tool-profile-manager.test.ts +++ b/src/shared/planning/tool-profile-manager.test.ts @@ -2,7 +2,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { beforeEach, describe, expect, it, vi } from "vitest" import { createMiniEventBus } from "../../extensions/__mocks__/mini-event-bus.js" import { createToolVisibility } from "../../extensions/prompt-construction/tool-visibility.js" -import { registerReadOnlyToolProvider, resetReadOnlyToolRegistry } from "./read-only-tool-registry.js" import { getToolsForProfile } from "./tool-catalog.js" import { apply, @@ -39,14 +38,10 @@ const makeMockPi = ( } as unknown as ExtensionAPI } -// Reset both module-level state variables before every test so runs are -// fully independent even though the ESM module is evaluated once per VM. -// Also reset the read-only-tool registry so provider registrations from one -// test do not leak into another (the WeakMap is keyed on the mock pi, which -// is freshly constructed per test). +// Reset module-level state before every test so runs are fully independent +// even though the ESM module is evaluated once per VM. beforeEach(() => { resetAll() - resetReadOnlyToolRegistry() }) describe("apply", () => { @@ -154,105 +149,38 @@ describe("apply", () => { expect(calledWith).toContain("read") expect(calledWith).toContain("bash") }) - describe("planning-ferment read-only MCP union", () => { - it("includes read-only-qualified tool names from registered providers", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record", "server_search_items"]) - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).toContain("server_get_record") - expect(calledWith).toContain("server_search_items") - // Catalog tools are still present - expect(calledWith).toContain("read") - }) - - it("includes read-only-qualified tool names under planning-adhoc (else branch widened)", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record"]) - - apply("planning-adhoc", "adhoc", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).toContain("server_get_record") - }) - - it("unions providers and deduplicates overlapping names", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record"]) - registerReadOnlyToolProvider(pi, () => ["server_get_record", "server_list_things"]) - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - const occurrences = calledWith.filter((n) => n === "server_get_record").length - expect(occurrences).toBe(1) - expect(calledWith).toContain("server_list_things") - }) - - it("includes nothing extra when no providers are registered", () => { - const pi = makeMockPi() - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - const expected = getToolsForProfile("planning-ferment").map((t) => t.name) - expect(calledWith).toEqual(expected) - }) - - it("respects the cooperative-visibility disabled filter for read-only tools", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_get_record"]) - // Simulate the cooperative layer voting to hide the read-only MCP - // tool. `createToolVisibility` reads `pi.getActiveTools()` and - // writes back via `pi.setActiveTools()`; the mock above mirrors - // that. The disable vote must propagate through the WeakMap so - // `getDisabledToolNames(pi)` returns it when `applyCore` runs. - createToolVisibility(pi).disable(["server_get_record"]) - // Clear the disable's own setActiveTools call so the assertion below - // observes the apply() call only. - vi.mocked(pi.setActiveTools).mockClear() - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).not.toContain("server_get_record") - }) - - it("applyCore on a PEER pi excludes a cross-extension vote cast on the shared bus (the DAP→ferment case)", () => { - // pi-mono hands each extension its own ExtensionAPI; a full-toolset - // snapshot taken under ferment's pi must still respect votes DAP cast - // under DAP's pi. The shared synchronous bus is the session identity, - // exactly as in the real runner (resource-loader creates one bus per - // session). - const handlers = new Map void>>() - const events = { - on: (c: string, h: (d: unknown) => void) => { - const set = handlers.get(c) ?? new Set() - set.add(h) - handlers.set(c, set) - return () => set.delete(h) - }, - emit: (c: string, d: unknown) => { - for (const h of [...(handlers.get(c) ?? [])]) h(d) - }, - } - const dapPi = makeMockPi({ allTools: [{ name: "bash" }, { name: "edit" }], events }) - const fermentPi = makeMockPi({ allTools: [{ name: "bash" }, { name: "edit" }], events }) - - // DAP votes to defer a tool under its own pi at session_start. - createToolVisibility(dapPi).disable(["bash"]) - - // Ferment applies the full-toolset "idle" profile under ITS pi at - // before_agent_start. The vote must be visible cross-pi, so the - // snapshot must NOT re-surface bash. - apply("idle", "ferment", fermentPi) - - const calledWith = (fermentPi.setActiveTools as ReturnType).mock.calls.at(-1)?.[0] as string[] - expect(calledWith).not.toContain("bash") - expect(calledWith).toContain("edit") - }) + it("applyCore on a PEER pi excludes a cross-extension vote cast on the shared bus (the DAP→ferment case)", () => { + // pi-mono hands each extension its own ExtensionAPI; a full-toolset + // snapshot taken under ferment's pi must still respect votes DAP cast + // under DAP's pi. The shared synchronous bus is the session identity, + // exactly as in the real runner (resource-loader creates one bus per + // session). + const handlers = new Map void>>() + const events = { + on: (c: string, h: (d: unknown) => void) => { + const set = handlers.get(c) ?? new Set() + set.add(h) + handlers.set(c, set) + return () => set.delete(h) + }, + emit: (c: string, d: unknown) => { + for (const h of [...(handlers.get(c) ?? [])]) h(d) + }, + } + const dapPi = makeMockPi({ allTools: [{ name: "bash" }, { name: "edit" }], events }) + const fermentPi = makeMockPi({ allTools: [{ name: "bash" }, { name: "edit" }], events }) + + // DAP votes to defer a tool under its own pi at session_start. + createToolVisibility(dapPi).disable(["bash"]) + + // Ferment applies the full-toolset "idle" profile under ITS pi at + // before_agent_start. The vote must be visible cross-pi, so the + // snapshot must NOT re-surface bash. + apply("idle", "ferment", fermentPi) + + const calledWith = (fermentPi.setActiveTools as ReturnType).mock.calls.at(-1)?.[0] as string[] + expect(calledWith).not.toContain("bash") + expect(calledWith).toContain("edit") }) }) @@ -313,31 +241,24 @@ describe("installTurnBoundaryReset", () => { }) describe("reapplyCurrentProfile", () => { - it("re-applies the last profile, picking up newly-registered read-only tools", () => { - // Simulate the real-world timing gap: the planning snapshot is applied - // while the MCP read-only-tool provider returns [] (state not yet - // populated). After init completes the provider starts returning tool - // names; reapplyCurrentProfile must re-run applyCore so those names - // enter the active set. - const pi = makeMockPi() - let providerResult: string[] = [] - registerReadOnlyToolProvider(pi, () => providerResult) - - // First apply — provider returns nothing (MCP not yet initialized) + it("re-applies a planning profile without exposing newly registered MCP tools", () => { + const pi = makeMockPi({ allTools: [{ name: "read" }] }) apply("planning-ferment", "ferment", pi) - let calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).not.toContain("server_get_record") - // MCP init completes — provider now returns a read-only tool - providerResult = ["server_get_record"] + ;(pi.getAllTools as ReturnType).mockReturnValue([ + { name: "read" }, + { name: "mcp" }, + { name: "server_get_record" }, + ]) vi.clearAllMocks() const reapplied = reapplyCurrentProfile(pi) expect(reapplied).toBe(true) expect(pi.setActiveTools).toHaveBeenCalledOnce() - calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).toContain("server_get_record") + const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] + expect(calledWith).not.toContain("mcp") + expect(calledWith).not.toContain("server_get_record") expect(calledWith).toContain("read") }) @@ -366,104 +287,3 @@ describe("reapplyCurrentProfile", () => { expect(calledWith).toContain("server_get_record") }) }) - -describe("read-only MCP filter integration (planning-ferment vs implementation-ferment)", () => { - // These tests verify the registry + profile-manager behaviour (union, - // inclusion, exclusion) using pre-filtered fixture arrays. They do NOT - // exercise protocol-annotation capture — that policy lives in - // src/extensions/mcp/annotation-catalog.ts and is covered by its co-located - // tests. Coupling to it here would invert the dependency direction - // (shared/planning must not import from src/extensions/mcp). - // - // Fixture: three MCP tools behind a server. Only `server_get_record` is - // read-only-qualified (annotated with readOnlyHint:true). - const mcpReadOnlyProvider = (): string[] => ["server_get_record"] - - it("planning-ferment: includes read-only MCP tool and excludes write/destructive MCP tools", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, mcpReadOnlyProvider) - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - // Read-only tool is included - expect(calledWith).toContain("server_get_record") - // Write tools are NOT included — they're neither in the catalog nor read-only-qualified - expect(calledWith).not.toContain("server_create_record") - expect(calledWith).not.toContain("server_delete_record") - // Catalog tools are still present - expect(calledWith).toContain("read") - }) - - it("planning-ferment: heuristic-only read-only tool (no annotations) is included", () => { - // A separate provider whose read-only set is hardcoded — simulates a - // server that classified its tools via the name heuristic rather than - // annotations. The fixture asserts the registry treats it as read-only. - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, () => ["server_search_items"]) - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).toContain("server_search_items") - expect(calledWith).not.toContain("server_update_record") - }) - - it("planning-adhoc: includes read-only MCP tool and excludes write MCP tools", () => { - const pi = makeMockPi() - registerReadOnlyToolProvider(pi, mcpReadOnlyProvider) - - apply("planning-adhoc", "adhoc", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - // Read-only tool is included - expect(calledWith).toContain("server_get_record") - // Write tools are NOT included - expect(calledWith).not.toContain("server_create_record") - expect(calledWith).not.toContain("server_delete_record") - }) - - it("implementation-ferment: includes ALL MCP tools (read and write)", () => { - const pi = makeMockPi({ - allTools: [ - { name: "read" }, - { name: "bash" }, - { name: "server_get_record" }, // read-only MCP - { name: "server_create_record" }, // write MCP - { name: "server_delete_record" }, // destructive MCP - ], - }) - registerReadOnlyToolProvider(pi, mcpReadOnlyProvider) - - apply("implementation-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - // All MCP tools are present — implementation phase has full access - expect(calledWith).toContain("server_get_record") - expect(calledWith).toContain("server_create_record") - expect(calledWith).toContain("server_delete_record") - // Core tools still present - expect(calledWith).toContain("read") - expect(calledWith).toContain("bash") - }) - - it("planning-ferment: write MCP tool is NOT added even if present in getAllTools", () => { - // Edge case: the write tool is registered in pi.getAllTools() (so it would - // appear under implementation-ferment), but planning-ferment must still - // exclude it because it's not read-only-qualified and not in the catalog. - const pi = makeMockPi({ - allTools: [ - { name: "read" }, - { name: "server_get_record" }, // read-only — should appear - { name: "server_create_record" }, // write — must NOT appear - ], - }) - registerReadOnlyToolProvider(pi, mcpReadOnlyProvider) - - apply("planning-ferment", "ferment", pi) - - const calledWith = (pi.setActiveTools as ReturnType).mock.calls[0][0] as string[] - expect(calledWith).toContain("server_get_record") - expect(calledWith).not.toContain("server_create_record") - }) -}) diff --git a/src/shared/planning/tool-profile-manager.ts b/src/shared/planning/tool-profile-manager.ts index 048de1a7a..7e36274b5 100644 --- a/src/shared/planning/tool-profile-manager.ts +++ b/src/shared/planning/tool-profile-manager.ts @@ -36,7 +36,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { isFermentOnlyToolName } from "../../extensions/ferment/tool-names.js" import { getDisabledToolNames } from "../../extensions/prompt-construction/tool-visibility.js" -import { getReadOnlyToolNames } from "./read-only-tool-registry.js" import { getToolsForProfile, isAdhocOnlyToolName, type ToolProfile } from "./tool-catalog.js" import { getToolSessionScope } from "./tool-session-scope.js" @@ -61,11 +60,9 @@ let turnListenerInstalled = false * Tracks the last profile applied per session so extensions that register * tools asynchronously (e.g. mcp-adapter after its SSE/OAuth init completes) * can ask the profile manager to re-derive the active set without knowing - * which profile is active. Without this, late-registered read-only MCP tools - * are silently dropped: the cooperative-layer no-op guard - * (`isSnapshotAppliedThisTurn`) swallows the `expose()` call the adapter makes - * after registration, and the snapshot itself was computed before init - * finished so `getReadOnlyToolNames` returned `[]`. + * which profile is active. Reapplying ensures late adapter registration cannot + * widen a restrictive profile while still surfacing those tools in profiles + * that preserve the full registered toolset. */ let lastProfileByScope = new WeakMap() @@ -129,22 +126,6 @@ export function applyCore(profile: ToolProfile, pi: ExtensionAPI): void { const tools = getToolsForProfile(profile) allowedNames = tools.map((t) => t.name) - // During planning (both planning-ferment and planning-adhoc), union in - // read-only-qualified tools registered by extensions (e.g. mcp-adapter's - // read-only MCP tools). Write tools remain excluded — the model cannot - // call them during planning, mirroring the hard filter on edit/write. - // worker is intentionally NOT modified here so the worker phase keeps - // its catalog. - if (profile === "planning-ferment" || profile === "planning-adhoc") { - const readOnlyExtra = getReadOnlyToolNames(pi) - if (readOnlyExtra.length > 0) { - const existing = new Set(allowedNames) - for (const name of readOnlyExtra) { - if (!existing.has(name)) allowedNames.push(name) - } - } - } - // Filter out tools that the cooperative visibility layer has voted to // hide. Without this, a snapshot apply would re-surface tools that // another extension disabled (e.g. ask_user / confirm_ferment_completion_ @@ -268,8 +249,7 @@ export function installTurnBoundaryReset(pi: ExtensionAPI): void { * direct tools are registered after SSE/OAuth init completes) need a way to * surface those tools into the active set without calling `apply()` themselves * (they don't know which profile is active). This function re-runs `applyCore` - * with the stored profile, re-evaluating `getReadOnlyToolNames` against the - * now-populated tool-metadata state. + * with the stored profile against the now-populated registered toolset. * * Safe to call at any time. Returns `false` (no-op) when no profile has been * applied yet for this session. diff --git a/tests/e2e/tui/mcp-stdio.test.ts b/tests/e2e/tui/mcp-stdio.test.ts index 3b5e2e053..0ea144992 100644 --- a/tests/e2e/tui/mcp-stdio.test.ts +++ b/tests/e2e/tui/mcp-stdio.test.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs" -import { join } from "node:path" import { expect, test } from "@microsoft/tui-test" import { STREAM_TIMEOUT_MS, waitForText } from "./support/assertions.js" import { runMcpKimchiSession, TUI_TEST_CONFIG } from "./support/kimchi-fixture.js" @@ -97,54 +95,14 @@ test("registers and calls a direct MCP tool on the first session", async ({ term ) }) -test("uses MCP read-only annotations in plan mode and fails closed for explicit false", async ({ terminal }) => { - const dangerousGatewayCall = gatewayMcpCall("get_danger") +test("does not expose MCP tools in plan mode", async ({ terminal }) => { await runMcpKimchiSession( terminal, { - artifactName: "mcp-stdio-plan-annotations", - extraArgs: ["--plan=true"], - mcp: { - directTools: ["echo", "get_danger"], - behavior: { - catalogTools: [ - { - name: "get_danger", - description: "Mutating tool with a read-looking name", - inputSchema: { type: "object", properties: {}, additionalProperties: false }, - annotations: { readOnlyHint: false }, - }, - ], - }, - }, - responses: [dangerousGatewayCall.response, modelReply("Planning MCP annotations were applied.")], - }, - async (fixture, trace) => { - terminal.submit("Inspect the available planning tools") - await waitForText(terminal, "Planning MCP annotations were applied.", { timeoutMs: STREAM_TIMEOUT_MS }) - - const annotationCache = JSON.parse(readFileSync(join(fixture.agentDir, "mcp-annotations.json"), "utf8")) as { - tools: Record - } - expect(Object.values(annotationCache.tools)).toContain("not-read-only") - const request = requireRequestAdvertisingTool(fixture.fake.requests, "fixture_echo") - const tools = (request.body as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] - expect(tools.some((tool) => tool.function?.name === "fixture_get_danger")).toBe(false) - expect(toolResultText(fixture.fake.requests, dangerousGatewayCall)).toContain("unavailable in plan mode") - expect(fixture.mcp.hasEvent("tool_called", { name: "get_danger" })).toBe(false) - trace.step("plan profile and gateway both rejected explicit readOnlyHint false") - }, - ) -}) - -test("calls a server-prefixed read-only MCP tool through the gateway in plan mode", async ({ terminal }) => { - const safeGatewayCall = gatewayMcpCall("get_safe") - await runMcpKimchiSession( - terminal, - { - artifactName: "mcp-stdio-plan-read-only-gateway", + artifactName: "mcp-stdio-plan-disabled", extraArgs: ["--plan=true"], mcp: { + directTools: ["get_safe"], behavior: { catalogTools: [ { @@ -154,23 +112,21 @@ test("calls a server-prefixed read-only MCP tool through the gateway in plan mod annotations: { readOnlyHint: true }, }, ], - tools: [ - mcpToolResult("get_safe", { - content: [{ type: "text", text: "fixture safe value" }], - }), - ], }, }, - responses: [safeGatewayCall.response, modelReply("The read-only MCP gateway call succeeded in plan mode.")], + responses: [modelReply("MCP tools are unavailable in plan mode.")], }, async (fixture, trace) => { - terminal.submit("Read the safe MCP fixture value") - await waitForText(terminal, "The read-only MCP gateway call succeeded in plan mode.", { - timeoutMs: STREAM_TIMEOUT_MS, - }) - await fixture.mcp.waitForEvent("tool_called", { where: { name: "get_safe", arguments: {} } }) - expect(toolResultText(fixture.fake.requests, safeGatewayCall)).toContain("fixture safe value") - trace.step("prefixed gateway name was matched to its read-only protocol annotation") + terminal.submit("Inspect the available planning tools") + await waitForText(terminal, "MCP tools are unavailable in plan mode.", { timeoutMs: STREAM_TIMEOUT_MS }) + + const request = fixture.fake.requests.find((candidate) => candidate.url.startsWith("/openai/v1/chat/completions")) + expect(request).toBeDefined() + const tools = (request?.body as { tools?: Array<{ function?: { name?: string } }> } | undefined)?.tools ?? [] + expect(tools.some((tool) => tool.function?.name === "mcp")).toBe(false) + expect(tools.some((tool) => tool.function?.name === "fixture_get_safe")).toBe(false) + expect(fixture.mcp.hasEvent("tool_called", { name: "get_safe" })).toBe(false) + trace.step("plan profile omitted both gateway and direct MCP tools") }, ) })