diff --git a/.changeset/smart-crews-shout.md b/.changeset/smart-crews-shout.md new file mode 100644 index 00000000..3e983bce --- /dev/null +++ b/.changeset/smart-crews-shout.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": patch +--- + +Centralize Caplets exposure projection rendering across attach, native, remote, and MCP surfaces. diff --git a/CONCEPTS.md b/CONCEPTS.md index 9bf9252c..deca1f40 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -8,6 +8,12 @@ Shared domain vocabulary for this project -- entities, named processes, and stat A configured capability surface that exposes a backend to agents through a stable handle, progressive wrapper tools, or direct tool operations. +### Caplets Exposure Projection + +The shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets. + +Caplets Exposure Projection is the source of truth for exposure identity and availability. MCP, native, and attach host adapters render it differently; they do not re-own exposure policy or execution behavior. + ### Prebuilt Caplets Catalog The repo-owned collection of installable Caplet files under `caplets/`. diff --git a/CONTEXT.md b/CONTEXT.md index 4fdb0d13..7ddb4760 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,6 +20,10 @@ _Avoid_: Caplets utility namespace, custom helper surface The first compatibility-global set for Code Mode: URL and query handling, text encoding, base64 and Buffer-compatible byte handling, web binary payload objects, safe crypto primitives, timing, scheduling, and structured cloning. _Avoid_: Minimal encoding helpers, full Node compatibility +**Caplets exposure projection**: +A shared adapter-neutral runtime view of which local and remote Caplets are exposed as Code Mode handles, progressive tools, direct downstream operations, or direct MCP surfaces, including non-callable diagnostic breadcrumbs for hidden Caplets. MCP, native, and attach host adapters render it differently; they do not own exposure policy or execution behavior. +_Avoid_: Tool registration list, exposure wrapper map, transport-specific surface + **Code Mode bridge value**: A value passed between a Code Mode script and a Caplets backend call, including JSON-compatible data and supported standard-library binary payload objects. _Avoid_: JSON-only payload, host object leak diff --git a/docs/architecture.md b/docs/architecture.md index eb4af7fc..b52c5906 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Project sources override user/global sources. Source-aware inspection reports wh Backend managers provide a common shape for listing tools, searching tools, describing exact tools, calling tools, and checking readiness. MCP-backed Caplets additionally support resources, resource templates, prompts, and completion. -### Exposure Policy +### Exposure Policy And Projection `packages/core/src/exposure/policy.ts` resolves one exposure value into three booleans: @@ -38,6 +38,8 @@ Backend managers provide a common shape for listing tools, searching tools, desc The global default is `code_mode`. Per-Caplet config may choose `direct`, `progressive`, `code_mode`, `direct_and_code_mode`, or `progressive_and_code_mode`. +`packages/core/src/exposure/projection.ts` turns resolved discovery snapshots and attach manifests into the Caplets exposure projection: the adapter-neutral view of Code Mode handles, progressive tools, direct downstream operations, direct MCP surfaces, route descriptors, hidden diagnostic breadcrumbs, and local/remote merge outcomes. MCP serving, native integrations, and attach/remote clients render this projection; they do not re-own exposure identity, namespace shadowing, or hidden-Caplet policy. + ### MCP Server `packages/core/src/serve/session.ts` registers the user-facing MCP surface. diff --git a/docs/plans/2026-07-01-001-refactor-caplets-exposure-projection-plan.md b/docs/plans/2026-07-01-001-refactor-caplets-exposure-projection-plan.md new file mode 100644 index 00000000..079e67e0 --- /dev/null +++ b/docs/plans/2026-07-01-001-refactor-caplets-exposure-projection-plan.md @@ -0,0 +1,406 @@ +--- +title: Caplets Exposure Projection - Plan +type: refactor +date: 2026-07-01 +topic: caplets-exposure-projection +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-brainstorm +execution: code +--- + +# Caplets Exposure Projection - Plan + +## Goal Capsule + +- **Objective:** Deepen Caplets exposure projection into the single source of truth for exposed Caplets across MCP, native, and remote host adapters. +- **Product authority:** Product Contract below is the WHAT; ADR-0001 keeps Code Mode as the default exposure; ADR-0002 stays out of scope. +- **Execution profile:** Cross-surface refactor in `packages/core`, implemented characterization-first with projection tests taking over policy coverage before adapter rewrites land. +- **Stop conditions:** Stop and re-plan if route descriptors require moving actual execution into the projection module, if Code Mode default exposure semantics change, or if namespace shadowing behavior would change rather than move. +- **Tail ownership:** Update tests and architecture docs in the same branch; no release notes or changeset unless implementation changes user-visible behavior beyond preserving existing contracts. + +--- + +## Product Contract + +### Summary + +Create a deep Caplets exposure projection module that resolves callable surfaces, hidden diagnostic breadcrumbs, MCP non-tool surfaces, and local/remote merge-shadowing decisions behind one interface. +Host adapters render that projection and pass route descriptors back to runtime execution; they do not re-own exposure policy. + +### Problem Frame + +Caplets currently exposes the same capability model through local MCP serving, native integrations, and remote attach or stacked runtime paths. +The existing exposure seam is shallow: discovery produces useful data, but adapters still reconstruct parts of naming, direct routes, Code Mode markers, MCP primitive tools, hidden states, and local/remote merge behavior. +That weakens locality because an exposure bug can live in any adapter, and it weakens leverage because tests must repeat the same policy expectations through several adapter-shaped fixtures. + +The desired refactor supports the product strategy by keeping the agent-facing surface small and dependable while preserving exact backend semantics across local, remote, Cloud, and native setups. + +### Key Decisions + +- **Projection only, not execution.** The deep module owns identity, availability, diagnostic breadcrumbs, and route metadata; actual tool calls, Code Mode runs, resource reads, prompt calls, and completion remain engine/native runtime execution concerns. +- **Snapshot required.** Adapters should render from the last resolved Caplets exposure projection instead of guessing from config during cold start or reload. +- **MCP non-tool surfaces are first-class.** MCP Resources, MCP Resource Templates, prompts, and completion-related affordances belong in the projection even when each host adapter renders them differently. +- **Hidden Caplets stay non-callable but diagnosable.** Hidden entries must never appear in the callable surface, but the projection should carry safe breadcrumbs that let agents and users diagnose setup, Project Binding, discovery, quarantine, and empty-surface problems. +- **Local/remote merge lives behind the seam.** The projection owns shadowing, namespace-qualified identity, and local/remote merge outcomes so native, remote, and stacked runtime adapters do not implement competing precedence models. +- **Projection tests become the primary test surface.** Adapter tests should verify rendering from projection fixtures; policy tests should target the projection interface directly. + +### Requirements + +**Projection contract** + +- R1. The Caplets exposure projection must represent every exposed Caplet surface needed by local MCP, local native, remote attach, and stacked remote runtime adapters. +- R2. The projection must classify Code Mode handles, progressive tools, direct downstream operations, MCP Resources, MCP Resource Templates, prompts, and completion affordances without requiring adapters to rediscover their exposure mode. +- R3. Projected callable entries must carry route descriptors and metadata, not executable callbacks. +- R4. The projection interface must be small enough that tests can validate exposure policy without constructing a full host adapter. + +**Freshness and diagnostics** + +- R5. Host adapters must render from a resolved projection snapshot rather than independently falling back to enabled Caplets from config. +- R6. A stale or unavailable projection must be represented explicitly so adapters can report availability state without inventing exposure behavior. +- R7. Hidden Caplets must be absent from callable entries while retaining safe diagnostic breadcrumbs for agent/user troubleshooting. +- R8. Diagnostic breadcrumbs must explain why a Caplet is hidden at a product level, such as setup required, Project Binding missing, Project Binding quarantined, discovery failed, or empty surface. + +**Adapter coverage** + +- R9. Local MCP serving must render its Code Mode tool, progressive tools, direct tools, resources, resource templates, and prompts from the projection. +- R10. Native integrations must render progressive tools, direct operation tools, Code Mode run entries, and MCP primitive affordances from the projection. +- R11. Remote attach and stacked remote runtime paths must derive their manifest-visible capabilities and Code Mode handles from the same projection model. +- R12. Adapters may format projected entries differently, but they must not apply independent exposure policy after receiving the projection. + +**Local/remote merge and shadowing** + +- R13. The projection must own local/remote merge outcomes for forbid, allow, and namespace shadowing policies. +- R14. Namespace-qualified Caplet IDs must be the same across Code Mode handles, progressive exposure, direct native tool names, direct MCP entries, and remote manifest entries. +- R15. Bare IDs removed by namespace collision must remain unavailable across all projected callable surfaces. +- R16. Non-colliding Caplets must keep stable unqualified IDs unless another accepted policy says otherwise. + +**Testing and migration** + +- R17. Existing adapter tests that currently assert exposure policy should be narrowed or replaced by projection-interface tests. +- R18. Adapter tests should use projection fixtures to verify host-specific rendering and route-descriptor handoff only. +- R19. The refactor must preserve ADR-0001: Code Mode remains the default exposure and progressive/direct modes remain supported alternatives. +- R20. The refactor must not change Media artifact behavior, backend auth semantics, or Code Mode execution behavior. + +### Conceptual Diagram + +```mermaid +flowchart TB + C[Config, Caplet files, remote manifests, project context] --> P[Deep module: Caplets exposure projection] + P --> H[Hidden diagnostic breadcrumbs] + P --> CM[Code Mode handles] + P --> PR[Progressive tools] + P --> DT[Direct downstream operations] + P --> MS[MCP Resources, Templates, Prompts] + P --> M[Merged local and remote identity] + CM --> A1[MCP adapter] + PR --> A1 + DT --> A1 + MS --> A1 + CM --> A2[Native adapter] + PR --> A2 + DT --> A2 + MS --> A2 + M --> A3[Remote adapter] + H --> D[Doctor, health, and agent diagnostics] +``` + +The diagram shows the intended authority flow, not an implementation sequence. + +### Acceptance Examples + +- AE1. **Covers R1, R2, R9.** Given a runtime with Code Mode, progressive, direct tools, resources, and prompts, when local MCP serving renders its agent surface, then every visible item comes from the Caplets exposure projection. +- AE2. **Covers R3, R12.** Given a projected direct operation, when a native adapter renders it, then the adapter emits a host-shaped tool whose execution path uses the projection route descriptor rather than a callback owned by the projection. +- AE3. **Covers R5, R6.** Given projection discovery has not completed or the last projection is stale, when an adapter is asked for its surface, then it reports explicit projection availability state rather than guessing from config. +- AE4. **Covers R7, R8.** Given a Project Binding-required Caplet lacks project context, when projection resolves, then the Caplet is missing from callable entries and appears in diagnostic breadcrumbs with a safe reason. +- AE5. **Covers R10, R11.** Given an MCP backend exposes resources and prompts, when native and remote adapters render their surfaces, then both derive their resource/prompt-related affordances from first-class projection entries. +- AE6. **Covers R13, R15.** Given local and remote Caplets collide under namespace shadowing, when projection resolves, then only qualified IDs appear and the bare ID is absent across all callable surfaces. +- AE7. **Covers R17, R18.** Given a policy regression in namespace handling, when projection-interface tests run, then the failure appears without needing MCP, native, and remote adapter tests to each recreate the policy case. + +### Scope Boundaries + +- Do not move Code Mode execution, direct tool execution, resource reads, prompt calls, or completion execution into the projection module. +- Do not redesign backend managers, Media artifact handling, Caplets Vault, or Code Mode sandbox behavior. +- Do not revisit ADR-0001 or ADR-0002; this refactor should support those decisions. +- Do not change Namespace Shadowing product behavior beyond centralizing where the behavior is projected. + +### Dependencies / Assumptions + +- ADR-0001 makes Code Mode the default exposure and requires product/docs/tests to preserve Code Mode-first behavior. +- Namespace Shadowing already defines collision outcomes that the projection must preserve. +- Stacked Remote Runtime planning already expects local/upstream composition to reuse the existing native remote composition path rather than create a second precedence model. +- The domain glossary now names Caplets exposure projection as the resolved runtime view of local and remote exposed surfaces plus hidden diagnostic breadcrumbs. + +### Sources / Research + +- `CONTEXT.md` defines Caplets exposure projection, Code Mode, Media artifact, Caplets Vault, and Google Discovery API backend vocabulary. +- `STRATEGY.md` frames Caplets as a Code Mode-first capability layer for coding agents. +- `docs/adr/0001-code-mode-default-exposure.md` records Code Mode as the default exposure decision. +- `docs/adr/0002-media-artifacts-for-non-inline-results.md` records the Media artifact result contract and is intentionally out of scope here. +- `docs/brainstorms/2026-06-23-namespace-shadowing-policy-requirements.md` defines namespace collision behavior across exposure modes. +- `docs/plans/2026-06-23-002-feat-stacked-remote-runtime-plan.md` expects stacked mode to preserve local/upstream composition and shadowing behavior. +- `docs/solutions/architecture-patterns/code-mode-repl-sessions.md` separates live state, identity, and recovery state; this plan mirrors that discipline by separating projection route identity from execution. +- `docs/solutions/integration-issues/stale-remote-profile-credentials-refresh.md` warns against long-lived native remote clients holding stale runtime options; projection refresh must preserve refreshed runtime option behavior. +- `docs/solutions/integration-issues/vault-cli-runtime-integration-fixes.md` shows that adjacent execution surfaces drift when shared runtime policy is not centralized. +- `packages/core/src/exposure/discovery.ts` is the current exposure discovery module. +- `packages/core/src/exposure/namespace.ts` is the current namespace shadowing resolver. +- `packages/core/src/attach/api.ts`, `packages/core/src/serve/session.ts`, `packages/core/src/native/service.ts`, and `packages/core/src/native/remote.ts` are current adapter paths with exposure rendering responsibilities. + +--- + +## Planning Contract + +### Product Contract Preservation + +Product Contract unchanged. + +### Key Technical Decisions + +- KTD1. **Wrap the current exposure snapshot before replacing adapters.** Start by creating a projection module that can consume the current `ExposureSnapshot` and emit adapter-neutral entries; this keeps the deletion test honest because later units can remove duplicated adapter policy rather than invent a second resolver. +- KTD2. **Use route descriptors as the projection interface.** Projected entries should name the visible ID, source Caplet ID, downstream operation or MCP surface, exposure mode, schema metadata, annotations, shadowing policy, and diagnostic state; they should not hold callbacks or runtime service references. +- KTD3. **Model MCP non-tool surfaces beside tools.** Resources, resource templates, prompts, and completion affordances should be first-class projection entries so native primitive tools, attach manifest exports, and MCP resources render from the same surface authority. +- KTD4. **Move local/remote merge into the projection seam.** The native composite merge currently owns suppression, namespace qualification, Code Mode merge, and route mapping; this should become projection logic so remote attach, native tools, and stacked runtime share one identity outcome. +- KTD5. **Expose projection availability explicitly.** Native and remote adapters should track projection readiness or last-known-good projection state instead of reconstructing visible tools from config while asynchronous discovery is pending. +- KTD6. **Replace duplicated policy tests with projection tests.** Policy cases for default Code Mode exposure, hidden Caplets, direct resources/prompts, namespace collisions, and source Caplet routing belong in projection tests; adapter tests should prove host rendering and route execution only. + +### High-Level Technical Design + +#### Projection topology + +```mermaid +flowchart TB + E[CapletsEngine.exposureSnapshot] --> B[Projection builder] + M[Native or remote manifest tools] --> B + NS[Namespace resolver] --> B + B --> P[Caplets exposure projection] + P --> MCP[MCP session renderer] + P --> Native[Native tool renderer] + P --> Attach[Attach manifest renderer] + P --> Composite[Composite local and remote renderer] + MCP --> Exec[Engine execution] + Native --> Exec + Attach --> Exec + Composite --> Exec +``` + +The projection builder is the only module that interprets exposure policy after backend discovery. +Renderers can format for their host, but they consume already-resolved projection entries. + +#### Migration sequence + +```mermaid +flowchart TB + U1[U1 projection model and fixtures] --> U2[U2 attach manifest renderer] + U1 --> U3[U3 MCP session renderer] + U1 --> U4[U4 native local renderer] + U4 --> U5[U5 composite local and remote merge] + U2 --> U6[U6 remote manifest conversion] + U5 --> U6 + U2 --> U7[U7 test and docs cleanup] + U3 --> U7 + U4 --> U7 + U5 --> U7 + U6 --> U7 +``` + +The sequence moves the easiest snapshot consumer first, then the local adapters, then the composite merge with namespace routing. + +#### Route descriptor flow + +```mermaid +sequenceDiagram + participant Adapter as Host adapter + participant Projection as Caplets exposure projection + participant Runtime as Engine/native runtime + Adapter->>Projection: read visible entry and route descriptor + Adapter->>Adapter: render host-specific tool/resource/prompt + Adapter->>Runtime: execute descriptor target with user input + Runtime-->>Adapter: return existing runtime result envelope +``` + +Projection decides what exists and how it is named. +Runtime execution remains outside the projection seam. + +### Assumptions + +- `packages/core/src/attach/api.ts` can be a first migration target because it already has explicit manifest projection types and route maps. +- Native composite merge behavior can move behind the projection seam without changing public `NativeCapletsService` methods. +- Existing focused test suites are sufficient for characterization; no live backend credentials are needed for this refactor. + +### System-Wide Impact + +- **Agent surface parity:** Code Mode handles, progressive tools, direct tools, MCP resources/prompts, and remote manifest entries must show the same resolved Caplet identity. +- **Diagnostics:** Hidden Caplet breadcrumbs should improve `attach` and agent diagnostics without widening callable surfaces. +- **Telemetry:** Execution telemetry should remain attached to engine/native execution paths; projection rendering should not create new activation events. +- **Remote runtime:** Composite local/remote projection must preserve refreshed runtime options, attach session lifecycle, and Project Binding degradation behavior. + +### Risks and Mitigations + +| Risk | Mitigation | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Projection module becomes another shallow layer over `ExposureSnapshot`. | Require U2-U5 to delete or narrow duplicated adapter policy tests as each renderer moves. | +| Route descriptors underspecify execution and force adapters to recover hidden state. | Start with attach route map parity and native route parity tests before moving renderers. | +| Native remote last-known-good behavior regresses when snapshot fallback is removed. | Preserve explicit stale/last-known-good projection state and test listener behavior. | +| Namespace alternatives drift between Code Mode and direct tools. | Move namespace rewriting into projection tests that assert all exposed surfaces together. | +| Adapter refactor changes public tool names or schemas. | Characterize current MCP/native/attach output before each adapter migration. | +| Hidden diagnostic breadcrumbs expose raw backend errors, credential hints, or filesystem details. | Keep breadcrumbs to product-level reason codes and safe summaries; add projection tests that reject raw exception text, secrets, and local paths. | + +### Documentation and Operational Notes + +- Update `docs/architecture.md` after implementation so it names Caplets exposure projection as the shared surface authority. +- Update public docs only if user-visible behavior or diagnostics change; pure internal refactor does not require new product docs. +- Add a changeset only if the implementation changes package-observable behavior, diagnostics, or exported types. + +--- + +## Implementation Units + +### U1. Define projection model and snapshot builder + +- **Goal:** Create the deep projection module that converts current exposure discovery output into adapter-neutral visible entries, hidden breadcrumbs, availability state, and route descriptors. +- **Requirements:** R1, R2, R3, R4, R7, R8, R19 +- **Dependencies:** None +- **Files:** `packages/core/src/exposure/projection.ts`, `packages/core/src/exposure/discovery.ts`, `packages/core/src/exposure/index.ts`, `packages/core/test/exposure-projection.test.ts`, `packages/core/test/exposure-discovery.test.ts` +- **Approach:** Introduce a projection type that wraps `ExposureSnapshot` without changing behavior. Include entry kinds for progressive Caplets, Code Mode Caplets, direct tools, MCP resources, MCP resource templates, prompts, completions, and hidden diagnostics. Keep execution as route data that points back to existing engine/native execution methods. Preserve `discoverExposureSnapshot` as the backend discovery source during this unit. +- **Execution note:** Start with characterization tests built from existing `ExposureSnapshot` fixtures before adding the projection builder. +- **Patterns to follow:** `packages/core/src/exposure/discovery.ts` for hidden Caplet reasons; `packages/core/src/attach/api.ts` for stable export and route separation; `packages/core/src/exposure/direct-names.ts` for direct naming helpers. +- **Test scenarios:** + - Covers AE1. Given a mixed snapshot with progressive, Code Mode, direct tools, resources, templates, and prompts, projection returns all expected entry kinds with stable visible IDs. + - Covers AE2. Given a direct tool entry, projection returns a route descriptor with Caplet ID and downstream name but no executable callback. + - Covers AE4. Given hidden Caplets with Project Binding and discovery failures, projection emits diagnostic breadcrumbs and no callable entries for those Caplets. + - Given discovery failures include raw backend error details, projection emits safe diagnostic reason codes and summaries without secrets, credential values, or local filesystem paths. + - Given `discoverNonDirectMcpSurfaces: false`, projection preserves empty non-direct MCP surface behavior rather than inventing resources or prompts. + - Given direct resource templates with similar downstream templates, projection keeps their projected identities unique. +- **Verification:** Projection tests prove policy and entry-shape behavior without constructing MCP, native, or remote adapters. + +### U2. Render attach manifests from projection + +- **Goal:** Move attach manifest construction onto Caplets exposure projection while preserving manifest revision stability, route maps, hidden diagnostics, and invocation behavior. +- **Requirements:** R1, R2, R3, R7, R8, R11, R12, R18, R19 +- **Dependencies:** U1 +- **Files:** `packages/core/src/attach/api.ts`, `packages/core/src/serve/http.ts`, `packages/core/test/attach-api.test.ts`, `packages/core/test/serve-http.test.ts` +- **Approach:** Replace direct `ExposureSnapshot` reads inside `buildAttachProjection` with projection entries. Keep the existing `AttachProjection` manifest and `routes` contract stable for callers. Convert completion exports, prompt/resource/template exports, direct tool exports, progressive Caplet exports, Code Mode Caplet exports, and diagnostics from projection data instead of snapshot arrays. +- **Execution note:** Characterize current manifest revision hashing and route invocation before changing export construction. +- **Patterns to follow:** Existing `sortAttachProjectionInput`, `withRevisionExportIds`, and `routesFor` functions in `packages/core/src/attach/api.ts`. +- **Test scenarios:** + - Covers AE1. Given the current mixed fixture, attach manifest exports all visible projection entries with the same stable IDs and revision behavior as before. + - Covers AE4. Given hidden Project Binding Caplets, attach diagnostics still include authoritative Project Binding metadata. + - Covers AE5. Given projected resources, resource templates, prompts, and completions, attach manifest exports them and invokes them through existing route kinds. + - Given a stale manifest revision, invoke still returns the existing stale-manifest error path. + - Given native attach projection inputs, existing native attach behavior remains compatible until native moves onto the shared projection in U4-U5. +- **Verification:** `packages/core/test/attach-api.test.ts` covers manifest shape, diagnostics, route invocation, stale revision handling, and direct/MCP non-tool surfaces through projection data. + +### U3. Render local MCP sessions from projection + +- **Goal:** Make local MCP serving register Code Mode, progressive tools, direct tools, resources, resource templates, and prompts from Caplets exposure projection. +- **Requirements:** R1, R2, R3, R5, R9, R12, R18, R19 +- **Dependencies:** U1 +- **Files:** `packages/core/src/serve/session.ts`, `packages/core/src/serve/http.ts`, `packages/core/src/serve/index.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/code-mode-mcp.test.ts` +- **Approach:** Change `CapletsMcpSession.reconcileFromSnapshot` into projection reconciliation. Keep registration objects and callbacks in the MCP adapter, but derive desired registrations from projection entries. Preserve Code Mode run tool registration when projection has Code Mode Caplets. Preserve direct resource and prompt callbacks by routing descriptors to existing engine methods. +- **Execution note:** Add failing tests that render from a projection fixture before replacing snapshot array loops. +- **Patterns to follow:** Existing registration diffing in `CapletsMcpSession.reconcileFromSnapshot`; `directResourceResult` for wrapping non-MCP resource read results; `codeModeRunToolDescription` for Code Mode prompt guidance. +- **Test scenarios:** + - Covers AE1. Given a projection with all entry kinds, MCP session registers one Code Mode tool, progressive tools, direct tools, direct resources, direct resource templates, and direct prompts. + - Covers AE2. Given a projected direct tool route, registered MCP callback calls the engine direct execution path with the downstream name from the route descriptor. + - Covers AE3. Given projection unavailable state, MCP registration does not guess tools from config and exposes only the documented availability behavior. + - Given a reload changes projection entries, removed MCP tools/resources/prompts are unregistered and updated ones are refreshed. + - Given a projection with no Code Mode Caplets, the Code Mode tool is removed. +- **Verification:** MCP session tests prove rendering and reconciliation from projection fixtures, while Code Mode MCP tests prove Code Mode execution remains routed through `runCodeMode` unchanged. + +### U4. Render local native tools from projection + +- **Goal:** Make local native integration tool listing consume Caplets exposure projection instead of recomputing exposure from enabled config and partial discovery state. +- **Requirements:** R1, R2, R3, R5, R6, R10, R12, R18, R19 +- **Dependencies:** U1 +- **Files:** `packages/core/src/native/service.ts`, `packages/core/src/native/tools.ts`, `packages/core/src/native/options.ts`, `packages/core/test/native.test.ts` +- **Approach:** Replace local `listTools` exposure loops with a native renderer that consumes projection entries. Preserve native tool naming, direct route IDs, prompt guidance, Code Mode run tool shape, and `directToolRoutes` behavior. Represent pending, stale, or failed projection refresh explicitly so native tools do not fall back to config-derived guesses. +- **Execution note:** Characterize native tool listing before changing the cold-start and reload paths because current tests assert both direct discovery and default Code Mode exposure. +- **Patterns to follow:** `progressiveNativeTool`, `directNativeTool`, `codeModeRunNativeTool`, and `codeModeCallableNativeTools` in `packages/core/src/native/service.ts`. +- **Test scenarios:** + - Covers AE1. Given a projection with progressive, direct, and Code Mode entries, native list returns the same visible native tools as current behavior. + - Covers AE2. Given a projected direct route, native execution uses the stored route descriptor and existing engine direct execution path. + - Covers AE3. Given projection refresh has not resolved, native list does not synthesize enabled config entries and reports the planned availability state. + - Covers AE5. Given projected MCP resources/prompts, native renderer creates primitive resource and prompt tools without recomputing surface presence. + - Given watched config changes alter projection, native tools-changed listeners fire only when rendered native tools change. + - Given direct Google Discovery and HTTP Caplets, native direct operation schemas and annotations remain unchanged. +- **Verification:** Native tests prove local projection rendering, direct execution routes, listener behavior, default Code Mode exposure, and MCP primitive rendering without duplicated exposure policy assertions. + +### U5. Move composite local/remote merge into projection + +- **Goal:** Put local/remote suppression, namespace qualification, Code Mode merge, source Caplet routing, and namespace diagnostics behind the Caplets exposure projection seam. +- **Requirements:** R1, R3, R11, R12, R13, R14, R15, R16, R18, R19 +- **Dependencies:** U1, U4 +- **Files:** `packages/core/src/exposure/projection.ts`, `packages/core/src/exposure/namespace.ts`, `packages/core/src/native/service.ts`, `packages/core/src/native/remote.ts`, `packages/core/test/exposure-projection.test.ts`, `packages/core/test/exposure-namespace.test.ts`, `packages/core/test/native-remote.test.ts` +- **Approach:** Extract the core of `CompositeNativeCapletsService.mergeTools`, `resolveVisibleToolIds`, `nativeNamespaceEntries`, and direct-tool renaming into projection-level merge functions. Feed local projection entries and remote manifest-derived projection entries into one merge. Keep composite native execution routes as route descriptors that choose local or remote execution at the native service layer. +- **Execution note:** Add projection-level tests for each existing namespace/shadowing native-remote scenario before deleting adapter-owned merge logic. +- **Patterns to follow:** `resolveNamespaceExposure` in `packages/core/src/exposure/namespace.ts`; native remote tests around forbid, allow, namespace, Code Mode-only collisions, direct-tool alternatives, and Project Binding degradation. +- **Test scenarios:** + - Covers AE6. Given local and remote Caplets collide under namespace shadowing, projection exposes only qualified alternatives across progressive, direct, and Code Mode entries. + - Given remote `forbid`, projection suppresses matching local progressive, direct, and Code Mode entries while retaining remote entries. + - Given remote `allow`, projection keeps local overlay routes visible and executable. + - Given namespace-generated IDs collide with a bare ID, projection fails closed with diagnostic breadcrumbs and no ambiguous callable entries. + - Given local and remote direct tools share a source Caplet, projection preserves source Caplet ID and rewritten direct tool alternatives. + - Given upstream Project Binding is unavailable in stacked mode, projection keeps safe local and non-project upstream entries available with diagnostics. +- **Verification:** Projection tests own merge policy; native-remote tests prove the composite renderer still routes local and remote execution correctly with last-known-good behavior. + +### U6. Convert remote manifest tooling onto projection entries + +- **Goal:** Make remote attach manifest parsing and standalone remote native tooling feed projection-compatible entries instead of reconstructing primitive tools and Code Mode markers independently. +- **Requirements:** R2, R3, R10, R11, R12, R14, R18, R19 +- **Dependencies:** U2, U5 +- **Files:** `packages/core/src/native/remote.ts`, `packages/core/src/attach/api.ts`, `packages/core/src/native/service.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/attach-api.test.ts` +- **Approach:** Replace `toolsFromManifest`, `primitiveToolsFromManifest`, and remote Code Mode marker logic with projection-compatible manifest conversion. Preserve older attach manifest compatibility and standalone remote service behavior. Keep stale-manifest retry and credential refresh behavior outside projection and covered by existing tests. +- **Execution note:** Preserve older manifest compatibility tests before switching conversion logic because remote clients may connect to older hosts. +- **Patterns to follow:** `buildAttachProjection` export shapes in `packages/core/src/attach/api.ts`; stale manifest and credential-refresh tests in `packages/core/test/native-remote.test.ts`. +- **Test scenarios:** + - Covers AE5. Given remote manifest resources, resource templates, prompts, and completions, remote native tools derive primitive affordances from projection entries. + - Given a direct tool name ends with a primitive suffix, exact direct tool exports still win over generated primitive affordances. + - Given an older attach manifest lacks Code Mode caplet entries, remote client preserves fallback behavior. + - Given refreshed remote profile credentials, manifest polling and attach event reconnects continue using refreshed runtime options. + - Given a standalone remote native service exposes Code Mode handles, local Code Mode declarations still include only code-mode-callable Caplets. +- **Verification:** Remote native tests prove manifest conversion, primitive affordance rendering, stale retry behavior, and Code Mode handle scoping remain compatible. + +### U7. Retire duplicated policy tests and update architecture docs + +- **Goal:** Complete the deletion test by removing or narrowing adapter tests that only restate projection policy, then document Caplets exposure projection as the shared authority. +- **Requirements:** R4, R17, R18, R19, R20 +- **Dependencies:** U2, U3, U4, U5, U6 +- **Files:** `packages/core/test/exposure-projection.test.ts`, `packages/core/test/exposure-discovery.test.ts`, `packages/core/test/serve-session.test.ts`, `packages/core/test/native.test.ts`, `packages/core/test/native-remote.test.ts`, `packages/core/test/attach-api.test.ts`, `docs/architecture.md`, `CONCEPTS.md`, `CONTEXT.md` +- **Approach:** Audit tests moved by U2-U6 and keep only adapter tests that prove rendering, registration lifecycle, route execution, stale state handling, and host compatibility. Move remaining policy assertions into `exposure-projection.test.ts`. Update architecture docs and glossary only where the implemented module changes durable vocabulary or architecture. +- **Execution note:** Run this unit after adapter migrations so deleted tests are replaced by stronger projection coverage, not merely removed. +- **Patterns to follow:** Project test quality bar in `AGENTS.md`; existing `docs/architecture.md` runtime layer summary. +- **Test scenarios:** + - Covers AE7. Given a namespace policy regression, projection tests fail even if adapter rendering tests are not run. + - Given adapter rendering tests use projection fixtures, those tests fail only for host-format or route-handoff regressions. + - Given docs mention exposure policy, architecture docs point readers at Caplets exposure projection rather than adapter-specific logic. + - Given `CONTEXT.md` and `CONCEPTS.md` already define Caplets Exposure Projection, no duplicate or conflicting vocabulary is introduced. +- **Verification:** Test suite coverage is stronger at the projection seam, adapter tests are narrower, and docs name the new module without claiming behavior changes that did not occur. + +--- + +## Verification Contract + +| Gate | Applies to | Done signal | +| ------------------------------------------------------------------------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------ | +| `pnpm --filter @caplets/core test -- test/exposure-projection.test.ts` | U1, U5, U7 | Projection policy tests pass and own the deep module interface. | +| `pnpm --filter @caplets/core test -- test/attach-api.test.ts` | U2, U6 | Attach manifest rendering, routes, diagnostics, and stale revision behavior pass. | +| `pnpm --filter @caplets/core test -- test/serve-session.test.ts test/code-mode-mcp.test.ts` | U3 | MCP registration and Code Mode MCP execution pass. | +| `pnpm --filter @caplets/core test -- test/native.test.ts` | U4 | Local native rendering, listeners, direct routes, and Code Mode default exposure pass. | +| `pnpm --filter @caplets/core test -- test/native-remote.test.ts test/exposure-namespace.test.ts` | U5, U6 | Composite local/remote merge, namespace, stale manifest, and remote compatibility behavior pass. | +| `pnpm --filter @caplets/core test` | Whole plan | Core package regression suite passes after all units. | +| `pnpm typecheck` | Whole plan | New projection types and imports typecheck across packages. | +| `pnpm docs:check` | U7 | Generated/public docs remain in sync if docs were changed. | +| `pnpm verify` | Final pre-PR gate | Full repository gate passes before shipping. | + +--- + +## Definition of Done + +- All Product Contract requirements R1-R20 are satisfied or explicitly re-planned with user approval. +- All acceptance examples AE1-AE7 are covered by projection, adapter, or remote tests. +- `packages/core/src/exposure/projection.ts` or equivalent becomes the only module that owns exposure identity, hidden breadcrumbs, MCP non-tool surface classification, and local/remote merge policy. +- MCP, native, and remote adapters render projection entries and do not independently apply exposure policy after projection resolution. +- Route descriptors preserve existing execution ownership in engine/native runtime paths. +- Adapter tests that only restated projection policy are deleted or narrowed after replacement projection tests exist. +- Documentation and glossary entries use Caplets exposure projection vocabulary consistently. +- Abandoned intermediate adapters, compatibility shims, and dead helper functions from the refactor are removed before final verification. diff --git a/packages/core/src/attach/api.ts b/packages/core/src/attach/api.ts index f7d681be..1b4545d3 100644 --- a/packages/core/src/attach/api.ts +++ b/packages/core/src/attach/api.ts @@ -8,15 +8,11 @@ import { decodeDirectResourceUri, directResourceUriMatchesTemplate, } from "../exposure/direct-names"; -import type { - CallableCaplet, - DirectPromptRegistration, - DirectResourceRegistration, - DirectResourceTemplateRegistration, - DirectToolRegistration, - ExposureSnapshot, -} from "../exposure/discovery"; -import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +import { + buildExposureProjection, + type ExposureProjectionEntry, + type ExposureProjectionHiddenCaplet, +} from "../exposure/projection"; import type { NativeCapletsService } from "../native/service"; export const CAPLETS_ATTACH_SESSION_HEADER = "caplets-attach-session-id"; @@ -130,10 +126,6 @@ type AttachRoute = | { kind: "prompt"; capletId: string; downstreamName: string } | { kind: "completion"; capletId: string }; -type AttachCapletWithShadowing = { - shadowing?: CapletShadowingPolicy | undefined; -}; - export type AttachProjection = { manifest: AttachManifest; routes: Map; @@ -151,16 +143,16 @@ type AttachManifestProjectionInput = { }; export async function buildAttachProjection(engine: CapletsEngine): Promise { - const snapshot = await engine.exposureSnapshot(); + const projection = buildExposureProjection(await engine.exposureSnapshot()); const partial = sortAttachProjectionInput({ - caplets: snapshot.progressiveCaplets.map(progressiveCapletExport), - tools: snapshot.directTools.map(toolExport), - resources: snapshot.directResources.map(resourceExport), - resourceTemplates: snapshot.directResourceTemplates.map(resourceTemplateExport), - prompts: snapshot.directPrompts.map(promptExport), - completions: completionExports(snapshot), - codeModeCaplets: snapshot.codeModeCaplets.map(codeModeCapletExport), - diagnostics: snapshot.hiddenCaplets.map(attachDiagnosticForHiddenCaplet), + caplets: projection.entries.flatMap(progressiveCapletExport), + tools: projection.entries.flatMap(toolExport), + resources: projection.entries.flatMap(resourceExport), + resourceTemplates: projection.entries.flatMap(resourceTemplateExport), + prompts: projection.entries.flatMap(promptExport), + completions: projection.entries.flatMap(completionExport), + codeModeCaplets: projection.entries.flatMap(codeModeCapletExport), + diagnostics: projection.hiddenCaplets.map(attachDiagnosticForHiddenCaplet), }); const revision = revisionFor(partial); const manifest: AttachManifest = { @@ -175,7 +167,7 @@ export async function buildAttachProjection(engine: CapletsEngine): Promise { - if (!hidden.error) return {}; - const details = hidden.error.details; + if (!hidden.diagnostic) return {}; + const details = hidden.diagnostic.details; if (!hidden.reason.startsWith("project_binding_")) { - return { details: hidden.error }; + return { details: hidden.diagnostic }; } const existing = isRecord(details) && isRecord(details.projectBinding) ? details.projectBinding : {}; @@ -412,126 +404,142 @@ export function attachErrorResponse(error: unknown): { } function progressiveCapletExport( - entry: CallableCaplet, -): Omit { - const inputSchema = generatedToolInputJsonSchemaForCaplet(entry.caplet); - return { - stableId: `progressive:${entry.caplet.server}`, - kind: "caplet", - name: entry.caplet.server, - title: entry.caplet.name, - description: entry.caplet.description, - inputSchema, - schemaHash: schemaHash(inputSchema), - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; + entry: ExposureProjectionEntry, +): Array> { + if (entry.kind !== "progressive-caplet") return []; + return [ + { + stableId: `progressive:${entry.capletId}`, + kind: "caplet", + name: entry.id, + title: entry.title, + description: entry.description, + inputSchema: entry.inputSchema, + schemaHash: schemaHash(entry.inputSchema ?? null), + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } -function codeModeCapletExport(entry: CallableCaplet): Omit { - return { - stableId: `code_mode:${entry.caplet.server}`, - kind: "caplet", - name: entry.caplet.name, - title: entry.caplet.name, - description: entry.caplet.description, - schemaHash: null, - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; +function codeModeCapletExport( + entry: ExposureProjectionEntry, +): Array> { + if (entry.kind !== "code-mode-caplet") return []; + return [ + { + stableId: `code_mode:${entry.capletId}`, + kind: "caplet", + name: entry.title ?? entry.id, + title: entry.title, + description: entry.description, + schemaHash: null, + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } -function toolExport(entry: DirectToolRegistration): Omit { - return { - stableId: `tool:${entry.caplet.server}:${entry.downstreamName}`, - kind: "tool", - name: entry.name, - downstreamName: entry.downstreamName, - title: entry.tool.name, - description: entry.tool.description, - inputSchema: entry.tool.inputSchema, - outputSchema: entry.tool.outputSchema, - annotations: entry.tool.annotations, - schemaHash: schemaHash({ input: entry.tool.inputSchema, output: entry.tool.outputSchema }), - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; +function toolExport(entry: ExposureProjectionEntry): Array> { + if (entry.kind !== "direct-tool" || entry.route.kind !== "direct-tool") return []; + return [ + { + stableId: `tool:${entry.capletId}:${entry.route.downstreamName}`, + kind: "tool", + name: entry.id, + downstreamName: entry.route.downstreamName, + title: entry.title, + description: entry.description, + inputSchema: entry.inputSchema, + outputSchema: entry.outputSchema, + annotations: entry.annotations, + schemaHash: schemaHash({ input: entry.inputSchema, output: entry.outputSchema }), + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } -function resourceExport(entry: DirectResourceRegistration): Omit { - return { - stableId: `resource:${entry.caplet.server}:${entry.downstreamUri}`, - kind: "resource", - uri: entry.uri, - downstreamUri: entry.downstreamUri, - title: entry.resource.name, - description: entry.resource.description, - ...(entry.resource.mimeType ? { mimeType: entry.resource.mimeType } : {}), - ...(typeof entry.resource.size === "number" ? { size: entry.resource.size } : {}), - schemaHash: null, - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; +function resourceExport( + entry: ExposureProjectionEntry, +): Array> { + if (entry.kind !== "direct-resource" || entry.route.kind !== "direct-resource") return []; + return [ + { + stableId: `resource:${entry.capletId}:${entry.route.downstreamUri}`, + kind: "resource", + uri: entry.id, + downstreamUri: entry.route.downstreamUri, + title: entry.title, + description: entry.description, + ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + ...(typeof entry.size === "number" ? { size: entry.size } : {}), + schemaHash: null, + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } function resourceTemplateExport( - entry: DirectResourceTemplateRegistration, -): Omit { - return { - stableId: `resourceTemplate:${entry.caplet.server}:${entry.downstreamUriTemplate}`, - kind: "resourceTemplate", - uriTemplate: entry.uriTemplate, - downstreamUriTemplate: entry.downstreamUriTemplate, - title: entry.resourceTemplate.name, - description: entry.resourceTemplate.description, - ...(entry.resourceTemplate.mimeType ? { mimeType: entry.resourceTemplate.mimeType } : {}), - schemaHash: null, - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; + entry: ExposureProjectionEntry, +): Array> { + if ( + entry.kind !== "direct-resource-template" || + entry.route.kind !== "direct-resource-template" + ) { + return []; + } + return [ + { + stableId: `resourceTemplate:${entry.capletId}:${entry.route.downstreamUriTemplate}`, + kind: "resourceTemplate", + uriTemplate: entry.id, + downstreamUriTemplate: entry.route.downstreamUriTemplate, + title: entry.title, + description: entry.description, + ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + schemaHash: null, + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } -function promptExport(entry: DirectPromptRegistration): Omit { - const inputSchema = { arguments: entry.prompt.arguments ?? [] }; - return { - stableId: `prompt:${entry.caplet.server}:${entry.downstreamName}`, - kind: "prompt", - name: entry.name, - downstreamName: entry.downstreamName, - title: entry.prompt.name, - description: entry.prompt.description, - inputSchema, - schemaHash: schemaHash(inputSchema), - capletId: entry.caplet.server, - shadowing: shadowingPolicy(entry.caplet), - }; +function promptExport(entry: ExposureProjectionEntry): Array> { + if (entry.kind !== "direct-prompt" || entry.route.kind !== "direct-prompt") return []; + return [ + { + stableId: `prompt:${entry.capletId}:${entry.route.downstreamName}`, + kind: "prompt", + name: entry.id, + downstreamName: entry.route.downstreamName, + title: entry.title, + description: entry.description, + inputSchema: entry.inputSchema, + schemaHash: schemaHash(entry.inputSchema ?? null), + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } -function completionExports( - snapshot: ExposureSnapshot, +function completionExport( + entry: ExposureProjectionEntry, ): Array> { - const caplets = new Map( - [...snapshot.directPrompts, ...snapshot.directResourceTemplates].map((entry) => [ - entry.caplet.server, - entry.caplet, - ]), - ); - return [...caplets.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([capletId, caplet]) => ({ - stableId: `completion:${capletId}`, + if (entry.kind !== "completion") return []; + return [ + { + stableId: `completion:${entry.capletId}`, kind: "completion", - name: `${capletId}:complete`, - title: "Complete", - description: `MCP completion for ${capletId}.`, + name: entry.id, + title: entry.title, + description: entry.description, schemaHash: null, - capletId, - shadowing: shadowingPolicy(caplet), - })); -} - -function shadowingPolicy(caplet: AttachCapletWithShadowing): CapletShadowingPolicy { - return caplet.shadowing ?? "forbid"; + capletId: entry.capletId, + shadowing: entry.shadowing, + }, + ]; } function sortAttachProjectionInput( diff --git a/packages/core/src/exposure/projection.ts b/packages/core/src/exposure/projection.ts new file mode 100644 index 00000000..acdf62db --- /dev/null +++ b/packages/core/src/exposure/projection.ts @@ -0,0 +1,712 @@ +import type { CapletShadowingPolicy } from "../config"; +import { + resolveNamespaceExposure, + type NamespaceDiagnostic, + type NamespaceSourceEntry, +} from "./namespace"; +import type { SafeErrorSummary } from "../errors"; +import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema"; +import type { + CallableCaplet, + DirectPromptRegistration, + DirectResourceRegistration, + DirectResourceTemplateRegistration, + DirectToolRegistration, + ExposureSnapshot, + HiddenCaplet, + HiddenCapletReason, +} from "./discovery"; + +export type ExposureProjectionAvailability = + | { state: "ready" } + | { state: "unavailable"; reason: string } + | { state: "stale"; reason: string }; + +export type ExposureProjectionEntryKind = + | "progressive-caplet" + | "code-mode-caplet" + | "direct-tool" + | "direct-resource" + | "direct-resource-template" + | "direct-prompt" + | "completion"; + +export type ExposureProjectionRouteKey = `${ExposureProjectionEntryKind}:${string}`; + +export type ExposureProjectionRoute = + | { kind: "progressive-caplet"; capletId: string } + | { kind: "code-mode-caplet"; capletId: string } + | { kind: "direct-tool"; capletId: string; downstreamName: string } + | { kind: "direct-resource"; capletId: string; downstreamUri: string } + | { kind: "direct-resource-template"; capletId: string; downstreamUriTemplate: string } + | { kind: "direct-prompt"; capletId: string; downstreamName: string } + | { kind: "completion"; capletId: string }; + +export type ExposureProjectionEntry = { + kind: ExposureProjectionEntryKind; + id: string; + capletId: string; + title?: string | undefined; + description?: string | undefined; + inputSchema?: unknown; + outputSchema?: unknown; + annotations?: unknown; + mimeType?: string | undefined; + size?: number | undefined; + sourceCapletId?: string | undefined; + shadowing: CapletShadowingPolicy; + route: ExposureProjectionRoute; +}; + +export type ExposureProjectionHiddenCaplet = { + capletId: string; + reason: HiddenCapletReason; + diagnostic?: SafeErrorSummary | undefined; +}; + +export type ExposureProjection = { + availability: ExposureProjectionAvailability; + entries: ExposureProjectionEntry[]; + hiddenCaplets: ExposureProjectionHiddenCaplet[]; + routes: Map; +}; + +export function exposureProjectionRouteKey( + entry: Pick, +): ExposureProjectionRouteKey { + return `${entry.kind}:${entry.id}`; +} + +export function buildExposureProjection(snapshot: ExposureSnapshot): ExposureProjection { + const entries = [ + ...snapshot.progressiveCaplets.map(progressiveCapletEntry), + ...snapshot.codeModeCaplets.map(codeModeCapletEntry), + ...snapshot.directTools.map(directToolEntry), + ...snapshot.directResources.map(directResourceEntry), + ...snapshot.directResourceTemplates.map(directResourceTemplateEntry), + ...snapshot.directPrompts.map(directPromptEntry), + ...completionEntries(snapshot), + ]; + + return { + availability: { state: "ready" }, + entries, + hiddenCaplets: snapshot.hiddenCaplets.map(hiddenCapletEntry), + routes: new Map(entries.map((entry) => [exposureProjectionRouteKey(entry), entry.route])), + }; +} + +export type ManifestProjectionInput = { + caplets: ManifestProjectionCaplet[]; + tools: ManifestProjectionTool[]; + resources: ManifestProjectionResource[]; + resourceTemplates: ManifestProjectionResourceTemplate[]; + prompts: ManifestProjectionPrompt[]; + completions: ManifestProjectionCompletion[]; + codeModeCaplets?: ManifestProjectionCodeModeCaplet[] | undefined; +}; + +type ManifestProjectionBase = { + capletId: string; + sourceCapletId?: string | undefined; + title?: string | undefined; + description?: string | undefined; + inputSchema?: unknown; + outputSchema?: unknown; + annotations?: unknown; + shadowing: CapletShadowingPolicy; +}; + +type ManifestProjectionCaplet = ManifestProjectionBase & { + kind: "caplet"; + name: string; +}; + +type ManifestProjectionTool = ManifestProjectionBase & { + kind: "tool"; + name: string; + downstreamName: string; +}; + +type ManifestProjectionResource = ManifestProjectionBase & { + kind: "resource"; + uri: string; + downstreamUri: string; + mimeType?: string | undefined; + size?: number | undefined; +}; + +type ManifestProjectionResourceTemplate = ManifestProjectionBase & { + kind: "resourceTemplate"; + uriTemplate: string; + downstreamUriTemplate: string; + mimeType?: string | undefined; +}; + +type ManifestProjectionPrompt = ManifestProjectionBase & { + kind: "prompt"; + name: string; + downstreamName: string; +}; + +type ManifestProjectionCompletion = ManifestProjectionBase & { + kind: "completion"; + name?: string | undefined; +}; + +type ManifestProjectionCodeModeCaplet = ManifestProjectionBase & { + kind: "caplet"; + name: string; +}; + +export function buildManifestExposureProjection( + manifest: ManifestProjectionInput, +): ExposureProjection { + const entries = [ + ...manifest.caplets.map(manifestProgressiveCapletEntry), + ...manifest.tools.map(manifestDirectToolEntry), + ...manifest.resources.map(manifestDirectResourceEntry), + ...manifest.resourceTemplates.map(manifestDirectResourceTemplateEntry), + ...manifest.prompts.map(manifestDirectPromptEntry), + ...manifest.completions.map(manifestCompletionEntry), + ...(manifest.codeModeCaplets ?? []).map(manifestCodeModeCapletEntry), + ]; + return { + availability: { state: "ready" }, + entries, + hiddenCaplets: [], + routes: new Map(entries.map((entry) => [exposureProjectionRouteKey(entry), entry.route])), + }; +} + +function manifestProgressiveCapletEntry(entry: ManifestProjectionCaplet): ExposureProjectionEntry { + return { + kind: "progressive-caplet", + id: entry.capletId, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title ?? entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + shadowing: entry.shadowing, + route: { kind: "progressive-caplet", capletId: entry.capletId }, + }; +} + +function manifestDirectToolEntry(entry: ManifestProjectionTool): ExposureProjectionEntry { + return { + kind: "direct-tool", + id: entry.name, + capletId: entry.capletId, + sourceCapletId: entry.sourceCapletId ?? entry.capletId, + title: entry.title ?? entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + outputSchema: entry.outputSchema, + annotations: entry.annotations, + shadowing: entry.shadowing, + route: { kind: "direct-tool", capletId: entry.capletId, downstreamName: entry.downstreamName }, + }; +} + +function manifestDirectResourceEntry(entry: ManifestProjectionResource): ExposureProjectionEntry { + return { + kind: "direct-resource", + id: entry.uri, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title, + description: entry.description, + ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + ...(typeof entry.size === "number" ? { size: entry.size } : {}), + shadowing: entry.shadowing, + route: { + kind: "direct-resource", + capletId: entry.capletId, + downstreamUri: entry.downstreamUri, + }, + }; +} + +function manifestDirectResourceTemplateEntry( + entry: ManifestProjectionResourceTemplate, +): ExposureProjectionEntry { + return { + kind: "direct-resource-template", + id: entry.uriTemplate, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title, + description: entry.description, + ...(entry.mimeType ? { mimeType: entry.mimeType } : {}), + shadowing: entry.shadowing, + route: { + kind: "direct-resource-template", + capletId: entry.capletId, + downstreamUriTemplate: entry.downstreamUriTemplate, + }, + }; +} + +function manifestDirectPromptEntry(entry: ManifestProjectionPrompt): ExposureProjectionEntry { + return { + kind: "direct-prompt", + id: entry.name, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title ?? entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + shadowing: entry.shadowing, + route: { + kind: "direct-prompt", + capletId: entry.capletId, + downstreamName: entry.downstreamName, + }, + }; +} + +function manifestCompletionEntry(entry: ManifestProjectionCompletion): ExposureProjectionEntry { + return { + kind: "completion", + id: entry.name ?? `${entry.capletId}:complete`, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title, + description: entry.description, + shadowing: entry.shadowing, + route: { kind: "completion", capletId: entry.capletId }, + }; +} + +function manifestCodeModeCapletEntry( + entry: ManifestProjectionCodeModeCaplet, +): ExposureProjectionEntry { + return { + kind: "code-mode-caplet", + id: entry.capletId, + capletId: entry.capletId, + ...(entry.sourceCapletId ? { sourceCapletId: entry.sourceCapletId } : {}), + title: entry.title ?? entry.name, + description: entry.description, + shadowing: entry.shadowing, + route: { kind: "code-mode-caplet", capletId: entry.capletId }, + }; +} + +function progressiveCapletEntry(entry: CallableCaplet): ExposureProjectionEntry { + const capletId = entry.caplet.server; + return { + kind: "progressive-caplet", + id: capletId, + capletId, + title: entry.caplet.name, + description: entry.caplet.description, + inputSchema: generatedToolInputJsonSchemaForCaplet(entry.caplet), + shadowing: shadowingPolicy(entry.caplet), + route: { kind: "progressive-caplet", capletId }, + }; +} + +function codeModeCapletEntry(entry: CallableCaplet): ExposureProjectionEntry { + const capletId = entry.caplet.server; + return { + kind: "code-mode-caplet", + id: capletId, + capletId, + title: entry.caplet.name, + description: entry.caplet.description, + shadowing: shadowingPolicy(entry.caplet), + route: { kind: "code-mode-caplet", capletId }, + }; +} + +function directToolEntry(entry: DirectToolRegistration): ExposureProjectionEntry { + return { + kind: "direct-tool", + id: entry.name, + capletId: entry.caplet.server, + title: entry.tool.name, + description: entry.tool.description, + inputSchema: entry.tool.inputSchema, + outputSchema: entry.tool.outputSchema, + annotations: entry.tool.annotations, + shadowing: shadowingPolicy(entry.caplet), + route: { + kind: "direct-tool", + capletId: entry.caplet.server, + downstreamName: entry.downstreamName, + }, + }; +} + +function directResourceEntry(entry: DirectResourceRegistration): ExposureProjectionEntry { + return { + kind: "direct-resource", + id: entry.uri, + capletId: entry.caplet.server, + title: entry.resource.name, + description: entry.resource.description, + ...(entry.resource.mimeType ? { mimeType: entry.resource.mimeType } : {}), + ...(typeof entry.resource.size === "number" ? { size: entry.resource.size } : {}), + shadowing: shadowingPolicy(entry.caplet), + route: { + kind: "direct-resource", + capletId: entry.caplet.server, + downstreamUri: entry.downstreamUri, + }, + }; +} + +function directResourceTemplateEntry( + entry: DirectResourceTemplateRegistration, +): ExposureProjectionEntry { + return { + kind: "direct-resource-template", + id: entry.uriTemplate, + capletId: entry.caplet.server, + title: entry.resourceTemplate.name, + description: entry.resourceTemplate.description, + ...(entry.resourceTemplate.mimeType ? { mimeType: entry.resourceTemplate.mimeType } : {}), + shadowing: shadowingPolicy(entry.caplet), + route: { + kind: "direct-resource-template", + capletId: entry.caplet.server, + downstreamUriTemplate: entry.downstreamUriTemplate, + }, + }; +} + +function directPromptEntry(entry: DirectPromptRegistration): ExposureProjectionEntry { + const inputSchema = { arguments: entry.prompt.arguments ?? [] }; + return { + kind: "direct-prompt", + id: entry.name, + capletId: entry.caplet.server, + title: entry.prompt.name, + description: entry.prompt.description, + inputSchema, + shadowing: shadowingPolicy(entry.caplet), + route: { + kind: "direct-prompt", + capletId: entry.caplet.server, + downstreamName: entry.downstreamName, + }, + }; +} + +function completionEntries(snapshot: ExposureSnapshot): ExposureProjectionEntry[] { + const caplets = new Map( + [...snapshot.directPrompts, ...snapshot.directResourceTemplates].map((entry) => [ + entry.caplet.server, + entry.caplet, + ]), + ); + return [...caplets.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([capletId, caplet]) => ({ + kind: "completion" as const, + id: `${capletId}:complete`, + capletId, + title: "Complete", + description: `MCP completion for ${capletId}.`, + shadowing: shadowingPolicy(caplet), + route: { kind: "completion" as const, capletId }, + })); +} + +function hiddenCapletEntry(hidden: HiddenCaplet): ExposureProjectionHiddenCaplet { + return { + capletId: hidden.capletId, + reason: hidden.reason, + ...(hidden.error ? { diagnostic: safeDiagnostic(hidden.error) } : {}), + }; +} + +function safeDiagnostic(error: SafeErrorSummary): SafeErrorSummary { + return { + code: error.code, + message: sanitizeString(error.message), + ...(error.details === undefined ? {} : { details: sanitizeDetails(error.details) }), + }; +} + +function sanitizeDetails(value: unknown): unknown { + if (typeof value === "string") return sanitizeString(value); + if (Array.isArray(value)) return value.map(sanitizeDetails); + if (!value || typeof value !== "object") return value; + + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [ + key, + sensitiveKey(key) ? "[REDACTED]" : sanitizeDetails(nested), + ]), + ); +} + +function sanitizeString(value: string): string { + return value + .replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]") + .replace( + /(?:\/Users|\/home)\/[^\s,)]+|[A-Za-z]:[\\/](?:Users|home)[^,\s)]*/g, + "[REDACTED_PATH]", + ); +} + +function sensitiveKey(key: string): boolean { + return /(?:token|secret|credential|password|path)$/i.test(key); +} + +function shadowingPolicy(caplet: { shadowing?: CapletShadowingPolicy | undefined }) { + return caplet.shadowing ?? "forbid"; +} + +export type NativeProjectionMergeTool = { + caplet: string; + sourceCaplet?: string | undefined; + shadowing?: CapletShadowingPolicy | undefined; + codeModeRun?: boolean | undefined; +}; + +export type NativeProjectionMergeRoute = { + service: "local" | "remote"; + capletId: string; +}; + +type NativeProjectionNamespaceRoute = { + service: "local" | "remote"; + baseId: string; +}; + +export type NativeProjectionMergeOptions = { + remoteTools: Tool[]; + localTools: Tool[]; + remoteCodeModeTools: Tool[]; + localCodeModeTools: Tool[]; + remoteIdentity: string; + localIdentity: string; + namespaceAliases: { + local?: string | undefined; + upstreams: Record; + }; + renameTool(tool: Tool, visibleBaseId: string): Tool; +}; + +export type NativeProjectionMergeResult = { + remoteTools: Tool[]; + localTools: Tool[]; + remoteCodeModeTools: Tool[]; + localCodeModeTools: Tool[]; + routes: Map; + namespaceDiagnostics: Map; + suppressedLocalIds: Set; +}; + +export function resolveNativeProjectionMerge( + options: NativeProjectionMergeOptions, +): NativeProjectionMergeResult { + const suppressedLocalIds = remoteSuppressedProjectionCapletIds( + options.remoteTools, + options.remoteCodeModeTools, + ); + const localTools = options.localTools.filter( + (tool) => tool.codeModeRun !== true && !suppressedLocalIds.has(projectionSourceBaseId(tool)), + ); + const localCodeModeTools = options.localCodeModeTools.filter( + (tool) => !suppressedLocalIds.has(tool.caplet), + ); + const remoteTools = options.remoteTools.filter((tool) => tool.codeModeRun !== true); + const resolved = resolveVisibleProjectionToolIds({ + remoteTools, + localTools, + remoteCodeModeTools: options.remoteCodeModeTools, + localCodeModeTools, + remoteIdentity: options.remoteIdentity, + localIdentity: options.localIdentity, + namespaceAliases: options.namespaceAliases, + renameTool: options.renameTool, + }); + return { ...resolved, suppressedLocalIds }; +} + +function resolveVisibleProjectionToolIds(options: { + remoteTools: Tool[]; + localTools: Tool[]; + remoteCodeModeTools: Tool[]; + localCodeModeTools: Tool[]; + remoteIdentity: string; + localIdentity: string; + namespaceAliases: { local?: string | undefined; upstreams: Record }; + renameTool(tool: Tool, visibleBaseId: string): Tool; +}): Omit, "suppressedLocalIds"> { + const entries = nativeProjectionNamespaceEntries( + [ + { service: "remote", tools: [...options.remoteTools, ...options.remoteCodeModeTools] }, + { service: "local", tools: [...options.localTools, ...options.localCodeModeTools] }, + ], + options, + ); + const resolution = resolveNamespaceExposure(entries); + const namespacedRecords = resolution.visibleRecords.filter((record) => record.namespaced); + const namespacedBaseIds = new Set(namespacedRecords.map((record) => record.baseId)); + const diagnosticBaseIds = new Set( + resolution.unavailableDiagnostics.map((diagnostic) => diagnostic.requestedId), + ); + const routes = new Map(); + const namespaceDiagnostics = new Map(resolution.suppressedBareIds); + for (const diagnostic of resolution.unavailableDiagnostics) { + namespaceDiagnostics.set(diagnostic.requestedId, diagnostic); + } + const alternativesByBaseId = new Map>(); + const staleIdsByBaseId = new Map>(); + + const setRoute = ( + visibleCapletId: string, + route: NativeProjectionMergeRoute, + overwrite: boolean, + ) => { + if (overwrite || !routes.has(visibleCapletId)) routes.set(visibleCapletId, route); + }; + const rewrite = (service: "local" | "remote", tools: Tool[], overwrite: boolean): Tool[] => { + const rewritten: Tool[] = []; + for (const tool of tools) { + const baseId = projectionSourceBaseId(tool); + if (diagnosticBaseIds.has(baseId)) continue; + if (!namespacedBaseIds.has(baseId)) { + rewritten.push(tool); + setRoute(tool.caplet, { service, capletId: tool.caplet }, overwrite); + continue; + } + const record = namespacedRecords.find( + (candidate) => candidate.baseId === baseId && candidate.route.service === service, + ); + if (!record) continue; + const visibleTool = options.renameTool(tool, record.id); + rewritten.push(visibleTool); + addProjectionMapSetValue(alternativesByBaseId, baseId, visibleTool.caplet); + if (tool.caplet !== visibleTool.caplet) { + addProjectionMapSetValue(staleIdsByBaseId, baseId, tool.caplet); + } + setRoute(visibleTool.caplet, { service, capletId: tool.caplet }, overwrite); + } + return rewritten; + }; + + const remoteTools = rewrite("remote", options.remoteTools, false); + const localTools = rewrite("local", options.localTools, true); + const remoteCodeModeTools = rewrite("remote", options.remoteCodeModeTools, false); + const localCodeModeTools = rewrite("local", options.localCodeModeTools, true); + for (const [baseId, alternatives] of alternativesByBaseId) { + const baseDiagnostic = resolution.suppressedBareIds.get(baseId); + if (!baseDiagnostic) continue; + const alternativeList = [...alternatives]; + namespaceDiagnostics.set( + baseId, + projectionNamespaceDiagnosticWithAlternatives(baseId, baseDiagnostic, alternativeList), + ); + for (const staleId of staleIdsByBaseId.get(baseId) ?? []) { + namespaceDiagnostics.set( + staleId, + projectionNamespaceDiagnosticWithAlternatives(staleId, baseDiagnostic, alternativeList), + ); + } + } + + return { + remoteTools, + localTools, + remoteCodeModeTools, + localCodeModeTools, + routes, + namespaceDiagnostics, + }; +} + +function nativeProjectionNamespaceEntries( + groups: Array<{ service: "local" | "remote"; tools: Tool[] }>, + identities: { + remoteIdentity: string; + localIdentity: string; + namespaceAliases: { local?: string | undefined; upstreams: Record }; + }, +): Array> { + const entries = new Map>(); + for (const group of groups) { + const byBaseId = new Map(); + for (const tool of group.tools) { + const baseId = projectionSourceBaseId(tool); + byBaseId.set(baseId, [...(byBaseId.get(baseId) ?? []), tool]); + } + for (const [baseId, tools] of byBaseId) { + const service = group.service; + entries.set(`${service}:${baseId}`, { + baseId, + sourceKind: service === "local" ? "local" : "upstream", + sourceLabel: service === "local" ? "local" : "remote", + namespaceAlias: + service === "local" + ? identities.namespaceAliases.local + : identities.namespaceAliases.upstreams[identities.remoteIdentity], + durableSourceIdentity: + service === "local" ? identities.localIdentity : identities.remoteIdentity, + shadowing: aggregateProjectionShadowing(tools), + route: { service, baseId }, + }); + } + } + return [...entries.values()]; +} + +function remoteSuppressedProjectionCapletIds( + allRemoteTools: Tool[], + remoteCodeModeTools: Tool[], +): Set { + return new Set( + [ + ...allRemoteTools + .filter((tool) => tool.codeModeRun !== true && (tool.shadowing ?? "forbid") === "forbid") + .map(projectionSourceBaseId), + ...remoteCodeModeTools + .filter((tool) => (tool.shadowing ?? "forbid") === "forbid") + .map((tool) => tool.caplet), + ].filter((caplet) => caplet !== "code_mode"), + ); +} + +function aggregateProjectionShadowing( + tools: Tool[], +): CapletShadowingPolicy { + if (tools.some((tool) => (tool.shadowing ?? "forbid") === "forbid")) return "forbid"; + if (tools.some((tool) => tool.shadowing === "namespace")) return "namespace"; + return "allow"; +} + +function projectionSourceBaseId(tool: NativeProjectionMergeTool): string { + return tool.sourceCaplet ?? tool.caplet; +} + +function addProjectionMapSetValue( + map: Map>, + key: Key, + value: Value, +): void { + let values = map.get(key); + if (!values) { + values = new Set(); + map.set(key, values); + } + values.add(value); +} + +function projectionNamespaceDiagnosticWithAlternatives( + requestedId: string, + diagnostic: NamespaceDiagnostic, + alternatives: string[], +): NamespaceDiagnostic { + return { + ...diagnostic, + requestedId, + alternatives, + hint: `Caplet '${requestedId}' is unavailable because namespace shadowing exposes qualified alternatives: ${alternatives.join(", ")}.`, + }; +} diff --git a/packages/core/src/native/remote.ts b/packages/core/src/native/remote.ts index 53c81990..a972a995 100644 --- a/packages/core/src/native/remote.ts +++ b/packages/core/src/native/remote.ts @@ -4,6 +4,10 @@ import { directResourceUriMatchesTemplate, } from "../exposure/direct-names"; import { generatedToolInputJsonSchemaForCaplet, operations } from "../generated-tool-input-schema"; +import { + buildManifestExposureProjection, + type ExposureProjectionEntry, +} from "../exposure/projection"; import { CAPLETS_ATTACH_SESSION_HEADER, type AttachCodeModeCaplet, @@ -885,53 +889,68 @@ function primitiveInvokeInput( } function toolsFromManifest(manifest: AttachManifest): RemoteCapletsTool[] { + const projection = buildManifestExposureProjection(manifest); const codeModeCaplets = manifest.codeModeCaplets ?? []; const codeModeMarker = attachCodeModeMarker(codeModeCaplets); - const codeModeShadowing: CapletShadowingPolicy = codeModeCaplets.some( - (entry) => entry.shadowing === "forbid", - ) - ? "forbid" - : codeModeCaplets.some((entry) => entry.shadowing === "namespace") - ? "namespace" - : "allow"; + const codeModeEntries = projection.entries.filter((entry) => entry.kind === "code-mode-caplet"); return [ - ...manifest.caplets.map((entry) => ({ + ...projection.entries.flatMap((entry) => progressiveToolFromProjection(entry, codeModeMarker)), + ...projection.entries.flatMap((entry) => directToolFromProjection(entry, codeModeMarker)), + ...primitiveToolsFromProjection(projection.entries, codeModeMarker), + ...(codeModeEntries.length > 0 + ? [ + { + name: nativeCodeModeToolId, + capletId: nativeCodeModeToolId, + title: "Code Mode", + description: "Remote Caplets available to locally-run attached Code Mode.", + codeModeRun: true, + codeModeCaplets, + shadowing: aggregateProjectionEntryShadowing(codeModeEntries), + inputSchema: codeModeRunInputJsonSchema(), + }, + ] + : []), + ]; +} + +function progressiveToolFromProjection( + entry: ExposureProjectionEntry, + codeModeMarker: Pick | Record, +): RemoteCapletsTool[] { + if (entry.kind !== "progressive-caplet") return []; + return [ + { name: entry.capletId, capletId: entry.capletId, sourceCapletId: entry.sourceCapletId, - title: entry.title ?? entry.name, + title: entry.title, description: entry.description, inputSchema: entry.inputSchema, shadowing: entry.shadowing, ...codeModeMarker, - })), - ...manifest.tools.map((entry) => ({ - name: entry.name, + }, + ]; +} + +function directToolFromProjection( + entry: ExposureProjectionEntry, + codeModeMarker: Pick | Record, +): RemoteCapletsTool[] { + if (entry.kind !== "direct-tool" || entry.route.kind !== "direct-tool") return []; + return [ + { + name: entry.id, capletId: entry.capletId, - sourceCapletId: entry.capletId, - title: entry.title ?? entry.name, + sourceCapletId: entry.sourceCapletId ?? entry.capletId, + title: entry.title, description: entry.description, inputSchema: entry.inputSchema, outputSchema: entry.outputSchema, annotations: entry.annotations, shadowing: entry.shadowing, ...codeModeMarker, - })), - ...primitiveToolsFromManifest(manifest, codeModeMarker), - ...(codeModeCaplets.length > 0 - ? [ - { - name: nativeCodeModeToolId, - capletId: nativeCodeModeToolId, - title: "Code Mode", - description: "Remote Caplets available to locally-run attached Code Mode.", - codeModeRun: true, - codeModeCaplets, - shadowing: codeModeShadowing, - inputSchema: codeModeRunInputJsonSchema(), - }, - ] - : []), + }, ]; } @@ -941,11 +960,13 @@ function attachCodeModeMarker( return codeModeCaplets.length === 0 ? { codeModeCaplets: [] } : {}; } -function primitiveToolsFromManifest( - manifest: AttachManifest, +function primitiveToolsFromProjection( + entries: ExposureProjectionEntry[], codeModeMarker: Pick | Record, ): RemoteCapletsTool[] { - const directToolNames = new Set(manifest.tools.map((entry) => entry.name)); + const directToolNames = new Set( + entries.filter((entry) => entry.kind === "direct-tool").map((entry) => entry.id), + ); const byCaplet = new Map< string, { @@ -959,7 +980,9 @@ function primitiveToolsFromManifest( const entryFor = (capletId: string, shadowing: CapletShadowingPolicy) => { const existing = byCaplet.get(capletId); if (existing) { - if (shadowing === "forbid") existing.shadowing = "forbid"; + if (shadowing === "forbid" || (shadowing === "namespace" && existing.shadowing === "allow")) { + existing.shadowing = shadowing; + } return existing; } const next = { @@ -972,13 +995,15 @@ function primitiveToolsFromManifest( byCaplet.set(capletId, next); return next; }; - for (const entry of manifest.resources) - entryFor(entry.capletId, entry.shadowing).resources = true; - for (const entry of manifest.resourceTemplates) - entryFor(entry.capletId, entry.shadowing).resourceTemplates = true; - for (const entry of manifest.prompts) entryFor(entry.capletId, entry.shadowing).prompts = true; - for (const entry of manifest.completions) - entryFor(entry.capletId, entry.shadowing).completions = true; + for (const entry of entries) { + if (entry.kind === "direct-resource") + entryFor(entry.capletId, entry.shadowing).resources = true; + if (entry.kind === "direct-resource-template") { + entryFor(entry.capletId, entry.shadowing).resourceTemplates = true; + } + if (entry.kind === "direct-prompt") entryFor(entry.capletId, entry.shadowing).prompts = true; + if (entry.kind === "completion") entryFor(entry.capletId, entry.shadowing).completions = true; + } const tools: RemoteCapletsTool[] = []; const addPrimitiveTool = ( @@ -1028,6 +1053,14 @@ function primitiveTool( }; } +function aggregateProjectionEntryShadowing( + entries: ExposureProjectionEntry[], +): CapletShadowingPolicy { + if (entries.some((entry) => entry.shadowing === "forbid")) return "forbid"; + if (entries.some((entry) => entry.shadowing === "namespace")) return "namespace"; + return "allow"; +} + function primitiveInputSchema(operation: string): Record { if (operation === "read_resource") { return { diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index 0824623a..207f737c 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -43,12 +43,13 @@ import { nativeCodeModeToolName, } from "./tools"; import { nativeDirectToolName } from "../exposure/direct-names"; -import { - resolveNamespaceExposure, - type NamespaceDiagnostic, - type NamespaceSourceEntry, -} from "../exposure/namespace"; +import type { NamespaceDiagnostic } from "../exposure/namespace"; import { resolveExposure } from "../exposure/policy"; +import { + buildExposureProjection, + resolveNativeProjectionMerge, + type ExposureProjection, +} from "../exposure/projection"; import { generateCodeModeDeclarations, generateCodeModeRunToolDescription, @@ -194,6 +195,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { private readonly toolListeners = new Set(); private directToolRoutes = new Map(); private exposureSnapshot: ExposureSnapshot | undefined; + private exposureProjection: ExposureProjection | undefined; private readonly codeModeSessions = new CodeModeSessionManager(); private postReloadRefresh: Promise | undefined; private exposureRefreshGeneration = 0; @@ -219,19 +221,19 @@ class DefaultNativeCapletsService implements NativeCapletsService { listTools(): NativeCapletTool[] { this.directToolRoutes = new Map(); + if (this.exposureSnapshot && this.exposureProjection) { + return this.projectedNativeTools(this.exposureProjection, this.exposureSnapshot); + } const progressiveTools: NativeCapletTool[] = []; const codeModeCaplets: NativeCapletTool[] = []; const directTools: NativeCapletTool[] = []; - const snapshotCaplets = this.exposureSnapshot?.callableCaplets; - const caplets = snapshotCaplets - ? snapshotCaplets.map((entry) => entry.caplet) - : this.engine.enabledServers().filter((caplet) => { - if (caplet.setup) return false; - if (caplet.projectBinding?.required && !this.engine.currentProjectBindingContext()) { - return false; - } - return true; - }); + const caplets = this.engine.enabledServers().filter((caplet) => { + if (caplet.setup) return false; + if (caplet.projectBinding?.required && !this.engine.currentProjectBindingContext()) { + return false; + } + return true; + }); for (const caplet of caplets) { const exposure = resolveExposure( caplet.exposure, @@ -257,6 +259,72 @@ class DefaultNativeCapletsService implements NativeCapletsService { ]; } + private projectedNativeTools( + projection: ExposureProjection, + snapshot: ExposureSnapshot, + ): NativeCapletTool[] { + const progressiveTools: NativeCapletTool[] = []; + const directTools: NativeCapletTool[] = []; + const codeModeCaplets: NativeCapletTool[] = []; + const callableById = new Map( + snapshot.callableCaplets.map((entry) => [entry.caplet.server, entry]), + ); + const directToolsByRoute = new Map( + snapshot.directTools.map((entry) => [ + directToolRouteKey(entry.caplet.server, entry.downstreamName), + entry, + ]), + ); + const primitiveCapletIds = new Set(); + + for (const entry of projection.entries) { + const callable = callableById.get(entry.capletId); + if (!callable) continue; + if (entry.kind === "progressive-caplet") { + progressiveTools.push(progressiveNativeTool(callable.caplet)); + } + if (entry.kind === "code-mode-caplet") { + codeModeCaplets.push(codeModeCapletDescriptor(callable.caplet)); + } + if (entry.kind === "direct-tool") { + const direct = + entry.route.kind === "direct-tool" + ? directToolsByRoute.get( + directToolRouteKey(entry.route.capletId, entry.route.downstreamName), + ) + : undefined; + if (direct) directTools.push(this.directDiscoveredTool(callable.caplet, direct, entry.id)); + } + if ( + entry.kind === "direct-resource" || + entry.kind === "direct-resource-template" || + entry.kind === "direct-prompt" || + entry.kind === "completion" + ) { + primitiveCapletIds.add(entry.capletId); + } + } + + for (const capletId of [...primitiveCapletIds].sort()) { + const callable = callableById.get(capletId); + if (!callable || callable.caplet.backend !== "mcp") continue; + directTools.push( + ...mcpPrimitiveNativeTools(callable.caplet, snapshot).map((operationName) => + this.directNativeTool(callable.caplet, operationName, { + description: `MCP ${operationName.replace(/_/g, " ")}.`, + inputSchema: nativeMcpPrimitiveInputSchema(operationName), + }), + ), + ); + } + + return [ + ...progressiveTools, + ...directTools, + ...(codeModeCaplets.length > 0 ? [codeModeRunNativeTool(codeModeCaplets)] : []), + ]; + } + async execute(capletId: string, request: unknown): Promise { if (capletId === nativeCodeModeToolId) { const started = Date.now(); @@ -380,8 +448,10 @@ class DefaultNativeCapletsService implements NativeCapletsService { private directDiscoveredTool( caplet: ReturnType[number], entry: DirectToolRegistration, + visibleId?: string, ): NativeCapletTool { return this.directNativeTool(caplet, entry.downstreamName, { + ...(visibleId ? { visibleId } : {}), ...(entry.tool.description ? { description: entry.tool.description } : {}), ...(entry.tool.inputSchema ? { inputSchema: entry.tool.inputSchema as Record } @@ -397,13 +467,14 @@ class DefaultNativeCapletsService implements NativeCapletsService { caplet: ReturnType[number], operationName: string, options: { + visibleId?: string; description?: string; inputSchema?: Record; outputSchema?: Record; annotations?: Record; }, ): NativeCapletTool { - const routeId = `${caplet.server}__${operationName}`; + const routeId = options.visibleId ?? `${caplet.server}__${operationName}`; const toolName = nativeDirectToolName(caplet.server, operationName); this.directToolRoutes.set(routeId, { capletId: caplet.server, operationName }); return { @@ -429,6 +500,7 @@ class DefaultNativeCapletsService implements NativeCapletsService { }); if (generation !== this.exposureRefreshGeneration) return; this.exposureSnapshot = snapshot; + this.exposureProjection = buildExposureProjection(snapshot); if (options.emitToolsChanged) this.emitToolsChanged(); } catch (error) { if (generation !== this.exposureRefreshGeneration) return; @@ -519,6 +591,10 @@ function codeModeCapletDescriptor( }; } +function directToolRouteKey(capletId: string, downstreamName: string): string { + return `${capletId}:${downstreamName}`; +} + function mcpPrimitiveNativeTools( caplet: ReturnType[number], snapshot: ExposureSnapshot | undefined, @@ -533,6 +609,9 @@ function mcpPrimitiveNativeTools( if (snapshot?.directPrompts.some((entry) => entry.caplet.server === caplet.server)) { operations.push("list_prompts", "get_prompt", "complete"); } + if (snapshot?.directResourceTemplates.some((entry) => entry.caplet.server === caplet.server)) { + operations.push("complete"); + } return [...new Set(operations)]; } @@ -1298,145 +1377,30 @@ class CompositeNativeCapletsService implements NativeCapletsService { routes: Map; namespaceDiagnostics: Map; } { - const allLocalTools = this.local.listTools(); - const allRemoteTools = this.remote.listTools(); - const remoteCodeModeTools = remoteCodeModeCallableNativeTools(allRemoteTools); - const remoteIds = remoteSuppressedCapletIds(allRemoteTools, remoteCodeModeTools); - const unsuppressedLocalTools = allLocalTools.filter( - (tool) => tool.codeModeRun !== true && !remoteIds.has(tool.sourceCaplet ?? tool.caplet), - ); - this.warnShadowedLocalCaplets(allLocalTools, remoteIds); - const unsuppressedLocalCodeModeTools = codeModeCallableNativeTools(allLocalTools, { - fallbackToVisible: false, - }).filter((tool) => !remoteIds.has(tool.caplet)); - const remoteTools = allRemoteTools.filter((tool) => tool.codeModeRun !== true); - const resolved = this.resolveVisibleToolIds( + const localTools = this.local.listTools(); + const remoteTools = this.remote.listTools(); + const resolved = resolveNativeProjectionMerge({ remoteTools, - unsuppressedLocalTools, - remoteCodeModeTools, - unsuppressedLocalCodeModeTools, - ); + localTools, + remoteCodeModeTools: remoteCodeModeCallableNativeTools(remoteTools), + localCodeModeTools: codeModeCallableNativeTools(localTools, { fallbackToVisible: false }), + remoteIdentity: this.remoteIdentity, + ...nativeNamespaceContext(this.options), + renameTool: renameNativeTool, + }); + this.warnShadowedLocalCaplets(localTools, resolved.suppressedLocalIds); const codeModeTools = [...resolved.remoteCodeModeTools, ...resolved.localCodeModeTools]; - const tools = [ - ...resolved.remoteTools, - ...resolved.localTools, - ...(codeModeTools.length > 0 ? [codeModeRunNativeTool(codeModeTools)] : []), - ]; return { - tools, + tools: [ + ...resolved.remoteTools, + ...resolved.localTools, + ...(codeModeTools.length > 0 ? [codeModeRunNativeTool(codeModeTools)] : []), + ], routes: resolved.routes, namespaceDiagnostics: resolved.namespaceDiagnostics, }; } - private resolveVisibleToolIds( - remoteTools: NativeCapletTool[], - localTools: NativeCapletTool[], - remoteCodeModeTools: NativeCapletTool[], - localCodeModeTools: NativeCapletTool[], - ): { - remoteTools: NativeCapletTool[]; - localTools: NativeCapletTool[]; - remoteCodeModeTools: NativeCapletTool[]; - localCodeModeTools: NativeCapletTool[]; - routes: Map; - namespaceDiagnostics: Map; - } { - const entries = nativeNamespaceEntries( - [ - { service: "remote", tools: [...remoteTools, ...remoteCodeModeTools] }, - { service: "local", tools: [...localTools, ...localCodeModeTools] }, - ], - { - ...nativeNamespaceContext(this.options), - remoteIdentity: this.remoteIdentity, - }, - ); - const resolution = resolveNamespaceExposure(entries); - const namespacedRecords = resolution.visibleRecords.filter((record) => record.namespaced); - const namespacedBaseIds = new Set(namespacedRecords.map((record) => record.baseId)); - const diagnosticBaseIds = new Set( - resolution.unavailableDiagnostics.map((diagnostic) => diagnostic.requestedId), - ); - const routes = new Map(); - const namespaceDiagnostics = new Map(resolution.suppressedBareIds); - for (const diagnostic of resolution.unavailableDiagnostics) { - namespaceDiagnostics.set(diagnostic.requestedId, diagnostic); - } - const alternativesByBaseId = new Map>(); - const staleIdsByBaseId = new Map>(); - - const setRoute = ( - visibleCapletId: string, - route: { service: "local" | "remote"; capletId: string }, - overwrite: boolean, - ) => { - if (overwrite || !routes.has(visibleCapletId)) { - routes.set(visibleCapletId, route); - } - }; - const rewrite = ( - service: "local" | "remote", - tools: NativeCapletTool[], - overwrite: boolean, - ) => { - const rewritten: NativeCapletTool[] = []; - for (const tool of tools) { - const baseId = sourceBaseId(tool); - if (diagnosticBaseIds.has(baseId)) { - continue; - } - if (!namespacedBaseIds.has(baseId)) { - rewritten.push(tool); - setRoute(tool.caplet, { service, capletId: tool.caplet }, overwrite); - continue; - } - - const record = namespacedRecords.find( - (candidate) => candidate.baseId === baseId && candidate.route.service === service, - ); - if (!record) continue; - const visibleTool = renameNativeTool(tool, record.id); - rewritten.push(visibleTool); - addMapSetValue(alternativesByBaseId, baseId, visibleTool.caplet); - if (tool.caplet !== visibleTool.caplet) { - addMapSetValue(staleIdsByBaseId, baseId, tool.caplet); - } - setRoute(visibleTool.caplet, { service, capletId: tool.caplet }, overwrite); - } - return rewritten; - }; - - const remoteVisibleTools = rewrite("remote", remoteTools, false); - const localVisibleTools = rewrite("local", localTools, true); - const remoteVisibleCodeModeTools = rewrite("remote", remoteCodeModeTools, false); - const localVisibleCodeModeTools = rewrite("local", localCodeModeTools, true); - for (const [baseId, alternatives] of alternativesByBaseId) { - const baseDiagnostic = resolution.suppressedBareIds.get(baseId); - if (!baseDiagnostic) continue; - const alternativeList = [...alternatives]; - namespaceDiagnostics.set( - baseId, - namespaceDiagnosticWithAlternatives(baseId, baseDiagnostic, alternativeList), - ); - for (const staleId of staleIdsByBaseId.get(baseId) ?? []) { - namespaceDiagnostics.set( - staleId, - namespaceDiagnosticWithAlternatives(staleId, baseDiagnostic, alternativeList), - ); - } - } - - return { - remoteTools: remoteVisibleTools, - localTools: localVisibleTools, - remoteCodeModeTools: remoteVisibleCodeModeTools, - localCodeModeTools: localVisibleCodeModeTools, - routes, - namespaceDiagnostics, - }; - } - private warnShadowedLocalCaplets(localTools: NativeCapletTool[], remoteIds: Set): void { const localIds = new Set([ ...localTools @@ -1480,33 +1444,6 @@ class CompositeNativeCapletsService implements NativeCapletsService { } } -type NativeNamespaceRoute = { - service: "local" | "remote"; - baseId: string; -}; - -function addMapSetValue(map: Map>, key: Key, value: Value): void { - let values = map.get(key); - if (!values) { - values = new Set(); - map.set(key, values); - } - values.add(value); -} - -function namespaceDiagnosticWithAlternatives( - requestedId: string, - diagnostic: NamespaceDiagnostic, - alternatives: string[], -): NamespaceDiagnostic { - return { - ...diagnostic, - requestedId, - alternatives, - hint: `Caplet '${requestedId}' is unavailable because namespace shadowing exposes qualified alternatives: ${alternatives.join(", ")}.`, - }; -} - function nativeNamespaceContext(options: NativeCapletsServiceOptions): { localIdentity: string; namespaceAliases: NamespaceAliasesConfig; @@ -1526,53 +1463,8 @@ function nativeNamespaceContext(options: NativeCapletsServiceOptions): { }; } -function nativeNamespaceEntries( - groups: Array<{ service: "local" | "remote"; tools: NativeCapletTool[] }>, - identities: { - remoteIdentity: string; - localIdentity: string; - namespaceAliases: NamespaceAliasesConfig; - }, -): NamespaceSourceEntry[] { - const entries = new Map>(); - for (const group of groups) { - const byBaseId = new Map(); - for (const tool of group.tools) { - const baseId = sourceBaseId(tool); - byBaseId.set(baseId, [...(byBaseId.get(baseId) ?? []), tool]); - } - for (const [baseId, tools] of byBaseId) { - const service = group.service; - entries.set(`${service}:${baseId}`, { - baseId, - sourceKind: service === "local" ? "local" : "upstream", - sourceLabel: service === "local" ? "local" : "remote", - namespaceAlias: - service === "local" - ? identities.namespaceAliases.local - : identities.namespaceAliases.upstreams[identities.remoteIdentity], - durableSourceIdentity: - service === "local" ? identities.localIdentity : identities.remoteIdentity, - shadowing: aggregateShadowing(tools), - route: { service, baseId }, - }); - } - } - return [...entries.values()]; -} - -function aggregateShadowing(tools: NativeCapletTool[]): CapletShadowingPolicy { - if (tools.some((tool) => (tool.shadowing ?? "forbid") === "forbid")) return "forbid"; - if (tools.some((tool) => tool.shadowing === "namespace")) return "namespace"; - return "allow"; -} - -function sourceBaseId(tool: NativeCapletTool): string { - return tool.sourceCaplet ?? tool.caplet; -} - function renameNativeTool(tool: NativeCapletTool, visibleBaseId: string): NativeCapletTool { - const baseId = sourceBaseId(tool); + const baseId = tool.sourceCaplet ?? tool.caplet; const directTool = Boolean(tool.sourceCaplet && tool.caplet.startsWith(`${baseId}__`)); const visibleCapletId = directTool ? `${visibleBaseId}${tool.caplet.slice(baseId.length)}` @@ -1593,22 +1485,6 @@ function remoteCodeModeCallableNativeTools(tools: NativeCapletTool[]): NativeCap return codeModeCallableNativeTools(tools, { fallbackToVisible: true }); } -function remoteSuppressedCapletIds( - allRemoteTools: NativeCapletTool[], - remoteCodeModeTools = remoteCodeModeCallableNativeTools(allRemoteTools), -): Set { - return new Set( - [ - ...allRemoteTools - .filter((tool) => tool.codeModeRun !== true && (tool.shadowing ?? "forbid") === "forbid") - .map((tool) => tool.sourceCaplet ?? tool.caplet), - ...remoteCodeModeTools - .filter((tool) => (tool.shadowing ?? "forbid") === "forbid") - .map((tool) => tool.caplet), - ].filter((caplet) => caplet !== nativeCodeModeToolId), - ); -} - function createProjectBindingSessionManager( cloud: ResolvedNativeRemoteOptions["cloud"], remoteOptions: ResolvedNativeRemoteOptions, diff --git a/packages/core/src/serve/session.ts b/packages/core/src/serve/session.ts index 5cb95b26..c202b075 100644 --- a/packages/core/src/serve/session.ts +++ b/packages/core/src/serve/session.ts @@ -32,6 +32,7 @@ import type { DirectToolRegistration, ExposureSnapshot, } from "../exposure/discovery"; +import { buildExposureProjection, type ExposureProjection } from "../exposure/projection"; import { decodeDirectResourceUri } from "../exposure/direct-names"; import { resolveExposure } from "../exposure/policy"; import { generatedToolInputSchemaForCaplet } from "../generated-tool-input-schema"; @@ -123,64 +124,89 @@ export class CapletsMcpSession { } private reconcileFromSnapshot(snapshot: ExposureSnapshot): void { - if (snapshot.codeModeCaplets.length > 0) { + this.reconcileFromProjection(buildExposureProjection(snapshot), snapshot); + } + + private reconcileFromProjection( + projection: ExposureProjection, + snapshot: ExposureSnapshot, + ): void { + const codeModeCaplets = projection.entries + .filter((entry) => entry.kind === "code-mode-caplet") + .flatMap((entry) => { + const caplet = snapshot.codeModeCaplets.find( + (candidate) => candidate.caplet.server === entry.capletId, + ); + return caplet ? [caplet] : []; + }); + if (codeModeCaplets.length > 0) { if (this.codeModeTool) { this.codeModeTool.update({ title: "Code Mode", - description: codeModeRunToolDescription(snapshot.codeModeCaplets), + description: codeModeRunToolDescription(codeModeCaplets), paramsSchema: codeModeRunParamsSchema, callback: async (request: unknown) => this.handleCodeModeRunTool(request), enabled: true, }); } else { - this.codeModeTool = this.registerCodeModeTool(snapshot); + this.codeModeTool = this.registerCodeModeTool(codeModeCaplets); } } else if (this.codeModeTool) { this.codeModeTool.remove(); this.codeModeTool = undefined; } + const progressiveById = new Map( + snapshot.progressiveCaplets.map((entry) => [entry.caplet.server, entry]), + ); + const directToolsById = new Map(snapshot.directTools.map((entry) => [entry.name, entry])); const desiredTools = new Map(); - for (const entry of snapshot.progressiveCaplets) { - desiredTools.set(entry.caplet.server, { - register: () => this.registerCapletTool(entry.caplet), - update: (tool) => - (tool.update as (updates: Record) => void)({ - title: entry.caplet.name, - description: capabilityDescription(entry.caplet), - paramsSchema: generatedToolInputSchemaForCaplet(entry.caplet).shape, - callback: async (request: unknown) => - this.engine.execute(entry.caplet.server, request) as never, - enabled: true, - }), - }); - } - for (const entry of snapshot.directTools) { - desiredTools.set(entry.name, { - register: () => this.registerDirectTool(entry), - update: (tool) => - (tool.update as (updates: Record) => void)({ - title: entry.tool.name, - description: entry.tool.description, - paramsSchema: entry.tool.inputSchema as never, - outputSchema: entry.tool.outputSchema as never, - annotations: entry.tool.annotations, - _meta: { - caplets: { - capletId: entry.caplet.server, - downstreamName: entry.downstreamName, - exposure: "direct", + for (const entry of projection.entries) { + if (entry.kind === "progressive-caplet") { + const capletEntry = progressiveById.get(entry.id); + if (!capletEntry) continue; + desiredTools.set(entry.id, { + register: () => this.registerCapletTool(capletEntry.caplet), + update: (tool) => + (tool.update as (updates: Record) => void)({ + title: capletEntry.caplet.name, + description: capabilityDescription(capletEntry.caplet), + paramsSchema: generatedToolInputSchemaForCaplet(capletEntry.caplet).shape, + callback: async (request: unknown) => + this.engine.execute(capletEntry.caplet.server, request) as never, + enabled: true, + }), + }); + } + if (entry.kind === "direct-tool") { + const directTool = directToolsById.get(entry.id); + if (!directTool) continue; + desiredTools.set(entry.id, { + register: () => this.registerDirectTool(directTool), + update: (tool) => + (tool.update as (updates: Record) => void)({ + title: directTool.tool.name, + description: directTool.tool.description, + paramsSchema: directTool.tool.inputSchema as never, + outputSchema: directTool.tool.outputSchema as never, + annotations: directTool.tool.annotations, + _meta: { + caplets: { + capletId: directTool.caplet.server, + downstreamName: directTool.downstreamName, + exposure: "direct", + }, }, - }, - callback: async (request: unknown) => - this.engine.executeDirectTool( - entry.caplet.server, - entry.downstreamName, - isRecord(request) ? request : {}, - ) as never, - enabled: true, - }), - }); + callback: async (request: unknown) => + this.engine.executeDirectTool( + directTool.caplet.server, + directTool.downstreamName, + isRecord(request) ? request : {}, + ) as never, + enabled: true, + }), + }); + } } for (const [name, tool] of this.tools) { const plan = desiredTools.get(name); @@ -201,14 +227,24 @@ export class CapletsMcpSession { for (const prompt of this.prompts.values()) prompt.remove(); this.resources.clear(); this.prompts.clear(); - for (const entry of snapshot.directResources) { - this.resources.set(entry.uri, this.registerDirectResource(entry)); - } - for (const entry of snapshot.directResourceTemplates) { - this.resources.set(entry.uriTemplate, this.registerDirectResourceTemplate(entry)); - } - for (const entry of snapshot.directPrompts) { - this.prompts.set(entry.name, this.registerDirectPrompt(entry)); + const resourcesById = new Map(snapshot.directResources.map((entry) => [entry.uri, entry])); + const resourceTemplatesById = new Map( + snapshot.directResourceTemplates.map((entry) => [entry.uriTemplate, entry]), + ); + const promptsById = new Map(snapshot.directPrompts.map((entry) => [entry.name, entry])); + for (const entry of projection.entries) { + if (entry.kind === "direct-resource") { + const resource = resourcesById.get(entry.id); + if (resource) this.resources.set(entry.id, this.registerDirectResource(resource)); + } + if (entry.kind === "direct-resource-template") { + const template = resourceTemplatesById.get(entry.id); + if (template) this.resources.set(entry.id, this.registerDirectResourceTemplate(template)); + } + if (entry.kind === "direct-prompt") { + const prompt = promptsById.get(entry.id); + if (prompt) this.prompts.set(entry.id, this.registerDirectPrompt(prompt)); + } } } @@ -223,12 +259,12 @@ export class CapletsMcpSession { this.prompts.clear(); } - private registerCodeModeTool(snapshot: ExposureSnapshot): RegisteredTool { + private registerCodeModeTool(codeModeCaplets: CallableCaplet[]): RegisteredTool { return this.server.registerTool( "code_mode", { title: "Code Mode", - description: codeModeRunToolDescription(snapshot.codeModeCaplets), + description: codeModeRunToolDescription(codeModeCaplets), inputSchema: codeModeRunParamsSchema, }, async (request: unknown) => this.handleCodeModeRunTool(request), diff --git a/packages/core/test/attach-api.test.ts b/packages/core/test/attach-api.test.ts index 0e3fc753..d48e74d1 100644 --- a/packages/core/test/attach-api.test.ts +++ b/packages/core/test/attach-api.test.ts @@ -100,6 +100,48 @@ describe("Attach API dispatch", () => { ]); }); + it("sanitizes hidden discovery diagnostics through exposure projection", async () => { + const engine = { + exposureSnapshot: async () => ({ + callableCaplets: [], + progressiveCaplets: [], + codeModeCaplets: [], + directTools: [], + directResources: [], + directResourceTemplates: [], + directPrompts: [], + hiddenCaplets: [ + { + capletId: "vaulted", + reason: "discovery_failed", + error: { + code: "SERVER_UNAVAILABLE", + message: "Failed with sk-live-secret-token at /Users/ian/.config/caplets/token.json", + details: { + token: "sk-live-secret-token", + path: "/Users/ian/.config/caplets/token.json", + }, + }, + }, + ], + }), + } as unknown as CapletsEngine; + + const projection = await buildAttachProjection(engine); + + expect(projection.manifest.diagnostics).toEqual([ + expect.objectContaining({ + code: "ATTACH_CAPLET_DISCOVERY_FAILED", + capletId: "vaulted", + details: expect.objectContaining({ + code: "SERVER_UNAVAILABLE", + }), + }), + ]); + expect(JSON.stringify(projection.manifest.diagnostics)).not.toContain("sk-live-secret-token"); + expect(JSON.stringify(projection.manifest.diagnostics)).not.toContain("/Users/ian"); + }); + it("includes authoritative Project Binding metadata for hidden Caplets", async () => { const engine = { exposureSnapshot: async () => ({ diff --git a/packages/core/test/exposure-projection.test.ts b/packages/core/test/exposure-projection.test.ts new file mode 100644 index 00000000..92da2963 --- /dev/null +++ b/packages/core/test/exposure-projection.test.ts @@ -0,0 +1,625 @@ +import type { Prompt, Resource, ResourceTemplate, Tool } from "@modelcontextprotocol/sdk/types"; +import { describe, expect, it } from "vitest"; +import type { CapletConfig } from "../src/config"; +import type { CallableCaplet, ExposureSnapshot } from "../src/exposure/discovery"; +import { + buildExposureProjection, + buildManifestExposureProjection, + exposureProjectionRouteKey, + resolveNativeProjectionMerge, +} from "../src/exposure/projection"; + +const discoveredAt = 1_719_000_000_000; + +describe("Caplets exposure projection", () => { + it("projects mixed snapshot surfaces into adapter-neutral entries and route descriptors", () => { + const http = callable(httpCaplet("search", "progressive_and_code_mode"), { + tools: [tool("query")], + exposure: { + value: "progressive_and_code_mode", + progressive: true, + direct: false, + codeMode: true, + }, + }); + const docsCaplet = mcpCaplet("docs", "direct"); + const docs = callable(docsCaplet, { + tools: [tool("read")], + resources: [resource("file:///README.md")], + resourceTemplates: [resourceTemplate("file:///{path}")], + prompts: [prompt("summarize")], + exposure: { value: "direct", progressive: false, direct: true, codeMode: false }, + }); + + const projection = buildExposureProjection( + snapshot({ + callableCaplets: [http, docs], + progressiveCaplets: [http], + codeModeCaplets: [http], + directTools: [ + { caplet: docsCaplet, downstreamName: "read", name: "docs__read", tool: tool("read") }, + ], + directResources: [ + { + caplet: docsCaplet, + downstreamUri: "file:///README.md", + uri: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", + resource: resource("file:///README.md"), + }, + ], + directResourceTemplates: [ + { + caplet: docsCaplet, + downstreamUriTemplate: "file:///{path}", + uriTemplate: + "caplets://docs/resources/{encodedUri}?template=file%3A%2F%2F%2F%7Bpath%7D", + resourceTemplate: resourceTemplate("file:///{path}"), + }, + ], + directPrompts: [ + { + caplet: docsCaplet, + downstreamName: "summarize", + name: "docs__summarize", + prompt: prompt("summarize"), + }, + ], + }), + ); + + expect(projection.availability).toEqual({ state: "ready" }); + expect(projection.entries.map((entry) => [entry.kind, entry.id, entry.capletId])).toEqual([ + ["progressive-caplet", "search", "search"], + ["code-mode-caplet", "search", "search"], + ["direct-tool", "docs__read", "docs"], + ["direct-resource", "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", "docs"], + [ + "direct-resource-template", + "caplets://docs/resources/{encodedUri}?template=file%3A%2F%2F%2F%7Bpath%7D", + "docs", + ], + ["direct-prompt", "docs__summarize", "docs"], + ["completion", "docs:complete", "docs"], + ]); + expect(projection.routes.get("direct-tool:docs__read")).toEqual({ + kind: "direct-tool", + capletId: "docs", + downstreamName: "read", + }); + expect(projection.routes.get("progressive-caplet:search")).toEqual({ + kind: "progressive-caplet", + capletId: "search", + }); + expect(projection.routes.get("code-mode-caplet:search")).toEqual({ + kind: "code-mode-caplet", + capletId: "search", + }); + expect(projection.routes.get("direct-tool:docs__read")).not.toEqual( + expect.objectContaining({ callback: expect.any(Function) }), + ); + }); + + it("keeps hidden Caplets non-callable while exposing safe diagnostic breadcrumbs", () => { + const projection = buildExposureProjection( + snapshot({ + hiddenCaplets: [ + { + capletId: "vaulted", + reason: "discovery_failed", + error: { + code: "SERVER_UNAVAILABLE", + message: + "Failed with sk-live-secret-token at /Users/ian/.config/caplets/token.json and C:\\Users\\ian\\.caplets\\config.json", + details: { + token: "sk-live-secret-token", + path: "C:/Users/ian/.caplets/config.json", + }, + }, + }, + { capletId: "workspace", reason: "project_binding_missing_context" }, + ], + }), + ); + + expect(projection.entries.map((entry) => entry.capletId)).not.toContain("vaulted"); + expect(projection.hiddenCaplets).toEqual([ + expect.objectContaining({ + capletId: "vaulted", + reason: "discovery_failed", + diagnostic: expect.objectContaining({ + code: "SERVER_UNAVAILABLE", + }), + }), + expect.objectContaining({ + capletId: "workspace", + reason: "project_binding_missing_context", + }), + ]); + expect(JSON.stringify(projection.hiddenCaplets)).not.toContain("sk-live-secret-token"); + expect(JSON.stringify(projection.hiddenCaplets)).not.toContain("/Users/ian"); + expect(JSON.stringify(projection.hiddenCaplets)).not.toContain("C:\\Users\\ian"); + expect(JSON.stringify(projection.hiddenCaplets)).not.toContain("C:/Users/ian"); + }); + + it("preserves skipped non-direct MCP surface discovery as an empty projection", () => { + const docs = callable(mcpCaplet("docs", "progressive_and_code_mode"), { + exposure: { + value: "progressive_and_code_mode", + progressive: true, + direct: false, + codeMode: true, + }, + }); + + const projection = buildExposureProjection( + snapshot({ + callableCaplets: [docs], + progressiveCaplets: [docs], + codeModeCaplets: [docs], + }), + ); + + expect(projection.entries.map((entry) => entry.kind)).toEqual([ + "progressive-caplet", + "code-mode-caplet", + ]); + expect(projection.entries).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ kind: "direct-resource" }), + expect.objectContaining({ kind: "direct-prompt" }), + ]), + ); + }); +}); + +describe("remote manifest exposure projection", () => { + it("projects attach manifest surfaces into first-class exposure entries", () => { + const projection = buildManifestExposureProjection({ + caplets: [ + manifestEntry({ + kind: "caplet", + name: "Docs", + capletId: "docs", + title: "Docs", + description: "Docs caplet.", + inputSchema: { type: "object" }, + shadowing: "allow", + }), + ], + tools: [ + manifestEntry({ + kind: "tool", + name: "docs__search", + downstreamName: "search", + capletId: "docs", + title: "Search", + description: "Search docs.", + inputSchema: { type: "object", properties: { q: { type: "string" } } }, + outputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + shadowing: "allow", + }), + ], + resources: [ + manifestEntry({ + kind: "resource", + uri: "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", + downstreamUri: "file:///README.md", + capletId: "docs", + sourceCapletId: "upstream-docs", + title: "README", + description: "README resource.", + mimeType: "text/markdown", + size: 42, + shadowing: "allow", + }), + ], + resourceTemplates: [ + manifestEntry({ + kind: "resourceTemplate", + uriTemplate: "caplets://docs/resources/{encodedUri}", + downstreamUriTemplate: "file:///{path}", + capletId: "docs", + sourceCapletId: "upstream-docs", + title: "File", + description: "File resource.", + mimeType: "text/plain", + shadowing: "allow", + }), + ], + prompts: [ + manifestEntry({ + kind: "prompt", + name: "docs__explain", + downstreamName: "explain", + capletId: "docs", + sourceCapletId: "upstream-docs", + title: "Explain", + description: "Explain prompt.", + inputSchema: { arguments: [{ name: "topic", required: true }] }, + shadowing: "allow", + }), + ], + completions: [ + manifestEntry({ + kind: "completion", + name: "docs:complete", + capletId: "docs", + sourceCapletId: "upstream-docs", + title: "Complete", + description: "Complete docs inputs.", + shadowing: "allow", + }), + ], + codeModeCaplets: [ + manifestEntry({ + kind: "caplet", + name: "Docs", + capletId: "docs", + title: "Docs", + description: "Docs Code Mode handle.", + shadowing: "allow", + }), + ], + }); + + expect(projection.entries.map((entry) => [entry.kind, entry.id, entry.route])).toEqual([ + ["progressive-caplet", "docs", { kind: "progressive-caplet", capletId: "docs" }], + [ + "direct-tool", + "docs__search", + { kind: "direct-tool", capletId: "docs", downstreamName: "search" }, + ], + [ + "direct-resource", + "caplets://docs/resources/file%3A%2F%2F%2FREADME.md", + { kind: "direct-resource", capletId: "docs", downstreamUri: "file:///README.md" }, + ], + [ + "direct-resource-template", + "caplets://docs/resources/{encodedUri}", + { + kind: "direct-resource-template", + capletId: "docs", + downstreamUriTemplate: "file:///{path}", + }, + ], + [ + "direct-prompt", + "docs__explain", + { kind: "direct-prompt", capletId: "docs", downstreamName: "explain" }, + ], + ["completion", "docs:complete", { kind: "completion", capletId: "docs" }], + ["code-mode-caplet", "docs", { kind: "code-mode-caplet", capletId: "docs" }], + ]); + expect( + projection.routes.get( + exposureProjectionRouteKey({ kind: "direct-tool", id: "docs__search" }), + ), + ).toEqual({ + kind: "direct-tool", + capletId: "docs", + downstreamName: "search", + }); + expect( + projection.entries + .filter((entry) => + ["direct-resource", "direct-resource-template", "direct-prompt", "completion"].includes( + entry.kind, + ), + ) + .map((entry) => [entry.kind, entry.sourceCapletId]), + ).toEqual([ + ["direct-resource", "upstream-docs"], + ["direct-resource-template", "upstream-docs"], + ["direct-prompt", "upstream-docs"], + ["completion", "upstream-docs"], + ]); + }); + + it("keeps manifest Code Mode entries explicit instead of inferring fallback handles", () => { + const explicitEmpty = buildManifestExposureProjection({ + caplets: [manifestEntry({ kind: "caplet", name: "Remote", capletId: "remote" })], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [], + }); + const explicitHandle = buildManifestExposureProjection({ + caplets: [manifestEntry({ kind: "caplet", name: "Remote", capletId: "remote" })], + tools: [], + resources: [], + resourceTemplates: [], + prompts: [], + completions: [], + codeModeCaplets: [manifestEntry({ kind: "caplet", name: "Remote", capletId: "remote" })], + }); + + expect(explicitEmpty.entries.filter((entry) => entry.kind === "code-mode-caplet")).toEqual([]); + expect(explicitHandle.entries.filter((entry) => entry.kind === "code-mode-caplet")).toEqual([ + expect.objectContaining({ id: "remote", capletId: "remote" }), + ]); + }); +}); + +function manifestEntry>( + entry: T, +): T & { + stableId: string; + exportId: string; + schemaHash: null; + shadowing: "allow" | "forbid" | "namespace"; +} { + return { + stableId: `${String(entry.kind)}:${String(entry.name ?? entry.uri ?? entry.uriTemplate ?? entry.capletId)}`, + exportId: `export-${String(entry.kind)}-${String(entry.name ?? entry.uri ?? entry.uriTemplate ?? entry.capletId)}`, + schemaHash: null, + shadowing: "forbid", + ...entry, + }; +} + +describe("native projection merge", () => { + it("suppresses local entries when remote forbids shadowing", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [mergeTool("shared", "remote", "forbid")], + localTools: [mergeTool("shared", "local", "namespace")], + remoteCodeModeTools: [], + localCodeModeTools: [], + remoteIdentity: "https://remote.example.com", + localIdentity: "local:/repo", + namespaceAliases: { upstreams: {} }, + renameTool: renameMergeTool, + }); + + expect(result.remoteTools.map((tool) => tool.caplet)).toEqual(["shared"]); + expect(result.localTools).toEqual([]); + expect(result.routes.get("shared")).toEqual({ service: "remote", capletId: "shared" }); + expect(result.suppressedLocalIds).toEqual(new Set(["shared"])); + }); + + it("keeps local entries visible when remote allows shadowing", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [mergeTool("shared", "remote", "allow")], + localTools: [mergeTool("shared", "local", "namespace")], + remoteCodeModeTools: [], + localCodeModeTools: [], + remoteIdentity: "https://remote.example.com", + localIdentity: "local:/repo", + namespaceAliases: { upstreams: {} }, + renameTool: renameMergeTool, + }); + + expect(result.remoteTools.map((tool) => tool.caplet)).toEqual(["shared"]); + expect(result.localTools.map((tool) => tool.caplet)).toEqual(["shared"]); + expect(result.routes.get("shared")).toEqual({ service: "local", capletId: "shared" }); + }); + + it("qualifies namespace collisions and makes bare IDs diagnostic-only", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [mergeTool("shared", "remote", "namespace")], + localTools: [mergeTool("shared", "local", "namespace")], + remoteCodeModeTools: [], + localCodeModeTools: [], + remoteIdentity: "https://remote.example.com", + localIdentity: "local:/repo", + namespaceAliases: { local: "mac", upstreams: { "https://remote.example.com": "vps" } }, + renameTool: renameMergeTool, + }); + + expect(result.remoteTools.map((tool) => tool.caplet)).toEqual(["vps-d4b6__shared"]); + expect(result.localTools.map((tool) => tool.caplet)).toEqual(["mac-6617__shared"]); + expect(result.routes.has("shared")).toBe(false); + expect(result.namespaceDiagnostics.get("shared")).toMatchObject({ + requestedId: "shared", + reason: "namespace_collision", + alternatives: ["vps-d4b6__shared", "mac-6617__shared"], + }); + }); + + it("qualifies Code Mode-only collisions and removes the bare handle", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [], + localTools: [], + remoteCodeModeTools: [mergeTool("shared", "remote", "namespace")], + localCodeModeTools: [mergeTool("shared", "local", "namespace")], + remoteIdentity: "https://remote.example.com", + localIdentity: "local:/repo", + namespaceAliases: { local: "mac", upstreams: { "https://remote.example.com": "vps" } }, + renameTool: renameMergeTool, + }); + + expect(result.remoteCodeModeTools.map((tool) => tool.caplet)).toEqual(["vps-d4b6__shared"]); + expect(result.localCodeModeTools.map((tool) => tool.caplet)).toEqual(["mac-6617__shared"]); + expect(result.routes.has("shared")).toBe(false); + expect(result.namespaceDiagnostics.get("shared")).toMatchObject({ + requestedId: "shared", + reason: "namespace_collision", + alternatives: ["vps-d4b6__shared", "mac-6617__shared"], + }); + }); + + it("fails closed when generated namespace IDs collide with bare IDs", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [ + mergeTool("shared", "remote", "namespace"), + // These five bare IDs are calibrated to exhaust the current generated + // namespace suffix retry policy for the `clash` namespace alias. + mergeTool("clash-8516__shared", "remote", "forbid"), + mergeTool("clash-85163__shared", "remote", "forbid"), + mergeTool("clash-851639__shared", "remote", "forbid"), + mergeTool("clash-851639a__shared", "remote", "forbid"), + mergeTool("clash-851639a7__shared", "remote", "forbid"), + ], + localTools: [mergeTool("shared", "local", "namespace")], + remoteCodeModeTools: [], + localCodeModeTools: [], + remoteIdentity: "http://127.0.0.1:5387/v1/attach", + localIdentity: "local:/repo", + namespaceAliases: { upstreams: { "http://127.0.0.1:5387/v1/attach": "clash" } }, + renameTool: renameMergeTool, + }); + + expect(result.remoteTools.map((tool) => tool.caplet)).toEqual([ + "clash-8516__shared", + "clash-85163__shared", + "clash-851639__shared", + "clash-851639a__shared", + "clash-851639a7__shared", + ]); + expect(result.localTools).toEqual([]); + expect(result.routes.has("shared")).toBe(false); + expect(result.namespaceDiagnostics.get("shared")).toMatchObject({ + reason: "generated_id_collision", + }); + }); + + it("rewrites direct-tool alternatives while preserving source routes", () => { + const result = resolveNativeProjectionMerge({ + remoteTools: [mergeTool("shared__read", "remote", "namespace", "shared")], + localTools: [mergeTool("shared__write", "local", "namespace", "shared")], + remoteCodeModeTools: [], + localCodeModeTools: [], + remoteIdentity: "https://remote.example.com", + localIdentity: "local:/repo", + namespaceAliases: { local: "mac", upstreams: { "https://remote.example.com": "vps" } }, + renameTool: renameMergeTool, + }); + + expect(result.remoteTools.map((tool) => [tool.caplet, tool.sourceCaplet])).toEqual([ + ["vps-d4b6__shared__read", "vps-d4b6__shared"], + ]); + expect(result.localTools.map((tool) => [tool.caplet, tool.sourceCaplet])).toEqual([ + ["mac-6617__shared__write", "mac-6617__shared"], + ]); + expect(result.routes.get("vps-d4b6__shared__read")).toEqual({ + service: "remote", + capletId: "shared__read", + }); + expect(result.namespaceDiagnostics.get("shared__read")).toMatchObject({ + alternatives: ["vps-d4b6__shared__read", "mac-6617__shared__write"], + }); + }); +}); + +type MergeTool = { + caplet: string; + sourceCaplet?: string | undefined; + shadowing?: "forbid" | "allow" | "namespace" | undefined; + service: "local" | "remote"; +}; + +function mergeTool( + caplet: string, + service: "local" | "remote", + shadowing: "forbid" | "allow" | "namespace", + sourceCaplet?: string, +): MergeTool { + return { + caplet, + service, + shadowing, + ...(sourceCaplet ? { sourceCaplet } : {}), + }; +} + +function renameMergeTool(tool: MergeTool, visibleBaseId: string): MergeTool { + const baseId = tool.sourceCaplet ?? tool.caplet; + const directTool = Boolean(tool.sourceCaplet && tool.caplet.startsWith(`${baseId}__`)); + return { + ...tool, + caplet: directTool ? `${visibleBaseId}${tool.caplet.slice(baseId.length)}` : visibleBaseId, + sourceCaplet: directTool ? visibleBaseId : tool.sourceCaplet, + }; +} + +function snapshot(overrides: Partial): ExposureSnapshot { + return { + callableCaplets: [], + progressiveCaplets: [], + codeModeCaplets: [], + directTools: [], + directResources: [], + directResourceTemplates: [], + directPrompts: [], + hiddenCaplets: [], + ...overrides, + }; +} + +function callable( + caplet: CapletConfig, + options: { + exposure: CallableCaplet["exposure"]; + tools?: Tool[] | undefined; + resources?: Resource[] | undefined; + resourceTemplates?: ResourceTemplate[] | undefined; + prompts?: Prompt[] | undefined; + }, +): CallableCaplet { + return { + caplet, + exposure: options.exposure, + tools: options.tools ?? [], + resources: options.resources ?? [], + resourceTemplates: options.resourceTemplates ?? [], + prompts: options.prompts ?? [], + discoveredAt, + }; +} + +function httpCaplet( + server: string, + exposure: CapletConfig["exposure"], +): Extract { + return { + server, + backend: "http", + name: server, + description: `Call ${server} actions.`, + exposure, + baseUrl: "https://example.com", + auth: { type: "none" }, + actions: { query: { method: "GET", path: "/query" } }, + requestTimeoutMs: 60000, + maxResponseBytes: 200000, + disabled: false, + }; +} + +function mcpCaplet( + server: string, + exposure: CapletConfig["exposure"], +): Extract { + return { + server, + backend: "mcp", + name: server, + description: `Call ${server} MCP server.`, + exposure, + transport: "stdio", + command: process.execPath, + disabled: false, + startupTimeoutMs: 60000, + callTimeoutMs: 60000, + toolCacheTtlMs: 300000, + }; +} + +function tool(name: string): Tool { + return { name, description: `Run ${name}.`, inputSchema: { type: "object" } }; +} + +function resource(uri: string): Resource { + return { uri, name: uri }; +} + +function resourceTemplate(uriTemplate: string): ResourceTemplate { + return { uriTemplate, name: uriTemplate }; +} + +function prompt(name: string): Prompt { + return { name, description: `Prompt ${name}.` }; +} diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index ac294309..7d1055c9 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -1101,6 +1101,86 @@ describe("RemoteNativeCapletsService", () => { await service.close(); }); + it("surfaces completion-only manifest entries as native primitive tools", async () => { + const remote = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: vi.fn(async (input) => { + if (String(input).endsWith("/manifest")) { + return Response.json({ + ...attachManifest("rev-1", "export-unused"), + caplets: [], + completions: [ + { + stableId: "completion:docs", + exportId: "export-completion", + kind: "completion", + name: "docs:complete", + title: "Complete", + description: "Complete docs.", + schemaHash: null, + capletId: "docs", + shadowing: "forbid", + }, + ], + }); + } + return Response.json({ ok: true, data: { completed: true } }); + }), + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + }); + const service = new RemoteNativeCapletsService({ client: remote, pollIntervalMs: 60_000 }); + + await service.reload(); + + expect(configuredCapletIds(service.listTools())).toEqual(["docs__complete"]); + await expect(service.execute("docs__complete", { ref: { name: "topic" } })).resolves.toEqual({ + completed: true, + }); + await service.close(); + }); + + it("upgrades primitive tool shadowing when a later manifest entry uses namespace", async () => { + const remote = createSdkRemoteCapletsClient({ + url: new URL("https://caplets.example.com/v1/attach"), + requestInit: {}, + fetch: vi.fn(async (input) => { + if (String(input).endsWith("/manifest")) { + return Response.json({ + ...attachManifestWithDirectMcpPrimitives("rev-1"), + resources: attachManifestWithDirectMcpPrimitives("rev-1").resources.map((entry) => ({ + ...entry, + shadowing: "allow" as const, + })), + resourceTemplates: [], + prompts: [], + completions: attachManifestWithDirectMcpPrimitives("rev-1").completions.map( + (entry) => ({ + ...entry, + shadowing: "namespace" as const, + }), + ), + }); + } + return Response.json({ ok: true, data: {} }); + }), + auth: { enabled: false, user: "caplets" }, + pollIntervalMs: 60_000, + }); + const service = new RemoteNativeCapletsService({ client: remote, pollIntervalMs: 60_000 }); + + await service.reload(); + + expect(service.listTools()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ caplet: "docs__read_resource", shadowing: "namespace" }), + expect.objectContaining({ caplet: "docs__complete", shadowing: "namespace" }), + ]), + ); + await service.close(); + }); + it("passes prompt argument metadata through attached prompt lists", async () => { const remote = createSdkRemoteCapletsClient({ url: new URL("https://caplets.example.com/v1/attach"), @@ -2616,38 +2696,6 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); - it("omits bare Code Mode handles when namespace shadowing qualifies remote and local overlays", async () => { - const fixture = client([{ name: "shared", title: "Remote Shared", shadowing: "namespace" }]); - const { dir, configPath, projectConfigPath } = tempConfig({ - mcpServers: { - shared: { - name: "Local Shared", - description: "Local shared Caplet.", - command: process.execPath, - shadowing: "namespace", - }, - }, - }); - dirs.push(dir); - const service = createNativeCapletsService({ - mode: "remote", - remote: { url: "http://127.0.0.1:5387" }, - remoteClientFactory: vi.fn(() => fixture.api), - configPath, - projectConfigPath, - }); - - await service.reload(); - - const callableIds = listCodeModeCallableCaplets(service).map((caplet) => caplet.id); - expect(callableIds).not.toContain("shared"); - expect(callableIds).toEqual([ - expect.stringMatching(/^local-[a-f0-9]{4}__shared$/u), - expect.stringMatching(/^remote-[a-f0-9]{4}__shared$/u), - ]); - await service.close(); - }); - it("preserves namespace collisions across stacked attach manifests", async () => { const innerRemote = client([ { name: "computer-use", title: "VPS Computer Use", shadowing: "namespace" }, @@ -2834,46 +2882,6 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); - it("keeps namespace collisions fail-closed when qualified IDs collide with bare IDs", async () => { - const fixture = client([ - { name: "shared", title: "Remote Shared", shadowing: "namespace" }, - { name: "clash-8516__shared", title: "Remote Collision" }, - { name: "clash-85163__shared", title: "Remote Collision 5" }, - { name: "clash-851639__shared", title: "Remote Collision 6" }, - { name: "clash-851639a__shared", title: "Remote Collision 7" }, - { name: "clash-851639a7__shared", title: "Remote Collision 8" }, - ]); - const { dir, configPath, projectConfigPath } = tempConfig({ - namespaceAliases: { - upstreams: { - "http://127.0.0.1:5387/v1/attach": "clash", - }, - }, - mcpServers: { - shared: { name: "Local Shared", description: "Local wins.", command: process.execPath }, - }, - }); - dirs.push(dir); - const service = createNativeCapletsService({ - mode: "remote", - remote: { url: "http://127.0.0.1:5387" }, - remoteClientFactory: vi.fn(() => fixture.api), - configPath, - projectConfigPath, - }); - - await service.reload(); - - expect(configuredCapletIds(service.listTools())).not.toContain("shared"); - await expect(service.execute("shared", { operation: "inspect" })).rejects.toMatchObject({ - code: "CAPLET_NAMESPACE_COLLISION", - details: expect.objectContaining({ - reason: "generated_id_collision", - }), - }); - await service.close(); - }); - it("reports rewritten direct-tool alternatives for namespace collisions", async () => { const fixture = client([ {