diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a3b2824a..ab415136 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,6 +7,11 @@ "url": "https://allagents.dev" }, "plugins": [ + { + "name": "allagents", + "description": "End-user guidance for managing AllAgents through its CLI", + "source": "./plugins/allagents" + }, { "name": "deepwiki", "description": "AI-generated documentation for GitHub repositories", @@ -16,6 +21,11 @@ "name": "engineering", "description": "Reusable engineering workflow skills", "source": "./plugins/engineering" + }, + { + "name": "tradingview", + "description": "Official TradingView market data and analytics", + "source": "./plugins/tradingview" } ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a8378c20..5b4e6806 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -7,10 +7,20 @@ "url": "https://allagents.dev" }, "plugins": [ + { + "name": "allagents", + "description": "End-user guidance for managing AllAgents through its CLI", + "source": "./plugins/allagents" + }, { "name": "deepwiki", "description": "AI-generated documentation for GitHub repositories", "source": "./plugins/deepwiki" + }, + { + "name": "tradingview", + "description": "Official TradingView market data and analytics", + "source": "./plugins/tradingview" } ] } diff --git a/AGENTS.md b/AGENTS.md index 6e47df4d..6bb25326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -184,7 +184,4 @@ bun run build ## Interactive Testing -### agent-tui -- Use `agent-tui` when you need to exercise interactive terminal behavior manually. -- Prefer testing the built CLI inside a temporary workspace so the interaction matches real user conditions. -- When documenting manual verification, record the exact command, the temp workspace setup, and what terminal behavior you confirmed. +- TUI navigation, prompt, copy, status, or recovery changes: use the installed `agent-tui` skill for terminal automation, then apply the AllAgents-specific acceptance criteria in [`docs/agent-guides/tui-dogfooding.md`](docs/agent-guides/tui-dogfooding.md) before implementation and final verification. diff --git a/CHANGELOG.md b/CHANGELOG.md index b569766a..5091c716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,13 @@ ### Breaking Changes -- **MCP proxy command**: Removed the temporary `allagents mcp proxy-stdio` alias. Use `allagents mcp proxy ` instead. +- **MCP setup commands**: HTTP servers added with `allagents mcp add` now + authenticate and route through AllAgents automatically. The public + `--proxy` option and `mcp auth ` command were replaced by + `mcp reauth `. The generated `mcp proxy` helper remains internal. - **Migration**: Re-run `allagents mcp update` or `allagents update` after upgrading so synced client configs are regenerated with `mcp proxy`. + **Migration**: Remove `--proxy` from `mcp add` calls. Replace + `allagents mcp auth ` with `allagents mcp reauth `. - **Plugin Git ref terminology**: Renamed workspace plugin `pin` to `ref`, CLI `--pin` to `--ref`, and sync-state `pinnedRef` to `requestedRef`. Inline @@ -15,8 +19,38 @@ **Migration**: Replace `pin:` with `ref:` in plugin objects and `--pin` with `--ref` in scripts. This is a clean cutover; the old names are not accepted. +### Fixed + +- Project-scoped Copilot MCP servers are now written to `.github/mcp.json`, + which Copilot CLI discovers, instead of the unsupported + `.copilot/mcp-config.json` project path. +- Interactive OAuth guidance for `mcp add` and `mcp reauth` now uses normal + terminal output instead of the error channel. Callback URLs are entered + through an abortable masked prompt, and failed reauthentication restores the + previous working credentials. + ### Added +- Added a thin first-party AllAgents skill that treats the installed CLI as + authoritative and follows its progressive `--help --json` indexes and leaf + contracts instead of relying on memorized commands. +- Added progressive machine-readable CLI help: concise root and group indexes + lead to leaf contracts with usage guidance, interaction requirements, + expected output, options, examples, and output schemas. + +- Added the official TradingView MCP plugin with OAuth-backed access to market + data, analytics, watchlists, alerts, news, and screeners. +- Added automatic OAuth login to `allagents mcp add` and named credential + renewal with `allagents mcp reauth`, including local loopback completion and + remote callback URL paste with strict redirect and state validation. +- Generated HTTP MCP bridges now invoke the current pinned AllAgents version + through cached `npx`, so managed MCP connections do not require a global + AllAgents installation. +- Full MCP server management in the interactive TUI, including destination + selection, listing, inspection, add, reauthenticate, and remove flows for + project, user, and named-profile declarations. Client configuration updates + automatically after mutations, with a contextual retry when an update fails. + - Pi and OMP as file-sync clients at project and user scope, including native runtime skill paths and agent instructions. - Native Pi package and OMP marketplace-plugin lifecycle support for install, diff --git a/README.md b/README.md index 2c51a11d..29b9d4e9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,13 @@ npx allagents update No cloning required — AllAgents fetches the `workspace.yaml` directly from GitHub and sets up everything. +Install the end-user AllAgents skill when you want your coding agent to operate +the CLI using its current machine-readable command contracts: + +```bash +npx allagents plugin install allagents@allagentsdev/allagents --scope user +``` + ## How It Works 1. **Configure** your workspace with repos, plugins, and target clients in `workspace.yaml` @@ -101,9 +108,9 @@ clients: | `allagents profile remove --yes` | Remove unchanged managed profile resources | | `allagents skill add [--from ] [--scope project\|user] [--client ] [--yes]` | Add source-backed skills for selected scope and clients (plural `skills` alias supported) | | `allagents skill list` | List skills and status | -| `allagents mcp add ` | Add an MCP server and sync to clients | -| `allagents mcp proxy ` | Bridge a remote HTTP MCP server to local stdio | -| `allagents mcp list` | List workspace MCP servers | +| `allagents mcp add [--scope user \| --profile ]` | Add, authenticate, and sync an MCP server | +| `allagents mcp reauth [--scope user \| --profile ]` | Reauthenticate an HTTP MCP server in one destination | +| `allagents mcp list [--scope user \| --profile ]` | List MCP declarations in one destination | | `allagents workspace status` | Show workspace state | | `allagents self update` | Update AllAgents CLI | diff --git a/docs/agent-guides/tui-dogfooding.md b/docs/agent-guides/tui-dogfooding.md new file mode 100644 index 00000000..1e0759d4 --- /dev/null +++ b/docs/agent-guides/tui-dogfooding.md @@ -0,0 +1,78 @@ +# TUI Dogfooding + +This guide defines AllAgents-specific acceptance criteria for changes to interactive CLI navigation, prompts, labels, status messages, or failure recovery. + +## Tooling boundary + +Use the installed `agent-tui` skill for terminal automation. That skill owns installation checks, command selection, session lifecycle, snapshots, actions, waits, assertions, and cleanup. Follow its current CLI workflow rather than reproducing command recipes here. + +This guide owns the product-specific UX questions, interaction coverage, and evidence required for AllAgents. + +## Goal + +Prove that the built TUI is understandable to a first-time user, not only that its underlying mutation succeeds. + +## Setup + +1. Build the CLI. +2. Create isolated temporary project and HOME directories. +3. Seed the smallest realistic configuration that exposes every changed state. +4. Use the `agent-tui` skill to drive the built CLI and capture each changed decision screen before interacting with it. + +Never dogfood against a real user workspace when an isolated fixture can exercise the behavior. + +## Confusion pass + +For every changed screen, state: + +- the object the user is currently managing; +- the single decision the screen asks them to make; +- where Back and Ctrl+C will land; +- whether the selected scope or destination remains visible and intact. + +Apply one screen, one decision: + +- A resource list contains resources plus Add and Back. +- A resource detail contains actions for that resource plus Back. +- Scope or destination changes happen by returning to the chooser. +- Maintenance mechanics stay automatic. Show a retry only when automatic recovery fails. + +If a menu mixes resource selection, navigation, and maintenance operations, simplify it before continuing. + +## Language pass + +Use the established public term for each operation in labels, progress messages, results, errors, and documentation. Internal implementation terms do not belong in user-facing copy. For example, use **Update** rather than sync or reconcile. + +Read the complete screen, not only the changed label. Adjacent hints, summaries, and success or error messages must use the same vocabulary. + +## Interaction pass + +Exercise every changed path that applies: + +1. Enter the flow from the main menu. +2. Move forward through each chooser and detail screen. +3. Use Back from every changed level. +4. Use Ctrl+C from every changed level. +5. Complete a successful mutation and verify both the next screen and filesystem result. +6. Trigger a realistic failure and verify the error leaves a clear recovery path. +7. Exercise retry, repeated retry failure, explicit cancellation, and eventual success when retry behavior changed. + +A transition passes only when it lands on the screen a user would predict without losing or silently changing scope. + +## Durable coverage + +Keep regression tests for navigation state, scope preservation, cancellation, mutation boundaries, and failure recovery. Test exact copy only when the wording is a deliberate product contract; use the manual confusion pass for general prose quality. + +## Completion evidence + +Record in the PR description: + +- the exact built command; +- temporary workspace and HOME setup; +- selections and transitions exercised; +- screenshots or the text of each observed decision screen; +- resulting configuration or filesystem state; +- failure and retry behavior checked; +- cleanup performed. + +Dogfooding is complete only when the full changed journey passes the confusion, language, interaction, and filesystem checks. diff --git a/docs/plans/2026-02-03-workspace-sync-both-scopes.md b/docs/plans/2026-02-03-workspace-sync-both-scopes.md index 9de15b08..86d44e0d 100644 --- a/docs/plans/2026-02-03-workspace-sync-both-scopes.md +++ b/docs/plans/2026-02-03-workspace-sync-both-scopes.md @@ -446,7 +446,7 @@ In `src/cli/metadata/workspace.ts`, remove the `--scope` example and option: **Step 2: Run help text tests** -Run: `bun test tests/e2e/cli-help.test.ts tests/e2e/cli-enriched-help.test.ts tests/e2e/cli-agent-help.test.ts` +Run: `bun test tests/e2e/cli-help.test.ts tests/e2e/cli-enriched-help.test.ts tests/unit/cli/structured-help.test.ts` Expected: PASS (help tests should not hardcode --scope for sync) **Step 3: Commit** diff --git a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md index 22f91856..a65513c1 100644 --- a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md +++ b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md @@ -222,7 +222,7 @@ flowchart TB - **Goal:** Expose the orchestration as a predictable interactive and scriptable command. - **Requirements:** R1-R3, R6, R8-R12; F1-F3. - **Dependencies:** U1, U2. -- **Files:** `src/cli/commands/plugin-skills.ts`, `src/cli/metadata/plugin-skills.ts`, `src/cli/skill-arg-normalizer.ts`, `tests/unit/cli/skill-update.test.ts`, `tests/unit/cli/agent-help.test.ts`. +- **Files:** `src/cli/commands/plugin-skills.ts`, `src/cli/metadata/plugin-skills.ts`, `src/cli/skill-arg-normalizer.ts`, `tests/unit/cli/skill-update.test.ts`, `tests/unit/cli/structured-help.test.ts`. - **Approach:** Register `update`; validate scope/filter values; use Clack for scope and confirmation prompts; group warnings once per physical refresh unit with every impacted scope/plugin listed; collect all decisions before execution; interpret No as retain/skip and cancel as pre-mutation abort; render one concise summary; produce the same result model and exit contract through JSON without UI noise. - **Patterns to follow:** Existing skill search scope picker, global JSON envelope helpers, and enriched command metadata. - **Test scenarios:** diff --git a/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md b/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md index 7eb76159..1b3ae48c 100644 --- a/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md +++ b/docs/plans/2026-09-17-1211-perf-update-no-op-paths-plan.md @@ -188,7 +188,7 @@ flowchart LR ### System-Wide Impact -- **CLI and agents:** Human output, `--json`, exit codes, and agent-help schemas stay stable. Automation still sees successful no-op checks as existing update outcomes. +- **CLI and agents:** Human output, `--json`, exit codes, and structured-help schemas stay stable. Automation still sees successful no-op checks as existing update outcomes. - **Filesystem state:** Direct plugin and skill no-ops avoid persistent checkout work while retaining existing scope synchronization. Marketplace no-ops retain the registry timestamp write required by compatibility. - **Dependency direction:** CLI/TUI callers create a neutral context and pass it into domain updaters; domain updaters consume context/Git/identity helpers. The leaf identity helper and Git module never import plugin, marketplace, skill, or CLI/TUI modules. - **Scope ownership:** Remote and physical checkout facts can be shared, but each consumer independently derives public output and writes only its owning registry. External-plugin `changed` is the OR of successful marketplace and external-checkout physical changes without changing current public precedence or sync eligibility. diff --git a/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md b/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md index 797fa50e..fe2dc4e8 100644 --- a/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md +++ b/docs/plans/2026-09-19-1012-feat-interactive-install-scope-clients-plan.md @@ -80,7 +80,7 @@ The feature is primarily UX. Existing marketplace resolution, declaration instal - A shared summary-data model rendered by CLI and TUI. - Localized project/user declaration persistence for client overrides while preserving object fields. - Crash-safe publication of the targeted project/user config via the repository’s established same-directory temporary-write-and-rename pattern. -- Existing command metadata, agent help, user docs, changelog, focused regression tests, and built-CLI dogfood. +- Existing command metadata, structured help, user docs, changelog, focused regression tests, and built-CLI dogfood. ### Out of Scope @@ -231,7 +231,7 @@ Only these areas require code-review attention beyond dogfooding: - `src/cli/tui/actions/plugins.ts` - `src/cli/metadata/plugin.ts` - `tests/unit/cli/tui-plugin-install.test.ts` (new) -- `tests/unit/cli/agent-help.test.ts` +- `tests/unit/cli/structured-help.test.ts` - `tests/e2e/plugin-install-options.test.ts` (new) **Changes:** @@ -281,7 +281,7 @@ Only these areas require code-review attention beyond dogfooding: - Document chooser order, first-config default consequence, per-plugin overrides, flags, and non-interactive behavior. - Add a valid `plugins[].clients` example using `source`. -- Update command metadata and generated agent help. +- Update command metadata and structured help. - Remove temporary dogfood workspaces and any obsolete plan artifacts after implementation. ## Verification Contract diff --git a/docs/public/schemas/v1/project-workspace.schema.json b/docs/public/schemas/v1/project-workspace.schema.json index e3e35307..1dc9933f 100644 --- a/docs/public/schemas/v1/project-workspace.schema.json +++ b/docs/public/schemas/v1/project-workspace.schema.json @@ -315,7 +315,8 @@ "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "servers": { "type": "object", @@ -336,9 +337,6 @@ } } }, - "required": [ - "clients" - ], "additionalProperties": true }, "mcpServers": { diff --git a/docs/public/schemas/v1/user-workspace.schema.json b/docs/public/schemas/v1/user-workspace.schema.json index 20c15212..067c2b2a 100644 --- a/docs/public/schemas/v1/user-workspace.schema.json +++ b/docs/public/schemas/v1/user-workspace.schema.json @@ -318,7 +318,8 @@ "type": "array", "items": { "type": "string" - } + }, + "default": [] }, "servers": { "type": "object", @@ -339,9 +340,6 @@ } } }, - "required": [ - "clients" - ], "additionalProperties": true }, "mcpServers": { @@ -1038,7 +1036,13 @@ "additionalProperties": false } ] + }, + "propertyNames": { + "pattern": "^[A-Za-z0-9_.-]{1,100}$" } + }, + "mcpProxy": { + "$ref": "#/definitions/AllAgentsUserWorkspace/properties/mcpProxy" } }, "required": [ diff --git a/docs/src/content/docs/docs/getting-started/installation.mdx b/docs/src/content/docs/docs/getting-started/installation.mdx index bc3788c5..716a9978 100644 --- a/docs/src/content/docs/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/docs/getting-started/installation.mdx @@ -22,7 +22,9 @@ npx allagents ``` :::note -If you plan to use the [MCP Proxy](/docs/guides/mcp-proxy/) feature, install AllAgents globally (npm or bun) instead. The proxy command is invoked directly by your MCP clients later, not by you through npx, so `allagents` needs to already be resolvable on `PATH` at that point. +HTTP MCP servers also work when setup is run through `npx`. Generated client +configs invoke a pinned AllAgents version through `npx`, which reuses npm's +package cache after the first launch. ::: ## Using bun diff --git a/docs/src/content/docs/docs/guides/agent-portability.mdx b/docs/src/content/docs/docs/guides/agent-portability.mdx index ab4b479c..1ca8b95c 100644 --- a/docs/src/content/docs/docs/guides/agent-portability.mdx +++ b/docs/src/content/docs/docs/guides/agent-portability.mdx @@ -124,7 +124,7 @@ Running `allagents update` syncs agents, skills, hooks, and MCP servers to both | Hooks | `.claude/hooks/` | `.github/hooks/` | | Commands / Prompts | `.claude/commands/` | `.github/prompts/` | | Agent file | `CLAUDE.md` | `AGENTS.md` | -| MCP servers | `.claude/mcp.json` | `.copilot/mcp-config.json` | +| MCP servers | `.claude/mcp.json` | `.github/mcp.json` | | WORKSPACE-RULES | Injected in `CLAUDE.md` | Injected in `AGENTS.md` | ### Skills Are the Strongest Interop Point diff --git a/docs/src/content/docs/docs/guides/mcp-proxy.mdx b/docs/src/content/docs/docs/guides/mcp-proxy.mdx index ed722c87..54abb8bf 100644 --- a/docs/src/content/docs/docs/guides/mcp-proxy.mdx +++ b/docs/src/content/docs/docs/guides/mcp-proxy.mdx @@ -1,185 +1,215 @@ --- -title: MCP Proxy -description: Transparently proxy HTTP MCP servers through a built-in stdio bridge, with OAuth handled for you. +title: HTTP MCP Servers +description: Add, authenticate, and share HTTP MCP servers across AI clients. --- -Some MCP servers use HTTP transport with OAuth authentication, but not every AI client supports HTTP natively. The MCP proxy feature rewrites HTTP server configs to run through AllAgents' own built-in stdio bridge (`allagents mcp proxy `), so all clients connect through an already-authenticated proxy — no separate package to install. +AllAgents acts as the MCP client for HTTP servers it manages. One command +connects to the server, handles OAuth when required, saves the selected project, +user, or profile declaration, and syncs every selected AI client: -## Quick Start +```bash +allagents mcp add tradingview https://mcp.tradingview.com/mcp +``` + +You do not need to run a separate proxy or authentication command. -The fastest way to try MCP proxy is to scaffold the ready-made -[`examples/workspaces/mcp-proxy`](https://github.com/allagentsdev/allagents/tree/main/examples/workspaces/mcp-proxy) -workspace with `allagents workspace init --from`: +## Add a Server + +Use a stable name and the server's HTTP endpoint: ```bash -allagents workspace init ./mcp-proxy-demo \ - --from allagentsdev/allagents/examples/workspaces/mcp-proxy -cd ./mcp-proxy-demo +allagents mcp add deepwiki https://mcp.deepwiki.com/mcp +allagents mcp add tradingview https://mcp.tradingview.com/mcp ``` -This creates a workspace pre-configured with the `deepwiki` plugin — a real -public HTTP MCP server (`https://mcp.deepwiki.com/mcp`) — and an `mcpProxy` -section that rewrites it to stdio for Codex while leaving Claude's HTTP -config untouched: +For an HTTP server, `mcp add`: -```yaml -# .allagents/workspace.yaml -repositories: [] +1. Connects as the AllAgents MCP client +2. Opens a browser if the server requires OAuth +3. Verifies the MCP connection before changing the selected declaration +4. Stores the server under that destination's `mcpServers` +5. Routes the selected AI clients through AllAgents and syncs their configs -plugins: - # Real HTTP MCP server from the official AllAgents marketplace. - # Ships a `.mcp.json` that points at https://mcp.deepwiki.com/mcp - - allagentsdev/allagents/plugins/deepwiki +Public servers such as DeepWiki complete without a browser. OAuth servers such +as TradingView prompt for login on the first connection. -clients: - - claude - - codex +### Local and Remote Browsers -mcpProxy: - # Claude Code supports HTTP MCP natively, so it gets the original URL. - # Codex only speaks stdio, so rewrite its config to run through the - # built-in `allagents mcp proxy` bridge. - clients: - - codex -``` +The same command supports both environments: -`workspace init` also runs the initial sync, so you can immediately inspect -what each client received: +- **Local browser:** approve access and let the browser return to the loopback + callback. The terminal continues automatically. +- **Remote or headless host:** open the printed authorization URL on another + device. After approval, copy the complete loopback callback URL from the + browser address bar and paste it into the waiting terminal prompt. -```bash -cat .mcp.json # Claude — original HTTP config -cat .codex/config.toml # Codex — rewritten to `allagents mcp proxy` stdio -``` +AllAgents accepts only the registered loopback address with the exact OAuth +state. -DeepWiki is a public, no-auth MCP server, so this example works end-to-end -with nothing more than `allagents` itself installed. Point any of your -configured clients at the workspace and you can immediately call tools like -`read_wiki_structure` or `ask_question` against any indexed GitHub repo. +In a non-interactive shell, AllAgents still verifies the connection without +opening a browser. Public servers and servers with valid cached credentials +succeed. If fresh OAuth consent is required, the command fails before changing +the workspace; rerun it in an interactive terminal. -## Why Use MCP Proxy +### Client Filters and Headers -- **OAuth handled once** — the built-in proxy runs the full PKCE authorization flow the first time it connects, then caches the client registration and tokens under `~/.allagents/oauth-proxy/`; subsequent connections reuse them (with automatic token refresh) instead of reopening a browser -- **Stdio everywhere** — clients that only support stdio can connect to HTTP servers -- **Transparent** — configure which clients need proxying and AllAgents rewrites configs automatically during sync -- **Nothing extra to install** — the proxy is built into the `allagents` binary; there's no separate package to fetch or cache on first use +```bash +allagents mcp add internal https://mcp.internal.corp \ + --header Authorization=Bearer-token \ + --client claude \ + --client codex,copilot +``` -## Configuration +`--client` is repeatable and comma-compatible. It limits both server sync and +AllAgents routing; duplicate values are ignored in first-seen order. Without +`--client`, routing stays dynamic so clients added to the destination later +receive the same AllAgents-managed connection. +`--header` values are sent only to the MCP server origin, not to OAuth +discovery or identity-provider origins. -Add an `mcpProxy` section to your `workspace.yaml`: +Profile declarations accept only exact environment references for header values: -```yaml -mcpProxy: - clients: - - claude - - copilot - servers: - my-internal-api: - proxy: - - codex +```bash +allagents mcp add tradingview https://mcp.tradingview.com/mcp \ + --profile markets \ + --header 'Authorization=${TRADINGVIEW_TOKEN}' ``` -### Fields +Generated profile bridge arguments store the header-to-variable binding, not +the resolved secret. AllAgents resolves the variable immediately before the MCP +connection and fails clearly when it is missing. -| Field | Required | Description | -|-------|----------|-------------| -| `clients` | Yes | Default list of clients where all HTTP servers are proxied | -| `servers` | No | Per-server overrides | -| `servers..proxy` | Yes (if server entry exists) | Additional clients to proxy this specific server for | +## Reauthenticate -### How It Works +Use the configured server name, not its URL: -1. During `allagents update`, AllAgents collects MCP servers from installed plugins -2. For each server + client pair, it checks if proxying is needed: - - Is the client listed in `mcpProxy.clients`? - - Is there a per-server override in `mcpProxy.servers..proxy` that includes this client? -3. If yes **and** the server uses HTTP transport (has a `url` field), the config is rewritten to invoke `allagents mcp proxy` via stdio -4. Stdio servers are never transformed — they pass through unchanged +```bash +allagents mcp reauth tradingview +allagents mcp reauth tradingview --scope user +allagents mcp reauth tradingview --profile markets +``` -### Transform Example +`reauth` clears only the selected destination's cached OAuth registration and +tokens, then runs a fresh connection and login. It does not revoke the grant at +the remote provider. Reconnect the AI client afterward if it already had an MCP +session open. -A plugin provides an HTTP MCP server: +## Stdio Servers -```json -{ - "knowledge-base": { - "url": "https://knowledge.mcp.example.com" - } -} +Stdio servers use the same `add` command and run directly rather than through +the HTTP client: + +```bash +allagents mcp add gh-server npx \ + --arg=-y \ + --arg=@modelcontextprotocol/server-github \ + -e GH_TOKEN=ghp_xxx ``` -With `mcpProxy.clients: [claude]`, the synced config for Claude becomes: +## How HTTP Routing Works + +AI clients have different MCP transport support. AllAgents normalizes that +difference: HTTP servers added with `mcp add` are synced as stdio commands that +launch AllAgents' internal HTTP bridge. OAuth registration and tokens are +therefore shared instead of being configured independently in every client. + +The generated client config may contain an internal invocation like: ```json { - "knowledge-base": { - "command": "allagents", - "args": ["mcp", "proxy", "https://knowledge.mcp.example.com"] + "tradingview": { + "command": "npx", + "args": [ + "-y", + "allagents@", + "mcp", + "proxy", + "https://mcp.tradingview.com/mcp" + ] } } ``` -Other clients not listed in `mcpProxy.clients` receive the original HTTP config unchanged. +For a named profile, the generated arguments also include `--profile `. +That hidden selector makes every client inside one profile share the same +profile-owned credentials without sharing them with another profile. + +This is generated plumbing, not a setup command. AllAgents pins the package +version that created the config, and `npx` reuses npm's package cache on later +launches. -## Per-Server Overrides +### Plugin-Provided Servers -The `servers` map lets you proxy specific servers for additional clients beyond the default list: +Servers declared by plugins are not added through `mcp add`. To route those +through AllAgents, use the advanced destination-local `mcpProxy` setting: ```yaml mcpProxy: clients: - - claude + - codex servers: - my-internal-api: + plugin-server: proxy: - - codex + - claude - copilot ``` -In this example: -- **All HTTP servers** are proxied for `claude` (from the default `clients` list) -- **Only `my-internal-api`** is additionally proxied for `codex` and `copilot` - -Per-server `proxy` lists are additive — they extend the default `clients`, not replace them. - -## OAuth & Token Cache +The optional `clients` list applies to every HTTP server in that destination. +Per-server lists add clients for only the named server, and `*` selects every +eligible client. Profiles place the same shape beside their own `mcpServers` +under `profiles..mcpProxy`; profile policy never uses qualified global +client IDs. Stdio servers are never transformed. -The first time `allagents mcp proxy ` connects to a server that requires OAuth, it runs the standard authorization-code + PKCE flow: it registers a client with the server's authorization server (or reuses a cached registration), opens your browser to complete the login, and exchanges the resulting code for tokens. +## OAuth Cache -Client registration, tokens, and discovery metadata are cached per server under: +AllAgents caches OAuth client registration, tokens, PKCE verifier, and +discovery metadata per server URL. Project and ordinary user declarations share +the ordinary cache: -``` +```text ~/.allagents/oauth-proxy// - client-info.json - tokens.json - code-verifier.txt - discovery.json ``` -Later connections reuse this cache — no browser prompt — and an expired access token is refreshed automatically using the cached refresh token, still without reopening a browser. If you ever need to force a fresh login for a specific server (e.g. a revoked token), delete that server's subdirectory and reconnect. - -## Prerequisites - -None beyond `allagents` itself — the proxy has no separate runtime dependency to fetch or cache. - -:::caution -`allagents` must be installed and on `PATH` (`npm install -g allagents` or `bun install -g allagents`) — running it via `npx allagents` is **not** enough for this feature. The proxy command is invoked directly by each MCP client (Claude Code, Codex, etc.), not by you, and the generated config embeds a bare `command: "allagents"`. `npx` resolves and runs the package for *your own* shell invocation, but doesn't add anything to `PATH` for another process to find afterward — so a client spawning that config later will fail with "command not found" unless `allagents` is genuinely installed. -::: +Each named profile owns an isolated cache: -## Scope - -MCP proxy works with both project-scoped and user-scoped syncs. - -### Project Scope - -During `allagents update`, proxied servers are written to each client's project-level MCP config file: +```text +~/.allagents/profiles//oauth-proxy// +``` -| Client | Config File | -|--------|------------| -| Claude | `.mcp.json` | -| VS Code | `.vscode/mcp.json` | -| Copilot | `.copilot/mcp-config.json` | -| Codex | `.codex/config.toml` | +Both contain `client-info.json`, `tokens.json`, `code-verifier.txt`, and +`discovery.json`. Clients inside one profile share those files; different +profiles do not. Later connections reuse valid tokens and refresh expired access +tokens when possible. `mcp reauth --profile ` resets only that profile. +Removing an installed or declared-only profile removes its OAuth subtree with +its other profile-owned runtime state. Cleanup rejects symbolic-link or +non-directory OAuth roots and retains retryable state on failure. -### User Scope +## Prerequisites -When using `--scope user`, proxied servers are synced via client CLI commands (`claude mcp add`, `codex mcp add`) to user-level config, making them available across all projects. +The generated bridge requires Node.js with `npx` available. A global AllAgents +installation is not required: both setup and the generated MCP connection can +use the npm cache. + +The first bridge launch downloads the pinned AllAgents package if that version +is not already cached. Later launches reuse the cached package. Environments +that must work offline should prime the cache before disconnecting. + +## Destinations + +HTTP MCP routing supports project, ordinary user, and named profile +destinations. Omit destination flags for project scope, use `--scope user` for +ordinary user configuration, or `--profile ` for a profile. `--scope` and +`--profile` cannot be combined. From the home directory, the project path +aliases the user workspace, so an unflagged command resolves to user scope and +explicit `--scope project` is rejected. + +| Destination | Codex | Copilot | +|-------------|-------|---------| +| Project | `.codex/config.toml` | `.github/mcp.json` | +| User | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | +| Profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | + +Project and ordinary user updates are MCP-only. Updating an installed profile +uses the ownership-aware profile reconciler because some clients combine MCP +and settings in one file. Editing a declared but uninstalled profile does not +implicitly install it. diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 505b9666..a547c764 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -3,10 +3,35 @@ title: CLI Commands description: Complete reference for AllAgents CLI commands. --- +## Structured Help + +Add `--json` to any help invocation to return machine-readable command +metadata instead of terminal-formatted help: + +```bash +allagents --help --json +allagents mcp --help --json +allagents mcp add --help --json +``` + +Structured help is progressively disclosed: + +1. Root help returns a concise index of top-level commands and groups. +2. Group help returns only its immediate commands and nested groups. +3. Each index entry provides a `help_command` for the next level. +4. Leaf help returns the complete contract: when to use the command, + positionals, options, examples, interaction requirements, expected output, + output schema, and JSON field allowlist. + +This keeps command discovery small while making side effects, automation +requirements, and output expectations available before execution. `--help +--json` and `--json --help` are equivalent when they follow the command path. +Field selection with `--json=` is not supported for help output. + ## Top-Level Commands ```bash -allagents update [--offline] [--dry-run] [--client ] [--scope ] [--profile ...] +allagents update [--offline] [--dry-run] [--client ] [--profile ...] allagents status ``` @@ -19,10 +44,9 @@ Updates plugins in the workspace using non-destructive sync. By default, remote | `--offline` | Use cached plugins without fetching latest from remote | | `--dry-run` | Preview changes without applying them | | `-c, --client ` | Sync only the specified client (e.g., `opencode`, `claude`) | -| `-s, --scope ` | Sync scope: `project` (default) or `user` | | `--profile ` | Reconcile only this installed global profile; repeatable | -When `--scope user` is used, sync targets the user-level workspace at `~/.allagents/workspace.yaml` and installs plugins to user directories (`~/.claude/`, `~/.codex/`, etc.) instead of the project. +Ordinary user, installed profile, and current project reconciliation are selected automatically; `allagents update` does not take a scope flag. Without `--profile`, update independently attempts the user workspace, every installed profile that still has a declaration, and the current project @@ -31,8 +55,7 @@ the command exits nonzero after all applicable work completes. Using one or more `--profile` filters validates the entire selected set before mutation, updates only those installed and still-declared profiles, and skips -ordinary user and project sync. `--profile` cannot be combined with `--scope` -or `--client`. +ordinary user and project sync. `--profile` cannot be combined with `--client`. **Non-destructive behavior:** - First sync overlays files without deleting existing user files @@ -435,96 +458,165 @@ Local plugin sources are listed separately in `data.skippedLocalSources`. The JS ## MCP Commands ```bash -allagents mcp add [options] -allagents mcp proxy [--header KEY=VALUE...] -allagents mcp remove -allagents mcp list -allagents mcp get -allagents mcp update [--offline] +allagents mcp add [options] [--scope project|user | --profile ] +allagents mcp reauth [--scope project|user | --profile ] +allagents mcp remove [--scope project|user | --profile ] +allagents mcp list [--scope project|user | --profile ] +allagents mcp get [--scope project|user | --profile ] +allagents mcp update [--offline] [--scope project|user | --profile ] ``` -Manage MCP servers at the workspace level. Servers are persisted in a top-level `mcpServers:` field in `workspace.yaml` (parallel to `mcpProxy:`) and synced to all configured clients that support project-scoped MCP (`claude`, `codex`, `vscode`, `copilot`). +Every MCP command selects one declaration destination. With no selector, +AllAgents uses the current project's `.allagents/workspace.yaml`. From the home +directory that path aliases the ordinary user config, so an unflagged command +resolves to user scope and explicit `--scope project` is rejected. Use +`--scope user` to select top-level MCP fields in `~/.allagents/workspace.yaml`; +`--profile ` selects `profiles.` in that same file. +`--scope` and `--profile` are mutually exclusive. + +Run `allagents` without arguments and choose **MCP Servers** to manage the same +project, user, and named-profile destinations interactively. Each destination +lists its servers directly, with add, inspect, reauthenticate, and remove +actions. Client configuration updates automatically after mutations; if an +update fails after the declaration changes, the TUI offers a contextual retry. +Credential values remain hidden from summaries and selection screens. + +Codex and Copilot materialize the selected destination at these exact paths: + +| Destination | Codex | Copilot CLI | +|-------------|-------|-------------| +| Project | `.codex/config.toml` | `.github/mcp.json` | +| User | `~/.codex/config.toml` | `~/.copilot/mcp-config.json` | +| Profile | `~/.allagents/profiles//clients/codex/home/.config.toml` | `~/.allagents/profiles//clients/copilot/home/mcp-config.json` | ### mcp add -Add a new MCP server to `workspace.yaml` and immediately sync it to all configured clients. +Add a new MCP server to the selected destination and immediately update its +client configuration. +For HTTP servers, AllAgents connects first, completes OAuth when required, and +routes selected clients through its built-in MCP client. | Flag | Description | |------|-------------| | `--transport ` | Transport type: `http` or `stdio` (auto-detected from URL by default) | | `--arg ` | Argument for the stdio command (repeatable) | | `-e, --env ` | Environment variable for stdio transport (repeatable) | -| `--header ` | HTTP header for http transport (repeatable) | -| `--client ` | Comma-separated list of clients that should receive this server (default: all project-scoped clients) | +| `--header ` | HTTP header for HTTP transport (repeatable) | +| `--client ` | Client selector; repeatable and comma-compatible. Values are deduplicated in first-seen order. Defaults to every eligible client in the selected destination. | +| `--scope ` | Destination scope: `project` (default) or `user` | +| `--profile ` | Named profile destination; cannot be combined with `--scope` | | `-f, --force` | Replace an existing server with the same name | -**Transport auto-detection:** if `` starts with `http://` or `https://`, http transport is selected; otherwise stdio is selected. Passing `--transport stdio` with a URL, or `--transport http` with a non-URL command, is rejected. +**Transport auto-detection:** if `` starts with `http://` or +`https://`, HTTP transport is selected; otherwise stdio is selected. Passing +`--transport stdio` with a URL, or `--transport http` with a non-URL command, +is rejected. + +Non-interactive HTTP adds perform the same connection check without starting a +browser. They can use cached credentials, but fail before mutation when fresh +OAuth consent is required. + +Profile header values must be exact environment references such as +`'Authorization=${TRADINGVIEW_TOKEN}'`. Generated bridge configs retain only +the header-to-variable binding; the value is resolved when the bridge connects. +Missing variables fail before a request is sent. ```bash -# HTTP server +# Project HTTP server allagents mcp add deepwiki https://mcp.deepwiki.com/mcp -# HTTP server with headers and client filter -allagents mcp add internal https://mcp.internal.corp --header Authorization=Bearer-token --client claude,copilot +# Repeatable and comma-compatible client filters +allagents mcp add internal https://mcp.internal.corp \ + --header Authorization=Bearer-token \ + --client claude --client codex,copilot + +# User destination +allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --scope user + +# Profile destination +allagents mcp add tradingview https://mcp.tradingview.com/mcp --profile markets # stdio server with args and env vars allagents mcp add gh-server npx --arg=-y --arg=@modelcontextprotocol/server-github -e GH_TOKEN=ghp_xxx -# Replace an existing server (update workflow) +# Replace an existing server allagents mcp add deepwiki https://new.example.com --force ``` -### mcp proxy +### mcp reauth -Expose a remote HTTP MCP server locally over stdio. This is the helper command AllAgents writes into proxied client configs when `mcpProxy` rewrites an HTTP server for clients that only support stdio transport. - -| Flag | Description | -|------|-------------| -| `--header ` | HTTP header forwarded to the upstream MCP server (repeatable) | +Force a fresh OAuth login for an HTTP MCP server in the selected destination: ```bash -allagents mcp proxy https://mcp.deepwiki.com/mcp -allagents mcp proxy https://mcp.internal.corp --header Authorization=Bearer-token +allagents mcp reauth tradingview +allagents mcp reauth tradingview --scope user +allagents mcp reauth tradingview --profile markets ``` -`proxy` is the only supported public command shown in help and generated configs. +AllAgents clears only the selected credential cache, opens a browser, and +verifies a new connection. Project and ordinary user declarations share the +ordinary URL-keyed cache. Each named profile has an isolated cache. Removing a +local cache does not revoke the grant at the remote authorization provider. If +the browser is on another device, paste the complete loopback callback URL into +the waiting terminal prompt. + +The HTTP-to-stdio bridge written into generated client configs is an internal +implementation detail; users do not need to run it directly. ### mcp remove -Remove a server from `workspace.yaml` and unsync it from all clients. Only servers AllAgents added are removed; pre-existing user-managed servers in client MCP configs are preserved. +Remove a server from the selected declaration destination and reconcile its +client configs. Only entries tracked by AllAgents are removed; pre-existing +user-managed servers in client MCP configs are preserved. ```bash allagents mcp remove deepwiki +allagents mcp remove deepwiki --scope user +allagents mcp remove tradingview --profile markets ``` ### mcp list -List all MCP servers defined in `workspace.yaml`. +List inline MCP declarations from exactly one selected destination. Header, +environment, URL credential, and sensitive query values are redacted in both +human and JSON output. ```bash allagents mcp list +allagents mcp list --profile markets ``` ### mcp get -Print the `workspace.yaml` definition for a specific server as YAML. +Print one inline declaration from exactly one selected destination as redacted +YAML, or as redacted structured data with `--json`. ```bash allagents mcp get deepwiki +allagents mcp get tradingview --profile markets ``` -Exits with status 1 if the server is not defined in `workspace.yaml`. +Exits with status 1 if the server is not defined at that destination. +Plugin-provided declarations are not included by `list` or `get`. ### mcp update -Re-sync MCP servers only, without touching skills, agents, hooks, or other plugin artifacts. Useful when you've edited `workspace.yaml`'s `mcpServers:` block manually and want to push the changes to clients without running a full workspace sync. +Reconcile MCP servers without touching skills, agents, hooks, or other plugin +artifacts. Project and ordinary user destinations run MCP-only reconciliation. +An installed profile runs its ownership-aware profile reconciliation because +some clients store settings and MCP in the same managed file. A declared but +uninstalled profile is not implicitly installed. | Flag | Description | |------|-------------| | `--offline` | Use cached plugins without fetching from remote marketplaces | +| `--scope ` | Destination scope: `project` (default) or `user` | +| `--profile ` | Named profile destination; cannot be combined with `--scope` | ```bash allagents mcp update +allagents mcp update --scope user +allagents mcp update --profile markets allagents mcp update --offline ``` @@ -532,10 +624,10 @@ To modify a server definition, use `allagents mcp add ... --force`. ### Ownership model -AllAgents only tracks MCP servers it added. This means: +AllAgents only tracks MCP servers it added: -- `mcp add` marks the server as AllAgents-owned in `.allagents/sync-state.json`. -- `mcp remove` only removes servers from client MCP configs that AllAgents originally added. User-managed servers (added manually to `.mcp.json`, `.vscode/mcp.json`, etc.) are never touched. -- If you manually add a server to a client config first, and then run `mcp add` with the same name, the existing user-managed entry is left alone (AllAgents will skip it with a warning). +- `mcp add` records ownership in the selected destination's sync or profile state. +- `mcp remove` only removes servers from client MCP configs that AllAgents originally added. User-managed servers are never touched. +- If a same-name client entry existed before AllAgents managed it, the entry remains user-owned and is skipped with a warning. See [CLAUDE.md — MCP Server Sync](https://github.com/allagentsdev/allagents/blob/main/CLAUDE.md) for the full ownership rule. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 73be7631..25946a59 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -196,6 +196,13 @@ profiles: command: review-mcp env: REVIEW_TOKEN: ${REVIEW_TOKEN} + tradingview: + type: http + url: https://mcp.tradingview.com/mcp + mcpProxy: + servers: + tradingview: + proxy: [copilot] codex-review: clients: @@ -253,6 +260,7 @@ profiles: | `plugins[].clients` | No | Restrict the plugin to named clients in this profile | | `plugins[].skills` | No | Skill allowlist or `{ exclude: [...] }` where the adapter supports filtering | | `profiles..mcpServers` | No | Profile-scoped stdio or HTTP MCP declarations | +| `profiles..mcpProxy` | No | Profile-local HTTP-to-stdio routing policy; selectors must be clients declared by that profile | Relative local plugin sources resolve from the user's home directory, not the current project. Profile names and launcher names are safe command basenames; @@ -273,6 +281,12 @@ headers accept exact `${ENV_VAR}` references only, and credential-bearing command arguments must use the same exact form. Resolved secret values are never written to plans, launchers, profile state, or generated configuration. +Profile MCP is materialized inside the profile root. Codex writes +`clients/codex/home/.config.toml`; Copilot writes +`clients/copilot/home/mcp-config.json`. Proxied HTTP servers share OAuth only +inside that profile, under `oauth-proxy/`. Removing the profile removes +that profile-owned OAuth state. + OpenCode profile settings accept `model`, `small_model`, `default_agent`, `username`, `share`, `autoupdate`, `snapshot`, `subagent_depth`, `logLevel`, `disabled_providers`, and `enabled_providers`. All other keys fail validation. @@ -512,7 +526,7 @@ This differs from [plugin skill deduplication](/docs/guides/plugins/#duplicate-s ## MCP Servers -The optional top-level `mcpServers` field defines MCP servers managed directly by the workspace. Servers defined here are synced to all configured project-scoped MCP clients (`claude`, `codex`, `vscode`, `copilot`). +The optional top-level `mcpServers` field defines MCP servers managed directly by a project or ordinary user workspace. Project servers sync to configured project MCP clients; user servers sync to the corresponding user destinations. ```yaml mcpServers: @@ -547,15 +561,22 @@ mcpServers: | `command` | stdio only | Executable for the stdio command | | `args` | No | Command arguments array (stdio transport only) | | `env` | No | Environment variables map (stdio transport only) | -| `clients` | No | Subset of project-scoped clients that should receive this server. Defaults to all configured clients. | +| `clients` | No | Subset of clients in this destination that should receive this server. Defaults to all configured clients. | -Manage these entries declaratively in `workspace.yaml`, or via the [`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level servers override any plugin-supplied server with the same name (with a warning). +Manage these entries declaratively in the selected `workspace.yaml`, or via the +[`allagents mcp` commands](/docs/reference/cli/#mcp-commands). Workspace-level +servers override plugin-supplied servers with the same name, with a warning. -Servers AllAgents adds are tracked in `.allagents/sync-state.json`; pre-existing user-managed servers in client MCP configs (`.mcp.json`, `.vscode/mcp.json`, `.copilot/mcp-config.json`, `.codex/config.toml`) are never touched. +Ownership is tracked separately for project, ordinary user, and each named +profile. Pre-existing user-managed entries in client configs are never claimed +or removed. -## MCP Proxy +## Advanced MCP Routing -The optional `mcpProxy` section rewrites HTTP MCP servers to stdio via AllAgents' built-in `allagents mcp proxy` bridge for clients that need it. See the [MCP Proxy guide](/docs/guides/mcp-proxy/) for details. +HTTP servers created with `allagents mcp add` are routed through AllAgents +automatically. The optional `mcpProxy` section applies the same built-in bridge +to HTTP servers supplied by plugins, globally or per server. See the +[HTTP MCP Servers guide](/docs/guides/mcp-proxy/) for details. ```yaml mcpProxy: @@ -570,9 +591,9 @@ mcpProxy: | Field | Required | Description | |-------|----------|-------------| -| `clients` | Yes | Clients where all HTTP servers are proxied to stdio | +| `clients` | No | Clients where all HTTP servers are proxied to stdio; defaults to an empty list | | `servers` | No | Per-server overrides with additional client lists | -| `servers..proxy` | Yes (per entry) | Additional clients to proxy this specific server for | +| `servers..proxy` | Yes (per entry) | Additional clients for this server, or `*` for every current and future eligible client | Only servers with HTTP transport (`url` field) are transformed. Stdio servers pass through unchanged. diff --git a/plugins/allagents/.claude-plugin/plugin.json b/plugins/allagents/.claude-plugin/plugin.json new file mode 100644 index 00000000..2c47f39e --- /dev/null +++ b/plugins/allagents/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "allagents", + "description": "End-user guidance for managing workspaces, plugins, skills, profiles, clients, and MCP servers with the AllAgents CLI.", + "author": { + "name": "AllAgents" + }, + "version": "1.0.0", + "category": "development", + "homepage": "https://github.com/allagentsdev/allagents/tree/main/plugins/allagents" +} diff --git a/plugins/allagents/skills/allagents/SKILL.md b/plugins/allagents/skills/allagents/SKILL.md new file mode 100644 index 00000000..6d40e09d --- /dev/null +++ b/plugins/allagents/skills/allagents/SKILL.md @@ -0,0 +1,51 @@ +--- +name: allagents +description: Manage AllAgents workspaces, plugins, skills, client targets, global profiles, and MCP servers through the allagents CLI. Use when a user asks to initialize, inspect, update, or configure AllAgents; install or remove plugins or skills; manage profiles; or add, inspect, authenticate, update, or remove MCP servers. Resolve current command syntax from machine-readable help instead of relying on remembered flags. +--- + +# AllAgents CLI + +The installed AllAgents CLI owns the current command surface and operational contract. This skill only resolves the runner, loads that contract, and applies it. Do not maintain a second command reference here. + +## Resolve the CLI runner + +Choose one runner at the start of the task and use it for discovery, execution, and verification: + +1. If `allagents` is on `PATH`, use `allagents`. +2. Otherwise, if `npx` is available, use `npx --yes allagents`. +3. If neither is available, stop and ask the user to install AllAgents or Node.js with npm. + +Examples and returned `help_command` values use the canonical `allagents` token. When `npx --yes allagents` is the selected runner, replace only that leading token before executing every discovery, execution, and verification command. Do not mix runners within one operation. + +## Load the current contract + +1. Confirm the selected runner with `allagents --version`. +2. Discover the concise top-level index with `allagents --help --json`. +3. Choose an entry, rewrite its leading runner token when required, and execute its `help_command`; group responses reveal only their immediate children. +4. Continue through nested groups until a leaf command returns its full contract. +5. Follow the leaf's `when_to_use`, positionals, options, examples, interaction requirement, expected output, output schema, and JSON field allowlist. + +The structured response is authoritative. Do not rely on remembered flags, copied examples, aliases, destination behavior, or mutation semantics. If the installed CLI does not advertise an operation, do not invent it. + +## Execute through the discovered surface + +- Run `allagents` without arguments in an interactive terminal only when the user wants to browse and choose in the TUI. +- Use the discovered direct command when the operation is already known. +- For automation, use `--json` plus only the explicit selectors and non-interactive options advertised by the leaf help. +- Use `--json=` only with fields in the leaf's JSON allowlist. Use `--jq` only with JSON output. +- Prefer an advertised CLI command over hand-editing an AllAgents declaration or generated client file. +- Preserve the exact scope, destination, profile, clients, and other ownership selectors requested by the user. If the request is insufficient and the CLI requires a choice, ask rather than guessing. +- Never echo credentials. Supply sensitive values only through mechanisms advertised by the current leaf help. + +Treat the process exit code and structured result as authoritative. After a mutation, rediscover and run the relevant read-only list, get, or status command against the same selectors. Do not claim success from a config write alone when the CLI reports a partial update or failed client reconciliation. + +## Recover from errors + +- Read the structured error before retrying. +- If command syntax is rejected, reload root, group, and leaf help; do not fall back to stale syntax. +- If a mutation may have partially applied, inspect current state before any retry. +- Follow only recovery operations exposed by the installed CLI. + +## Completion + +Report the selected runner, command path, explicit selectors, structured result, and verification command. Completion requires the declared state and corresponding live or generated state to agree, or an explicit partial-update result with the CLI-advertised recovery path. diff --git a/plugins/tradingview/.claude-plugin/plugin.json b/plugins/tradingview/.claude-plugin/plugin.json new file mode 100644 index 00000000..2956a301 --- /dev/null +++ b/plugins/tradingview/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "tradingview", + "description": "Official TradingView market data and analytics for quotes, technicals, fundamentals, news, watchlists, alerts, and screeners.", + "author": { + "name": "AllAgents" + }, + "version": "1.0.0", + "category": "finance", + "homepage": "https://github.com/allagentsdev/allagents/tree/main/plugins/tradingview" +} diff --git a/plugins/tradingview/.mcp.json b/plugins/tradingview/.mcp.json new file mode 100644 index 00000000..587c6cdf --- /dev/null +++ b/plugins/tradingview/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "tradingview": { + "type": "http", + "url": "https://mcp.tradingview.com/mcp" + } + } +} diff --git a/scripts/dev-mcp-server.ts b/scripts/dev-mcp-server.ts index 40808d3c..78ba98c1 100644 --- a/scripts/dev-mcp-server.ts +++ b/scripts/dev-mcp-server.ts @@ -9,7 +9,7 @@ async function main() { console.log(` OAuth issuer: ${server.idpIssuer}`); console.log(''); console.log('Point allagents at it, e.g.:'); - console.log(` allagents mcp add local-dev ${server.mcpUrl} --proxy`); + console.log(` allagents mcp add local-dev ${server.mcpUrl}`); console.log(` bun run scripts/smoke-mcp-oauth.ts ${server.mcpUrl}`); console.log(''); console.log( diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts deleted file mode 100644 index ebb740e1..00000000 --- a/src/cli/agent-help.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { AgentCommandMeta } from './help.js'; -import { normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; - -import { - skillsAddMeta, - skillsListMeta, - skillsRemoveMeta, - skillsSearchMeta, - skillsUpdateMeta, -} from './metadata/plugin-skills.js'; -import { - marketplaceAddMeta, - marketplaceBrowseMeta, - marketplaceListMeta, - marketplaceRemoveMeta, - marketplaceUpdateMeta, - pluginInstallMeta, - pluginListMeta, - pluginUninstallMeta, - pluginUpdateMeta, - pluginValidateMeta, -} from './metadata/plugin.js'; -import { updateMeta } from './metadata/self.js'; -import { - profileInstallMeta, - profileListMeta, - profileRemoveMeta, - profileStatusMeta, -} from './metadata/profile.js'; -import { - initMeta, - setupMeta, - statusMeta, - syncMeta, -} from './metadata/workspace.js'; - -const allCommands: AgentCommandMeta[] = [ - initMeta, - setupMeta, - syncMeta, - statusMeta, - pluginInstallMeta, - pluginUninstallMeta, - pluginUpdateMeta, - marketplaceListMeta, - marketplaceAddMeta, - marketplaceRemoveMeta, - marketplaceUpdateMeta, - marketplaceBrowseMeta, - pluginListMeta, - pluginValidateMeta, - skillsListMeta, - skillsAddMeta, - skillsRemoveMeta, - skillsSearchMeta, - skillsUpdateMeta, - updateMeta, - profileInstallMeta, - profileListMeta, - profileStatusMeta, - profileRemoveMeta, -]; - -/** - * Strip --agent-help from args so cmd-ts doesn't see it. - */ -export function extractAgentHelpFlag(args: string[]): { args: string[]; agentHelp: boolean } { - const idx = args.indexOf('--agent-help'); - if (idx === -1) return { args, agentHelp: false }; - return { args: [...args.slice(0, idx), ...args.slice(idx + 1)], agentHelp: true }; -} - -function formatForAgent(meta: AgentCommandMeta) { - const result: Record = { - command: meta.command, - description: meta.description, - when_to_use: meta.whenToUse, - }; - if (meta.positionals && meta.positionals.length > 0) { - result.positionals = meta.positionals; - } - if (meta.options && meta.options.length > 0) { - result.options = meta.options; - } - result.examples = meta.examples; - if (meta.outputSchema) { - result.output_schema = meta.outputSchema; - } - if (meta.jsonFields && meta.jsonFields.length > 0) { - result.json_fields = [...meta.jsonFields]; - } - return result; -} - -/** Maps deprecated command paths to their current canonical equivalents. */ -const commandAliases: Record = { - 'workspace status': 'status', -}; - -function resolveAlias(commandPath: string): string { - return commandAliases[commandPath] ?? commandPath; -} - -/** - * Look up metadata by a runtime command path (e.g. "skill update foo"). - * Resolves deprecated aliases (e.g. "workspace status" -> "status"). - * Used by index.ts to validate `--json=` against the per-command - * allowlist before dispatching. A longest-prefix match allows command metadata - * to resolve when positional arguments follow the command tokens. - */ -export function findMetaByCommand( - commandPath: string, -): AgentCommandMeta | undefined { - if (!commandPath) return undefined; - const resolved = resolveAlias(commandPath); - const exact = allCommands.find((command) => command.command === resolved); - if (exact) return exact; - - return allCommands - .filter((command) => resolved.startsWith(`${command.command} `)) - .sort((a, b) => b.command.length - a.command.length)[0]; -} - -export function printAgentHelp(args: string[], version: string): void { - // Determine which command is being asked about by looking at remaining args. - const positional = args.filter(a => !a.startsWith('-')); - const normalized = normalizeSkillHelpArgs(positional); - const commandPath = normalized.join(' '); - - if (!commandPath) { - // Full tree - const tree = { - name: 'allagents', - version, - description: 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization', - commands: allCommands.map(formatForAgent), - }; - console.log(JSON.stringify(tree, null, 2)); - } else { - const resolved = resolveAlias(commandPath); - // Find exact matching command - const match = allCommands.find(c => c.command === resolved); - if (match) { - console.log(JSON.stringify(formatForAgent(match), null, 2)); - } else { - // Try prefix match for subcommand groups - const matches = allCommands.filter(c => c.command.startsWith(`${resolved} `)); - if (matches.length > 0) { - const group = { - name: resolved, - commands: matches.map(formatForAgent), - }; - console.log(JSON.stringify(group, null, 2)); - } else { - console.error(`Unknown command: ${commandPath}`); - process.exit(1); - } - } - } - process.exit(0); -} diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 7aa20b8d..1dc2fbfe 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -1,3 +1,4 @@ +import { isCancel, password } from '@clack/prompts'; import { array, command, @@ -10,22 +11,31 @@ import { } from 'cmd-ts'; import { dump } from 'js-yaml'; import { - addWorkspaceMcpServer, + runHttpMcpStdioProxy, + validateOAuthCallbackUrl, +} from '../../core/mcp-http-stdio-proxy.js'; +import { + addManagedMcpServer, + listManagedMcpServers, + type McpAuthorizationInteraction, + type McpDestinationSync, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, +} from '../../core/mcp-management.js'; +import { buildMcpServerConfigFromFlags, - clearWorkspaceMcpServerProxy, - getWorkspaceMcpServer, - listWorkspaceMcpServers, + getMcpServer, + type McpDestination, parseKeyValuePairs, - removeWorkspaceMcpServer, - setWorkspaceMcpServerProxy, + resolveMcpDestination, } from '../../core/mcp-servers.js'; -import { runHttpMcpStdioProxy } from '../../core/mcp-http-stdio-proxy.js'; -import { syncMcpOnly } from '../../core/mcp-sync.js'; import { type ClientType, ClientTypeSchema, type McpServerConfig, } from '../../models/workspace-config.js'; +import { buildProfileData, formatProfileResult } from '../format-profile.js'; import { formatMcpResult } from '../format-sync.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; @@ -33,44 +43,185 @@ import { mcpAddMeta, mcpGetMeta, mcpListMeta, + mcpReauthMeta, mcpRemoveMeta, mcpUpdateMeta, } from '../metadata/mcp.js'; +import { terminalSafe } from '../terminal-output.js'; // ============================================================================= // Helpers // ============================================================================= -function parseClientFilter(input: string): ClientType[] { - const items = input - .split(',') - .map((s) => s.trim()) - .filter(Boolean); +const destinationArgs = { + scope: option({ + type: optional(string), + long: 'scope', + description: "Declaration scope: 'project' (default) or 'user'", + }), + profile: option({ + type: optional(string), + long: 'profile', + description: 'Named profile declaration destination', + }), +}; + +interface DestinationFlags { + scope?: string | undefined; + profile?: string | undefined; +} + +function resolveCommandDestination( + commandName: string, + flags: DestinationFlags, +): McpDestination { + try { + return resolveMcpDestination({ + cwd: process.cwd(), + ...(flags.scope === undefined ? {} : { scope: flags.scope }), + ...(flags.profile === undefined ? {} : { profile: flags.profile }), + }); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +function serializeDestination( + destination: McpDestination, +): { kind: 'project' | 'user' } | { kind: 'profile'; name: string } { + return destination.kind === 'profile' + ? { kind: 'profile', name: destination.name } + : { kind: destination.kind }; +} + +function destinationDisplay(destination: McpDestination): string { + switch (destination.kind) { + case 'project': + return 'workspace.yaml'; + case 'user': + return 'the user workspace'; + case 'profile': + return `profile '${terminalSafe(destination.name)}'`; + } +} + +function parseClientFilter(inputs: string[]): ClientType[] | undefined { + if (inputs.length === 0) return undefined; + const result: ClientType[] = []; - for (const item of items) { - const parsed = ClientTypeSchema.safeParse(item); - if (!parsed.success) { - throw new Error( - `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, - ); + const seen = new Set(); + for (const input of inputs) { + for (const segment of input.split(',')) { + const item = segment.trim(); + if (!item) { + throw new Error('--client values cannot contain empty segments'); + } + const parsed = ClientTypeSchema.safeParse(item); + if (!parsed.success) { + throw new Error( + `Invalid client '${item}'. Valid clients: ${ClientTypeSchema.options.join(', ')}`, + ); + } + if (!seen.has(parsed.data)) { + seen.add(parsed.data); + result.push(parsed.data); + } } - result.push(parsed.data); } return result; } +const REDACTED_VALUE = '[REDACTED]'; +function isSensitiveCredentialName(value: string): boolean { + const normalized = value.replace(/[^A-Za-z0-9]/g, '').toLowerCase(); + return ( + normalized === 'key' || + /(?:authorization|auth|credentials?|password|passwd|secrets?|signature|tokens?|accesstoken|refreshtoken|apikey|accesskey|privatekey)$/.test( + normalized, + ) + ); +} + +function redactUrlCredentials(value: string): string { + try { + const url = new URL(value); + if (url.username) url.username = REDACTED_VALUE; + if (url.password) url.password = REDACTED_VALUE; + for (const key of url.searchParams.keys()) { + if (isSensitiveCredentialName(key)) { + url.searchParams.set(key, REDACTED_VALUE); + } + } + return url.toString(); + } catch { + return value; + } +} + +function redactMcpArguments(args: string[]): string[] { + let redactNext = false; + return args.map((argument) => { + if (redactNext) { + redactNext = false; + return REDACTED_VALUE; + } + const assignment = /^([^=]+)=(.*)$/.exec(argument); + if (assignment && isSensitiveCredentialName(assignment[1] as string)) { + return `${assignment[1]}=${REDACTED_VALUE}`; + } + if (/^Bearer\s+\S+/i.test(argument)) { + return `Bearer ${REDACTED_VALUE}`; + } + if (/^https?:\/\//i.test(argument)) { + return redactUrlCredentials(argument); + } + if ( + argument.startsWith('-') && + isSensitiveCredentialName(argument.replace(/^-+/, '')) + ) { + redactNext = true; + } + return argument; + }); +} + +function redactMcpServerConfig(config: McpServerConfig): McpServerConfig { + if ('url' in config) { + return { + ...config, + url: redactUrlCredentials(config.url), + ...(config.headers && { + headers: Object.fromEntries( + Object.keys(config.headers).map((key) => [key, REDACTED_VALUE]), + ), + }), + }; + } + return { + ...config, + ...(config.args && { args: redactMcpArguments(config.args) }), + ...(config.env && { + env: Object.fromEntries( + Object.keys(config.env).map((key) => [key, REDACTED_VALUE]), + ), + }), + }; +} + function exitWithError(command: string, error: string): never { if (isJsonMode()) { jsonOutput({ success: false, command, error }); } else { - console.error(`Error: ${error}`); + console.error(`Error: ${terminalSafe(error)}`); } process.exit(1); } /** - * Shared flag parsing for `mcp add` / `mcp update`. Exits with a user-friendly - * error if any flag is invalid. + * Parse and validate flags shared by MCP server declarations. */ function buildConfigFromAddFlags( commandName: string, @@ -79,7 +230,7 @@ function buildConfigFromAddFlags( args: string[], env: string[], header: string[], - client: string | undefined, + client: string[], ): McpServerConfig { if (transport && transport !== 'http' && transport !== 'stdio') { exitWithError( @@ -95,12 +246,10 @@ function buildConfigFromAddFlags( if ('error' in headerResult) exitWithError(commandName, headerResult.error); let clients: ClientType[] | undefined; - if (client) { - try { - clients = parseClientFilter(client); - } catch (e) { - exitWithError(commandName, e instanceof Error ? e.message : String(e)); - } + try { + clients = parseClientFilter(client); + } catch (e) { + exitWithError(commandName, e instanceof Error ? e.message : String(e)); } const buildOpts: Parameters[0] = { @@ -117,41 +266,132 @@ function buildConfigFromAddFlags( return built.config; } +function createAuthorizationInteraction(): McpAuthorizationInteraction { + return { + output: console.log, + readCallback: async ({ redirectUrl, state, signal }) => { + const callbackUrl = await password({ + message: 'Paste the OAuth callback URL if using another browser', + signal, + validate: (value) => { + if (!value) { + return 'OAuth callback URL is required'; + } + try { + validateOAuthCallbackUrl(value, redirectUrl, state); + return undefined; + } catch (error) { + return error instanceof Error + ? error.message + : 'Invalid OAuth callback URL'; + } + }, + }); + if (isCancel(callbackUrl)) { + throw new Error('OAuth authorization cancelled'); + } + return callbackUrl; + }, + }; +} + +async function runManagedMcpOperation( + commandName: string, + operation: () => Promise, +): Promise { + try { + return await operation(); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +async function getConfiguredMcpServer( + commandName: string, + destination: McpDestination, + name: string, +): Promise { + try { + return await getMcpServer(destination, name); + } catch (error) { + exitWithError( + commandName, + error instanceof Error ? error.message : String(error), + ); + } +} + +function profileSyncData( + sync: Extract, +): { status: 'not-installed' } | Record { + return sync.result + ? buildProfileData(sync.result) + : { status: 'not-installed' }; +} + +function printMcpSyncResult( + result: Extract['result'], +): void { + for (const [scope, scopeResult] of Object.entries(result.mcpResults)) { + if (!scopeResult) continue; + const lines = formatMcpResult(scopeResult, scope); + if (lines.length > 0) { + console.log(''); + for (const line of lines) console.log(line); + } + } + for (const warning of result.warnings) { + console.log(` \u26A0 ${warning}`); + } +} + +function printProfileSyncResult( + destination: Extract, + sync: Extract, +): void { + if (!sync.result) { + console.log( + `Profile '${terminalSafe(destination.name)}' is not installed; skipped client reconciliation.`, + ); + return; + } + console.log(''); + for (const line of formatProfileResult(sync.result)) console.log(line); +} + /** - * Run MCP-only sync after a mutation and print per-scope results. Always runs - * offline because the mutation only affects local workspace.yaml and does not - * require refreshing plugins from remote marketplaces. + * Render the completed reconciliation for a declaration mutation. */ -async function runPostMutationSync( +function renderPostMutationSync( commandName: string, + destination: McpDestination, + sync: McpDestinationSync, successMessage: string, jsonExtra: Record, -): Promise { - const syncResult = await syncMcpOnly(process.cwd(), { offline: true }); - if (!syncResult.success) { - exitWithError(commandName, syncResult.error ?? 'MCP sync failed'); - } - +): void { if (isJsonMode()) { jsonOutput({ success: true, command: commandName, - data: { ...jsonExtra, mcpResults: syncResult.mcpResults }, + data: { + ...jsonExtra, + destination: serializeDestination(destination), + ...(sync.kind === 'mcp' + ? { mcpResults: sync.result.mcpResults } + : { sync: profileSyncData(sync) }), + }, }); return; } console.log(successMessage); - for (const [scope, result] of Object.entries(syncResult.mcpResults)) { - if (!result) continue; - const lines = formatMcpResult(result, scope); - if (lines.length > 0) { - console.log(''); - for (const line of lines) console.log(line); - } - } - for (const warning of syncResult.warnings) { - console.log(` \u26A0 ${warning}`); + if (sync.kind === 'mcp') { + printMcpSyncResult(sync.result); + } else if (destination.kind === 'profile') { + printProfileSyncResult(destination, sync); } } @@ -214,15 +454,10 @@ const addArgs = { long: 'header', description: 'HTTP header KEY=VALUE (repeatable)', }), - client: option({ - type: optional(string), + client: multioption({ + type: array(string), long: 'client', - description: 'Comma-separated list of client filters', - }), - proxy: flag({ - long: 'proxy', - description: - 'Rewrite HTTP MCP server sync through the built-in AllAgents HTTP proxy helper for the targeted clients', + description: 'Client filter (repeatable; comma-separated values accepted)', }), }; @@ -231,6 +466,7 @@ const mcpAddCmd = command({ description: buildDescription(mcpAddMeta), args: { ...addArgs, + ...destinationArgs, force: flag({ long: 'force', short: 'f', @@ -245,9 +481,14 @@ const mcpAddCmd = command({ env, header, client, - proxy, force, + scope, + profile, }) => { + const destination = resolveCommandDestination('mcp add', { + scope, + profile, + }); const config = buildConfigFromAddFlags( 'mcp add', commandOrUrl, @@ -257,55 +498,28 @@ const mcpAddCmd = command({ header, client, ); - const proxyClients = client ? parseClientFilter(client) : undefined; - if (proxy && !('url' in config)) { - exitWithError( - 'mcp add', - '--proxy is only supported for HTTP MCP servers', - ); - } - - const addResult = await addWorkspaceMcpServer( - name, - config, - process.cwd(), - force, - ); - if (!addResult.success) - exitWithError('mcp add', addResult.error ?? 'Unknown error'); - - if (proxy) { - const proxyResult = await setWorkspaceMcpServerProxy( - name, - process.cwd(), - proxyClients, - ); - if (!proxyResult.success) { - exitWithError( - 'mcp add', - proxyResult.error ?? 'Failed to persist MCP proxy config', - ); - } - } else if (force) { - const clearResult = await clearWorkspaceMcpServerProxy( + const authorization = + !isJsonMode() && process.stdin.isTTY + ? createAuthorizationInteraction() + : undefined; + const result = await runManagedMcpOperation('mcp add', () => + addManagedMcpServer({ + destination, name, - process.cwd(), - ); - if (!clearResult.success) { - exitWithError( - 'mcp add', - clearResult.error ?? 'Failed to clear MCP proxy config', - ); - } - } + config, + force, + ...(authorization ? { authorization } : {}), + }), + ); - await runPostMutationSync( + renderPostMutationSync( 'mcp add', - `\u2713 Added MCP server '${name}' to workspace.yaml`, + destination, + result.sync, + `\u2713 Added MCP server '${terminalSafe(name)}' to ${destinationDisplay(destination)}`, { name, - config: addResult.config, - proxy, + config: redactMcpServerConfig(result.config), }, ); }, @@ -320,19 +534,62 @@ const mcpRemoveCmd = command({ description: buildDescription(mcpRemoveMeta), args: { name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, }, - handler: async ({ name }) => { - const removeResult = await removeWorkspaceMcpServer(name, process.cwd()); - if (!removeResult.success) - exitWithError('mcp remove', removeResult.error ?? 'Unknown error'); - await runPostMutationSync( + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp remove', { + scope, + profile, + }); + const sync = await runManagedMcpOperation('mcp remove', () => + removeManagedMcpServer(destination, name), + ); + renderPostMutationSync( 'mcp remove', - `\u2713 Removed MCP server '${name}' from workspace.yaml`, + destination, + sync, + `\u2713 Removed MCP server '${terminalSafe(name)}' from ${destinationDisplay(destination)}`, { name }, ); }, }); +// ============================================================================= +// mcp reauth +// ============================================================================= + +const mcpReauthCmd = command({ + name: 'reauth', + description: buildDescription(mcpReauthMeta), + args: { + name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, + }, + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp reauth', { + scope, + profile, + }); + if (isJsonMode() || !process.stdin.isTTY) { + exitWithError( + 'mcp reauth', + 'OAuth login requires an interactive terminal', + ); + } + + await runManagedMcpOperation('mcp reauth', () => + reauthenticateManagedMcpServer( + destination, + name, + createAuthorizationInteraction(), + ), + ); + console.log( + `\u2713 Reauthenticated MCP server '${terminalSafe(name)}' in ${destinationDisplay(destination)}`, + ); + }, +}); + // ============================================================================= // mcp proxy // ============================================================================= @@ -347,13 +604,51 @@ const mcpProxyCmd = command({ long: 'header', description: 'HTTP header KEY=VALUE (repeatable)', }), + headerEnv: multioption({ + type: array(string), + long: 'header-env', + description: 'HTTP header KEY=ENV_VAR reference (repeatable)', + }), + profile: option({ + type: optional(string), + long: 'profile', + description: 'Profile-owned OAuth credential scope', + }), }, - handler: async ({ serverUrl, header }) => { + handler: async ({ serverUrl, header, headerEnv, profile }) => { const headerResult = parseKeyValuePairs(header, '--header'); if ('error' in headerResult) { exitWithError('mcp proxy', headerResult.error); } - await runHttpMcpStdioProxy(serverUrl, headerResult.values); + const headerEnvResult = parseKeyValuePairs(headerEnv, '--header-env'); + if ('error' in headerEnvResult) { + exitWithError('mcp proxy', headerEnvResult.error); + } + const environmentHeaders: Record = {}; + for (const [key, value] of Object.entries(headerEnvResult.values)) { + const reference = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value); + const variable = reference?.[1] ?? value; + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(variable)) { + exitWithError( + 'mcp proxy', + `Invalid environment variable '${value}' for header '${key}'`, + ); + } + environmentHeaders[key] = `\${${variable}}`; + } + const destination = + profile === undefined + ? undefined + : resolveCommandDestination('mcp proxy', { profile }); + await runHttpMcpStdioProxy( + serverUrl, + { ...headerResult.values, ...environmentHeaders }, + { + ...(destination?.kind === 'profile' + ? { profile: destination.name } + : {}), + }, + ); }, }); @@ -364,37 +659,52 @@ const mcpProxyCmd = command({ const mcpListCmd = command({ name: 'list', description: buildDescription(mcpListMeta), - args: {}, - handler: async () => { - let servers: Record; - try { - servers = await listWorkspaceMcpServers(process.cwd()); - } catch (e) { - exitWithError('mcp list', e instanceof Error ? e.message : String(e)); - } + args: destinationArgs, + handler: async ({ scope, profile }) => { + const destination = resolveCommandDestination('mcp list', { + scope, + profile, + }); + const servers = await runManagedMcpOperation('mcp list', () => + listManagedMcpServers(destination), + ); const names = Object.keys(servers); + const redactedServers = Object.fromEntries( + Object.entries(servers).map(([name, config]) => [ + name, + redactMcpServerConfig(config), + ]), + ); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp list', - data: { servers, total: names.length }, + data: { + destination: serializeDestination(destination), + servers: redactedServers, + total: names.length, + }, }); return; } if (names.length === 0) { - console.log('No MCP servers defined in workspace.yaml.'); + console.log( + `No MCP servers defined in ${destinationDisplay(destination)}.`, + ); console.log(''); console.log('Add one with:'); console.log(' allagents mcp add '); return; } - console.log(`MCP servers (${names.length}):`); + console.log( + `MCP servers in ${destinationDisplay(destination)} (${names.length}):`, + ); console.log(''); for (const name of names) { - const config = servers[name]; + const config = redactedServers[name]; if (!config) continue; for (const line of serverToDisplay(name, config)) { console.log(` ${line}`); @@ -413,31 +723,36 @@ const mcpGetCmd = command({ description: buildDescription(mcpGetMeta), args: { name: positional({ type: string, displayName: 'name' }), + ...destinationArgs, }, - handler: async ({ name }) => { - let config: McpServerConfig | null; - try { - config = await getWorkspaceMcpServer(name, process.cwd()); - } catch (e) { - exitWithError('mcp get', e instanceof Error ? e.message : String(e)); - } + handler: async ({ name, scope, profile }) => { + const destination = resolveCommandDestination('mcp get', { + scope, + profile, + }); + const config = await getConfiguredMcpServer('mcp get', destination, name); if (!config) { exitWithError( 'mcp get', - `MCP server '${name}' not found in workspace.yaml`, + `MCP server '${name}' not found in ${destinationDisplay(destination)}`, ); } + const redactedConfig = redactMcpServerConfig(config); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp get', - data: { name, config }, + data: { + destination: serializeDestination(destination), + name, + config: redactedConfig, + }, }); return; } - console.log(dump({ [name]: config }, { lineWidth: -1 }).trimEnd()); + console.log(dump({ [name]: redactedConfig }, { lineWidth: -1 }).trimEnd()); }, }); @@ -453,34 +768,58 @@ const mcpUpdateCmd = command({ long: 'offline', description: 'Use cached plugins without fetching from remote', }), + ...destinationArgs, }, - handler: async ({ offline }) => { - const result = await syncMcpOnly(process.cwd(), { offline }); - if (!result.success) { - exitWithError('mcp update', result.error ?? 'MCP sync failed'); - } + handler: async ({ offline, scope, profile }) => { + const destination = resolveCommandDestination('mcp update', { + scope, + profile, + }); + const sync = await runManagedMcpOperation('mcp update', () => + updateManagedMcpServers(destination, { offline }), + ); if (isJsonMode()) { jsonOutput({ success: true, command: 'mcp update', - data: { mcpResults: result.mcpResults, warnings: result.warnings }, + data: { + destination: serializeDestination(destination), + ...(sync.kind === 'mcp' + ? { + mcpResults: sync.result.mcpResults, + warnings: sync.result.warnings, + } + : { sync: profileSyncData(sync) }), + }, }); return; } - const hasAnyChanges = Object.values(result.mcpResults).some( - (r) => - r && - (r.added > 0 || r.overwritten > 0 || r.removed > 0 || r.skipped > 0), + if (sync.kind === 'profile') { + if (destination.kind === 'profile') { + printProfileSyncResult(destination, sync); + } + return; + } + + const hasAnyChanges = Object.values(sync.result.mcpResults).some( + (result) => + result && + (result.added > 0 || + result.overwritten > 0 || + result.removed > 0 || + result.skipped > 0), ); if (!hasAnyChanges) { console.log('No MCP server changes.'); } else { - for (const [scope, mcpResult] of Object.entries(result.mcpResults)) { + for (const [resultScope, mcpResult] of Object.entries( + sync.result.mcpResults, + )) { if (!mcpResult) continue; - const lines = formatMcpResult(mcpResult, scope); + const lines = formatMcpResult(mcpResult, resultScope); if (lines.length > 0) { for (const line of lines) console.log(line); console.log(''); @@ -488,9 +827,9 @@ const mcpUpdateCmd = command({ } } - if (result.warnings.length > 0) { + if (sync.result.warnings.length > 0) { console.log('Warnings:'); - for (const warning of result.warnings) { + for (const warning of sync.result.warnings) { console.log(` \u26A0 ${warning}`); } } @@ -501,15 +840,19 @@ const mcpUpdateCmd = command({ // mcp group // ============================================================================= -export const mcpCmd = conciseSubcommands({ - name: 'mcp', - description: 'Manage MCP servers for AI clients', - cmds: { - add: mcpAddCmd, - proxy: mcpProxyCmd, - remove: mcpRemoveCmd, - list: mcpListCmd, - get: mcpGetCmd, - update: mcpUpdateCmd, +export const mcpCmd = conciseSubcommands( + { + name: 'mcp', + description: 'Manage MCP servers for AI clients', + cmds: { + add: mcpAddCmd, + reauth: mcpReauthCmd, + proxy: mcpProxyCmd, + remove: mcpRemoveCmd, + list: mcpListCmd, + get: mcpGetCmd, + update: mcpUpdateCmd, + }, }, -}); + ['proxy'], +); diff --git a/src/cli/help.ts b/src/cli/help.ts index 5cf71a7d..3f37d244 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,4 +1,5 @@ import { subcommands } from 'cmd-ts'; +import { terminalSafe } from './terminal-output.js'; /** * Command metadata type and help text builder for enriched --help output. @@ -31,6 +32,8 @@ export interface AgentCommandMeta extends CommandMeta { positionals?: CommandPositional[]; options?: CommandOption[]; outputSchema?: Record; + /** Whether an agent can invoke the command without a human interaction step. */ + interaction?: 'none' | 'conditional' | 'required'; /** * Allowlist of fields that may be requested via `--json=,`. * @@ -68,6 +71,7 @@ export function buildDescription(meta: CommandMeta): string { */ export function conciseSubcommands( config: Parameters[0], + hiddenSubcommands: readonly string[] = [], ): ReturnType { const result = subcommands(config); const originalPrintHelp = result.printHelp.bind(result); @@ -82,13 +86,21 @@ export function conciseSubcommands( cmd.description = cmd.description.split('\n')[0] ?? cmd.description; } } - const output = originalPrintHelp(context); + let output = originalPrintHelp(context); for (const [key, cmd] of Object.entries(config.cmds)) { const original = originals.get(key); if (original !== undefined) { cmd.description = original; } } + for (const name of hiddenSubcommands) { + output = output + .split('\n') + .filter( + (line) => !terminalSafe(line).startsWith(`- ${name} -`), + ) + .join('\n'); + } return output; }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 80aa3687..db896da6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,33 +1,37 @@ #!/usr/bin/env node -import { run } from 'cmd-ts'; -import { conciseSubcommands } from './help.js'; -import { workspaceCmd, syncCmd, initCmd, statusCmd } from './commands/workspace.js'; -import { pluginCmd } from './commands/plugin.js'; +import { run, setDefaultHelpFormatter } from 'cmd-ts'; +import packageJson from '../../package.json'; import { mcpCmd } from './commands/mcp.js'; -import { selfCmd } from './commands/self.js'; +import { pluginCmd } from './commands/plugin.js'; import { skillsCmd } from './commands/plugin-skills.js'; import { profileCmd } from './commands/profile.js'; +import { selfCmd } from './commands/self.js'; +import { + initCmd, + statusCmd, + syncCmd, + workspaceCmd, +} from './commands/workspace.js'; +import { type AgentCommandMeta, conciseSubcommands } from './help.js'; import { - extractJsonFlag, extractJqFlag, + extractJsonFlag, setJsonMode, validateJsonFields, } from './json-output.js'; +import { normalizeSkillArgs } from './skill-arg-normalizer.js'; import { - extractAgentHelpFlag, + createStructuredHelpFormatter, findMetaByCommand, - printAgentHelp, -} from './agent-help.js'; +} from './structured-help.js'; import { getUpdateNotice } from './update-check.js'; -import { normalizeSkillArgs, normalizeSkillHelpArgs } from './skill-arg-normalizer.js'; -import packageJson from '../../package.json'; const app = conciseSubcommands({ name: 'allagents', description: 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization\n\n' + - 'For AI agents: use --agent-help for machine-readable help, or --json for structured output', + 'Use --help --json for machine-readable command metadata, or --json for structured command output', version: packageJson.version, cmds: { init: initCmd, @@ -42,11 +46,37 @@ const app = conciseSubcommands({ }, }); +function hasHelpFlag( + args: readonly string[], + meta: AgentCommandMeta | undefined, +): boolean { + const valueOptions = new Set( + (meta?.options ?? []) + .filter((option) => option.type === 'string') + .flatMap((option) => [option.flag, option.short].filter(Boolean)), + ); + let consumesNext = false; + + for (const arg of args) { + if (consumesNext) { + consumesNext = false; + continue; + } + if (arg === '--') break; + if (valueOptions.has(arg)) { + consumesNext = true; + continue; + } + if (arg === '--help' || arg === '-h') return true; + } + + return false; +} + const rawArgs = process.argv.slice(2); const { args: argsNoJson, json, jsonFields } = extractJsonFlag(rawArgs); const { args: argsNoJq, jqExpr } = extractJqFlag(argsNoJson); -const { args: argsAfterAgentHelp, agentHelp } = extractAgentHelpFlag(argsNoJq); -const finalArgs = normalizeSkillArgs(argsAfterAgentHelp); +const finalArgs = normalizeSkillArgs(argsNoJq); const commandPath = finalArgs.filter((arg) => !arg.startsWith('-')).join(' '); const commandMeta = findMetaByCommand(commandPath); @@ -56,11 +86,18 @@ if (jqExpr && !json) { process.exit(2); } -// Validate `--json=` against the meta allowlist for the invoked command. +// Help owns its field-selection error so the structured formatter can explain +// that help output is not filterable. Option values and positionals that happen +// to equal a help token remain ordinary command input. +const requestsHelp = hasHelpFlag(finalArgs, commandMeta); let validatedFields: string[] | undefined; if (jsonFields) { - const result = validateJsonFields(jsonFields, commandMeta); - validatedFields = result ? [...result] : undefined; + if (requestsHelp) { + validatedFields = [...jsonFields]; + } else { + const result = validateJsonFields(jsonFields, commandMeta); + validatedFields = result ? [...result] : undefined; + } } setJsonMode(json, { @@ -68,17 +105,19 @@ setJsonMode(json, { ...(jqExpr && { jqExpr }), }); +if (json) { + setDefaultHelpFormatter(createStructuredHelpFormatter(packageJson.version)); +} + // Kick off the update check for ordinary non-JSON invocations unless the // resolved command metadata marks the command as strictly read-only. const isWizard = finalArgs.length === 0 && process.stdout.isTTY && !json; -if (!agentHelp && !json && !isWizard && !commandMeta?.skipUpdateCheck) { +if (!json && !isWizard && !commandMeta?.skipUpdateCheck) { const notice = await getUpdateNotice(packageJson.version); if (notice) process.stderr.write(`${notice}\n\n`); } -if (agentHelp) { - printAgentHelp(normalizeSkillHelpArgs(argsAfterAgentHelp), packageJson.version); -} else if (isWizard) { +if (isWizard) { // Interactive wizard when no args and running in a terminal const { runWizard } = await import('./tui/wizard.js'); await runWizard(); diff --git a/src/cli/json-output.ts b/src/cli/json-output.ts index 1c814a7a..4db3bfc2 100644 --- a/src/cli/json-output.ts +++ b/src/cli/json-output.ts @@ -9,7 +9,10 @@ export function isJsonMode(): boolean { return jsonMode; } -export function setJsonMode(value: boolean, options?: { fields?: string[]; jqExpr?: string }): void { +export function setJsonMode( + value: boolean, + options?: { fields?: string[]; jqExpr?: string }, +): void { jsonMode = value; jsonFields = options?.fields ?? null; jqExpr = options?.jqExpr ?? null; @@ -32,14 +35,21 @@ export interface JsonEnvelope { * narrows each item to the requested fields. Otherwise the filter is applied * to the top-level `data` object directly. */ -function applyFieldFilter(envelope: JsonEnvelope, fields: string[]): JsonEnvelope { +function applyFieldFilter( + envelope: JsonEnvelope, + fields: string[], +): JsonEnvelope { if (!envelope.data || typeof envelope.data !== 'object') return envelope; const data = envelope.data as Record; const keys = Object.keys(data); // Single top-level array of objects → filter each item. const arrayKey = keys.find( - (k) => Array.isArray(data[k]) && (data[k] as unknown[]).every((it) => it !== null && typeof it === 'object' && !Array.isArray(it)), + (k) => + Array.isArray(data[k]) && + (data[k] as unknown[]).every( + (it) => it !== null && typeof it === 'object' && !Array.isArray(it), + ), ); if (arrayKey && keys.length >= 1) { const items = data[arrayKey] as Array>; @@ -51,7 +61,10 @@ function applyFieldFilter(envelope: JsonEnvelope, fields: string[]): JsonEnvelop return { ...envelope, data: projectFields(data, fields) }; } -function projectFields(obj: Record, fields: string[]): Record { +function projectFields( + obj: Record, + fields: string[], +): Record { const out: Record = {}; for (const f of fields) { if (f in obj) out[f] = obj[f]; @@ -60,30 +73,38 @@ function projectFields(obj: Record, fields: string[]): Record 0) { - final = applyFieldFilter(envelope, jsonFields); - } - if (jqExpr) { - console.log(runJq(final, jqExpr)); - return; - } - console.log(JSON.stringify(final, null, 2)); + const final = + jsonFields && jsonFields.length > 0 + ? applyFieldFilter(envelope, jsonFields) + : envelope; + jsonValueOutput(final); } /** @@ -93,9 +114,11 @@ export function jsonOutput(envelope: JsonEnvelope): void { * `json` — boolean, true if the flag was present in either form. * `jsonFields` — comma-split field list when `--json=` was supplied. */ -export function extractJsonFlag( - args: string[], -): { args: string[]; json: boolean; jsonFields?: string[] } { +export function extractJsonFlag(args: string[]): { + args: string[]; + json: boolean; + jsonFields?: string[]; +} { const out: string[] = []; let json = false; let fields: string[] | undefined; @@ -109,7 +132,10 @@ export function extractJsonFlag( json = true; const value = a.slice('--json='.length); if (value.length > 0) { - fields = value.split(',').map((s) => s.trim()).filter(Boolean); + fields = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); } continue; } @@ -125,7 +151,10 @@ export function extractJsonFlag( * `--jq` without `--json` is rejected by the caller; this function only does * lexical extraction so the args list passed to cmd-ts no longer contains it. */ -export function extractJqFlag(args: string[]): { args: string[]; jqExpr?: string } { +export function extractJqFlag(args: string[]): { + args: string[]; + jqExpr?: string; +} { const idx = args.indexOf('--jq'); if (idx === -1) return { args }; const expr = args[idx + 1]; @@ -137,6 +166,15 @@ export function extractJqFlag(args: string[]): { args: string[]; jqExpr?: string return { args: next, jqExpr: expr }; } +export function jsonFieldAllowlist( + meta: AgentCommandMeta | undefined, +): readonly string[] { + if (meta?.jsonFields && meta.jsonFields.length > 0) { + return meta.jsonFields; + } + return Object.keys(meta?.outputSchema ?? {}); +} + /** * Validate requested `--json=` against a meta's allowlist. Exits with * a sorted "Available fields" message on any unknown field. @@ -148,9 +186,8 @@ export function validateJsonFields( meta: AgentCommandMeta | undefined, ): readonly string[] | undefined { if (!fields || fields.length === 0) return undefined; - const allow = meta?.jsonFields; - if (!allow || allow.length === 0) { - // No allowlist declared → accept any field (no validation). + const allow = jsonFieldAllowlist(meta); + if (allow.length === 0) { return fields; } const unknown = fields.find((f) => !allow.includes(f)); diff --git a/src/cli/metadata/mcp.ts b/src/cli/metadata/mcp.ts index 2da5f6ec..e6cd476d 100644 --- a/src/cli/metadata/mcp.ts +++ b/src/cli/metadata/mcp.ts @@ -1,25 +1,54 @@ import type { AgentCommandMeta } from '../help.js'; +const destinationOptions: NonNullable = [ + { + flag: '--scope', + type: 'string', + choices: ['project', 'user'], + description: + "Declaration destination: 'project' (default) or ordinary 'user' configuration; cannot be combined with --profile", + }, + { + flag: '--profile', + type: 'string', + description: + 'Named profile declaration destination; cannot be combined with --scope', + }, +]; + +const destinationOutput = { + kind: 'project | user | profile', + name: 'string?', +}; + +const reconciliationOutput = { + mcpResults: 'object?', + sync: 'object?', +}; + export const mcpAddMeta: AgentCommandMeta = { command: 'mcp add', - description: 'Add an MCP server to workspace.yaml and sync to clients', + description: 'Add an MCP server, authenticate, and sync it to clients', whenToUse: - 'When adding a new MCP server that you want AllAgents to manage and sync to all configured clients', + 'When adding or replacing an MCP server in the current project, ordinary user configuration, or a declared profile. Agent and non-interactive use works for public servers or valid cached credentials; fresh OAuth consent requires a human rerun.', examples: [ 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp', 'allagents mcp add my-server npx --arg=-y --arg=@my/mcp-server', 'allagents mcp add gh-api npx -e GH_TOKEN=abc123 --arg=-y --arg=@modelcontextprotocol/server-github', - 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot', - 'allagents mcp add secure-api https://api.example.com/mcp --proxy', + 'allagents mcp add deepwiki https://mcp.deepwiki.com/mcp --client claude,copilot --client codex', + 'allagents mcp add personal npx --scope user', + 'allagents mcp add trading https://example.com/mcp --profile markets', + 'allagents --json mcp add trading https://example.com/mcp --profile markets', ], expectedOutput: - 'Adds the server to workspace.yaml and syncs it to all configured clients. With --proxy, HTTP servers are rewritten through the built-in AllAgents HTTP-to-stdio proxy path for the targeted clients. Exit 0 on success, 1 on failure.', + 'Validates and adds the declaration to the selected project, user, or profile destination, authenticates HTTP servers when needed, records proxy routing atomically, and reconciles installed clients. JSON output redacts credential values. Declared-only profiles are not implicitly installed. Exit 0 on success, 1 on failure.', + interaction: 'conditional', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name (unique within workspace.yaml)', + description: 'Server name (unique within the selected destination)', }, { name: 'commandOrUrl', @@ -57,13 +86,7 @@ export const mcpAddMeta: AgentCommandMeta = { flag: '--client', type: 'string', description: - 'Comma-separated list of clients that should receive this server (default: all project-scoped clients)', - }, - { - flag: '--proxy', - type: 'boolean', - description: - 'For HTTP servers, persist server-scoped proxy intent and sync targeted clients via the built-in AllAgents HTTP proxy helper', + 'Client filter (repeatable; each value may be comma-separated; duplicates are removed in first-seen order)', }, { flag: '--force', @@ -71,66 +94,147 @@ export const mcpAddMeta: AgentCommandMeta = { type: 'boolean', description: 'Replace an existing server with the same name', }, + ...destinationOptions, ], + outputSchema: { + destination: destinationOutput, + name: 'string', + config: 'object', + ...reconciliationOutput, + }, }; export const mcpRemoveMeta: AgentCommandMeta = { command: 'mcp remove', - description: 'Remove an MCP server from workspace.yaml and all clients', - whenToUse: 'When you no longer need an MCP server that AllAgents added', - examples: ['allagents mcp remove deepwiki'], + description: + 'Remove an MCP server from a project, user, or profile destination', + whenToUse: + 'When you no longer need an MCP server declaration in the selected destination', + examples: [ + 'allagents mcp remove deepwiki', + 'allagents mcp remove deepwiki --scope user', + 'allagents mcp remove trading --profile markets', + 'allagents --json mcp remove trading --profile markets', + ], expectedOutput: - 'Removes the server from workspace.yaml and unsyncs it from all configured clients. Exit 0 on success, 1 if the server is not defined in workspace.yaml.', + 'Removes the server declaration and proxy intent from the selected destination, then reconciles that destination. Declared-only profiles remain uninstalled. Exit 0 on success, 1 if the server is not defined.', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name to remove', + description: 'Server name to remove from the selected destination', }, ], + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + name: 'string', + ...reconciliationOutput, + }, }; export const mcpListMeta: AgentCommandMeta = { command: 'mcp list', - description: 'List MCP servers defined in workspace.yaml', + description: + 'List MCP servers declared in a project, user, or profile destination', whenToUse: - 'To inspect MCP servers AllAgents is managing at the workspace level', - examples: ['allagents mcp list'], + 'To inspect inline MCP declarations in exactly one selected destination without merging plugin-provided servers', + examples: [ + 'allagents mcp list', + 'allagents mcp list --scope user', + 'allagents mcp list --profile markets', + 'allagents --json mcp list --scope user', + ], expectedOutput: - 'Prints a table of workspace-defined MCP servers with transport, target, and client filter. Exit 0 on success.', + 'Prints MCP declarations from the selected project, ordinary user, or named profile destination with transport, target, and client filter. Header, environment, URL credential, and sensitive query values are redacted in human and JSON output. Exit 0 on success.', + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + servers: 'record', + total: 'number', + }, }; export const mcpGetMeta: AgentCommandMeta = { command: 'mcp get', - description: 'Show the workspace definition for an MCP server', - whenToUse: 'To see how an MCP server is configured in workspace.yaml', - examples: ['allagents mcp get deepwiki'], + description: + 'Show one MCP declaration from a project, user, or profile destination', + whenToUse: + 'To inspect how one MCP server is declared in exactly one selected destination', + examples: [ + 'allagents mcp get deepwiki', + 'allagents mcp get deepwiki --scope user', + 'allagents mcp get trading --profile markets', + 'allagents --json mcp get trading --profile markets', + ], + expectedOutput: + 'Prints the selected server declaration as YAML or structured JSON with header, environment, URL credential, and sensitive query values redacted. Exit 0 on success, 1 if it is not found in that destination.', + positionals: [ + { + name: 'name', + type: 'string', + required: true, + description: 'Server name in the selected destination', + }, + ], + options: [...destinationOptions], + outputSchema: { + destination: destinationOutput, + name: 'string', + config: 'redacted MCP server config', + }, +}; + +export const mcpReauthMeta: AgentCommandMeta = { + command: 'mcp reauth', + description: 'Reauthenticate a configured HTTP MCP server', + whenToUse: + 'Human-operated only: use when an HTTP MCP server in a project, ordinary user, or named profile destination needs a fresh OAuth login. JSON and non-interactive execution are rejected.', + examples: [ + 'allagents mcp reauth tradingview', + 'allagents mcp reauth secure-api --scope user', + 'allagents mcp reauth tradingview --profile markets', + ], expectedOutput: - 'Prints the server config (YAML). Exit 0 on success, 1 if not found.', + 'Clears cached OAuth credentials owned by the selected destination, opens a browser for login, accepts a pasted callback URL when the browser is remote, and verifies the connection. Profile credentials remain isolated. Exit 0 on success, 1 on cancellation or failure.', positionals: [ { name: 'name', type: 'string', required: true, - description: 'Server name', + description: 'HTTP MCP server name in the selected destination', }, ], + options: [...destinationOptions], + interaction: 'required', }; export const mcpUpdateMeta: AgentCommandMeta = { command: 'mcp update', - description: 'Sync MCP servers only, without touching other artifacts', + description: + 'Reconcile MCP servers for one project, user, or profile destination', whenToUse: - "When you've edited workspace.yaml's mcpServers block or plugin .mcp.json files and want to re-sync only MCP servers without re-running the full workspace sync. To modify a server definition use 'mcp add --force'.", - examples: ['allagents mcp update', 'allagents mcp update --offline'], + "After editing MCP declarations for a project, ordinary user configuration, or named profile. Project and user destinations run MCP-only reconciliation; installed profiles use the full profile ownership planner. To modify a server definition use 'mcp add --force'.", + examples: [ + 'allagents mcp update', + 'allagents mcp update --scope user', + 'allagents mcp update --profile markets', + 'allagents mcp update --offline', + 'allagents --json mcp update --profile markets', + ], expectedOutput: - 'Runs the MCP portion of sync for all project-scoped clients. Prints per-scope added/updated/removed counts. Exit 0 on success, 1 on failure.', + 'Reconciles the selected destination and prints client results. A declared-only profile is reported as not installed and is not implicitly installed. Exit 0 on success, 1 on failure.', options: [ { flag: '--offline', type: 'boolean', description: 'Use cached plugins without fetching from remote', }, + ...destinationOptions, ], + outputSchema: { + destination: destinationOutput, + ...reconciliationOutput, + }, }; diff --git a/src/cli/metadata/profile.ts b/src/cli/metadata/profile.ts index e835b2ef..23524b62 100644 --- a/src/cli/metadata/profile.ts +++ b/src/cli/metadata/profile.ts @@ -202,9 +202,9 @@ export const profileStatusMeta: AgentCommandMeta = { export const profileRemoveMeta: AgentCommandMeta = { command: 'profile remove', - description: 'Remove an installed global profile safely', + description: 'Remove an installed or declared global profile safely', whenToUse: - 'When you want AllAgents to remove resources it owns for one installed user profile while retaining referenced resources', + 'When you want AllAgents to remove resources and profile-owned OAuth credentials for one installed or declared user profile while retaining referenced resources', examples: [ 'allagents profile remove work', 'allagents profile remove work --dry-run', @@ -212,7 +212,7 @@ export const profileRemoveMeta: AgentCommandMeta = { 'allagents --json profile remove work --yes', ], expectedOutput: - 'Displays a redacted deterministic removal plan, asks before applying unless --yes is supplied, and reports removed, retained, unchanged, or failed resources.', + 'Displays a redacted deterministic removal plan, asks before applying unless --yes is supplied, removes profile-owned OAuth credentials even for a declared-only profile, and reports removed, retained, unchanged, or failed resources.', positionals: profileNamePositional, options: mutationOptions, outputSchema: { diff --git a/src/cli/structured-help.ts b/src/cli/structured-help.ts new file mode 100644 index 00000000..47536719 --- /dev/null +++ b/src/cli/structured-help.ts @@ -0,0 +1,281 @@ +import type { HelpFormatter } from 'cmd-ts'; +import type { AgentCommandMeta } from './help.js'; +import { + formatJsonValue, + getJsonFields, + jsonFieldAllowlist, +} from './json-output.js'; +import { + mcpAddMeta, + mcpGetMeta, + mcpListMeta, + mcpReauthMeta, + mcpRemoveMeta, + mcpUpdateMeta, +} from './metadata/mcp.js'; +import { + marketplaceAddMeta, + marketplaceBrowseMeta, + marketplaceListMeta, + marketplaceRemoveMeta, + marketplaceUpdateMeta, + pluginInstallMeta, + pluginListMeta, + pluginUninstallMeta, + pluginUpdateMeta, + pluginValidateMeta, +} from './metadata/plugin.js'; +import { + skillsAddMeta, + skillsListMeta, + skillsRemoveMeta, + skillsSearchMeta, + skillsUpdateMeta, +} from './metadata/plugin-skills.js'; +import { + profileInstallMeta, + profileListMeta, + profileRemoveMeta, + profileStatusMeta, +} from './metadata/profile.js'; +import { updateMeta } from './metadata/self.js'; +import { + initMeta, + pruneMeta, + setupMeta, + statusMeta, + syncMeta, +} from './metadata/workspace.js'; +import { + repoAddMeta, + repoListMeta, + repoRemoveMeta, +} from './metadata/workspace-repo.js'; + +interface RegisteredCommand { + command: string; + meta: AgentCommandMeta; +} + +/** + * Public command paths mirror the cmd-ts tree. Aliases reference the same + * metadata objects as their canonical commands so the two help surfaces cannot + * drift. + */ +const registeredCommands: RegisteredCommand[] = [ + { command: 'init', meta: initMeta }, + { command: 'update', meta: syncMeta }, + { command: 'status', meta: statusMeta }, + { command: 'workspace init', meta: initMeta }, + { command: 'workspace setup', meta: setupMeta }, + { command: 'workspace sync', meta: syncMeta }, + { command: 'workspace status', meta: statusMeta }, + { command: 'workspace prune', meta: pruneMeta }, + { command: 'workspace repo add', meta: repoAddMeta }, + { command: 'workspace repo remove', meta: repoRemoveMeta }, + { command: 'workspace repo list', meta: repoListMeta }, + { command: 'mcp add', meta: mcpAddMeta }, + { command: 'mcp reauth', meta: mcpReauthMeta }, + { command: 'mcp remove', meta: mcpRemoveMeta }, + { command: 'mcp list', meta: mcpListMeta }, + { command: 'mcp get', meta: mcpGetMeta }, + { command: 'mcp update', meta: mcpUpdateMeta }, + { command: 'plugin install', meta: pluginInstallMeta }, + { command: 'plugin uninstall', meta: pluginUninstallMeta }, + { command: 'plugin update', meta: pluginUpdateMeta }, + { command: 'plugin marketplace list', meta: marketplaceListMeta }, + { command: 'plugin marketplace add', meta: marketplaceAddMeta }, + { command: 'plugin marketplace remove', meta: marketplaceRemoveMeta }, + { command: 'plugin marketplace update', meta: marketplaceUpdateMeta }, + { command: 'plugin marketplace browse', meta: marketplaceBrowseMeta }, + { command: 'plugin list', meta: pluginListMeta }, + { command: 'plugin validate', meta: pluginValidateMeta }, + { command: 'plugin skills list', meta: skillsListMeta }, + { command: 'plugin skills add', meta: skillsAddMeta }, + { command: 'plugin skills remove', meta: skillsRemoveMeta }, + { command: 'plugin skills search', meta: skillsSearchMeta }, + { command: 'plugin skills update', meta: skillsUpdateMeta }, + { command: 'skill list', meta: skillsListMeta }, + { command: 'skill add', meta: skillsAddMeta }, + { command: 'skill remove', meta: skillsRemoveMeta }, + { command: 'skill search', meta: skillsSearchMeta }, + { command: 'skill update', meta: skillsUpdateMeta }, + { command: 'self update', meta: updateMeta }, + { command: 'profile install', meta: profileInstallMeta }, + { command: 'profile list', meta: profileListMeta }, + { command: 'profile status', meta: profileStatusMeta }, + { command: 'profile remove', meta: profileRemoveMeta }, +]; + +const groupDescriptions: Readonly> = { + workspace: 'Manage workspace lifecycle, synchronization, and repositories', + 'workspace repo': 'Manage repositories declared in the current workspace', + mcp: 'Manage MCP servers in project, user, and profile destinations', + plugin: 'Manage plugins, marketplaces, and plugin-scoped skills', + 'plugin marketplace': 'Manage plugin marketplace registrations and contents', + 'plugin skills': + 'Manage skills through the plugin compatibility command path', + skill: 'Discover, install, configure, and update skills', + self: 'Manage the installed AllAgents CLI', + profile: 'Manage declared and installed global agent profiles', +}; + +function formatStructuredHelp( + meta: AgentCommandMeta, + command = meta.command, +): Record { + const result: Record = { + command, + description: meta.description, + when_to_use: meta.whenToUse, + expected_output: meta.expectedOutput, + interaction: meta.interaction ?? 'none', + }; + if (meta.positionals && meta.positionals.length > 0) { + result.positionals = meta.positionals; + } + if (meta.options && meta.options.length > 0) { + result.options = meta.options; + } + result.examples = meta.examples; + if (meta.outputSchema) { + result.output_schema = meta.outputSchema; + } + result.json_fields = [...jsonFieldAllowlist(meta)]; + return result; +} + +function helpCommand(command: string): string { + return `allagents ${command} --help --json`; +} + +function formatIndexEntry(command: string): Record { + const exact = registeredCommands.find((entry) => entry.command === command); + if (exact) { + return { + command, + kind: 'command', + description: exact.meta.description, + when_to_use: exact.meta.whenToUse, + help_command: helpCommand(command), + }; + } + + return { + command, + kind: 'group', + description: + groupDescriptions[command] ?? `Commands grouped under ${command}`, + help_command: helpCommand(command), + }; +} + +function immediateCommandIndex(commandPath: string): Record[] { + const prefix = commandPath ? `${commandPath} ` : ''; + const seen = new Set(); + const result: Record[] = []; + + for (const registered of registeredCommands) { + if (!registered.command.startsWith(prefix)) continue; + const remainder = registered.command.slice(prefix.length); + const next = remainder.split(' ')[0]; + if (!next) continue; + + const command = commandPath ? `${commandPath} ${next}` : next; + if (seen.has(command)) continue; + seen.add(command); + result.push(formatIndexEntry(command)); + } + + return result; +} + +function findRegisteredCommand( + commandPath: string, +): RegisteredCommand | undefined { + let longest: RegisteredCommand | undefined; + for (const command of registeredCommands) { + const matches = + commandPath === command.command || + commandPath.startsWith(`${command.command} `); + if ( + matches && + (!longest || command.command.length > longest.command.length) + ) { + longest = command; + } + } + return longest; +} + +/** + * Look up metadata by a runtime command path (e.g. "skill update foo"). + * Public aliases and trailing positionals/options resolve through the same + * longest-prefix registry used by structured help. + */ +export function findMetaByCommand( + commandPath: string, +): AgentCommandMeta | undefined { + return findRegisteredCommand(commandPath)?.meta; +} + +function commandPathFromHelp(path: string[]): string { + return (path[0] === 'allagents' ? path.slice(1) : path).join(' '); +} + +function buildStructuredHelp( + commandPath: string, + version: string, +): Record { + if (!commandPath) { + return { + name: 'allagents', + version, + description: + 'CLI tool for managing multi-repo AI agent workspaces with plugin synchronization', + next: 'Choose a command or group and run its help_command for the next level.', + commands: immediateCommandIndex(''), + }; + } + + const match = registeredCommands.find( + (command) => command.command === commandPath, + ); + if (match) { + return formatStructuredHelp(match.meta, match.command); + } + + const commands = immediateCommandIndex(commandPath); + if (commands.length > 0) { + return { + name: commandPath, + description: + groupDescriptions[commandPath] ?? + `Commands grouped under ${commandPath}`, + next: 'Choose a command or group and run its help_command for the next level.', + commands, + }; + } + + process.stderr.write(`Unknown command: ${commandPath}\n`); + process.exit(1); +} + +function formatHelp(path: string[], version: string): string { + if (getJsonFields()) { + process.stderr.write( + 'Error: --json= is not supported with --help; use --json.\n', + ); + process.exit(2); + } + return formatJsonValue( + buildStructuredHelp(commandPathFromHelp(path), version), + ); +} + +export function createStructuredHelpFormatter(version: string): HelpFormatter { + return { + formatCommand: ({ path }) => formatHelp(path, version), + formatSubcommands: ({ path }) => formatHelp(path, version), + }; +} diff --git a/src/cli/tui/__tests__/mcp.test.ts b/src/cli/tui/__tests__/mcp.test.ts new file mode 100644 index 00000000..3309ba54 --- /dev/null +++ b/src/cli/tui/__tests__/mcp.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, it } from 'bun:test'; +import { + type AddManagedMcpServerRequest, + type McpDestinationSync, + McpUpdateError, +} from '../../../core/mcp-management.js'; +import type { McpDestination } from '../../../core/mcp-servers.js'; +import type { + McpServerConfig, + UserWorkspaceConfig, +} from '../../../models/workspace-config.js'; +import { + type McpManagementApi, + type McpTuiDependencies, + type McpTuiPrompts, + runMcpServers, +} from '../actions/mcp.js'; +import type { TuiCache } from '../cache.js'; +import type { TuiContext } from '../context.js'; + +const CANCEL = Symbol('cancel'); + +interface SelectRequest { + message: string; + options: Array<{ label: string; value: string; hint?: string }>; +} + +class ScriptedPrompts implements McpTuiPrompts { + readonly selectRequests: SelectRequest[] = []; + readonly notes: Array<{ message: string; title?: string }> = []; + readonly passwordRequests: Array<{ message: string; signal: AbortSignal }> = + []; + readonly selects: Array; + readonly texts: Array; + readonly multiselects: Array; + readonly confirms: Array; + + constructor(script: { + selects: Array; + texts?: Array; + multiselects?: Array; + confirms?: Array; + }) { + this.selects = [...script.selects]; + this.texts = [...(script.texts ?? [])]; + this.multiselects = [...(script.multiselects ?? [])]; + this.confirms = [...(script.confirms ?? [])]; + } + + async select(request: { + message: string; + options: Array<{ label: string; value: T; hint?: string }>; + }): Promise { + this.selectRequests.push(request as SelectRequest); + const value = this.selects.shift(); + if (value === undefined) { + throw new Error(`Missing select response for ${request.message}`); + } + return value as T | symbol; + } + + async text(): Promise { + const value = this.texts.shift(); + if (value === undefined) throw new Error('Missing text response'); + return value; + } + + async password(request: { + message: string; + signal: AbortSignal; + }): Promise { + this.passwordRequests.push(request); + return this.text(); + } + + async multiselect(): Promise { + const value = this.multiselects.shift(); + if (value === undefined) throw new Error('Missing multiselect response'); + return value as T[] | symbol; + } + + async confirm(): Promise { + const value = this.confirms.shift(); + if (value === undefined) throw new Error('Missing confirm response'); + return value; + } + + isCancel(value: unknown): value is symbol { + return value === CANCEL; + } + + note(message: string, title?: string): void { + this.notes.push({ message, ...(title && { title }) }); + } +} + +function context(hasWorkspace = true): TuiContext { + return { + hasWorkspace, + workspacePath: hasWorkspace ? '/workspace' : null, + projectPluginCount: 0, + userPluginCount: 0, + needsSync: false, + hasUserConfig: true, + marketplaceCount: 0, + }; +} + +function destination(options: { + cwd?: string; + scope?: string; + profile?: string; +}): McpDestination { + if (options.profile) { + return { + kind: 'profile', + name: options.profile, + configPath: '/home/user/.allagents/workspace.yaml', + }; + } + if (options.scope === 'project') { + return { + kind: 'project', + workspacePath: options.cwd ?? '/workspace', + configPath: `${options.cwd ?? '/workspace'}/.allagents/workspace.yaml`, + }; + } + return { + kind: 'user', + configPath: '/home/user/.allagents/workspace.yaml', + }; +} + +const completedSync: McpDestinationSync = { kind: 'profile', result: null }; + +function dependencies(options: { + prompts: ScriptedPrompts; + servers?: Record; + userConfig?: UserWorkspaceConfig | null; + onAdd?: (request: AddManagedMcpServerRequest) => Promise | void; + onReauthenticate?: (destination: McpDestination, name: string) => void; + onRemove?: (destination: McpDestination, name: string) => void; + removeError?: Error; + updateErrors?: Error[]; + onUpdate?: (destination: McpDestination) => void; +}): McpTuiDependencies { + const management: McpManagementApi = { + async addManagedMcpServer(request) { + await options.onAdd?.(request); + return { config: request.config, sync: completedSync }; + }, + async listManagedMcpServers() { + return options.servers ?? {}; + }, + async reauthenticateManagedMcpServer(selected, name) { + options.onReauthenticate?.(selected, name); + }, + async removeManagedMcpServer(selected, name) { + options.onRemove?.(selected, name); + if (options.removeError) throw options.removeError; + return completedSync; + }, + async updateManagedMcpServers(selected) { + options.onUpdate?.(selected); + const error = options.updateErrors?.shift(); + if (error) throw error; + return completedSync; + }, + }; + return { + prompts: options.prompts, + management, + getUserConfig: async () => options.userConfig ?? null, + resolveDestination: destination, + }; +} + +function cacheCounter(): { cache: TuiCache; count: () => number } { + let invalidations = 0; + return { + cache: { + invalidate() { + invalidations += 1; + }, + } as unknown as TuiCache, + count: () => invalidations, + }; +} + +describe('runMcpServers', () => { + it('selects a declared profile and adds an HTTP server through management', async () => { + const callbackUrl = + 'http://127.0.0.1:3117/callback?code=complete&state=state'; + const callbackSignal = new AbortController().signal; + const prompts = new ScriptedPrompts({ + selects: ['profile:work', '__add__', 'http', '__back__', '__back__'], + texts: [ + 'remote', + 'https://mcp.example.test/path', + `Authorization=\${MCP_TOKEN}`, + '', + callbackUrl, + ], + multiselects: [['claude']], + confirms: [true], + }); + let added: AddManagedMcpServerRequest | undefined; + let pastedCallback: string | undefined; + const { cache, count } = cacheCounter(); + const userConfig = { + repositories: [], + plugins: [], + clients: [], + profiles: { + work: { + clients: [{ name: 'claude', install: 'file', settings: {} }], + plugins: [], + }, + }, + } as UserWorkspaceConfig; + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + userConfig, + async onAdd(request) { + added = request; + request.authorization?.output( + 'Open the browser to authorize this server.', + ); + pastedCallback = await request.authorization?.readCallback({ + authorizationUrl: new URL('https://login.example.test/authorize'), + redirectUrl: 'http://127.0.0.1:3117/callback', + state: 'state', + signal: callbackSignal, + }); + }, + }), + ); + + expect(added?.destination).toMatchObject({ kind: 'profile', name: 'work' }); + expect(added?.config).toEqual({ + type: 'http', + url: 'https://mcp.example.test/path', + headers: { Authorization: `\${MCP_TOKEN}` }, + clients: ['claude'], + }); + expect(pastedCallback).toBe(callbackUrl); + expect(prompts.passwordRequests).toEqual([ + { + message: 'Paste the OAuth callback URL', + signal: callbackSignal, + }, + ]); + expect(prompts.notes).toContainEqual({ + message: 'Open the browser to authorize this server.', + title: 'Authorization', + }); + expect(count()).toBe(1); + expect( + prompts.selectRequests[0]?.options.map((option) => option.value), + ).toEqual(['project', 'user', 'profile:work', '__back__']); + }); + + it('lists servers first and uses Back to return to the destination chooser', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', '__back__', 'user', '__back__', '__back__'], + }); + + await runMcpServers( + context(), + undefined, + dependencies({ + prompts, + servers: { + zeta: { type: 'stdio', command: 'zeta' }, + alpha: { type: 'http', url: 'https://example.test/mcp' }, + }, + }), + ); + + const destinationRequests = prompts.selectRequests.filter( + (request) => request.message === 'MCP server destination', + ); + expect(destinationRequests).toHaveLength(3); + + const serverRequest = prompts.selectRequests.find( + (request) => request.message === 'Project MCP Servers', + ); + expect(serverRequest?.options).toEqual([ + { label: 'alpha', value: 'server:alpha', hint: 'HTTP' }, + { label: 'zeta', value: 'server:zeta', hint: 'stdio' }, + { label: '+ Add server', value: '__add__' }, + { label: 'Back', value: '__back__' }, + ]); + }); + + it('exits MCP management when the server-list prompt is cancelled', async () => { + const prompts = new ScriptedPrompts({ + selects: ['user', CANCEL], + }); + + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts, + servers: { remote: { type: 'http', url: 'https://example.test/mcp' } }, + }), + ); + + expect(prompts.selectRequests.map((request) => request.message)).toEqual([ + 'MCP server destination', + 'User MCP Servers', + ]); + expect(prompts.notes).toEqual([]); + }); + + it('offers reauthentication only for HTTP servers and calls management for HTTP', async () => { + const prompts = new ScriptedPrompts({ + selects: [ + 'user', + 'server:local', + 'back', + 'server:remote', + 'reauthenticate', + 'back', + '__back__', + '__back__', + ], + }); + const reauthenticated: Array<{ + destination: McpDestination; + name: string; + }> = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(false), + cache, + dependencies({ + prompts, + servers: { + local: { type: 'stdio', command: 'npx', args: ['secret-token'] }, + remote: { type: 'http', url: 'https://example.test/mcp' }, + }, + onReauthenticate(selected, name) { + reauthenticated.push({ destination: selected, name }); + }, + }), + ); + + const localActions = prompts.selectRequests.find( + (request) => request.message === 'MCP server: local', + ); + const remoteActions = prompts.selectRequests.find( + (request) => request.message === 'MCP server: remote', + ); + expect(localActions?.options.map((option) => option.value)).not.toContain( + 'reauthenticate', + ); + expect(remoteActions?.options.map((option) => option.value)).toContain( + 'reauthenticate', + ); + expect(reauthenticated).toEqual([ + { + destination: expect.objectContaining({ kind: 'user' }), + name: 'remote', + }, + ]); + expect(count()).toBe(1); + }); + + it('confirms removal and invalidates after the mutation', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', 'server:local', 'remove', '__back__', '__back__'], + confirms: [true], + }); + const removals: string[] = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + servers: { local: { type: 'stdio', command: 'node' } }, + onRemove(selected, name) { + expect(selected.kind).toBe('project'); + removals.push(name); + }, + }), + ); + + expect(removals).toEqual(['local']); + expect(count()).toBe(1); + }); + + it('offers a retry when removal persisted but its client update failed', async () => { + const prompts = new ScriptedPrompts({ + selects: ['project', 'server:local', 'remove', '__back__', '__back__'], + confirms: [true, true, true], + }); + const updates: McpDestination[] = []; + const { cache, count } = cacheCounter(); + + await runMcpServers( + context(), + cache, + dependencies({ + prompts, + servers: { local: { type: 'stdio', command: 'node' } }, + removeError: new McpUpdateError(new Error('Client update failed')), + updateErrors: [new Error('Client update still failing')], + onUpdate(selected) { + updates.push(selected); + }, + }), + ); + + expect(updates).toEqual([ + expect.objectContaining({ kind: 'project' }), + expect.objectContaining({ kind: 'project' }), + ]); + expect(prompts.notes).toContainEqual({ + message: 'Client update failed', + title: 'Update Error', + }); + expect(prompts.notes).toContainEqual({ + message: 'Client update still failing', + title: 'Update Error', + }); + expect(prompts.notes).toContainEqual({ + message: 'Client configuration updated.', + title: 'MCP Servers', + }); + expect(count()).toBe(1); + }); + + it('does not mutate when add or removal prompts are cancelled', async () => { + let addCalls = 0; + const addPrompts = new ScriptedPrompts({ + selects: ['user', '__add__', '__back__', '__back__'], + texts: [CANCEL], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts: addPrompts, + onAdd() { + addCalls += 1; + }, + }), + ); + + let removeCalls = 0; + const removePrompts = new ScriptedPrompts({ + selects: [ + 'user', + 'server:local', + 'remove', + 'back', + '__back__', + '__back__', + ], + confirms: [false], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts: removePrompts, + servers: { local: { type: 'stdio', command: 'node' } }, + onRemove() { + removeCalls += 1; + }, + }), + ); + + expect(addCalls).toBe(0); + expect(removeCalls).toBe(0); + }); + + it('shows safe metadata without header, environment, argument, or URL credential values', async () => { + const prompts = new ScriptedPrompts({ + selects: [ + 'user', + 'server:secure-http', + 'back', + 'server:secure-stdio', + 'back', + '__back__', + '__back__', + ], + }); + await runMcpServers( + context(false), + undefined, + dependencies({ + prompts, + servers: { + 'secure-http': { + type: 'http', + url: 'https://user:password@example.test/private?token=url-secret', + headers: { Authorization: 'header-secret' }, + }, + 'secure-stdio': { + type: 'stdio', + command: '/private/secret-command', + args: ['--token', 'argument-secret'], + env: { API_TOKEN: 'environment-secret' }, + }, + }, + }), + ); + + const rendered = prompts.notes.map((note) => note.message).join('\n'); + expect(rendered).toContain('Origin: https://example.test'); + expect(rendered).toContain('Headers: Authorization'); + expect(rendered).toContain('Arguments: 2 configured'); + expect(rendered).toContain('Environment: API_TOKEN'); + expect(rendered).not.toContain('password'); + expect(rendered).not.toContain('url-secret'); + expect(rendered).not.toContain('header-secret'); + expect(rendered).not.toContain('secret-command'); + expect(rendered).not.toContain('argument-secret'); + expect(rendered).not.toContain('environment-secret'); + }); +}); diff --git a/src/cli/tui/__tests__/wizard.test.ts b/src/cli/tui/__tests__/wizard.test.ts index 8e66017a..e77d55df 100644 --- a/src/cli/tui/__tests__/wizard.test.ts +++ b/src/cli/tui/__tests__/wizard.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'bun:test'; -import { buildMenuOptions, type MenuAction } from '../wizard.js'; +import { describe, expect, it } from 'bun:test'; import type { TuiContext } from '../context.js'; +import { buildMenuOptions, type MenuAction } from '../wizard.js'; /** Helper to create a TuiContext with sensible defaults. */ function makeContext(overrides: Partial = {}): TuiContext { @@ -30,38 +30,40 @@ describe('buildMenuOptions', () => { ]; for (const ctx of states) { - it(`includes workspace, plugins, skills, clients, marketplace (hasWorkspace=${ctx.hasWorkspace}, needsSync=${ctx.needsSync})`, () => { + it(`includes workspace, plugins, skills, clients, mcp, marketplace (hasWorkspace=${ctx.hasWorkspace}, needsSync=${ctx.needsSync})`, () => { const values = actionValues(ctx); expect(values).toContain('workspace'); expect(values).toContain('plugins'); expect(values).toContain('skills'); expect(values).toContain('clients'); + expect(values).toContain('mcp'); expect(values).toContain('marketplace'); }); } }); - describe('sync option', () => { - it('should show sync when sync is needed', () => { + describe('update option', () => { + it('should show Update when an update is needed', () => { const context = makeContext({ hasWorkspace: true, needsSync: true }); const values = actionValues(context); expect(values).toContain('sync'); }); - it('should show sync needed hint', () => { + it('should use Update terminology', () => { const context = makeContext({ hasWorkspace: true, needsSync: true }); const options = buildMenuOptions(context); - const syncOption = options.find((o) => o.value === 'sync'); - expect(syncOption?.hint).toBe('sync needed'); + const updateOption = options.find((o) => o.value === 'sync'); + expect(updateOption?.label).toBe('Update'); + expect(updateOption?.hint).toBe('update needed'); }); - it('should NOT show sync when not needed', () => { + it('should NOT show Update when not needed', () => { const context = makeContext({ hasWorkspace: true, needsSync: false }); const values = actionValues(context); expect(values).not.toContain('sync'); }); - it('should NOT show sync without workspace', () => { + it('should NOT show Update without workspace', () => { const context = makeContext({ hasWorkspace: false }); const values = actionValues(context); expect(values).not.toContain('sync'); diff --git a/src/cli/tui/actions/mcp.ts b/src/cli/tui/actions/mcp.ts new file mode 100644 index 00000000..d97e9a47 --- /dev/null +++ b/src/cli/tui/actions/mcp.ts @@ -0,0 +1,589 @@ +import * as p from '@clack/prompts'; +import { + addManagedMcpServer, + listManagedMcpServers, + type McpAuthorizationInteraction, + McpUpdateError, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, +} from '../../../core/mcp-management.js'; +import { + type McpDestination, + resolveMcpDestination, +} from '../../../core/mcp-servers.js'; +import { getUserWorkspaceConfig } from '../../../core/user-workspace.js'; +import { + type ClientType, + ClientTypeSchema, + type McpServerConfig, + type UserWorkspaceConfig, +} from '../../../models/workspace-config.js'; +import { terminalSafe } from '../../terminal-output.js'; +import type { TuiCache } from '../cache.js'; +import type { TuiContext } from '../context.js'; + +interface PromptOption { + label: string; + value: T; + hint?: string; +} + +export interface McpTuiPrompts { + select(request: { + message: string; + options: Array>; + }): Promise; + text(request: { + message: string; + placeholder?: string; + }): Promise; + password(request: { + message: string; + signal: AbortSignal; + }): Promise; + multiselect(request: { + message: string; + options: Array>; + required: false; + }): Promise; + confirm(request: { message: string }): Promise; + isCancel(value: unknown): value is symbol; + note(message: string, title?: string): void; +} + +export interface McpManagementApi { + addManagedMcpServer: typeof addManagedMcpServer; + listManagedMcpServers: typeof listManagedMcpServers; + reauthenticateManagedMcpServer: typeof reauthenticateManagedMcpServer; + removeManagedMcpServer: typeof removeManagedMcpServer; + updateManagedMcpServers: typeof updateManagedMcpServers; +} + +export interface McpTuiDependencies { + prompts: McpTuiPrompts; + management: McpManagementApi; + getUserConfig(): Promise; + resolveDestination(options: { + cwd?: string; + scope?: string; + profile?: string; + }): McpDestination; +} + +const clackPrompts: McpTuiPrompts = { + async select(request: { + message: string; + options: Array>; + }): Promise { + return p.select({ + ...request, + options: request.options as unknown as p.Option[], + }); + }, + async text(request): Promise { + return p.text(request); + }, + async password(request): Promise { + return p.password(request); + }, + async multiselect(request: { + message: string; + options: Array>; + required: false; + }): Promise { + return p.multiselect({ + ...request, + options: request.options as unknown as p.Option[], + }); + }, + async confirm(request): Promise { + return p.confirm(request); + }, + isCancel(value): value is symbol { + return p.isCancel(value); + }, + note(message, title): void { + p.note(message, title); + }, +}; + +const defaultDependencies: McpTuiDependencies = { + prompts: clackPrompts, + management: { + addManagedMcpServer, + listManagedMcpServers, + reauthenticateManagedMcpServer, + removeManagedMcpServer, + updateManagedMcpServers, + }, + getUserConfig: getUserWorkspaceConfig, + resolveDestination: resolveMcpDestination, +}; + +interface DestinationChoice { + key: string; + label: string; + hint: string; + destination: McpDestination; + clients: readonly ClientType[]; +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const redacted = message.replace(/https?:\/\/[^\s'"<>]+/gi, (value) => { + try { + return new URL(value).origin; + } catch { + return '[redacted URL]'; + } + }); + return terminalSafe(redacted); +} + +async function buildDestinationChoices( + context: TuiContext, + dependencies: McpTuiDependencies, +): Promise { + const choices: DestinationChoice[] = []; + if (context.hasWorkspace && context.workspacePath) { + choices.push({ + key: 'project', + label: 'Project', + hint: 'current workspace', + destination: dependencies.resolveDestination({ + cwd: context.workspacePath, + scope: 'project', + }), + clients: ClientTypeSchema.options, + }); + } + + choices.push({ + key: 'user', + label: 'User', + hint: 'global user configuration', + destination: dependencies.resolveDestination({ scope: 'user' }), + clients: ClientTypeSchema.options, + }); + + const userConfig = await dependencies.getUserConfig(); + for (const name of Object.keys(userConfig?.profiles ?? {}).sort()) { + const profile = userConfig?.profiles?.[name]; + if (!profile) continue; + choices.push({ + key: `profile:${name}`, + label: `Profile: ${terminalSafe(name)}`, + hint: 'declared user profile', + destination: dependencies.resolveDestination({ profile: name }), + clients: profile.clients.map((client) => client.name), + }); + } + return choices; +} + +async function selectDestination( + context: TuiContext, + dependencies: McpTuiDependencies, +): Promise { + const choices = await buildDestinationChoices(context, dependencies); + const selected = await dependencies.prompts.select({ + message: 'MCP server destination', + options: [ + ...choices.map((choice) => ({ + label: choice.label, + value: choice.key, + hint: choice.hint, + })), + { label: 'Back', value: '__back__' }, + ], + }); + if (dependencies.prompts.isCancel(selected) || selected === '__back__') { + return null; + } + return choices.find((choice) => choice.key === selected) ?? null; +} + +function destinationLabel(destination: McpDestination): string { + if (destination.kind === 'profile') { + return `profile ${terminalSafe(destination.name)}`; + } + return destination.kind; +} + +async function promptRequired( + prompts: McpTuiPrompts, + message: string, + placeholder?: string, + validate?: (value: string) => string | null, +): Promise { + while (true) { + const value = await prompts.text({ + message, + ...(placeholder && { placeholder }), + }); + if (prompts.isCancel(value)) return null; + const normalized = value.trim(); + const validationError = normalized + ? validate?.(normalized) + : 'A value is required.'; + if (!validationError) return normalized; + prompts.note(validationError, 'Invalid value'); + } +} + +async function promptArguments( + prompts: McpTuiPrompts, +): Promise { + const arguments_: string[] = []; + while (true) { + const value = await prompts.text({ + message: 'Argument (leave blank when finished)', + }); + if (prompts.isCancel(value)) return null; + if (value === '') return arguments_; + arguments_.push(value); + } +} + +async function promptKeyValues( + prompts: McpTuiPrompts, + label: string, +): Promise | null> { + const entries: Record = {}; + while (true) { + const value = await prompts.text({ + message: `${label} (KEY=VALUE; leave blank when finished)`, + }); + if (prompts.isCancel(value)) return null; + if (value.trim() === '') return entries; + const separator = value.indexOf('='); + const key = separator < 0 ? '' : value.slice(0, separator).trim(); + if (!key) { + prompts.note( + 'Enter a non-empty key followed by = and a value.', + 'Invalid entry', + ); + continue; + } + entries[key] = value.slice(separator + 1); + } +} + +async function promptClients( + prompts: McpTuiPrompts, + available: readonly ClientType[], +): Promise { + const selected = await prompts.multiselect({ + message: 'Limit to clients (leave empty for all supported clients)', + options: available.map((client) => ({ label: client, value: client })), + required: false, + }); + return prompts.isCancel(selected) ? null : selected; +} + +function authorizationInteraction( + prompts: McpTuiPrompts, +): McpAuthorizationInteraction { + return { + output(message): void { + prompts.note(terminalSafe(message), 'Authorization'); + }, + async readCallback(request): Promise { + const value = await prompts.password({ + message: 'Paste the OAuth callback URL', + signal: request.signal, + }); + if (prompts.isCancel(value)) { + throw new Error('OAuth authorization cancelled'); + } + const callbackUrl = value.trim(); + if (!callbackUrl) { + throw new Error('OAuth callback URL is required'); + } + return callbackUrl; + }, + }; +} + +async function handleMutationError( + choice: DestinationChoice, + error: unknown, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + if (!(error instanceof McpUpdateError)) { + prompts.note(errorMessage(error), 'Error'); + return false; + } + + cache?.invalidate(); + prompts.note(errorMessage(error), 'Update Error'); + while (true) { + const retry = await prompts.confirm({ + message: 'Client configuration update failed. Retry update now?', + }); + if (prompts.isCancel(retry) || !retry) return true; + + try { + await management.updateManagedMcpServers(choice.destination); + prompts.note('Client configuration updated.', 'MCP Servers'); + return true; + } catch (retryError) { + prompts.note(errorMessage(retryError), 'Update Error'); + } + } +} + +async function addServer( + choice: DestinationChoice, + existing: Record, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + const name = await promptRequired(prompts, 'Server name'); + if (name === null) return; + + const force = Object.hasOwn(existing, name); + + const transport = await prompts.select({ + message: 'Transport', + options: [ + { label: 'HTTP', value: 'http' }, + { label: 'stdio', value: 'stdio' }, + ], + }); + if (prompts.isCancel(transport)) return; + + let config: McpServerConfig; + if (transport === 'http') { + const url = await promptRequired( + prompts, + 'Server URL', + 'https://example.com/mcp', + (value) => + /^https?:\/\//i.test(value) + ? null + : 'Enter an http:// or https:// URL.', + ); + if (url === null) return; + const headers = await promptKeyValues(prompts, 'Header'); + if (headers === null) return; + const clients = await promptClients(prompts, choice.clients); + if (clients === null) return; + config = { + type: 'http', + url, + ...(Object.keys(headers).length > 0 && { headers }), + ...(clients.length > 0 && { clients }), + }; + } else { + const command = await promptRequired(prompts, 'Command', 'npx'); + if (command === null) return; + const args = await promptArguments(prompts); + if (args === null) return; + const env = await promptKeyValues(prompts, 'Environment variable'); + if (env === null) return; + const clients = await promptClients(prompts, choice.clients); + if (clients === null) return; + config = { + type: 'stdio', + command, + ...(args.length > 0 && { args }), + ...(Object.keys(env).length > 0 && { env }), + ...(clients.length > 0 && { clients }), + }; + } + + prompts.note(safeServerMetadata(name, config), 'Review MCP Server'); + const confirmed = await prompts.confirm({ + message: force + ? `Replace MCP server "${terminalSafe(name)}"?` + : `Add MCP server "${terminalSafe(name)}"?`, + }); + if (prompts.isCancel(confirmed) || !confirmed) return; + + try { + await management.addManagedMcpServer({ + destination: choice.destination, + name, + config, + force, + ...('url' in config && { + authorization: authorizationInteraction(prompts), + }), + }); + cache?.invalidate(); + prompts.note( + `Added ${terminalSafe(name)} to ${destinationLabel(choice.destination)}.`, + 'MCP Servers', + ); + } catch (error) { + await handleMutationError(choice, error, cache, dependencies); + } +} + +function safeServerMetadata(name: string, config: McpServerConfig): string { + const lines = [`Name: ${terminalSafe(name)}`]; + if ('url' in config) { + let origin = 'configured endpoint'; + try { + origin = new URL(config.url).origin; + } catch { + // The management layer owns config validation. Avoid echoing malformed input. + } + lines.push('Transport: HTTP', `Origin: ${terminalSafe(origin)}`); + const headerNames = Object.keys(config.headers ?? {}); + lines.push( + `Headers: ${ + headerNames.length > 0 + ? headerNames.map(terminalSafe).join(', ') + : 'none' + }`, + ); + } else { + lines.push('Transport: stdio', 'Command: configured'); + lines.push(`Arguments: ${config.args?.length ?? 0} configured`); + const environmentNames = Object.keys(config.env ?? {}); + lines.push( + `Environment: ${ + environmentNames.length > 0 + ? environmentNames.map(terminalSafe).join(', ') + : 'none' + }`, + ); + } + lines.push( + `Clients: ${ + config.clients?.length + ? config.clients.map(terminalSafe).join(', ') + : 'all supported' + }`, + ); + return lines.join('\n'); +} + +function serverActions(config: McpServerConfig): Array> { + return [ + ...('url' in config + ? [{ label: 'Reauthenticate', value: 'reauthenticate' }] + : []), + { label: 'Remove', value: 'remove' }, + { label: 'Back', value: 'back' }, + ]; +} + +async function serverDetail( + choice: DestinationChoice, + name: string, + config: McpServerConfig, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise { + const { prompts, management } = dependencies; + while (true) { + prompts.note(safeServerMetadata(name, config), 'MCP Server'); + const action = await prompts.select({ + message: `MCP server: ${terminalSafe(name)}`, + options: serverActions(config), + }); + if (prompts.isCancel(action) || action === 'back') return; + + if (action === 'reauthenticate' && 'url' in config) { + try { + await management.reauthenticateManagedMcpServer( + choice.destination, + name, + authorizationInteraction(prompts), + ); + cache?.invalidate(); + prompts.note(`Reauthenticated ${terminalSafe(name)}.`, 'MCP Servers'); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + } + continue; + } + + if (action === 'remove') { + const confirmed = await prompts.confirm({ + message: `Remove MCP server "${terminalSafe(name)}"?`, + }); + if (prompts.isCancel(confirmed) || !confirmed) continue; + try { + await management.removeManagedMcpServer(choice.destination, name); + cache?.invalidate(); + prompts.note(`Removed ${terminalSafe(name)}.`, 'MCP Servers'); + return; + } catch (error) { + if (await handleMutationError(choice, error, cache, dependencies)) + return; + } + } + } +} + +async function manageDestination( + choice: DestinationChoice, + cache: TuiCache | undefined, + dependencies: McpTuiDependencies, +): Promise<'destination' | 'exit'> { + const { prompts, management } = dependencies; + while (true) { + let servers: Record; + try { + servers = await management.listManagedMcpServers(choice.destination); + } catch (error) { + prompts.note(errorMessage(error), 'Error'); + return 'destination'; + } + + const names = Object.keys(servers).sort(); + const selected = await prompts.select({ + message: `${choice.label} MCP Servers`, + options: [ + ...names.map((name) => ({ + label: terminalSafe(name), + value: `server:${name}`, + hint: 'url' in (servers[name] as McpServerConfig) ? 'HTTP' : 'stdio', + })), + { label: '+ Add server', value: '__add__' }, + { label: 'Back', value: '__back__' }, + ], + }); + + if (prompts.isCancel(selected)) return 'exit'; + if (selected === '__back__') return 'destination'; + if (selected === '__add__') { + await addServer(choice, servers, cache, dependencies); + continue; + } + + if (selected.startsWith('server:')) { + const name = selected.slice('server:'.length); + const config = servers[name]; + if (config) { + await serverDetail(choice, name, config, cache, dependencies); + } + } + } +} + +/** Manage project, user, and declared-profile MCP servers without shelling out to the CLI. */ +export async function runMcpServers( + context: TuiContext, + cache?: TuiCache, + dependencies: McpTuiDependencies = defaultDependencies, +): Promise { + try { + while (true) { + const choice = await selectDestination(context, dependencies); + if (!choice) return; + if ((await manageDestination(choice, cache, dependencies)) === 'exit') + return; + } + } catch (error) { + dependencies.prompts.note(errorMessage(error), 'Error'); + } +} diff --git a/src/cli/tui/actions/skills.ts b/src/cli/tui/actions/skills.ts index f2c665e1..c4667afb 100644 --- a/src/cli/tui/actions/skills.ts +++ b/src/cli/tui/actions/skills.ts @@ -252,14 +252,14 @@ async function runToggleSkills( } // Auto-sync affected scopes - s.message('Syncing...'); + s.message('Updating...'); if (changedProject && context.workspacePath) { await syncWorkspace(context.workspacePath); } if (changedUser) { await syncUserWorkspace(); } - s.stop('Skills updated and synced'); + s.stop('Skills updated'); cache?.invalidate(); const changes: string[] = []; diff --git a/src/cli/tui/actions/sync.ts b/src/cli/tui/actions/sync.ts index 53d3adb1..dd16b269 100644 --- a/src/cli/tui/actions/sync.ts +++ b/src/cli/tui/actions/sync.ts @@ -15,23 +15,23 @@ export async function runSync(context: TuiContext): Promise { // Sync project-level plugins if workspace exists if (context.hasWorkspace && context.workspacePath) { - s.start('Syncing project plugins...'); + s.start('Updating project...'); const result = await syncWorkspace(context.workspacePath); if (result.error) { if (context.userPluginCount > 0) { - s.message('Syncing user plugins...'); + s.message('Updating user configuration...'); } else { - s.stop('Sync failed'); + s.stop('Update failed'); } - p.note(result.error, 'Sync Error'); + p.note(result.error, 'Update Error'); } else { projectLines = formatVerboseSyncLines(result); if (context.userPluginCount > 0) { - s.message('Syncing user plugins...'); + s.message('Updating user configuration...'); } else { - s.stop('Sync complete'); - p.note(projectLines.join('\n'), 'Project Sync'); + s.stop('Update complete'); + p.note(projectLines.join('\n'), 'Project Update'); return; } } @@ -40,21 +40,21 @@ export async function runSync(context: TuiContext): Promise { // Sync user-level plugins if (context.userPluginCount > 0) { if (!context.hasWorkspace || !context.workspacePath) { - s.start('Syncing user plugins...'); + s.start('Updating user configuration...'); } const userResult = await syncUserWorkspace(); - s.stop('Sync complete'); + s.stop('Update complete'); // Show project results first (deferred from above) if (projectLines) { - p.note(projectLines.join('\n'), 'Project Sync'); + p.note(projectLines.join('\n'), 'Project Update'); } if (userResult.error) { - p.note(userResult.error, 'User Sync Error'); + p.note(userResult.error, 'User Update Error'); } else { const lines = formatVerboseSyncLines(userResult); - p.note(lines.join('\n'), 'User Sync'); + p.note(lines.join('\n'), 'User Update'); } } } catch (error) { diff --git a/src/cli/tui/wizard.ts b/src/cli/tui/wizard.ts index bca8ccc7..c87a0ac6 100644 --- a/src/cli/tui/wizard.ts +++ b/src/cli/tui/wizard.ts @@ -1,7 +1,7 @@ -import * as p from '@clack/prompts'; +import { relative } from 'node:path'; import { settings } from '@clack/core'; +import * as p from '@clack/prompts'; import chalk from 'chalk'; -import { relative } from 'node:path'; import packageJson from '../../../package.json'; import { TuiCache } from './cache.js'; import { getTuiContext, type TuiContext } from './context.js'; @@ -11,12 +11,14 @@ const { select } = p; // Disable Escape key as cancel trigger to prevent terminal freezes. // Ctrl+C (\x03) still works for cancellation. settings.aliases.delete('escape'); -import { runSync } from './actions/sync.js'; -import { runStatus } from './actions/status.js'; -import { runBrowseMarketplaces, runPlugins } from './actions/plugins.js'; + +import { getUpdateNotice } from '../update-check.js'; import { runManageClients } from './actions/clients.js'; +import { runMcpServers } from './actions/mcp.js'; +import { runBrowseMarketplaces, runPlugins } from './actions/plugins.js'; import { runSkills } from './actions/skills.js'; -import { getUpdateNotice } from '../update-check.js'; +import { runStatus } from './actions/status.js'; +import { runSync } from './actions/sync.js'; export type MenuAction = | 'workspace' @@ -24,24 +26,27 @@ export type MenuAction = | 'plugins' | 'skills' | 'clients' + | 'mcp' | 'marketplace' | 'exit'; /** * Build context-aware menu options based on workspace state. - * Plugins, Skills, Clients, and Marketplaces are always visible. + * Plugins, Skills, Clients, MCP Servers, and Marketplaces are always visible. */ export function buildMenuOptions(context: TuiContext) { - const options: Array<{ label: string; value: MenuAction; hint?: string }> = []; + const options: Array<{ label: string; value: MenuAction; hint?: string }> = + []; if (context.needsSync) { - options.push({ label: 'Sync plugins', value: 'sync', hint: 'sync needed' }); + options.push({ label: 'Update', value: 'sync', hint: 'update needed' }); } options.push({ label: 'Workspace', value: 'workspace' }); options.push({ label: 'Plugins', value: 'plugins' }); options.push({ label: 'Skills', value: 'skills' }); options.push({ label: 'Clients', value: 'clients' }); + options.push({ label: 'MCP Servers', value: 'mcp' }); options.push({ label: 'Marketplaces', value: 'marketplace' }); options.push({ label: 'Exit', value: 'exit' }); @@ -59,7 +64,7 @@ function buildCompactSummary(context: TuiContext): string { parts.push(`${context.userPluginCount} user`); parts.push(`${context.marketplaceCount} marketplaces`); if (context.needsSync) { - parts.push(chalk.yellow('sync needed')); + parts.push(chalk.yellow('update needed')); } return parts.join(', '); } @@ -82,9 +87,9 @@ function buildSummary(context: TuiContext): string { lines.push(`Marketplaces: ${context.marketplaceCount}`); if (context.needsSync) { - lines.push(`Sync: ${chalk.yellow('needed')}`); + lines.push(`Update: ${chalk.yellow('needed')}`); } else if (context.hasWorkspace) { - lines.push(`Sync: ${chalk.green('up to date')}`); + lines.push(`Update: ${chalk.green('up to date')}`); } return lines.join('\n'); @@ -143,6 +148,9 @@ export async function runWizard(): Promise { case 'clients': await runManageClients(context, cache); break; + case 'mcp': + await runMcpServers(context, cache); + break; case 'marketplace': await runBrowseMarketplaces(context, cache); break; diff --git a/src/core/claude-mcp.ts b/src/core/claude-mcp.ts index 7318008b..a6d3cbcc 100644 --- a/src/core/claude-mcp.ts +++ b/src/core/claude-mcp.ts @@ -1,11 +1,11 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import JSON5 from 'json5'; -import { executeCommand } from './native/types.js'; import type { NativeCommandResult } from './native/types.js'; -import { collectMcpServers } from './vscode-mcp.js'; -import type { McpMergeResult } from './vscode-mcp.js'; +import { executeCommand } from './native/types.js'; import type { ValidatedPlugin } from './sync.js'; +import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers } from './vscode-mcp.js'; type ExecuteFn = ( binary: string, @@ -46,7 +46,16 @@ export function buildClaudeMcpAddArgs( ): string[] | null { // HTTP-based if (typeof config.url === 'string') { - return ['mcp', 'add', '--transport', 'http', '--scope', scope, name, config.url]; + return [ + 'mcp', + 'add', + '--transport', + 'http', + '--scope', + scope, + name, + config.url, + ]; } // stdio-based (command + args) @@ -131,12 +140,15 @@ export function syncClaudeMcpConfig( const content = readFileSync(configPath, 'utf-8'); existingConfig = JSON5.parse(content); } catch { - result.warnings.push(`Could not parse existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not parse existing ${configPath}, starting fresh`, + ); existingConfig = {}; } } - const existingServers = (existingConfig.mcpServers as Record) ?? {}; + const existingServers = + (existingConfig.mcpServers as Record) ?? {}; // Process plugin servers: add new, update tracked, skip user-managed conflicts for (const [name, config] of pluginServers) { @@ -171,7 +183,10 @@ export function syncClaudeMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && trackedName in existingServers) { + if ( + !currentServerNames.has(trackedName) && + trackedName in existingServers + ) { delete existingServers[trackedName]; result.removed++; result.removedServers.push(trackedName); @@ -180,14 +195,19 @@ export function syncClaudeMcpConfig( } // Write back if there were changes and not dry-run - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { existingConfig.mcpServers = existingServers; const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } @@ -206,7 +226,11 @@ export function syncClaudeMcpConfig( if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } } @@ -252,6 +276,7 @@ export async function syncClaudeMcpServersViaCli( overwrittenServers: [], removedServers: [], trackedServers: [], + authoritative: true, }; // Skip entirely when there are no MCP servers to sync and nothing to remove @@ -265,9 +290,22 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Claude CLI not available: ${versionResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } + function isMissingClaudeMcpServer( + result: NativeCommandResult, + name: string, + ): boolean { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp( + `^(?:Error:\\s*)?No MCP server found with name:\\s*${escapedName}\\.?$`, + 'i', + ).test(`${result.output}\n${result.error ?? ''}`.trim()); + } + // Process plugin servers: add new, skip existing user-managed for (const [name, config] of pluginServers) { // Check if server already exists via `claude mcp get ` @@ -294,6 +332,8 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Unsupported MCP server config for '${name}', skipping`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } @@ -303,6 +343,8 @@ export async function syncClaudeMcpServersViaCli( result.warnings.push( `Failed to add MCP server '${name}': ${addResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } } @@ -320,15 +362,32 @@ export async function syncClaudeMcpServersViaCli( if (!currentServerNames.has(trackedName)) { // Check if it still exists before trying to remove const getResult = await exec('claude', ['mcp', 'get', trackedName]); + if ( + !getResult.success && + !isMissingClaudeMcpServer(getResult, trackedName) + ) { + result.warnings.push( + `Failed to inspect MCP server '${trackedName}': ${getResult.error ?? 'unknown error'}`, + ); + result.authoritative = false; + result.trackedServers.push(trackedName); + continue; + } if (getResult.success) { if (!dryRun) { const removeResult = await exec('claude', [ - 'mcp', 'remove', trackedName, '--scope', 'user', + 'mcp', + 'remove', + trackedName, + '--scope', + 'user', ]); if (!removeResult.success) { result.warnings.push( `Failed to remove MCP server '${trackedName}': ${removeResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers.push(trackedName); continue; } } diff --git a/src/core/codex-mcp.ts b/src/core/codex-mcp.ts index 769b6c48..2a6c5748 100644 --- a/src/core/codex-mcp.ts +++ b/src/core/codex-mcp.ts @@ -1,10 +1,10 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; -import { executeCommand } from './native/types.js'; import type { NativeCommandResult } from './native/types.js'; +import { executeCommand } from './native/types.js'; import type { ValidatedPlugin } from './sync.js'; -import { collectMcpServers } from './vscode-mcp.js'; import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers } from './vscode-mcp.js'; type ExecuteFn = ( binary: string, @@ -89,6 +89,7 @@ export async function syncCodexMcpServers( overwrittenServers: [], removedServers: [], trackedServers: [], + authoritative: true, }; // Skip calling codex CLI entirely when there are no MCP servers to sync and nothing to remove @@ -102,6 +103,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Codex CLI not available or 'codex mcp list' failed: ${listResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } @@ -111,6 +114,8 @@ export async function syncCodexMcpServers( existingNames = new Set(parsed.map((s) => s.name)); } catch { result.warnings.push('Failed to parse codex mcp list output'); + result.authoritative = false; + result.trackedServers = [...previouslyTracked]; return result; } @@ -135,6 +140,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Unsupported MCP server config for '${name}', skipping`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } @@ -144,6 +151,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Failed to add MCP server '${name}': ${addResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + if (previouslyTracked.has(name)) result.trackedServers.push(name); continue; } } @@ -172,6 +181,8 @@ export async function syncCodexMcpServers( result.warnings.push( `Failed to remove MCP server '${trackedName}': ${removeResult.error ?? 'unknown error'}`, ); + result.authoritative = false; + result.trackedServers.push(trackedName); continue; } } @@ -192,8 +203,10 @@ export async function syncCodexMcpServers( * Convert a TOML value to its string representation. */ function toTomlValue(value: unknown): string { - if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; - if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (typeof value === 'string') + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + if (typeof value === 'number' || typeof value === 'boolean') + return String(value); if (Array.isArray(value)) return `[${value.map(toTomlValue).join(', ')}]`; return `"${String(value)}"`; } @@ -201,7 +214,10 @@ function toTomlValue(value: unknown): string { /** * Generate TOML for a single MCP server entry. */ -export function serverToToml(name: string, config: Record): string { +export function serverToToml( + name: string, + config: Record, +): string { const lines: string[] = [`[mcp_servers.${name}]`]; const envEntries: [string, unknown][] = []; @@ -244,12 +260,18 @@ export function parseCodexConfigToml(content: string): { for (const line of lines) { // Match [mcp_servers.] or [mcp_servers..env] - const sectionMatch = line.match(/^\[mcp_servers\.([^\].]+?)(?:\.[^\]]+)?\]$/); + const sectionMatch = line.match( + /^\[mcp_servers\.([^\].]+?)(?:\.[^\]]+)?\]$/, + ); if (sectionMatch) { // Save previous server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); } currentServer = sectionMatch[1] ?? null; if (currentServer) serverNames.add(currentServer); @@ -262,8 +284,12 @@ export function parseCodexConfigToml(content: string): { if (otherSectionMatch) { // Save previous server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); currentServer = null; currentLines = []; } @@ -280,13 +306,20 @@ export function parseCodexConfigToml(content: string): { // Save last server if any if (currentServer) { - serverSections.set(currentServer, (serverSections.get(currentServer) ?? '') + - (serverSections.has(currentServer) ? '\n' : '') + currentLines.join('\n')); + serverSections.set( + currentServer, + (serverSections.get(currentServer) ?? '') + + (serverSections.has(currentServer) ? '\n' : '') + + currentLines.join('\n'), + ); } return { serverNames, - nonMcpContent: nonMcpLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), + nonMcpContent: nonMcpLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(), serverSections, }; } @@ -342,12 +375,17 @@ export function syncCodexProjectMcpConfig( try { existingContent = readFileSync(configPath, 'utf-8'); } catch { - result.warnings.push(`Could not read existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not read existing ${configPath}, starting fresh`, + ); } } - const { serverNames: existingNames, nonMcpContent, serverSections } = - parseCodexConfigToml(existingContent); + const { + serverNames: existingNames, + nonMcpContent, + serverSections, + } = parseCodexConfigToml(existingContent); // Track which servers to keep in the final output const finalServers = new Map(serverSections); @@ -357,12 +395,18 @@ export function syncCodexProjectMcpConfig( if (existingNames.has(name)) { if (hasTracking && previouslyTracked.has(name)) { // We own it — overwrite with new config - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.overwritten++; result.overwrittenServers.push(name); result.trackedServers.push(name); } else if (force) { - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.overwritten++; result.overwrittenServers.push(name); result.trackedServers.push(name); @@ -372,7 +416,10 @@ export function syncCodexProjectMcpConfig( result.skippedServers.push(name); } } else { - finalServers.set(name, serverToToml(name, config as Record)); + finalServers.set( + name, + serverToToml(name, config as Record), + ); result.added++; result.addedServers.push(name); result.trackedServers.push(name); @@ -383,7 +430,10 @@ export function syncCodexProjectMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && finalServers.has(trackedName)) { + if ( + !currentServerNames.has(trackedName) && + finalServers.has(trackedName) + ) { finalServers.delete(trackedName); result.removed++; result.removedServers.push(trackedName); @@ -392,7 +442,8 @@ export function syncCodexProjectMcpConfig( } // Write back if changes occurred - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { const parts: string[] = []; if (nonMcpContent) { diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index 5605a11e..22dc9d47 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -1,63 +1,213 @@ +import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { + access, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; import { createServer, type IncomingMessage, type ServerResponse, } from 'node:http'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { constants as fsConstants } from 'node:fs'; -import { access } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { spawn } from 'node:child_process'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { dirname, join } from 'node:path'; import { - type OAuthDiscoveryState, type OAuthClientProvider, + type OAuthDiscoveryState, UnauthorizedError, } from '@modelcontextprotocol/sdk/client/auth.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; -import { getHomeDir } from '../constants.js'; +import type { + FetchLike, + Transport, +} from '@modelcontextprotocol/sdk/shared/transport.js'; import { + CallToolRequestSchema as CallToolSchema, GetPromptRequestSchema as GetPromptSchema, ListPromptsRequestSchema as ListPromptsSchema, - ListResourceTemplatesRequestSchema as ListResourceTemplatesSchema, ListResourcesRequestSchema as ListResourcesSchema, + ListResourceTemplatesRequestSchema as ListResourceTemplatesSchema, ListToolsRequestSchema as ListToolsSchema, ReadResourceRequestSchema as ReadResourceSchema, - CallToolRequestSchema as CallToolSchema, } from '@modelcontextprotocol/sdk/types.js'; +import { getHomeDir } from '../constants.js'; +import { ProfileNameSchema } from '../models/workspace-config.js'; const AUTH_TIMEOUT_MS = 5 * 60 * 1000; export const AUTH_URL_LOG_PREFIX = 'If the browser does not open, visit: '; +const ENVIRONMENT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + +export function resolveMcpHeaderReferences( + headers: Record, + environment: NodeJS.ProcessEnv = process.env, +): Record { + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => { + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (!reference) return [key, value]; + const variable = reference[1] as string; + const resolved = environment[variable]; + if (resolved === undefined) { + throw new Error( + `MCP header '${key}' references missing environment variable '${variable}'`, + ); + } + return [key, resolved]; + }), + ); +} + +export interface OAuthCallbackRequest { + authorizationUrl: URL; + redirectUrl: string; + state: string; + signal: AbortSignal; +} + +export type OAuthCallbackUrlReader = ( + request: OAuthCallbackRequest, +) => Promise; + +type ParsedOAuthCallback = + | { code: string; authorizationError?: never } + | { code?: never; authorizationError: true }; + +export class OAuthAuthorizationError extends Error { + constructor() { + super('OAuth authorization failed'); + this.name = 'OAuthAuthorizationError'; + } +} + +function parseOAuthCallbackResponse( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): ParsedOAuthCallback { + let callback: URL; + try { + callback = new URL(callbackUrl.trim()); + } catch { + throw new Error('Invalid OAuth callback URL'); + } + + const expected = new URL(redirectUrl); + if ( + callback.username || + callback.password || + callback.origin !== expected.origin || + callback.pathname !== expected.pathname || + callback.hash + ) { + throw new Error( + 'OAuth callback URL does not match the registered redirect', + ); + } + + const states = callback.searchParams.getAll('state'); + if (states.length !== 1 || states[0] !== expectedState) { + throw new Error('OAuth state validation failed'); + } + + const errors = callback.searchParams.getAll('error'); + const codes = callback.searchParams.getAll('code'); + if (errors.length === 1 && errors[0] && codes.length === 0) { + return { authorizationError: true }; + } + if (errors.length > 0) { + throw new Error('Invalid OAuth authorization response'); + } + if (codes.length !== 1 || !codes[0]) { + throw new Error('No OAuth authorization code received'); + } + return { code: codes[0] }; +} + +export function validateOAuthCallbackUrl( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): void { + parseOAuthCallbackResponse(callbackUrl, redirectUrl, expectedState); +} + +export function parseOAuthCallbackUrl( + callbackUrl: string, + redirectUrl: string, + expectedState: string, +): string { + const callback = parseOAuthCallbackResponse( + callbackUrl, + redirectUrl, + expectedState, + ); + if (callback.authorizationError) { + throw new OAuthAuthorizationError(); + } + return callback.code; +} + export function hashServerUrl(serverUrl: string): string { return createHash('sha256').update(serverUrl).digest('hex').slice(0, 16); } -function getCacheDir(serverUrl: string): string { +export function getMcpOAuthCacheDir( + serverUrl: string, + profile?: string, +): string { + const hash = hashServerUrl(serverUrl); + if (!profile) { + return join(getHomeDir(), '.allagents', 'oauth-proxy', hash); + } return join( getHomeDir(), '.allagents', + 'profiles', + ProfileNameSchema.parse(profile), 'oauth-proxy', - hashServerUrl(serverUrl), + hash, ); } -function getRequestInit( +function getMcpFetch( + serverUrl: string, headers: Record, -): RequestInit | undefined { +): FetchLike | undefined { if (Object.keys(headers).length === 0) { return undefined; } - return { headers }; + + const serverOrigin = new URL(serverUrl).origin; + return async (input, init) => { + const requestUrl = new URL(input.toString()); + if (requestUrl.origin !== serverOrigin) { + return fetch(input, init); + } + + const mergedHeaders = new Headers(headers); + new Headers(init?.headers).forEach((value, key) => { + mergedHeaders.set(key, value); + }); + + return fetch(input, { + ...init, + headers: mergedHeaders, + redirect: 'error', + }); + }; } async function pathExists(path: string): Promise { @@ -77,7 +227,7 @@ async function readJsonFile(path: string): Promise { } async function writePrivateFile(path: string, content: string): Promise { - await mkdir(dirname(path), { recursive: true }); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile(path, content, { encoding: 'utf-8', mode: 0o600 }); } @@ -177,6 +327,13 @@ function tryOpenBrowser(url: string): Promise { }); } +interface OAuthProviderOptions { + callbackUrlReader?: OAuthCallbackUrlReader; + authorizationOutput?: (message: string) => void; + allowAuthorization?: boolean; + profile?: string; +} + class FileOAuthClientProvider implements OAuthClientProvider { private readonly clientInfoPath: string; private readonly tokensPath: string; @@ -188,18 +345,26 @@ class FileOAuthClientProvider implements OAuthClientProvider { private discovery: OAuthDiscoveryState | undefined = undefined; private codeVerifierValue: string | undefined = undefined; private pendingAuth: Promise | undefined = undefined; + private authorizationUnavailable = false; + private readonly callbackUrlReader: OAuthCallbackUrlReader | undefined; + private readonly authorizationOutput: (message: string) => void; + private readonly allowAuthorization: boolean; private readonly stateValue = randomUUID(); constructor( private readonly port: number, serverUrl: string, + options: OAuthProviderOptions = {}, ) { - const cacheDir = getCacheDir(serverUrl); + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); this.clientInfoPath = join(cacheDir, 'client-info.json'); this.tokensPath = join(cacheDir, 'tokens.json'); this.verifierPath = join(cacheDir, 'code-verifier.txt'); this.discoveryPath = join(cacheDir, 'discovery.json'); this.redirectUriValue = `http://127.0.0.1:${port}/callback`; + this.callbackUrlReader = options.callbackUrlReader; + this.authorizationOutput = options.authorizationOutput ?? console.error; + this.allowAuthorization = options.allowAuthorization ?? true; } get redirectUrl(): string { @@ -260,6 +425,10 @@ class FileOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL): void { + if (!this.allowAuthorization) { + this.authorizationUnavailable = true; + return; + } this.pendingAuth ??= this.waitForAuthorizationCode(authorizationUrl); } @@ -310,6 +479,9 @@ class FileOAuthClientProvider implements OAuthClientProvider { } async waitForAuthCode(): Promise { + if (this.authorizationUnavailable) { + throw new Error('OAuth authorization requires an interactive terminal'); + } if (!this.pendingAuth) { throw new Error('OAuth authorization has not been started'); } @@ -317,89 +489,126 @@ class FileOAuthClientProvider implements OAuthClientProvider { } private waitForAuthorizationCode(authorizationUrl: URL): Promise { - return new Promise((resolve, reject) => { - const server = createServer( - (request: IncomingMessage, response: ServerResponse) => { - try { - const parsed = new URL( - request.url ?? '/', - `http://127.0.0.1:${this.port}`, - ); - const code = parsed.searchParams.get('code'); - const error = parsed.searchParams.get('error'); - const state = parsed.searchParams.get('state'); - - if (code) { - if (state !== this.stateValue) { - response.writeHead(400, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - '

Authorization failed

State validation failed.

', - ); - server.close(); - reject(new Error('OAuth state validation failed')); - return; - } - response.writeHead(200, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - '

Authorization complete

You can close this window.

', - ); - server.close(); - resolve(code); - return; - } - - const message = error ?? 'No authorization code received'; - response.writeHead(400, { - 'content-type': 'text/html; charset=utf-8', - }); - response.end( - `

Authorization failed

${message}

`, - ); - server.close(); - reject(new Error(message)); - } catch (error) { - server.close(); - reject(error instanceof Error ? error : new Error(String(error))); - } - }, - ); - - const timeout = setTimeout(() => { - server.close(); - reject(new Error('Timed out waiting for OAuth authorization callback')); - }, AUTH_TIMEOUT_MS); - - server.on('close', () => { - clearTimeout(timeout); - }); - server.on('error', reject); - server.listen(this.port, '127.0.0.1', () => { - console.error('Opening browser for authorization...'); - console.error( - `${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`, + const { promise, resolve, reject } = Promise.withResolvers(); + const readerAbortController = new AbortController(); + let settled = false; + const settle = ( + outcome: 'resolve' | 'reject', + value: string | Error, + ): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + readerAbortController.abort(); + if (server.listening) server.close(); + if (outcome === 'resolve') { + resolve(value as string); + } else { + reject(value as Error); + } + }; + const acceptCallback = (callbackUrl: string): void => { + try { + settle( + 'resolve', + parseOAuthCallbackUrl( + callbackUrl, + this.redirectUriValue, + this.stateValue, + ), ); - // Test-only escape hatch: e2e tests fetch the URL themselves against a local - // dummy IdP, and skipping the real OS browser-open avoids ever launching one. - if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { - console.error( - 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', + } catch (error) { + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); + } + }; + const server = createServer( + (request: IncomingMessage, response: ServerResponse) => { + try { + const callbackUrl = new URL( + request.url ?? '/', + this.redirectUriValue, + ).toString(); + const code = parseOAuthCallbackUrl( + callbackUrl, + this.redirectUriValue, + this.stateValue, + ); + response.writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + }); + response.end( + '

Authorization complete

You can close this window.

', + ); + settle('resolve', code); + } catch (error) { + response.writeHead(400, { + 'content-type': 'text/html; charset=utf-8', + }); + response.end( + '

Authorization failed

The OAuth response was rejected.

', + ); + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), ); - } else { - void tryOpenBrowser(authorizationUrl.toString()); } - }); + }, + ); + const timeout = setTimeout(() => { + settle( + 'reject', + new Error('Timed out waiting for OAuth authorization callback'), + ); + }, AUTH_TIMEOUT_MS); + server.on('error', (error) => settle('reject', error)); + server.listen(this.port, '127.0.0.1', () => { + this.authorizationOutput('Opening browser for authorization...'); + this.authorizationOutput( + `${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`, + ); + this.authorizationOutput( + this.callbackUrlReader + ? 'Using a remote browser? Paste its callback URL in this terminal.' + : 'Using a remote browser? Run `allagents mcp reauth ` in this workspace, then reconnect.', + ); + // Test-only escape hatch: e2e tests fetch the URL themselves against a local + // dummy IdP, and skipping the real OS browser-open avoids ever launching one. + if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { + this.authorizationOutput( + 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', + ); + } else { + void tryOpenBrowser(authorizationUrl.toString()); + } + if (this.callbackUrlReader) { + void this.callbackUrlReader({ + authorizationUrl, + redirectUrl: this.redirectUriValue, + state: this.stateValue, + signal: readerAbortController.signal, + }) + .then(acceptCallback) + .catch((error) => { + if (readerAbortController.signal.aborted) return; + settle( + 'reject', + error instanceof Error ? error : new Error(String(error)), + ); + }); + } }); + return promise; } } async function buildOAuthProvider( serverUrl: string, + options: OAuthProviderOptions = {}, ): Promise { - const cacheDir = getCacheDir(serverUrl); + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); const cachedClientInfo = await readJsonFile( join(cacheDir, 'client-info.json'), ); @@ -409,7 +618,7 @@ async function buildOAuthProvider( port = await findFreePort(); } - const provider = new FileOAuthClientProvider(port, serverUrl); + const provider = new FileOAuthClientProvider(port, serverUrl, options); await provider.load(); return provider; } @@ -427,11 +636,21 @@ function parseCallToolResponse( }; } +interface RemoteConnection { + client: Client; + transport: StreamableHTTPClientTransport; +} + +interface RemoteTransportOptions extends OAuthProviderOptions { + headers?: Record; +} + async function connectRemoteTransport( serverUrl: string, - headers: Record, -): Promise { - const provider = await buildOAuthProvider(serverUrl); + options: RemoteTransportOptions = {}, +): Promise { + const provider = await buildOAuthProvider(serverUrl, options); + const headers = resolveMcpHeaderReferences(options.headers ?? {}); const client = new Client( { name: 'AllAgents', @@ -441,10 +660,10 @@ async function connectRemoteTransport( ); const buildTransport = () => { - const requestInit = getRequestInit(headers); + const mcpFetch = getMcpFetch(serverUrl, headers); return new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider: provider, - ...(requestInit && { requestInit }), + ...(mcpFetch && { fetch: mcpFetch }), }); }; @@ -462,14 +681,80 @@ async function connectRemoteTransport( await client.connect(transport as unknown as Transport); } - return client; + return { client, transport }; +} + +export interface ConnectHttpMcpServerOptions { + headers?: Record; + callbackUrlReader?: OAuthCallbackUrlReader; + authorizationOutput?: (message: string) => void; + resetCredentials?: boolean; + allowAuthorization?: boolean; + profile?: string; +} + +export async function connectHttpMcpServer( + serverUrl: string, + options: ConnectHttpMcpServerOptions = {}, +): Promise { + const cacheDir = getMcpOAuthCacheDir(serverUrl, options.profile); + const backupDir = options.resetCredentials + ? `${cacheDir}.reauth-backup-${randomUUID()}` + : undefined; + let hasBackup = false; + + if (backupDir) { + try { + await rename(cacheDir, backupDir); + hasBackup = true; + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ENOENT') + ) { + throw error; + } + } + } + + try { + const { client, transport } = await connectRemoteTransport( + serverUrl, + options, + ); + try { + await transport.terminateSession(); + } finally { + await client.close(); + } + } catch (error) { + if (backupDir) { + try { + await rm(cacheDir, { recursive: true, force: true }); + if (hasBackup) await rename(backupDir, cacheDir); + } catch (restoreError) { + throw new AggregateError( + [error, restoreError], + 'MCP reauthentication failed and the previous credentials could not be restored', + ); + } + } + throw error; + } + + if (hasBackup && backupDir) { + await rm(backupDir, { recursive: true, force: true }); + } } export async function runHttpMcpStdioProxy( serverUrl: string, headers: Record = {}, + options: { profile?: string } = {}, ): Promise { - const remote = await connectRemoteTransport(serverUrl, headers); + const { client: remote } = await connectRemoteTransport(serverUrl, { + headers, + ...options, + }); const local = new Server( { name: 'AllAgents', diff --git a/src/core/mcp-management.ts b/src/core/mcp-management.ts new file mode 100644 index 00000000..e79290c8 --- /dev/null +++ b/src/core/mcp-management.ts @@ -0,0 +1,244 @@ +import { getHomeDir } from '../constants.js'; +import { + type McpServerConfig, + ProfileDeclarationSchema, +} from '../models/workspace-config.js'; +import { parseUserWorkspaceConfig } from '../utils/workspace-parser.js'; +import { + connectHttpMcpServer, + type OAuthCallbackUrlReader, +} from './mcp-http-stdio-proxy.js'; +import { + addMcpServer, + getMcpServer, + listMcpServers, + type McpDestination, + removeMcpServer, +} from './mcp-servers.js'; +import { + type SyncMcpOnlyResult, + syncMcpOnly, + syncUserMcpOnly, +} from './mcp-sync.js'; +import { + type ProfileApplyResult, + updateInstalledProfiles, +} from './profile/index.js'; + +export interface McpAuthorizationInteraction { + output(message: string): void; + readCallback: OAuthCallbackUrlReader; +} + +export type McpDestinationSync = + | { kind: 'mcp'; result: SyncMcpOnlyResult } + | { kind: 'profile'; result: ProfileApplyResult | null }; + +export interface AddManagedMcpServerRequest { + destination: McpDestination; + name: string; + config: McpServerConfig; + force?: boolean; + authorization?: McpAuthorizationInteraction; +} + +export interface UpdateManagedMcpServersOptions { + offline?: boolean; +} + +export class McpUpdateError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }); + this.name = 'McpUpdateError'; + } +} + +function destinationDisplay(destination: McpDestination): string { + switch (destination.kind) { + case 'project': + return 'workspace.yaml'; + case 'user': + return 'the user workspace'; + case 'profile': + return `profile '${destination.name}'`; + } +} + +async function validateProfileAddCandidate( + destination: McpDestination, + name: string, + config: McpServerConfig, +): Promise { + if (destination.kind !== 'profile') return; + + const workspace = await parseUserWorkspaceConfig(destination.configPath); + const profile = workspace.profiles?.[destination.name]; + if (!profile) { + throw new Error(`Profile '${destination.name}' is not declared`); + } + const validation = ProfileDeclarationSchema.safeParse({ + ...profile, + mcpServers: { + ...profile.mcpServers, + [name]: config, + }, + }); + if (!validation.success) { + const issues = validation.error.issues.map( + (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, + ); + throw new Error(`Invalid MCP server config:\n${issues.join('\n')}`); + } +} + +async function connectConfiguredHttpServer( + destination: McpDestination, + config: Extract, + authorization: McpAuthorizationInteraction | undefined, + resetCredentials: boolean, +): Promise { + await connectHttpMcpServer(config.url, { + headers: config.headers ?? {}, + resetCredentials, + allowAuthorization: authorization !== undefined, + ...(destination.kind === 'profile' ? { profile: destination.name } : {}), + ...(authorization + ? { + authorizationOutput: authorization.output, + callbackUrlReader: authorization.readCallback, + } + : {}), + }); +} + +export async function updateManagedMcpServers( + destination: McpDestination, + options: UpdateManagedMcpServersOptions = {}, +): Promise { + const offline = options.offline ?? false; + if (destination.kind === 'project') { + const result = await syncMcpOnly(destination.workspacePath, { offline }); + if (!result.success) { + throw new Error(result.error ?? 'MCP sync failed'); + } + return { kind: 'mcp', result }; + } + if (destination.kind === 'user') { + const result = await syncUserMcpOnly({ offline }); + if (!result.success) { + throw new Error(result.error ?? 'MCP sync failed'); + } + return { kind: 'mcp', result }; + } + + let results: readonly ProfileApplyResult[]; + try { + results = await updateInstalledProfiles([destination.name], { + offline, + homeDir: getHomeDir(), + userConfigPath: destination.configPath, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === `Profile '${destination.name}' is not installed`) { + return { kind: 'profile', result: null }; + } + throw new Error(message); + } + + const result = results[0]; + if (!result) { + throw new Error(`Profile '${destination.name}' was not reconciled`); + } + if (!result.success) { + throw new Error( + result.error ?? `Profile '${destination.name}' update failed`, + ); + } + return { kind: 'profile', result }; +} + +export async function addManagedMcpServer( + request: AddManagedMcpServerRequest, +): Promise<{ config: McpServerConfig; sync: McpDestinationSync }> { + const { destination, name, config, force = false, authorization } = request; + const existing = await getMcpServer(destination, name); + if (existing && !force) { + throw new Error( + `MCP server '${name}' already exists in ${destinationDisplay(destination)}. Use --force to replace it.`, + ); + } + await validateProfileAddCandidate(destination, name, config); + + if ('url' in config) { + await connectConfiguredHttpServer( + destination, + config, + authorization, + false, + ); + } + + const addResult = await addMcpServer(destination, name, config, { + force, + proxy: + 'url' in config + ? { + ...(config.clients === undefined + ? {} + : { clients: config.clients }), + } + : false, + }); + if (!addResult.success) { + throw new Error(addResult.error ?? 'Unknown error'); + } + + let sync: McpDestinationSync; + try { + sync = await updateManagedMcpServers(destination, { offline: true }); + } catch (error) { + throw new McpUpdateError(error); + } + return { config: addResult.config ?? config, sync }; +} + +export async function removeManagedMcpServer( + destination: McpDestination, + name: string, +): Promise { + const removeResult = await removeMcpServer(destination, name); + if (!removeResult.success) { + throw new Error(removeResult.error ?? 'Unknown error'); + } + try { + return await updateManagedMcpServers(destination, { offline: true }); + } catch (error) { + throw new McpUpdateError(error); + } +} + +export async function reauthenticateManagedMcpServer( + destination: McpDestination, + name: string, + authorization: McpAuthorizationInteraction, +): Promise { + const config = await getMcpServer(destination, name); + if (!config) { + throw new Error( + `MCP server '${name}' is not defined in ${destinationDisplay(destination)}`, + ); + } + if (!('url' in config)) { + throw new Error( + `MCP server '${name}' uses stdio and cannot be reauthenticated`, + ); + } + await connectConfiguredHttpServer(destination, config, authorization, true); +} + +export async function listManagedMcpServers( + destination: McpDestination, +): Promise> { + return listMcpServers(destination); +} diff --git a/src/core/mcp-proxy.ts b/src/core/mcp-proxy.ts index b5798f96..0c871984 100644 --- a/src/core/mcp-proxy.ts +++ b/src/core/mcp-proxy.ts @@ -1,4 +1,11 @@ -import type { McpProxyConfig } from '../models/workspace-config.js'; +import packageJson from '../../package.json'; +import { + type McpProxyConfig, + type McpServerConfig, + ProfileNameSchema, +} from '../models/workspace-config.js'; + +const ENVIRONMENT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; /** * Determine if a server+client pair should be proxied. @@ -12,7 +19,10 @@ export function shouldProxy( return true; } const serverOverride = config.servers?.[serverName]; - if (serverOverride?.proxy.includes(client)) { + if ( + serverOverride?.proxy.includes('*') || + serverOverride?.proxy.includes(client) + ) { return true; } return false; @@ -39,17 +49,30 @@ function isHttpServer( function toProxiedConfig( url: string, headers?: Record, -): Record { - const args = ['mcp', 'proxy', url]; + profile?: string, +): McpServerConfig { + const args = ['-y', `allagents@${packageJson.version}`, 'mcp', 'proxy', url]; + const env: Record = {}; + if (profile) { + args.push('--profile', ProfileNameSchema.parse(profile)); + } if (headers) { for (const [key, value] of Object.entries(headers)) { - args.push('--header', `${key}=${value}`); + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (reference) { + const variable = reference[1] as string; + args.push('--header-env', `${key}=${variable}`); + env[variable] = value; + } else { + args.push('--header', `${key}=${value}`); + } } } return { - command: 'allagents', + command: 'npx', args, + ...(Object.keys(env).length > 0 && { env }), }; } @@ -58,15 +81,23 @@ function toProxiedConfig( * Returns a new Map with HTTP configs rewritten to stdio where applicable. * Non-HTTP servers and non-proxied clients are passed through unchanged. */ -export function applyMcpProxy( - servers: Map, +export function applyMcpProxy( + servers: Map, client: string, config: McpProxyConfig, -): Map { - const result = new Map(); + options: { profile?: string } = {}, +): Map { + const result = new Map(); for (const [name, serverConfig] of servers) { if (isHttpServer(serverConfig) && shouldProxy(name, client, config)) { - result.set(name, toProxiedConfig(serverConfig.url, serverConfig.headers)); + result.set( + name, + toProxiedConfig( + serverConfig.url, + serverConfig.headers, + options.profile, + ), + ); } else { result.set(name, serverConfig); } diff --git a/src/core/mcp-servers.ts b/src/core/mcp-servers.ts index 409bd2c2..6745294c 100644 --- a/src/core/mcp-servers.ts +++ b/src/core/mcp-servers.ts @@ -1,28 +1,66 @@ +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { + lstat, + mkdir, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; -import type { - ClientEntry, - ClientType, - McpServerConfig, - WorkspaceConfig, -} from '../models/workspace-config.js'; import { + type ClientType, + type McpServerConfig, McpServerConfigSchema, - getClientTypes, + type ProfileDeclaration, + ProfileMcpServerConfigSchema, + type ProfileName, + ProfileNameSchema, + type WorkspaceConfig, } from '../models/workspace-config.js'; +import { + type EditableUserWorkspaceConfig, + parseUserWorkspaceConfigDocumentForEdit, + parseWorkspaceConfigForEdit, + validateProjectWorkspaceConfig, + validateUserWorkspaceConfig, +} from '../utils/workspace-parser.js'; +import { + ensureUserWorkspace, + getUserWorkspaceConfigPath, + isUserConfigPath, +} from './user-workspace.js'; import { ensureWorkspace } from './workspace-modify.js'; -import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; -const PROJECT_MCP_CLIENTS: ReadonlySet = new Set([ - 'claude', - 'codex', - 'vscode', - 'copilot', - 'universal', -]); +export type McpDestination = + | { + kind: 'project'; + workspacePath: string; + configPath: string; + } + | { + kind: 'user'; + configPath: string; + } + | { + kind: 'profile'; + name: ProfileName; + configPath: string; + }; + +export interface ResolveMcpDestinationOptions { + cwd?: string; + scope?: string; + profile?: string; +} + +export interface AddMcpServerOptions { + force?: boolean; + proxy?: false | { clients?: ClientType[] }; +} /** * Result of add/remove/update operations on workspace mcpServers. @@ -34,170 +72,339 @@ export interface McpServerModifyResult { config?: McpServerConfig; } -export interface McpProxyModifyResult { - success: boolean; - error?: string; - proxyClients?: ClientType[]; -} +type EditableMcpProxy = { + clients?: string[]; + servers?: Record; +}; -function removeServerScopedProxyIntent( - workspaceConfig: WorkspaceConfig, - name: string, -): void { - if (!workspaceConfig.mcpProxy?.servers?.[name]) { - return; - } +type EditableMcpContainer = { + mcpServers?: Record; + mcpProxy?: EditableMcpProxy; +}; + +const LOCK_TIMEOUT_MS = 5_000; +const STALE_LOCK_MS = 30_000; - delete workspaceConfig.mcpProxy.servers[name]; - if (Object.keys(workspaceConfig.mcpProxy.servers).length === 0) { - workspaceConfig.mcpProxy.servers = undefined; +async function readLockOwner(lockPath: string): Promise { + return readFile(join(lockPath, 'owner'), 'utf8').catch(() => null); +} + +async function withDestinationLock( + configPath: string, + mutate: () => Promise, +): Promise { + const lockPath = `${configPath}.lock`; + const breakPath = `${lockPath}.break`; + const token = `${process.pid}:${randomUUID()}`; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + let acquired = false; + while (!acquired) { + let created = false; + try { + await mkdir(lockPath, { mode: 0o700 }); + created = true; + await writeFile(join(lockPath, 'owner'), token, { + encoding: 'utf8', + mode: 0o600, + }); + acquired = true; + } catch (error) { + if (created) { + await rm(lockPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const lockStats = await lstat(lockPath).catch(() => null); + if (lockStats && Date.now() - lockStats.mtimeMs > STALE_LOCK_MS) { + let breaker = false; + try { + await mkdir(breakPath, { mode: 0o700 }); + breaker = true; + const currentStats = await lstat(lockPath).catch(() => null); + if ( + currentStats && + Date.now() - currentStats.mtimeMs > STALE_LOCK_MS + ) { + await rm(lockPath, { recursive: true, force: true }); + } + } catch (breakError) { + if ((breakError as NodeJS.ErrnoException).code !== 'EEXIST') { + throw breakError; + } + } finally { + if (breaker) { + await rm(breakPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting to update ${configPath}`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + continue; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting to update ${configPath}`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } } - if ( - workspaceConfig.mcpProxy.clients.length === 0 && - !workspaceConfig.mcpProxy.servers - ) { - workspaceConfig.mcpProxy = undefined; + try { + return await mutate(); + } finally { + if ((await readLockOwner(lockPath)) === token) { + await rm(lockPath, { recursive: true, force: true }).catch( + () => undefined, + ); + } } } +type EditableMcpDocument = WorkspaceConfig | EditableUserWorkspaceConfig; + function getConfigPath(workspacePath: string): string { return join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); } -async function readConfig(configPath: string): Promise { - return parseWorkspaceConfigForEdit(configPath); -} - -async function writeConfig( - configPath: string, - config: WorkspaceConfig, -): Promise { - await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); +function parseProfileName(name: string): ProfileName { + const validation = ProfileNameSchema.safeParse(name); + if (!validation.success) { + const detail = + validation.error.issues[0]?.message ?? 'Invalid portable profile name'; + throw new Error(`Invalid profile name '${name}': ${detail}`); + } + return validation.data; } -function getProjectMcpClients(entries: ClientEntry[]): ClientType[] { - return getClientTypes(entries).filter((client): client is ClientType => - PROJECT_MCP_CLIENTS.has(client), - ); +function validateDestination(destination: McpDestination): void { + if (destination.kind === 'profile') { + parseProfileName(destination.name); + } } /** - * Validate a server config via the McpServerConfigSchema. Returns a - * user-friendly error on failure. + * Resolve the declaration document selected by the MCP command flags. */ -function validateServerConfig( - config: unknown, -): { valid: true; data: McpServerConfig } | { valid: false; error: string } { - const result = McpServerConfigSchema.safeParse(config); - if (!result.success) { - const issues = result.error.issues.map( - (i) => ` - ${i.path.join('.')}: ${i.message}`, - ); +export function resolveMcpDestination( + options: ResolveMcpDestinationOptions = {}, +): McpDestination { + if (options.scope !== undefined && options.profile !== undefined) { + throw new Error('--scope and --profile cannot be used together'); + } + + if ( + options.scope !== undefined && + options.scope !== 'project' && + options.scope !== 'user' + ) { + throw new Error(`Invalid MCP scope '${String(options.scope)}'`); + } + + if (options.profile !== undefined) { return { - valid: false, - error: `Invalid MCP server config:\n${issues.join('\n')}`, + kind: 'profile', + name: parseProfileName(options.profile), + configPath: getUserWorkspaceConfigPath(), }; } - return { valid: true, data: result.data }; -} -/** - * Add a new MCP server entry to workspace.yaml. Fails if a server with the - * given name already exists (unless `force` is set). - */ -export async function addWorkspaceMcpServer( - name: string, - config: McpServerConfig, - workspacePath: string = process.cwd(), - force = false, -): Promise { - const validation = validateServerConfig(config); - if (!validation.valid) { - return { success: false, error: validation.error }; + if (options.scope === 'user') { + return { + kind: 'user', + configPath: getUserWorkspaceConfigPath(), + }; } - try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); - workspaceConfig.mcpServers ??= {}; - - if (workspaceConfig.mcpServers[name] && !force) { - return { - success: false, - error: `MCP server '${name}' already exists in workspace.yaml. Pass --force to replace it.`, - }; + const workspacePath = options.cwd ?? process.cwd(); + if (isUserConfigPath(workspacePath)) { + if (options.scope === 'project') { + throw new Error( + '--scope project cannot be used from the home directory because it aliases the user workspace; run from a project directory or use --scope user', + ); } - - workspaceConfig.mcpServers[name] = validation.data; - await writeConfig(configPath, workspaceConfig); - return { success: true, config: validation.data }; - } catch (error) { return { - success: false, - error: error instanceof Error ? error.message : String(error), + kind: 'user', + configPath: getUserWorkspaceConfigPath(), }; } + return { + kind: 'project', + workspacePath, + configPath: getConfigPath(workspacePath), + }; } -/** - * Persist server-scoped MCP proxy intent for a workspace-defined server. - * Keeps any existing workspace-wide proxy defaults unchanged. - */ -export async function setWorkspaceMcpServerProxy( - name: string, - workspacePath: string = process.cwd(), - proxyClients?: ClientType[], -): Promise { +async function readDestinationConfig( + destination: McpDestination, +): Promise { + return destination.kind === 'project' + ? parseWorkspaceConfigForEdit(destination.configPath) + : parseUserWorkspaceConfigDocumentForEdit(destination.configPath); +} + +function selectDestinationContainer( + destination: McpDestination, + document: EditableMcpDocument, +): EditableMcpContainer { + if (destination.kind !== 'profile') { + return document as EditableMcpContainer; + } + + const profile = (document as EditableUserWorkspaceConfig).profiles?.[ + destination.name + ]; + if (!profile) { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return profile as ProfileDeclaration & EditableMcpContainer; +} + +async function writeDestinationConfig( + destination: McpDestination, + document: EditableMcpDocument, +): Promise { + if (destination.kind === 'project') { + validateProjectWorkspaceConfig(document, destination.configPath); + } else { + validateUserWorkspaceConfig(document, destination.configPath); + } + const existing = await lstat(destination.configPath).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (existing?.isSymbolicLink()) { + throw new Error( + `Refusing to replace symbolic-link workspace config: ${destination.configPath}`, + ); + } + if (existing && !existing.isFile()) { + throw new Error( + `Workspace config is not a regular file: ${destination.configPath}`, + ); + } + const mode = + existing?.mode ?? (destination.kind === 'project' ? 0o644 : 0o600); + const temporaryPath = join( + dirname(destination.configPath), + `.${basename(destination.configPath)}.${process.pid}.${randomUUID()}.tmp`, + ); try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); + await writeFile(temporaryPath, dump(document, { lineWidth: -1 }), { + encoding: 'utf-8', + mode, + }); + await rename(temporaryPath, destination.configPath); + } finally { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { - return { - success: false, - error: `MCP server '${name}' not found in workspace.yaml`, - }; +async function ensureDestinationForAdd( + destination: McpDestination, +): Promise { + if (destination.kind === 'project') { + await ensureWorkspace(destination.workspacePath); + } else if ( + destination.kind === 'user' && + !existsSync(destination.configPath) && + resolve(destination.configPath) === resolve(getUserWorkspaceConfigPath()) + ) { + await ensureUserWorkspace(); + } +} + +function removeServerScopedProxyIntent( + container: EditableMcpContainer, + name: string, +): void { + const proxy = container.mcpProxy; + if (!proxy) return; + + if (proxy.servers) { + delete proxy.servers[name]; + if (Object.keys(proxy.servers).length === 0) { + delete proxy.servers; } + } + if ((proxy.clients?.length ?? 0) === 0 && !proxy.servers) { + delete container.mcpProxy; + } +} + +function applyServerScopedProxyIntent( + container: EditableMcpContainer, + name: string, + proxy: AddMcpServerOptions['proxy'], +): void { + if (proxy === undefined) return; + if (proxy === false) { + removeServerScopedProxyIntent(container, name); + return; + } - const resolvedClients = [ - ...new Set(proxyClients ?? getProjectMcpClients(workspaceConfig.clients)), - ]; - workspaceConfig.mcpProxy ??= { clients: [] }; - workspaceConfig.mcpProxy.clients ??= []; - workspaceConfig.mcpProxy.servers ??= {}; - workspaceConfig.mcpProxy.servers[name] = { proxy: resolvedClients }; + const resolvedClients = [...new Set(proxy.clients ?? ['*'])]; + container.mcpProxy ??= {}; + container.mcpProxy.servers ??= {}; + container.mcpProxy.servers[name] = { proxy: resolvedClients }; +} - await writeConfig(configPath, workspaceConfig); - return { success: true, proxyClients: resolvedClients }; - } catch (error) { +function validateServerConfig( + destination: McpDestination, + config: unknown, +): { valid: true; data: McpServerConfig } | { valid: false; error: string } { + const result = + destination.kind === 'profile' + ? ProfileMcpServerConfigSchema.safeParse(config) + : McpServerConfigSchema.safeParse(config); + if (!result.success) { + const issues = result.error.issues.map( + (issue) => ` - ${issue.path.join('.')}: ${issue.message}`, + ); return { - success: false, - error: error instanceof Error ? error.message : String(error), + valid: false, + error: `Invalid MCP server config:\n${issues.join('\n')}`, }; } + return { valid: true, data: result.data }; } -export async function clearWorkspaceMcpServerProxy( +/** + * Add or replace one inline MCP declaration and its server-local proxy intent + * in a single validated document write. + */ +export async function addMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), -): Promise { + config: McpServerConfig, + options: AddMcpServerOptions = {}, +): Promise { try { - await ensureWorkspace(workspacePath); - const configPath = getConfigPath(workspacePath); - const workspaceConfig = await readConfig(configPath); - - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { - return { - success: false, - error: `MCP server '${name}' not found in workspace.yaml`, - }; + validateDestination(destination); + const validation = validateServerConfig(destination, config); + if (!validation.valid) { + return { success: false, error: validation.error }; } - removeServerScopedProxyIntent(workspaceConfig, name); - await writeConfig(configPath, workspaceConfig); - return { success: true }; + await ensureDestinationForAdd(destination); + return await withDestinationLock(destination.configPath, async () => { + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + container.mcpServers ??= {}; + + if (container.mcpServers[name] && !options.force) { + return { + success: false, + error: `MCP server '${name}' already exists in workspace.yaml. Pass --force to replace it.`, + }; + } + + container.mcpServers[name] = validation.data; + applyServerScopedProxyIntent(container, name, options.proxy); + await writeDestinationConfig(destination, document); + return { success: true, config: validation.data }; + }); } catch (error) { return { success: false, @@ -207,39 +414,40 @@ export async function clearWorkspaceMcpServerProxy( } /** - * Remove an MCP server entry from workspace.yaml. Returns success: false if - * the server is not defined in workspace.yaml (it may still exist in a plugin). + * Remove one inline MCP declaration and its server-local proxy intent. */ -export async function removeWorkspaceMcpServer( +export async function removeMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), ): Promise { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) { - return { - success: false, - error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found in ${workspacePath}`, - }; - } - try { - const workspaceConfig = await readConfig(configPath); - if (!workspaceConfig.mcpServers || !(name in workspaceConfig.mcpServers)) { + validateDestination(destination); + if (!existsSync(destination.configPath)) { return { success: false, - error: `MCP server '${name}' not found in workspace.yaml`, + error: `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE} not found`, }; } - delete workspaceConfig.mcpServers[name]; - if (Object.keys(workspaceConfig.mcpServers).length === 0) { - workspaceConfig.mcpServers = undefined; - } - - removeServerScopedProxyIntent(workspaceConfig, name); - - await writeConfig(configPath, workspaceConfig); - return { success: true }; + return await withDestinationLock(destination.configPath, async () => { + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + if (!container.mcpServers || !(name in container.mcpServers)) { + return { + success: false, + error: `MCP server '${name}' not found in workspace.yaml`, + }; + } + + delete container.mcpServers[name]; + if (Object.keys(container.mcpServers).length === 0) { + delete container.mcpServers; + } + removeServerScopedProxyIntent(container, name); + + await writeDestinationConfig(destination, document); + return { success: true }; + }); } catch (error) { return { success: false, @@ -249,29 +457,40 @@ export async function removeWorkspaceMcpServer( } /** - * Read an MCP server entry from workspace.yaml. Returns null if it is not - * defined there (it may still be defined by a plugin). + * Read one inline MCP declaration from exactly the selected destination. */ -export async function getWorkspaceMcpServer( +export async function getMcpServer( + destination: McpDestination, name: string, - workspacePath: string = process.cwd(), ): Promise { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) return null; - const workspaceConfig = await readConfig(configPath); - return workspaceConfig.mcpServers?.[name] ?? null; + validateDestination(destination); + if (!existsSync(destination.configPath)) { + if (destination.kind === 'profile') { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return null; + } + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + return container.mcpServers?.[name] ?? null; } /** - * List all MCP servers defined in workspace.yaml. + * List inline MCP declarations from exactly the selected destination. */ -export async function listWorkspaceMcpServers( - workspacePath: string = process.cwd(), +export async function listMcpServers( + destination: McpDestination, ): Promise> { - const configPath = getConfigPath(workspacePath); - if (!existsSync(configPath)) return {}; - const workspaceConfig = await readConfig(configPath); - return workspaceConfig.mcpServers ?? {}; + validateDestination(destination); + if (!existsSync(destination.configPath)) { + if (destination.kind === 'profile') { + throw new Error(`Profile '${destination.name}' is not declared`); + } + return {}; + } + const document = await readDestinationConfig(destination); + const container = selectDestinationContainer(destination, document); + return container.mcpServers ?? {}; } /** diff --git a/src/core/mcp-sync.ts b/src/core/mcp-sync.ts index 405cfbb1..d2ed85fc 100644 --- a/src/core/mcp-sync.ts +++ b/src/core/mcp-sync.ts @@ -1,31 +1,28 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import { CONFIG_DIR, getHomeDir, WORKSPACE_CONFIG_FILE } from '../constants.js'; +import type { SyncState } from '../models/sync-state.js'; import type { ClientType, + UserWorkspaceConfig, WorkspaceConfig, } from '../models/workspace-config.js'; -import type { SyncState } from '../models/sync-state.js'; -import { - buildPluginSyncPlans, - collectSyncClients, - seedFetchCacheFromMarketplaces, - validateAllPlugins, - type ValidatedPlugin, -} from './sync.js'; -import type { McpMergeResult } from './vscode-mcp.js'; -import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; import { syncClaudeMcpConfig } from './claude-mcp.js'; import { syncCodexProjectMcpConfig } from './codex-mcp.js'; +import { ensureMarketplacesRegistered } from './marketplace.js'; import { applyMcpProxy } from './mcp-proxy.js'; +import type { ValidatedPlugin } from './sync.js'; import { getPreviouslySyncedMcpServers, loadSyncState, - saveSyncState, type McpScope, + saveSyncState, } from './sync-state.js'; -import { ensureMarketplacesRegistered } from './marketplace.js'; -import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { syncUserMcpAdapters } from './user-mcp-sync.js'; +import { getUserWorkspaceConfig } from './user-workspace.js'; +import type { McpMergeResult } from './vscode-mcp.js'; +import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; import { migrateWorkspaceSkillsV1toV2 } from './workspace-modify.js'; /** @@ -90,7 +87,7 @@ function buildSyncSpecs(workspacePath: string): McpSyncSpec[] { { client: 'copilot', scope: 'copilot', - configPath: join(workspacePath, '.copilot', 'mcp-config.json'), + configPath: join(workspacePath, '.github', 'mcp.json'), syncFn: syncClaudeMcpConfig, }, ]; @@ -185,6 +182,58 @@ export interface SyncMcpOnlyResult { error?: string; } +interface PreparedMcpOnlySync { + validPlugins: ValidatedPlugin[]; + syncClients: ClientType[]; + warnings: string[]; +} + +async function prepareMcpOnlySync( + config: WorkspaceConfig, + scope: 'project' | 'user', + root: string, + offline: boolean, +): Promise { + // Deferred because sync.ts imports this module's project orchestrator; a + // static reverse import would create an mcp-sync.ts <-> sync.ts load cycle. + const { + buildPluginSyncPlans, + collectSyncClients, + seedFetchCacheFromMarketplaces, + validateAllPlugins, + } = await import('./sync.js'); + const { plans, warnings } = buildPluginSyncPlans( + config.plugins, + config.clients, + scope, + ); + const activePlans = plans.filter( + (plan) => + plan.clients.length > 0 || + (scope === 'project' && plan.nativeClients.length > 0), + ); + const syncClients = collectSyncClients(config.clients, activePlans); + + if (!offline) { + const marketplaceResults = await ensureMarketplacesRegistered( + activePlans.map((plan) => plan.source), + ); + await seedFetchCacheFromMarketplaces(marketplaceResults); + } + + const validatedPlugins = await validateAllPlugins(activePlans, root, offline); + const validPlugins = validatedPlugins.filter( + (plugin): plugin is ValidatedPlugin => plugin.success, + ); + warnings.push( + ...validatedPlugins + .filter((plugin) => !plugin.success) + .map((plugin) => `${plugin.plugin}: ${plugin.error} (skipped)`), + ); + + return { validPlugins, syncClients, warnings }; +} + /** * Standalone MCP-only sync for the `allagents mcp update` command. * @@ -226,43 +275,12 @@ export async function syncMcpOnly( }; } - const warnings: string[] = []; - - const { plans, warnings: planWarnings } = buildPluginSyncPlans( - config.plugins, - config.clients, + const { validPlugins, syncClients, warnings } = await prepareMcpOnlySync( + config, 'project', - ); - warnings.push(...planWarnings); - - const filteredPlans = plans.filter( - (plan) => plan.clients.length > 0 || plan.nativeClients.length > 0, - ); - const syncClients = collectSyncClients(config.clients, filteredPlans); - - // Pre-register marketplaces so that plugin validation can resolve them. - // Skip in offline mode to avoid network calls. - if (!offline) { - const marketplaceResults = await ensureMarketplacesRegistered( - filteredPlans.map((plan) => plan.source), - ); - await seedFetchCacheFromMarketplaces(marketplaceResults); - } - - // Validate plugins so we can read their .mcp.json files - const validatedPlugins = await validateAllPlugins( - filteredPlans, workspacePath, offline, ); - const validPlugins = validatedPlugins.filter( - (v): v is ValidatedPlugin => v.success, - ); - warnings.push( - ...validatedPlugins - .filter((v) => !v.success) - .map((v) => `${v.plugin}: ${v.error} (skipped)`), - ); const previousState = await loadSyncState(workspacePath); @@ -308,3 +326,86 @@ export async function syncMcpOnly( warnings, }; } + +/** + * Reconcile only ordinary user-scoped MCP destinations. + * + * This intentionally omits user workspace migrations, plugin file operations, + * native installs, and profile materialization. + */ +export async function syncUserMcpOnly( + options: { offline?: boolean; dryRun?: boolean } = {}, +): Promise { + const { offline = false, dryRun = false } = options; + let config: UserWorkspaceConfig | null; + try { + config = await getUserWorkspaceConfig(); + } catch (error) { + return { + success: false, + mcpResults: {}, + warnings: [], + error: error instanceof Error ? error.message : String(error), + }; + } + + if (!config) { + return { success: true, mcpResults: {}, warnings: [] }; + } + + const homeDir = getHomeDir(); + const { validPlugins, syncClients, warnings } = await prepareMcpOnlySync( + config, + 'user', + homeDir, + offline, + ); + + const previousState = await loadSyncState(homeDir); + const syncResult = await syncUserMcpAdapters({ + validPlugins, + config, + previousState, + syncClients, + dryRun, + force: false, + }); + warnings.push(...syncResult.warnings); + + if (!dryRun) { + const trackingChanged = Object.entries(syncResult.trackedServers).some( + ([scope, current]) => { + if (!current) return false; + const previous = previousState?.mcpServers?.[scope] ?? []; + return ( + current.length !== previous.length || + current.some((name, index) => name !== previous[index]) + ); + }, + ); + const adaptersChanged = Object.values(syncResult.mcpResults).some( + (result) => + result !== undefined && + (result.added > 0 || result.overwritten > 0 || result.removed > 0), + ); + + if (trackingChanged || adaptersChanged) { + await saveSyncState(homeDir, { + files: previousState?.files ?? {}, + mcpServers: { + ...previousState?.mcpServers, + ...syncResult.trackedServers, + }, + }); + } + } + + return { + success: syncResult.complete, + mcpResults: syncResult.mcpResults, + warnings, + ...(!syncResult.complete && { + error: 'MCP sync was incomplete; retained ownership for failed clients', + }), + }; +} diff --git a/src/core/profile/adapters/mcp.ts b/src/core/profile/adapters/mcp.ts index fae93b63..c07f4816 100644 --- a/src/core/profile/adapters/mcp.ts +++ b/src/core/profile/adapters/mcp.ts @@ -1,10 +1,11 @@ import { - ProfileMcpServerConfigSchema, type ClientType, + type McpServerConfig, + ProfileMcpServerConfigSchema, + ProfileMcpServerNameSchema, } from '../../../models/workspace-config.js'; import type { ProfileSerializationInput } from '../types.js'; -const MCP_NAME_PATTERN = /^[a-zA-Z0-9_.-]{1,100}$/; const SENSITIVE_QUERY_KEY = /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; const SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; @@ -13,14 +14,17 @@ const SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; export function serializeProfileMcpServers( input: ProfileSerializationInput, client: ClientType, -): Record | null { +): Record | null { if (input.mcpServers === undefined) return null; - const selected: Record = {}; + const selected: Record = {}; for (const name of Object.keys(input.mcpServers).sort()) { - if (!MCP_NAME_PATTERN.test(name)) { + const nameValidation = ProfileMcpServerNameSchema.safeParse(name); + if (!nameValidation.success) { throw new Error(`Invalid profile MCP server name '${name}'`); } - const parsed = ProfileMcpServerConfigSchema.safeParse(input.mcpServers[name]); + const parsed = ProfileMcpServerConfigSchema.safeParse( + input.mcpServers[name], + ); if (!parsed.success) { const issue = parsed.error.issues[0]; throw new Error( @@ -37,10 +41,15 @@ export function serializeProfileMcpServers( throw new Error(`Invalid profile MCP server '${name}': URL is invalid`); } if (url.username || url.password) { - throw new Error(`Invalid profile MCP server '${name}': URL contains credentials`); + throw new Error( + `Invalid profile MCP server '${name}': URL contains credentials`, + ); } for (const [key, value] of url.searchParams) { - if (SENSITIVE_QUERY_KEY.test(key) && !SECRET_REFERENCE_PATTERN.test(value)) { + if ( + SENSITIVE_QUERY_KEY.test(key) && + !SECRET_REFERENCE_PATTERN.test(value) + ) { throw new Error( `Invalid profile MCP server '${name}': secret query values must be exact \${ENV_VAR} references`, ); diff --git a/src/core/profile/manager.ts b/src/core/profile/manager.ts index a98f08db..f12eb039 100644 --- a/src/core/profile/manager.ts +++ b/src/core/profile/manager.ts @@ -6,10 +6,11 @@ import type { ProfileState, } from '../../models/profile-state.js'; import { - ProfileNameSchema, type ClientType, type ProfileDeclaration, + ProfileNameSchema, } from '../../models/workspace-config.js'; +import { getProfileAdapter } from './adapters/registry.js'; import { assertSafeProfilePath, fingerprintProfileFile, @@ -17,7 +18,28 @@ import { removeManagedFile, sha256Fingerprint, } from './files.js'; +import type { + ProfileApplyResult, + ProfileApplyStep, + ProfileApplyStepStatus, + ProfilePlan, + ProfilePlanAction, + ProfileRuntimeOptions, + ProfileStatusResult, +} from './index.js'; import { diagnoseLauncherPath, renderProfileLaunchers } from './launcher.js'; +import { + getInternalProfilePlan, + getProfileRoot, + type InternalProfilePlan, + type InternalProfilePlanStep, + type ProfilePlanDependencies, + planProfileOperation, + type ResolvedProfileRuntime, + readOptionalProfileWorkspace, + readProfileWorkspace, + resolveProfileRuntimeOptions, +} from './plan.js'; import { checkpointProfileResource, createProfileState, @@ -27,29 +49,7 @@ import { sanitizeProfileError, saveProfileState, } from './state.js'; -import { - getInternalProfilePlan, - getProfileRoot, - planProfileOperation, - readProfileWorkspace, - readOptionalProfileWorkspace, - resolveProfileRuntimeOptions, - type InternalProfilePlan, - type InternalProfilePlanStep, - type ProfilePlanDependencies, - type ResolvedProfileRuntime, -} from './plan.js'; -import { getProfileAdapter } from './adapters/registry.js'; import { isNativeProfileAdapter, type ProfileAdapter } from './types.js'; -import type { - ProfileApplyResult, - ProfileApplyStep, - ProfileApplyStepStatus, - ProfilePlan, - ProfilePlanAction, - ProfileRuntimeOptions, - ProfileStatusResult, -} from './index.js'; export interface ProfileManagerDependencies extends ProfilePlanDependencies { readonly now?: () => Date; @@ -192,6 +192,23 @@ async function removeEmptyManagedRoot(root: string): Promise { } } +async function removeProfileOAuthProxyRoot(profileRoot: string): Promise { + const oauthProxyRoot = join(profileRoot, 'oauth-proxy'); + await assertSafeProfilePath(profileRoot, oauthProxyRoot); + const stats = await lstat(oauthProxyRoot).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return; + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error( + `Profile OAuth proxy root is not a real directory: ${oauthProxyRoot}`, + ); + } + await assertSafeProfilePath(profileRoot, oauthProxyRoot); + await rm(oauthProxyRoot, { recursive: true, force: true }); +} + async function removeOwnedManagedRoot( root: string, expectedRoot: string, @@ -606,18 +623,10 @@ export async function applyProfilePlan( if (plan.operation === 'remove') { if (!retainedManaged) { try { + await removeProfileOAuthProxyRoot(profileRoot); const statePath = getProfileStatePath(profileRoot); await assertSafeProfilePath(profileRoot, statePath); await rm(statePath, { force: true }); - await removeEmptyManagedRoot(profileRoot); - return { - profile: plan.profile, - operation: plan.operation, - status: 'removed', - success: true, - steps: results, - warnings: plan.warnings, - }; } catch (error) { const message = safeError(error); return { @@ -630,6 +639,22 @@ export async function applyProfilePlan( error: message, }; } + const warnings = [...plan.warnings]; + try { + await removeEmptyManagedRoot(profileRoot); + } catch (error) { + warnings.push( + `Profile resources were removed, but the empty profile directory could not be pruned: ${safeError(error)}`, + ); + } + return { + profile: plan.profile, + operation: plan.operation, + status: 'removed', + success: true, + steps: results, + warnings, + }; } state = await saveProfileState(profileRoot, { ...state, diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts index 1af5b58e..bd304c2d 100644 --- a/src/core/profile/plan.ts +++ b/src/core/profile/plan.ts @@ -1,4 +1,4 @@ -import { lstat, readFile, readdir } from 'node:fs/promises'; +import { lstat, readdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import type { @@ -6,30 +6,44 @@ import type { ProfileState, } from '../../models/profile-state.js'; import { + type ClientType, getPluginRef, getPluginSource, - type ClientType, type InstallMode, + type McpServerConfig, type ProfileDeclaration, - type ProfilePluginEntry, ProfileNameSchema, + type ProfilePluginEntry, type UserWorkspaceConfig, } from '../../models/workspace-config.js'; +import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; +import { applyMcpProxy } from '../mcp-proxy.js'; import type { NativeInspectionResult, NativeResource, } from '../native/types.js'; import { sanitizeNativeProvenance } from '../native/types.js'; -import { copyPluginToWorkspace, collectPluginSkills } from '../transform.js'; -import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; -import { resolveProfileFileSource } from './source.js'; +import { collectPluginSkills, copyPluginToWorkspace } from '../transform.js'; +import { serializeProfileMcpServers } from './adapters/mcp.js'; import { getProfileAdapter } from './adapters/registry.js'; import { assertSafeProfilePath, fingerprintProfileFile, sha256Fingerprint, } from './files.js'; +import type { + ProfileOperationKind, + ProfilePlan, + ProfilePlanAction, + ProfilePlanClient, + ProfilePlanMcpServer, + ProfilePlanStep, + ProfilePlanStepDetail, + ProfileRuntimeOptions, + ProfileStepKind, +} from './index.js'; import { renderProfileLaunchers } from './launcher.js'; +import { resolveProfileFileSource } from './source.js'; import { hashProfileDeclaration, loadProfileState, @@ -43,17 +57,6 @@ import { type ProfileMarketplaceRegistration, type ProfileResolvedPlugin, } from './types.js'; -import type { - ProfileOperationKind, - ProfilePlan, - ProfilePlanAction, - ProfilePlanClient, - ProfilePlanMcpServer, - ProfilePlanStep, - ProfilePlanStepDetail, - ProfileRuntimeOptions, - ProfileStepKind, -} from './index.js'; export interface ResolvedProfileRuntime { readonly userConfigPath: string; @@ -399,7 +402,6 @@ function sameNativeIdentity( ); } - function findInstalledNativeResource( inspection: NativeInspectionResult, matches: (resource: NativeResource) => boolean, @@ -501,31 +503,17 @@ async function planManagedFile(input: { }; } -function hasSelectedMcp( - declaration: ProfileDeclaration, - client: ClientType, -): boolean { - return Object.values(declaration.mcpServers ?? {}).some( - (server) => !server.clients || server.clients.includes(client), - ); -} - function managedContextRoot(context: ProfileClientContext): string { return context.operationContext.roots?.config ?? context.root; } -const EXACT_SECRET_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; +const SECRET_REFERENCE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; function requestedSecretNames(value: unknown): string[] { const names = new Set(); const visit = (entry: unknown): void => { if (typeof entry === 'string') { - const match = EXACT_SECRET_REFERENCE.exec(entry); - if (match?.[1]) names.add(match[1]); - try { - const url = new URL(entry); - for (const queryValue of url.searchParams.values()) visit(queryValue); - } catch { - // Non-URL strings have already been checked as exact references. + for (const match of entry.matchAll(SECRET_REFERENCE)) { + if (match[1]) names.add(match[1]); } return; } @@ -544,14 +532,10 @@ function requestedSecretNames(value: unknown): string[] { } function mcpDisclosures( - declaration: ProfileDeclaration, - client: ClientType, + mcpServers: Readonly>, ): readonly ProfilePlanMcpServer[] { const servers: ProfilePlanMcpServer[] = []; - for (const [name, server] of Object.entries( - declaration.mcpServers ?? {}, - ).sort(([left], [right]) => left.localeCompare(right))) { - if (server.clients && !server.clients.includes(client)) continue; + for (const [name, server] of Object.entries(mcpServers)) { if ('url' in server) { servers.push({ name, @@ -567,7 +551,7 @@ function mcpDisclosures( command: server.command, args: Object.freeze( (server.args ?? []).map((argument) => - EXACT_SECRET_REFERENCE.test(argument) ? '[REDACTED]' : argument, + argument.replace(SECRET_REFERENCE, '[REDACTED]'), ), ), }, @@ -578,10 +562,6 @@ function mcpDisclosures( return Object.freeze(servers); } - - - - async function planRoot( client: ClientType, context: ProfileClientContext, @@ -700,8 +680,8 @@ export async function planProfileOperation( const priorState = loadedState.status === 'loaded' ? loadedState.state : null; if (operation !== 'remove' && !declaration) throw new Error(`Profile '${profile}' is not declared`); - if (operation === 'remove' && !priorState) - throw new Error(`Profile '${profile}' is not installed`); + if (operation === 'remove' && !priorState && !declaration) + throw new Error(`Profile '${profile}' is not installed or declared`); const desiredClients = declaration ? declaration.clients.map((client) => client.name) @@ -957,9 +937,32 @@ export async function planProfileOperation( } } + const selectedMcpServers = serializeProfileMcpServers( + { + plugins: [], + ...(declaration.mcpServers && { + mcpServers: declaration.mcpServers, + }), + }, + client, + ); + const effectiveMcpServers = + selectedMcpServers === null + ? undefined + : declaration.mcpProxy + ? Object.fromEntries( + applyMcpProxy( + new Map(Object.entries(selectedMcpServers)), + client, + declaration.mcpProxy, + { profile }, + ), + ) + : selectedMcpServers; + const hasMcp = Object.keys(effectiveMcpServers ?? {}).length > 0; + const requiresMcpPrerequisite = - hasSelectedMcp(declaration, client) && - adapter.mcpPrerequisite !== undefined; + hasMcp && adapter.mcpPrerequisite !== undefined; const plannedMcpPrerequisite = adapter.mcpPrerequisite ? nativePlugins.find(({ resource }) => adapter.mcpPrerequisite?.matches(resource), @@ -1272,14 +1275,13 @@ export async function planProfileOperation( ...filePlugins, ], settings: declaredClient.settings, - ...(declaration.mcpServers && { mcpServers: declaration.mcpServers }), + ...(effectiveMcpServers && { mcpServers: effectiveMcpServers }), }; if ( Object.keys(declaredClient.settings).length > 0 && !adapter.capabilities.settings ) throw new Error(`Profile client '${client}' does not support settings`); - const hasMcp = hasSelectedMcp(declaration, client); if (hasMcp && !adapter.capabilities.mcp) throw new Error( `Profile client '${client}' does not support MCP configuration`, @@ -1306,7 +1308,9 @@ export async function planProfileOperation( ...planned, public: { ...planned.public, - detail: { mcpServers: mcpDisclosures(declaration, client) }, + detail: { + mcpServers: mcpDisclosures(effectiveMcpServers ?? {}), + }, }, context, } @@ -1329,7 +1333,7 @@ export async function planProfileOperation( public: { ...planned.public, detail: { - mcpServers: mcpDisclosures(declaration, client), + mcpServers: mcpDisclosures(effectiveMcpServers ?? {}), }, }, ...(requiresMcpPrerequisite && { diff --git a/src/core/sync.ts b/src/core/sync.ts index 61924e1b..345888f0 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,155 +1,147 @@ import { existsSync, - readFileSync, - writeFileSync, lstatSync, + readFileSync, type Stats, + writeFileSync, } from 'node:fs'; -import { rm, unlink, rmdir, copyFile } from 'node:fs/promises'; -import { join, resolve, dirname, relative } from 'node:path'; +import { copyFile, rm, rmdir, unlink } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; import JSON5 from 'json5'; import { - CONFIG_DIR, - WORKSPACE_CONFIG_FILE, AGENT_FILES, + CONFIG_DIR, getHomeDir, + WORKSPACE_CONFIG_FILE, } from '../constants.js'; -import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import type { ClientMapping } from '../models/client-mapping.js'; +import { + CANONICAL_SKILLS_PATH, + CLIENT_MAPPINGS, + isUniversalClient, + resolveClientMappings, + USER_CLIENT_MAPPINGS, +} from '../models/client-mapping.js'; +import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; +import type { + NativeStateResource, + SyncState, + SyncStateSource, +} from '../models/sync-state.js'; import type { - WorkspaceConfig, ClientType, PluginEntry, - WorkspaceFile, - SyncMode, PluginSkillsConfig, + SyncMode, + WorkspaceConfig, + WorkspaceFile, } from '../models/workspace-config.js'; import { - getPluginClients, + type ClientEntry, + getClientTypes, getEffectivePluginSource, + getPluginClients, getPluginExclude, - getClientTypes, normalizeClientEntry, resolveInstallMode, - type ClientEntry, } from '../models/workspace-config.js'; +import { getEmbeddedMarketplaceFileArtifacts } from '../utils/marketplace-manifest-parser.js'; import { isGitHubUrl, - parseGitHubUrl, parseFileSource, + parseGitHubUrl, stripGitRef, } from '../utils/plugin-path.js'; -import { fetchPlugin, getPluginName, seedFetchCache } from './plugin.js'; -import { - copyPluginToWorkspace, - copyWorkspaceFiles, - collectPluginSkills, - type CopyResult, - findRelocatedGitHubHooks, - dedupeAgentFilesByName, - planAgentOutputs, - type AgentDedupeRecord, - type AgentOutput, - type AgentOutputConflict, - type AgentOutputFailure, - type AgentOutputPlan, -} from './transform.js'; -import { updateAgentFiles } from './workspace-repo.js'; import { - discoverWorkspaceSkills, - writeSkillsIndex, - cleanupSkillsIndex, - groupSkillsByRepo, -} from './repo-skills.js'; -import { - CLIENT_MAPPINGS, - USER_CLIENT_MAPPINGS, - CANONICAL_SKILLS_PATH, - isUniversalClient, - resolveClientMappings, -} from '../models/client-mapping.js'; -import type { ClientMapping } from '../models/client-mapping.js'; -import type { MarketplaceFileArtifacts } from '../models/marketplace-manifest.js'; -import { getEmbeddedMarketplaceFileArtifacts } from '../utils/marketplace-manifest-parser.js'; -import { - resolveSkillNames, getSkillKey, + resolveSkillNames, type SkillEntry, } from '../utils/skill-name-resolver.js'; +import { Stopwatch } from '../utils/stopwatch.js'; +import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; +import { + assertSafeDestination, + clientMappingsFromContexts, + pathIsWithin, + type ResolvedClientContext, + resolveClientContexts, + resolveMappedPath, +} from './client-context.js'; +import { syncCodexProjectHooks } from './codex-hooks.js'; +import { + COPILOT_MANAGED_HOOKS_RELATIVE_PATH, + syncCopilotProjectHooks, +} from './copilot-hooks.js'; +import { processManagedRepos } from './managed-repos.js'; import { - isPluginSpec, - resolvePluginSpecWithAutoRegister, ensureMarketplacesRegistered, - parsePluginSpec, - getMarketplaceOverrides, - getRegistryPath, - getProjectRegistryPath, getMarketplace, getMarketplaceAccessError, + getMarketplaceOverrides, + getProjectRegistryPath, + getRegistryPath, + isPluginSpec, + parsePluginSpec, + resolvePluginSpecWithAutoRegister, } from './marketplace.js'; +import { syncMcpServers as runMcpSync } from './mcp-sync.js'; +import { + getNativeClient, + mergeNativeSyncResults, + type NativeEffect, + type NativeMutationResult, + type NativeOperationContext, + type NativeResource, + type NativeSyncResult, + sanitizeNativeProvenance, +} from './native/index.js'; +import { fetchPlugin, getPluginName, seedFetchCache } from './plugin.js'; +import { + cleanupSkillsIndex, + discoverWorkspaceSkills, + groupSkillsByRepo, + writeSkillsIndex, +} from './repo-skills.js'; import { - loadSyncState, - saveSyncState, - saveNativeStateResources, - getPreviouslySyncedFiles, - getPreviouslySyncedMcpServers, getNativeStateResources, + getPreviouslySyncedFiles, + loadSyncState, nativeStateOwnership, + saveNativeStateResources, + saveSyncState, } from './sync-state.js'; -import type { - NativeStateResource, - SyncState, - SyncStateSource, -} from '../models/sync-state.js'; +import { + type AgentDedupeRecord, + type AgentOutput, + type AgentOutputConflict, + type AgentOutputFailure, + type AgentOutputPlan, + type CopyResult, + collectPluginSkills, + copyPluginToWorkspace, + copyWorkspaceFiles, + dedupeAgentFilesByName, + findRelocatedGitHubHooks, + planAgentOutputs, +} from './transform.js'; +import { syncUserMcpAdapters } from './user-mcp-sync.js'; import { getUserWorkspaceConfig, migrateUserWorkspaceSkillsV1toV2, } from './user-workspace.js'; +import type { McpMergeResult } from './vscode-mcp.js'; import { + computeWorkspaceHash, generateVscodeWorkspace, getWorkspaceOutputPath, - computeWorkspaceHash, reconcileVscodeWorkspaceFolders, } from './vscode-workspace.js'; import { + migrateWorkspaceSkillsV1toV2, setRepositories, updateRepositories, - migrateWorkspaceSkillsV1toV2, } from './workspace-modify.js'; -import { collectMcpServers, syncVscodeMcpConfig } from './vscode-mcp.js'; -import type { McpMergeResult } from './vscode-mcp.js'; -import { applyMcpProxy } from './mcp-proxy.js'; -import { syncCodexMcpServers } from './codex-mcp.js'; -import { syncCodexProjectHooks } from './codex-hooks.js'; -import { - COPILOT_MANAGED_HOOKS_RELATIVE_PATH, - syncCopilotProjectHooks, -} from './copilot-hooks.js'; -import { - syncClaudeMcpConfig, - syncClaudeMcpServersViaCli, -} from './claude-mcp.js'; -import { getCopilotMcpConfigPath } from './copilot-mcp.js'; -import { syncMcpServers as runMcpSync } from './mcp-sync.js'; -import { - getNativeClient, - mergeNativeSyncResults, - sanitizeNativeProvenance, - type NativeEffect, - type NativeMutationResult, - type NativeOperationContext, - type NativeResource, - type NativeSyncResult, -} from './native/index.js'; -import { Stopwatch } from '../utils/stopwatch.js'; -import { processManagedRepos } from './managed-repos.js'; -import { - assertSafeDestination, - clientMappingsFromContexts, - pathIsWithin, - resolveClientContexts, - resolveMappedPath, - type ResolvedClientContext, -} from './client-context.js'; +import { updateAgentFiles } from './workspace-repo.js'; /** * Result of deduplicating clients by skillsPath @@ -453,7 +445,7 @@ export function nativeOperationContext( export function nativeContextIdentity(context: NativeOperationContext): string { if (context.client !== 'omp') return resolve(context.root); const roots = Object.entries(context.roots ?? {}) - .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([name, path]) => [name, resolve(path)]); return JSON.stringify({ root: resolve(context.root), @@ -519,10 +511,7 @@ export function nativeIdentityMatches( candidate.marketplaceName === targetSpec.marketplaceName, ); } - if ( - requestedSpec?.plugin === target || - resolvedSpec?.plugin === target - ) { + if (requestedSpec?.plugin === target || resolvedSpec?.plugin === target) { return true; } @@ -548,7 +537,8 @@ function nativeSelectionMatches( return ( !selection || selection.targets.some((target) => - nativeIdentityMatches(target, requestedIdentity, resolvedIdentity)) + nativeIdentityMatches(target, requestedIdentity, resolvedIdentity), + ) ); } @@ -584,7 +574,10 @@ async function preflightNativePlans( errors.push(resolution.error ?? `${client} rejected '${plan.source}'`); continue; } - const logicalIdentity = nativeLogicalIdentity(client, resolution.resource); + const logicalIdentity = nativeLogicalIdentity( + client, + resolution.resource, + ); const clientIdentities = desiredIdentities.get(client) ?? new Map(); const duplicate = clientIdentities.get(logicalIdentity); if (duplicate) { @@ -645,14 +638,13 @@ function nativePreflightFailureResult( const resolution = adapter?.resolveSource(plan.source, context, { source: plan.source, }); - const resource: NativeResource = - resolution?.resource ?? { - kind: client === 'pi' ? 'package' : 'plugin', - requestedIdentity: plan.source, - resolvedIdentity: plan.source, - context, - provenance: { source: plan.source }, - }; + const resource: NativeResource = resolution?.resource ?? { + kind: client === 'pi' ? 'package' : 'plugin', + requestedIdentity: plan.source, + resolvedIdentity: plan.source, + context, + provenance: { source: plan.source }, + }; const error = errors.find( (candidate) => @@ -847,10 +839,7 @@ const MANAGED_DIRECTORY_KEYS = [ 'githubPath', ] as const satisfies readonly (keyof ClientMapping)[]; -function resolveTrackedPath( - workspacePath: string, - filePath: string, -): string { +function resolveTrackedPath(workspacePath: string, filePath: string): string { return resolveMappedPath(workspacePath, filePath.replace(/[\\/]$/, '')); } @@ -918,14 +907,7 @@ export async function selectivePurgeWorkspace( const purgedPaths: string[] = []; for (const filePath of previousFiles) { - if ( - !trackedPathIsAllowed( - workspacePath, - filePath, - mapping, - context, - ) - ) { + if (!trackedPathIsAllowed(workspacePath, filePath, mapping, context)) { continue; } const cleanPath = resolveTrackedPath(workspacePath, filePath); @@ -950,7 +932,10 @@ export async function selectivePurgeWorkspace( await unlink(cleanPath); } purgedPaths.push(filePath); - await cleanupEmptyParents(context?.writeRoot ?? workspacePath, cleanPath); + await cleanupEmptyParents( + context?.writeRoot ?? workspacePath, + cleanPath, + ); } catch { // Best effort - continue with other files } @@ -1299,8 +1284,7 @@ export function collectSyncedPaths( } } - const directoryRoots = MANAGED_DIRECTORY_KEYS - .map((key) => mapping[key]) + const directoryRoots = MANAGED_DIRECTORY_KEYS.map((key) => mapping[key]) .filter((path): path is string => path !== undefined) .map((path) => resolveMappedPath(workspacePath, path)); const belongsToDirectory = directoryRoots.some((root) => @@ -1605,7 +1589,8 @@ export function buildPluginSyncPlans( for (const client of pluginClientTypes) { const clientEntry = normalizeClientEntry( clientEntries.find( - (entry) => (typeof entry === 'string' ? entry : entry.name) === client, + (entry) => + (typeof entry === 'string' ? entry : entry.name) === client, ) ?? client, ); if (resolveInstallMode(plugin, clientEntry) === 'file') { @@ -2151,7 +2136,8 @@ async function syncNativePlugins( selection, resource.requestedIdentity, resource.resolvedIdentity, - )); + ), + ); if (selected.length > 0) desiredByClient.set(client, selected); } } @@ -2174,7 +2160,8 @@ async function syncNativePlugins( selection, resource.requestedIdentity, resource.resolvedIdentity, - )) + ), + ) ) { clients.add(client); } @@ -2271,14 +2258,17 @@ async function syncNativePlugins( resource.requestedIdentity, resource.resolvedIdentity, ) - : true); + : true, + ); let inspection = await adapter.inspect(context); if (!inspection.success) { - const affected = desired.length > 0 - ? desired - : tracked.map((resource) => - nativeResourceFromState(resource, context)); + const affected = + desired.length > 0 + ? desired + : tracked.map((resource) => + nativeResourceFromState(resource, context), + ); for (const resource of affected) { effects.push({ action: 'failed', @@ -2928,7 +2918,6 @@ export async function syncWorkspace( workspacePath: string = process.cwd(), options: SyncOptions = {}, ): Promise { - const { offline = false, dryRun = false, @@ -3046,7 +3035,6 @@ export async function syncWorkspace( ); } - // Generic marketplace registration/fetch is needed only by file targets. const filePlans = filteredPlans.filter((plan) => plan.clients.length > 0); const marketplaceResults = await sw.measure('marketplace-registration', () => @@ -3083,7 +3071,9 @@ export async function syncWorkspace( sw.stop('workspace-source-validation'); } - const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const failedValidations = validatedPlugins.filter( + (plugin) => !plugin.success, + ); const requiredNativeFailures = failedValidations.filter((plugin) => { const plan = filteredPlans.find( (candidate) => candidate.configurationIndex === plugin.configurationIndex, @@ -3115,7 +3105,9 @@ export async function syncWorkspace( } const validPlugins = validatedPlugins.filter((plugin) => plugin.success); - const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); + const filePlugins = validPlugins.filter( + (plugin) => plugin.clients.length > 0, + ); if (validPlugins.length === 0 && filteredPlans.length > 0) { return failedSyncResult( `All plugins failed validation (workspace unchanged):\n${failedValidations.map((plugin) => ` - ${plugin.plugin}: ${plugin.error}`).join('\n')}`, @@ -3128,9 +3120,7 @@ export async function syncWorkspace( !!config.workspace?.source && !validatedWorkspaceSource; const workspaceFilesSourcePath = validatedWorkspaceSource?.resolved; const workspaceFilesToCopy = - config.workspace && !skipWorkspaceFiles - ? [...config.workspace.files] - : []; + config.workspace && !skipWorkspaceFiles ? [...config.workspace.files] : []; let workspaceFilesGithubCache = new Map(); if (config.workspace && !skipWorkspaceFiles) { if (hasRepositories && workspaceFilesSourcePath) { @@ -3209,10 +3199,7 @@ export async function syncWorkspace( clientContexts, CLIENT_MAPPINGS, ); - const resolvedMappings = resolveClientMappings( - syncClients, - contextMappings, - ); + const resolvedMappings = resolveClientMappings(syncClients, contextMappings); // Step 2b: Get paths that will be purged (for dry-run reporting) // In non-destructive mode, only show files from state (or nothing on first sync) @@ -3509,7 +3496,8 @@ export async function syncWorkspace( (effect) => effect.action === 'failed' || effect.action === 'unknown', ).length ?? 0; const totalFailed = fileFailures + nativeFailures; - const hasFailures = pluginResults.some((result) => !result.success) || + const hasFailures = + pluginResults.some((result) => !result.success) || totalFailed > 0 || nativeResult?.success === false; @@ -3534,28 +3522,24 @@ export async function syncWorkspace( if (!dryRun) { const sources = await buildSourcesProvenance(filePlugins, config.plugins); await sw.measure('persist-state', () => - persistSyncState( - workspacePath, - newStatePaths, - { - ...(vscodeState && { vscodeState }), - ...(codexHookSync.managedHooks && { - codexHooks: codexHookSync.managedHooks, - }), - ...(Object.keys(mcpResults).length > 0 && { - mcpTrackedServers: Object.fromEntries( - Object.entries(mcpResults).map(([scope, r]) => [ - scope, - r.trackedServers, - ]), - ), - }), - ...(writtenSkillsIndexFiles.length > 0 && { - skillsIndex: writtenSkillsIndexFiles, - }), - ...(Object.keys(sources).length > 0 && { sources }), - }, - ), + persistSyncState(workspacePath, newStatePaths, { + ...(vscodeState && { vscodeState }), + ...(codexHookSync.managedHooks && { + codexHooks: codexHookSync.managedHooks, + }), + ...(Object.keys(mcpResults).length > 0 && { + mcpTrackedServers: Object.fromEntries( + Object.entries(mcpResults).map(([scope, r]) => [ + scope, + r.trackedServers, + ]), + ), + }), + ...(writtenSkillsIndexFiles.length > 0 && { + skillsIndex: writtenSkillsIndexFiles, + }), + ...(Object.keys(sources).length > 0 && { sources }), + }), ); } @@ -3723,7 +3707,9 @@ export async function syncUserWorkspace( () => validateAllPlugins(pluginPlans, homeDir, offline), `${pluginPlans.length} plugin(s)`, ); - const failedValidations = validatedPlugins.filter((plugin) => !plugin.success); + const failedValidations = validatedPlugins.filter( + (plugin) => !plugin.success, + ); const requiredNativeFailures = failedValidations.filter((plugin) => { const plan = pluginPlans.find( (candidate) => candidate.configurationIndex === plugin.configurationIndex, @@ -3750,7 +3736,9 @@ export async function syncUserWorkspace( ); } const validPlugins = validatedPlugins.filter((plugin) => plugin.success); - const filePlugins = validPlugins.filter((plugin) => plugin.clients.length > 0); + const filePlugins = validPlugins.filter( + (plugin) => plugin.clients.length > 0, + ); const messages: string[] = []; if (validPlugins.length === 0 && pluginPlans.length > 0) { return failedSyncResult( @@ -3842,11 +3830,7 @@ export async function syncUserWorkspace( ); const pluginSkillMaps = buildPluginSkillNameMaps(allSkills); const agentOutputPlan = await sw.measure('agent-output-planning', () => - planValidatedPluginAgentOutputs( - filePlugins, - homeDir, - userContextMappings, - ), + planValidatedPluginAgentOutputs(filePlugins, homeDir, userContextMappings), ); appendAgentOutputConflictWarnings(agentOutputPlan, warnings); const indexedAgentOutputPlan = indexAgentOutputPlan(agentOutputPlan); @@ -3895,129 +3879,20 @@ export async function syncUserWorkspace( `${filePlugins.length} plugin(s)`, ); - // MCP Proxy: prepare transform if configured (user-scoped) - const userMcpProxyConfig = config.mcpProxy; - const userWorkspaceMcpServers = config.mcpServers; - - // Emit collection warnings once across all user-scoped client syncs. - let userCollectWarningsEmitted = false; - function getUserServersForClient(client: ClientType): Map { - const { servers, warnings: collectWarnings } = collectMcpServers( - filePlugins, - userWorkspaceMcpServers, - client, - ); - if (!userCollectWarningsEmitted) { - warnings.push(...collectWarnings); - userCollectWarningsEmitted = true; - } - if (userMcpProxyConfig) { - return applyMcpProxy(servers, client, userMcpProxyConfig); - } - return servers; - } - - // Sync MCP server configs to VS Code if vscode client is configured sw.start('mcp-sync'); - const mcpResults: Record = {}; - if (syncClients.includes('vscode')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'vscode', - ); - const vscodeMcpOverrides = getUserServersForClient('vscode'); - const vscodeMcp = syncVscodeMcpConfig(filePlugins, { - dryRun, - force, - trackedServers: trackedMcpServers, - serverOverrides: vscodeMcpOverrides, - }); - if (vscodeMcp.warnings.length > 0) { - warnings.push(...vscodeMcp.warnings); - } - mcpResults.vscode = vscodeMcp; - } - - // Sync MCP servers to Codex CLI if codex client is configured - if (syncClients.includes('codex')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'codex', - ); - const codexMcpOverrides = getUserServersForClient('codex'); - const codexMcp = await syncCodexMcpServers(filePlugins, { - dryRun, - trackedServers: trackedMcpServers, - ...(codexMcpOverrides && { serverOverrides: codexMcpOverrides }), - }); - if (codexMcp.warnings.length > 0) { - warnings.push(...codexMcp.warnings); - } - mcpResults.codex = codexMcp; - } - - // Sync MCP servers to Claude Code via CLI if claude client is configured - if (syncClients.includes('claude')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'claude', - ); - const claudeMcpOverrides = getUserServersForClient('claude'); - const claudeMcp = await syncClaudeMcpServersViaCli(filePlugins, { - dryRun, - trackedServers: trackedMcpServers, - ...(claudeMcpOverrides && { serverOverrides: claudeMcpOverrides }), - }); - if (claudeMcp.warnings.length > 0) { - warnings.push(...claudeMcp.warnings); - } - mcpResults.claude = claudeMcp; - } - - // Sync MCP servers to Copilot CLI config if copilot client is configured - if (syncClients.includes('copilot')) { - const trackedMcpServers = getPreviouslySyncedMcpServers( - previousState, - 'copilot', - ); - const copilotMcpPath = getCopilotMcpConfigPath(); - const copilotMcpOverrides = getUserServersForClient('copilot'); - const copilotMcp = syncClaudeMcpConfig(filePlugins, { - dryRun, - force, - configPath: copilotMcpPath, - trackedServers: trackedMcpServers, - ...(copilotMcpOverrides && { serverOverrides: copilotMcpOverrides }), - }); - if (copilotMcp.warnings.length > 0) { - warnings.push(...copilotMcp.warnings); - } - mcpResults.copilot = copilotMcp; - } - + const userMcpSyncResult = await syncUserMcpAdapters({ + validPlugins: filePlugins, + config, + previousState, + syncClients, + dryRun, + force, + }); sw.stop('mcp-sync'); - - // Warn about clients that don't support user-scoped MCP sync - const USER_MCP_CLIENTS = new Set([ - 'claude', - 'codex', - 'vscode', - 'copilot', - 'universal', - ]); - const allUserMcpServers = collectMcpServers( - filePlugins, - userWorkspaceMcpServers, - ).servers; - if (allUserMcpServers.size > 0) { - for (const client of syncClients) { - if (!USER_MCP_CLIENTS.has(client)) { - warnings.push( - `MCP servers not synced for ${client} (not supported at user scope)`, - ); - } - } - } + warnings.push(...userMcpSyncResult.warnings); + const mcpResults: Record = { + ...userMcpSyncResult.mcpResults, + }; // Run native CLI installations for user scope const nativeResult = await sw.measure('native-plugin-sync', () => @@ -4078,20 +3953,16 @@ export async function syncUserWorkspace( // Save sync state (including MCP servers and native resources). if (!dryRun) { await sw.measure('persist-state', () => - persistSyncState( - homeDir, - newStatePaths, - { - ...(Object.keys(mcpResults).length > 0 && { - mcpTrackedServers: Object.fromEntries( - Object.entries(mcpResults).map(([scope, r]) => [ - scope, - r.trackedServers, - ]), - ), - }), - }, - ), + persistSyncState(homeDir, newStatePaths, { + ...(Object.keys(mcpResults).length > 0 && { + mcpTrackedServers: Object.fromEntries( + Object.entries(mcpResults).map(([scope, r]) => [ + scope, + r.trackedServers, + ]), + ), + }), + }), ); } diff --git a/src/core/user-mcp-sync.ts b/src/core/user-mcp-sync.ts new file mode 100644 index 00000000..e5b6d63a --- /dev/null +++ b/src/core/user-mcp-sync.ts @@ -0,0 +1,143 @@ +import type { SyncState } from '../models/sync-state.js'; +import type { + ClientType, + WorkspaceConfig, +} from '../models/workspace-config.js'; +import { + syncClaudeMcpConfig, + syncClaudeMcpServersViaCli, +} from './claude-mcp.js'; +import { syncCodexMcpServers } from './codex-mcp.js'; +import { getCopilotMcpConfigPath } from './copilot-mcp.js'; +import { applyMcpProxy } from './mcp-proxy.js'; +import type { ValidatedPlugin } from './sync.js'; +import { getPreviouslySyncedMcpServers, type McpScope } from './sync-state.js'; +import { + collectMcpServers, + type McpMergeResult, + syncVscodeMcpConfig, +} from './vscode-mcp.js'; + +const USER_MCP_CLIENTS: Partial> = { + claude: true, + codex: true, + vscode: true, + copilot: true, + universal: true, +}; + +export interface SyncUserMcpAdaptersOptions { + validPlugins: ValidatedPlugin[]; + config: WorkspaceConfig; + previousState: SyncState | null; + syncClients: ClientType[]; + dryRun?: boolean; + force?: boolean; +} + +export interface SyncUserMcpAdaptersResult { + mcpResults: Partial>; + warnings: string[]; + trackedServers: Partial>; + complete: boolean; +} + +/** + * Reconcile ordinary user-scoped MCP destinations using the same ownership + * semantics as a full user workspace sync. + */ +export async function syncUserMcpAdapters({ + validPlugins, + config, + previousState, + syncClients, + dryRun = false, + force = false, +}: SyncUserMcpAdaptersOptions): Promise { + const warnings: string[] = []; + const mcpResults: Partial> = {}; + const trackedServers: Partial> = {}; + let collectWarningsEmitted = false; + + function getServersForClient(client: ClientType): Map { + const { servers, warnings: collectWarnings } = collectMcpServers( + validPlugins, + config.mcpServers, + client, + ); + if (!collectWarningsEmitted) { + warnings.push(...collectWarnings); + collectWarningsEmitted = true; + } + return config.mcpProxy + ? applyMcpProxy(servers, client, config.mcpProxy) + : servers; + } + + if (syncClients.includes('vscode')) { + const result = syncVscodeMcpConfig(validPlugins, { + dryRun, + force, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'vscode'), + serverOverrides: getServersForClient('vscode'), + }); + warnings.push(...result.warnings); + mcpResults.vscode = result; + trackedServers.vscode = result.trackedServers; + } + + if (syncClients.includes('codex')) { + const result = await syncCodexMcpServers(validPlugins, { + dryRun, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'codex'), + serverOverrides: getServersForClient('codex'), + }); + warnings.push(...result.warnings); + mcpResults.codex = result; + trackedServers.codex = result.trackedServers; + } + + if (syncClients.includes('claude')) { + const result = await syncClaudeMcpServersViaCli(validPlugins, { + dryRun, + trackedServers: getPreviouslySyncedMcpServers(previousState, 'claude'), + serverOverrides: getServersForClient('claude'), + }); + warnings.push(...result.warnings); + mcpResults.claude = result; + trackedServers.claude = result.trackedServers; + } + + if (syncClients.includes('copilot')) { + const result = syncClaudeMcpConfig(validPlugins, { + dryRun, + force, + configPath: getCopilotMcpConfigPath(), + trackedServers: getPreviouslySyncedMcpServers(previousState, 'copilot'), + serverOverrides: getServersForClient('copilot'), + }); + warnings.push(...result.warnings); + mcpResults.copilot = result; + trackedServers.copilot = result.trackedServers; + } + + const allServers = collectMcpServers(validPlugins, config.mcpServers).servers; + if (allServers.size > 0) { + for (const client of syncClients) { + if (!USER_MCP_CLIENTS[client]) { + warnings.push( + `MCP servers not synced for ${client} (not supported at user scope)`, + ); + } + } + } + + return { + mcpResults, + warnings, + trackedServers, + complete: Object.values(mcpResults).every( + (result) => result?.authoritative !== false, + ), + }; +} diff --git a/src/core/vscode-mcp.ts b/src/core/vscode-mcp.ts index d94e74c7..9f9973f2 100644 --- a/src/core/vscode-mcp.ts +++ b/src/core/vscode-mcp.ts @@ -1,10 +1,12 @@ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import JSON5 from 'json5'; import { getHomeDir } from '../constants.js'; +import type { + ClientType, + McpServerConfig, +} from '../models/workspace-config.js'; import type { ValidatedPlugin } from './sync.js'; -import type { ClientType } from '../models/workspace-config.js'; -import type { McpServerConfig } from '../models/workspace-config.js'; /** * Deep equality check for MCP server configs. @@ -44,6 +46,8 @@ export interface McpMergeResult { removedServers: string[]; /** All servers that are now tracked (for saving to sync state) */ trackedServers: string[]; + /** False when the adapter could not determine or apply authoritative state. */ + authoritative?: boolean; /** Path to the config file that was modified (set when changes are written) */ configPath?: string; } @@ -62,7 +66,14 @@ export function getVscodeMcpConfigPath(): string { } const home = getHomeDir(); if (platform === 'darwin') { - return join(home, 'Library', 'Application Support', 'Code', 'User', 'mcp.json'); + return join( + home, + 'Library', + 'Application Support', + 'Code', + 'User', + 'mcp.json', + ); } // Linux return join(home, '.config', 'Code', 'User', 'mcp.json'); @@ -72,7 +83,9 @@ export function getVscodeMcpConfigPath(): string { * Read .mcp.json from a plugin root directory. * Returns the mcpServers object or null if absent/invalid. */ -export function readPluginMcpConfig(pluginPath: string): Record | null { +export function readPluginMcpConfig( + pluginPath: string, +): Record | null { const mcpPath = join(pluginPath, '.mcp.json'); if (!existsSync(mcpPath)) { return null; @@ -80,7 +93,12 @@ export function readPluginMcpConfig(pluginPath: string): Record try { const content = readFileSync(mcpPath, 'utf-8'); const parsed = JSON5.parse(content); - if (parsed && typeof parsed === 'object' && parsed.mcpServers && typeof parsed.mcpServers === 'object') { + if ( + parsed && + typeof parsed === 'object' && + parsed.mcpServers && + typeof parsed.mcpServers === 'object' + ) { return parsed.mcpServers as Record; } return null; @@ -94,7 +112,9 @@ export function readPluginMcpConfig(pluginPath: string): Record * before it is passed to a client sync function. Currently removes `clients` * (a sync filter that should never be written into client MCP configs). */ -function stripWorkspaceMcpMeta(config: McpServerConfig): Record { +function stripWorkspaceMcpMeta( + config: McpServerConfig, +): Record { const { clients: _clients, ...rest } = config as McpServerConfig & { clients?: ClientType[]; }; @@ -126,7 +146,9 @@ export function collectMcpServers( for (const [name, config] of Object.entries(mcpServers)) { if (servers.has(name)) { - warnings.push(`MCP server '${name}' from ${plugin.plugin} conflicts with earlier plugin (skipped)`); + warnings.push( + `MCP server '${name}' from ${plugin.plugin} conflicts with earlier plugin (skipped)`, + ); } else { servers.set(name, config); } @@ -135,7 +157,11 @@ export function collectMcpServers( if (workspaceServers) { for (const [name, config] of Object.entries(workspaceServers)) { - if (targetClient && config.clients && !config.clients.includes(targetClient)) { + if ( + targetClient && + config.clients && + !config.clients.includes(targetClient) + ) { continue; } if (servers.has(name)) { @@ -205,13 +231,16 @@ export function syncVscodeMcpConfig( existingConfig = JSON5.parse(content); } catch { // If invalid, start fresh but warn - result.warnings.push(`Could not parse existing ${configPath}, starting fresh`); + result.warnings.push( + `Could not parse existing ${configPath}, starting fresh`, + ); existingConfig = {}; } } // Get or create the servers object (VS Code uses "servers" key) - const existingServers = (existingConfig.servers as Record) ?? {}; + const existingServers = + (existingConfig.servers as Record) ?? {}; // Process plugin servers: add new, update tracked, skip user-managed conflicts for (const [name, config] of pluginServers) { @@ -253,7 +282,10 @@ export function syncVscodeMcpConfig( if (hasTracking) { const currentServerNames = new Set(pluginServers.keys()); for (const trackedName of previouslyTracked) { - if (!currentServerNames.has(trackedName) && trackedName in existingServers) { + if ( + !currentServerNames.has(trackedName) && + trackedName in existingServers + ) { delete existingServers[trackedName]; result.removed++; result.removedServers.push(trackedName); @@ -262,14 +294,19 @@ export function syncVscodeMcpConfig( } // Write back if there were changes and not dry-run - const hasChanges = result.added > 0 || result.overwritten > 0 || result.removed > 0; + const hasChanges = + result.added > 0 || result.overwritten > 0 || result.removed > 0; if (hasChanges && !dryRun) { existingConfig.servers = existingServers; const dir = dirname(configPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } @@ -289,7 +326,11 @@ export function syncVscodeMcpConfig( if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(configPath, `${JSON.stringify(existingConfig, null, 2)}\n`, 'utf-8'); + writeFileSync( + configPath, + `${JSON.stringify(existingConfig, null, 2)}\n`, + 'utf-8', + ); result.configPath = configPath; } } diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index bae8ca74..bdc358d2 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -340,7 +340,7 @@ export const McpProxyServerSchema = z.object({ * built-in AllAgents HTTP proxy helper */ export const McpProxyConfigSchema = z.object({ - clients: z.array(z.string()), + clients: z.array(z.string()).default([]), servers: z.record(McpProxyServerSchema).optional(), }); @@ -383,8 +383,7 @@ export type McpServerConfig = z.infer; * Portable secret references are preserved verbatim until the selected client * resolves them at runtime. Profile declarations never accept resolved values. */ -const PROFILE_SECRET_REFERENCE_PATTERN = - /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; +const PROFILE_SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; export const ProfileSecretReferenceSchema = z .string() @@ -406,6 +405,13 @@ export const ProfileNameSchema = z export type ProfileName = z.infer; +export const ProfileMcpServerNameSchema = z + .string() + .regex( + /^[A-Za-z0-9_.-]{1,100}$/, + 'Expected 1-100 ASCII letters, numbers, dots, underscores, or hyphens', + ); + /** * Normalize a declared launcher to the command identity which can exist on * every supported platform. Windows companion extensions share one identity. @@ -428,9 +434,7 @@ export const ClaudeProfileSettingsSchema = z }) .strict(); -export type ClaudeProfileSettings = z.infer< - typeof ClaudeProfileSettingsSchema ->; +export type ClaudeProfileSettings = z.infer; export const OpenCodeProfileSettingsSchema = z .object({ @@ -510,9 +514,7 @@ export const CodexProfileSettingsSchema = z }) .strict(); -export type CodexProfileSettings = z.infer< - typeof CodexProfileSettingsSchema ->; +export type CodexProfileSettings = z.infer; /** * Profile clients deliberately use object form only. Each adapter owns a @@ -625,13 +627,12 @@ const PROFILE_SENSITIVE_MCP_FIELD_PATTERN = /(?:^|[-_.])(?:api[-_]?key|auth|authorization|credential|key|password|secret|signature|token)(?:$|[-_.])/i; function isProfileSecretReference(value: string | undefined): boolean { - return ( - value !== undefined && PROFILE_SECRET_REFERENCE_PATTERN.test(value) - ); + return value !== undefined && PROFILE_SECRET_REFERENCE_PATTERN.test(value); } -const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( - (arguments_, ctx) => { +const ProfileMcpArgumentsSchema = z + .array(z.string()) + .superRefine((arguments_, ctx) => { const invalidIndexes = new Set(); const reject = (index: number) => { if (invalidIndexes.has(index)) return; @@ -644,6 +645,12 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( }; for (const [index, argument] of arguments_.entries()) { + if (arguments_[index - 1] === '--header-env') { + if (!/^[^=:\s]+=[A-Za-z_][A-Za-z0-9_]*$/.test(argument)) { + reject(index); + } + continue; + } const separateOption = argument.match(/^(?:--?|\/)([^=:\s]+)$/); const separateOptionName = separateOption?.[1]; if ( @@ -652,9 +659,7 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( ) { const credentialIndex = index + 1; if (!isProfileSecretReference(arguments_[credentialIndex])) { - reject( - credentialIndex < arguments_.length ? credentialIndex : index, - ); + reject(credentialIndex < arguments_.length ? credentialIndex : index); } continue; } @@ -662,16 +667,12 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( if (/^bearer$/i.test(argument)) { const credentialIndex = index + 1; if (!isProfileSecretReference(arguments_[credentialIndex])) { - reject( - credentialIndex < arguments_.length ? credentialIndex : index, - ); + reject(credentialIndex < arguments_.length ? credentialIndex : index); } continue; } - const assignment = argument.match( - /^(?:--?|\/)?([^=:\s]+)[=:]\s*(.*)$/, - ); + const assignment = argument.match(/^(?:--?|\/)?([^=:\s]+)[=:]\s*(.*)$/); const assignmentName = assignment?.[1]; const inlineCredential = assignmentName && @@ -706,8 +707,7 @@ const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( reject(index); } } - }, -); + }); export const ProfileMcpServerConfigSchema = z.union([ z @@ -737,7 +737,10 @@ export const ProfileDeclarationSchema = z .object({ clients: z.array(ProfileClientSchema).min(1), plugins: z.array(ProfilePluginEntrySchema).default([]), - mcpServers: z.record(ProfileMcpServerConfigSchema).optional(), + mcpServers: z + .record(ProfileMcpServerNameSchema, ProfileMcpServerConfigSchema) + .optional(), + mcpProxy: McpProxyConfigSchema.optional(), }) .strict() .superRefine((profile, ctx) => { @@ -755,11 +758,12 @@ export const ProfileDeclarationSchema = z }); const validateSelector = ( - clients: ClientType[] | undefined, + clients: readonly string[] | undefined, path: (string | number)[], + allowWildcard = false, ): void => { if (!clients) return; - const selected = new Set(); + const selected = new Set(); clients.forEach((client, index) => { if (selected.has(client)) { ctx.addIssue({ @@ -767,7 +771,10 @@ export const ProfileDeclarationSchema = z path: [...path, index], message: `Client selector '${client}' is duplicated`, }); - } else if (!declaredClients.has(client)) { + } else if ( + !(allowWildcard && client === '*') && + !declaredClients.has(client as ClientType) + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [...path, index], @@ -786,11 +793,22 @@ export const ProfileDeclarationSchema = z if (profile.mcpServers) { for (const [serverName, server] of Object.entries(profile.mcpServers)) { - validateSelector(server.clients, [ - 'mcpServers', - serverName, - 'clients', - ]); + validateSelector(server.clients, ['mcpServers', serverName, 'clients']); + } + } + + if (profile.mcpProxy) { + validateSelector(profile.mcpProxy.clients, ['mcpProxy', 'clients']); + if (profile.mcpProxy.servers) { + for (const [serverName, server] of Object.entries( + profile.mcpProxy.servers, + )) { + validateSelector( + server.proxy, + ['mcpProxy', 'servers', serverName, 'proxy'], + true, + ); + } } } }); diff --git a/src/utils/workspace-parser.ts b/src/utils/workspace-parser.ts index 058d5f6c..425e6983 100644 --- a/src/utils/workspace-parser.ts +++ b/src/utils/workspace-parser.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; + import { - ProjectWorkspaceConfigSchema, - UserWorkspaceConfigSchema, type ProjectWorkspaceConfig, + ProjectWorkspaceConfigSchema, type UserWorkspaceConfig, + UserWorkspaceConfigSchema, type WorkspaceConfig, } from '../models/workspace-config.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; @@ -13,15 +14,19 @@ const configName = `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`; export type WorkspaceConfigScope = 'project' | 'user'; +export type EditableUserWorkspaceConfig = Omit< + UserWorkspaceConfig, + 'repositories' | 'plugins' | 'clients' +> & + Partial>; + function formatValidationError( path: string, scope: WorkspaceConfigScope, input: unknown, ): ProjectWorkspaceConfig | UserWorkspaceConfig { const schema = - scope === 'user' - ? UserWorkspaceConfigSchema - : ProjectWorkspaceConfigSchema; + scope === 'user' ? UserWorkspaceConfigSchema : ProjectWorkspaceConfigSchema; const result = schema.safeParse(input); if (result.success) return result.data; @@ -35,7 +40,11 @@ export function validateProjectWorkspaceConfig( input: unknown, path: string = configName, ): ProjectWorkspaceConfig { - return formatValidationError(path, 'project', input) as ProjectWorkspaceConfig; + return formatValidationError( + path, + 'project', + input, + ) as ProjectWorkspaceConfig; } export function validateUserWorkspaceConfig( @@ -107,6 +116,28 @@ export async function parseWorkspaceConfigForEdit( return input as WorkspaceConfig; } +async function loadUserWorkspaceConfigForEdit(path: string): Promise<{ + input: EditableUserWorkspaceConfig; + validated: UserWorkspaceConfig; +}> { + const input = await loadConfigFile(path); + const validated = validateUserWorkspaceConfig(input, path); + return { + input: input as EditableUserWorkspaceConfig, + validated, + }; +} + +/** + * Validate a user workspace for mutation while preserving its raw field + * omissions. Callers must validate the whole document again before writing. + */ +export async function parseUserWorkspaceConfigDocumentForEdit( + path: string, +): Promise { + return (await loadUserWorkspaceConfigForEdit(path)).input; +} + /** * Validate a user workspace before mutation without materializing profile * defaults or dropping unrelated top-level fields. Profiles-only workspaces @@ -114,12 +145,10 @@ export async function parseWorkspaceConfigForEdit( */ export async function parseUserWorkspaceConfigForEdit( path: string, -): Promise { - const input = await loadConfigFile(path); - const validated = validateUserWorkspaceConfig(input); - const config = input as Record; - config.repositories ??= validated.repositories; - config.plugins ??= validated.plugins; - config.clients ??= validated.clients; - return config as WorkspaceConfig; +): Promise { + const { input, validated } = await loadUserWorkspaceConfigForEdit(path); + input.repositories ??= validated.repositories; + input.plugins ??= validated.plugins; + input.clients ??= validated.clients; + return input as UserWorkspaceConfig; } diff --git a/tests/e2e/copilot-project-mcp.test.ts b/tests/e2e/copilot-project-mcp.test.ts index fc41a175..d0425f5f 100644 --- a/tests/e2e/copilot-project-mcp.test.ts +++ b/tests/e2e/copilot-project-mcp.test.ts @@ -32,7 +32,7 @@ describe('copilot project-scoped MCP sync e2e', () => { rmSync(testDir, { recursive: true, force: true }); }); - test('sync writes MCP servers to project .copilot/mcp-config.json when copilot client is configured', async () => { + test('sync writes MCP servers to project .github/mcp.json when copilot client is configured', async () => { writeFileSync( join(testDir, '.allagents', 'workspace.yaml'), `repositories: @@ -48,7 +48,7 @@ clients: expect(result.success).toBe(true); - const mcpConfigPath = join(testDir, '.copilot', 'mcp-config.json'); + const mcpConfigPath = join(testDir, '.github', 'mcp.json'); expect(existsSync(mcpConfigPath)).toBe(true); const mcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf-8')); @@ -80,7 +80,7 @@ clients: expect(result.success).toBe(true); expect(result.mcpResults?.copilot).toBeUndefined(); - const mcpConfigPath = join(testDir, '.copilot', 'mcp-config.json'); + const mcpConfigPath = join(testDir, '.github', 'mcp.json'); expect(existsSync(mcpConfigPath)).toBe(false); }); @@ -118,7 +118,7 @@ clients: expect(result3.mcpResults?.copilot?.removed).toBe(1); expect(result3.mcpResults?.copilot?.removedServers).toContain('deepwiki'); - const mcpConfig = JSON.parse(readFileSync(join(testDir, '.copilot', 'mcp-config.json'), 'utf-8')); + const mcpConfig = JSON.parse(readFileSync(join(testDir, '.github', 'mcp.json'), 'utf-8')); expect(mcpConfig.mcpServers.deepwiki).toBeUndefined(); }); }); diff --git a/tests/e2e/mcp-add-proxy.test.ts b/tests/e2e/mcp-add-proxy.test.ts index dc788577..829dcb7d 100644 --- a/tests/e2e/mcp-add-proxy.test.ts +++ b/tests/e2e/mcp-add-proxy.test.ts @@ -3,6 +3,13 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { load } from 'js-yaml'; +import { + type DummyMcpOAuthServer, + startDummyMcpOAuthServer, +} from '../helpers/dummy-mcp-oauth-server.js'; +import packageJson from '../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; interface CliResult { exitCode: number; @@ -10,23 +17,33 @@ interface CliResult { stderr: string; } -function runCli(workdir: string, homeDir: string, args: string[]): CliResult { +async function runCli( + workdir: string, + homeDir: string, + args: string[], + json = true, +): Promise { const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); - const proc = Bun.spawnSync(['bun', 'run', cliEntry, '--json', ...args], { + const proc = Bun.spawn( + ['bun', 'run', cliEntry, ...(json ? ['--json'] : []), ...args], + { cwd: workdir, env: { ...process.env, HOME: homeDir, + ALLAGENTS_TEST_HOME: homeDir, }, stderr: 'pipe', stdout: 'pipe', - }); + }, + ); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); - return { - exitCode: proc.exitCode, - stdout: new TextDecoder().decode(proc.stdout), - stderr: new TextDecoder().decode(proc.stderr), - }; + return { exitCode, stdout, stderr }; } function readWorkspaceConfig(workspaceDir: string): Record { @@ -36,23 +53,26 @@ function readWorkspaceConfig(workspaceDir: string): Record { >; } -describe('mcp add --proxy e2e', () => { +describe('mcp add HTTP client routing e2e', () => { let workspaceDir: string; let homeDir: string; + let dummy: DummyMcpOAuthServer; - beforeEach(() => { + beforeEach(async () => { workspaceDir = join(tmpdir(), `allagents-e2e-mcp-add-proxy-${Date.now()}-${Math.random().toString(36).slice(2)}`); homeDir = join(tmpdir(), `allagents-e2e-home-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(join(workspaceDir, '.allagents'), { recursive: true }); mkdirSync(homeDir, { recursive: true }); + dummy = await startDummyMcpOAuthServer({ requireAuth: false }); }); - afterEach(() => { + afterEach(async () => { + await dummy.stop(); rmSync(workspaceDir, { recursive: true, force: true }); rmSync(homeDir, { recursive: true, force: true }); }); - test('adds deepwiki with proxy enabled for all configured MCP clients', () => { + test('adds deepwiki with proxy enabled for all configured MCP clients', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), `repositories: [] @@ -66,12 +86,11 @@ clients: 'utf-8', ); - const result = runCli(workspaceDir, homeDir, [ + const result = await runCli(workspaceDir, homeDir, [ 'mcp', 'add', 'deepwiki', - 'https://mcp.deepwiki.com/mcp', - '--proxy', + dummy.mcpUrl, ]); expect(result.exitCode).toBe(0); @@ -80,38 +99,49 @@ clients: const workspace = readWorkspaceConfig(workspaceDir); expect(workspace.mcpServers).toEqual({ - deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, + deepwiki: { type: 'http', url: dummy.mcpUrl }, }); expect(workspace.mcpProxy).toEqual({ - clients: [], servers: { deepwiki: { - proxy: ['claude', 'codex', 'vscode', 'copilot'], + proxy: ['*'], }, }, }); const claudeConfig = JSON.parse(readFileSync(join(workspaceDir, '.mcp.json'), 'utf-8')); - expect(claudeConfig.mcpServers.deepwiki.command).toBe('allagents'); + expect(claudeConfig.mcpServers.deepwiki.command).toBe('npx'); expect(claudeConfig.mcpServers.deepwiki.args).toEqual([ + '-y', + packageRef, 'mcp', 'proxy', - 'https://mcp.deepwiki.com/mcp', + dummy.mcpUrl, ]); const codexConfig = readFileSync(join(workspaceDir, '.codex', 'config.toml'), 'utf-8'); - expect(codexConfig).toContain('proxy'); - expect(codexConfig).toContain('https://mcp.deepwiki.com/mcp'); + expect(codexConfig).toContain('npx'); + expect(codexConfig).toContain(packageRef); const vscodeConfig = JSON.parse(readFileSync(join(workspaceDir, '.vscode', 'mcp.json'), 'utf-8')); - expect(vscodeConfig.servers.deepwiki.command).toBe('allagents'); - expect(vscodeConfig.servers.deepwiki.args[0]).toBe('mcp'); + expect(vscodeConfig.servers.deepwiki.command).toBe('npx'); + expect(vscodeConfig.servers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); - const copilotConfig = JSON.parse(readFileSync(join(workspaceDir, '.copilot', 'mcp-config.json'), 'utf-8')); - expect(copilotConfig.mcpServers.deepwiki.command).toBe('allagents'); - expect(copilotConfig.mcpServers.deepwiki.args[0]).toBe('mcp'); + const copilotConfig = JSON.parse(readFileSync(join(workspaceDir, '.github', 'mcp.json'), 'utf-8')); + expect(copilotConfig.mcpServers.deepwiki.command).toBe('npx'); + expect(copilotConfig.mcpServers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); - const rerun = runCli(workspaceDir, homeDir, ['mcp', 'update']); + const rerun = await runCli(workspaceDir, homeDir, ['mcp', 'update']); expect(rerun.exitCode).toBe(0); const rerunPayload = JSON.parse(rerun.stdout); expect(rerunPayload.success).toBe(true); @@ -119,9 +149,9 @@ clients: expect(rerunPayload.data.mcpResults.codex.added).toBe(0); expect(rerunPayload.data.mcpResults.vscode.added).toBe(0); expect(rerunPayload.data.mcpResults.copilot.added).toBe(0); - }); + }, 15_000); - test('scopes proxying to selected clients with --client', () => { + test('scopes proxying to selected clients with --client', async () => { writeFileSync( join(workspaceDir, '.allagents', 'workspace.yaml'), `repositories: [] @@ -134,12 +164,11 @@ clients: 'utf-8', ); - const result = runCli(workspaceDir, homeDir, [ + const result = await runCli(workspaceDir, homeDir, [ 'mcp', 'add', 'secure-api', - 'https://api.example.com/mcp', - '--proxy', + dummy.mcpUrl, '--client', 'claude,codex', ]); @@ -150,12 +179,11 @@ clients: expect(workspace.mcpServers).toEqual({ 'secure-api': { type: 'http', - url: 'https://api.example.com/mcp', + url: dummy.mcpUrl, clients: ['claude', 'codex'], }, }); expect(workspace.mcpProxy).toEqual({ - clients: [], servers: { 'secure-api': { proxy: ['claude', 'codex'], @@ -167,4 +195,384 @@ clients: expect(existsSync(join(workspaceDir, '.codex', 'config.toml'))).toBe(true); expect(existsSync(join(workspaceDir, '.vscode', 'mcp.json'))).toBe(false); }); + + test('accepts repeatable and comma-compatible client selectors', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + `repositories: [] +plugins: [] +clients: + - claude + - codex + - copilot +`, + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--client', + 'claude,codex', + '--client', + 'copilot', + '--client', + 'codex', + ]); + + expect(result.exitCode).toBe(0); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toEqual({ + local: { + type: 'stdio', + command: 'local-mcp', + clients: ['claude', 'codex', 'copilot'], + }, + }); + }); + + + test('rejects empty client segments before mutation', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - codex\n', + 'utf8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--client', + 'codex,', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain('empty segments'); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + + test('routes ordinary user declarations and output with --scope user', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients: []\n', + 'utf-8', + ); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + join(homeDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients:\n - copilot\n', + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--scope', + 'user', + ]); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expect(payload.data.destination).toEqual({ kind: 'user' }); + expect( + readWorkspaceConfig(workspaceDir).mcpServers, + ).toBeUndefined(); + const userConfig = load( + readFileSync(join(homeDir, '.allagents', 'workspace.yaml'), 'utf8'), + ) as Record; + expect(userConfig.mcpServers).toEqual({ + local: { type: 'stdio', command: 'local-mcp' }, + }); + expect( + JSON.parse( + readFileSync(join(homeDir, '.copilot', 'mcp-config.json'), 'utf8'), + ).mcpServers.local, + ).toEqual({ + type: 'stdio', + command: 'local-mcp', + }); + }); + + test('manages a declared profile without implicitly installing it', async () => { + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + join(homeDir, '.allagents', 'workspace.yaml'), + `profiles: + markets: + clients: + - name: codex + - name: copilot +`, + 'utf-8', + ); + + const add = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--profile', + 'markets', + '--client', + 'codex', + '--client', + 'copilot', + ]); + expect(add.exitCode).toBe(0); + const addPayload = JSON.parse(add.stdout); + expect(addPayload.data.destination).toEqual({ + kind: 'profile', + name: 'markets', + }); + expect(addPayload.data.sync).toEqual({ status: 'not-installed' }); + + const list = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'list', + '--profile', + 'markets', + ]); + expect(list.exitCode).toBe(0); + expect(JSON.parse(list.stdout).data.servers).toEqual({ + local: { + type: 'stdio', + command: 'local-mcp', + clients: ['codex', 'copilot'], + }, + }); + + const get = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'get', + 'local', + '--profile', + 'markets', + ]); + expect(get.exitCode).toBe(0); + expect(JSON.parse(get.stdout).data.destination).toEqual({ + kind: 'profile', + name: 'markets', + }); + + const remove = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'remove', + 'local', + '--profile', + 'markets', + ]); + expect(remove.exitCode).toBe(0); + const userConfig = load( + readFileSync(join(homeDir, '.allagents', 'workspace.yaml'), 'utf8'), + ) as { + profiles: Record>; + }; + expect(userConfig.profiles.markets.mcpServers).toBeUndefined(); + }, 15_000); + + test('rejects combining --scope and --profile before mutation', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: []\nplugins: []\nclients: []\n', + 'utf8', + ); + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'local', + 'local-mcp', + '--scope', + 'user', + '--profile', + 'markets', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain( + '--scope and --profile cannot be used together', + ); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + + test('fails before mutation when a non-interactive HTTP preflight cannot connect', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + `repositories: [] +plugins: [] +clients: + - claude +`, + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'unreachable', + 'http://127.0.0.1:1/mcp', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).success).toBe(false); + expect(readWorkspaceConfig(workspaceDir).mcpServers).toBeUndefined(); + }); + + test('redacts credential values from list and get JSON output', async () => { + const userConfigPath = join(homeDir, '.allagents', 'workspace.yaml'); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + userConfigPath, + `repositories: [] +plugins: [] +clients: [] +mcpServers: + secure-http: + url: https://user:password@example.com/mcp?accessToken=camel-query-secret&apiKey=api-query-secret&key=generic-query-secret + headers: + Authorization: Bearer header-secret + secure-stdio: + command: local-mcp + args: + - --token + - arg-secret + - --apiKey=inline-secret + - https://example.com/callback?accessToken=arg-query-secret + - --key + - generic-arg-secret + - key=generic-inline-secret + env: + API_TOKEN: env-secret +`, + 'utf8', + ); + + const list = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'list', + '--scope', + 'user', + ]); + expect(list.exitCode).toBe(0); + expect(list.stdout).not.toContain('header-secret'); + expect(list.stdout).not.toContain('env-secret'); + expect(list.stdout).not.toContain('camel-query-secret'); + expect(list.stdout).not.toContain('api-query-secret'); + expect(list.stdout).not.toContain('arg-secret'); + expect(list.stdout).not.toContain('inline-secret'); + expect(list.stdout).not.toContain('arg-query-secret'); + expect(list.stdout).not.toContain('generic-query-secret'); + expect(list.stdout).not.toContain('generic-arg-secret'); + expect(list.stdout).not.toContain('generic-inline-secret'); + const listPayload = JSON.parse(list.stdout); + expect(listPayload.data.servers['secure-http'].headers.Authorization).toBe( + '[REDACTED]', + ); + expect(listPayload.data.servers['secure-stdio'].env.API_TOKEN).toBe( + '[REDACTED]', + ); + expect(listPayload.data.servers['secure-stdio'].args).toEqual([ + '--token', + '[REDACTED]', + '--apiKey=[REDACTED]', + 'https://example.com/callback?accessToken=[REDACTED]', + '--key', + '[REDACTED]', + 'key=[REDACTED]', + ]); + + const get = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'get', + 'secure-http', + '--scope', + 'user', + ]); + expect(get.exitCode).toBe(0); + expect(get.stdout).not.toContain('header-secret'); + expect(get.stdout).not.toContain('password'); + expect(get.stdout).not.toContain('camel-query-secret'); + expect(get.stdout).not.toContain('api-query-secret'); + expect(get.stdout).not.toContain('generic-query-secret'); + expect(JSON.parse(get.stdout).data.config.headers.Authorization).toBe( + '[REDACTED]', + ); + + const humanList = await runCli( + workspaceDir, + homeDir, + ['mcp', 'list', '--scope', 'user'], + false, + ); + expect(humanList.exitCode).toBe(0); + expect(humanList.stdout).toContain('[REDACTED]'); + expect(humanList.stdout).not.toContain('header-secret'); + expect(humanList.stdout).not.toContain('env-secret'); + expect(humanList.stdout).not.toContain('camel-query-secret'); + expect(humanList.stdout).not.toContain('api-query-secret'); + expect(humanList.stdout).not.toContain('arg-secret'); + expect(humanList.stdout).not.toContain('inline-secret'); + expect(humanList.stdout).not.toContain('arg-query-secret'); + expect(humanList.stdout).not.toContain('generic-query-secret'); + expect(humanList.stdout).not.toContain('generic-arg-secret'); + expect(humanList.stdout).not.toContain('generic-inline-secret'); + }); + + test('rejects invalid profile server names before HTTP preflight or persistence', async () => { + const userConfigPath = join(homeDir, '.allagents', 'workspace.yaml'); + mkdirSync(join(homeDir, '.allagents'), { recursive: true }); + writeFileSync( + userConfigPath, + `repositories: [] +plugins: [] +clients: [] +profiles: + markets: + clients: + - name: codex +`, + 'utf8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'invalid/name', + dummy.mcpUrl, + '--profile', + 'markets', + ]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout).error).toContain('Expected 1-100 ASCII'); + expect(dummy.mcpRequestHeaders).toHaveLength(0); + const userConfig = load(readFileSync(userConfigPath, 'utf8')) as { + profiles: Record>; + }; + expect(userConfig.profiles.markets.mcpServers).toBeUndefined(); + }); + + test('returns a structured error for malformed workspace config', async () => { + writeFileSync( + join(workspaceDir, '.allagents', 'workspace.yaml'), + 'repositories: [', + 'utf-8', + ); + + const result = await runCli(workspaceDir, homeDir, [ + 'mcp', + 'add', + 'example', + 'https://example.com/mcp', + ]); + + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout); + expect(payload.success).toBe(false); + expect(payload.command).toBe('mcp add'); + }); }); diff --git a/tests/e2e/mcp-proxy-command.test.ts b/tests/e2e/mcp-proxy-command.test.ts index 6043e34b..14db69b1 100644 --- a/tests/e2e/mcp-proxy-command.test.ts +++ b/tests/e2e/mcp-proxy-command.test.ts @@ -1,11 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { join } from 'node:path'; -function runCli(args: string[]) { +function runCli(args: string[], env: Record = {}) { const cliEntry = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); const proc = Bun.spawnSync(['bun', 'run', cliEntry, ...args], { cwd: process.cwd(), - env: process.env, + env: { ...process.env, ...env }, stderr: 'pipe', stdout: 'pipe', }); @@ -17,15 +17,271 @@ function runCli(args: string[]) { }; } -describe('mcp proxy command help', () => { - test('lists proxy as the canonical subcommand', () => { +describe('mcp public command help', () => { + test('lists setup and reauthentication without exposing the proxy helper', () => { const result = runCli(['mcp', '--help']); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain('- proxy - Expose a remote HTTP MCP server locally over stdio'); + expect(result.stdout).toContain('- add - Add an MCP server'); + expect(result.stdout).toContain('- reauth - Reauthenticate a configured HTTP MCP server'); + expect(result.stdout).not.toContain('- auth -'); + expect(result.stdout).not.toContain('- proxy -'); expect(result.stdout).not.toContain('- proxy-stdio -'); }); + test('keeps proxy hidden when help uses ANSI color', () => { + const result = runCli(['mcp', '--help'], { FORCE_COLOR: '1' }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain('Expose a remote HTTP MCP server locally over stdio'); + }); + + test('exposes complete leaf context for add and reauth', () => { + const addResult = runCli(['mcp', 'add', '--help', '--json']); + const reauthResult = runCli(['--json', 'mcp', 'reauth', '-h']); + + expect(addResult.exitCode).toBe(0); + expect(JSON.parse(addResult.stdout)).toMatchObject({ + command: 'mcp add', + description: expect.any(String), + when_to_use: expect.any(String), + expected_output: expect.stringContaining('reconciles installed clients'), + interaction: 'conditional', + positionals: expect.arrayContaining([ + expect.objectContaining({ name: 'name', required: true }), + expect.objectContaining({ name: 'commandOrUrl', required: true }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ flag: '--scope' }), + expect.objectContaining({ flag: '--profile' }), + ]), + examples: expect.arrayContaining([ + expect.stringContaining('allagents mcp add'), + ]), + output_schema: expect.objectContaining({ + destination: expect.any(Object), + name: 'string', + }), + json_fields: ['destination', 'name', 'config', 'mcpResults', 'sync'], + }); + expect(reauthResult.exitCode).toBe(0); + expect(JSON.parse(reauthResult.stdout)).toMatchObject({ + command: 'mcp reauth', + interaction: 'required', + expected_output: expect.stringContaining('Clears cached OAuth credentials'), + json_fields: [], + }); + }); + + test('keeps exact bare command help human-readable', () => { + const result = runCli(['mcp', 'add', '--help']); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Add an MCP server'); + expect(result.stdout).toContain('--arg'); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('resolves structured help from the longest command prefix', () => { + const positionalResult = runCli([ + 'skill', + 'search', + 'terraform', + '--help', + '--json', + ]); + const optionResult = runCli([ + 'mcp', + 'list', + '--scope', + 'user', + '--help', + '--json', + ]); + + expect(positionalResult.exitCode).toBe(0); + expect(JSON.parse(positionalResult.stdout)).toMatchObject({ + command: 'skill search', + positionals: [{ name: 'query', required: true }], + output_schema: { total: 'number' }, + }); + expect(optionResult.exitCode).toBe(0); + expect(JSON.parse(optionResult.stdout).command).toBe('mcp list'); + }); + + test('applies jq to the existing bare structured-help value', () => { + const result = runCli(['--help', '--json', '--jq', '.name']); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('"allagents"'); + }); + + test('progressively discloses workspace commands and nested repo commands', () => { + const groupResult = runCli(['workspace', '--help', '--json']); + const repoGroupResult = runCli([ + 'workspace', + 'repo', + '--help', + '--json', + ]); + const repoResult = runCli([ + 'workspace', + 'repo', + 'add', + '../project', + '--help', + '--json', + ]); + + expect(groupResult.exitCode).toBe(0); + const group = JSON.parse(groupResult.stdout) as { + commands: Array<{ + command: string; + kind: 'command' | 'group'; + help_command: string; + }>; + }; + expect(group.commands).toEqual([ + expect.objectContaining({ command: 'workspace init', kind: 'command' }), + expect.objectContaining({ command: 'workspace setup', kind: 'command' }), + expect.objectContaining({ command: 'workspace sync', kind: 'command' }), + expect.objectContaining({ command: 'workspace status', kind: 'command' }), + expect.objectContaining({ command: 'workspace prune', kind: 'command' }), + expect.objectContaining({ + command: 'workspace repo', + kind: 'group', + help_command: 'allagents workspace repo --help --json', + }), + ]); + for (const command of group.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + } + expect(repoGroupResult.exitCode).toBe(0); + const repoGroup = JSON.parse(repoGroupResult.stdout) as { + commands: Array<{ command: string; help_command: string }>; + }; + expect(repoGroup.commands.map(({ command }) => command)).toEqual([ + 'workspace repo add', + 'workspace repo remove', + 'workspace repo list', + ]); + for (const command of repoGroup.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + } + expect(repoResult.exitCode).toBe(0); + expect(JSON.parse(repoResult.stdout)).toMatchObject({ + command: 'workspace repo add', + positionals: [{ name: 'path', required: true }], + output_schema: { repo: 'string | null' }, + expected_output: expect.any(String), + interaction: 'none', + }); + }); + + test('covers canonical and compatibility skill command paths', () => { + const pluginGroupResult = runCli([ + 'plugin', + 'skills', + '--help', + '--json', + ]); + const pluginLeafResult = runCli([ + 'plugin', + 'skills', + 'list', + '--help', + '--json', + ]); + const pluralAliasResult = runCli(['skills', 'list', '--help', '--json']); + + expect(pluginGroupResult.exitCode).toBe(0); + const pluginGroup = JSON.parse(pluginGroupResult.stdout) as { + commands: Array<{ command: string }>; + }; + expect(pluginGroup.commands.map(({ command }) => command)).toEqual([ + 'plugin skills list', + 'plugin skills add', + 'plugin skills remove', + 'plugin skills search', + 'plugin skills update', + ]); + expect(pluginLeafResult.exitCode).toBe(0); + expect(JSON.parse(pluginLeafResult.stdout).command).toBe( + 'plugin skills list', + ); + expect(pluralAliasResult.exitCode).toBe(0); + expect(JSON.parse(pluralAliasResult.stdout).command).toBe('skill list'); + }); + + test('does not treat a registered string option value as structured help', () => { + const result = runCli(['mcp', 'add', '--arg', '--help', '--json']); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('does not treat positional help after -- as structured help', () => { + const result = runCli(['--json', 'mcp', 'add', '--', '--help']); + + expect(result.exitCode).not.toBe(0); + expect(result.stdout).not.toContain('"when_to_use"'); + }); + + test('exposes a concise root index before group and leaf details', () => { + const result = runCli(['--help', '--json']); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout) as { + name: string; + commands: Array<{ + command: string; + kind: 'command' | 'group'; + help_command: string; + options?: unknown; + output_schema?: unknown; + }>; + }; + expect(parsed.name).toBe('allagents'); + expect(parsed.commands.map(({ command }) => command)).toEqual([ + 'init', + 'update', + 'status', + 'workspace', + 'mcp', + 'plugin', + 'skill', + 'self', + 'profile', + ]); + expect(parsed.commands).toContainEqual( + expect.objectContaining({ + command: 'mcp', + kind: 'group', + help_command: 'allagents mcp --help --json', + }), + ); + for (const command of parsed.commands) { + expect(command.help_command).toBe( + `allagents ${command.command} --help --json`, + ); + expect(command.options).toBeUndefined(); + expect(command.output_schema).toBeUndefined(); + } + }); + + test('rejects field selection for structured JSON help', () => { + const result = runCli(['mcp', 'add', '--help', '--json=command']); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + '--json= is not supported with --help; use --json', + ); + }); + test('rejects proxy-stdio after the rename', () => { const result = runCli(['mcp', 'proxy-stdio']); diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts index 641905c4..1830351a 100644 --- a/tests/e2e/mcp-proxy-oauth.test.ts +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { hashServerUrl } from '../../src/core/mcp-http-stdio-proxy.ts'; +import { + connectHttpMcpServer, + getMcpOAuthCacheDir, + hashServerUrl, +} from '../../src/core/mcp-http-stdio-proxy.ts'; import { type DummyMcpOAuthServer, FIXTURE_ANSWER, @@ -34,6 +38,8 @@ function connectAndAutoAuthorize( describe('mcp proxy OAuth e2e', () => { let homeDir: string; let dummy: DummyMcpOAuthServer | undefined; + const originalTestHome = process.env.ALLAGENTS_TEST_HOME; + const originalNoBrowser = process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER; beforeEach(() => { homeDir = join( @@ -41,12 +47,24 @@ describe('mcp proxy OAuth e2e', () => { `allagents-e2e-oauth-home-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); mkdirSync(homeDir, { recursive: true }); + process.env.ALLAGENTS_TEST_HOME = homeDir; + process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER = '1'; }); afterEach(async () => { rmSync(homeDir, { recursive: true, force: true }); await dummy?.stop(); dummy = undefined; + if (originalTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalTestHome; + } + if (originalNoBrowser === undefined) { + delete process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER; + } else { + process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER = originalNoBrowser; + } }); test('completes OAuth and calls a tool on the first connection', async () => { @@ -83,6 +101,246 @@ describe('mcp proxy OAuth e2e', () => { } }, 15000); + test('completes OAuth from a callback URL pasted on a headless host', async () => { + dummy = await startDummyMcpOAuthServer(); + const previousTestHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = homeDir; + + try { + const resourceSecret = 'resource-server-only'; + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: async ({ authorizationUrl }) => { + const response = await fetch(authorizationUrl, { + redirect: 'manual', + }); + expect(response.status).toBe(302); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }, + headers: { 'x-resource-secret': resourceSecret }, + }); + + expect(dummy.authorizeCallCount).toBe(1); + expect(dummy.tokenCallCounts.authorization_code).toBe(1); + expect( + dummy.mcpRequestHeaders.some( + (headers) => headers['x-resource-secret'] === resourceSecret, + ), + ).toBe(true); + expect( + dummy.idpRequestHeaders.every( + (headers) => headers['x-resource-secret'] === undefined, + ), + ).toBe(true); + expect(dummy.activeSessionCount).toBe(0); + const connection = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + await connection.close(); + expect(dummy.authorizeCallCount).toBe(1); + } finally { + if (previousTestHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = previousTestHome; + } + } + }, 15000); + + test('accepts a local callback while the remote paste fallback is pending', async () => { + dummy = await startDummyMcpOAuthServer(); + let fallbackAborted = false; + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: ({ authorizationUrl, signal }) => { + void fetch(authorizationUrl); + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + fallbackAborted = true; + reject(new Error('Local callback completed')); + }, + { once: true }, + ); + }); + }, + }); + + expect(fallbackAborted).toBe(true); + expect(dummy.authorizeCallCount).toBe(1); + }, 15000); + + test('routes authorization guidance through the configured output', async () => { + dummy = await startDummyMcpOAuthServer(); + const output: string[] = []; + + await connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: (message) => output.push(message), + callbackUrlReader: async ({ authorizationUrl }) => { + const response = await fetch(authorizationUrl, { + redirect: 'manual', + }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }, + }); + + expect(output[0]).toBe('Opening browser for authorization...'); + expect(output[1]).toStartWith( + 'If the browser does not open, visit: http', + ); + expect(output[2]).toBe( + 'Using a remote browser? Paste its callback URL in this terminal.', + ); + }, 15000); + + test('forces a fresh OAuth flow when credentials are reset', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(1); + + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + resetCredentials: true, + }); + expect(dummy.authorizeCallCount).toBe(2); + }, 15000); + + test('restores previous credentials when a reset authorization fails', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: () => {}, + callbackUrlReader: authorize, + }); + const tokensPath = join(getMcpOAuthCacheDir(dummy.mcpUrl), 'tokens.json'); + const previousTokens = readFileSync(tokensPath, 'utf8'); + + await expect( + connectHttpMcpServer(dummy.mcpUrl, { + authorizationOutput: () => {}, + callbackUrlReader: async () => { + throw new Error('Authorization cancelled'); + }, + resetCredentials: true, + }), + ).rejects.toThrow('Authorization cancelled'); + + expect(readFileSync(tokensPath, 'utf8')).toBe(previousTokens); + const authorizationCount = dummy.authorizeCallCount; + await connectHttpMcpServer(dummy.mcpUrl, { + allowAuthorization: false, + authorizationOutput: () => {}, + }); + expect(dummy.authorizeCallCount).toBe(authorizationCount); + }, 15000); + + test('isolates OAuth reuse and reset between profiles and ordinary scope', async () => { + dummy = await startDummyMcpOAuthServer(); + const authorize = async ({ authorizationUrl }: { authorizationUrl: URL }) => { + const response = await fetch(authorizationUrl, { redirect: 'manual' }); + const location = response.headers.get('location'); + expect(location).toBeTruthy(); + return new URL(location!, authorizationUrl).toString(); + }; + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + }); + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(1); + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'research', + callbackUrlReader: authorize, + }); + await connectHttpMcpServer(dummy.mcpUrl, { + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(3); + expect( + readFileSync( + join(getMcpOAuthCacheDir(dummy.mcpUrl, 'markets'), 'tokens.json'), + 'utf8', + ), + ).toContain('access_token'); + expect( + readFileSync( + join(getMcpOAuthCacheDir(dummy.mcpUrl, 'research'), 'tokens.json'), + 'utf8', + ), + ).toContain('access_token'); + expect( + readFileSync(join(getMcpOAuthCacheDir(dummy.mcpUrl), 'tokens.json'), 'utf8'), + ).toContain('access_token'); + + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'markets', + callbackUrlReader: authorize, + resetCredentials: true, + }); + expect(dummy.authorizeCallCount).toBe(4); + await connectHttpMcpServer(dummy.mcpUrl, { + profile: 'research', + callbackUrlReader: authorize, + }); + expect(dummy.authorizeCallCount).toBe(4); + }, 20000); + + test('fails without prompting when authorization is disabled', async () => { + dummy = await startDummyMcpOAuthServer(); + + await expect( + connectHttpMcpServer(dummy.mcpUrl, { + allowAuthorization: false, + }), + ).rejects.toThrow('OAuth authorization requires an interactive terminal'); + expect(dummy.authorizeCallCount).toBe(0); + }, 15000); + + test('resolves header environment references only at connection time', async () => { + dummy = await startDummyMcpOAuthServer({ requireAuth: false }); + const originalToken = process.env.TRADINGVIEW_TOKEN; + process.env.TRADINGVIEW_TOKEN = 'runtime-secret'; + try { + await connectHttpMcpServer(dummy.mcpUrl, { + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + allowAuthorization: false, + }); + expect( + dummy.mcpRequestHeaders.some( + (headers) => headers.authorization === 'runtime-secret', + ), + ).toBe(true); + } finally { + if (originalToken === undefined) { + delete process.env.TRADINGVIEW_TOKEN; + } else { + process.env.TRADINGVIEW_TOKEN = originalToken; + } + } + }, 15000); + test('reuses the cached token on a second connection without re-authorizing', async () => { dummy = await startDummyMcpOAuthServer(); diff --git a/tests/e2e/plugin-skills.test.ts b/tests/e2e/plugin-skills.test.ts index 10335f97..968c58d2 100644 --- a/tests/e2e/plugin-skills.test.ts +++ b/tests/e2e/plugin-skills.test.ts @@ -333,9 +333,7 @@ description: Blog watcher existsSync(join(tmpDir, '.github', 'skills', 'ediprod', 'SKILL.md')), ).toBe(true); expect(existsSync(join(tmpDir, '.github', 'hooks'))).toBe(false); - expect(existsSync(join(tmpDir, '.copilot', 'mcp-config.json'))).toBe( - false, - ); + expect(existsSync(join(tmpDir, '.github', 'mcp.json'))).toBe(false); } finally { if (originalTestHome === undefined) { delete process.env.ALLAGENTS_TEST_HOME; diff --git a/tests/helpers/dummy-mcp-oauth-server.ts b/tests/helpers/dummy-mcp-oauth-server.ts index e3af80e5..54543638 100644 --- a/tests/helpers/dummy-mcp-oauth-server.ts +++ b/tests/helpers/dummy-mcp-oauth-server.ts @@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type Server as HttpServer, + type IncomingHttpHeaders, type ServerResponse, } from 'node:http'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; @@ -36,6 +37,8 @@ interface TokenRecord { export interface StartDummyMcpOAuthServerOptions { /** Access token lifetime in ms. Short values let tests exercise the refresh path. */ accessTokenTtlMs?: number; + /** Disable authentication when a test only needs a reachable HTTP MCP server. */ + requireAuth?: boolean; } export interface DummyMcpOAuthServer { @@ -43,6 +46,9 @@ export interface DummyMcpOAuthServer { idpIssuer: string; readonly authorizeCallCount: number; readonly tokenCallCounts: { authorization_code: number; refresh_token: number }; + readonly idpRequestHeaders: ReadonlyArray; + readonly mcpRequestHeaders: ReadonlyArray; + readonly activeSessionCount: number; stop(): Promise; } @@ -104,6 +110,7 @@ export async function startDummyMcpOAuthServer( options: StartDummyMcpOAuthServerOptions = {}, ): Promise { const accessTokenTtlMs = options.accessTokenTtlMs ?? 60_000; + const requireAuth = options.requireAuth ?? true; const registeredClientIds = new Set(); const authCodes = new Map(); @@ -114,6 +121,8 @@ export async function startDummyMcpOAuthServer( authorizeCallCount: 0, tokenCallCounts: { authorization_code: 0, refresh_token: 0 }, }; + const idpRequestHeaders: IncomingHttpHeaders[] = []; + const mcpRequestHeaders: IncomingHttpHeaders[] = []; let idpIssuer = ''; let mcpUrl = ''; @@ -126,6 +135,7 @@ export async function startDummyMcpOAuthServer( req: IncomingMessage, res: ServerResponse, ): Promise { + idpRequestHeaders.push({ ...req.headers }); const url = new URL(req.url ?? '/', idpIssuer); if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') { @@ -308,6 +318,7 @@ export async function startDummyMcpOAuthServer( req: IncomingMessage, res: ServerResponse, ): Promise { + mcpRequestHeaders.push({ ...req.headers }); const url = new URL(req.url ?? '/', mcpUrl); if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') { @@ -326,7 +337,7 @@ export async function startDummyMcpOAuthServer( const record = token ? accessTokens.get(token) : undefined; const isValid = record !== undefined && record.expiresAt > Date.now(); - if (!isValid) { + if (requireAuth && !isValid) { res.writeHead(401, { 'content-type': 'text/plain', 'www-authenticate': `Bearer resource_metadata="${mcpUrl}/.well-known/oauth-protected-resource"`, @@ -363,6 +374,15 @@ export async function startDummyMcpOAuthServer( get tokenCallCounts() { return counters.tokenCallCounts; }, + get idpRequestHeaders() { + return idpRequestHeaders; + }, + get mcpRequestHeaders() { + return mcpRequestHeaders; + }, + get activeSessionCount() { + return sessions.size; + }, async stop() { await Promise.all([...sessions.values()].map((transport) => transport.close())); await Promise.all([close(idpServer), close(mcpHttpServer)]); diff --git a/tests/unit/cli/index-json-fields.test.ts b/tests/unit/cli/index-json-fields.test.ts index ad830040..99b38b30 100644 --- a/tests/unit/cli/index-json-fields.test.ts +++ b/tests/unit/cli/index-json-fields.test.ts @@ -25,4 +25,27 @@ describe('CLI JSON field validation', () => { expect(proc.stderr.toString()).toContain('Available fields:'); expect(proc.stdout.toString()).toBe(''); }); + + test('derives JSON fields from the command output schema', () => { + const proc = Bun.spawnSync( + [ + 'bun', + 'run', + cliEntry, + '--json=definitely-invalid', + 'mcp', + 'list', + ], + { stdout: 'pipe', stderr: 'pipe' }, + ); + + expect(proc.exitCode).toBe(2); + expect(proc.stderr.toString()).toContain( + 'Unknown JSON field: "definitely-invalid"', + ); + expect(proc.stderr.toString()).toContain(' destination'); + expect(proc.stderr.toString()).toContain(' servers'); + expect(proc.stderr.toString()).toContain(' total'); + expect(proc.stdout.toString()).toBe(''); + }); }); diff --git a/tests/unit/cli/profile-command.test.ts b/tests/unit/cli/profile-command.test.ts index ce6ff407..8dda2b0a 100644 --- a/tests/unit/cli/profile-command.test.ts +++ b/tests/unit/cli/profile-command.test.ts @@ -838,7 +838,7 @@ describe('profile formatting', () => { }); describe('profile root registration', () => { - test('is present in root help and agent help', () => { + test('is present in root help and structured JSON help', () => { const rootHelp = Bun.spawnSync( ['bun', 'run', cliEntry, '--help'], { stdout: 'pipe', stderr: 'pipe' }, @@ -846,12 +846,12 @@ describe('profile root registration', () => { expect(rootHelp.exitCode).toBe(0); expect(rootHelp.stdout.toString()).toContain('profile'); - const agentHelp = Bun.spawnSync( - ['bun', 'run', cliEntry, '--agent-help', 'profile'], + const structuredHelp = Bun.spawnSync( + ['bun', 'run', cliEntry, 'profile', '--help', '--json'], { stdout: 'pipe', stderr: 'pipe' }, ); - expect(agentHelp.exitCode).toBe(0); - const parsed = JSON.parse(agentHelp.stdout.toString()) as { + expect(structuredHelp.exitCode).toBe(0); + const parsed = JSON.parse(structuredHelp.stdout.toString()) as { commands: Array<{ command: string }>; }; expect(parsed.commands.map((entry) => entry.command)).toEqual([ diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/structured-help.test.ts similarity index 79% rename from tests/unit/cli/agent-help.test.ts rename to tests/unit/cli/structured-help.test.ts index b910c1f8..7c8de23f 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/structured-help.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test'; -import { extractAgentHelpFlag, findMetaByCommand } from '../../../src/cli/agent-help.js'; +import { findMetaByCommand } from '../../../src/cli/structured-help.js'; import { initMeta, setupMeta, @@ -51,32 +51,6 @@ const allCommands: AgentCommandMeta[] = [ updateMeta, ]; -describe('extractAgentHelpFlag', () => { - test('returns agentHelp false when flag is absent', () => { - const result = extractAgentHelpFlag(['workspace', 'sync']); - expect(result.agentHelp).toBe(false); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from end of args', () => { - const result = extractAgentHelpFlag(['workspace', 'sync', '--agent-help']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from beginning of args', () => { - const result = extractAgentHelpFlag(['--agent-help', 'workspace', 'sync']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); - - test('strips --agent-help from middle of args', () => { - const result = extractAgentHelpFlag(['workspace', '--agent-help', 'sync']); - expect(result.agentHelp).toBe(true); - expect(result.args).toEqual(['workspace', 'sync']); - }); -}); - describe('agent command metadata', () => { test('contains exactly 20 commands', () => { expect(allCommands.length).toBe(20); @@ -202,10 +176,29 @@ describe('findMetaByCommand', () => { expect(meta!.command).toBe('status'); }); - test('resolves deprecated "workspace status" alias to status meta', () => { - const meta = findMetaByCommand('workspace status'); - expect(meta).toBeDefined(); - expect(meta!.command).toBe('status'); + test('resolves the public MCP setup commands', () => { + const addMeta = findMetaByCommand('mcp add tradingview https://mcp.tradingview.com/mcp'); + const reauthMeta = findMetaByCommand('mcp reauth tradingview'); + expect(addMeta?.command).toBe('mcp add'); + expect(reauthMeta?.command).toBe('mcp reauth'); + expect(addMeta?.interaction).toBe('conditional'); + expect(addMeta?.outputSchema).toBeDefined(); + expect(reauthMeta?.interaction).toBe('required'); + expect(findMetaByCommand('mcp list')?.outputSchema).toMatchObject({ + total: 'number', + }); + expect(findMetaByCommand('mcp get')?.outputSchema).toMatchObject({ + name: 'string', + }); + expect(findMetaByCommand('mcp update')?.outputSchema).toBeDefined(); + }); + + test('resolves public workspace aliases to their shared metadata', () => { + expect(findMetaByCommand('workspace init ./project')?.command).toBe('init'); + expect(findMetaByCommand('workspace sync --profile work')?.command).toBe( + 'update', + ); + expect(findMetaByCommand('workspace status')?.command).toBe('status'); }); test('resolves command metadata when rest-positionals follow the command', () => { @@ -213,6 +206,18 @@ describe('findMetaByCommand', () => { expect(meta?.command).toBe('skill update'); }); + test('resolves workspace-only commands exposed by the human command tree', () => { + expect(findMetaByCommand('workspace prune')?.command).toBe( + 'workspace prune', + ); + expect(findMetaByCommand('workspace repo add ../project')?.command).toBe( + 'workspace repo add', + ); + expect(findMetaByCommand('workspace repo list')?.outputSchema).toMatchObject( + { total: 'number' }, + ); + }); + test('returns undefined for unknown command', () => { expect(findMetaByCommand('workspace frobnicate')).toBeUndefined(); }); diff --git a/tests/unit/core/claude-mcp.test.ts b/tests/unit/core/claude-mcp.test.ts index 99d1ebed..d16e06cd 100644 --- a/tests/unit/core/claude-mcp.test.ts +++ b/tests/unit/core/claude-mcp.test.ts @@ -310,7 +310,83 @@ describe('syncClaudeMcpServersViaCli (user-scoped via CLI)', () => { expect(removeCall!.args).toContain('user'); }); - test('warns when claude CLI is not available', async () => { + test('retains ownership when removing an orphan fails', async () => { + const { fn } = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: true, + output: 'old-server: ...', + }, + 'claude mcp remove': { + success: false, + output: '', + error: 'temporary failure', + }, + }); + + const result = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: fn, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.removed).toBe(0); + }); + + + test('retains ownership when orphan inspection fails transiently', async () => { + const { fn } = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'permission denied', + }, + }); + + const result = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: fn, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.warnings[0]).toContain('Failed to inspect'); + }); + + test('only treats the exact Claude server-missing response as absence', async () => { + const misleading = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'config file not found', + }, + }); + const incomplete = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: misleading.fn, + }); + expect(incomplete.authoritative).toBe(false); + expect(incomplete.trackedServers).toEqual(['old-server']); + + const missing = mockExec({ + 'claude --version': { success: true, output: '1.0.0' }, + 'claude mcp get old-server': { + success: false, + output: '', + error: 'No MCP server found with name: old-server', + }, + }); + const authoritative = await syncClaudeMcpServersViaCli([], { + trackedServers: ['old-server'], + _mockExecute: missing.fn, + }); + expect(authoritative.authoritative).toBe(true); + expect(authoritative.trackedServers).toEqual([]); + }); + test('retains ownership when the claude CLI is not available', async () => { writeFileSync( join(pluginDir, '.mcp.json'), JSON.stringify({ mcpServers: { deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' } } }), @@ -321,10 +397,13 @@ describe('syncClaudeMcpServersViaCli (user-scoped via CLI)', () => { }); const result = await syncClaudeMcpServersViaCli([makePlugin(pluginDir)], { + trackedServers: ['owned-server'], _mockExecute: fn, }); expect(result.added).toBe(0); + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['owned-server']); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings[0]).toContain('Claude CLI not available'); }); diff --git a/tests/unit/core/codex-mcp.test.ts b/tests/unit/core/codex-mcp.test.ts index b3383382..5883ec39 100644 --- a/tests/unit/core/codex-mcp.test.ts +++ b/tests/unit/core/codex-mcp.test.ts @@ -169,15 +169,39 @@ describe('syncCodexMcpServers', () => { }; const result = await syncCodexMcpServers([makePlugin(pluginDir)], { + trackedServers: ['owned-server'], _mockExecute: mockExecute, }); expect(result.added).toBe(0); expect(result.removed).toBe(0); + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['owned-server']); expect(result.warnings.length).toBeGreaterThan(0); expect(result.warnings.some((w) => w.toLowerCase().includes('codex'))).toBe(true); }); + test('retains ownership when removing an orphan fails', async () => { + const mockExecute = (_binary: string, args: string[]): NativeCommandResult => { + if (args.includes('list')) { + return { + success: true, + output: JSON.stringify([{ name: 'old-server' }]), + }; + } + return { success: false, output: '', error: 'temporary failure' }; + }; + + const result = await syncCodexMcpServers([], { + trackedServers: ['old-server'], + _mockExecute: mockExecute, + }); + + expect(result.authoritative).toBe(false); + expect(result.trackedServers).toEqual(['old-server']); + expect(result.removed).toBe(0); + }); + test('skips codex CLI call when no plugins have MCP servers and nothing previously tracked', async () => { // Plugin has no .mcp.json let execCalled = false; diff --git a/tests/unit/core/mcp-http-stdio-proxy.test.ts b/tests/unit/core/mcp-http-stdio-proxy.test.ts index ed7e2ea6..d7826d11 100644 --- a/tests/unit/core/mcp-http-stdio-proxy.test.ts +++ b/tests/unit/core/mcp-http-stdio-proxy.test.ts @@ -1,13 +1,189 @@ -import { describe, expect, test } from 'bun:test'; -import { getBrowserOpenCommands } from '../../../src/core/mcp-http-stdio-proxy.js'; - -describe('getBrowserOpenCommands', () => { - test('uses explorer on Windows so OAuth URLs are not parsed by cmd', () => { - const url = - 'https://idp.example/auth?response_type=code&client_id=test&state=abc'; - - expect(getBrowserOpenCommands(url, 'win32')).toEqual([ - { command: 'explorer.exe', args: [url] }, - ]); - }); -}); \ No newline at end of file +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { + getBrowserOpenCommands, + getMcpOAuthCacheDir, + hashServerUrl, + parseOAuthCallbackUrl, + validateOAuthCallbackUrl, + resolveMcpHeaderReferences, +} from '../../../src/core/mcp-http-stdio-proxy.js'; + +describe('resolveMcpHeaderReferences', () => { + test('resolves exact environment references and preserves literal values', () => { + expect( + resolveMcpHeaderReferences( + { + Authorization: '${TRADINGVIEW_TOKEN}', + 'X-Literal': 'public', + }, + { TRADINGVIEW_TOKEN: 'secret-token' }, + ), + ).toEqual({ + Authorization: 'secret-token', + 'X-Literal': 'public', + }); + }); + + test('rejects a missing referenced environment variable', () => { + expect(() => + resolveMcpHeaderReferences( + { Authorization: '${TRADINGVIEW_TOKEN}' }, + {}, + ), + ).toThrow("missing environment variable 'TRADINGVIEW_TOKEN'"); + }); +}); + +describe('getBrowserOpenCommands', () => { + test('uses explorer on Windows so OAuth URLs are not parsed by cmd', () => { + const url = + 'https://idp.example/auth?response_type=code&client_id=test&state=abc'; + + expect(getBrowserOpenCommands(url, 'win32')).toEqual([ + { command: 'explorer.exe', args: [url] }, + ]); + }); +}); + +describe('getMcpOAuthCacheDir', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + const home = '/tmp/allagents-mcp-oauth-home'; + const url = 'https://mcp.tradingview.com/mcp'; + + beforeEach(() => { + process.env.ALLAGENTS_TEST_HOME = home; + }); + + afterEach(() => { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + }); + + test('shares ordinary project and user credentials by URL', () => { + expect(getMcpOAuthCacheDir(url)).toBe( + join(home, '.allagents', 'oauth-proxy', hashServerUrl(url)), + ); + }); + + test('isolates credentials inside each profile root', () => { + expect(getMcpOAuthCacheDir(url, 'markets')).toBe( + join( + home, + '.allagents', + 'profiles', + 'markets', + 'oauth-proxy', + hashServerUrl(url), + ), + ); + expect(getMcpOAuthCacheDir(url, 'research')).not.toBe( + getMcpOAuthCacheDir(url, 'markets'), + ); + }); + + test('rejects unsafe profile names before constructing a path', () => { + expect(() => getMcpOAuthCacheDir(url, '../outside')).toThrow(); + }); +}); + +describe('parseOAuthCallbackUrl', () => { + const redirectUrl = 'http://127.0.0.1:38421/callback'; + const state = 'expected-state'; + + test('returns the code from the registered callback URL', () => { + expect( + parseOAuthCallbackUrl( + `${redirectUrl}?code=authorization-code&state=${state}`, + redirectUrl, + state, + ), + ).toBe('authorization-code'); + }); + + test.each([ + [ + 'different state', + `${redirectUrl}?code=authorization-code&state=wrong-state`, + 'OAuth state validation failed', + ], + [ + 'duplicate state', + `${redirectUrl}?code=authorization-code&state=${state}&state=${state}`, + 'OAuth state validation failed', + ], + [ + 'missing state', + `${redirectUrl}?code=authorization-code`, + 'OAuth state validation failed', + ], + [ + 'different loopback port', + `http://127.0.0.1:9999/callback?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'different callback path', + `http://127.0.0.1:38421/other?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'embedded credentials', + `http://user@127.0.0.1:38421/callback?code=authorization-code&state=${state}`, + 'OAuth callback URL does not match', + ], + [ + 'fragment', + `${redirectUrl}?code=authorization-code&state=${state}#fragment`, + 'OAuth callback URL does not match', + ], + [ + 'missing code', + `${redirectUrl}?state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'empty code', + `${redirectUrl}?code=&state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'duplicate code', + `${redirectUrl}?code=one&code=two&state=${state}`, + 'No OAuth authorization code received', + ], + [ + 'code and error', + `${redirectUrl}?code=one&error=access_denied&state=${state}`, + 'Invalid OAuth authorization response', + ], + ])('rejects a callback with %s', (_name, callbackUrl, message) => { + expect(() => + parseOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).toThrow(message); + }); + + test('accepts an authorization denial as a valid callback envelope', () => { + const callbackUrl = `${redirectUrl}?error=access_denied&state=${state}`; + + expect(() => + validateOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).not.toThrow(); + expect(() => + parseOAuthCallbackUrl(callbackUrl, redirectUrl, state), + ).toThrow('OAuth authorization failed'); + }); + + test('does not include provider-controlled error text in the exception', () => { + expect(() => + parseOAuthCallbackUrl( + `${redirectUrl}?error=%1B%5B31maccess_denied&state=${state}`, + redirectUrl, + state, + ), + ).toThrow(new Error('OAuth authorization failed')); + }); +}); diff --git a/tests/unit/core/mcp-proxy-cli.test.ts b/tests/unit/core/mcp-proxy-cli.test.ts index 761002ab..76d8e65e 100644 --- a/tests/unit/core/mcp-proxy-cli.test.ts +++ b/tests/unit/core/mcp-proxy-cli.test.ts @@ -7,6 +7,9 @@ import { buildCodexMcpAddArgs } from '../../../src/core/codex-mcp.js'; import { syncVscodeMcpConfig } from '../../../src/core/vscode-mcp.js'; import { applyMcpProxy } from '../../../src/core/mcp-proxy.js'; import type { McpProxyConfig } from '../../../src/models/workspace-config.js'; +import packageJson from '../../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; describe('CLI args with proxy transform', () => { test('buildClaudeMcpAddArgs handles proxied HTTP config', () => { @@ -19,8 +22,8 @@ describe('CLI args with proxy transform', () => { const args = buildClaudeMcpAddArgs('deepwiki', proxiedConfig); expect(args).toEqual([ - 'mcp', 'add', '--scope', 'user', 'deepwiki', '--', 'allagents', - 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', + 'mcp', 'add', '--scope', 'user', 'deepwiki', '--', 'npx', + '-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', ]); }); @@ -34,8 +37,8 @@ describe('CLI args with proxy transform', () => { const args = buildCodexMcpAddArgs('deepwiki', proxiedConfig); expect(args).toEqual([ - 'mcp', 'add', 'deepwiki', '--', 'allagents', - 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', + 'mcp', 'add', 'deepwiki', '--', 'npx', + '-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp', ]); }); }); @@ -57,8 +60,8 @@ describe('syncVscodeMcpConfig with serverOverrides', () => { test('writes proxied stdio config when serverOverrides is provided', () => { const proxiedServers = new Map([ ['deepwiki', { - command: 'allagents', - args: ['mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], + command: 'npx', + args: ['-y', packageRef, 'mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], }], ]); @@ -68,7 +71,12 @@ describe('syncVscodeMcpConfig with serverOverrides', () => { expect(result.addedServers).toEqual(['deepwiki']); const written = JSON.parse(readFileSync(configPath, 'utf-8')); - expect(written.servers.deepwiki.command).toBe('allagents'); - expect(written.servers.deepwiki.args[0]).toBe('mcp'); + expect(written.servers.deepwiki.command).toBe('npx'); + expect(written.servers.deepwiki.args.slice(0, 4)).toEqual([ + '-y', + packageRef, + 'mcp', + 'proxy', + ]); }); }); diff --git a/tests/unit/core/mcp-proxy.test.ts b/tests/unit/core/mcp-proxy.test.ts index 018c6f37..c7ccf09c 100644 --- a/tests/unit/core/mcp-proxy.test.ts +++ b/tests/unit/core/mcp-proxy.test.ts @@ -7,6 +7,9 @@ import { applyMcpProxy, } from '../../../src/core/mcp-proxy.js'; import type { McpProxyConfig } from '../../../src/models/workspace-config.js'; +import packageJson from '../../../package.json'; + +const packageRef = `allagents@${packageJson.version}`; function makeTempDir(): string { const dir = join(tmpdir(), `allagents-mcp-proxy-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -34,6 +37,15 @@ describe('shouldProxy', () => { expect(shouldProxy('my-api', 'codex', config)).toBe(true); }); + test('returns true for every client when a server uses the wildcard', () => { + const config: McpProxyConfig = { + clients: [], + servers: { 'my-api': { proxy: ['*'] } }, + }; + expect(shouldProxy('my-api', 'claude', config)).toBe(true); + expect(shouldProxy('my-api', 'future-client', config)).toBe(true); + }); + test('returns true when client is in both default and per-server', () => { const config: McpProxyConfig = { clients: ['claude'], @@ -59,8 +71,51 @@ describe('applyMcpProxy', () => { const config: McpProxyConfig = { clients: ['claude'] }; const result = applyMcpProxy(servers, 'claude', config); expect(result.get('deepwiki')).toEqual({ - command: 'allagents', - args: ['mcp', 'proxy', 'https://mcp.deepwiki.com/mcp'], + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.deepwiki.com/mcp', + ], + }); + }); + + test('includes the profile selector only for profile-owned bridges', () => { + const servers = new Map([ + ['tradingview', { url: 'https://mcp.tradingview.com/mcp' }], + ]); + const config: McpProxyConfig = { + clients: [], + servers: { tradingview: { proxy: ['codex'] } }, + }; + + expect( + applyMcpProxy(servers, 'codex', config, { profile: 'markets' }).get( + 'tradingview', + ), + ).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + ], + }); + expect(applyMcpProxy(servers, 'codex', config).get('tradingview')).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + ], }); }); @@ -89,7 +144,7 @@ describe('applyMcpProxy', () => { ]); const config: McpProxyConfig = { clients: ['copilot'] }; const result = applyMcpProxy(servers, 'copilot', config); - expect((result.get('http-server') as Record).command).toBe('allagents'); + expect((result.get('http-server') as Record).command).toBe('npx'); expect((result.get('stdio-server') as Record).command).toBe('npx'); expect((result.get('stdio-server') as Record).args).toEqual(['some-mcp']); }); @@ -104,7 +159,7 @@ describe('applyMcpProxy', () => { servers: { 'my-api': { proxy: ['codex'] } }, }; const result = applyMcpProxy(servers, 'codex', config); - expect((result.get('my-api') as Record).command).toBe('allagents'); + expect((result.get('my-api') as Record).command).toBe('npx'); expect(result.get('other-api')).toEqual({ url: 'https://other.example.com/mcp' }); }); @@ -121,8 +176,10 @@ describe('applyMcpProxy', () => { const config: McpProxyConfig = { clients: ['claude'] }; const result = applyMcpProxy(servers, 'claude', config); expect(result.get('secure-api')).toEqual({ - command: 'allagents', + command: 'npx', args: [ + '-y', + packageRef, 'mcp', 'proxy', 'https://api.example.com/mcp', @@ -133,4 +190,40 @@ describe('applyMcpProxy', () => { ], }); }); + + test('keeps profile secrets as environment bindings in generated bridge args', () => { + const servers = new Map([ + [ + 'secure-api', + { + url: 'https://api.example.com/mcp', + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + }, + ], + ]); + const config: McpProxyConfig = { + clients: [], + servers: { 'secure-api': { proxy: ['codex'] } }, + }; + + expect( + applyMcpProxy(servers, 'codex', config, { profile: 'markets' }).get( + 'secure-api', + ), + ).toEqual({ + command: 'npx', + args: [ + '-y', + packageRef, + 'mcp', + 'proxy', + 'https://api.example.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + env: { TRADINGVIEW_TOKEN: '${TRADINGVIEW_TOKEN}' }, + }); + }); }); diff --git a/tests/unit/core/mcp-servers.test.ts b/tests/unit/core/mcp-servers.test.ts index 13146608..a71c412f 100644 --- a/tests/unit/core/mcp-servers.test.ts +++ b/tests/unit/core/mcp-servers.test.ts @@ -1,17 +1,23 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { load } from 'js-yaml'; import { - addWorkspaceMcpServer, + addMcpServer, buildMcpServerConfigFromFlags, - clearWorkspaceMcpServerProxy, - getWorkspaceMcpServer, - listWorkspaceMcpServers, + getMcpServer, + listMcpServers, parseKeyValuePairs, - removeWorkspaceMcpServer, - setWorkspaceMcpServerProxy, + removeMcpServer, + resolveMcpDestination, + type McpDestination, } from '../../../src/core/mcp-servers.js'; function makeTempWorkspace(): string { @@ -35,220 +41,329 @@ function readWorkspace(dir: string): Record { >; } -describe('addWorkspaceMcpServer', () => { + +describe('destination-aware MCP declarations', () => { let dir: string; + let configPath: string; + beforeEach(() => { dir = makeTempWorkspace(); + configPath = join(dir, '.allagents', 'workspace.yaml'); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); - test('adds an http server', async () => { - const result = await addWorkspaceMcpServer( - 'deepwiki', - { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, - dir, - ); - expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpServers).toEqual({ - deepwiki: { type: 'http', url: 'https://mcp.deepwiki.com/mcp' }, - }); - }); - - test('rejects duplicate without force', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + test('rejects simultaneous scope and profile selectors', () => { + expect(() => + resolveMcpDestination({ + cwd: dir, + scope: 'user', + profile: 'research', + }), + ).toThrow('--scope and --profile cannot be used together'); }); - test('replaces duplicate with force', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await addWorkspaceMcpServer('a', { command: 'y' }, dir, true); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect((cfg.mcpServers as Record).a.command).toBe('y'); - }); - - test('rejects invalid config', async () => { - // Neither command nor url - const result = await addWorkspaceMcpServer( - 'bad', - { type: 'http' } as unknown as Parameters[1], - dir, - ); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid MCP server config'); - }); - - test('preserves other workspace.yaml fields on add', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const cfg = readWorkspace(dir); - expect(cfg.repositories).toEqual([]); - expect(cfg.plugins).toEqual([]); - expect(cfg.clients).toEqual(['claude']); + test('resolves an unflagged command from HOME to the user destination', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = dir; + try { + expect(resolveMcpDestination({ cwd: dir })).toEqual({ + kind: 'user', + configPath: join(dir, '.allagents', 'workspace.yaml'), + }); + } finally { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + } }); - test('persists server-scoped proxy intent without widening global proxy clients', async () => { - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, - ); - - const result = await setWorkspaceMcpServerProxy('wtgkb', dir); - expect(result.success).toBe(true); - expect(result.proxyClients).toEqual(['claude']); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: [], - servers: { - wtgkb: { proxy: ['claude'] }, - }, - }); + test('rejects explicit project scope from HOME instead of aliasing user state', () => { + const originalHome = process.env.ALLAGENTS_TEST_HOME; + process.env.ALLAGENTS_TEST_HOME = dir; + try { + expect(() => + resolveMcpDestination({ cwd: dir, scope: 'project' }), + ).toThrow('--scope project cannot be used from the home directory'); + } finally { + if (originalHome === undefined) { + delete process.env.ALLAGENTS_TEST_HOME; + } else { + process.env.ALLAGENTS_TEST_HOME = originalHome; + } + } }); - test('preserves existing workspace-wide proxy defaults when adding server-scoped proxy intent', async () => { + test('mutates ordinary user declarations without changing profiles', async () => { writeFileSync( - join(dir, '.allagents', 'workspace.yaml'), + configPath, `repositories: [] plugins: [] clients: - - claude - codex -mcpProxy: - clients: - - codex +profiles: + research: + clients: + - name: copilot `, 'utf-8', ); - - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + const destination: McpDestination = { + kind: 'user', + configPath, + }; + + const result = await addMcpServer( + destination, + 'remote', + { type: 'http', url: 'https://mcp.example' }, + { proxy: { clients: ['codex'] } }, ); - const result = await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: ['codex'], - servers: { - wtgkb: { proxy: ['claude'] }, + expect(result.success).toBe(true); + expect(await getMcpServer(destination, 'remote')).toEqual({ + type: 'http', + url: 'https://mcp.example', + }); + expect(await listMcpServers(destination)).toEqual({ + remote: { type: 'http', url: 'https://mcp.example' }, + }); + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + research: { + clients: [{ name: 'copilot' }], + }, + }, + mcpProxy: { + servers: { + remote: { proxy: ['codex'] }, + }, }, }); }); - test('clears server-scoped proxy intent without removing workspace-wide defaults', async () => { + test('serializes concurrent declaration updates without losing either server', async () => { + const destination: McpDestination = { + kind: 'project', + workspacePath: dir, + configPath, + }; + + const [first, second] = await Promise.all([ + addMcpServer(destination, 'first', { command: 'first-mcp' }), + addMcpServer(destination, 'second', { command: 'second-mcp' }), + ]); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(await listMcpServers(destination)).toEqual({ + first: { command: 'first-mcp' }, + second: { command: 'second-mcp' }, + }); + }); + + test('rejects a symbolic-link workspace config without replacing its target', async () => { + const targetPath = join(dir, 'workspace-target.yaml'); + const original = readFileSync(configPath, 'utf8'); + writeFileSync(targetPath, original, 'utf8'); + rmSync(configPath); + symlinkSync(targetPath, configPath); + const destination: McpDestination = { + kind: 'project', + workspacePath: dir, + configPath, + }; + + const result = await addMcpServer(destination, 'blocked', { + command: 'blocked-mcp', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('symbolic-link workspace config'); + expect(readFileSync(targetPath, 'utf8')).toBe(original); + }); + + test('atomically writes profile server and proxy policy', async () => { writeFileSync( - join(dir, '.allagents', 'workspace.yaml'), + configPath, `repositories: [] plugins: [] -clients: - - claude - - codex -mcpProxy: - clients: - - codex +clients: [] +profiles: + markets: + clients: + - name: codex + - name: copilot `, 'utf-8', ); - - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await addMcpServer( + destination, + 'tradingview', + { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + clients: ['codex', 'copilot'], + }, + { proxy: { clients: ['codex', 'copilot'] } }, ); - await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - const result = await clearWorkspaceMcpServerProxy('wtgkb', dir); expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toEqual({ - clients: ['codex'], + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + markets: { + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + clients: ['codex', 'copilot'], + }, + }, + mcpProxy: { + servers: { + tradingview: { proxy: ['codex', 'copilot'] }, + }, + }, + }, + }, }); }); -}); - -describe('removeWorkspaceMcpServer', () => { - let dir: string; - beforeEach(() => { - dir = makeTempWorkspace(); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - test('removes an existing server', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const result = await removeWorkspaceMcpServer('a', dir); - expect(result.success).toBe(true); - const cfg = readWorkspace(dir); - expect(cfg.mcpServers).toBeUndefined(); - }); + test('rejects invalid profile selectors without partially writing', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex +`, + 'utf-8', + ); + const before = readFileSync(configPath, 'utf-8'); + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await addMcpServer( + destination, + 'remote', + { + type: 'http', + url: 'https://mcp.example', + clients: ['copilot'], + }, + { proxy: { clients: ['copilot'] } }, + ); - test('fails when server does not exist', async () => { - const result = await removeWorkspaceMcpServer('nonexistent', dir); expect(result.success).toBe(false); - expect(result.error).toContain('not found'); + expect(result.error).toContain('not declared by this profile'); + expect(readFileSync(configPath, 'utf-8')).toBe(before); }); - test('removes server-scoped proxy intent when removing a server', async () => { - await addWorkspaceMcpServer( - 'wtgkb', - { type: 'http', url: 'https://knowledge.mcp.wtg.zone' }, - dir, + test('rejects an invalid profile server name before writing', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex +`, + 'utf-8', + ); + const before = readFileSync(configPath, 'utf-8'); + const result = await addMcpServer( + { kind: 'profile', name: 'markets', configPath }, + 'invalid/name', + { command: 'local-mcp' }, + { proxy: false }, ); - await setWorkspaceMcpServerProxy('wtgkb', dir, ['claude']); - - const result = await removeWorkspaceMcpServer('wtgkb', dir); - expect(result.success).toBe(true); - - const cfg = readWorkspace(dir); - expect(cfg.mcpProxy).toBeUndefined(); - }); -}); -describe('getWorkspaceMcpServer / listWorkspaceMcpServers', () => { - let dir: string; - beforeEach(() => { - dir = makeTempWorkspace(); - }); - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); + expect(result.success).toBe(false); + expect(result.error).toContain('Expected 1-100 ASCII'); + expect(readFileSync(configPath, 'utf-8')).toBe(before); }); - test('get returns null for missing server', async () => { - const cfg = await getWorkspaceMcpServer('missing', dir); - expect(cfg).toBeNull(); - }); + test('rejects an undeclared profile destination', async () => { + const result = await addMcpServer( + { kind: 'profile', name: 'missing', configPath }, + 'remote', + { command: 'local-mcp' }, + { proxy: false }, + ); - test('get returns server config', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - const cfg = await getWorkspaceMcpServer('a', dir); - expect(cfg).toEqual({ command: 'x' } as unknown as typeof cfg); + expect(result.success).toBe(false); + expect(result.error).toContain("Profile 'missing' is not declared"); }); - test('list returns all servers', async () => { - await addWorkspaceMcpServer('a', { command: 'x' }, dir); - await addWorkspaceMcpServer( - 'b', - { type: 'http', url: 'https://b.test' }, - dir, + test('removes only the selected profile declaration and proxy policy', async () => { + writeFileSync( + configPath, + `profiles: + markets: + clients: + - name: codex + mcpServers: + remote: + url: https://mcp.example + mcpProxy: + servers: + remote: + proxy: + - codex + research: + clients: + - name: copilot + mcpServers: + keep: + command: keep-mcp +`, + 'utf-8', ); - const servers = await listWorkspaceMcpServers(dir); - expect(Object.keys(servers).sort()).toEqual(['a', 'b']); - }); + const destination: McpDestination = { + kind: 'profile', + name: 'markets', + configPath, + }; + + const result = await removeMcpServer(destination, 'remote'); - test('list returns empty object when none defined', async () => { - const servers = await listWorkspaceMcpServers(dir); - expect(servers).toEqual({}); + expect(result.success).toBe(true); + expect(readWorkspace(dir)).toMatchObject({ + profiles: { + markets: { + clients: [{ name: 'codex' }], + }, + research: { + mcpServers: { + keep: { command: 'keep-mcp' }, + }, + }, + }, + }); + expect( + ( + (readWorkspace(dir).profiles as Record>) + .markets + ).mcpServers, + ).toBeUndefined(); + expect( + ( + (readWorkspace(dir).profiles as Record>) + .markets + ).mcpProxy, + ).toBeUndefined(); }); }); diff --git a/tests/unit/core/mcp-sync-user.test.ts b/tests/unit/core/mcp-sync-user.test.ts new file mode 100644 index 00000000..6abce8f0 --- /dev/null +++ b/tests/unit/core/mcp-sync-user.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { dump, load } from 'js-yaml'; +import { WORKSPACE_CONFIG_FILE } from '../../../src/constants.js'; +import { getCopilotMcpConfigPath } from '../../../src/core/copilot-mcp.js'; +import { syncUserMcpOnly } from '../../../src/core/mcp-sync.js'; +import { getSyncStatePath } from '../../../src/core/sync-state.js'; +import { getVscodeMcpConfigPath } from '../../../src/core/vscode-mcp.js'; +import { stubHomeDir } from '../../helpers/env.js'; + +describe('syncUserMcpOnly', () => { + let home: string; + let restoreHome: () => void; + let configPath: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'allagents-mcp-user-sync-')); + restoreHome = stubHomeDir(home); + configPath = join(home, '.allagents', WORKSPACE_CONFIG_FILE); + }); + + afterEach(async () => { + restoreHome(); + await rm(home, { recursive: true, force: true }); + }); + + async function writeUserConfig(config: Record): Promise { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf8'); + } + + test('reconciles only ordinary user MCP destinations and preserves profiles', async () => { + const config = { + repositories: [], + plugins: [], + clients: ['vscode', 'copilot'], + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + profiles: { + markets: { + clients: [{ name: 'codex' }], + mcpServers: { + private: { command: 'private-mcp' }, + }, + }, + }, + }; + await writeUserConfig(config); + const statePath = getSyncStatePath(home); + await writeFile( + statePath, + `${JSON.stringify({ + version: 1, + lastSync: '2026-01-01T00:00:00.000Z', + files: { cursor: ['keep.md'] }, + mcpServers: { claude: ['keep'] }, + skillsIndex: ['keep-skill'], + unknownFutureField: { keep: true }, + })}\n`, + 'utf8', + ); + + const result = await syncUserMcpOnly({ offline: true }); + + expect(result.success).toBe(true); + expect(JSON.parse(await readFile(getVscodeMcpConfigPath(), 'utf8'))).toEqual({ + servers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + }); + expect(JSON.parse(await readFile(getCopilotMcpConfigPath(), 'utf8'))).toEqual({ + mcpServers: { + tradingview: { + type: 'http', + url: 'https://mcp.tradingview.com/mcp', + }, + }, + }); + expect(existsSync(join(home, '.github', 'mcp.json'))).toBe(false); + + const writtenConfig = load(await readFile(configPath, 'utf8')) as typeof config; + expect(writtenConfig.profiles).toEqual(config.profiles); + const state = JSON.parse(await readFile(statePath, 'utf8')) as Record< + string, + unknown + >; + expect(state.files).toEqual({ cursor: ['keep.md'] }); + expect(state.skillsIndex).toEqual(['keep-skill']); + expect(state.unknownFutureField).toEqual({ keep: true }); + expect(state.mcpServers).toEqual({ + claude: ['keep'], + vscode: ['tradingview'], + copilot: ['tradingview'], + }); + }); + + test('returns an empty success when no user config exists', async () => { + const result = await syncUserMcpOnly({ offline: true }); + + expect(result).toEqual({ success: true, mcpResults: {}, warnings: [] }); + }); + + test('keeps dry-run free of destination and state writes', async () => { + await writeUserConfig({ + repositories: [], + plugins: [], + clients: ['copilot'], + mcpServers: { + local: { command: 'local-mcp' }, + }, + }); + + const result = await syncUserMcpOnly({ offline: true, dryRun: true }); + + expect(result.success).toBe(true); + expect(result.mcpResults.copilot?.added).toBe(1); + expect(existsSync(getCopilotMcpConfigPath())).toBe(false); + expect(existsSync(getSyncStatePath(home))).toBe(false); + }); +}); diff --git a/tests/unit/core/profile/codex.test.ts b/tests/unit/core/profile/codex.test.ts index bc624722..d4513043 100644 --- a/tests/unit/core/profile/codex.test.ts +++ b/tests/unit/core/profile/codex.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { CodexProfileAdapter } from '../../../../src/core/profile/adapters/codex.js'; +import packageJson from '../../../../package.json'; describe('Codex profile adapter', () => { it('isolates CODEX_HOME, selects an exact named config, and preserves cwd', () => { @@ -130,6 +131,21 @@ describe('Codex profile adapter', () => { args: ['server.js'], env: { LOCAL_TOKEN: '${LOCAL_TOKEN}' }, }, + bridge: { + command: 'npx', + args: [ + '-y', + 'allagents@1.15.0', + 'mcp', + 'proxy', + 'https://mcp.example.test', + '--profile', + 'review', + '--header-env', + 'Authorization=REMOTE_TOKEN', + ], + env: { REMOTE_TOKEN: '${REMOTE_TOKEN}' }, + }, remote: { url: 'https://mcp.example.test', headers: { @@ -149,6 +165,10 @@ describe('Codex profile adapter', () => { 'personality = "pragmatic"\n' + 'sandbox_mode = "workspace-write"\n' + 'web_search = "cached"\n\n' + + '[mcp_servers.bridge]\n' + + `args = ["-y", "allagents@${packageJson.version}", "mcp", "proxy", "https://mcp.example.test", "--profile", "review", "--header-env", "Authorization=REMOTE_TOKEN"]\n` + + 'command = "npx"\n' + + 'env_vars = ["REMOTE_TOKEN"]\n\n' + '[mcp_servers.local]\n' + 'args = ["server.js"]\n' + 'command = "node"\n' + diff --git a/tests/unit/core/profile/manager.test.ts b/tests/unit/core/profile/manager.test.ts index 636a53d6..cf6a2541 100644 --- a/tests/unit/core/profile/manager.test.ts +++ b/tests/unit/core/profile/manager.test.ts @@ -1,9 +1,20 @@ import { afterEach, describe, expect, it, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { + mkdtemp, + mkdir, + readFile, + realpath, + rm, + stat, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { dump } from 'js-yaml'; +import packageJson from '../../../../package.json'; import { applyProfilePlan, getProfileStatus, @@ -31,6 +42,7 @@ import type { NativeResource, NativeResourceObservation, } from '../../../../src/core/native/types.js'; +import type { ClientType } from '../../../../src/models/workspace-config.js'; const roots: string[] = []; @@ -185,7 +197,7 @@ class MemoryProfileAdapter implements ProfileAdapter { runtimeChecks = 0; constructor( - readonly client: 'pi' | 'omp', + readonly client: ClientType, private readonly home: string, ) { this.nativeClient = new MemoryNativeClient(client); @@ -640,6 +652,98 @@ describe('profile lifecycle manager', () => { expect(await readFile(join(test.home, '.allagents', 'profiles', 'work', 'clients', 'pi', 'agent', 'mcp.json'), 'utf8')).toContain('docs-mcp'); }); + it('applies profile proxy policy after client selection', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + markets: { + clients: [{ name: 'codex' }, { name: 'omp' }], + plugins: [], + mcpServers: { + tradingview: { + url: 'https://mcp.tradingview.com/mcp', + headers: { Authorization: '${TRADINGVIEW_TOKEN}' }, + clients: ['codex'], + }, + excluded: { + url: 'https://mcp.example/excluded', + clients: ['omp'], + }, + }, + mcpProxy: { + servers: { + tradingview: { proxy: ['codex'] }, + excluded: { proxy: ['codex'] }, + }, + }, + }, + }); + const codex = new MemoryProfileAdapter('codex', test.home); + const omp = new MemoryProfileAdapter('omp', test.home); + const deps = dependencies(codex, omp); + + const plan = await planProfileOperation( + 'markets', + 'install', + test.options, + deps, + ); + const mcpStep = plan.steps.find( + (step) => step.kind === 'mcp' && step.client === 'codex', + ); + expect(mcpStep?.detail?.mcpServers).toEqual([ + { + name: 'tradingview', + transport: 'stdio', + command: { + command: 'npx', + args: [ + '-y', + `allagents@${packageJson.version}`, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + }, + requestedSecrets: ['TRADINGVIEW_TOKEN'], + }, + ]); + + expect((await applyProfilePlan(plan, test.options, deps)).success).toBe(true); + const mcpPath = join( + test.home, + '.allagents', + 'profiles', + 'markets', + 'clients', + 'codex', + 'agent', + 'mcp.json', + ); + expect(JSON.parse(await readFile(mcpPath, 'utf8'))).toEqual({ + mcpServers: { + tradingview: { + command: 'npx', + args: [ + '-y', + `allagents@${packageJson.version}`, + 'mcp', + 'proxy', + 'https://mcp.tradingview.com/mcp', + '--profile', + 'markets', + '--header-env', + 'Authorization=TRADINGVIEW_TOKEN', + ], + env: { TRADINGVIEW_TOKEN: '${TRADINGVIEW_TOKEN}' }, + }, + }, + }); + }); + it('references a usable preexisting Pi MCP adapter without taking cleanup ownership', async () => { const test = await fixture(); await writeWorkspace(test.userConfigPath, { @@ -1089,6 +1193,116 @@ describe('profile lifecycle manager', () => { await expect(stat(clientRoot)).rejects.toThrow(); }); + it('removes profile-owned OAuth state while preserving unrelated residue', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + const residue = join(profileRoot, 'notes.txt'); + await mkdir(join(oauthRoot, 'server'), { recursive: true }); + await writeFile(join(oauthRoot, 'server', 'tokens.json'), '{}', 'utf8'); + await writeFile(residue, 'keep', 'utf8'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('removed'); + await expect(stat(oauthRoot)).rejects.toThrow(); + expect(await readFile(residue, 'utf8')).toBe('keep'); + await expect(stat(join(profileRoot, 'state.json'))).rejects.toThrow(); + }); + + it('removes OAuth state for a declared profile that was never installed', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const deps = dependencies(new MemoryProfileAdapter('pi', test.home)); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + await mkdir(join(oauthRoot, 'server'), { recursive: true }); + await writeFile(join(oauthRoot, 'server', 'tokens.json'), '{}', 'utf8'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('removed'); + await expect(stat(oauthRoot)).rejects.toThrow(); + await expect(stat(join(profileRoot, 'state.json'))).rejects.toThrow(); + }); + + it('rejects a symlinked OAuth root without deleting its target or removal state', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const deps = dependencies(new MemoryProfileAdapter('pi', test.home)); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const profileRoot = join(test.home, '.allagents', 'profiles', 'work'); + const oauthRoot = join(profileRoot, 'oauth-proxy'); + const externalRoot = join(test.home, 'external-oauth'); + const externalToken = join(externalRoot, 'tokens.json'); + await mkdir(externalRoot, { recursive: true }); + await writeFile(externalToken, 'keep', 'utf8'); + await symlink(externalRoot, oauthRoot, 'dir'); + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + const result = await applyProfilePlan(removal, test.options, deps); + + expect(result.status).toBe('partial'); + expect(result.success).toBe(false); + expect(result.error).toContain('symbolic link'); + expect(await readFile(externalToken, 'utf8')).toBe('keep'); + expect((await stat(join(profileRoot, 'state.json'))).isFile()).toBe(true); + + await unlink(oauthRoot); + const retry = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + expect((await applyProfilePlan(retry, test.options, deps)).status).toBe( + 'removed', + ); + }); + it('inspects replaced managed roots for state-only clients', async () => { const test = await fixture(); await writeWorkspace(test.userConfigPath, { diff --git a/tests/unit/models/workspace-config-mcp-proxy.test.ts b/tests/unit/models/workspace-config-mcp-proxy.test.ts index fdf66959..776a1d8b 100644 --- a/tests/unit/models/workspace-config-mcp-proxy.test.ts +++ b/tests/unit/models/workspace-config-mcp-proxy.test.ts @@ -48,11 +48,17 @@ describe('mcpProxy workspace config', () => { expect(result.success).toBe(true); }); - test('rejects mcpProxy without clients', () => { + test('defaults clients to empty for server-only proxy policy', () => { const result = WorkspaceConfigSchema.safeParse({ ...baseConfig, mcpProxy: { servers: { 'my-api': { proxy: ['codex'] } } }, }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mcpProxy).toEqual({ + clients: [], + servers: { 'my-api': { proxy: ['codex'] } }, + }); + } }); }); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts index 6b0cf454..53cf98c4 100644 --- a/tests/unit/models/workspace-config-profiles.test.ts +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -425,6 +425,48 @@ describe('profile workspace declarations', () => { } }); + it('accepts destination-local profile proxy policy', () => { + const result = UserWorkspaceConfigSchema.parse( + userConfigWithProfile({ + clients: [{ name: 'codex' }, { name: 'copilot' }], + mcpServers: { + remote: { url: 'https://mcp.example', clients: ['codex', 'copilot'] }, + }, + mcpProxy: { + servers: { + remote: { proxy: ['codex', 'copilot'] }, + shared: { proxy: ['*'] }, + }, + }, + }), + ); + + expect(result.profiles?.research?.mcpProxy).toEqual({ + clients: [], + servers: { + remote: { proxy: ['codex', 'copilot'] }, + shared: { proxy: ['*'] }, + }, + }); + }); + + it('requires profile proxy selectors to be unique declared clients', () => { + for (const mcpProxy of [ + { clients: ['omp'] }, + { clients: ['codex', 'codex'] }, + { servers: { remote: { proxy: ['omp'] } } }, + { servers: { remote: { proxy: ['codex', 'codex'] } } }, + ]) { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'codex' }], + mcpProxy, + }), + ); + expect(result.success).toBe(false); + } + }); + it('requires exact portable secret references in profile MCP credentials', () => { for (const server of [ { command: 'local-mcp', env: { TOKEN: 'plaintext' } },