From 39268bcf38e327d6fc779c8dddc5b453a87d9a75 Mon Sep 17 00:00:00 2001 From: bo Date: Sun, 9 Aug 2026 19:14:18 +0800 Subject: [PATCH 1/2] feat(runtime): rebuild MCP and Skill control plane Hot-apply user MCP servers across all agents, add strict HTTP and STDIO lifecycle management, and expose MCP controls in Settings. Add five-tier Skill discovery, diagnostics, immutable execution overlays, and the Composer picker. BREAKING CHANGE: MCP server configuration now requires type and enabled, and the legacy url, headers, and timeout shape is rejected. --- AGENTS.md | 87 +- CHANGELOG.md | 13 + apps/server/src/app.test.ts | 6 +- apps/server/src/app.ts | 3 + apps/server/src/errors.ts | 1 + apps/server/src/routes/attachments.test.ts | 4 +- apps/server/src/routes/compression.test.ts | 5 +- apps/server/src/routes/config.test.ts | 42 +- apps/server/src/routes/files.test.ts | 2 - apps/server/src/routes/mcp.test.ts | 255 ++++-- apps/server/src/routes/mcp.ts | 105 ++- apps/server/src/routes/messages.test.ts | 5 +- apps/server/src/routes/projects.test.ts | 2 - apps/server/src/routes/sessions.test.ts | 2 - apps/server/src/routes/skills.test.ts | 238 +++++ apps/server/src/routes/skills.ts | 104 +++ apps/server/src/server-host.test.ts | 94 +- apps/server/src/server-host.ts | 39 +- apps/web/src/api/config.test.ts | 10 + apps/web/src/api/config.ts | 10 +- apps/web/src/api/mcp.test.ts | 45 +- apps/web/src/api/mcp.ts | 32 +- apps/web/src/api/skills.test.ts | 81 ++ apps/web/src/api/skills.ts | 62 ++ .../ExecutionWorkstream.interaction.tsx | 3 + .../components/features/ChatHeader.test.tsx | 1 + .../components/features/ChatInput.test.tsx | 85 ++ .../web/src/components/features/ChatInput.tsx | 162 +++- .../ComposerQueueList.interaction.tsx | 2 + .../SessionComposerDock.interaction.tsx | 2 + .../features/SettingsDialog.interaction.tsx | 101 ++- .../features/SettingsDialog.test.tsx | 52 +- .../components/features/SettingsDialog.tsx | 39 +- .../TodoProgressButton.interaction.tsx | 2 + .../components/features/settings-helpers.ts | 12 +- .../components/features/settings-panels.tsx | 235 ++++- apps/web/src/components/ui/Dialog.test.tsx | 2 + apps/web/src/components/ui/Dialog.tsx | 4 +- apps/web/src/context/global-sse.test.tsx | 10 +- apps/web/src/context/settings-modal.tsx | 4 +- .../lib/execution-status-presentation.test.ts | 1 + apps/web/src/lib/execution-workstream.test.ts | 1 + apps/web/src/routes/session.test.tsx | 3 + apps/web/src/store/mcp-status-store.test.ts | 30 +- apps/web/src/store/session-store.test.ts | 17 + config.example.json | 26 + design-system/pages/settings.md | 26 +- docs/architecture.md | 38 + docs/configuration.md | 32 +- ...-skill-control-plane-hard-cut-plan-goal.md | 246 +++++ ...p-skill-control-plane-hard-cut-progress.md | 71 ++ docs/integrations.md | 64 +- .../src/__arch__/architecture.test.ts | 19 + .../__arch__/tool-output-boundaries.test.ts | 9 +- .../tool-output-policy-matrix.test.ts | 12 +- .../src/agents/configured-agent-mcp.test.ts | 14 +- .../src/agents/configured-agent.test.ts | 98 +- .../agent-core/src/agents/configured-agent.ts | 103 ++- .../src/agents/definitions/analyst.ts | 2 +- .../src/agents/definitions/build.ts | 1 + .../agents/definitions/definitions.test.ts | 16 +- .../src/agents/definitions/discussion.ts | 2 +- .../src/agents/definitions/explore.ts | 1 + .../agent-core/src/agents/definitions/lead.ts | 2 +- .../src/agents/definitions/librarian.ts | 2 +- .../agent-core/src/agents/factory-types.ts | 9 +- .../agent-core/src/agents/factory.test.ts | 119 +-- packages/agent-core/src/agents/factory.ts | 27 +- .../agent-core/src/agents/query/loop.test.ts | 1 + packages/agent-core/src/agents/query/loop.ts | 29 +- ...vider-secret-redaction.integration.test.ts | 1 + .../src/agents/query/recovery.test.ts | 5 +- packages/agent-core/src/agents/query/types.ts | 10 +- .../src/agents/session-agent-manager.test.ts | 1 + .../src/agents/session-agent-manager.ts | 10 +- packages/agent-core/src/agents/types.ts | 9 +- .../src/attachments/read-paths.test.ts | 1 + .../background/tasks/title-generation.test.ts | 1 + .../agent-core/src/commands/skill.test.ts | 69 +- packages/agent-core/src/commands/skill.ts | 75 +- packages/agent-core/src/commands/types.ts | 5 +- packages/agent-core/src/config/index.ts | 11 + packages/agent-core/src/config/mcp.test.ts | 688 +++++--------- packages/agent-core/src/config/mcp.ts | 259 ++++-- .../config/runtime-secret-literals.test.ts | 22 +- .../src/config/runtime-secret-literals.ts | 8 +- .../src/config/server-config-service.test.ts | 167 +++- .../src/config/server-config-service.ts | 208 ++++- .../src/events/session-event-bridge.test.ts | 1 + .../session-execution-manager.test.ts | 302 ++++++ .../execution/session-execution-manager.ts | 115 ++- .../session-tool-batch-scheduler.test.ts | 467 +++++++++- .../execution/session-tool-batch-scheduler.ts | 528 +++++++++-- packages/agent-core/src/index.ts | 9 +- ...ead-architecture-flows.integration.test.ts | 25 +- packages/agent-core/src/main.test.ts | 135 ++- .../agent-core/src/mcp/builtin-servers.ts | 18 +- packages/agent-core/src/mcp/client.test.ts | 645 +++++-------- packages/agent-core/src/mcp/client.ts | 356 +++++--- packages/agent-core/src/mcp/errors.ts | 10 +- .../src/mcp/fixtures/stdio-server.ts | 29 + packages/agent-core/src/mcp/index.ts | 2 +- packages/agent-core/src/mcp/manager.test.ts | 858 ------------------ packages/agent-core/src/mcp/manager.ts | 312 ------- packages/agent-core/src/mcp/naming.test.ts | 306 +------ packages/agent-core/src/mcp/naming.ts | 101 +-- .../src/mcp/runtime-service.test.ts | 373 ++++++++ .../agent-core/src/mcp/runtime-service.ts | 621 +++++++++++++ .../agent-core/src/mcp/tool-adapter.test.ts | 266 +++--- packages/agent-core/src/mcp/tool-adapter.ts | 253 +++--- .../src/mcp/transports.integration.test.ts | 332 +++++++ .../agent-core/src/prompt/compiler.test.ts | 11 +- packages/agent-core/src/prompt/compiler.ts | 15 +- packages/agent-core/src/prompt/live-eval.ts | 2 +- packages/agent-core/src/prompt/types.ts | 7 +- .../runtime-automations.integration.test.ts | 14 +- .../src/runtime-automations.test.ts | 14 +- .../src/runtime-data/service.test.ts | 11 +- packages/agent-core/src/runtime-mcp.test.ts | 631 ++++++------- .../src/runtime-skill-command.test.ts | 200 ++++ packages/agent-core/src/runtime.ts | 365 +++++--- .../src/session-input/model-selection.test.ts | 1 + .../src/session-input/service.test.ts | 73 ++ .../agent-core/src/session-input/service.ts | 152 ++++ packages/agent-core/src/skills/index.ts | 3 + .../src/skills/package-reader.test.ts | 38 + .../agent-core/src/skills/package-reader.ts | 243 ++++- .../agent-core/src/skills/pagination.test.ts | 51 ++ packages/agent-core/src/skills/pagination.ts | 102 +++ .../agent-core/src/skills/projection.test.ts | 62 ++ packages/agent-core/src/skills/projection.ts | 58 ++ .../agent-core/src/skills/service.test.ts | 531 ++++------- packages/agent-core/src/skills/service.ts | 458 ++++++---- packages/agent-core/src/skills/types.ts | 58 +- packages/agent-core/src/store/helpers.test.ts | 4 + packages/agent-core/src/store/helpers.ts | 32 +- .../src/store/logical-execution.test.ts | 3 + .../src/store/message-phase-hard-cut.test.ts | 2 + .../src/store/session-store-manager.test.ts | 5 +- packages/agent-core/src/store/store.test.ts | 1 + .../src/testing/test-execution-fixtures.ts | 4 + .../src/testing/test-mcp-runtime.ts | 89 ++ .../tool-output/artifact-lifecycle.test.ts | 13 +- .../tool-output/live-bash.integration.test.ts | 2 +- .../src/tools/builtins/skill-list.test.ts | 54 +- .../src/tools/builtins/skill-list.ts | 22 +- .../src/tools/builtins/skill-read.test.ts | 40 +- .../src/tools/builtins/skill-read.ts | 31 +- .../src/tools/concurrency/partition.test.ts | 38 +- .../src/tools/concurrency/partition.ts | 10 +- packages/agent-core/src/tools/index.ts | 1 - .../agent-core/src/tools/permission/index.ts | 1 - .../src/tools/permission/mcp.test.ts | 54 -- .../agent-core/src/tools/permission/mcp.ts | 11 - .../agent-core/src/tools/registry.test.ts | 51 +- packages/agent-core/src/tools/registry.ts | 24 +- packages/agent-core/src/tools/types.ts | 4 +- packages/protocol/src/execution.test.ts | 3 + packages/protocol/src/execution.ts | 1 + packages/protocol/src/guards.test.ts | 31 +- packages/protocol/src/guards.ts | 38 +- .../src/message-phase-hard-cut.test.ts | 1 + packages/protocol/src/reduce.test.ts | 6 +- packages/protocol/src/reduce.ts | 1 + packages/protocol/src/types.test.ts | 50 +- packages/protocol/src/types.ts | 131 ++- 166 files changed, 9664 insertions(+), 4792 deletions(-) create mode 100644 apps/server/src/routes/skills.test.ts create mode 100644 apps/server/src/routes/skills.ts create mode 100644 apps/web/src/api/skills.test.ts create mode 100644 apps/web/src/api/skills.ts create mode 100644 docs/goals/mcp-skill-control-plane-hard-cut-plan-goal.md create mode 100644 docs/goals/mcp-skill-control-plane-hard-cut-progress.md create mode 100644 packages/agent-core/src/mcp/fixtures/stdio-server.ts delete mode 100644 packages/agent-core/src/mcp/manager.test.ts delete mode 100644 packages/agent-core/src/mcp/manager.ts create mode 100644 packages/agent-core/src/mcp/runtime-service.test.ts create mode 100644 packages/agent-core/src/mcp/runtime-service.ts create mode 100644 packages/agent-core/src/mcp/transports.integration.test.ts create mode 100644 packages/agent-core/src/runtime-skill-command.test.ts create mode 100644 packages/agent-core/src/skills/pagination.test.ts create mode 100644 packages/agent-core/src/skills/pagination.ts create mode 100644 packages/agent-core/src/skills/projection.test.ts create mode 100644 packages/agent-core/src/skills/projection.ts create mode 100644 packages/agent-core/src/testing/test-mcp-runtime.ts delete mode 100644 packages/agent-core/src/tools/permission/mcp.test.ts delete mode 100644 packages/agent-core/src/tools/permission/mcp.ts diff --git a/AGENTS.md b/AGENTS.md index 9940bf17..6726dae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,7 +238,7 @@ packages/utils/src/ **Data flow:** ``` ~/.archcode/config.json → startup activation or token-protected Setup - → optional Session auth → providers → registerBuiltinTools → fire-and-forget MCP background load + → optional Session auth → providers → registerBuiltinTools → live MCP runtime activation → Hono Runtime routes → Session-scoped Lead / Automation / HITL routes → SessionExecutionManager → ConfiguredAgent → query loop → store → SSE → Web UI @@ -297,7 +297,7 @@ partitionToolCalls → global permissions Every descriptor declares an explicit `outputPolicy`. Registry is the sole Raw-to-Finalized conversion boundary: blocked requests produce no settled result, while settled and synthetic results are finalized exactly once. `ToolOutputFinalizer` owns redaction of output/details and streaming capture redacts before artifact persistence; model, Session/SSE/UI, audit, and logger consume only finalized data. Large one-shot output is recovered through authorized, bounded `output_read` and `output_search` pages rather than a full-output escape hatch. -**Config** (`~/.archcode/config.json`): server-wide `provider..{npm, name, options, models}` + strict `profiles.{principal,deep,fast}.{model,variant,options}` + optional `memory`, `integrations.github`, and `mcp.servers..{url, headers, timeout}`. Strict Zod. Provider values are literal; MCP URL/headers and GitHub token resolution retain their environment-variable behavior. Project directories are never searched for configuration. +**Config** (`~/.archcode/config.json`): server-wide `provider..{npm, name, options, models}` + strict `profiles.{principal,deep,fast}.{model,variant,options}` + optional `memory`, `integrations.github`, and `mcp.{disabledBuiltins,servers}`. Each MCP server entry strictly requires `type: "http" | "stdio"` and `enabled`; HTTP uses `url`/`headers`, while STDIO uses `command`/`args`/`env`. Optional `connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs` default to 10,000/30,000/60,000 ms. Provider values are literal; MCP URL/header or STDIO env values and GitHub token resolution retain their environment-variable behavior. Project directories are never searched for configuration. **Model configuration** (`~/.archcode/config.json`): - Provider ids and model ids combine as `provider:modelId` (example: `"local:glm-5"`). Do **not** use `provider/model`. @@ -398,22 +398,16 @@ All six implement `Agent`: `store: StoreApi`, `run(options) - `plan-work` writes one ordinary Markdown Plan per Todo under `.archcode/plans/`. Plan has no service, state, ID, API, dedicated page, or Goal link. `execute-plan` is activated only by the Todo-to-work handoff when that file exists. - `review-work` guides Lead review orchestration. Analyst analysis/review Skills include `analyze-work`, `review-change`, and the reserved `goal-review` final gate. - A Skill is one package: required `SKILL.md`; optional `scripts/`, `references/`, `assets/`, and other contained resources. Its strict YAML frontmatter accepts `name`, `description`, optional `license`, `compatibility`, and `metadata`; `description` states both method and activation timing. -- Discovery (`skill_list` and available Prompt metadata) returns exactly name, description, and source. Entry activation (`skill_read({ name })`) returns the entry plus sorted resource descriptors; `skill_read({ name, resource })` reads exactly one listed text resource on demand. Binary assets are valid package resources but are not returned by the text-only tool. -- Project `.archcode/skills//` > user `~/.archcode/skills//` > embedded builtin is whole-package precedence: bodies and resources never merge or fall through. Reserved lifecycle builtins remain unshadowable and Agent-gated. +- Skill precedence is whole-package and strict: project `.archcode/skills//` > project `.agents/skills//` > user `~/.archcode/skills//` > user `~/.agents/skills//` > embedded builtin. Bodies and resources never merge or fall through. Reserved lifecycle builtins remain unshadowable and Agent-gated. +- Discovery (`skill_list` and available Prompt metadata) returns exactly name, description, and source. Prompt projection is bounded and reports omitted entries; `skill_list` returns digest-bound metadata pages with cursors for continuation. Entry activation (`skill_read({ name })`) returns the entry plus sorted resource descriptors; `skill_read({ name, resource })` reads exactly one listed text resource on demand. Binary assets are valid package resources but are not returned by the text-only tool. +- Invalid package candidates are surfaced as `SKILL_INVALID_PACKAGE` diagnostics. A winning invalid package fails closed; resolution never falls through to a lower-precedence package. The same winning package is claimed once for one `/skill use` logical Execution; `skill_read` uses that Execution snapshot and resume revalidates its digest. - Skills remain guidance only: their package metadata and resources cannot grant tools or permissions, execute scripts automatically, change Agent/Profile/MCP/workspace scope/delegation, or grant completion authority. Scripts use only existing Bash permissions. -**MCP visibility by agent:** - -| Agent | MCP servers | -|-------|-------------| -| `lead` | `context7`, `exa` | -| `discussion` | — | -| `analyst` | `context7` | -| `build` | — | -| `explore` | — | -| `librarian` | `context7`, `grep.app`, `exa` | - -**MCP tool resolution**: `AgentDefinition.mcpTools` lists MCP server names (e.g. `["context7", "exa"]`). `factoryResolveAllowedTools` merges matching `mcp__{server}__*` tools from the registry. MCP tools load in background; agents see them on the next `run()` call after registration. See MCP section below. +**MCP visibility**: User MCP servers are process-global and visible to all six +Agent identities from the current live runtime at the next model-call +boundary. They are not filtered by Agent role and do not add an approval step. +Built-in visibility remains the hardcoded role matrix in the MCP section below; +it is independent of user-server visibility. **Query loop lifecycle:** ``` @@ -449,7 +443,11 @@ beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-in ## Session Store -Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId, todoId }`, where Automation `todoId` is nullable; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Active Skill bodies are resolved again for every Execution. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. +Zustand vanilla store per Agent Session. `append(StreamEvent)` → `reduceStreamEvent()` → `toModelMessages()`. Strict Session identity includes `agentName`, immutable resolved `profile`, `activeSkillNames`, root/parent ids, cwd, delegated identity, and exactly one immutable `RootSessionSource` on every root: `direct`, `todo { todoId, entry }`, or `automation { automationId, invocationId, todoId }`, where Automation `todoId` is nullable; children never copy a root source. An optional `goal` belongs only to a root Lead Session. Strict identity validation requires Todo `discussion` entry ↔ Discussion Agent and every other root source ↔ Lead Agent. Persistent active Skill names are resolved when a new logical Execution is claimed; that Execution then uses immutable package snapshots through suspension and resume. Tool parts: `pending → running → completed | error`. `readSnapshots` (Map) supports the edit guard. Reminders include todo continuation and child terminal notifications. Persisted under the project workspace at `.archcode/runtime/sessions/{id}/session.json`, validated by strict `SessionFileSchema` on load. `SessionExecutionManager` alone owns logical Execution start/suspend/resume/end, admission, live run resources, and recovery. Store load performs no lifecycle repair; it exposes only current-schema durable facts and reducer state. + +Explicit `/skill use` claims the winning Skill package once for one logical +Execution; `skill_read` uses that Execution snapshot and a resumed Execution +revalidates its digest before continuing. ## Context Compaction @@ -483,13 +481,58 @@ HITL is a durable project-scoped approval/question queue backed by `.archcode/ru ## MCP -HTTP Streamable only. Built-in: context7, grep.app, exa (hardcoded in `BUILTIN_MCP_SERVERS` and non-overridable). User servers are read from `~/.archcode/config.json → mcp.servers`. Tool names: `mcp__{server}__{tool}`. Failed discovery = warning, not crash. - -**Background loading** (non-blocking): `McpManager.startBackgroundDiscovery()` fires-and-forgets at `createRuntime()` — server boots immediately while MCP servers connect in background. Per-server status: `pending → ready(toolCount, warningCount) | failed`; Prompt projection distinguishes `ready`, `ready-zero`, and `partial-warning`. Status is accessible via `AgentRuntime.getMcpServerStatuses()` and `AgentRuntime.subscribeMcpStatusChanges(listener)`. +MCP is a process-global live integration. `McpRuntimeService` is the high- +cohesion owner for resolved configuration, HTTP/STDIO transports, discovery, +tool inventory, status, Test, Reconnect, hot apply, and shutdown. It has no +Session, Execution, Agent, Tool Registry, permission, retry, or persistence +ownership. Tool names are `mcp__{server}__{tool}`; failed discovery is a +per-server warning/failure, not a Runtime crash. + +User servers are configured at `~/.archcode/config.json → mcp.servers`. Every +entry requires `type` (`http` or `stdio`) and `enabled`. HTTP uses `url` and +optional `headers`; STDIO uses `command`, optional `args`, and optional `env`. +`connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs` default to +10,000/30,000/60,000 ms. `mcp.disabledBuiltins` can disable fixed built-ins +(`context7`, `grep.app`, `exa`) but cannot replace them. + +Initial activation is non-blocking: the server publishes `connecting` or +`disabled` before transport work completes. Per-server status is +`disabled → connecting → ready(toolCount, warningCount) | failed`; Prompt +projection maps these to `disabled`, `connecting`, `ready`, `ready-zero`, +`partial-warning`, and `failed`. Status and inventory are available through the +global MCP routes and SSE status events; Settings also offers draft Test and +Reconnect. A Config save commits once, then hot-applies the resolved MCP config; +the independent `mcpApply` result reports whether live apply succeeded. + +At each model-call boundary, `ConfiguredAgent` takes a transient live MCP tool +descriptor/status projection for that call. Tool execution uses those exact +descriptors; a later reconnect, disable, or discovery change affects the next +boundary, not a call already handed to the model. The projection exists only +for that model-call boundary. + +All six Agent identities receive every configured user-server descriptor at +their next model-call boundary, with no role filter and no additional MCP +approval. Built-in visibility remains the locked role matrix: + +| Agent | Built-in MCP servers | +|-------|----------------------| +| `lead` | `context7`, `exa` | +| `discussion` | — | +| `analyst` | `context7` | +| `build` | — | +| `explore` | — | +| `librarian` | `context7`, `grep.app`, `exa` | -**Agent visibility**: agents opt into MCP tools via `mcpTools: ["context7", "exa"]` (server names) in their `AgentDefinition`. `factoryResolveAllowedTools` merges `mcp__{server}__*` tools from `ToolRegistry.listByPrefix()` — picks up tools registered after background load completes. Tools become visible on the next `run()` call (per-message resolution at `ConfiguredAgent.run()` line 189), not mid-message. +The matrix applies only to built-ins. A local read-only Agent can still invoke +a user MCP tool that writes to an external system; local tool read-only status +does not constrain external MCP side effects. -**SSE bridge**: MCP status changes emit `GlobalSSEMcpStatusEvent` (`type: "mcp_status"`) via `globalEventBus` → Web `useMcpStatusStore`. API route: `GET /api/mcp/status` (global, not project-scoped). Web `GlobalSSEProvider` fetches the snapshot on mount and on SSE `reset` events (reconnect) to populate the store even when connecting after MCP servers became ready. +MCP status changes emit `GlobalSSEMcpStatusEvent` (`type: "mcp_status"`) via +`globalEventBus` → Web `useMcpStatusStore`. API routes are global (not +project-scoped): `GET /api/mcp/status`, `GET /api/mcp/inventory`, +`POST /api/mcp/test/:serverName`, and `POST /api/mcp/reconnect/:serverName`. +Web `GlobalSSEProvider` fetches the status snapshot on mount and after an SSE +`reset` so late subscribers still see the current live state. ## Key Dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index b491c753..8b15e5c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) `SKILL.md` plus optional `scripts/`, `references/`, `assets/`, and other contained resources. Skill discovery is metadata-only; entry and listed resources are disclosed progressively. +- Replace the legacy MCP configuration path with a process-global live runtime + supporting required `type` + `enabled` HTTP and STDIO server entries, + independent connect/discovery/call deadlines (10s/30s/60s defaults), + `disabledBuiltins`, hot apply on Settings save, and status/Test/Reconnect + controls. User MCP servers are visible to all six Agent identities; the + built-in visibility matrix remains role-defined. ### Breaking Changes @@ -24,6 +30,13 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) paths. `when_to_use`, `allowed_tools`, and all other top-level frontmatter fields are rejected. There is no migration, fallback, compatibility reader, or resource merge with lower-precedence packages. +- MCP configuration is a hard cut. Every server entry now requires + `type: "http" | "stdio"` and `enabled`; HTTP uses `url`/`headers`, STDIO + uses `command`/`args`/`env`, and the single `timeout` field is replaced by + `connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs`. Built-in + opt-out uses `disabledBuiltins`; user-server role filters and extra MCP + approval assumptions are removed. There is no migration, compatibility + reader, or fallback for the old configuration shape. ## [0.0.8] - 2026-08-04 diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 18944de0..3e7494ac 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -11,7 +11,8 @@ const mockRuntime = { subscribeSessionRuntimeChanges: mock(() => () => undefined), subscribeMcpStatusChanges: mock(() => () => undefined), subscribeModelRuntimeChanges: mock(() => () => undefined), - getMcpServerStatuses: mock(() => new Map()), + getMcpServerStatus: mock(() => ({ servers: {} })), + getMcpServerInventory: mock(() => ({ servers: {} })), } as unknown as AgentRuntime; describe("createRuntimeApp", () => { @@ -41,6 +42,7 @@ describe("createRuntimeApp", () => { }, origin: "user_message", maxSteps: 50, + executionSkills: [], } }); listener({ type: "event", slug: "proj", sessionId: "session-1", eventId: 2, createdAt: 2, agentName: "lead", payload: { type: "execution-end", @@ -71,7 +73,7 @@ describe("createRuntimeApp", () => { const observed: GlobalSSEEvent[] = []; const unsubscribe = globalEventBus.subscribe((event) => observed.push(event)); createRuntimeApp(runtime); - listener!("context7", { state: "ready", toolCount: 1, warningCount: 0 }); + listener!("context7", { state: "ready", toolCount: 1, warningCount: 0, connectedAt: 1 }); expect(observed[0]).toMatchObject({ type: "mcp_status", serverName: "context7" }); unsubscribe(); }); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 83d04482..3389351b 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -20,6 +20,7 @@ import { createMessagesRoutes } from "./routes/messages"; import { createMcpRoutes } from "./routes/mcp"; import { createProjectsRoutes } from "./routes/projects"; import { createSessionsRoutes } from "./routes/sessions"; +import { createSkillsRoutes } from "./routes/skills"; import { createTodosRoutes } from "./routes/todos"; import { createToolOutputRoutes } from "./routes/tool-outputs"; import { globalEventBus } from "./events/global-event-bus"; @@ -64,6 +65,7 @@ export function createRuntimeApp( const automations = createAutomationsRoutes(serverRuntime); const todos = createTodosRoutes(serverRuntime); const sessions = createSessionsRoutes(serverRuntime); + const skills = createSkillsRoutes(serverRuntime); const messages = createMessagesRoutes(serverRuntime); const attachments = createAttachmentsRoutes(serverRuntime); const globalEvents = createGlobalEventsRoutes(globalEventBus, { @@ -89,6 +91,7 @@ export function createRuntimeApp( app.route("/api/projects", todos); app.route("/api/projects", projectHitl); app.route("/api/projects/:slug/sessions", sessions); + app.route("/api/projects", skills); app.route("/api/projects/:slug/sessions/:sessionId", messages); app.route("/api/projects/:slug/sessions/:sessionId/attachments", attachments); app.route("/api/projects/:slug/sessions/:sessionId/compression", compression); diff --git a/apps/server/src/errors.ts b/apps/server/src/errors.ts index 35bf4bcd..45eab91a 100644 --- a/apps/server/src/errors.ts +++ b/apps/server/src/errors.ts @@ -37,6 +37,7 @@ export type ServerErrorCode = | "TOOL_OUTPUT_INVALID_PATTERN" | "TOOL_OUTPUT_SEARCH_TIMEOUT" | "TOOL_OUTPUT_POLICY_VIOLATION" + | "SKILL_INVENTORY_CHANGED" | "ATTACHMENT_INVALID" | "ATTACHMENT_TOO_LARGE" | "ATTACHMENT_CONFLICT" diff --git a/apps/server/src/routes/attachments.test.ts b/apps/server/src/routes/attachments.test.ts index 4075d02c..47efb89a 100644 --- a/apps/server/src/routes/attachments.test.ts +++ b/apps/server/src/routes/attachments.test.ts @@ -175,7 +175,6 @@ async function createFixture(name: string) { }); const runtime = { projectRegistry, - warnings: [], uploadSessionAttachment: ( input: Parameters[0], ) => attachments.upload(input), @@ -189,7 +188,8 @@ async function createFixture(name: string) { subscribeMcpStatusChanges: () => () => undefined, listSessionRuntimeEvents: async () => [], listHitlSnapshotEvents: async () => [], - getMcpServerStatuses: () => new Map(), + getMcpServerStatus: () => ({ servers: {} }), + getMcpServerInventory: () => ({ servers: {} }), } as unknown as AgentRuntime; return { app: createRuntimeApp(runtime).app, diff --git a/apps/server/src/routes/compression.test.ts b/apps/server/src/routes/compression.test.ts index 9cbd00b4..450933fb 100644 --- a/apps/server/src/routes/compression.test.ts +++ b/apps/server/src/routes/compression.test.ts @@ -48,15 +48,14 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { const runtime = { projectRegistry, - mcpManager: undefined, toolRegistry: undefined, skillService: undefined, - warnings: [], contextResolver: undefined, hitl: undefined, subscribeSessionRuntimeChanges: () => () => undefined, subscribeMcpStatusChanges: () => () => undefined, - getMcpServerStatuses: () => new Map(), + getMcpServerStatus: () => ({ servers: {} }), + getMcpServerInventory: () => ({ servers: {} }), createSession: async () => { throw new Error("not implemented"); }, getSessionFile: async () => { throw new Error("not implemented"); }, resolveCompressionOriginalRange: mock(async (workspaceRoot: string, sessionId: string, blockRef: string) => { diff --git a/apps/server/src/routes/config.test.ts b/apps/server/src/routes/config.test.ts index f70cbd9e..a8b198fe 100644 --- a/apps/server/src/routes/config.test.ts +++ b/apps/server/src/routes/config.test.ts @@ -2,8 +2,14 @@ import { describe, expect, mock, test } from "bun:test"; import { ConfigRevisionConflictError, ConfigSemanticValidationError, - type ServerConfigService, } from "@archcode/agent-core"; +import type { + ModelRuntimeCatalog, + ProviderAdapterCatalog, + ServerConfigSnapshot, + UpdateServerConfigRequest, + UpdateServerConfigResponse, +} from "@archcode/protocol"; import { createConfigRoutes } from "./config"; import { errorHandler } from "../error-handler"; @@ -29,7 +35,15 @@ const snapshot = { modelRuntimeRevision: "revision-1", configPath: "/Users/test/.archcode/config.json", restartRequiredSections: [], -} as const; +} as unknown as ServerConfigSnapshot; + +const savedResponse: UpdateServerConfigResponse = { + ...snapshot, + mcpApply: { + state: "applied", + status: { servers: {} }, + }, +}; const modelRuntimeCatalog = { revision: "revision-1", @@ -49,17 +63,19 @@ const providerAdapterCatalog = [{ }], }] as const; -type ConfigServiceTestPort = Pick< - ServerConfigService, - "getSnapshot" | "getModelRuntimeCatalog" | "getProviderAdapterCatalog" | "save" ->; +type ConfigServiceTestPort = { + getSnapshot(): Promise; + getModelRuntimeCatalog(): ModelRuntimeCatalog; + getProviderAdapterCatalog(): ProviderAdapterCatalog; + save(request: UpdateServerConfigRequest): Promise; +}; function createService(overrides: Partial = {}) { return { getSnapshot: mock(async () => snapshot), getModelRuntimeCatalog: mock(() => modelRuntimeCatalog), getProviderAdapterCatalog: mock(() => providerAdapterCatalog), - save: mock(async () => snapshot), + save: mock(async () => savedResponse), ...overrides, } as ConfigServiceTestPort; } @@ -79,6 +95,18 @@ describe("config routes", () => { expect(await response.json()).toEqual(snapshot); }); + test("returns the independent MCP apply result with a config save", async () => { + const service = createService(); + const response = await createApp(service).request("/", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: "revision-1", config: snapshot.config }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(savedResponse); + }); + test("returns the secret-free model runtime catalog", async () => { const response = await createApp(createService()).request("/model-runtime"); diff --git a/apps/server/src/routes/files.test.ts b/apps/server/src/routes/files.test.ts index 33272766..ac0028b5 100644 --- a/apps/server/src/routes/files.test.ts +++ b/apps/server/src/routes/files.test.ts @@ -21,10 +21,8 @@ interface CreateTestAppOptions { function createTestRuntime(projectRegistry: ProjectRegistry): AgentRuntime { return { projectRegistry, - mcpManager: undefined, toolRegistry: undefined, skillService: undefined, - warnings: [], contextResolver: undefined, subscribeSessionRuntimeChanges: () => () => undefined, createSession: async () => ({ sessionId: "session", title: null, createdAt: Date.now(), messages: [], steps: [], todos: [], reminders: [] }), diff --git a/apps/server/src/routes/mcp.test.ts b/apps/server/src/routes/mcp.test.ts index 4b83e9a1..2207a1ea 100644 --- a/apps/server/src/routes/mcp.test.ts +++ b/apps/server/src/routes/mcp.test.ts @@ -1,98 +1,197 @@ +import { Hono } from "hono"; import { describe, expect, mock, test } from "bun:test"; -import type { AgentRuntime } from "@archcode/agent-core"; -import type { McpServerStatus } from "@archcode/protocol"; -import { createRuntimeApp } from "../app"; +import type { + McpServerInventoryResponse, + McpServerStatusResponse, + UpdateServerConfigRequest, +} from "@archcode/protocol"; -interface McpStatusResponseBody { - servers: Record; +import { errorHandler } from "../error-handler"; +import { createMcpRoutes, type McpRuntimePort, type McpTestResponse } from "./mcp"; + +const status: McpServerStatusResponse = { + servers: { + context7: { state: "ready", toolCount: 3, warningCount: 0, connectedAt: 100 }, + docs: { state: "connecting", startedAt: 200 }, + broken: { state: "failed", error: "connection refused", failedAt: 300 }, + disabled: { state: "disabled", updatedAt: 400 }, + }, +}; + +const inventory: McpServerInventoryResponse = { + servers: { + context7: [{ + serverName: "context7", + name: "search", + registryName: "mcp__context7__search", + description: "Search docs", + }], + docs: [], + }, +}; + +function createRuntime(overrides: Partial = {}): McpRuntimePort { + return { + getMcpServerStatus: mock(() => status), + getMcpServerInventory: mock(() => inventory), + testMcpServerDraft: mock(async () => ({ + tools: inventory.servers.context7 ?? [], + warnings: [], + } satisfies McpTestResponse)), + reconnectMcpServer: mock(async () => undefined), + ...overrides, + }; +} + +function createApp(runtime: McpRuntimePort): Hono { + const app = new Hono(); + app.route("/api/mcp", createMcpRoutes(runtime)); + app.onError(errorHandler); + return app; } -function createTestRuntime(statuses: Map): AgentRuntime { +function draft(): UpdateServerConfigRequest { return { - projectRegistry: undefined, - mcpManager: undefined, - toolRegistry: undefined, - skillService: undefined, - warnings: [], - contextResolver: undefined, - subscribeSessionRuntimeChanges: mock(() => () => undefined), - subscribeMcpStatusChanges: mock(() => () => undefined), - getMcpServerStatuses: mock(() => statuses), - createSession: async () => ({ sessionId: "session", title: null, createdAt: Date.now(), messages: [], steps: [], todos: [], reminders: [] }), - getSessionFile: async (_workspaceRoot: string, sessionId: string) => ({ sessionId, title: null, createdAt: Date.now(), messages: [], steps: [], todos: [], reminders: [] }), - listSessions: async () => [], - stopSessionFamily: async () => undefined, - abortAllSessionExecutions: async () => undefined, - getSessionFamilyActivity: () => "idle" as const, - getSessionExecution: () => undefined, - subscribeSessionEvents: () => () => undefined, - deleteSession: async () => undefined, - disposeSessionAgent: () => undefined, - disposeAllSessionAgents: () => undefined, - isSessionTombstoned: () => false, - notifyRuntimeShutdown: () => undefined, - } as unknown as AgentRuntime; + expectedRevision: "revision-1", + config: { + provider: {}, + profiles: { + principal: { model: "local:model" }, + deep: { model: "local:model" }, + fast: { model: "local:model" }, + }, + mcp: { + servers: { + docs: { + type: "http", + enabled: true, + url: "https://mcp.example.test", + }, + }, + }, + }, + } as UpdateServerConfigRequest; } -describe("GET /api/mcp/status", () => { - test("returns 200 with server statuses when MCP servers exist", async () => { - const statuses = new Map([ - ["context7", { state: "ready", toolCount: 3, warningCount: 0 }], - ["exa", { state: "pending" }], - ["broken", { state: "failed", error: "connection refused" }], - ["disabled-server", { state: "disabled" }], - ]); - const runtime = createTestRuntime(statuses); - const { app } = createRuntimeApp(runtime); - - const res = await app.request("/api/mcp/status"); - const body = (await res.json()) as McpStatusResponseBody; - - expect(res.status).toBe(200); - expect(body.servers).toEqual({ - "context7": { state: "ready", toolCount: 3, warningCount: 0 }, - "exa": { state: "pending" }, - "broken": { state: "failed", error: "connection refused" }, - "disabled-server": { state: "disabled" }, +describe("MCP control-plane routes", () => { + test("GET /api/mcp/status returns the runtime status DTO unchanged", async () => { + const runtime = createRuntime(); + const response = await createApp(runtime).request("/api/mcp/status"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(status); + expect(runtime.getMcpServerStatus).toHaveBeenCalledTimes(1); + }); + + test("GET /api/mcp/inventory returns the runtime inventory DTO unchanged", async () => { + const runtime = createRuntime(); + const response = await createApp(runtime).request("/api/mcp/inventory"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(inventory); + expect(runtime.getMcpServerInventory).toHaveBeenCalledTimes(1); + }); + + test("POST /api/mcp/test/:serverName forwards the complete draft without writing", async () => { + const request = draft(); + const runtime = createRuntime(); + const response = await createApp(runtime).request("/api/mcp/test/docs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + tools: inventory.servers.context7, + warnings: [], + }); + expect(runtime.testMcpServerDraft).toHaveBeenCalledWith("docs", request, { + signal: expect.any(AbortSignal), }); }); - test("returns 200 with empty servers object when no MCP servers configured", async () => { - const runtime = createTestRuntime(new Map()); - const { app } = createRuntimeApp(runtime); + test("POST /api/mcp/test/:serverName forwards the exact HTTP request signal", async () => { + const controller = new AbortController(); + let receivedSignal: AbortSignal | undefined; + const runtime = createRuntime({ + testMcpServerDraft: mock(async ( + _serverName: string, + _request: UpdateServerConfigRequest, + options?: { signal?: AbortSignal }, + ) => { + receivedSignal = options?.signal; + return { tools: [], warnings: [] }; + }), + }); + const httpRequest = new Request("http://localhost/api/mcp/test/docs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(draft()), + signal: controller.signal, + }); - const res = await app.request("/api/mcp/status"); - const body = (await res.json()) as McpStatusResponseBody; + const response = await createApp(runtime).request(httpRequest); - expect(res.status).toBe(200); - expect(body.servers).toEqual({}); + expect(response.status).toBe(200); + expect(receivedSignal).toBe(controller.signal); }); - test("returns correct pending/ready/failed states matching the runtime", async () => { - const statuses = new Map([ - ["pending-srv", { state: "pending" }], - ["ready-srv", { state: "ready", toolCount: 5, warningCount: 2 }], - ["failed-srv", { state: "failed", error: "boom" }], - ]); - const runtime = createTestRuntime(statuses); - const { app } = createRuntimeApp(runtime); - - const res = await app.request("/api/mcp/status"); - const body = (await res.json()) as McpStatusResponseBody; - - expect(res.status).toBe(200); - expect(body.servers["pending-srv"]).toEqual({ state: "pending" }); - expect(body.servers["ready-srv"]).toEqual({ state: "ready", toolCount: 5, warningCount: 2 }); - expect(body.servers["failed-srv"]).toEqual({ state: "failed", error: "boom" }); + test("POST /api/mcp/reconnect/:serverName reconnects then returns the status snapshot", async () => { + const runtime = createRuntime(); + const response = await createApp(runtime).request("/api/mcp/reconnect/docs", { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(status); + expect(runtime.reconnectMcpServer).toHaveBeenCalledWith("docs"); + expect(runtime.getMcpServerStatus).toHaveBeenCalledTimes(1); }); - test("route is mounted at /api/mcp/status (global, no project slug)", async () => { - const runtime = createTestRuntime(new Map()); - const { app } = createRuntimeApp(runtime); + test("rejects malformed draft bodies and invalid server names with 400", async () => { + const runtime = createRuntime(); + const app = createApp(runtime); + const malformedBodies: unknown[] = [ + null, + [], + {}, + { expectedRevision: "revision-1" }, + { config: {}, expectedRevision: 1 }, + { config: {}, expectedRevision: "revision-1", extra: true }, + ]; + + for (const body of malformedBodies) { + const response = await app.request("/api/mcp/test/docs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(400); + } + + for (const serverName of ["bad/name", "bad__name", "bad name", "bad?name"]) { + const response = await app.request(`/api/mcp/test/${encodeURIComponent(serverName)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(draft()), + }); + expect(response.status).toBe(400); + } + expect(runtime.testMcpServerDraft).not.toHaveBeenCalled(); + }); - const res = await app.request("/api/mcp/status"); + test("leaves runtime errors to the existing HTTP error handler", async () => { + const runtime = createRuntime({ + reconnectMcpServer: mock(async () => { + throw new Error("connection details must stay server-side"); + }), + }); + const response = await createApp(runtime).request("/api/mcp/reconnect/docs", { method: "POST" }); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ servers: {} }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: { code: "INTERNAL_ERROR", message: "Internal server error" }, + }); }); }); diff --git a/apps/server/src/routes/mcp.ts b/apps/server/src/routes/mcp.ts index b65cb539..088c59e6 100644 --- a/apps/server/src/routes/mcp.ts +++ b/apps/server/src/routes/mcp.ts @@ -1,25 +1,98 @@ import { Hono } from "hono"; -import type { AgentRuntime } from "@archcode/agent-core"; -import type { McpServerStatus } from "@archcode/protocol"; +import type { + McpServerInventoryResponse, + McpServerStatusResponse, + McpToolInventoryItem, + UpdateServerConfigRequest, +} from "@archcode/protocol"; + +import { BadRequestError } from "../errors"; +import { readBoundedJsonBody } from "../request-body"; + +const MCP_DRAFT_BODY_MAX_BYTES = 2 * 1024 * 1024; +const MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/; /** - * MCP routes. Mounted under `/api/mcp` so the MCP status endpoint is reachable - * at `/api/mcp/status`. - * - * MCP status is runtime-global (not per-project); the route intentionally has no - * `:slug` segment so clients can fetch the snapshot without a project context. + * The server route only owns HTTP validation and dispatch. Config resolution, + * secret policy, draft testing, and live MCP lifecycle stay behind this port. */ -export function createMcpRoutes(runtime: AgentRuntime): Hono { +export interface McpRuntimePort { + getMcpServerStatus(): McpServerStatusResponse; + getMcpServerInventory(): McpServerInventoryResponse; + testMcpServerDraft( + serverName: string, + request: UpdateServerConfigRequest, + options?: { signal?: AbortSignal }, + ): Promise; + reconnectMcpServer(serverName: string): Promise; +} + +export interface McpTestResponse { + readonly tools: McpToolInventoryItem[]; + readonly warnings: string[]; +} + +/** MCP routes are global (not project-scoped). */ +export function createMcpRoutes(runtime: McpRuntimePort): Hono { const app = new Hono(); - app.get("/status", (c) => { - const statuses = runtime.getMcpServerStatuses(); - const servers: Record = {}; - for (const [name, status] of statuses) { - servers[name] = status; - } - return c.json({ servers }); + app.get("/status", (c) => c.json(runtime.getMcpServerStatus())); + app.get("/inventory", (c) => c.json(runtime.getMcpServerInventory())); + + app.post("/test/:serverName", async (c) => { + const serverName = parseServerName(c.req.param("serverName")); + const request = parseDraftRequest(await readBoundedJsonBody(c.req.raw, { + maxBytes: MCP_DRAFT_BODY_MAX_BYTES, + label: "MCP test draft", + })); + return c.json(await runtime.testMcpServerDraft(serverName, request, { + signal: c.req.raw.signal, + })); + }); + + app.post("/reconnect/:serverName", async (c) => { + const serverName = parseServerName(c.req.param("serverName")); + await runtime.reconnectMcpServer(serverName); + return c.json(runtime.getMcpServerStatus()); }); return app; -} \ No newline at end of file +} + +function parseServerName(value: string | undefined): string { + if ( + value === undefined + || value.length === 0 + || !MCP_SERVER_NAME_PATTERN.test(value) + || value.includes("__") + ) { + throw new BadRequestError("Invalid MCP server name"); + } + return value; +} + +function parseDraftRequest(value: unknown): UpdateServerConfigRequest { + if (!isRecord(value)) { + throw new BadRequestError("MCP test draft must be an object"); + } + const keys = Object.keys(value).sort(); + if ( + keys.length !== 2 + || keys[0] !== "config" + || keys[1] !== "expectedRevision" + ) { + throw new BadRequestError("MCP test draft must contain only expectedRevision and config"); + } + if ( + typeof value.expectedRevision !== "string" + || value.expectedRevision.length === 0 + || !isRecord(value.config) + ) { + throw new BadRequestError("MCP test draft must include expectedRevision and config"); + } + return value as unknown as UpdateServerConfigRequest; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/server/src/routes/messages.test.ts b/apps/server/src/routes/messages.test.ts index 06c98fdb..fa4b9be2 100644 --- a/apps/server/src/routes/messages.test.ts +++ b/apps/server/src/routes/messages.test.ts @@ -12,7 +12,7 @@ const requestedModelSelection = { }; function createTestRuntime(projectRegistry: ProjectRegistry): AgentRuntime { - const pending = { id: "message-1", clientRequestId: "request-1", content: "Hello", attachments: [], state: "queued" as const, revision: 0, requestedModelSelection }; + const pending = { id: "message-1", clientRequestId: "request-1", content: "Hello", attachments: [], state: "queued" as const, revision: 0, requestedModelSelection, executionSkillNames: [] }; return { projectRegistry, contextResolver: undefined, @@ -28,7 +28,8 @@ function createTestRuntime(projectRegistry: ProjectRegistry): AgentRuntime { subscribeHitlEvents: mock(() => () => undefined), subscribeSessionRuntimeChanges: mock(() => () => undefined), subscribeMcpStatusChanges: mock(() => () => undefined), - getMcpServerStatuses: mock(() => new Map()), + getMcpServerStatus: mock(() => ({ servers: {} })), + getMcpServerInventory: mock(() => ({ servers: {} })), acceptSessionMessage: mock(async () => ({ clientRequestId: pending.clientRequestId, messageId: pending.id, status: "pending" as const, message: pending })), editPendingSessionMessage: mock(async () => ({ ...pending, content: "Edited", revision: 1 })), deletePendingSessionMessage: mock(async () => ({ messageId: pending.id, clientRequestId: pending.clientRequestId, revision: 2 })), diff --git a/apps/server/src/routes/projects.test.ts b/apps/server/src/routes/projects.test.ts index 0eb6009f..05000830 100644 --- a/apps/server/src/routes/projects.test.ts +++ b/apps/server/src/routes/projects.test.ts @@ -16,10 +16,8 @@ function createTestRuntime( ): AgentRuntime { return { projectRegistry, - mcpManager: undefined, toolRegistry: undefined, skillService: undefined, - warnings: [], contextResolver: undefined, reconcileRegisteredProject: async () => undefined, startAutomationScheduler: async () => undefined, diff --git a/apps/server/src/routes/sessions.test.ts b/apps/server/src/routes/sessions.test.ts index 2e449b83..fe7c42f5 100644 --- a/apps/server/src/routes/sessions.test.ts +++ b/apps/server/src/routes/sessions.test.ts @@ -94,10 +94,8 @@ function createTestRuntime(projectRegistry: ProjectRegistry) { const runtime = { projectRegistry, - mcpManager: undefined, toolRegistry: undefined, skillService: undefined, - warnings: [], contextResolver: undefined, subscribeSessionRuntimeChanges: () => () => undefined, createSession: async (workspaceRoot: string) => { diff --git a/apps/server/src/routes/skills.test.ts b/apps/server/src/routes/skills.test.ts new file mode 100644 index 00000000..5a4fd473 --- /dev/null +++ b/apps/server/src/routes/skills.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { SessionFileNotFoundError, type AgentRuntime } from "@archcode/agent-core"; +import type { ProjectSkillInventoryResponse, SkillPromptProjection } from "@archcode/protocol"; +import { errorHandler } from "../error-handler"; +import { createSkillsRoutes } from "./skills"; + +const roots: string[] = []; +type SkillInventoryPage = Awaited>; +type SkillPromptCatalog = Awaited>; + +const promptProjection: SkillPromptProjection = { + includedEntries: [{ name: "review", description: "Review changes", source: "builtin" }], + omittedCount: 1, + renderedText: "- review: Review changes (source=builtin)\n- 1 additional Skills omitted; use skill_list to continue discovery.", + byteLength: 108, +}; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("project Skill inventory routes", () => { + test("returns a cursor page and the canonical Prompt projection for the registered workspace", async () => { + const fixture = await createFixture(); + const inventory = { + name: "review", + source: "builtin" as const, + sourceLabel: "builtin", + winner: true, + shadowed: false, + valid: true, + description: "Review changes", + }; + fixture.runtime.skillService.inventoryPage.mockResolvedValueOnce({ + items: [inventory], + nextCursor: "cursor-2", + }); + fixture.runtime.skillService.projectPromptCatalog.mockResolvedValueOnce(promptProjection); + + const response = await fixture.app.request("/api/projects/project/skills?cursor=cursor-1"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + items: [{ + name: "review", + source: "builtin", + winner: true, + shadowed: false, + valid: true, + description: "Review changes", + }], + nextCursor: "cursor-2", + promptProjection, + }); + expect(fixture.runtime.skillService.inventoryPage).toHaveBeenCalledWith(fixture.workspaceRoot, "cursor-1"); + expect(fixture.runtime.skillService.projectPromptCatalog).toHaveBeenCalledWith(fixture.workspaceRoot); + expect(fixture.runtime.getSessionSkillCatalog).not.toHaveBeenCalled(); + }); + + test("uses the Session-scoped runtime inventory so worktree cwd and Agent builtin policy stay authoritative", async () => { + const fixture = await createFixture(); + fixture.runtime.getSessionSkillCatalog.mockResolvedValueOnce({ + items: [ + { name: "orchestrate-work", source: "builtin", winner: true, shadowed: false, valid: true }, + { name: "worktree-custom", source: "project-agents", winner: true, shadowed: false, valid: true }, + ], + nextCursor: "session-page-2", + promptProjection: { + includedEntries: [ + { name: "orchestrate-work", description: "Lead workflow", source: "builtin" }, + { name: "worktree-custom", description: "Worktree custom", source: "project-agents" }, + ], + omittedCount: 0, + renderedText: "session skills", + byteLength: 14, + }, + }); + + const response = await fixture.app.request("/api/projects/project/skills?cursor=session-page-1&sessionId=child-session"); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.items.map((item: { name: string }) => item.name)).toEqual(["orchestrate-work", "worktree-custom"]); + expect(body.items.map((item: { name: string }) => item.name)).not.toContain("review-change"); + expect(body.nextCursor).toBe("session-page-2"); + expect(fixture.runtime.getSessionSkillCatalog).toHaveBeenCalledWith( + fixture.workspaceRoot, + "child-session", + "session-page-1", + ); + expect(fixture.runtime.skillService.inventoryPage).not.toHaveBeenCalled(); + expect(fixture.runtime.skillService.projectPromptCatalog).not.toHaveBeenCalled(); + }); + + test("keeps invalid-package diagnostics bounded to the Web DTO", async () => { + const fixture = await createFixture(); + fixture.runtime.skillService.inventoryPage.mockResolvedValueOnce({ + items: [{ + name: "broken", + source: "project-archcode", + sourceLabel: "/private/project/.archcode/skills/broken", + winner: true, + shadowed: false, + valid: false, + diagnostic: { + name: "broken", + source: "project-archcode", + code: "SKILL_INVALID_PACKAGE", + message: "Skill package failed validation", + }, + }], + }); + fixture.runtime.skillService.projectPromptCatalog.mockResolvedValueOnce({ + includedEntries: [], + omittedCount: 0, + renderedText: "- none", + byteLength: 6, + }); + + const response = await fixture.app.request("/api/projects/project/skills"); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ + items: [{ + name: "broken", + source: "project-archcode", + winner: true, + shadowed: false, + valid: false, + diagnostic: { code: "SKILL_INVALID_PACKAGE", message: "Skill package failed validation" }, + }], + promptProjection: { + includedEntries: [], + omittedCount: 0, + renderedText: "- none", + byteLength: 6, + }, + }); + expect(JSON.stringify(body)).not.toContain("/private/project"); + }); + + test("validates the cursor and resolves missing projects through the shared HTTP errors", async () => { + const fixture = await createFixture(); + const malformed = await fixture.app.request("/api/projects/project/skills?cursor=bad.cursor"); + expect(malformed.status).toBe(400); + expect(fixture.runtime.skillService.inventoryPage).not.toHaveBeenCalled(); + + const emptySession = await fixture.app.request("/api/projects/project/skills?sessionId=%20%20"); + expect(emptySession.status).toBe(400); + const unknownQuery = await fixture.app.request("/api/projects/project/skills?sessionId=root&unexpected=true"); + expect(unknownQuery.status).toBe(400); + expect(fixture.runtime.getSessionSkillCatalog).not.toHaveBeenCalled(); + + const missing = await fixture.app.request("/api/projects/missing/skills"); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ + error: { code: "PROJECT_NOT_FOUND", message: "Project not found: missing" }, + }); + }); + + test("returns a stable 400 when a cursor no longer matches the inventory digest", async () => { + const fixture = await createFixture(); + fixture.runtime.skillService.inventoryPage.mockRejectedValueOnce(Object.assign( + new Error("Catalog changed or cursor is invalid; restart from the first page"), + { name: "DigestBoundCursorError", code: "SKILL_INVENTORY_CHANGED" }, + )); + fixture.runtime.skillService.projectPromptCatalog.mockResolvedValueOnce(promptProjection); + + const response = await fixture.app.request("/api/projects/project/skills?cursor=stale"); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: { + code: "SKILL_INVENTORY_CHANGED", + message: "Catalog changed or cursor is invalid; restart from the first page", + }, + }); + }); + + test("maps a missing Session inventory scope to the stable Session 404", async () => { + const fixture = await createFixture(); + fixture.runtime.getSessionSkillCatalog.mockRejectedValueOnce(new SessionFileNotFoundError("missing-session")); + + const response = await fixture.app.request("/api/projects/project/skills?sessionId=missing-session"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: { code: "SESSION_NOT_FOUND", message: "Session not found: missing-session" }, + }); + }); +}); + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), "archcode-skills-route-")); + roots.push(root); + const workspaceRoot = join(root, "workspace"); + await mkdir(workspaceRoot, { recursive: true }); + + const runtime = { + projectRegistry: { + get: mock(async (slug: string) => slug === "project" + ? { slug, name: "Project", workspaceRoot, addedAt: new Date(0).toISOString() } + : undefined), + }, + skillService: { + inventoryPage: mock(async (_workspaceRoot: string, _cursor?: string): Promise => ({ items: [] })), + projectPromptCatalog: mock(async (_workspaceRoot: string): Promise => ({ + includedEntries: [], + omittedCount: 0, + renderedText: "- none", + byteLength: 6, + })), + }, + getSessionSkillCatalog: mock(async ( + _workspaceRoot: string, + _sessionId: string, + _cursor?: string, + ): Promise => ({ + items: [], + promptProjection: { + includedEntries: [], + omittedCount: 0, + renderedText: "- none", + byteLength: 6, + }, + })), + }; + + const app = new Hono(); + app.onError(errorHandler); + app.route("/api/projects", createSkillsRoutes(runtime as unknown as AgentRuntime)); + return { app, runtime, workspaceRoot }; +} diff --git a/apps/server/src/routes/skills.ts b/apps/server/src/routes/skills.ts new file mode 100644 index 00000000..cca8e41a --- /dev/null +++ b/apps/server/src/routes/skills.ts @@ -0,0 +1,104 @@ +import { Hono } from "hono"; +import { SessionFileNotFoundError } from "@archcode/agent-core"; +import type { AgentRuntime } from "@archcode/agent-core"; +import type { + ProjectSkillInventoryItem, + ProjectSkillInventoryResponse, +} from "@archcode/protocol"; +import { z } from "zod/v4"; +import { ServerError, SessionNotFoundError } from "../errors"; +import { resolveProject } from "../resolve"; +import { zValidator } from "../validation"; + +const SkillParamsSchema = z.strictObject({ slug: z.string().min(1) }); +const SkillCursorSchema = z.string() + .min(1) + .max(16 * 1024) + .regex(/^[A-Za-z0-9_-]+$/); +const SkillQuerySchema = z.strictObject({ + cursor: SkillCursorSchema.optional(), + sessionId: z.string().trim().min(1).max(128).optional(), +}); + +type SkillInventoryPage = Awaited>; +type SkillPromptCatalog = Awaited>; + +/** Project-scoped Skill discovery and diagnostics for the Web workbench. */ +export function createSkillsRoutes(runtime: AgentRuntime): Hono { + const app = new Hono(); + + app.get( + "/:slug/skills", + zValidator("param", SkillParamsSchema), + zValidator("query", SkillQuerySchema), + async (c) => { + const { slug } = c.req.valid("param"); + const { cursor, sessionId } = c.req.valid("query"); + const project = await resolveProject(runtime, slug); + + try { + if (sessionId !== undefined) { + return c.json(await runtime.getSessionSkillCatalog( + project.workspaceRoot, + sessionId, + cursor, + )); + } + const { page, promptProjection } = await projectSkillInventory(runtime, project.workspaceRoot, cursor); + const response: ProjectSkillInventoryResponse = { + items: page.items.map(toInventoryItem), + ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }), + promptProjection, + }; + return c.json(response); + } catch (error) { + if (sessionId !== undefined && error instanceof SessionFileNotFoundError) { + throw new SessionNotFoundError(sessionId); + } + if (isDigestBoundCursorError(error)) { + if (error.code === "SKILL_INVENTORY_CHANGED") { + throw new ServerError("SKILL_INVENTORY_CHANGED", error.message, 400); + } + throw new ServerError("BAD_REQUEST", error.message, 400, { scopeCode: error.code }); + } + throw error; + } + }, + ); + + return app; +} + +async function projectSkillInventory( + runtime: AgentRuntime, + workspaceRoot: string, + cursor?: string, +): Promise<{ readonly page: SkillInventoryPage; readonly promptProjection: SkillPromptCatalog }> { + const [page, promptProjection] = await Promise.all([ + runtime.skillService.inventoryPage(workspaceRoot, cursor), + runtime.skillService.projectPromptCatalog(workspaceRoot), + ]); + return { page, promptProjection }; +} + +function isDigestBoundCursorError(error: unknown): error is Error & { readonly code: string } { + return error instanceof Error + && error.name === "DigestBoundCursorError" + && "code" in error + && typeof error.code === "string"; +} + +type SkillInventoryRecordLike = SkillInventoryPage["items"][number]; + +function toInventoryItem(record: SkillInventoryRecordLike): ProjectSkillInventoryItem { + const { diagnostic, sourceLabel: _sourceLabel, ...item } = record; + return diagnostic === undefined + ? item + : { + ...item, + diagnostic: { + code: diagnostic.code, + message: diagnostic.message, + }, + }; +} diff --git a/apps/server/src/server-host.test.ts b/apps/server/src/server-host.test.ts index 93f321c4..f94a0627 100644 --- a/apps/server/src/server-host.test.ts +++ b/apps/server/src/server-host.test.ts @@ -85,7 +85,11 @@ function fakeRuntime(configService: ServerConfigService): AgentRuntime { subscribeMcpStatusChanges: mock(() => () => undefined), subscribeModelRuntimeChanges: mock(() => () => undefined), subscribeResourceChanges: mock(() => () => undefined), - getMcpServerStatuses: mock(() => new Map()), + getMcpServerStatus: mock(() => ({ servers: {} })), + getMcpServerInventory: mock(() => ({ servers: {} })), + applyMcpConfig: mock(async () => undefined), + testMcpServerDraft: mock(async () => ({ tools: [], warnings: [] })), + reconnectMcpServer: mock(async () => undefined), listSessionRuntimeEvents: mock(async () => []), listHitlSnapshotEvents: mock(async () => []), } as unknown as AgentRuntime; @@ -1280,6 +1284,90 @@ describe("ArchCodeServerHost", () => { })).status).toBe(409); }); + test("commits Config then hot-applies the exact resolved MCP config", async () => { + const home = await createHome(); + await mkdir(join(home, ".archcode"), { recursive: true }); + await writeFile(resolveServerConfigPath(home), `${JSON.stringify(diskConfig())}\n`, { mode: 0o600 }); + const configService = new ServerConfigService({ homeDir: home }); + const runtime = fakeRuntime(configService); + const host = await ArchCodeServerHost.create({ + configService, + createRuntime: mock(async () => runtime), + logger: silentLogger, + ...hostInfrastructure(home), + }); + host.startRuntimeActivation(); + await waitForRuntimeState(host, "ready"); + const snapshot = await (await host.app.request("/api/config")).json() as any; + snapshot.config.provider.local.options.apiKey = { action: "preserve" }; + snapshot.config.mcp = { + disabledBuiltins: ["exa"], + servers: { + docs: { type: "http", enabled: true, url: "https://docs.test/mcp" }, + }, + }; + + const response = await host.app.request("/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: snapshot.revision, config: snapshot.config }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ mcpApply: { state: "applied", status: { servers: {} } } }); + expect(runtime.applyMcpConfig).toHaveBeenCalledWith({ + disabledBuiltins: ["exa"], + servers: { + docs: { + type: "http", + enabled: true, + url: "https://docs.test/mcp", + headers: undefined, + connectTimeoutMs: 10_000, + discoveryTimeoutMs: 30_000, + callTimeoutMs: 60_000, + }, + }, + }); + }); + + test("keeps a committed Config when MCP hot-apply fails", async () => { + const home = await createHome(); + await mkdir(join(home, ".archcode"), { recursive: true }); + await writeFile(resolveServerConfigPath(home), `${JSON.stringify(diskConfig())}\n`, { mode: 0o600 }); + const configService = new ServerConfigService({ homeDir: home }); + const runtime = fakeRuntime(configService); + runtime.applyMcpConfig = mock(async () => { throw new Error("secret transport failure"); }); + const host = await ArchCodeServerHost.create({ + configService, + createRuntime: mock(async () => runtime), + logger: silentLogger, + ...hostInfrastructure(home), + }); + host.startRuntimeActivation(); + await waitForRuntimeState(host, "ready"); + const snapshot = await (await host.app.request("/api/config")).json() as any; + snapshot.config.provider.local.options.apiKey = { action: "preserve" }; + snapshot.config.provider.local.models["test-model"].name = "Committed despite MCP failure"; + + const response = await host.app.request("/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: snapshot.revision, config: snapshot.config }), + }); + + expect(response.status).toBe(200); + const body = await response.json() as any; + expect(body.mcpApply).toEqual({ + state: "failed", + error: "Configuration was saved, but MCP live apply failed", + status: { servers: {} }, + }); + expect(JSON.stringify(body)).not.toContain("secret transport failure"); + expect((await configService.getSnapshot()).config.provider.local.models["test-model"].name) + .toBe("Committed despite MCP failure"); + }); + test("serializes concurrent Config save before delete and retry recovery mutations", async () => { for (const recovery of ["delete", "retry"] as const) { const home = await createHome(); @@ -1328,12 +1416,12 @@ describe("ArchCodeServerHost", () => { await waitForRuntimeState(host, "error"); order.length = 0; - const originalSave = configService.save.bind(configService); + const originalSave = configService.saveWithRuntimeConfig.bind(configService); let signalSaveEntered!: () => void; const saveEntered = new Promise((resolve) => { signalSaveEntered = resolve; }); let releaseSave!: () => void; const saveGate = new Promise((resolve) => { releaseSave = resolve; }); - configService.save = mock(async (request) => { + configService.saveWithRuntimeConfig = mock(async (request) => { order.push("save:start"); signalSaveEntered(); await saveGate; diff --git a/apps/server/src/server-host.ts b/apps/server/src/server-host.ts index fdb78dea..96fa7aa3 100644 --- a/apps/server/src/server-host.ts +++ b/apps/server/src/server-host.ts @@ -621,7 +621,44 @@ export class ArchCodeServerHost implements SetupCoordinatorPort, ConfigRecoveryC getModelRuntimeCatalog: () => this.options.configService.getModelRuntimeCatalog(), getProviderAdapterCatalog: () => this.options.configService.getProviderAdapterCatalog(), save: (request) => this.runHostMutation( - () => this.options.configService.save(request), + async () => { + const saved = await this.options.configService.saveWithRuntimeConfig(request); + const runtime = this.runtime; + if (runtime === undefined) { + return { + ...saved.snapshot, + mcpApply: { + state: "failed" as const, + error: "Configuration was saved, but the Runtime is unavailable for MCP live apply", + status: { servers: {} }, + }, + }; + } + try { + await runtime.applyMcpConfig(saved.resolvedMcpConfig); + return { + ...saved.snapshot, + mcpApply: { + state: "applied" as const, + status: runtime.getMcpServerStatus(), + }, + }; + } catch (error) { + this.options.logger.error("server.config.mcp-apply.failed", { + error: { + name: error instanceof Error ? error.name : "NonErrorThrow", + }, + }); + return { + ...saved.snapshot, + mcpApply: { + state: "failed" as const, + error: "Configuration was saved, but MCP live apply failed", + status: runtime.getMcpServerStatus(), + }, + }; + } + }, ), })); diff --git a/apps/web/src/api/config.test.ts b/apps/web/src/api/config.test.ts index 4184871c..ad67509a 100644 --- a/apps/web/src/api/config.test.ts +++ b/apps/web/src/api/config.test.ts @@ -72,6 +72,12 @@ describe("config API", () => { }, }, }, + mcp: { + servers: { + http: { type: "http", enabled: true, url: "https://mcp.example.test", headers: { Authorization: { configured: true } } }, + stdio: { type: "stdio", enabled: true, command: "server", env: { TOKEN: { configured: true } } }, + }, + }, } as never, revision: "r1", modelRuntimeRevision: "m1", @@ -86,6 +92,10 @@ describe("config API", () => { }); expect(draft.config.provider.local!.options.advancedFeature).toEqual({ configured: true }); expect(draft.config.provider.local!.options.nested).toEqual({ keep: [1, { enabled: true }] }); + const http = draft.config.mcp!.servers.http; + const stdio = draft.config.mcp!.servers.stdio; + expect(http.type === "http" ? http.headers?.Authorization : undefined).toEqual({ action: "preserve" }); + expect(stdio.type === "stdio" ? stdio.env?.TOKEN : undefined).toEqual({ action: "preserve" }); }); test("loads Provider adapters and model runtime from their stable endpoints", async () => { diff --git a/apps/web/src/api/config.ts b/apps/web/src/api/config.ts index 1431cec8..3a22ffd9 100644 --- a/apps/web/src/api/config.ts +++ b/apps/web/src/api/config.ts @@ -11,6 +11,7 @@ import type { ServerConfigSnapshot as ServerConfigSnapshotView, ServerConfigUpdate, UpdateServerConfigRequest, + UpdateServerConfigResponse, } from "@archcode/protocol"; import { apiFetch } from "./client"; @@ -79,9 +80,12 @@ export function toConfigDraft( } } for (const server of Object.values(config.mcp?.servers ?? {})) { - if (server.headers !== undefined) { + if (server.type === "http" && server.headers !== undefined) { server.headers = secretViewsToMutations(server.headers) as typeof server.headers; } + if (server.type === "stdio" && server.env !== undefined) { + server.env = secretViewsToMutations(server.env) as typeof server.env; + } } return { ...snapshot, @@ -93,8 +97,8 @@ export async function getServerConfig(): Promise { return apiFetch("/api/config"); } -export async function saveServerConfig(input: SaveServerConfigInput): Promise { - return apiFetch("/api/config", { +export async function saveServerConfig(input: SaveServerConfigInput): Promise { + return apiFetch("/api/config", { method: "PUT", body: input as unknown as Record, }); diff --git a/apps/web/src/api/mcp.test.ts b/apps/web/src/api/mcp.test.ts index 621939bf..659f90ee 100644 --- a/apps/web/src/api/mcp.test.ts +++ b/apps/web/src/api/mcp.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import type { McpServerStatus } from "@archcode/protocol"; import { ApiError } from "./client"; -import { getMcpStatus } from "./mcp"; +import { getMcpInventory, getMcpStatus, reconnectMcpServer, testMcpDraft } from "./mcp"; const originalFetch = globalThis.fetch; const originalDocument = globalThis.document; @@ -11,6 +11,37 @@ afterEach(() => { globalThis.document = originalDocument; }); +describe("MCP control actions", () => { + test("tests a named server with the complete unsaved Config request", async () => { + globalThis.document = { cookie: "" } as Document; + const request = { expectedRevision: "r1", config: { provider: {}, profiles: {} } } as never; + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("/api/mcp/test/local%20draft"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual(request); + return jsonResponse({ tools: [], warnings: [] }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(testMcpDraft("local draft", request)).resolves.toEqual({ tools: [], warnings: [] }); + }); + + test("loads inventory and reconnects only by saved server identity", async () => { + globalThis.document = { cookie: "" } as Document; + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === "/api/mcp/inventory") return jsonResponse({ servers: { local: [] } }); + expect(String(input)).toBe("/api/mcp/reconnect/local"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBeUndefined(); + return jsonResponse({ servers: { local: { state: "connecting", startedAt: 1 } } }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(getMcpInventory()).resolves.toEqual({ local: [] }); + await expect(reconnectMcpServer("local")).resolves.toEqual({ local: { state: "connecting", startedAt: 1 } }); + }); +}); + function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: init.status ?? 200, @@ -23,23 +54,23 @@ describe("getMcpStatus", () => { globalThis.document = { cookie: "" } as Document; const fetchMock = mock(async (input: RequestInfo | URL) => { expect(String(input)).toBe("/api/mcp/status"); - return jsonResponse({ servers: { context7: { state: "ready", toolCount: 3, warningCount: 0 } } }); + return jsonResponse({ servers: { context7: { state: "ready", toolCount: 3, warningCount: 0, connectedAt: 1 } } }); }); globalThis.fetch = fetchMock as unknown as typeof fetch; const result = await getMcpStatus(); - expect(result).toEqual({ context7: { state: "ready", toolCount: 3, warningCount: 0 } }); + expect(result).toEqual({ context7: { state: "ready", toolCount: 3, warningCount: 0, connectedAt: 1 } }); expect(fetchMock).toHaveBeenCalledTimes(1); }); test("returns the servers object from the response", async () => { globalThis.document = { cookie: "" } as Document; const servers: Record = { - context7: { state: "ready", toolCount: 2, warningCount: 1 }, - grep: { state: "pending" }, - exa: { state: "failed", error: "down" }, - disabled: { state: "disabled" }, + context7: { state: "ready", toolCount: 2, warningCount: 1, connectedAt: 1 }, + grep: { state: "connecting", startedAt: 1 }, + exa: { state: "failed", error: "down", failedAt: 1 }, + disabled: { state: "disabled", updatedAt: 1 }, }; const fetchMock = mock(async () => jsonResponse({ servers })); globalThis.fetch = fetchMock as unknown as typeof fetch; diff --git a/apps/web/src/api/mcp.ts b/apps/web/src/api/mcp.ts index 62d45e94..df53676a 100644 --- a/apps/web/src/api/mcp.ts +++ b/apps/web/src/api/mcp.ts @@ -1,5 +1,10 @@ import { apiFetch } from "./client"; -import type { McpServerStatus } from "@archcode/protocol"; +import type { + McpServerInventoryResponse, + McpServerStatus, + McpToolInventoryItem, + UpdateServerConfigRequest, +} from "@archcode/protocol"; export type McpServerStatusMap = Record; @@ -8,4 +13,27 @@ export async function getMcpStatus(): Promise { "/api/mcp/status", ); return res.servers; -} \ No newline at end of file +} + +export async function getMcpInventory(): Promise { + const response = await apiFetch("/api/mcp/inventory"); + return response.servers ?? {}; +} + +export interface McpDraftTestResponse { + tools: McpToolInventoryItem[]; + warnings: string[]; +} + +export function testMcpDraft(serverName: string, request: UpdateServerConfigRequest): Promise { + return apiFetch(`/api/mcp/test/${encodeURIComponent(serverName)}`, { + method: "POST", + body: request as unknown as Record, + }); +} + +export async function reconnectMcpServer(serverName: string): Promise { + return (await apiFetch<{ servers: McpServerStatusMap }>(`/api/mcp/reconnect/${encodeURIComponent(serverName)}`, { + method: "POST", + })).servers; +} diff --git a/apps/web/src/api/skills.test.ts b/apps/web/src/api/skills.test.ts new file mode 100644 index 00000000..288f3d6b --- /dev/null +++ b/apps/web/src/api/skills.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { getCompleteProjectSkillInventory, getCompleteProjectSkillInventoryView } from "./skills"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { globalThis.fetch = originalFetch; }); + +describe("project Skill inventory", () => { + test("traverses every cursor page and marks prompt-omitted winners", async () => { + Object.defineProperty(globalThis, "document", { configurable: true, value: { cookie: "" } }); + const promptProjection = { + includedEntries: [{ name: "included", description: "In prompt", source: "builtin" as const }], + omittedCount: 1, + renderedText: "skills", + byteLength: 6, + }; + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/projects/demo/skills") return Response.json({ + items: [{ name: "invalid", source: "project-archcode", winner: true, shadowed: false, valid: false, diagnostic: { code: "SKILL_INVALID_PACKAGE", message: "bad" } }], + nextCursor: "page-2", + promptProjection, + }); + expect(url).toBe("/api/projects/demo/skills?cursor=page-2"); + return Response.json({ + items: [ + { name: "included", source: "builtin", winner: true, shadowed: false, valid: true, description: "In prompt" }, + { name: "omitted", source: "builtin", winner: true, shadowed: false, valid: true, description: "Not in prompt" }, + { name: "included", source: "user-agents", winner: false, shadowed: true, valid: true }, + ], + promptProjection, + }); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const view = await getCompleteProjectSkillInventoryView("demo"); + + expect(view.items.map((item) => [item.name, item.valid, item.shadowed, item.promptOmitted])).toEqual([ + ["invalid", false, false, false], + ["included", true, false, false], + ["omitted", true, false, true], + ["included", true, true, false], + ]); + expect(view.promptProjection).toEqual(promptProjection); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("rejects a repeated cursor instead of looping forever", async () => { + Object.defineProperty(globalThis, "document", { configurable: true, value: { cookie: "" } }); + globalThis.fetch = mock(async () => Response.json({ + items: [], + nextCursor: "same", + promptProjection: { includedEntries: [], omittedCount: 0, renderedText: "", byteLength: 0 }, + })) as unknown as typeof fetch; + + await expect(getCompleteProjectSkillInventory("demo")).rejects.toThrow("repeated cursor"); + }); + + test("keeps Settings project-scoped and sends the Session id through every Composer page", async () => { + Object.defineProperty(globalThis, "document", { configurable: true, value: { cookie: "" } }); + const urls: string[] = []; + const promptProjection = { includedEntries: [], omittedCount: 0, renderedText: "", byteLength: 0 }; + globalThis.fetch = mock(async (input: RequestInfo | URL) => { + const url = String(input); + urls.push(url); + if (url === "/api/projects/demo/skills?sessionId=session%2Fone") { + return Response.json({ items: [], nextCursor: "page/2", promptProjection }); + } + return Response.json({ items: [], promptProjection }); + }) as unknown as typeof fetch; + + await getCompleteProjectSkillInventoryView("demo"); + await getCompleteProjectSkillInventory("demo", "session/one"); + + expect(urls).toEqual([ + "/api/projects/demo/skills", + "/api/projects/demo/skills?sessionId=session%2Fone", + "/api/projects/demo/skills?cursor=page%2F2&sessionId=session%2Fone", + ]); + }); +}); diff --git a/apps/web/src/api/skills.ts b/apps/web/src/api/skills.ts new file mode 100644 index 00000000..c8b9593d --- /dev/null +++ b/apps/web/src/api/skills.ts @@ -0,0 +1,62 @@ +import { apiFetch } from "./client"; +import type { + ProjectSkillInventoryItem, + ProjectSkillInventoryResponse, + SkillPromptProjection, +} from "@archcode/protocol"; + +export type ProjectSkillPickerItem = ProjectSkillInventoryItem & { readonly promptOmitted: boolean }; + +export interface CompleteProjectSkillInventory { + readonly items: readonly ProjectSkillPickerItem[]; + readonly promptProjection: SkillPromptProjection; +} + +export function getProjectSkillInventoryPage( + slug: string, + cursor?: string, + sessionId?: string, +): Promise { + const query = new URLSearchParams(); + if (cursor !== undefined) query.set("cursor", cursor); + if (sessionId !== undefined) query.set("sessionId", sessionId); + const suffix = query.size === 0 ? "" : `?${query.toString()}`; + return apiFetch(`/api/projects/${encodeURIComponent(slug)}/skills${suffix}`); +} + +export async function getCompleteProjectSkillInventoryView( + slug: string, + sessionId?: string, +): Promise { + const items: ProjectSkillInventoryItem[] = []; + let includedNames = new Set(); + let promptProjection: SkillPromptProjection | undefined; + const seenCursors = new Set(); + let cursor: string | undefined; + do { + const page = await getProjectSkillInventoryPage(slug, cursor, sessionId); + items.push(...page.items); + promptProjection = page.promptProjection; + includedNames = new Set(page.promptProjection.includedEntries.map((entry) => entry.name)); + cursor = page.nextCursor; + if (cursor !== undefined) { + if (seenCursors.has(cursor)) throw new Error("Skill inventory returned a repeated cursor"); + seenCursors.add(cursor); + } + } while (cursor !== undefined); + if (promptProjection === undefined) throw new Error("Skill inventory returned no page"); + return { + items: items.map((item) => ({ + ...item, + promptOmitted: item.valid && item.winner && !item.shadowed && !includedNames.has(item.name), + })), + promptProjection, + }; +} + +export async function getCompleteProjectSkillInventory( + slug: string, + sessionId?: string, +): Promise { + return [...(await getCompleteProjectSkillInventoryView(slug, sessionId)).items]; +} diff --git a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx index 14b09533..e59b48d2 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx @@ -47,6 +47,7 @@ function completed(id = "execution"): SessionExecutionRecord { startedAt: 0, origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 100, status: "completed", endedAt: 100, @@ -71,6 +72,7 @@ function running(id = "execution"): SessionExecutionRecord { startedAt: 0, origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 0, status: "running", runs: [{ ordinal: 0, startedAt: 0, binding }], @@ -97,6 +99,7 @@ function suspended( startedAt: 0, origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 10, status: "suspended", suspension, diff --git a/apps/web/src/components/features/ChatHeader.test.tsx b/apps/web/src/components/features/ChatHeader.test.tsx index c702a057..fd81c943 100644 --- a/apps/web/src/components/features/ChatHeader.test.tsx +++ b/apps/web/src/components/features/ChatHeader.test.tsx @@ -32,6 +32,7 @@ function suspended(): SessionExecutionRecord { startedAt: 0, origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 1, status: "suspended", suspension: { kind: "hitl", toolBatchId: "batch", blockerIds: ["hitl"] }, diff --git a/apps/web/src/components/features/ChatInput.test.tsx b/apps/web/src/components/features/ChatInput.test.tsx index fedfd7eb..a10e87c9 100644 --- a/apps/web/src/components/features/ChatInput.test.tsx +++ b/apps/web/src/components/features/ChatInput.test.tsx @@ -70,6 +70,7 @@ const stopSessionMutate = mock((_variables: unknown) => {}); const addLocalSendingMessage = mock((_input: unknown) => {}); const removeLocalSendingMessage = mock((_clientRequestId: string) => {}); const setLocalSendingMessageStatus = mock((_clientRequestId: string, _status: string) => {}); +const getCompleteProjectSkillInventory = mock(async (_slug: string, _sessionId?: string) => []); let activity: SessionFamilyActivity | undefined; let pendingHitlCount = 0; let hitlReady = false; @@ -127,6 +128,9 @@ let modelCatalog: ModelRuntimeCatalog = { revision: "m1", providers: [], profile mock.module("../../api/queries", () => ({ useModelRuntime: () => ({ data: modelCatalog, isFetching: modelRuntimeFetching }), })); +mock.module("../../api/skills", () => ({ + getCompleteProjectSkillInventory, +})); mock.module("../../context/settings-modal", () => ({ useSettingsModal: () => ({ openSettingsModal: mock(() => {}) }) })); mock.module("../../store/session-store", () => ({ @@ -206,6 +210,7 @@ describe("ChatInput runtime controls", () => { addLocalSendingMessage.mockClear(); removeLocalSendingMessage.mockClear(); setLocalSendingMessageStatus.mockClear(); + getCompleteProjectSkillInventory.mockClear(); }); test("renders one unified composer card with a real attachment control", () => { @@ -233,6 +238,86 @@ describe("ChatInput runtime controls", () => { expect(textContent(tree)).not.toContain("Images may be sent to the selected model provider."); }); + test("renders the complete Skill inventory with accessible availability states", () => { + activity = "idle"; + hitlReady = true; + stateValues[0] = "/skill use "; + stateValues[1] = true; + stateValues[2] = "skill use "; + stateValues[3] = 0; + stateValues[4] = [ + { name: "ready", source: "builtin", winner: true, shadowed: false, valid: true, description: "Ready Skill" }, + { name: "omitted", source: "builtin", winner: true, shadowed: false, valid: true, promptOmitted: true }, + { name: "shadowed", source: "user-agents", winner: false, shadowed: true, valid: true }, + { name: "invalid", source: "project-archcode", winner: true, shadowed: false, valid: false, diagnostic: { code: "SKILL_INVALID_PACKAGE", message: "Invalid frontmatter" } }, + ]; + stateValues[5] = "ready"; + + const tree = rerenderChatInput(); + const menu = findAll(tree, (element) => element.props?.role === "listbox")[0]; + const options = findAll(tree, (element) => element.props?.role === "option"); + const textarea = findAll(tree, (element) => element.type === "textarea")[0]; + + expect(menu?.props?.["aria-label"]).toBe("Skills"); + expect(options).toHaveLength(4); + expect(options.map(textContent)).toEqual([ + "readyReady SkillbuiltinWinnerValid", + "omittedbuiltinWinnerValidPrompt omitted", + "shadoweduser-agentsShadowedValid", + "invalidInvalid frontmatterproject-archcodeWinnerInvalid", + ]); + expect(options.map((option) => option.props?.disabled)).toEqual([false, false, true, true]); + expect(options[0]?.props?.className).toContain("flex-wrap"); + expect(findAll(options[0], (element) => element.type === "span" && String(element.props?.className).includes("break-all"))).not.toHaveLength(0); + expect(textarea?.props?.["aria-controls"]).toBe("composer-slash-menu"); + expect(textarea?.props?.["aria-activedescendant"]).toBe("composer-slash-option-0"); + }); + + test("offers an actual retry after Skill inventory loading fails", () => { + activity = "idle"; + hitlReady = true; + stateValues[0] = "/skill use "; + stateValues[1] = true; + stateValues[2] = "skill use "; + stateValues[3] = 0; + stateValues[4] = []; + stateValues[5] = "failed"; + + const tree = rerenderChatInput(); + const retry = findAll(tree, (element) => element.type === "button" && textContent(element) === "Retry")[0]; + + expect(findAll(tree, (element) => element.props?.role === "group" && element.props?.["aria-label"] === "Skills")).toHaveLength(1); + expect(retry).toBeDefined(); + (retry?.props?.onClick as () => void)(); + expect(stateValues[5]).toBe("loading"); + expect(getCompleteProjectSkillInventory).toHaveBeenCalledWith("proj", "root-1"); + }); + + test("sends Skill selection through the ordinary postMessage command path", () => { + activity = "idle"; + hitlReady = true; + stateValues[0] = "/skill use ready"; + stateValues[1] = true; + stateValues[2] = "skill use ready"; + stateValues[3] = 0; + stateValues[4] = [{ name: "ready", source: "builtin", winner: true, shadowed: false, valid: true }]; + stateValues[5] = "ready"; + const tree = rerenderChatInput(); + const textarea = findAll(tree, (element) => element.type === "textarea")[0]; + const preventDefault = mock(() => {}); + + (textarea.props?.onKeyDown as (event: unknown) => void)({ key: "Enter", shiftKey: false, nativeEvent: { isComposing: false }, preventDefault }); + + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(postMessageMutate).toHaveBeenCalledTimes(1); + expect(postMessageMutate.mock.calls[0]?.[0]).toMatchObject({ + slug: "proj", + sessionId: "root-1", + content: "/skill use ready", + attachmentIds: [], + }); + }); + test("accepts a selected file and enables an attachment-only send", () => { activity = "idle"; hitlReady = true; diff --git a/apps/web/src/components/features/ChatInput.tsx b/apps/web/src/components/features/ChatInput.tsx index 8cf896f4..8cf09ba8 100644 --- a/apps/web/src/components/features/ChatInput.tsx +++ b/apps/web/src/components/features/ChatInput.tsx @@ -18,12 +18,14 @@ import { ModelPicker } from "./ModelPicker"; import { coherentModelRuntime } from "../../lib/model-runtime-coherence"; import { createClientUuid } from "../../lib/client-uuid"; import { sessionFamilyActivityLabel } from "../../lib/session-family-presentation"; +import { getCompleteProjectSkillInventory, type ProjectSkillPickerItem } from "../../api/skills"; import type { StatusTone, VisualStatusKind } from "../../lib/status-visuals"; import { StatusGlyph } from "../primitives/StatusGlyph"; import { formatAttachmentSize } from "../primitives/AttachmentChip"; const SLASH_COMMANDS = [ { name: "/compact", description: "Compact conversation context" }, + { name: "/skill use", description: "Activate a Skill" }, ] as const; type SlashCommand = (typeof SLASH_COMMANDS)[number]; @@ -92,6 +94,8 @@ export function ChatInput({ const [showSlashMenu, setShowSlashMenu] = useState(false); const [slashFilter, setSlashFilter] = useState(""); const [slashActiveIndex, setSlashActiveIndex] = useState(0); + const [skillInventory, setSkillInventory] = useState([]); + const [skillInventoryState, setSkillInventoryState] = useState<"idle" | "loading" | "ready" | "failed">("idle"); const [hitlComposerExpanded, setHitlComposerExpanded] = useState(false); const [attachments, setAttachments] = useState([]); const [attachmentNotice, setAttachmentNotice] = useState(); @@ -100,6 +104,7 @@ export function ChatInput({ const slashMenuRef = useRef(null); const fileInputRef = useRef(null); const attachmentsRef = useRef([]); + const skillInventoryRequestRef = useRef(0); const modelSelection = useSessionStore(sessionId, (state) => state.modelSelection, slug); const nextModelSelection = useSessionStore(sessionId, (state) => state.nextModelSelection, slug); @@ -130,9 +135,53 @@ export function ChatInput({ && !attachmentUploadInProgress && !(hasAttachments && isSlashInput(value)); const status = composerStatus(activity, hitlReady, hasPendingHitl); - const filteredCommands = SLASH_COMMANDS.filter((command) => - command.name.startsWith(`/ ${slashFilter}`.replace(/\s/g, "")), + const skillUseInput = /^\/skill\s+use(?:\s+(.*))?$/i.exec(value); + const selectingSkill = skillUseInput !== null; + const skillQuery = skillUseInput?.[1]?.trim().toLowerCase() ?? ""; + const filteredSkills = skillUseInput === null ? [] : skillInventory.filter((skill) => + skill.name.toLowerCase().includes(skillQuery) || skill.description?.toLowerCase().includes(skillQuery), ); + const filteredCommands = (skillUseInput === null ? SLASH_COMMANDS : []).filter((command) => + command.name.replace(/\s/g, "").startsWith(`/ ${slashFilter}`.replace(/\s/g, "")), + ); + const slashOptionCount = skillUseInput === null ? filteredCommands.length : filteredSkills.length; + + useEffect(() => { + skillInventoryRequestRef.current += 1; + setSkillInventory([]); + setSkillInventoryState("idle"); + }, [sessionId, slug]); + + const loadSkillInventory = useCallback(() => { + const request = ++skillInventoryRequestRef.current; + setSkillInventoryState("loading"); + void getCompleteProjectSkillInventory(slug, sessionId).then((items) => { + if (request !== skillInventoryRequestRef.current) return; + setSkillInventory(items); + setSkillInventoryState("ready"); + }).catch(() => { + if (request === skillInventoryRequestRef.current) setSkillInventoryState("failed"); + }); + }, [sessionId, slug]); + + useEffect(() => { + if (!selectingSkill || skillInventoryState !== "idle") return; + loadSkillInventory(); + }, [loadSkillInventory, selectingSkill, skillInventoryState]); + + useEffect(() => { + if (!selectingSkill && skillInventoryState === "failed") setSkillInventoryState("idle"); + }, [selectingSkill, skillInventoryState]); + + useEffect(() => { + if (!selectingSkill || filteredSkills.length === 0) return; + setSlashActiveIndex((current) => { + const currentSkill = filteredSkills[current]; + if (currentSkill && skillIsSelectable(currentSkill)) return current; + const firstSelectable = filteredSkills.findIndex(skillIsSelectable); + return firstSelectable < 0 ? 0 : firstSelectable; + }); + }, [selectingSkill, skillInventory, skillQuery]); const adjustHeight = useCallback(() => { const element = textareaRef.current; @@ -303,6 +352,13 @@ export function ChatInput({ const selectSlashCommand = useCallback((command: SlashCommand) => { if (!canCompose || isQueueing || hasPendingHitl) return; if (!nextModelSelection) return; + if (command.name === "/skill use") { + setValue("/skill use "); + setSlashFilter("skill use "); + setSlashActiveIndex(0); + requestAnimationFrame(() => textareaRef.current?.focus()); + return; + } if (attachmentsRef.current.length > 0) { setAttachmentNotice(SLASH_ATTACHMENT_GUIDANCE); return; @@ -314,6 +370,19 @@ export function ChatInput({ textareaRef.current?.focus(); }, [canCompose, hasPendingHitl, isQueueing, nextModelSelection, submitMessage]); + const selectSkill = useCallback((skill: ProjectSkillPickerItem) => { + if (!skill.valid || skill.shadowed || !skill.winner || !canCompose || isQueueing || hasPendingHitl || !nextModelSelection) return; + if (attachmentsRef.current.length > 0) { + setAttachmentNotice(SLASH_ATTACHMENT_GUIDANCE); + return; + } + submitMessage(`/skill use ${skill.name}`, [], nextModelSelection.requested); + setShowSlashMenu(false); + setSlashFilter(""); + setSlashActiveIndex(0); + textareaRef.current?.focus(); + }, [canCompose, hasPendingHitl, isQueueing, nextModelSelection, submitMessage]); + const selectModel = useCallback((requestedModelSelection: RequestedModelSelection) => { patchModelSelection.mutate({ slug, @@ -328,20 +397,27 @@ export function ChatInput({ }, [modelSelection.revision, patchModelSelection, sessionId, slug]); const handleKeyDown = useCallback((event: React.KeyboardEvent) => { - if (showSlashMenu && filteredCommands.length > 0) { + if (showSlashMenu && slashOptionCount > 0) { if (event.key === "ArrowUp") { event.preventDefault(); - setSlashActiveIndex((index) => index <= 0 ? filteredCommands.length - 1 : index - 1); + setSlashActiveIndex((index) => nextSlashIndex(index, -1, slashOptionCount, filteredSkills, selectingSkill)); return; } if (event.key === "ArrowDown") { event.preventDefault(); - setSlashActiveIndex((index) => index >= filteredCommands.length - 1 ? 0 : index + 1); + setSlashActiveIndex((index) => nextSlashIndex(index, 1, slashOptionCount, filteredSkills, selectingSkill)); return; } if ((event.key === "Enter" || event.key === "Tab") && !event.nativeEvent.isComposing) { + const selectedSkill = skillUseInput === null ? undefined : filteredSkills[slashActiveIndex]; + if (selectedSkill !== undefined && !skillIsSelectable(selectedSkill)) { + if (event.key === "Enter") event.preventDefault(); + else setShowSlashMenu(false); + return; + } event.preventDefault(); - selectSlashCommand(filteredCommands[slashActiveIndex]); + if (selectedSkill !== undefined) selectSkill(selectedSkill); + else selectSlashCommand(filteredCommands[slashActiveIndex]); return; } if (event.key === "Escape") { @@ -365,12 +441,16 @@ export function ChatInput({ } }, [ filteredCommands, + filteredSkills, isQueueing, selectSlashCommand, + selectSkill, sendMessage, sessionId, showSlashMenu, slashActiveIndex, + slashOptionCount, + skillUseInput, slug, stopSession, ]); @@ -468,26 +548,53 @@ export function ChatInput({ return (
- {showSlashMenu && filteredCommands.length > 0 && canCompose && !isQueueing && !hasPendingHitl && ( + {showSlashMenu && canCompose && !isQueueing && !hasPendingHitl && (slashOptionCount > 0 || skillUseInput !== null) && (
- {filteredCommands.map((command, index) => ( + {skillUseInput !== null && skillInventoryState === "loading" &&

Loading Skills…

} + {skillUseInput !== null && skillInventoryState === "failed" &&
+

Unable to load Skills.

+ +
} + {skillUseInput !== null && skillInventoryState === "ready" && filteredSkills.length === 0 &&

No matching Skills.

} + {(skillUseInput === null ? filteredCommands : filteredSkills).map((entry, index) => { + const skill = skillUseInput === null ? undefined : entry as ProjectSkillPickerItem; + const command = skillUseInput === null ? entry as SlashCommand : undefined; + const unavailable = skill !== undefined && !skillIsSelectable(skill); + const id = `composer-slash-option-${index}`; + return ( - ))} + );})}
)} @@ -527,6 +634,11 @@ export function ChatInput({ onChange={handleChange} onKeyDown={handleKeyDown} onPaste={handlePaste} + aria-label="Message" + aria-autocomplete="list" + aria-controls={showSlashMenu ? "composer-slash-menu" : undefined} + aria-expanded={showSlashMenu} + aria-activedescendant={showSlashMenu && slashOptionCount > 0 ? `composer-slash-option-${slashActiveIndex}` : undefined} disabled={!canCompose} placeholder={ !runtimeReady @@ -637,3 +749,23 @@ export function ChatInput({
); } + +function skillIsSelectable(skill: ProjectSkillPickerItem): boolean { + return skill.valid && skill.winner && !skill.shadowed; +} + +function nextSlashIndex( + current: number, + direction: -1 | 1, + count: number, + skills: readonly ProjectSkillPickerItem[], + selectingSkill: boolean, +): number { + if (!selectingSkill) return (current + direction + count) % count; + for (let offset = 1; offset <= count; offset += 1) { + const candidate = (current + direction * offset + count * 2) % count; + const skill = skills[candidate]; + if (skill !== undefined && skillIsSelectable(skill)) return candidate; + } + return current; +} diff --git a/apps/web/src/components/features/ComposerQueueList.interaction.tsx b/apps/web/src/components/features/ComposerQueueList.interaction.tsx index e4116d51..bb489b16 100644 --- a/apps/web/src/components/features/ComposerQueueList.interaction.tsx +++ b/apps/web/src/components/features/ComposerQueueList.interaction.tsx @@ -149,6 +149,7 @@ describe("ComposerQueueList", () => { content: "Original queued instruction", attachments: [], source: "user", + executionSkillNames: [], state: "queued", revision: 7, acceptedAt: 1, @@ -160,6 +161,7 @@ describe("ComposerQueueList", () => { content: "Steering instruction", attachments: [], source: "user", + executionSkillNames: [], state: "steering", revision: 3, acceptedAt: 2, diff --git a/apps/web/src/components/features/SessionComposerDock.interaction.tsx b/apps/web/src/components/features/SessionComposerDock.interaction.tsx index e0c630ad..69db6b35 100644 --- a/apps/web/src/components/features/SessionComposerDock.interaction.tsx +++ b/apps/web/src/components/features/SessionComposerDock.interaction.tsx @@ -208,6 +208,7 @@ describe("SessionComposerDock", () => { content: "Queued request", attachments: [], source: "user", + executionSkillNames: [], state: "queued", revision: 1, acceptedAt: 3, @@ -220,6 +221,7 @@ describe("SessionComposerDock", () => { content: "Steering request", attachments: [], source: "user", + executionSkillNames: [], state: "steering", revision: 2, acceptedAt: 4, diff --git a/apps/web/src/components/features/SettingsDialog.interaction.tsx b/apps/web/src/components/features/SettingsDialog.interaction.tsx index 404245b6..7a3d53f2 100644 --- a/apps/web/src/components/features/SettingsDialog.interaction.tsx +++ b/apps/web/src/components/features/SettingsDialog.interaction.tsx @@ -5,6 +5,7 @@ import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ProviderAdapterCatalog } from "@archcode/protocol"; import type { ServerConfigSnapshot } from "../../api/config"; +import type { ServerConfig } from "../../api/config"; import { DialogRoot } from "../ui/Dialog"; import { RuntimeRecoverySettings, SettingsBody as SettingsBodyComponent, SettingsCloseButton } from "./SettingsDialog"; import { SettingsRuntimeDataPanel } from "./SettingsRuntimeDataPanel"; @@ -19,7 +20,7 @@ const snapshot: ServerConfigSnapshot = { config: { provider: { local: { npm: "@ai-sdk/openai-compatible", name: "Local", options: { baseURL: "http://localhost/v1", apiKey: { action: "preserve" }, headers: { Authorization: { action: "preserve" } } }, models: { demo: { name: "Demo", limit: { context: 1000, output: 500 }, modalities: { input: ["text"], output: ["text"] }, variants: { fast: { temperature: 0.1 } } } } } }, profiles: { principal: { model: "local:demo" }, deep: { model: "local:demo" }, fast: { model: "local:demo" } }, - mcp: { servers: { custom: { url: "https://example.com/mcp", headers: { Authorization: { action: "preserve" } } } } }, + mcp: { servers: { custom: { type: "http", enabled: true, url: "https://example.com/mcp", headers: { Authorization: { action: "preserve" } } } } }, }, }; @@ -96,6 +97,7 @@ function successfulSaveResponse(restartRequiredSections: ServerConfigSnapshot["r ...snapshot, modelRuntimeRevision: "m2", restartRequiredSections, + mcpApply: { state: "applied" as const, status: { servers: {} } }, config: { ...snapshot.config, provider: { @@ -108,18 +110,24 @@ function successfulSaveResponse(restartRequiredSections: ServerConfigSnapshot["r }, }, }, - mcp: { servers: { custom: { ...snapshot.config.mcp!.servers.custom, headers: { Authorization: { configured: true } } } } }, + mcp: { servers: { custom: { type: "http" as const, enabled: true, url: "https://example.com/mcp", headers: { Authorization: { configured: true } } } } }, }, }; } +function customHttpServer(config: ServerConfig) { + const server = config.mcp?.servers.custom; + if (server?.type !== "http") throw new Error("Expected custom HTTP MCP server"); + return server; +} + beforeEach(() => installDom()); afterEach(() => { act(() => root.unmount()); dom.window.close(); }); describe("SettingsDialog interactions", () => { test("keeps the apply notice and Settings workspace inside one bounded column", () => { const withNotice = structuredClone(snapshot); - withNotice.restartRequiredSections = ["mcp"]; + withNotice.restartRequiredSections = ["memory"]; act(() => root.render( {}} />)); const layout = container.querySelector("[data-settings-layout]") as HTMLElement; @@ -145,6 +153,7 @@ describe("SettingsDialog interactions", () => { ["Models", "Providers and their model profiles"], ["Profiles", "Principal, deep, and fast model bindings"], ["MCP", "MCP servers"], + ["Skills", "Open a project to inspect its Skills"], ["Memory", "Configure extraction thresholds"], ["GitHub", "Optional GitHub integration settings"], ]; @@ -227,7 +236,7 @@ describe("SettingsDialog interactions", () => { name: "Existing provider three", }; sparse.config.provider.local.models["model-3"] = structuredClone(snapshot.config.provider.local.models.demo); - sparse.config.mcp!.servers["server-3"] = { url: "https://three.example.com/mcp" }; + sparse.config.mcp!.servers["server-3"] = { type: "http", enabled: true, url: "https://three.example.com/mcp" }; act(() => root.render( {}} />)); click("Add provider"); @@ -316,13 +325,33 @@ describe("SettingsDialog interactions", () => { test("reports live-applied Models separately from named restart sections", async () => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async () => Response.json({ - ...successfulSaveResponse(["mcp", "memory"]), + ...successfulSaveResponse(["memory"]), })) }); act(() => root.render( {}} />)); click("Add provider"); await act(async () => { click("Save changes"); await Promise.resolve(); }); expect(container.textContent).toContain("Model and Profile changes applied live"); - expect(container.textContent).toContain("Restart required for: MCP, Memory"); + expect(container.textContent).toContain("Restart required for: Memory"); + }); + + test("keeps the saved-but-MCP-apply-failed outcome through the matching reload", async () => { + const response = { + ...successfulSaveResponse(), + revision: "r2", + mcpApply: { state: "failed" as const, error: "Connection refused", status: { servers: {} } }, + }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async () => Response.json(response)) }); + const reloaded = { ...snapshot, revision: "r2" }; + const onReload = async () => { + root.render(); + }; + act(() => root.render()); + + click("Add provider"); + await act(async () => { click("Save changes"); await Promise.resolve(); await Promise.resolve(); }); + + expect(container.querySelector("footer [role=\"alert\"]")?.textContent) + .toBe("Configuration saved, but MCP live apply failed: Connection refused"); }); test("clears a prior live-applied notice before a failed follow-up save", async () => { @@ -407,7 +436,7 @@ describe("SettingsDialog interactions", () => { const providerOptions = config.provider.local.options as unknown as { apiKey?: unknown; headers?: Record }; expect(providerOptions.apiKey).toEqual({ action: "preserve" }); expect(providerOptions.headers?.Authorization).toEqual({ action: "preserve" }); - expect(config.mcp?.servers.custom.headers?.Authorization).toEqual({ action: "preserve" }); + expect(customHttpServer(config).headers?.Authorization).toEqual({ action: "preserve" }); }); test("submits multi-character replacements for every secret collection", async () => { @@ -432,7 +461,7 @@ describe("SettingsDialog interactions", () => { expect(providerOptions.apiKey).toEqual({ action: "replace", value: "api-secret-123" }); expect(providerOptions.headers?.Authorization).toEqual({ action: "replace", value: "provider-header-123" }); expect(providerOptions.queryParams?.header).toEqual({ action: "replace", value: "query-secret-123" }); - expect(config.mcp?.servers.custom.headers?.Authorization).toEqual({ action: "replace", value: "mcp-header-123" }); + expect(customHttpServer(config).headers?.Authorization).toEqual({ action: "replace", value: "mcp-header-123" }); }); test("submits deletes for provider query and MCP header secrets", async () => { @@ -451,7 +480,7 @@ describe("SettingsDialog interactions", () => { const config = request?.config as typeof snapshot.config; const providerOptions = config.provider.local.options as unknown as { queryParams?: Record }; expect(providerOptions.queryParams?.token).toEqual({ action: "delete" }); - expect(config.mcp?.servers.custom.headers?.Authorization).toEqual({ action: "delete" }); + expect(customHttpServer(config).headers?.Authorization).toEqual({ action: "delete" }); }); test("uses variant keys from the model JSON in the Profile editor", () => { @@ -504,7 +533,8 @@ describe("SettingsDialog interactions", () => { expect((input("Name") as HTMLInputElement).readOnly).toBe(true); const withoutMcpSecrets = structuredClone(snapshot); - delete withoutMcpSecrets.config.mcp!.servers.custom.headers; + const serverWithoutSecrets = withoutMcpSecrets.config.mcp!.servers.custom; + if (serverWithoutSecrets.type === "http") delete serverWithoutSecrets.headers; act(() => root.render( {}} />)); click("MCP"); const name = input("Name"); @@ -622,7 +652,7 @@ describe("SettingsDialog interactions", () => { test("keeps built-in MCP rows locked in the rendered DOM", () => { - act(() => root.render( {}} />)); + act(() => root.render( {}} />)); click("MCP"); expect(container.textContent).toContain("Built-in"); expect(container.textContent).toContain("Ready"); @@ -633,6 +663,55 @@ describe("SettingsDialog interactions", () => { expect(container.textContent).not.toContain("Delete exa"); }); + test("offers draft Test for built-ins but blocks Reconnect while disabled", () => { + const disabled = structuredClone(snapshot); + disabled.config.mcp!.disabledBuiltins = ["context7"]; + act(() => root.render( {}} />)); + click("MCP"); + + const row = [...container.querySelectorAll("article")].find((article) => article.querySelector("h2")?.textContent === "context7"); + if (!row) throw new Error("Missing context7 MCP row"); + const testButton = [...row.querySelectorAll("button")].find((button) => button.textContent === "Test draft") as HTMLButtonElement; + const reconnectButton = [...row.querySelectorAll("button")].find((button) => button.textContent === "Reconnect") as HTMLButtonElement; + + expect(testButton.disabled).toBe(false); + expect(reconnectButton.disabled).toBe(true); + expect(reconnectButton.title).toContain("Enable and save"); + }); + + test("shows complete project Skill diagnostics and Prompt projection", async () => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async (url: string) => { + expect(url).toBe("/api/projects/demo/skills"); + return Response.json({ + items: [ + { name: "included", source: "project-archcode", winner: true, shadowed: false, valid: true, description: "Included guidance" }, + { name: "omitted", source: "builtin", winner: true, shadowed: false, valid: true, description: "Omitted guidance" }, + { name: "broken", source: "user-agents", winner: true, shadowed: false, valid: false, diagnostic: { code: "SKILL_INVALID_PACKAGE", message: "Invalid frontmatter" } }, + ], + promptProjection: { + includedEntries: [{ name: "included", source: "project-archcode", description: "Included guidance" }], + omittedCount: 1, + renderedText: "included: Included guidance", + byteLength: 27, + }, + }); + }) }); + + await act(async () => { + root.render( {}} />); + await Promise.resolve(); + await Promise.resolve(); + }); + await waitForText("Discovered packages"); + + expect(container.textContent).toContain("1 included · 1 omitted · 27 bytes"); + expect(container.textContent).toContain("Project .archcode"); + expect(container.textContent).toContain("Prompt omitted"); + expect(container.textContent).toContain("Invalid frontmatter"); + expect(container.textContent).not.toContain("sourceLabel"); + expect(container.querySelectorAll('[data-settings-skills] input[type="checkbox"]')).toHaveLength(0); + }); + test("renders the schema default as enabled when memory is absent", () => { const withoutMemory = { ...snapshot, config: { ...snapshot.config, memory: undefined } }; act(() => root.render( {}} />)); diff --git a/apps/web/src/components/features/SettingsDialog.test.tsx b/apps/web/src/components/features/SettingsDialog.test.tsx index 5354df75..98f01ea5 100644 --- a/apps/web/src/components/features/SettingsDialog.test.tsx +++ b/apps/web/src/components/features/SettingsDialog.test.tsx @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; import type { McpServerStatus, ProviderAdapterCatalog } from "@archcode/protocol"; +import { renderToStaticMarkup } from "react-dom/server"; import type { ServerConfig } from "../../api/config"; import { SettingsModelsPanel, SettingsMcpPanel, SettingsNavigation, SettingsApplyNotice, + SettingsSkillsPanel, } from "./SettingsDialog"; interface ElementLike { @@ -82,7 +84,7 @@ describe("SettingsDialog", () => { const labels = findAll(tree, (element) => element.type === "button").map(textContent); expect(textContent(tree)).toContain("Server"); - expect(labels).toEqual(["Models", "Profiles", "Security", "Runtime Data", "MCP", "Memory", "GitHub", "About & Updates"]); + expect(labels).toEqual(["Models", "Profiles", "Security", "Runtime Data", "MCP", "Skills", "Memory", "GitHub", "About & Updates"]); }); test("adds Config Recovery while disabling Config-dependent sections", () => { @@ -119,30 +121,29 @@ describe("SettingsDialog", () => { test("locks the three built-in MCP servers while showing live status", () => { const servers: Record = { - context7: { state: "ready", toolCount: 4, warningCount: 0 }, - "grep.app": { state: "pending" }, - exa: { state: "failed", error: "unreachable" }, + context7: { state: "ready", toolCount: 4, warningCount: 0, connectedAt: 1 }, + "grep.app": { state: "connecting", startedAt: 1 }, + exa: { state: "failed", error: "unreachable", failedAt: 1 }, }; - const tree = SettingsMcpPanel({ config, servers, onChange: () => {} }); - const buttons = findAll(tree, (element) => element.type === "button").map(textContent); - - expect(textContent(tree)).toContain("Built-in"); - expect(textContent(tree)).toContain("Ready"); - expect(textContent(tree)).toContain("Pending"); - expect(textContent(tree)).toContain("Failed"); - expect(textContent(tree)).toContain("4 tools available"); - expect(textContent(tree)).toContain("unreachable"); - expect(buttons).not.toContain("Delete context7"); - expect(buttons).not.toContain("Delete grep.app"); - expect(buttons).not.toContain("Delete exa"); + const markup = renderToStaticMarkup( {}} />); + + expect(markup).toContain("Built-in"); + expect(markup).toContain("Ready"); + expect(markup).toContain("Connecting"); + expect(markup).toContain("Failed"); + expect(markup).toContain("4 tools available"); + expect(markup).toContain("unreachable"); + expect(markup).not.toContain("Delete context7"); + expect(markup).not.toContain("Delete grep.app"); + expect(markup).not.toContain("Delete exa"); }); test("distinguishes live model application from named restart-only sections", () => { expect(textContent(SettingsApplyNotice({ modelsAppliedLive: false, restartRequiredSections: [] }))).toBe(""); expect(textContent(SettingsApplyNotice({ modelsAppliedLive: true, restartRequiredSections: [] }))).toContain("applied live"); - const notice = textContent(SettingsApplyNotice({ modelsAppliedLive: true, restartRequiredSections: ["mcp", "integrations.github"] })); + const notice = textContent(SettingsApplyNotice({ modelsAppliedLive: true, restartRequiredSections: ["memory", "integrations.github"] })); expect(notice).toContain("applied live"); - expect(notice).toContain("Restart required for: MCP, GitHub"); + expect(notice).toContain("Restart required for: Memory, GitHub"); }); test("does not claim live application while Runtime is unavailable", () => { @@ -158,10 +159,17 @@ describe("SettingsDialog", () => { }); test("keeps MCP configuration visible while live status is unavailable", () => { - const tree = SettingsMcpPanel({ config, servers: {}, onChange: () => {}, runtimeAvailable: false }); + const markup = renderToStaticMarkup( {}} runtimeAvailable={false} />); - expect(textContent(tree)).toContain("Unavailable while Runtime is offline"); - expect(textContent(tree)).toContain("Unavailable"); - expect(textContent(tree)).not.toContain("Failed"); + expect(markup).toContain("Unavailable while Runtime is offline"); + expect(markup).toContain("Unavailable"); + expect(markup).not.toContain("Failed"); + }); + + test("does not guess a project for the Skill diagnostics surface", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Project Skills"); + expect(markup).toContain("Open a project to inspect its Skills"); }); }); diff --git a/apps/web/src/components/features/SettingsDialog.tsx b/apps/web/src/components/features/SettingsDialog.tsx index e20bc098..63e16486 100644 --- a/apps/web/src/components/features/SettingsDialog.tsx +++ b/apps/web/src/components/features/SettingsDialog.tsx @@ -6,17 +6,16 @@ import { getProviderAdapterCatalog, getServerConfig, saveServerConfig, toConfigD import { useMcpStatusStore } from "../../store/mcp-status-store"; import { DialogContent, DialogDescription, DialogRoot, DialogTitle } from "../ui/Dialog"; import { cloneConfig, hasConfigChanges, missingProfileVariants, toFieldErrors, type SettingsSection } from "./settings-helpers"; -import { SettingsProfilesPanel, SettingsGithubPanel, SettingsMcpPanel, SettingsMemoryPanel, SettingsModelsPanel, SettingsNavigation } from "./settings-panels"; +import { SettingsProfilesPanel, SettingsGithubPanel, SettingsMcpPanel, SettingsMemoryPanel, SettingsModelsPanel, SettingsNavigation, SettingsSkillsPanel } from "./settings-panels"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import { SettingsRuntimeDataPanel } from "./SettingsRuntimeDataPanel"; import { SettingsUpdatesPanel } from "./SettingsUpdatesPanel"; -export { SettingsMcpPanel, SettingsModelsPanel, SettingsNavigation } from "./settings-panels"; +export { SettingsMcpPanel, SettingsModelsPanel, SettingsNavigation, SettingsSkillsPanel } from "./settings-panels"; type RestartRequiredSection = ServerConfigSnapshotView["restartRequiredSections"][number]; const restartSectionLabels: Record = { - mcp: "MCP", memory: "Memory", "integrations.github": "GitHub", }; @@ -32,10 +31,10 @@ export function SettingsApplyNotice({ modelsAppliedLive, restartRequiredSections } export function SettingsCloseButton({ onClose }: { onClose: () => void }) { - return ; + return ; } -export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runtime = { state: "ready" }, onRefreshRuntime = async () => {}, section: requestedSection = "models", onSectionChange, reloading = false, reloadError }: { snapshot: ServerConfigSnapshot; adapterCatalog: ProviderAdapterCatalog; servers: Record; onReload: () => Promise; runtime?: RuntimeStatus; onRefreshRuntime?: () => Promise; section?: SettingsSection; onSectionChange?: (section: SettingsSection) => void; reloading?: boolean; reloadError?: string }) { +export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runtime = { state: "ready" }, onRefreshRuntime = async () => {}, section: requestedSection = "models", onSectionChange, projectSlug, reloading = false, reloadError }: { snapshot: ServerConfigSnapshot; adapterCatalog: ProviderAdapterCatalog; servers: Record; onReload: () => Promise; runtime?: RuntimeStatus; onRefreshRuntime?: () => Promise; section?: SettingsSection; onSectionChange?: (section: SettingsSection) => void; projectSlug?: string; reloading?: boolean; reloadError?: string }) { const [section, setSection] = useState(requestedSection); const [draft, setDraft] = useState(() => cloneConfig(snapshot.config)); const [errors, setErrors] = useState>({}); @@ -46,12 +45,17 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt const [modelsAppliedLive, setModelsAppliedLive] = useState(false); const [savedWhileRuntimeUnavailable, setSavedWhileRuntimeUnavailable] = useState(false); const [jsonResetVersion, setJsonResetVersion] = useState(0); + const preserveSaveErrorRevision = useRef(undefined); useEffect(() => { setDraft(cloneConfig(snapshot.config)); setErrors({}); setJsonErrors({}); - setSaveError(undefined); + if (preserveSaveErrorRevision.current === snapshot.revision) { + preserveSaveErrorRevision.current = undefined; + } else { + setSaveError(undefined); + } setSavedWhileRuntimeUnavailable(false); setRestartRequiredSections(snapshot.restartRequiredSections); setJsonResetVersion((current) => current + 1); @@ -86,16 +90,20 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt try { const modelSettingsChanged = JSON.stringify(draft.provider) !== JSON.stringify(snapshot.config.provider) || JSON.stringify(draft.profiles) !== JSON.stringify(snapshot.config.profiles); - const next = toConfigDraft( - await saveServerConfig({ expectedRevision: snapshot.revision, config: draft }), - adapterCatalog, - ); + const response = await saveServerConfig({ expectedRevision: snapshot.revision, config: draft }); + const next = toConfigDraft(response, adapterCatalog); + useMcpStatusStore.getState().setServers(response.mcpApply.status.servers); setDraft(cloneConfig(next.config)); setRestartRequiredSections(next.restartRequiredSections); + if (response.mcpApply.state === "failed") { + preserveSaveErrorRevision.current = response.revision; + setSaveError(`Configuration saved, but MCP live apply failed: ${response.mcpApply.error}`); + } await onReload(); setModelsAppliedLive(runtime.state === "ready" && modelSettingsChanged); setSavedWhileRuntimeUnavailable(runtime.state !== "ready"); } catch (error) { + preserveSaveErrorRevision.current = undefined; const nextErrors = toFieldErrors(error); setErrors(nextErrors); const firstValidationMessage = Object.values(nextErrors)[0]; @@ -127,7 +135,8 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt {section === "security" && } - + + {section === "skills" && }
{saveError || reloadError ?
{saveError ?? reloadError}
: {hasJsonErrors ? "Fix invalid JSON before saving" : dirty ? "Unsaved changes" : "All changes saved"}}
@@ -136,8 +145,8 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt ; } -export function SettingsDialog({ open, section = "models", onClose }: { open: boolean; section?: SettingsSection; onClose: () => void }) { - return { if (!next) onClose(); }}>SettingsConfigure ArchCode server settings, Runtime data, and application updates. {}} />; +export function SettingsDialog({ open, section = "models", projectSlug, onClose }: { open: boolean; section?: SettingsSection; projectSlug?: string; onClose: () => void }) { + return { if (!next) onClose(); }}>SettingsConfigure ArchCode server settings, Project Skills, Runtime data, and application updates. {}} />; } export function RuntimeRecoverySettings({ runtime, onRefreshRuntime }: { runtime: RuntimeStatus; onRefreshRuntime: () => Promise }) { @@ -148,7 +157,7 @@ export function RuntimeRecoverySettings({ runtime, onRefreshRuntime }: { runtime ; } -function SettingsWorkspace({ active, section, runtime, onRefreshRuntime }: { active: boolean; section: SettingsSection; runtime: RuntimeStatus; onRefreshRuntime: () => Promise }) { +function SettingsWorkspace({ active, section, projectSlug, runtime, onRefreshRuntime }: { active: boolean; section: SettingsSection; projectSlug?: string; runtime: RuntimeStatus; onRefreshRuntime: () => Promise }) { const servers = useMcpStatusStore((state) => state.servers); const [activeSection, setActiveSection] = useState(section); const [snapshot, setSnapshot] = useState(); @@ -199,7 +208,7 @@ function SettingsWorkspace({ active, section, runtime, onRefreshRuntime }: { act const hasConfigData = snapshot !== undefined && adapterCatalog !== undefined; return hasConfigData - ? + ? : activeSection === "updates" || activeSection === "runtime-data" ? : {error diff --git a/apps/web/src/components/features/TodoProgressButton.interaction.tsx b/apps/web/src/components/features/TodoProgressButton.interaction.tsx index dc335e76..f2a73c7e 100644 --- a/apps/web/src/components/features/TodoProgressButton.interaction.tsx +++ b/apps/web/src/components/features/TodoProgressButton.interaction.tsx @@ -262,6 +262,7 @@ describe("TodoProgressButton interactions", () => { error: "boom", origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 1, runs: [], terminalSettlement: { key: "terminal:e1", goalInstanceId: null }, @@ -284,6 +285,7 @@ describe("TodoProgressButton interactions", () => { endedAt: 2, origin: "user_message", maxSteps: 10, + executionSkills: [], durationMs: 1, runs: [], terminalSettlement: { key: "terminal:e2", goalInstanceId: null }, diff --git a/apps/web/src/components/features/settings-helpers.ts b/apps/web/src/components/features/settings-helpers.ts index 3b2753dd..d0032cc4 100644 --- a/apps/web/src/components/features/settings-helpers.ts +++ b/apps/web/src/components/features/settings-helpers.ts @@ -2,7 +2,17 @@ import { BUILTIN_MCP_SERVER_NAMES } from "@archcode/protocol"; import type { ServerConfig } from "../../api/config"; import { ApiError } from "../../api/client"; -export type SettingsSection = "config-recovery" | "models" | "profiles" | "security" | "runtime-data" | "mcp" | "memory" | "github" | "updates"; +export type SettingsSection = + | "config-recovery" + | "models" + | "profiles" + | "security" + | "runtime-data" + | "mcp" + | "skills" + | "memory" + | "github" + | "updates"; export type FieldErrors = Record; export const PROFILE_NAMES = [ diff --git a/apps/web/src/components/features/settings-panels.tsx b/apps/web/src/components/features/settings-panels.tsx index 8e7d6c0d..38da286c 100644 --- a/apps/web/src/components/features/settings-panels.tsx +++ b/apps/web/src/components/features/settings-panels.tsx @@ -1,14 +1,18 @@ -import type { ConfigSecretMutation, McpServerStatus, ProviderAdapterCatalog, ProviderAdapterDescriptor, ProviderAdapterOptionDescriptor } from "@archcode/protocol"; -import { ChevronRight, Plus, Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { ConfigSecretMutation, McpServerStatus, McpToolInventoryItem, ProviderAdapterCatalog, ProviderAdapterDescriptor, ProviderAdapterOptionDescriptor, SkillSourceTier } from "@archcode/protocol"; +import { ChevronRight, Loader2, Plus, RefreshCw, Trash2 } from "lucide-react"; import type { ModelCallOptions, ServerConfig, ServerMcpConfig, ServerModelConfig } from "../../api/config"; +import { getMcpInventory, reconnectMcpServer, testMcpDraft } from "../../api/mcp"; +import { getCompleteProjectSkillInventoryView, type CompleteProjectSkillInventory } from "../../api/skills"; +import { useMcpStatusStore } from "../../store/mcp-status-store"; import { Field, JsonObjectField, NumberField, RenameInput, SecretField, SecretRecordEditor, TextInput } from "./settings-fields"; import { PROFILE_NAMES, BUILT_IN_MCP_NAMES, defaultMemoryConfig, errorAtOrBelow, missingProfileVariant, type FieldErrors, type SettingsSection, withDraft } from "./settings-helpers"; type JsonValidationChange = (path: string, error?: string) => void; -const secondaryActionClass = "inline-flex h-8 items-center justify-center gap-2 rounded-sm bg-bg-active px-3 text-[12px] font-medium text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary"; -const subtleActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-brand transition-colors duration-[var(--motion-hover)] hover:bg-brand-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"; -const dangerActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-error transition-colors duration-[var(--motion-hover)] hover:bg-error-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"; +const secondaryActionClass = "inline-flex h-8 items-center justify-center gap-2 rounded-sm bg-bg-active px-3 text-[12px] font-medium text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand [@media(pointer:coarse)]:min-h-11"; +const subtleActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-brand transition-colors duration-[var(--motion-hover)] hover:bg-brand-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand [@media(pointer:coarse)]:min-h-11"; +const dangerActionClass = "inline-flex h-7 items-center justify-center gap-2 rounded-sm px-2 text-[12px] font-medium text-error transition-colors duration-[var(--motion-hover)] hover:bg-error-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand [@media(pointer:coarse)]:min-h-11"; const selectClass = "h-8 w-full rounded-sm border border-border-control bg-bg-base px-3 text-[12px] text-text-primary outline-none transition-colors duration-[var(--motion-hover)] hover:border-text-secondary focus:border-brand focus:ring-2 focus:ring-brand-subtle"; const MODEL_MODALITIES = ["text", "image", "audio", "video"] as const; type ModelModality = typeof MODEL_MODALITIES[number]; @@ -150,8 +154,8 @@ function mergeAdvancedOptions( } const MCP_STATUS_META: Record = { - pending: { - label: "Pending", + connecting: { + label: "Connecting", dotClass: "bg-warning", badgeClass: "border-warning/30 bg-warning-muted text-warning", }, @@ -195,6 +199,7 @@ export function SettingsNavigation({ ["security", "Security"], ["runtime-data", "Runtime Data"], ["mcp", "MCP"], + ["skills", "Skills"], ["memory", "Memory"], ["github", "GitHub"], ["updates", "About & Updates"], @@ -379,24 +384,160 @@ export function SettingsProfilesPanel({ config, onChange, errors, onJsonValidati ; } -export function SettingsMcpPanel({ config, servers, onChange, errors = {}, runtimeAvailable = true }: { config: ServerConfig; servers: Record; onChange: (config: ServerConfig) => void; errors?: FieldErrors; runtimeAvailable?: boolean }) { +const SKILL_SOURCE_LABELS: Record = { + "project-archcode": "Project .archcode", + "project-agents": "Project .agents", + "user-archcode": "User .archcode", + "user-agents": "User .agents", + builtin: "Built-in", +}; + +export function SettingsSkillsPanel({ projectSlug }: { projectSlug?: string }) { + const [view, setView] = useState(); + const [state, setState] = useState<"idle" | "loading" | "ready" | "failed">("idle"); + const [error, setError] = useState(); + const [requestVersion, setRequestVersion] = useState(0); + + useEffect(() => { + if (projectSlug === undefined) { + setView(undefined); + setState("idle"); + setError(undefined); + return; + } + let mounted = true; + setState("loading"); + setError(undefined); + void getCompleteProjectSkillInventoryView(projectSlug).then((next) => { + if (!mounted) return; + setView(next); + setState("ready"); + }).catch((cause) => { + if (!mounted) return; + setView(undefined); + setState("failed"); + setError(cause instanceof Error ? cause.message : "Unable to load Project Skills"); + }); + return () => { mounted = false; }; + }, [projectSlug, requestVersion]); + + return
+ + {projectSlug === undefined &&

Open a project to inspect its Skills.

} + {projectSlug !== undefined && state === "loading" &&

Loading Project Skills…

} + {projectSlug !== undefined && state === "failed" &&
+

Unable to load Project Skills: {error}

+ +
} + {projectSlug !== undefined && state === "ready" && view !== undefined && <> +
+

Prompt directory

+

+ {view.promptProjection.includedEntries.length} included · {view.promptProjection.omittedCount} omitted · {view.promptProjection.byteLength} bytes +

+
+ Preview projected directory +
{view.promptProjection.renderedText || "No valid Skills are projected."}
+
+
+
+
+

Discovered packages

+

{view.items.length} candidate{view.items.length === 1 ? "" : "s"} across all precedence tiers

+
+ {view.items.length === 0 + ?

No Skills discovered for this project.

+ :
    {view.items.map((item, index) =>
  • +
    +
    +

    {item.name}

    + {item.description &&

    {item.description}

    } + {item.diagnostic &&

    {item.diagnostic.message}

    } +
    +
    + {SKILL_SOURCE_LABELS[item.source]} + {item.winner && Winner} + {item.shadowed && Shadowed} + {item.valid && Valid} + {!item.valid && Invalid} + {item.promptOmitted && Prompt omitted} +
    +
    +
  • )}
} +
+ } +
; +} + +export function SettingsMcpPanel({ config, savedConfig = config, expectedRevision = "draft", servers, onChange, errors = {}, runtimeAvailable = true, active = true }: { config: ServerConfig; savedConfig?: ServerConfig; expectedRevision?: string; servers: Record; onChange: (config: ServerConfig) => void; errors?: FieldErrors; runtimeAvailable?: boolean; active?: boolean }) { const custom = Object.entries(config.mcp?.servers ?? {}).filter(([name]) => !BUILT_IN_MCP_NAMES.includes(name as typeof BUILT_IN_MCP_NAMES[number])); const all = [...BUILT_IN_MCP_NAMES.map((name) => [name, undefined] as const), ...custom]; + const [inventory, setInventory] = useState>({}); + const [testResults, setTestResults] = useState>({}); + const [actionErrors, setActionErrors] = useState>({}); + const [inventoryError, setInventoryError] = useState(); + const [pendingActions, setPendingActions] = useState>(() => new Set()); + const pendingActionsRef = useRef(new Set()); + const setServers = useMcpStatusStore((state) => state.setServers); + + useEffect(() => { + if (!runtimeAvailable || !active) return; + let mounted = true; + setInventoryError(undefined); + void getMcpInventory().then((next) => { + if (mounted) setInventory(next); + }).catch((cause) => { + if (mounted) setInventoryError(cause instanceof Error ? cause.message : "Unable to load MCP tool inventory"); + }); + return () => { mounted = false; }; + }, [active, expectedRevision, runtimeAvailable, servers]); + + useEffect(() => { + setTestResults({}); + setActionErrors({}); + }, [config]); + + const runAction = async (key: string, action: () => Promise) => { + if (pendingActionsRef.current.has(key)) return; + pendingActionsRef.current.add(key); + setPendingActions((current) => new Set(current).add(key)); + setActionErrors((current) => { const next = { ...current }; delete next[key]; return next; }); + try { await action(); } + catch (cause) { setActionErrors((current) => ({ ...current, [key]: cause instanceof Error ? cause.message : "Action failed" })); } + finally { + pendingActionsRef.current.delete(key); + setPendingActions((current) => { const next = new Set(current); next.delete(key); return next; }); + } + }; + return
+ {inventoryError &&

MCP inventory unavailable: {inventoryError}

}
{all.map(([name, server]) => { const status = runtimeAvailable ? servers[name] : undefined; const builtIn = BUILT_IN_MCP_NAMES.includes(name as typeof BUILT_IN_MCP_NAMES[number]); + const disabledBuiltins = config.mcp?.disabledBuiltins ?? []; + const savedDisabledBuiltins = savedConfig.mcp?.disabledBuiltins ?? []; + const savedServer = savedConfig.mcp?.servers[name]; + const reconnectSaved = builtIn + ? disabledBuiltins.includes(name as typeof BUILT_IN_MCP_NAMES[number]) === savedDisabledBuiltins.includes(name as typeof BUILT_IN_MCP_NAMES[number]) + : JSON.stringify(server) === JSON.stringify(savedServer); + const reconnectEnabled = builtIn + ? !disabledBuiltins.includes(name as typeof BUILT_IN_MCP_NAMES[number]) + : server?.enabled === true; + const testKey = `test:${name}`; + const reconnectKey = `reconnect:${name}`; const statusMeta = runtimeAvailable ? MCP_STATUS_META[status?.state ?? "unreported"] : { label: "Unavailable", dotClass: "bg-text-muted", badgeClass: "border-border-default bg-bg-elevated text-text-tertiary", }; return
-
+
-

{name}

-

{runtimeAvailable ? describeStatus(status) : "Unavailable while Runtime is offline"}

+

{name}

+

{runtimeAvailable ? describeStatus(status) : "Unavailable while Runtime is offline"}

+ {status && }
{builtIn && Built-in} @@ -406,18 +547,66 @@ export function SettingsMcpPanel({ config, servers, onChange, errors = {}, runti
- {builtIn ?

Managed by ArchCode. This server cannot be edited, deleted, or overridden.

: } + {builtIn ? onChange(withDraft(config, (draft) => { + draft.mcp ??= { servers: {} }; + const current = new Set(draft.mcp.disabledBuiltins ?? []); + if (enabled) current.delete(name as typeof BUILT_IN_MCP_NAMES[number]); else current.add(name as typeof BUILT_IN_MCP_NAMES[number]); + draft.mcp.disabledBuiltins = BUILT_IN_MCP_NAMES.filter((entry) => current.has(entry)); + }))} label={`Enable ${name}`} description="Expose this built-in server to the live MCP runtime." /> : } +
+ + + {!reconnectEnabled + ? Enable and save before reconnecting. + : !reconnectSaved && Save before reconnecting.} +
+ {(actionErrors[testKey] || actionErrors[reconnectKey]) &&

{actionErrors[testKey] ?? actionErrors[reconnectKey]}

} +
; })}
- +
; } +function McpToolList({ tools, tested }: { tools: McpToolInventoryItem[]; tested: boolean }) { + if (tools.length === 0) return

{tested ? "Draft test discovered no tools." : "No discovered tools."}

; + return
{tested ? "Draft tools" : "Available tools"} · {tools.length}
    {tools.map((tool) =>
  • {tool.name}{tool.description && {tool.description}}
  • )}
; +} + function McpEditor({ name, server, config, onChange, errors }: { name: string; server: ServerMcpConfig; config: ServerConfig; onChange: (config: ServerConfig) => void; errors: FieldErrors }) { const update = (apply: (target: ServerMcpConfig) => void) => onChange(withDraft(config, (draft) => apply(draft.mcp!.servers[name]))); - const nameLocked = hasPreservedSecretRecord(server.headers); - return
{ if (next === name) return true; if (BUILT_IN_MCP_NAMES.includes(next as typeof BUILT_IN_MCP_NAMES[number]) || config.mcp!.servers[next]) return false; onChange(withDraft(config, (draft) => { draft.mcp!.servers[next] = draft.mcp!.servers[name]; delete draft.mcp!.servers[name]; })); return true; }} />{nameLocked && Replace or clear configured headers before renaming.} update((draft) => { draft.url = next; })} /> update((draft) => { draft.timeout = next; })} />
update((draft) => { draft.headers = next; })} errors={errors} path={`mcp.servers.${name}.headers`} />
; + const secretRecord = server.type === "http" ? server.headers : server.env; + const nameLocked = hasPreservedSecretRecord(secretRecord); + const replaceTransport = (type: "http" | "stdio") => onChange(withDraft(config, (draft) => { + const current = draft.mcp!.servers[name]; + draft.mcp!.servers[name] = type === "http" + ? { type, enabled: current.enabled, url: "https://example.com/mcp", connectTimeoutMs: current.connectTimeoutMs, discoveryTimeoutMs: current.discoveryTimeoutMs, callTimeoutMs: current.callTimeoutMs } + : { type, enabled: current.enabled, command: "", args: [], connectTimeoutMs: current.connectTimeoutMs, discoveryTimeoutMs: current.discoveryTimeoutMs, callTimeoutMs: current.callTimeoutMs }; + })); + return
+ update((draft) => { draft.enabled = enabled; })} label={`Enable ${name}`} description="Connect and expose discovered tools through the live MCP runtime." /> +
+ { if (next === name) return true; if (BUILT_IN_MCP_NAMES.includes(next as typeof BUILT_IN_MCP_NAMES[number]) || config.mcp!.servers[next]) return false; onChange(withDraft(config, (draft) => { draft.mcp!.servers[next] = draft.mcp!.servers[name]; delete draft.mcp!.servers[name]; })); return true; }} />{nameLocked && Replace or clear configured secrets before renaming.} + + {server.type === "http" ? update((draft) => { if (draft.type === "http") draft.url = next; })} /> : <> + update((draft) => { if (draft.type === "stdio") draft.command = next; })} /> +