diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/codex-thread-ids.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/codex-thread-ids.md new file mode 100644 index 000000000..179bf57fa --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/codex-thread-ids.md @@ -0,0 +1,16 @@ +# openapi-mcp-endpoint-directory-s5-impl — Codex implementation thread +- **Thread / session id:** `019fc9d0-5385-7710-ade4-a552ac9c9c10` +- **Rollout:** `/home/codex/.codex/sessions/2026/08/04/rollout-2026-08-04T00-48-21-019fc9d0-5385-7710-ade4-a552ac9c9c10.jsonl` +- **Worktree:** `/home/codex/repos/ns005-s5-impl` +- **Branch:** `agent/openapi-mcp-endpoint-directory-s5` (NO upstream by design). +- **Push rule:** explicit refspec only — `git push origin HEAD:refs/heads/feat/openapi-mcp-endpoint-directory`. +- **Requested route:** provider=openai · model=gpt-5.6-sol · effort=high +- **Observed route:** provider=openai · model=gpt-5.6-sol · effort=high +- **Route verdict:** matched +- **Runtime:** approval=never · sandbox=dangerFullAccess +- **Brief (staged):** `/home/codex/openapi-mcp-endpoint-directory-s5-impl-brief.md` +## Steering (same thread — never a second send-message-v2 at this worktree) +```bash +codex exec resume 019fc9d0-5385-7710-ade4-a552ac9c9c10 -- "" +``` +_Written by `.llm/tools/agentic/codex/launch-codex-slice.ts`._ diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/context-pack.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/context-pack.md new file mode 100644 index 000000000..766dbe9ba --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/context-pack.md @@ -0,0 +1,103 @@ +# Context Pack: OMB S5 ServiceEndpointDirectoryPort + adapters + +## Run Metadata + +| Field | Value | +| --- | --- | +| Run ID | `feat-openapi-mcp-endpoint-directory--s5` | +| Branch | `feat/openapi-mcp-endpoint-directory` | +| Current phase | `implement` (plan locked via composed waiver) | +| Archetype | `2 — Integration` slice | +| Scope overlays | none | + +## Current State + +Research and design are locked at clean `origin/main` `2c8865e8c`. Slices 1–3 now implement the +contract-first endpoint vocabulary, all four source adapters, deterministic composition, and the +bounded spec-first identity probe, with the complete surface published and documented through both +entrypoints. All implementation, consumer, JSR, and publish gates are green; supervisor evaluation +and merge-readiness actions remain deliberately unstarted. + +## Completed + +- Read issue #1131, RFC #1123, epic context, P1/P3 verdicts, canonical discovery design, required + harness/doctrine/Aspire/JSR/PR/tooling authorities, and package consumers. +- Confirmed baseline doc lint and publish dry-run clean, no lock churn. +- Recorded Design checkpoint and the milestone-run PLAN-EVAL composed waiver. +- Implemented discriminated source/directory/probe contracts and the named source precedence. +- Implemented override, Aspire CLI, run-manifest, and appsettings sources with injected IO seams. +- Proved 6/6 source fixtures, scoped check/lint/fmt, package-specific quality scan, and + `quality:gate` with no unsafe casts, lint ignores, or lock churn. +- Implemented deterministic precedence/conflicts, exclusions before fetch, bounded concurrency, + row-local timeout/error isolation, and parent abort propagation. +- Implemented credential-free, redirect-free OpenAPI-first probing, exact P3 401/403 guidance, + identity verification, response bounds, and opaque spec preservation. +- Proved 12/12 endpoint tests, 78/78 package tests, and 79-file scoped check/lint/fmt. +- Published the complete S5 surface from `.` and transitively `./cli`, documented default adapter + permissions/configuration, and regenerated the embedded README asset. +- Proved zero full-export doc diagnostics, JSR audit exit 0, clean publish dry run, and no dependency + or lock churn. Grouping the adapters/port kept folder-cardinality debt at baseline. + +## In Progress + +- Implementation handoff after the Slice 3 commit/push/comment. The provided PR worktree remains + owned by the Desktop supervisor session; this attached thread remains the sole staging sender. + +## Next Steps + +1. Supervisor performs substantive review and the composed evaluation protocol. +2. Supervisor decides when to update PR/issue acceptance state and mark the draft ready. +3. Keep S6 projection/tool registration and S7 run-token producer wiring in their owned slices. + +## Key Decisions + +| Decision | Source | Notes | +| --- | --- | --- | +| `override > aspire-cli > run-manifest > appsettings` | P1 + RFC | CLI is primary live; explicit override remains supreme. | +| Manifest requires expected current run id | plan D4 | Missing/mismatch is visible failure. | +| No S4 imports | user coordinate rule | S6 composes later. | + +## Files Changed + +| Path | Status | Notes | +| --- | --- | --- | +| `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/` | updated | Slice 1 evidence, reconcile note, attached-thread metadata | +| `packages/mcp/src/ports/service-endpoint-directory-port.ts` | new | Discriminated contract and three consumed ports | +| `packages/mcp/src/infrastructure/service-endpoints/` | new | Four named sources, URL policy, and fetch probe | +| `packages/mcp/tests/service-endpoint-source*` | new | Aspire/source fixture matrix; 6/6 passing | +| `packages/mcp/src/application/service-endpoint-directory.ts` | new | Default composition, precedence, bounds, isolation | +| `packages/mcp/src/infrastructure/fetch-service-endpoint-probe.ts` | new | Spec-first identity probe and P3 mapping | +| `packages/mcp/tests/service-endpoint-directory_test.ts` | new | Complete status/timeout/probe fixture matrix | +| `packages/mcp/deno.json` | updated | Test-only write permission for existing temp-dir tests | +| `packages/mcp/mod.ts`, `README.md` | updated | Published S5 surface and consumer/config/permission docs | +| `packages/mcp/src/publish-assets.generated.ts` | updated | Regenerated embedded README corpus | + +## Gates + +| Gate family | Current status | Evidence | +| --- | --- | --- | +| Static | slices 1–3 PASS | 12 focused tests + 78 package tests + 79-file check/lint/fmt | +| Fitness | slices 1–3 PASS | package quality scan + `quality:gate` + JSR audit | +| Runtime | PASS | all source/status rows and hanging-row isolation fixture-proven | +| Consumer | PASS | both entrypoints check/doc-lint clean; publish dry run clean | + +## Open Questions + +- None blocking implementation. + +## Drift and Debt + +- Drift: composed evaluator waiver; true remote baseline; manifest run-id injection clarification. +- Drift: lint/fmt wrappers require the package config because the root workspace glob shape does not + parse for those Deno subcommands in this worktree; no source/config mutation was made. +- Drift fixed: the package test task lacked the test-only write permission required by existing + temporary-directory tests; adding it restored the exact locked task to 78/78. +- Drift fixed: flat owned files initially created a new infrastructure cardinality warning; the A2 + port and adapters are now grouped under `src/ports/` and `infrastructure/service-endpoints/`. +- Tooling note: the JSR helper counts Deno's neutral slow-type progress banner as a warning; raw doc + lint and publish show no actual slow-type diagnostic. +- Debt: preserve existing `MCP-A6-V2-SHAPE`; no new debt planned. + +## Commits + +- See the draft PR's commit list + per-slice PR comments. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/drift.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/drift.md new file mode 100644 index 000000000..feeb77f36 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/drift.md @@ -0,0 +1,150 @@ +# Drift Log: OMB S5 ServiceEndpointDirectoryPort + adapters + +Drift is append-only. + +## 2026-08-04 — Attached implementation uses a run-owned staging worktree + +- **What:** The canonical implementation lane runs in `/home/codex/repos/ns005-s5-impl` on local + branch `agent/openapi-mcp-endpoint-directory-s5`, then pushes each slice to the required PR ref. +- **Source:** The agentic sender registry refused a rival sender because the provided worktree is + durably owned by the live Desktop supervisor thread. +- **Expected:** The implementation lane would attach directly to the provided PR worktree. +- **Actual:** A separate run-owned worktree preserves the one-sender invariant; the PR branch, + explicit push refspec, commit order, and review surface remain unchanged. +- **Severity:** minor +- **Action:** accept +- **Evidence:** agentic launcher `duplicate_sender_risk`; implementation brief path/branch contract. + +## 2026-08-04 — Milestone evaluator composition replaces local formal PLAN-EVAL + +- **What:** No local formal PLAN-EVAL is launched; the plan gate is recorded as composed waiver. +- **Source:** User-relayed `.llm/harness/workflow/milestone-run.md` evaluator rule and orchestrator D6. +- **Expected:** Normal run-loop would require a separate local formal PLAN-EVAL. +- **Actual:** Draft→ready augment + OpenHands + orchestrator pre-merge evaluation compose per-PR + evaluation; implementation proceeds in the same run after plan lock. +- **Severity:** significant +- **Action:** accept +- **Evidence:** `supervisor.md`, `plan-eval.md`, `worklog.md` Plan Gate row. + +## 2026-08-04 — True baseline is fetched origin/main + +- **What:** Local `main` pointed to an older commit while this branch matched remote `main`. +- **Source:** raw git rev-parse/fetch/ls-remote. +- **Expected:** re-baseline against current `main`. +- **Actual:** fetched `origin/main` is `2c8865e8c`; branch is identical and clean. +- **Severity:** minor +- **Action:** fix +- **Evidence:** `research.md` re-baseline. + +## 2026-08-04 — Current manifest run id must be injected + +- **What:** S-8 says a manifest is eligible only when `runId` is current, but S5 has no independent + current-run token source; trusting the file's own token or wall clock would not prove currency. +- **Source:** canonical discovery design vs P1 experiment/evidence and S7 ownership boundary. +- **Expected:** identity binding `projectRoot + runId` before use. +- **Actual:** S5 will require an expected current run id at composition. A present manifest without + it, or with a mismatch, reports a failed source outcome. S7 can wire the token later. +- **Severity:** significant +- **Action:** fix +- **Evidence:** `research.md` finding 8/open-question resolution; `plan.md` D4. + +## 2026-08-04 — Scoped lint and format need the package config + +- **What:** The scoped lint/fmt wrappers failed before source analysis when Deno parsed the root + workspace glob entries; rerunning the same wrapper selections with + `--config packages/mcp/deno.json` passed with zero findings. +- **Source:** Slice 1 validation output from `run-deno-lint.ts` and `run-deno-fmt.ts`. +- **Expected:** The plan's wrapper commands would run from the repository root without an explicit + config. +- **Actual:** `deno check` accepted the selection, while `deno lint`/`deno fmt` reported + `invalid type: string "packages/*", expected struct WorkspaceConfig` until the package config was + supplied. +- **Severity:** minor +- **Action:** accept for this lane; use the package config for exact lint/fmt evidence and leave root + configuration changes out of scope. +- **Evidence:** `worklog.md` Slice 1 gate table; package-configured lint/fmt exit 0 over 76 files. + +## 2026-08-04 — Package test task omitted test-only write permission + +- **What:** The locked package-test command initially failed three pre-existing tests before their + assertions because they create temporary directories, while the task omitted `--allow-write`. +- **Source:** Slice 2 `deno task --cwd packages/mcp test` output for `drift-evidence_test.ts` and + `stdio_test.ts`. +- **Expected:** The package task would execute its complete test corpus and exit 0. +- **Actual:** The task definition now grants write access only to the test process; runtime and + published package permissions are unchanged. The exact task then passed 78/78. +- **Severity:** minor +- **Action:** fix +- **Evidence:** `packages/mcp/deno.json`; `worklog.md` Slice 2 package-test gate. + +## 2026-08-04 — A2 port and adapters grouped after cardinality audit + +- **What:** The Design file list described a flat domain contract and flat infrastructure adapters; + the first Slice 3 audit showed the five owned adapters would add a new `src/infrastructure` + cardinality warning and increase the inherited domain count. +- **Source:** Archetype-2 port ownership plus F-16/R-FOLD-CARD and the Slice 3 JSR audit. +- **Expected:** Preserve existing horizontal package debt without adding or deepening a violation. +- **Actual:** The consumed contract lives in `src/ports/`; owned source/probe adapters and their URL + policy live in `src/infrastructure/service-endpoints/` without a sub-barrel. Public exports and + behavior are unchanged. Final audit restores inherited `src/domain` and + `src/application/flows` counts to their baseline 13 and introduces no infrastructure warning. +- **Severity:** minor +- **Action:** fix +- **Evidence:** final JSR audit; zero-diagnostic scoped/doc gates in `worklog.md` Slice 3. + +## 2026-08-04 — JSR audit matches Deno's slow-type progress banner + +- **What:** `audit-jsr-package.ts` reports `slowTypeWarnings=1` because its `/slow type/i` matcher + captures Deno's neutral `Checking for slow types in the public API...` progress line. +- **Source:** Slice 3 audit output and the helper's `runDryRun()` matcher. +- **Expected:** Only an actual slow-type diagnostic would produce an audit warning. +- **Actual:** Raw `deno doc --lint` for both entrypoints and `deno publish --dry-run --allow-dirty` + pass; Deno emits no slow-type error/warning after the progress line. The shared audit helper was + not changed by this package-scoped lane. +- **Severity:** minor +- **Action:** accept +- **Evidence:** `worklog.md` Slice 3 doc/audit/publish rows. + +## 2026-08-04 — Fable review primary unavailable at provider + +- **What:** The canonical `review_codex_complex` primary could not start because the native provider + returned `model_not_found` for `fable-5` before review work or token use. +- **Source:** Claude session `1abc6d8e-4c4a-4677-81dd-057eaab9145d` launch result. +- **Expected:** Anthropic Fable 5 at medium effort performs the opposite-family substantive review. +- **Actual:** The configured Claude-family Opus 4.8 fallback ran at medium effort in separate session + `a5d06fbf-041d-4d6a-a4d1-a69fce9ed447`; it returned PASS, then re-reviewed its three addressed + findings and retained PASS. Generator/evaluator family separation remained intact. +- **Severity:** minor +- **Action:** accept for this run; preserve both route identities in the review artifact. +- **Evidence:** `review-codex-complex.md` §§1 and 6. + +## 2026-08-04 — OpenHands completion used the verdict artifact, not its stale status comment + +- **What:** The OpenHands workflow emitted an exact PASS verdict and committed `evaluate.md`, while + its earlier persistent status comment remained on `Running`; the workflow also did not emit the + optional trace directory. +- **Source:** Agentic watcher result, PR comments, Actions run `30862805934`, and commit + `3e26b2bee`. +- **Expected:** The persistent comment transitions to the terminal state and trace output accompanies + the evaluator artifact. +- **Actual:** The later verdict comment and tracked evaluator artifact are terminal and internally + consistent; the stale progress comment and absent optional trace do not weaken acceptance proof. +- **Severity:** minor +- **Action:** accept for this run; use the exact watcher verdict, final PR comment, and tracked + `evaluate.md` as the authoritative evidence. +- **Evidence:** https://github.com/rickylabs/netscript/pull/1194#issuecomment-5172905933; + `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/evaluate.md`. + +## 2026-08-04 — Evaluator retained a stale identity-JSDoc advisory + +- **What:** OpenHands says `FetchServiceEndpointProbe` JSDoc does not repeat the root identity JSON + contract, but the reviewed source already documents that the root endpoint must return a JSON + `service` field matching the candidate name. +- **Source:** `evaluate.md` finding F-3 compared with + `packages/mcp/src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts`. +- **Expected:** The final evaluator narrative reflects the post-review source fix. +- **Actual:** The advisory is factually stale but explicitly non-blocking; executable verification, + product behavior, and the PASS verdict are unaffected. +- **Severity:** minor +- **Action:** accept the immutable evaluator artifact and record the correction here. +- **Evidence:** OpenHands PASS artifact; current `FetchServiceEndpointProbe` JSDoc. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/evaluate.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/evaluate.md new file mode 100644 index 000000000..bffa8179a --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/evaluate.md @@ -0,0 +1,158 @@ +# IMPL-EVAL — OMB S5 ServiceEndpointDirectoryPort + adapters + +## Route identity + +| Field | Value | +| --- | --- | +| Phase | IMPL-EVAL | +| Run ID | `feat-openapi-mcp-endpoint-directory--s5` | +| Branch | `feat/openapi-mcp-endpoint-directory` | +| Baseline | `2c8865e8c` | +| HEAD | `92f8abfa` | +| Reviewer | OpenHands (qwen3.7-max via openrouter) | +| Family independence | Verified — no overlap with Codex implementer or Opus 4.8 reviewer | + +## Independent verification summary + +Read in order: issue #1131, RFC PR #1123, P1-verdict.md, P3-verdict.md, plan.md, worklog.md, +review-codex-complex.md. Reviewed the complete diff from baseline `2c8865e8c` through HEAD `92f8abfa` +(6 commits, 28 files, +3062/-6). Re-ran focused fixtures, package tests, scoped check/lint/fmt, and +`deno doc --lint`. Verified lock hygiene (`git diff deno.lock` empty), forbidden-pattern scan +(clean), and S4 coupling scan (no projection imports). + +## Acceptance gates (issue #1131) + +### Box 1 — fixture matrix covers every source outcome and status row + +**PROVEN.** Re-ran: `deno test --allow-env --allow-net --allow-run --allow-read --allow-write +packages/mcp/tests/service-endpoint-sources_test.ts +packages/mcp/tests/service-endpoint-directory_test.ts` — exit 0; 15 passed, 0 failed (163 ms). + +Coverage traced: + +| Required row | Test name | Line | +| --- | --- | --- | +| Foreign-root manifest | `run manifest requires real project identity…` → `project_root_mismatch` | sources:148 | +| Torn manifest with healthy appsettings | `torn manifest remains failed while healthy appsettings remains independently usable` | sources:152 | +| Reused-port identity mismatch | `directory…applies precedence…identity_mismatch` | directory:87,126 | +| All four source used/absent/failed | 6 source tests cover override, appsettings, manifest, CLI outcomes | sources:all | +| CLI absent/non-zero/parse | `Aspire CLI absence, non-zero exit, and parse failure…` | sources:last | +| All five statuses | `running`, `not_running`, `spec_unavailable`, `identity_mismatch`, `excluded` | directory:all | + +### Box 2 — non-cooperative hanging spec fetch yields row-level timeout while healthy rows return + +**PROVEN.** Re-ran: same command; test `'one non-cooperative hanging spec fetch times out while +another directory row returns'` — exit 0; 21 ms. The fixture uses `new Promise(() => {})` (a fetch +that never settles and ignores the abort signal), and asserts the hung row is `spec_unavailable` +"timed out after 20ms" while the sibling row returns `running`. + +## Decisive checks (independent trace) + +- **Qualified F1(b) precedence.** `ENDPOINT_SOURCE_PRECEDENCE = override > aspire-cli > run-manifest + > appsettings` in the port contract; `selectCandidate` sorts by frozen precedence index; directory + fixture asserts exact ordered conflict list. Matches P1 FAIL→F1(b) ruling. **OK.** +- **Manifest identity safety.** Real-path equality of `projectRoot` + externally supplied + `expectedRunId` equal to manifest `runId`; absence → `absent`, missing token → + `expected_run_id_missing`, foreign root → `project_root_mismatch`, stale token → + `run_id_mismatch`. No currency inferred from the file's own token. **OK.** +- **Aspire CLI failure states.** `command_not_found` (Deno.errors.NotFound), `command_failed` + (non-zero exit, stderr/stdout detail), `parse_failed` (non-JSON or missing `resources[]`); + AbortError re-thrown. Banner-tolerant `extractJson`. DCP `-xxxxxxxx` suffix stripped, + `displayName` preferred. **OK.** +- **Deterministic conflicts.** Lower-precedence differing URLs recorded as `conflicts` in + precedence order with per-`(source,url)` dedup; identical URLs and selected source excluded. + **OK.** +- **Exclusion before fetch.** `#row` returns `excluded` before any probe; directory fixture + asserts the excluded service name never appears in the `probed[]` log. **OK.** +- **Parent cancellation.** Dedicated test `'parent cancellation rejects the directory instead of + fabricating endpoint rows'` aborts the supplied signal during a slow probe and asserts `list()` + rejects (`AbortError`). `throwIfAborted()` at entry, post-source, post-workers; probe and + source read re-throw on parent abort. **OK** (addresses review F-2). +- **Credential / redirect / response bounds.** Probe uses `credentials: 'omit'`, + `redirect: 'error'`, no `authorization` header (asserted); `readBoundedText` enforces byte cap + by both `content-length` and streamed length, cancelling body on overflow. **OK.** +- **Spec-first reused-port identity.** Probe fetches `/api/openapi.json` first, then `/`; only + both-success with `identity.service === candidate.name` yields `running`; valid spec on reused + port with foreign `/` identity maps `identity_mismatch`. **OK.** +- **Path-mounted overrides.** Dedicated test `'fetch probe preserves a path-mounted operator base + for spec and identity requests'` composes probe URLs relative to the base path. Override + normalizer retains the base path. **OK** (addresses review F-1). +- **Exact P3 `spec_unavailable` guidance.** `SPEC_UNAVAILABLE_AUTH_GUIDANCE` is byte-for-byte the + ratified P3 wording and is attached only on 401/403. **OK.** +- **Public API / JSDoc.** Every exported symbol has JSDoc; contract re-exported from both `.` and + `./cli`; `deno doc --lint` zero diagnostics (re-ran: exit 0, "Checked 1 file"). Naming + consistent and intention-revealing. **OK.** +- **A2 layering / S4 independence.** Consumed contract in `src/ports/`, adapters in + `src/infrastructure/service-endpoints/`, composition in `src/application/`. Spec kept `unknown` + (opaque); `grep` confirms no S4/projection import in any new file. **OK.** + +## Validation evidence (re-run) + +| Gate | Command | Result | +| --- | --- | --- | +| Focused source + directory matrix | `deno test --allow-env --allow-net --allow-run --allow-read --allow-write packages/mcp/tests/service-endpoint-sources_test.ts packages/mcp/tests/service-endpoint-directory_test.ts` | PASS, exit 0; 15 passed, 0 failed (163 ms) | +| Package tests | `deno task --cwd packages/mcp test` | PASS, exit 0; 81 passed, 0 failed (2 s) | +| Scoped check | `deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/mcp --ext ts,tsx` | PASS, exit 0; 79 files, 0 diagnostics | +| Scoped lint | `deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Scoped format | `deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Doc lint | `deno doc --lint packages/mcp/mod.ts` | PASS, exit 0; "Checked 1 file" | +| Lock hygiene | `git diff 2c8865e8c HEAD -- deno.lock` | PASS, exit 0; empty | +| Forbidden patterns | `git diff` scan for `as any`, `@ts-ignore`, `@ts-nocheck`, `// deno-lint-ignore`, `console.` | PASS; 0 matches | +| S4 coupling | `grep -rn 'from.*s4\|from.*projection\|import.*openapi-projection\|from.*openapi-spec' packages/mcp/src/` | PASS; 0 matches | + +Worklog records: quality:gate PASS, publish dry-run PASS, JSR audit PASS (sole slow-type warning is +the recorded progress-banner false positive; no actual slow-type diagnostic). Publish dry-run was +not re-run here (timed out at 60 s in this environment) — the worklog evidence stands. + +## Findings (advisory, non-blocking) + +No blocking finding. All four items from the Codex review are low-severity / advisory: + +- **F-1 (low) — path-prefixed base URL.** Now has a dedicated test proving path-mounted base URLs + are preserved by the probe. Resolved in this run. +- **F-2 (low) — parent cancellation unproven.** Now has a dedicated test. Resolved in this run. +- **F-3 (low) — service self-identification contract undocumented.** The probe requires `/` to + return JSON `{ "service": }`. This is documented in the README ("A running service must + return JSON containing its selected service name"), but the JSDoc on + `FetchServiceEndpointProbe` does not repeat it. Non-blocking; S7/product services must conform. +- **F-4 (very low) — `firstHttpUrl` prefers plaintext `http://` over `https://`.** Correct for + loopback dev; note for future TLS-fronted services. No action required now. + +## Drift (recorded) + +| Drift | Severity | Logged in drift.md | +| --- | --- | --- | +| Formal PLAN-EVAL composed/waived by milestone ruling | significant | yes | +| RFC omitted how MCP learns the current manifest `runId`; S5 requires injection | significant | yes | +| Existing package tests need test-only write permission for temporary directories | minor | yes | +| Planned flat role files were grouped to avoid new/deepened cardinality debt | minor | yes | +| JSR audit parser treats Deno's slow-type progress banner as a warning | minor | yes | +| Fable 5 review primary unavailable; same-family Opus fallback used | minor | yes | + +All drift items are documented, not defects. The two significant items (PLAN-EVAL waiver, run-id +injection) are explicit scope boundaries owned by S7 and the milestone-run orchestrator, not this +slice. + +## Verdict + +**PASS.** + +Rationale: both issue #1131 acceptance gates are honestly proven by executable evidence (not +narration). The contract, precedence (qualified F1(b)), manifest identity binding, all four source +outcomes, complete S-12 status mapping, deterministic conflicts, pre-fetch exclusion, credential- +and redirect-free bounded probing, spec-first reused-port identity, the exact P3 guidance, the hard +non-cooperative row-level timeout, parent-cancellation propagation, path-mounted overrides, and +A2/S4 independence are all correct and, for the two acceptance gates, proven by re-run tests. The +four recorded findings are low-severity/advisory: F-1 and F-2 are now resolved by dedicated tests; +F-3 and F-4 are documentation/future-notes. No acceptance box is violated, no honesty invariant is +broken (no path can fabricate a `running` row), and no lock churn or forbidden pattern was +introduced. + +Scope note: this IMPL-EVAL did not re-run publish dry-run (timed out in this environment) or the +full CLI scaffold E2E (outside the package-only surface per the task prompt). The worklog gate +tables record those as PASS from the implementer's evidence. No GitHub state, commit, push, PR, or +issue change was made by this evaluator. + +--- + +OPENHANDS_VERDICT: PASS diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/implement.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/implement.md new file mode 100644 index 000000000..6dd4f69a4 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/implement.md @@ -0,0 +1,64 @@ +use harness + +# Implementation Brief: OMB S5 ServiceEndpointDirectoryPort + adapters — #1131 + +You are the attached implementation lane. Work only in the run-owned staging worktree +`/home/codex/repos/ns005-s5-impl` on local branch +`agent/openapi-mcp-endpoint-directory-s5`. The PR branch remains +`feat/openapi-mcp-endpoint-directory`; every slice must push to that exact remote ref using the +explicit refspec below. Do not switch either worktree's branch or launch another sender. + +## SKILL + +- `.agents/skills/netscript-harness` — follow the locked run artifacts, per-slice evidence, and + commit trail; PLAN-EVAL is already composed/waived by the milestone ruling. +- `.agents/skills/netscript-doctrine` — apply Archetype-2 ports/adapters layering in `packages/mcp`. +- `.agents/skills/aspire` — use the Aspire 13.4 machine-readable query surface correctly. +- `.agents/skills/jsr-audit` — keep both public entrypoints doc-lint/publish clean. +- `.agents/skills/netscript-tools` — use scoped wrappers, quality gate, raw git truth, and lock hygiene. +- `.agents/skills/netscript-pr` — push explicit refspec and post one structured comment per slice. +- `.agents/skills/rtk` — compress read-heavy git/rg and proxy Deno task output. +- `.agents/skills/codex-wsl-remote` — remain on this attached thread/worktree; never fork a rival send. + +## Authority and required reading + +Read before editing: + +1. `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md` — LOCKED D1–D9. +2. `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/worklog.md` — Design and slices. +3. `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/context-pack.md`. +4. `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P1-verdict.md` and `P3-verdict.md`. +5. `.llm/runs/plan-openapi-mcp-plugin--seed/design/canonical/02-discovery.md`. +6. `packages/mcp/deno.json`, `mod.ts`, `cli.ts`, README, and focused existing port/adapter tests. + +## Binding contract + +- Effective precedence: `override > aspire-cli > run-manifest > appsettings`. +- Four source outcomes are always visible; CLI absent/non-zero/parse failures are explicit failed + rows, never absence. +- Manifest use requires real-path project-root match and a supplied expected current `runId` match. +- Override carrier: `.netscript/agent-mcp.json`, decoding only + `introspection.serviceEndpoints` and `introspection.excludeServices`. +- Status mapping: `not_running`, `spec_unavailable`, `identity_mismatch`, `excluded`, plus success + `running`; exclusions happen before fetch. +- Use the exact ratified P3 wording for 401/403 guidance. +- One hanging spec is one timed-out row while other rows return; bound concurrency and isolate all + probe failures. +- No S4 imports or projection implementation. Preserve parsed spec only as opaque JSON if needed by + S6. +- No new dependency, lint ignore, `as unknown as`, `as any`, or `deno.lock` churn. + +## Slices + +Implement the three Design slices in order. After each: + +1. Run its focused gate. +2. Update `worklog.md` and `context-pack.md` with exact evidence and one reconcile note. +3. Commit only owned files with a message naming what the slice proves. +4. Push with explicit refspec: + `git push origin HEAD:refs/heads/feat/openapi-mcp-endpoint-directory`. +5. Post one structured `[PHASE: IMPL]` draft-PR comment with scope, SHA, and commands/results. + +Do not mark the PR ready, add `Closes #1131`, tick final DoD boxes, trigger evaluation, or merge. +Those remain supervisor authority. Stop with a concise handoff after slice 3 gates, or write +`BLOCKED: ` if a locked decision cannot be implemented without rescope. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/openhands-augment-prompt.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/openhands-augment-prompt.md new file mode 100644 index 000000000..5ac862015 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/openhands-augment-prompt.md @@ -0,0 +1,63 @@ +use harness + +# Composed milestone implementation augment — PR #1194 / issue #1131 + +Perform the OpenHands cloud augment that participates in the milestone-run composed final +evaluation. This is review-only: do not edit product code, tests, package docs, PR/issue bodies, or +labels. Do not merge. If you find a defect, report it with a failing verdict and a concrete remedy; +do not implement it in this run. + +## SKILL + +- `netscript-harness` — apply the tracked run-artifact and evaluator-separation contracts. +- `netscript-doctrine` — review the `packages/mcp` Archetype-2 port/adapter layering and gates. +- `netscript-tools` — use repo-native scoped validation and evidence rules; preserve lock hygiene. +- `jsr-audit` — verify the new public port/adapter exports, docs, and dry-run evidence. +- `netscript-deno-toolchain` — use native Deno inspection/check/doc/publish commands correctly. +- `netscript-pr` — understand the authoritative DoD and closing-keyword/acceptance close gate. +- `aspire` — validate the Aspire 13.4 machine-query adapter contract and explicit failures. +- `openhands-handoff` — write the required tracked verdict and `OPENHANDS_SUMMARY_PATH` output. +- `rtk` — keep git/gh/read-heavy validation output compact. + +## Authority and scope + +Review the complete PR diff from baseline `2c8865e8c` through the checked-out PR head. Read first: + +- issue #1131 and RFC PR #1123; +- `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P1-verdict.md`; +- `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P3-verdict.md`; +- `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md`; +- `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/worklog.md`; +- `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md`. + +The qualified F1(b) ruling is binding: effective precedence is +`override > aspire-cli > run-manifest > appsettings`, with Aspire CLI the primary live source and +run-manifest still first-class additive. P3's exact `spec_unavailable` guidance is binding. S5 must +not import the parallel S4 projection domain. + +## Decisive checks + +Independently verify both issue acceptance gates: + +1. The fixture matrix covers every source outcome and status row, including foreign-root manifest, + torn manifest with healthy appsettings, and reused-port identity mismatch. +2. A non-cooperative hanging spec fetch yields a row-local timeout while healthy directory rows + return. + +Also inspect explicit Aspire CLI absent/non-zero/parse failures, real-root + current-run manifest +identity, deterministic conflicts, exclusions before fetch, parent cancellation, credential- and +redirect-free bounded requests, spec-first identity, path-mounted overrides, public JSDoc/exports, +and no S4 coupling. Run the focused fixtures and the smallest static/package/doc/JSR/publish gates +needed to validate the recorded claims. The full CLI scaffold E2E is outside this package-only +surface and is not required. + +## Output and hygiene + +- Write the formal cloud augment verdict to + `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/evaluate.md`. +- Write `OPENHANDS_SUMMARY_PATH` with the same verdict, decisive evidence, findings, commands, raw + exits, and residual risks. +- Emit `OPENHANDS_VERDICT: PASS` only if both acceptance boxes and all substantive contract claims + are proven; otherwise use the appropriate failing verdict. +- Do not mutate or commit `deno.lock`; compare it to true base `2c8865e8c` before finishing. +- Do not add lint ignores, unsafe casts, generated scratch, or unrelated files. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan-eval.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan-eval.md new file mode 100644 index 000000000..c738a555c --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan-eval.md @@ -0,0 +1,31 @@ +# PLAN-EVAL — feat-openapi-mcp-endpoint-directory--s5 + +- Plan evaluator session: no local formal evaluator, per owner-relayed milestone orchestrator D6 +- Run: `feat-openapi-mcp-endpoint-directory--s5` +- Surface / archetype: `packages/mcp` / Archetype 2 Integration slice +- Scope overlays: none + +## Checklist results + +| Plan-Gate item | Result | Evidence / location | +| --- | --- | --- | +| Research present and current | composed per milestone-run.md (orchestrator waiver) | `research.md` re-baseline at fetched `origin/main` | +| Decisions locked | composed per milestone-run.md (orchestrator waiver) | `plan.md` D1–D9 | +| Open-decision sweep | composed per milestone-run.md (orchestrator waiver) | all force-rework decisions resolved | +| Commit slices (< 30, gate + files each) | composed per milestone-run.md (orchestrator waiver) | `worklog.md` Design, three slices | +| Risk register | composed per milestone-run.md (orchestrator waiver) | `plan.md` | +| Gate set selected | composed per milestone-run.md (orchestrator waiver) | full Archetype-2 column + JSR + consumer gates | +| Deferred scope explicit | composed per milestone-run.md (orchestrator waiver) | `plan.md` Non-Scope / Deferred Scope | +| jsr-audit surface scan (pkg/plugin) | composed per milestone-run.md (orchestrator waiver) | `research.md`; baseline gates exit 0 | + +## Open-decision sweep + +None. The plan locks precedence, current-run handling, config carrier, probe order/status mapping, +bounded concurrency, and the S4 independence boundary. + +## Verdict + +`COMPOSED_WAIVER` + +Implementation is authorized in the same run by the explicit milestone-run/orchestrator ruling. This +artifact does not claim a separate-session PLAN-EVAL occurred. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md new file mode 100644 index 000000000..60d36f6a6 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md @@ -0,0 +1,163 @@ +# Plan: OMB S5 ServiceEndpointDirectoryPort + adapters + +## Run Metadata + +| Field | Value | +| --- | --- | +| Run ID | `feat-openapi-mcp-endpoint-directory--s5` | +| Branch | `feat/openapi-mcp-endpoint-directory` | +| Phase | `plan` (locked; PLAN-EVAL composed waiver recorded) | +| Target | `packages/mcp` | +| Archetype | `2 — Integration` for this port/adapter slice | +| Scope overlays | none | + +## Archetype + +Archetype 2 is binding for this slice: MCP consumes filesystem, process, and network facts through a +small package-owned directory port, four named source adapters, and a bounded probe adapter. The +package as a whole retains its accepted horizontal/A6 debt; this PR neither deepens nor closes it. + +## Current Doctrine Verdict + +`@netscript/mcp` is absent from doctrine 10's historical table. Open debt +`MCP-A6-V2-SHAPE` classifies its existing horizontal protocol-engine layout as an accepted deviation. +This slice uses the existing role folders and applies the full Archetype-2 gate column. + +## Axioms in Play + +| Axiom | Why it matters | +| --- | --- | +| A1/A2 | The public discriminated contracts precede implementation; degraded states cannot be hidden. | +| A5/A10 | The directory composes injected sources/probe; no container or inheritance. | +| A6/A7 | Filesystem/process/network wrappers exist only as tested adapters; Web Platform APIs are used directly. | +| A8/A9 | Files follow existing domain/application/infrastructure roles and the A2 slice profile. | +| A11 | `EndpointSource` is the named finite extension axis. | +| A14 | The fixture matrix, negative cases, JSR gates, and consumer checks preserve the contract. | + +## Goal + +Publish a deterministic `ServiceEndpointDirectoryPort` for S6 with four honest endpoint sources, +qualified F1(b) precedence, identity-bound probing, complete degraded-status mapping, per-row +timeouts, and fixtures proving every source outcome and status row. + +## Scope + +- Public constants/types for endpoint sources, source outcomes/failure codes, candidates, + conflicts, statuses, directory rows, probe outcomes, and the directory/source/probe ports. +- `aspire-cli`, run-manifest, appsettings, and override source adapters. +- Composed directory/factory with `override > aspire-cli > run-manifest > appsettings` precedence. +- Bounded, redirect-free, credential-free spec fetch; service self-identification; concurrency cap; + exclusions before fetch. +- Exact P3 401/403 guidance and explicit CLI absent/non-zero/parse failure rows. +- Public exports, README permission/config notes, focused fixtures/tests, full A2/JSR evidence. + +## Non-Scope + +- S4 OpenAPI projection types/logic and S6 MCP tool contracts/registry wiring. +- S7 manifest production or a new mechanism for transporting the current run id. +- Endpoint execution/policy, credentials, authenticated spec fetches, file watching, cross-machine + discovery, or scaffold changes. +- Repo-wide package restructuring, tool-count docs, release publication, or CLI E2E. + +## Hidden Scope + +- `appsettings` must preserve configured services without ports as `not_running` candidates. +- `localhost` from Aspire/P1 must normalize to numeric loopback before fetch. +- A manifest present without a verifiable expected current `runId` is failed, not absent/used. +- Unknown `.netscript/agent-mcp.json` top-level fields must not break future S13 policy composition; + only the S5 `introspection` subsection is decoded here. + +## Locked Decisions + +| ID | Decision | Rationale | +| --- | --- | --- | +| D1 | Export one application-facing `ServiceEndpointDirectoryPort`, plus narrow source and probe ports required by the tested IO seams. | Package owns consumed behavior; every IO class stays substitutable. | +| D2 | Model `SourceOutcome` and directory rows as discriminated unions with explicit failure codes. | Prevents impossible `failed` rows without reasons and makes CLI failures machine-visible. | +| D3 | Effective precedence is `override > aspire-cli > run-manifest > appsettings`. | Human override remains supreme; P1 selects CLI as current primary live source; fallbacks remain additive. | +| D4 | Manifest use requires real-path project-root equality and a supplied expected current `runId`; otherwise fail visibly. | S5 has no honest independent way to claim token currency; S7 can supply it later. | +| D5 | Override carrier is `.netscript/agent-mcp.json` with `introspection.serviceEndpoints` and `introspection.excludeServices`. | Ratified carrier and named S-25 seam; leaves deferred endpointExecution fields untouched. | +| D6 | Probe spec first, then `/` identity; only both successes produce `running`. | Auth failures get P3 wording; reused ports with valid specs still fail identity. | +| D7 | No-listener transport failures map `not_running`; listener timeout/HTTP/redirect/parse map `spec_unavailable`; self-id mismatch/unavailable maps `identity_mismatch`; exclusion maps `excluded` without fetch. | One S-12 mapping across consumers. | +| D8 | Use a small bounded worker loop with injected probe and `AbortSignal`; one rejected/timeout probe is converted to one row. | Per-service isolation without a dependency or hidden global. | +| D9 | Keep parsed spec JSON opaque to S5 and export no projection dependency. | S4 and S5 remain parallel surfaces consumed by S6. | + +## Open-Decision Sweep + +| Decision | Status | Notes | +| --- | --- | --- | +| Exact public names and union fields | resolved now | Named in the Design checkpoint; implementation may refine spelling only without semantic drift. | +| Run-id producer/wiring | safe to defer | S7-owned; S5 exposes the required composition input and failure. | +| Projection/operation counts | safe to defer | S4/S6-owned; no import or duplicate logic here. | +| External endpoint-provider registry | safe to defer | No external provider exists; finite first-party source list is closed. | + +## Risk Register + +| Risk | Mitigation | +| --- | --- | +| Aspire JSON shape drift/banner noise | Parse only top-level `resources[]`, accept `displayName`/DCP `name`, extract JSON after banner, and fixture the live 13.4.6 shape. | +| Stale/copied manifest trusted | Real-path match + expected run-id match + service self-identification. | +| Hanging endpoint hangs the tool | Per-row `AbortController`, timeout, concurrency cap, all-settled conversion to rows. | +| Foreign/non-loopback traffic | Numeric loopback required for discovered sources; only explicit override is operator-trusted. | +| Torn config masks fallback | Every adapter returns failed outcome while composition continues with lower sources. | +| Public slow types/docs regress | Explicit annotations/JSDoc plus full-export doc lint and dry-run. | +| New code deepens A6 debt | Stay in existing role folders; no speculative kernel/CLI restructuring. | + +## Anti-Patterns to Resolve or Avoid + +| AP | Status | Plan | +| --- | --- | --- | +| AP-1 | risk | Keep source adapters one per file and tests split if fixture matrix grows. | +| AP-2/AP-9 | risk | Use direct Deno/Web APIs; sibling adapters remain explicit. | +| AP-3 | risk | Ports have one operation each. | +| AP-8 | avoided | Plain factory/composition, no container. | +| AP-11/AP-25 | risk | All filesystem/process/network effects live in adapters and receive config/injection. | +| AP-13/AP-19 | risk | No console output; README declares read/run/net permissions. | +| AP-22/AP-23/AP-24 | risk | No sub-barrels, inline wiring bodies, or source switch; precedence is data. | + +## Fitness Gates + +| Gate | Required | Expected evidence | +| --- | --- | --- | +| F-1–F-5 | yes | `quality:gate`, manual public-surface review, scoped wrappers | +| F-6/F-7 | yes | package dry-run, full-export doc lint, JSR audit | +| F-8/F-9 | yes | `arch:check`; README permission block review | +| F-10–F-12 | yes | fixture/test shape, `arch:check`, lint | +| F-14–F-18 | yes | `quality:gate`/`arch:check`, changed-file review | +| F-19 | yes | scoped check/lint/fmt wrappers for `packages/mcp` | +| Runtime/Aspire | touched | injected command fixture matrix plus optional live CLI-help/shape evidence; no AppHost required | +| Consumer import | yes | both `mod.ts` and `cli.ts` full export surfaces check/doc-lint | + +## Arch-Debt Implications + +| Entry | Action | Notes | +| --- | --- | --- | +| `MCP-A6-V2-SHAPE` | none | Preserve existing horizontal layout; this A2 slice does not close package-wide debt. | +| New debt | none expected | Any new/deepened violation is a merge blocker, not an automatic debt entry. | + +## Validation Plan + +| Order | Gate | Command or check | Expected result | +| --- | --- | --- | --- | +| 0 | PLAN-EVAL | milestone-run composition | **composed per milestone-run.md (orchestrator waiver)** | +| 1 | Focused behavior | `deno test --allow-env --allow-net --allow-run --allow-read packages/mcp/tests/service-endpoint-*_test.ts` | all source/status/timeout fixtures pass | +| 2 | Scoped check | `.llm/tools/run-deno-check.ts --root packages/mcp --ext ts,tsx` | exit 0 | +| 3 | Scoped lint | `.llm/tools/run-deno-lint.ts --root packages/mcp --ext ts,tsx` | exit 0, no new ignores | +| 4 | Scoped format | `.llm/tools/run-deno-fmt.ts --root packages/mcp --ext ts,tsx` | exit 0 | +| 5 | Package tests | `deno task --cwd packages/mcp test` | exit 0 | +| 6 | Code/doctrine | `deno task quality:gate` | exit 0, no allowances/casts | +| 7 | Docs | `deno task doc:lint --root packages/mcp --pretty` | both exports, zero diagnostics | +| 8 | JSR fitness | `audit-jsr-package.ts --root packages/mcp --text` | pass | +| 9 | Publish | package `deno publish --dry-run --allow-dirty` | clean file list, no slow types | +| 10 | Hygiene | raw git diff/status against `origin/main` | no `deno.lock`, lint-ignore, or unrelated churn | + +## Dependencies + +- Wave-0 P1 and P3 verdict artifacts (present). +- Aspire CLI 13.4.6 machine-readable `describe` surface. +- S4 is deliberately not a dependency; S6 consumes S4 + S5. + +## Drift Watch + +- Aspire resource JSON field changes, manifest proof shape divergence, a need for S4 types, inability + to verify current run identity, or any requested scaffold/CLI tool wiring must be logged before + expanding scope. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/research.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/research.md new file mode 100644 index 000000000..b7c394fbf --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/research.md @@ -0,0 +1,50 @@ +# Research — feat-openapi-mcp-endpoint-directory--s5 + +## Re-baseline + +- Carried-in sources: issue #1131, ratified RFC #1123, canonical discovery design, and Wave-0 P1/P3 + verdict artifacts. +- Re-derived against `origin/main` at + `2c8865e8c4ec60ef080276d327fc75ab32c0cb85` on 2026-08-04. Local `main` was stale; the requested + branch already matched the fetched remote baseline and was clean. +- Current GitHub state: #1131 is open, milestone `0.0.5`, with exactly two acceptance boxes; no PR + exists for `feat/openapi-mcp-endpoint-directory`. + +## Findings + +| # | Finding | How to verify | +| --- | --- | --- | +| 1 | P1 is an explicit `FAIL` selecting qualified F1(b): `aspire-cli` is the current primary live source, while the correct post-allocation manifest seam remains additive. | `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P1-verdict.md` | +| 2 | P3 ratifies the exact `spec_unavailable` guidance for 401/403 without adding credential support. | `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P3-verdict.md` | +| 3 | The source axis is named and finite: `override`, `aspire-cli`, `run-manifest`, `appsettings`; every consulted source must report `used`, `absent`, or `failed(reason)`. | #1131; `design/canonical/02-discovery.md` | +| 4 | The ratified precedence remains explicit override first; applying P1 makes the effective current order `override > aspire-cli > run-manifest > appsettings`. | RFC §2 D3; P1 verdict; issue prompt arbitration | +| 5 | `aspire describe --apphost --format Json --non-interactive --nologo` is the machine-readable live query. Aspire 13.4.6 emits top-level `resources[]` rows with `displayName`, DCP-suffixed `name`, and declared `urls[]`. | `aspire describe --help`; `packages/cli/e2e/src/application/gates/scaffold/generated-app-endpoint.ts` | +| 6 | The generated service self-identifies at `/` as JSON containing `service`; the live spec is `/api/openapi.json`. | `packages/service/src/builder/service-builder-impl.ts` (`withServiceInfo`) | +| 7 | `aspire/appsettings.json` names configured services under `NetScript.Services`; `HostPort` is current and `Port` is legacy. Unpinned services have identity but no static URL. | `packages/cli/src/kernel/templates/aspire/generate-appsettings.ts`; workspace resolver | +| 8 | The P1 manifest actually proven in Wave 0 uses an array of `{ service, endpoint, url, ... }` rows and a UUID `runId`; no independent S5-owned mechanism exposes the current run id to MCP. | P1 experiment and `proofs/evidence/P1-runtime.json` | +| 9 | `.netscript/agent-mcp.json` is the ratified carrier; S5 owns `introspection.serviceEndpoints` and `introspection.excludeServices`, while endpoint execution policy remains deferred. | discovery example; canonical security design; S-25 triage | +| 10 | `@netscript/mcp` currently exports `.` and `./cli`; both full entrypoints are doc-lint clean and package dry-run is clean with no slow types. | `deno task doc:lint --root packages/mcp --pretty` exit 0; package `deno publish --dry-run --allow-dirty` exit 0 | +| 11 | The old doctrine verdict table predates `@netscript/mcp`. The package carries accepted `MCP-A6-V2-SHAPE` debt, while the user explicitly classifies this port/adapter slice as Archetype 2. | doctrine 10; `.llm/harness/debt/arch-debt.md`; task brief | +| 12 | The branch must not depend on S4 projection internals; the directory may carry parsed JSON as `unknown`/JSON data for S6 to project later. | task coordinate-surface rule | + +## jsr-audit surface scan + +- Surface scanned: `packages/mcp/deno.json`, `mod.ts`, `cli.ts`, README, full export map. +- Baseline: metadata, package name, description, exports, ESM shape, module docs, symbol docs, file + list, doc lint, and slow-type dry-run all pass. +- Planned risks: new public discriminated unions, ports, adapters, and factory must have explicit + annotations and JSDoc; both `.` and `./cli` must remain clean; tests/fixtures must stay excluded; + no `--allow-slow-types` exception applies. +- Planned evidence: structured full-export doc lint, package dry-run, package JSR fitness audit, + and `deno doc` inspection of the new symbols. + +## Open questions resolved before lock + +- **Manifest current-run proof:** S5 cannot infer a current token from a stale file. The manifest + adapter therefore requires an expected `runId` supplied at composition; a present manifest with + no expected token or a mismatch is a visible failed source outcome. S7 may wire the producer token + later without changing the port. +- **Live precedence:** explicit human override remains highest; `aspire-cli` is next as the P1-selected + primary live source; manifest and appsettings remain lower additive fallbacks. +- **Projection coupling:** no S4 imports. A successful probe may retain parsed JSON as opaque data; + S6 owns projection and operation counting. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-followup-prompt.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-followup-prompt.md new file mode 100644 index 000000000..7dd899e27 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-followup-prompt.md @@ -0,0 +1,16 @@ +# Follow-up review of findings F-1 through F-3 + +Continue the same opposite-family review. The supervisor addressed F-1, F-2, and F-3 in commit +`3a095bc85` after your PASS at `a26b1fd1b`: + +- path-mounted base URLs are now preserved for both spec and identity requests, with a real probe + fixture; +- parent cancellation now has a rejection regression test; +- the `{ "service": }` identity response contract is documented in public JSDoc and + README prose. + +Read the exact `a26b1fd1b..3a095bc85` diff, rerun the focused tests if useful, and update only +`.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md`. Preserve the original +review evidence, add a follow-up section stating whether each finding is resolved, update the +reviewed HEAD and test counts, and retain `PASS` only if no new substantive issue was introduced. +Do not edit product files, commit, push, post to GitHub, or change PR state. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-prompt.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-prompt.md new file mode 100644 index 000000000..f4f2010a4 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex-prompt.md @@ -0,0 +1,38 @@ +# Opposite-family substantive review — OMB S5 / PR #1194 + +You are the independent Claude-family reviewer for Codex-authored implementation on the canonical +`review_codex_complex` lane (Anthropic Fable 5, medium effort). Work read-only except for the one +review artifact named below. Do not modify product code, tests, docs, lockfiles, or other harness +artifacts. + +Route note: the primary `fable-5` launch (session `1abc6d8e-4c4a-4677-81dd-057eaab9145d`) +returned provider `model_not_found` before review or token use. This turn is the configured +Claude-family Opus 4.8 fallback at the same medium effort. Record both identities and the fallback +reason explicitly. + +Review the complete changeset from baseline `2c8865e8c` through current `HEAD` for issue #1131, +epic #1126, RFC PR #1123. Read the issue and RFC with `gh` first, then read: + +- `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P1-verdict.md` +- `.llm/runs/test-openapi-mcp-wave0-proofs--wave0/proofs/P3-verdict.md` +- `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/plan.md` +- `.llm/runs/feat-openapi-mcp-endpoint-directory--s5/design.md` +- relevant implementation, public exports, fixtures, and package documentation + +Adversarially assess contract correctness, all four source outcomes, qualified F1(b) precedence, +manifest identity safety, Aspire CLI failure states/parsing, deterministic conflict reporting, +exclusion-before-network behavior, row-local hard timeouts including a non-cooperative hanging spec +fetch, parent cancellation, credential/redirect/response bounds, spec-first reused-port identity, +exact P3 `spec_unavailable` guidance, public API/JSDoc quality, A2 layering/S4 independence, and +whether the issue acceptance boxes are actually proven. Run focused read-only validation when it +materially strengthens a finding. + +Write only +`.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md` with: + +1. requested and observed route identity; +2. findings ordered by severity, each with exact file/line evidence and a concrete remedy; +3. acceptance-box verdicts; +4. final `PASS` only if there are no unresolved substantive findings, otherwise `FAIL`. + +Do not post to GitHub, commit, push, change PR state, or mark the issue complete. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md new file mode 100644 index 000000000..ac3359fd8 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/review-codex-complex.md @@ -0,0 +1,176 @@ +# Review — OMB S5 ServiceEndpointDirectoryPort + adapters (Codex-authored) + +Opposite-family substantive review of the Codex implementation for issue #1131 (epic #1126, +RFC PR #1123), PR #1194. Read-only except this artifact. + +## 1. Route identity + +| Field | Requested | Observed / actual | +| --- | --- | --- | +| Lane | `review_codex_complex` (canonical) | `review_codex_complex` (canonical) | +| Provider / model | Anthropic **Fable 5** (`claude-fable-5`) | Anthropic **Opus 4.8** (`claude-opus-4-8`) — configured Claude-family fallback | +| Effort | medium | medium | +| Primary launch | session `1abc6d8e-4c4a-4677-81dd-057eaab9145d` | returned provider `model_not_found` **before any review work or token use** | +| Fallback reason | — | `fable-5` unavailable at provider; this turn is the documented Claude-family Opus 4.8 fallback at the same medium effort. Family-independence from the Codex implementer is preserved. | + +Reviewed range: baseline `2c8865e8c` → `HEAD a26b1fd1b` (6 commits `b0a6ad0b1`..`a26b1fd1b`). +Inputs read: issue #1131, RFC/proof context, `P1-verdict.md` (FAIL → F1(b)), `P3-verdict.md` (PASS), +`plan.md`, `worklog.md`, `drift.md`, the port contract, all four source adapters, the fetch probe, +the URL-normalization policy, both test files + fixtures, `mod.ts`, and the README diff. +Note: the prompt cited `design.md`; no such file exists in the run dir — design content lives in the +`worklog.md` "Design" section (verified present, so no gap). + +## 2. Adversarial assessment against the required dimensions + +All items below were traced in source and, where noted, re-executed read-only. + +- **Contract correctness (A1/A2).** `ServiceEndpointDirectoryResult` exposes `entries` **and** every + `sources` outcome; states are discriminated unions with mandatory discriminators. `failed` rows + always carry `code` + `reason`; `absent`/`failed` rows are statically forbidden from carrying + candidates/exclusions (`readonly []`). Degraded states cannot be hidden. **OK.** +- **Four source outcomes (S-9).** `used` / `absent` / `failed(code)` proven per source: override & + appsettings (`absent`/`used`/`invalid`), manifest (`absent`, `expected_run_id_missing`, + `run_id_mismatch`, `project_root_mismatch`, `invalid`, `used`), aspire-cli (`used`, + `command_not_found`, `command_failed`, `parse_failed`). A failed read is never rendered as healthy + absence. **OK.** +- **Qualified F1(b) precedence (D3/S-10).** `ENDPOINT_SOURCE_PRECEDENCE = override > aspire-cli > + run-manifest > appsettings`; `selectCandidate` sorts by frozen precedence index. Matches the P1 + FAIL→F1(b) ruling (CLI is primary live source, human override stays supreme). **OK.** +- **Manifest identity safety (S-8/D4).** `run-manifest-endpoint-source.ts` requires real-path + equality of `projectRoot` **and** an externally supplied `expectedRunId` equal to manifest `runId`; + absence → `absent`, missing token → `expected_run_id_missing`, foreign root → `project_root_mismatch`, + stale token → `run_id_mismatch`. No currency is inferred from the file's own token/clock. **OK.** +- **Aspire CLI failure states / parsing.** `command_not_found` (`Deno.errors.NotFound`), + `command_failed` (non-zero exit or other error, with stderr/stdout detail), `parse_failed` + (non-JSON or missing top-level `resources[]`). `AbortError` is re-thrown, not swallowed. + `extractJson` tolerates a leading banner; a banner without JSON fails safe as `parse_failed` + (never a false success). DCP `-xxxxxxxx` suffix stripped, `displayName` preferred. **OK.** +- **Deterministic conflict reporting (S-10).** Lower-precedence differing URLs recorded as + `conflicts` in precedence order with per-`(source,url)` dedup; identical URLs and the selected + source are excluded. Directory fixture asserts exact ordered conflict list. **OK.** +- **Exclusion before network (S-25).** `#row` returns the `excluded` row before any probe; the + precedence/status test asserts the excluded service name never appears in the `probed[]` log. **OK.** +- **Row-local hard timeout incl. non-cooperative fetch (S-11).** `probeWithDeadline` races the probe + against an `aborted` promise driven by `AbortSignal.any([parent, AbortSignal.timeout])`. The + aborted-listener is registered before the probe starts, so on timeout it resolves first and yields + a `spec_unavailable` "timed out after Nms" row **regardless of whether the probe honors the + signal**. Verified by the dedicated `new Promise(() => {})` non-cooperative-fetch test (re-ran: + pass, 21ms). Concurrency cap enforced by a bounded worker pool; `maximumActive <= 2` asserted. **OK.** +- **Parent cancellation.** `list()` calls `throwIfAborted()` at entry, post-source, post-workers; + `probeWithDeadline` and `#readSource` re-throw on parent abort so cancellation surfaces as a + rejection rather than fabricated rows. Correct by trace — see Finding 2 (no test). +- **Credential / redirect / response bounds.** Probe uses `credentials: 'omit'`, `redirect: 'error'`, + no `authorization` header (asserted); `readBoundedText` enforces the byte cap by both + `content-length` and streamed length, cancelling the body on overflow. **OK.** +- **Spec-first reused-port identity (D6).** Probe fetches `/api/openapi.json` first, then `/`, and + only both-success with `identity.service === candidate.name` yields `running`; a valid spec on a + reused port with a foreign `/` identity maps `identity_mismatch`. **OK.** +- **Exact P3 `spec_unavailable` guidance.** `SPEC_UNAVAILABLE_AUTH_GUIDANCE` is byte-for-byte the + ratified P3 wording and is attached only on 401/403. **OK.** +- **Public API / JSDoc.** Every exported symbol has a JSDoc line; contract re-exported from both `.` + and `./cli`; worklog records `doc:lint` + `deno doc --lint` zero-diagnostic and a clean publish + dry-run. Naming is consistent and intention-revealing. **OK.** +- **A2 layering / S4 independence.** Consumed contract in `src/ports/`, adapters in + `src/infrastructure/service-endpoints/`, composition in `src/application/`. Spec kept `unknown` + (opaque); `grep` confirms **no** S4/projection import in any new file. Drift log records the + adapter grouping that kept `src/infrastructure` under the cardinality cap. **OK.** + +Focused suite re-run on `HEAD`: `13 passed | 0 failed` (both `service-endpoint-*_test.ts`). + +## 3. Findings (by severity) + +No blocking finding. All four items are low-severity / advisory; none breaks an acceptance box, +crashes, or produces a false-positive `running`. + +### F-1 (low) — path-prefixed base URL is silently dropped by the probe +`packages/mcp/src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts:45,76`. +`new URL('/api/openapi.json', \`${baseUrl}/\`)` and `new URL('/', …)` use **absolute** paths, so any +path segment in the base URL is discarded (verified: base `http://operator.example.test/users` → +probe targets `http://operator.example.test/api/openapi.json` and `…/`). The override normalizer +(`endpoint-url.ts:18`) *retains* the base path, and the directory fixture stores exactly +`http://operator.example.test/users` under a **stubbed** probe — so the green test gives false +end-to-end confidence that a path-mounted override works, when a real probe would degrade it to +`spec_unavailable`/`identity_mismatch`. +Failure scenario: operator sets `introspection.serviceEndpoints.orders = +"https://gw.example/orders"` for a reverse-proxied service → probe hits `https://gw.example/…` root, +returns a wrong-service or missing-spec row for a healthy service. +Impact bound: safe degradation only (never a false `running`); discovered sources are origin-only +loopback so are unaffected. Non-substantive for this slice, but should be closed before S6 consumes +overrides. +Remedy: compose probe URLs relative to the base path (join without a leading slash) **or** normalize +all base URLs to origin-only and document the "origin, not spec URL" constraint (the README already +implies a base URL, but the P3 "reachable public spec URL" phrasing invites path/spec URLs the probe +cannot honor). + +### F-2 (low) — parent cancellation is correct but unproven +No fixture aborts the `list(signal)` parent mid-probe. The behavior is implemented correctly +(traced), but the review dimension "parent cancellation" has no regression guard. +Remedy: add a test that aborts the supplied signal during a slow probe and asserts `list()` rejects +(`AbortError`) without emitting fabricated rows. + +### F-3 (low) — service self-identification contract is undocumented +The probe requires `/` to return JSON `{ "service": }` (S-8). If a healthy NetScript service +root returns HTML or omits `service`, it maps `identity_mismatch`. The expectation is reasonable but +is an implicit product contract. +Remedy: document the `/` self-identification shape (README or the JSDoc on +`FetchServiceEndpointProbe`) so S7/product services conform. + +### F-4 (very low) — `firstHttpUrl` prefers plaintext `http://` over `https://` +`aspire-cli-endpoint-source.ts:160` selects the first `http://` URL before any `https://`. Correct +for loopback dev; note for future TLS-fronted services. No action required now. + +## 4. Acceptance-box verdicts + +| Box | Verdict | Evidence | +| --- | --- | --- | +| Fixture matrix covers every source outcome + status-mapping row, incl. foreign-root manifest, torn/invalid manifest with healthy appsettings, and identity mismatch on a reused port | **PROVEN** | `service-endpoint-sources_test.ts` (all source outcomes, `project_root_mismatch`, dedicated torn-manifest-with-healthy-appsettings test) + `service-endpoint-directory_test.ts` (running/not_running/spec_unavailable/identity_mismatch/excluded, reused-port mismatch) + probe test. Re-ran: pass. | +| One hanging spec endpoint yields a row-level timeout while the rest of the directory returns | **PROVEN** | Two tests — cooperative-abort probe and non-cooperative `new Promise(() => {})` fetch — both assert the hung row is `spec_unavailable` "timed out after 20ms" while sibling rows return `running`. Re-ran: pass. | + +Both boxes are honestly proven by executable evidence, not narration. + +## 5. Verdict + +**PASS.** + +Rationale: the contract, precedence (qualified F1(b)), manifest identity binding, all four source +outcomes, complete S-12 status mapping, deterministic conflicts, pre-fetch exclusion, credential- +and redirect-free bounded probing, spec-first reused-port identity, the exact P3 guidance, the hard +non-cooperative row-level timeout, parent-cancellation propagation, and A2/S4 independence are all +correct and, for the two acceptance gates, proven by re-run tests. The four recorded findings are +low-severity/advisory: each is a narrow edge or coverage/documentation gap, none violates an +acceptance box or the honesty invariant (no path can fabricate a `running` row). F-1 is the closest +to substantive and should be resolved before S6 wires operator overrides, but it degrades safely and +does not block this slice. + +Scope note: this review did not run `quality:gate`, `arch:check`, `doc:lint`, JSR audit, or publish +dry-run itself; those verdicts are taken from the worklog gate tables (all PASS) and are the +implementer's evidence, not re-verified here. No GitHub state, commit, push, PR, or issue change was +made. + +--- + +## 6. Follow-up review — findings F-1..F-3 (commit `3a095bc85`) + +Same opposite-family reviewer (Claude-family Opus 4.8 fallback, medium effort — route identity +unchanged from §1). Reviewed the exact `a26b1fd1b..3a095bc85` diff: 4 files, +57/-6 +(`README.md`, `fetch-service-endpoint-probe.ts`, `publish-assets.generated.ts` regen, +`service-endpoint-directory_test.ts`). No product source outside the probe changed; no lockfile, +dependency, or unrelated churn. **Reviewed HEAD advanced `a26b1fd1b` → `3a095bc85`.** + +Focused suite re-run on `3a095bc85`: **15 passed | 0 failed** (was 13; +2 new tests). Full log +confirmed both new tests and all prior tests green. + +| Finding | Status | Evidence | +| --- | --- | --- | +| **F-1** path-mounted base URL dropped by probe | **RESOLVED** | `fetch-service-endpoint-probe.ts:49-50,81` now build `const baseUrl = new URL(\`${candidate.baseUrl}/\`)` and resolve the spec as the **relative** `new URL('api/openapi.json', baseUrl)`, and the identity request as `baseUrl` itself — so any base path is preserved. New test `fetch probe preserves a path-mounted operator base…` asserts a `https://gateway.example.test/services/orders` override probes `…/services/orders/api/openapi.json` then `…/services/orders/` and maps `running`. Verified no regression to origin-only bases: the existing "spec before identity" test still asserts `http://127.0.0.1:43127/api/openapi.json` + `http://127.0.0.1:43127/` and passes (relative-join preserves the empty base path identically). | +| **F-2** parent cancellation unproven | **RESOLVED** | New test `parent cancellation rejects the directory instead of fabricating endpoint rows` aborts the `list(signal)` parent mid-probe (1s probe, immediate `AbortError`) and asserts `assertRejects(…, DOMException, 'fixture cancelled')` — proving cancellation surfaces as a rejection, not a fabricated row. Re-ran: pass. | +| **F-3** self-identification contract undocumented | **RESOLVED** | `FetchServiceEndpointProbe` JSDoc now states "The identity response must be JSON with a `service` field equal to the candidate name," and the README adds prose: a running service must return JSON containing its selected service name (e.g. `{ "service": "orders" }`) from its selected base path, framed as the reused-port guard. Embedded README asset (`publish-assets.generated.ts`) regenerated to match. | + +**No new substantive issue introduced.** The relative-URL change is the minimal correct fix and is +symmetric across spec and identity requests; `redirect: 'error'`, `credentials: 'omit'`, byte +bounds, and the spec-first ordering are untouched. F-4 (very-low: `firstHttpUrl` prefers plaintext +`http` over `https`) was not in scope for this commit and remains an open, non-blocking note. + +**Follow-up verdict: PASS (retained).** All three addressed findings are resolved with executable +proof; both acceptance boxes remain proven; no regression or new substantive finding. No GitHub, +commit, push, or PR-state change was made. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/supervisor.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/supervisor.md new file mode 100644 index 000000000..9e5915662 --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/supervisor.md @@ -0,0 +1,31 @@ +# Supervisor Identity — feat-openapi-mcp-endpoint-directory--s5 + +Written at run start per `workflow/lane-policy.md` § Supervisor identity. + +| Field | Value | +| --- | --- | +| Model | OpenAI Codex (GPT-5 family) | +| Session | Codex Desktop root supervisor thread; shell-visible thread id unavailable | +| Host | WSL2 Linux 6.18.33.2 · user `codex` | +| Checkout | `/home/codex/repos/ns005-s5` | +| Worktree | `/home/codex/repos/ns005-s5` | +| Branch | `feat/openapi-mcp-endpoint-directory` | +| Baseline | `2c8865e8c4ec60ef080276d327fc75ab32c0cb85` (`origin/main`, fetched 2026-08-04) | +| Run ID | `feat-openapi-mcp-endpoint-directory--s5` | + +## Routes in force + +| Task lane | Provider / model / effort | Role in this run | +| --- | --- | --- | +| Supervisor | Current Codex Desktop session | Research, plan lock, review, PR lifecycle, gate adjudication | +| `complex_implementation` | OpenAI / canonical GPT-5.6 Sol / high | Attached implementation session through `.llm/tools/agentic/` | +| `review_codex_complex` | Claude / canonical Fable 5 / medium | Opposite-family substantive code review before supervisor sign-off | +| Formal evaluation | Composed draft→ready augment + OpenHands + orchestrator pre-merge gate | Milestone-run evaluator composition; no local formal PLAN-EVAL or standalone local IMPL-EVAL | + +## Recorded lane/eval overrides + +- The user relayed milestone-orchestrator ruling D6: do not spawn or wait on a local formal + PLAN-EVAL. The plan gate is recorded as + **composed per milestone-run.md (orchestrator waiver)** and implementation proceeds in this run. +- Per-PR final evaluation composes the draft→ready augment review, OpenHands, and the orchestrator + pre-merge gate. This is the owner-authorized milestone-run application, not self-certification. diff --git a/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/worklog.md b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/worklog.md new file mode 100644 index 000000000..766a80a6f --- /dev/null +++ b/.llm/runs/feat-openapi-mcp-endpoint-directory--s5/worklog.md @@ -0,0 +1,205 @@ +# Worklog: OMB S5 ServiceEndpointDirectoryPort + adapters + +## Run Metadata + +| Field | Value | +| --- | --- | +| Run ID | `feat-openapi-mcp-endpoint-directory--s5` | +| Branch | `feat/openapi-mcp-endpoint-directory` | +| Archetype | `2 — Integration` slice inside `@netscript/mcp` | +| Scope overlays | none | + +## Design + +Recorded before implementation files. + +### Public Surface + +- `createServiceEndpointDirectory(options): ServiceEndpointDirectoryPort` — default composition + for the four sources and bounded fetch probe. +- `ServiceEndpointDirectoryPort.list(signal?)` — returns stable per-service rows plus every source + outcome. +- `EndpointSourcePort.read(context, signal)` — one-operation adapter contract. +- `ServiceEndpointProbePort.probe(candidate, signal)` — one-operation bounded identity/spec probe. +- `AspireCliEndpointSource`, `RunManifestEndpointSource`, `AppsettingsEndpointSource`, + `OverrideEndpointSource`, and `FetchServiceEndpointProbe` — published default adapters. + +### Domain Vocabulary + +- `ENDPOINT_SOURCES` / `EndpointSource` — `override`, `aspire-cli`, `run-manifest`, `appsettings`. +- `ENDPOINT_SOURCE_PRECEDENCE` — ordered current arbitration. +- `SourceOutcome` / `SourceFailureCode` — discriminated used/absent/failed source rows. +- `EndpointCandidate` / `EndpointConflict` — source facts before/after precedence. +- `SERVICE_ENDPOINT_STATUSES` / `ServiceEndpointStatus` — `running`, `not_running`, + `spec_unavailable`, `identity_mismatch`, `excluded`. +- `ServiceEndpointRow` / `ServiceEndpointDirectoryResult` — S6-facing directory output. +- `ServiceEndpointProbeResult` — probe success or one mapped failure class. +- `ServiceEndpointDirectoryOptions` — project root, expected run id, bounds, injected seams. + +### Ports + +- `ServiceEndpointDirectoryPort` — application consumer seam for S6/tests. +- `EndpointSourcePort` — genuine four-variant filesystem/process source axis. +- `ServiceEndpointProbePort` — network/timeout seam required for row-level isolation tests. + +### Constants + +- `ENDPOINT_SOURCES` — finite source vocabulary. +- `ENDPOINT_SOURCE_PRECEDENCE` — `override > aspire-cli > run-manifest > appsettings`. +- `SERVICE_ENDPOINT_STATUSES` — finite public status vocabulary. +- Default timeout, response-byte cap, and concurrency cap — named policy constants. +- Ratified P3 `spec_unavailable` guidance — one exported/used constant only if consumers need it; + otherwise a documented internal constant. + +### Commit Slices + +| # | Slice | Gate | Files | +| --- | --- | --- | --- | +| 1 | Contract + four honest sources prove every used/absent/failed outcome including CLI failures and foreign/torn manifest fallback facts. | focused source adapter tests + scoped check | domain contract, four infrastructure adapters, source fixture matrix/test, run artifacts | +| 2 | Composition + bounded probe prove precedence/conflicts and every status, including reused-port identity mismatch, exclusion, and one hanging spec isolated from healthy rows. | focused directory/probe tests + package tests + scoped wrappers | application directory/factory, fetch probe, status fixture matrix/test, run artifacts | +| 3 | Published surface and docs prove S6 importability and full Archetype-2/JSR fitness without lock churn. | `quality:gate`, doc lint, JSR audit, publish dry-run | `mod.ts`, `cli.ts` re-export if appropriate, README, final evidence artifacts | + +### Deferred Scope + +- Projection/tool registration/operation counts — S4/S6. +- Manifest producer/current-run transport — S7; S5 requires an injected expected token. +- Endpoint execution/authenticated fetch — S13 or later. +- Package-wide Archetype-6 restructuring — existing debt owner. + +### Contributor Path + +Add a first-party source by implementing `EndpointSourcePort`, adding its identifier and precedence +deliberately, wiring it in `createServiceEndpointDirectory`, then extending the source-outcome matrix. +Change probe policy through `ServiceEndpointProbePort`/factory options and extend every status row; +never import an infrastructure adapter from a consumer flow. + +## Progress Log + +| Time | Slice | Step | Notes | +| --- | --- | --- | --- | +| 2026-08-04 | bootstrap | research/design/plan lock | Issue/RFC/P1/P3/doctrine/Aspire/JSR baselines read; clean baseline confirmed. | +| 2026-08-04 | Plan Gate | composed per milestone-run.md (orchestrator waiver) | Owner/orchestrator directive: no local formal PLAN-EVAL; plan locked for same-run implementation. | +| 2026-08-04 | implementation dispatch | sender ownership reconciled | The provided PR worktree is durably owned by this Desktop supervisor thread; implementation uses a run-owned staging worktree and pushes each commit to the exact PR refspec. | +| 2026-08-04 | 1 | contract + source adapters | Added the discriminated directory/source/probe vocabulary, loopback normalization policy, and override/Aspire CLI/run-manifest/appsettings adapters. Source matrix passed 6/6, including CLI absent/non-zero/parse failures, foreign/missing/mismatched manifest identity, torn manifest with healthy appsettings, unknown shared-carrier fields, exclusions, and unpinned services. | +| 2026-08-04 | 1 | post-slice reconcile | PR #1194 remains draft, references #1131 without a closing keyword, and has no new implementation/reviewer comments; issue #1131 remains open with both acceptance gates unchecked. The launcher metadata push ref was corrected to the user-authorized PR branch. No scope or precedence readjustment was needed. | +| 2026-08-04 | 2 | composition + bounded probe | Added the default directory composition, deterministic precedence/conflicts, pre-fetch exclusions, bounded concurrency, per-row timeout/error isolation, and the credential-free/redirect-free spec-first identity probe. The fixture matrix passed 12/12 across source and directory tests; the package passed 78/78. | +| 2026-08-04 | 2 | package test gate repair | Added the missing test-only `--allow-write` permission required by the package's existing temporary-directory tests. The initially failing exact package task then passed 78/78 without changing runtime permissions or product dependencies. | +| 2026-08-04 | 2 | post-slice reconcile | PR #1194 remains draft at slice 1, references #1131 without a closing keyword, and has no reviewer comments after the slice 1 implementation comment. Issue #1131 remains open with both acceptance gates unchecked. No contract rescope or precedence readjustment was needed. | +| 2026-08-04 | 3 | public surface + docs | Published the factory, constants, discriminated contracts, ports, and default adapters from `.`; `./cli` inherits the same surface. Added carrier, precedence, status, permission, and composition guidance and regenerated the embedded README asset. | +| 2026-08-04 | 3 | doctrine reconcile | Moved the consumed contract to `src/ports/` and grouped the owned adapters under `src/infrastructure/service-endpoints/`. This removed the new infrastructure cardinality warning and kept existing domain/application warning counts at their baseline 13. | +| 2026-08-04 | 3 | post-slice reconcile | PR #1194 remains draft at slice 2, references #1131 without a closing keyword, and has no reviewer comments after the slice 2 implementation comment. Issue #1131 remains open with both acceptance gates unchecked. No DoD box, issue acceptance box, or evaluation state was changed. | +| 2026-08-04 | supervisor | decisive timeout proof | Added an end-to-end default-probe fixture whose spec fetch never settles and proved the hung row times out while a healthy sibling returns. Corrected the README to the generated `aspire/apphost.mts` default and removed an unusable placeholder run id. | +| 2026-08-04 | review | opposite-family substantive review | The Fable 5 primary returned `model_not_found`; the configured Claude-family Opus 4.8 medium fallback reviewed the entire change. Initial verdict PASS with three actionable low findings. The supervisor preserved path-mounted base URLs, added parent-cancellation proof, and documented service identity; the same reviewer re-ran 15/15 focused tests and retained PASS with all three resolved. | +| 2026-08-04 | supervisor | merge-readiness gates | Package tests passed 81/81; scoped check/lint/fmt passed over 79 files; package scan and `quality:gate` passed; embedded assets, full-export doc lint, raw entrypoint doc lint, JSR audit, and publish dry-run passed. JSR's sole slow-type warning remains the recorded progress-banner false positive; no actual slow-type diagnostic appeared. | +| 2026-08-04 | impl-eval | composed OpenHands evaluation | OpenHands (`openrouter/qwen/qwen3.7-max`) independently reviewed the complete diff, re-ran the 15/15 decisive fixtures and 81/81 package tests, and returned PASS. The tracked `evaluate.md` and [verdict comment](https://github.com/rickylabs/netscript/pull/1194#issuecomment-5172905933) prove both #1131 acceptance boxes. | + +## Decisions + +| Decision | Reason | Source | +| --- | --- | --- | +| CLI precedence after override | Qualified F1(b) selects the CLI as primary live source without defeating explicit operator intent. | P1 verdict + RFC S-10 | +| Expected run id required for manifest | Currency cannot be inferred honestly from a file's own token/time. | P1 evidence + S-8 | +| Opaque spec data only | Prevents S4 dependency and duplicate projection. | coordinate-surface rule | + +## Drift + +| Drift | Severity | Logged in drift.md | +| --- | --- | --- | +| Formal PLAN-EVAL composed/waived by milestone ruling | significant | yes | +| Local `main` stale; `origin/main` is true baseline | minor | yes | +| RFC omitted how MCP learns the current manifest `runId`; S5 requires injection | significant | yes | +| Existing package tests need test-only write permission for temporary directories | minor | yes | +| Planned flat role files were grouped to avoid new/deepened cardinality debt | minor | yes | +| JSR audit parser treats Deno's slow-type progress banner as a warning | minor | yes | +| Fable 5 review primary unavailable; same-family Opus fallback used | minor | yes | +| OpenHands persistent status comment did not transition from Running and no trace directory was emitted | minor | yes | +| OpenHands report retained a stale JSDoc advisory after the source fix | minor | yes | + +## Gate Results + +### Slice 1 — contract and source adapters + +| Gate | Command | Result | +| --- | --- | --- | +| Focused source matrix | `deno test --allow-env --allow-net --allow-run --allow-read packages/mcp/tests/service-endpoint-*_test.ts` | PASS, exit 0; 6 passed, 0 failed | +| Scoped check | `deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/mcp --ext ts,tsx` | PASS, exit 0; 76 files, 0 diagnostics | +| Scoped lint | `deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 76 files, 0 findings | +| Scoped format | `deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 76 files, 0 findings | +| Code quality | `deno run --allow-read .llm/tools/quality/scan-code-quality.ts --root packages/mcp` | PASS, exit 0; no findings or allowances | +| Framework quality | `deno task quality:gate` | PASS, exit 0; quality scan and architecture task completed | +| Diff hygiene | `git diff --check`; forbidden-pattern scan | PASS, exit 0; no whitespace errors, unsafe casts, lint ignores, or console calls | + +The wrapper invocations without `--config packages/mcp/deno.json` encountered the root workspace +configuration parser failure recorded in `drift.md`; the package-configured wrapper verdicts above +are the exact successful evidence. + +### Slice 2 — composition and bounded probe + +| Gate | Command | Result | +| --- | --- | --- | +| Focused directory/source matrix | `deno test --allow-env --allow-net --allow-run --allow-read packages/mcp/tests/service-endpoint-*_test.ts` | PASS, exit 0; 12 passed, 0 failed | +| Package tests | `deno task --cwd packages/mcp test` | PASS, exit 0; 78 passed, 0 failed | +| Scoped check | `deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/mcp --ext ts,tsx` | PASS, exit 0; 79 files, 0 diagnostics | +| Scoped lint | `deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Scoped format | `deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Code quality | `deno run --allow-read .llm/tools/quality/scan-code-quality.ts --root packages/mcp` | PASS, exit 0; no findings or allowances | +| Framework quality | `deno task quality:gate` | PASS, exit 0; quality scan and architecture task completed | +| Diff hygiene | `git diff --check`; forbidden-pattern scan | PASS, exit 0; no whitespace errors, unsafe casts, lint ignores, or console calls | + +The exact package task first exposed a missing test-only `--allow-write` permission in its existing +temporary-directory tests. The task definition was repaired, rerun, and is the passing evidence +above. + +### Slice 3 — published surface and JSR fitness + +| Gate | Command | Result | +| --- | --- | --- | +| Focused directory/source matrix | `deno test --allow-env --allow-net --allow-run --allow-read packages/mcp/tests/service-endpoint-*_test.ts` | PASS, exit 0; 12 passed, 0 failed | +| Package tests | `deno task --cwd packages/mcp test` | PASS, exit 0; 78 passed, 0 failed | +| Scoped check | `deno run --allow-read --allow-run .llm/tools/run-deno-check.ts --root packages/mcp --ext ts,tsx` | PASS, exit 0; 79 files, 0 diagnostics | +| Scoped lint | `deno run --allow-read --allow-run .llm/tools/run-deno-lint.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Scoped format | `deno run --allow-read --allow-run .llm/tools/run-deno-fmt.ts --root packages/mcp --ext ts,tsx --config packages/mcp/deno.json` | PASS, exit 0; 79 files, 0 findings | +| Embedded publish assets | `deno task check:publish-assets` | PASS, exit 0; generated README asset current | +| Package code quality | `deno run --allow-read .llm/tools/quality/scan-code-quality.ts --root packages/mcp` | PASS, exit 0; no findings or allowances | +| Framework quality | `deno task quality:gate` | PASS, exit 0; quality scan and architecture task completed | +| Full-export docs | `deno task doc:lint --root packages/mcp --pretty`; raw `deno doc --lint` per entrypoint | PASS, exit 0; `.` and `./cli`, zero diagnostics/private refs/missing docs | +| JSR fitness | `deno run --allow-read --allow-run --allow-env .llm/tools/fitness/audit-jsr-package.ts --root packages/mcp --text` | PASS, exit 0; no FAIL findings; two inherited cardinality warnings plus one parser false positive recorded in `drift.md` | +| Publish dry run | `deno task --cwd packages/mcp publish:dry-run` | PASS, exit 0; both entrypoints checked, clean 79-file publish, no slow-type diagnostic | +| Diff hygiene | `git diff --check`; changed-file forbidden-pattern scan; raw lock/status review | PASS, exit 0; no whitespace errors, unsafe casts, lint ignores, dependency/lock churn, or unrelated files | + +The final Slice 3 evidence is from the post-grouping tree. The JSR helper's `slowTypeWarnings=1` is +not a Deno slow-type diagnostic: its regex matches Deno's neutral `Checking for slow types in the +public API...` progress banner. Raw `deno doc --lint` and the publish dry run both pass without a +slow-type error or warning. The two cardinality warnings (`src/domain` and +`src/application/flows`, each 13) are inherited baseline package shape; the new adapter grouping +keeps `src/infrastructure` within the cap. + +### Supervisor merge-readiness rerun + +| Gate | Result | +| --- | --- | +| Opposite-family substantive review | PASS retained at `3a095bc85`; 15/15 focused tests; findings F-1–F-3 resolved | +| Package tests | PASS, exit 0; 81 passed, 0 failed | +| Scoped check/lint/fmt | PASS, exit 0; 79 files, zero diagnostics/findings | +| Package code quality + `quality:gate` | PASS, exit 0; no package findings/allowances; repository gate completed with inherited warnings only | +| Embedded assets + full-export docs | PASS, exit 0; generated asset current; both entrypoints zero diagnostics | +| JSR audit + publish dry-run | PASS, exit 0; only recorded progress-banner false positive; 79 intended publish files, no actual slow-type diagnostic | +| Lock/forbidden-pattern hygiene | PASS after run-artifact EOF cleanup; no `deno.lock` churn, new lint ignores, `@ts-ignore`, or unsafe double casts | + +### Composed IMPL-EVAL + +| Gate | Result | +| --- | --- | +| OpenHands independent evaluation | PASS at `3e26b2bee`; qwen3.7-max independently reviewed the full diff and re-ran the decisive gates | +| Acceptance box 1 | PROVEN; every source outcome and status row, including foreign-root manifest, torn manifest with healthy appsettings, and reused-port identity mismatch | +| Acceptance box 2 | PROVEN; a non-cooperative hanging fetch times out at row level while its healthy sibling returns `running` | +| Focused fixtures | PASS, exit 0; 15 passed, 0 failed | +| Package tests | PASS, exit 0; 81 passed, 0 failed | +| Evaluator artifact | `evaluate.md`; verdict comment https://github.com/rickylabs/netscript/pull/1194#issuecomment-5172905933 | + +## Handoff Notes + +- Implement against locked decisions D1–D9; do not import S4. +- The fixture matrices and timeout negative case are the decisive #1131 evidence. +- Slices 1–3 and their gates are complete. The implementation lane does not self-certify, mark the + PR ready, trigger evaluation, or exercise merge authority. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index caa0b2303..bc98be620 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -186,16 +186,64 @@ stdin or terminates the process; callers do not need to reach into an internal t Two entrypoints carry the package: -| Entry | What it gives you | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `.` | Tool contracts and schemas, the tool registry, the protocol runner (`createMcpServer`), port interfaces, and default adapters | -| `./cli` | The executable composition (`createMcpCliServer`, `runMcpStdioServer`) that binds real telemetry, docs, doctor, and process adapters | +| Entry | What it gives you | +| ------- | ---------------------------------------------------------------------------------------------------------------------- | +| `.` | Tool contracts and schemas, the tool registry, protocol runner, service endpoint directory ports, and default adapters | +| `./cli` | The executable composition plus every export from `.`, including the service endpoint directory surface | Every tool flow depends on a port interface, so embedders and tests supply their own adapters and assert against the published schemas. The always-current symbol list is [`deno doc jsr:@netscript/mcp@`](https://jsr.io/@netscript/mcp/doc) (pin `` on the pre-release line, as above). +### Discover service OpenAPI endpoints + +Embedders can compose the four discovery sources and bounded network probe without importing an +OpenAPI projection layer: + +```ts +import { createServiceEndpointDirectory } from '@netscript/mcp'; + +const endpoints = createServiceEndpointDirectory({ + projectRoot: Deno.cwd(), +}); + +const { entries, sources } = await endpoints.list(); +``` + +The effective per-service precedence is `override > aspire-cli > run-manifest > appsettings`. Every +source remains visible as `used`, `absent`, or `failed`; a failed Aspire CLI query or a stale +manifest is never rendered as healthy absence. The manifest at `.netscript/run/endpoints.json` is +eligible only when its real project root and `runId` match the supplied current run. `appHostPath` +defaults to `./aspire/apphost.mts`; override it when the active AppHost lives elsewhere. Supply +`expectedRunId` only when the host owns the current AppHost run token; without that identity proof, +a present run manifest is reported as failed and does not contribute endpoints. + +Explicit operator endpoints and exclusions live only in the S5-owned subsection of +`.netscript/agent-mcp.json`; sibling settings are ignored: + +```json +{ + "introspection": { + "serviceEndpoints": { + "orders": "https://orders.example.test" + }, + "excludeServices": ["internal-admin"] + } +} +``` + +Exclusions are applied before network access. Other rows report `running`, `not_running`, +`spec_unavailable`, or `identity_mismatch`; parsed OpenAPI is retained as opaque JSON for a later +consumer. Probes do not send credentials or follow redirects. A 401/403 explains how to expose only +the OpenAPI route anonymously or supply a reachable public spec URL. A running service must return +JSON containing its selected service name, for example `{ "service": "orders" }`, from its selected +base path; this second request prevents a reused port from being mistaken for the intended service. + +The default library composition needs `--allow-read` for carriers and real-path checks, +`--allow-run` for `aspire describe`, and `--allow-net` for bounded spec/identity requests. Tests and +custom hosts can replace every source and the probe through `ServiceEndpointDirectoryOptions`. + ## Configuration at a glance - **Telemetry endpoint discovery** (tools and `doctor`): explicit `--endpoint`, then @@ -203,6 +251,9 @@ pre-release line, as above). - **Docs corpus**: by default the docs tools index the documentation shipped with the installed package; set `--docs-root ` (or `NETSCRIPT_DOCS_ROOT`) to serve a project or site corpus instead. +- **Service endpoint discovery** (library surface): `.netscript/agent-mcp.json` override, then the + Aspire CLI machine-readable query, then an identity-bound run manifest, then + `aspire/appsettings.json`; lower-priority disagreements remain visible as conflicts. - **Command policy**: the shipped default allows the prefixes `db init|generate|migrate|seed|status|introspect`, `generate`, `contract`, `service list`, `plugin install|list|sync|doctor`, and `ui:add|ui:init|ui:list|ui:update`, and denies `deploy`, diff --git a/packages/mcp/deno.json b/packages/mcp/deno.json index 1e65a011a..4e896fdef 100644 --- a/packages/mcp/deno.json +++ b/packages/mcp/deno.json @@ -17,7 +17,7 @@ }, "tasks": { "check": "deno check mod.ts cli.ts", - "test": "deno test --allow-env --allow-net --allow-run --allow-read tests/", + "test": "deno test --allow-env --allow-net --allow-run --allow-read --allow-write tests/", "publish:dry-run": "deno publish --dry-run --allow-dirty" }, "publish": { diff --git a/packages/mcp/mod.ts b/packages/mcp/mod.ts index 5c3e51f57..35158102a 100644 --- a/packages/mcp/mod.ts +++ b/packages/mcp/mod.ts @@ -99,3 +99,59 @@ export type { EmbeddedDocsCorpusOptions, EmbeddedDocsSource, } from './src/infrastructure/embedded-docs-corpus.ts'; +export { + createServiceEndpointDirectory, + DEFAULT_SERVICE_ENDPOINT_CONCURRENCY, + DEFAULT_SERVICE_ENDPOINT_TIMEOUT_MS, +} from './src/application/service-endpoint-directory.ts'; +export type { ServiceEndpointDirectoryOptions } from './src/application/service-endpoint-directory.ts'; +export { + ENDPOINT_SOURCE_PRECEDENCE, + ENDPOINT_SOURCES, + SERVICE_ENDPOINT_STATUSES, + SOURCE_FAILURE_CODES, +} from './src/ports/service-endpoint-directory-port.ts'; +export type { + AbsentSourceOutcome, + EndpointCandidate, + EndpointConflict, + EndpointSource, + EndpointSourceContext, + EndpointSourcePort, + ExcludedServiceEndpointRow, + FailedServiceEndpointProbeResult, + FailedSourceOutcome, + IdentityMismatchServiceEndpointRow, + NotRunningServiceEndpointRow, + RunningServiceEndpointProbeResult, + RunningServiceEndpointRow, + ServiceEndpointDirectoryPort, + ServiceEndpointDirectoryResult, + ServiceEndpointProbePort, + ServiceEndpointProbeResult, + ServiceEndpointRow, + ServiceEndpointRowBase, + ServiceEndpointStatus, + SourceFailureCode, + SourceOutcome, + SpecUnavailableServiceEndpointRow, + UsedSourceOutcome, +} from './src/ports/service-endpoint-directory-port.ts'; +export { AppsettingsEndpointSource } from './src/infrastructure/service-endpoints/appsettings-endpoint-source.ts'; +export type { AppsettingsEndpointSourceOptions } from './src/infrastructure/service-endpoints/appsettings-endpoint-source.ts'; +export { AspireCliEndpointSource } from './src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts'; +export type { + AspireCliCommand, + AspireCliCommandResult, + AspireCliEndpointSourceOptions, +} from './src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts'; +export { + DEFAULT_SERVICE_ENDPOINT_RESPONSE_BYTE_LIMIT, + FetchServiceEndpointProbe, + SPEC_UNAVAILABLE_AUTH_GUIDANCE, +} from './src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts'; +export type { FetchServiceEndpointProbeOptions } from './src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts'; +export { OverrideEndpointSource } from './src/infrastructure/service-endpoints/override-endpoint-source.ts'; +export type { OverrideEndpointSourceOptions } from './src/infrastructure/service-endpoints/override-endpoint-source.ts'; +export { RunManifestEndpointSource } from './src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts'; +export type { RunManifestEndpointSourceOptions } from './src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts'; diff --git a/packages/mcp/src/application/service-endpoint-directory.ts b/packages/mcp/src/application/service-endpoint-directory.ts new file mode 100644 index 000000000..7f610855f --- /dev/null +++ b/packages/mcp/src/application/service-endpoint-directory.ts @@ -0,0 +1,287 @@ +import { + ENDPOINT_SOURCE_PRECEDENCE, + type EndpointCandidate, + type EndpointConflict, + type EndpointSource, + type EndpointSourceContext, + type EndpointSourcePort, + type FailedSourceOutcome, + type ServiceEndpointDirectoryPort, + type ServiceEndpointDirectoryResult, + type ServiceEndpointProbePort, + type ServiceEndpointProbeResult, + type ServiceEndpointRow, + type SourceOutcome, +} from '../ports/service-endpoint-directory-port.ts'; +import { AppsettingsEndpointSource } from '../infrastructure/service-endpoints/appsettings-endpoint-source.ts'; +import { AspireCliEndpointSource } from '../infrastructure/service-endpoints/aspire-cli-endpoint-source.ts'; +import { FetchServiceEndpointProbe } from '../infrastructure/service-endpoints/fetch-service-endpoint-probe.ts'; +import { OverrideEndpointSource } from '../infrastructure/service-endpoints/override-endpoint-source.ts'; +import { RunManifestEndpointSource } from '../infrastructure/service-endpoints/run-manifest-endpoint-source.ts'; + +/** Default deadline for one service spec and identity probe. */ +export const DEFAULT_SERVICE_ENDPOINT_TIMEOUT_MS = 3_000; +/** Default maximum number of service probes in flight. */ +export const DEFAULT_SERVICE_ENDPOINT_CONCURRENCY = 4; + +/** Options for composing the service endpoint directory. */ +export interface ServiceEndpointDirectoryOptions { + /** Project root supplied to the MCP process. */ + readonly projectRoot: string; + /** Current AppHost run token required to trust the optional run manifest. */ + readonly expectedRunId?: string; + /** Exact AppHost path supplied to the Aspire CLI adapter. */ + readonly appHostPath?: string; + /** Override all four source adapters, keyed by the finite source axis. */ + readonly sourceAdapters?: Readonly>; + /** Override the network probe adapter. */ + readonly probe?: ServiceEndpointProbePort; + /** Per-service deadline in milliseconds. */ + readonly timeoutMs?: number; + /** Maximum number of probes in flight. */ + readonly concurrency?: number; + /** Maximum response bytes retained by the default fetch probe. */ + readonly responseByteLimit?: number; + /** Override the Web Platform fetch boundary used by the default probe. */ + readonly fetch?: typeof fetch; +} + +/** Compose the four endpoint sources with bounded service probing. */ +export function createServiceEndpointDirectory( + options: ServiceEndpointDirectoryOptions, +): ServiceEndpointDirectoryPort { + const sourceAdapters = options.sourceAdapters ?? { + override: new OverrideEndpointSource(), + 'aspire-cli': new AspireCliEndpointSource(), + 'run-manifest': new RunManifestEndpointSource(), + appsettings: new AppsettingsEndpointSource(), + }; + const probe = options.probe ?? new FetchServiceEndpointProbe({ + ...(options.responseByteLimit === undefined + ? {} + : { responseByteLimit: options.responseByteLimit }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + }); + return new ComposedServiceEndpointDirectory( + { + projectRoot: options.projectRoot, + ...(options.expectedRunId === undefined ? {} : { expectedRunId: options.expectedRunId }), + ...(options.appHostPath === undefined ? {} : { appHostPath: options.appHostPath }), + }, + sourceAdapters, + probe, + positiveInteger(options.timeoutMs ?? DEFAULT_SERVICE_ENDPOINT_TIMEOUT_MS, 'timeoutMs'), + positiveInteger(options.concurrency ?? DEFAULT_SERVICE_ENDPOINT_CONCURRENCY, 'concurrency'), + ); +} + +class ComposedServiceEndpointDirectory implements ServiceEndpointDirectoryPort { + constructor( + private readonly context: EndpointSourceContext, + private readonly sourceAdapters: Readonly>, + private readonly probe: ServiceEndpointProbePort, + private readonly timeoutMs: number, + private readonly concurrency: number, + ) {} + + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const sources = await Promise.all( + ENDPOINT_SOURCE_PRECEDENCE.map((source) => this.#readSource(source, signal)), + ); + signal?.throwIfAborted(); + const candidates = collectCandidates(sources); + const exclusions = collectExclusions(sources); + const names = [...new Set([...candidates.keys(), ...exclusions])].sort(); + const entries = new Array(names.length); + let nextIndex = 0; + + const worker = async (): Promise => { + while (true) { + const index = nextIndex++; + if (index >= names.length) return; + const name = names[index]!; + const selected = selectCandidate(candidates.get(name) ?? []); + entries[index] = await this.#row(name, selected, exclusions.has(name), signal); + } + }; + const workerCount = Math.min(this.concurrency, Math.max(1, names.length)); + await Promise.all(Array.from({ length: workerCount }, worker)); + signal?.throwIfAborted(); + return { entries, sources }; + } + + async #readSource(source: EndpointSource, signal?: AbortSignal): Promise { + try { + const outcome = await this.sourceAdapters[source].read(this.context, signal); + if (outcome.source !== source) { + return sourceFailure(source, `source adapter returned outcome for ${outcome.source}`); + } + return outcome; + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + return sourceFailure(source, describe(error)); + } + } + + async #row( + name: string, + selected: SelectedCandidate | undefined, + excluded: boolean, + signal?: AbortSignal, + ): Promise { + const candidate = selected?.candidate; + const source = candidate?.source ?? 'override'; + const conflicts = selected?.conflicts ?? []; + if (excluded) { + return { + name, + status: 'excluded', + source, + conflicts, + ...(candidate?.baseUrl === undefined ? {} : { baseUrl: candidate.baseUrl }), + reason: 'Service is excluded by introspection.excludeServices.', + }; + } + if (!candidate?.baseUrl) { + return { + name, + status: 'not_running', + source, + conflicts, + reason: 'Service is configured without a currently discovered endpoint.', + }; + } + + const result = await probeWithDeadline(this.probe, candidate, this.timeoutMs, signal); + return rowFromProbe({ ...candidate, baseUrl: candidate.baseUrl }, conflicts, result); + } +} + +interface SelectedCandidate { + readonly candidate: EndpointCandidate; + readonly conflicts: readonly EndpointConflict[]; +} + +function collectCandidates(sources: readonly SourceOutcome[]): Map { + const candidates = new Map(); + for (const source of sources) { + if (source.outcome !== 'used') continue; + for (const candidate of source.candidates) { + const existing = candidates.get(candidate.name) ?? []; + existing.push(candidate); + candidates.set(candidate.name, existing); + } + } + return candidates; +} + +function collectExclusions(sources: readonly SourceOutcome[]): Set { + const exclusions = new Set(); + for (const source of sources) { + if (source.outcome !== 'used') continue; + for (const name of source.excludedServices) exclusions.add(name); + } + return exclusions; +} + +function selectCandidate(candidates: readonly EndpointCandidate[]): SelectedCandidate | undefined { + const ordered = [...candidates].sort((left, right) => + ENDPOINT_SOURCE_PRECEDENCE.indexOf(left.source) - + ENDPOINT_SOURCE_PRECEDENCE.indexOf(right.source) + ); + const candidate = ordered[0]; + if (!candidate) return undefined; + const conflicts: EndpointConflict[] = []; + const seen = new Set(); + for (const lower of ordered.slice(1)) { + if (!candidate.baseUrl || !lower.baseUrl || candidate.baseUrl === lower.baseUrl) continue; + const key = `${lower.source}\n${lower.baseUrl}`; + if (seen.has(key)) continue; + seen.add(key); + conflicts.push({ source: lower.source, baseUrl: lower.baseUrl }); + } + return { candidate, conflicts }; +} + +async function probeWithDeadline( + probe: ServiceEndpointProbePort, + candidate: EndpointCandidate, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const timeout = AbortSignal.timeout(timeoutMs); + const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; + const aborted = new Promise<{ kind: 'aborted' }>((resolve) => { + combined.addEventListener('abort', () => resolve({ kind: 'aborted' }), { once: true }); + }); + const operation = Promise.resolve() + .then(() => probe.probe(candidate, combined)) + .then( + (result): { kind: 'result'; result: ServiceEndpointProbeResult } => ({ + kind: 'result', + result, + }), + (error): { kind: 'error'; error: unknown } => ({ kind: 'error', error }), + ); + const settled = await Promise.race([operation, aborted]); + if (settled.kind === 'result') return settled.result; + if (settled.kind === 'error') { + if (signal?.aborted) signal.throwIfAborted(); + return { + outcome: 'spec_unavailable', + reason: `Endpoint probe failed: ${describe(settled.error)}`, + }; + } + if (signal?.aborted) signal.throwIfAborted(); + return { + outcome: 'spec_unavailable', + reason: `OpenAPI probe timed out after ${timeoutMs}ms.`, + }; +} + +function rowFromProbe( + candidate: EndpointCandidate & { readonly baseUrl: string }, + conflicts: readonly EndpointConflict[], + result: ServiceEndpointProbeResult, +): ServiceEndpointRow { + const base = { + name: candidate.name, + baseUrl: candidate.baseUrl, + source: candidate.source, + conflicts, + }; + if (result.outcome === 'running') { + return { ...base, status: 'running', spec: result.spec }; + } + return { + ...base, + status: result.outcome, + reason: result.reason, + ...(result.httpStatus === undefined ? {} : { httpStatus: result.httpStatus }), + ...(result.guidance === undefined ? {} : { guidance: result.guidance }), + }; +} + +function sourceFailure(source: EndpointSource, reason: string): FailedSourceOutcome { + return { + source, + outcome: 'failed', + code: 'source_failed', + reason, + candidates: [], + excludedServices: [], + }; +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive integer`); + } + return value; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/appsettings-endpoint-source.ts b/packages/mcp/src/infrastructure/service-endpoints/appsettings-endpoint-source.ts new file mode 100644 index 000000000..b4e39c869 --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/appsettings-endpoint-source.ts @@ -0,0 +1,89 @@ +import { join } from '@std/path'; +import type { + EndpointCandidate, + EndpointSourceContext, + EndpointSourcePort, + SourceOutcome, +} from '../../ports/service-endpoint-directory-port.ts'; + +/** Injectable filesystem boundary for appsettings discovery. */ +export interface AppsettingsEndpointSourceOptions { + /** Read one UTF-8 file. */ + readonly readText?: (path: string) => Promise; +} + +/** Reads configured services and optional pinned ports from `aspire/appsettings.json`. */ +export class AppsettingsEndpointSource implements EndpointSourcePort { + readonly #readText: (path: string) => Promise; + + /** Create an appsettings source with an optional filesystem test seam. */ + constructor(options: AppsettingsEndpointSourceOptions = {}) { + this.#readText = options.readText ?? Deno.readTextFile; + } + + /** Preserve configured services even when Aspire will allocate their ports later. */ + async read(context: EndpointSourceContext, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const path = join(context.projectRoot, 'aspire', 'appsettings.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await this.#readText(path)); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return absent(); + return failed(error instanceof SyntaxError ? 'invalid' : 'unreadable', describe(error)); + } + signal?.throwIfAborted(); + if (!isRecord(parsed)) return failed('invalid', 'appsettings.json must contain a JSON object'); + const netScript = parsed['NetScript']; + const services = isRecord(netScript) ? netScript['Services'] : undefined; + if (!isRecord(services)) { + return failed('invalid', 'appsettings.json must contain NetScript.Services'); + } + + const candidates: EndpointCandidate[] = []; + for (const [name, rawEntry] of Object.entries(services)) { + if (!isRecord(rawEntry)) { + return failed('invalid', `NetScript.Services.${name} must contain a JSON object`); + } + const rawPort = rawEntry['HostPort'] ?? rawEntry['Port']; + if (rawPort !== undefined && !validPort(rawPort)) { + return failed('invalid', `NetScript.Services.${name} declares an invalid host port`); + } + candidates.push({ + name, + ...(rawPort === undefined ? {} : { baseUrl: `http://127.0.0.1:${rawPort}` }), + source: 'appsettings', + operatorTrusted: false, + }); + } + candidates.sort((left, right) => left.name.localeCompare(right.name)); + return { source: 'appsettings', outcome: 'used', candidates, excludedServices: [] }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validPort(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65_535; +} + +function absent(): SourceOutcome { + return { source: 'appsettings', outcome: 'absent', candidates: [], excludedServices: [] }; +} + +function failed(code: 'unreadable' | 'invalid', reason: string): SourceOutcome { + return { + source: 'appsettings', + outcome: 'failed', + code, + reason, + candidates: [], + excludedServices: [], + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts b/packages/mcp/src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts new file mode 100644 index 000000000..7d3a5e5d6 --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts @@ -0,0 +1,180 @@ +import { join } from '@std/path'; +import type { + EndpointCandidate, + EndpointSourceContext, + EndpointSourcePort, + SourceFailureCode, + SourceOutcome, +} from '../../ports/service-endpoint-directory-port.ts'; +import { normalizeDiscoveredEndpointUrl } from './endpoint-url.ts'; + +/** Captured result of one Aspire CLI invocation. */ +export interface AspireCliCommandResult { + /** Process exit status. */ + readonly code: number; + /** Captured standard output. */ + readonly stdout: string; + /** Captured standard error. */ + readonly stderr: string; +} + +/** Injectable Aspire command boundary used by the source adapter. */ +export type AspireCliCommand = ( + command: string, + args: readonly string[], + signal?: AbortSignal, +) => Promise; + +/** Options for the Aspire CLI endpoint source. */ +export interface AspireCliEndpointSourceOptions { + /** Aspire executable name or path. */ + readonly command?: string; + /** Override the process boundary for deterministic tests. */ + readonly execute?: AspireCliCommand; +} + +/** Queries the running AppHost through Aspire's machine-readable describe surface. */ +export class AspireCliEndpointSource implements EndpointSourcePort { + readonly #command: string; + readonly #execute: AspireCliCommand; + + /** Create an Aspire source with an injectable command boundary. */ + constructor(options: AspireCliEndpointSourceOptions = {}) { + this.#command = options.command ?? 'aspire'; + this.#execute = options.execute ?? executeCommand; + } + + /** Report command absence, non-zero exits, and parse failures as distinct failed outcomes. */ + async read(context: EndpointSourceContext, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const appHostPath = context.appHostPath ?? join(context.projectRoot, 'aspire', 'apphost.mts'); + let result: AspireCliCommandResult; + try { + result = await this.#execute(this.#command, [ + 'describe', + '--apphost', + appHostPath, + '--format', + 'Json', + '--non-interactive', + '--nologo', + ], signal); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return failed('command_not_found', `Aspire CLI executable was not found: ${this.#command}`); + } + if (error instanceof DOMException && error.name === 'AbortError') throw error; + return failed('command_failed', describe(error)); + } + if (result.code !== 0) { + const detail = (result.stderr || result.stdout).trim(); + return failed( + 'command_failed', + `aspire describe exited ${result.code}${detail ? `: ${detail}` : ''}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(extractJson(result.stdout)); + } catch (error) { + return failed('parse_failed', `aspire describe JSON could not be parsed: ${describe(error)}`); + } + if (!isRecord(parsed) || !Array.isArray(parsed['resources'])) { + return failed('parse_failed', 'aspire describe output did not contain top-level resources[]'); + } + + const candidates: EndpointCandidate[] = []; + for (const rawResource of parsed['resources']) { + if (!isRecord(rawResource)) continue; + const name = resourceName(rawResource); + const baseUrl = firstHttpUrl(rawResource['urls']); + if (!name || !baseUrl) continue; + candidates.push({ name, baseUrl, source: 'aspire-cli', operatorTrusted: false }); + } + candidates.sort((left, right) => left.name.localeCompare(right.name)); + return { source: 'aspire-cli', outcome: 'used', candidates, excludedServices: [] }; + } +} + +async function executeCommand( + command: string, + args: readonly string[], + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const child = new Deno.Command(command, { + args: [...args], + stdout: 'piped', + stderr: 'piped', + }).spawn(); + let aborted = false; + const abort = (): void => { + aborted = true; + try { + child.kill('SIGTERM'); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + }; + signal?.addEventListener('abort', abort, { once: true }); + let output: Deno.CommandOutput; + try { + output = await child.output(); + } finally { + signal?.removeEventListener('abort', abort); + } + if (aborted) throw new DOMException('Aspire CLI query was aborted', 'AbortError'); + const decoder = new TextDecoder(); + return { + code: output.code, + stdout: decoder.decode(output.stdout), + stderr: decoder.decode(output.stderr), + }; +} + +function extractJson(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return trimmed; + const positions = [trimmed.indexOf('{'), trimmed.indexOf('[')].filter((index) => index >= 0); + if (positions.length === 0) throw new SyntaxError('no JSON object or array was emitted'); + return trimmed.slice(Math.min(...positions)); +} + +function resourceName(resource: Record): string | undefined { + const displayName = resource['displayName']; + if (typeof displayName === 'string' && displayName) return displayName; + const name = resource['name']; + if (typeof name !== 'string' || !name) return undefined; + return name.replace(/-[a-z0-9]{8}$/i, ''); +} + +function firstHttpUrl(value: unknown): string | undefined { + if (!Array.isArray(value)) return undefined; + const urls: string[] = []; + for (const entry of value) { + const rawUrl = isRecord(entry) ? entry['url'] : entry; + const normalized = normalizeDiscoveredEndpointUrl(rawUrl, false); + if (normalized) urls.push(normalized); + } + return urls.find((url) => url.startsWith('http://')) ?? urls[0]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function failed(code: SourceFailureCode, reason: string): SourceOutcome { + return { + source: 'aspire-cli', + outcome: 'failed', + code, + reason, + candidates: [], + excludedServices: [], + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/endpoint-url.ts b/packages/mcp/src/infrastructure/service-endpoints/endpoint-url.ts new file mode 100644 index 000000000..69e12f799 --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/endpoint-url.ts @@ -0,0 +1,33 @@ +/** Normalize a discovered HTTP(S) URL under the endpoint-directory network policy. */ +export function normalizeDiscoveredEndpointUrl( + value: unknown, + operatorTrusted: boolean, +): string | undefined { + if (typeof value !== 'string') return undefined; + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + if (!operatorTrusted) { + if (url.hostname === 'localhost') url.hostname = '127.0.0.1'; + if (!isNumericLoopback(url.hostname)) return undefined; + } + url.pathname = url.pathname.replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.href.replace(/\/$/, ''); +} + +function isNumericLoopback(hostname: string): boolean { + if (hostname === '[::1]' || hostname === '::1') return true; + const octets = hostname.split('.'); + if (octets.length !== 4 || octets[0] !== '127') return false; + return octets.every((octet) => { + if (!/^\d{1,3}$/.test(octet)) return false; + const value = Number(octet); + return value >= 0 && value <= 255; + }); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts b/packages/mcp/src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts new file mode 100644 index 000000000..a1f02d5f1 --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts @@ -0,0 +1,189 @@ +import type { + EndpointCandidate, + ServiceEndpointProbePort, + ServiceEndpointProbeResult, +} from '../../ports/service-endpoint-directory-port.ts'; + +/** Default maximum bytes retained from either probe response. */ +export const DEFAULT_SERVICE_ENDPOINT_RESPONSE_BYTE_LIMIT = 2_000_000; + +/** Exact P3 guidance for credential-free spec requests rejected with 401 or 403. */ +export const SPEC_UNAVAILABLE_AUTH_GUIDANCE = + 'spec_unavailable: OpenAPI document could not be fetched. The spec route may require authentication; allow anonymous access to the OpenAPI route (for NetScript auth, add /api/openapi.json to auth.authn.allowAnonymous) or provide a reachable public spec URL.'; + +/** Options for credential-free, bounded service endpoint probing. */ +export interface FetchServiceEndpointProbeOptions { + /** Maximum bytes retained from either response. */ + readonly responseByteLimit?: number; + /** Override the Web Platform fetch boundary for deterministic tests. */ + readonly fetch?: typeof fetch; +} + +/** + * Fetches OpenAPI first, then verifies the service identity at the selected base path. + * + * The identity response must be JSON with a `service` field equal to the candidate name. + */ +export class FetchServiceEndpointProbe implements ServiceEndpointProbePort { + readonly #responseByteLimit: number; + readonly #fetch: typeof fetch; + + /** Create a probe with explicit response bounds and an optional fetch seam. */ + constructor(options: FetchServiceEndpointProbeOptions = {}) { + this.#responseByteLimit = positiveInteger( + options.responseByteLimit ?? DEFAULT_SERVICE_ENDPOINT_RESPONSE_BYTE_LIMIT, + 'responseByteLimit', + ); + this.#fetch = options.fetch ?? fetch; + } + + /** Return one mapped status without attaching credentials or following redirects. */ + async probe( + candidate: EndpointCandidate, + signal: AbortSignal, + ): Promise { + if (!candidate.baseUrl) { + return { outcome: 'not_running', reason: 'Candidate has no discovered endpoint.' }; + } + signal.throwIfAborted(); + const baseUrl = new URL(`${candidate.baseUrl}/`); + const specUrl = new URL('api/openapi.json', baseUrl); + let specResponse: Response; + try { + specResponse = await this.#request(specUrl, signal); + } catch (error) { + return transportResult(error, 'OpenAPI document'); + } + if (!specResponse.ok) { + return { + outcome: 'spec_unavailable', + reason: `OpenAPI document returned HTTP ${specResponse.status}.`, + httpStatus: specResponse.status, + ...(specResponse.status === 401 || specResponse.status === 403 + ? { guidance: SPEC_UNAVAILABLE_AUTH_GUIDANCE } + : {}), + }; + } + const specText = await readBoundedText(specResponse, this.#responseByteLimit); + if (!specText.ok) return { outcome: 'spec_unavailable', reason: specText.reason }; + let spec: unknown; + try { + spec = JSON.parse(specText.value); + } catch (error) { + return { + outcome: 'spec_unavailable', + reason: `OpenAPI document was not valid JSON: ${describe(error)}`, + }; + } + + let identityResponse: Response; + try { + identityResponse = await this.#request(baseUrl, signal); + } catch (error) { + return { + outcome: 'identity_mismatch', + reason: `Service identity could not be fetched: ${describe(error)}`, + }; + } + if (!identityResponse.ok) { + return { + outcome: 'identity_mismatch', + reason: `Service identity returned HTTP ${identityResponse.status}.`, + httpStatus: identityResponse.status, + }; + } + const identityText = await readBoundedText(identityResponse, this.#responseByteLimit); + if (!identityText.ok) return { outcome: 'identity_mismatch', reason: identityText.reason }; + let identity: unknown; + try { + identity = JSON.parse(identityText.value); + } catch (error) { + return { + outcome: 'identity_mismatch', + reason: `Service identity was not valid JSON: ${describe(error)}`, + }; + } + if (!isRecord(identity) || identity['service'] !== candidate.name) { + const actual = isRecord(identity) && typeof identity['service'] === 'string' + ? identity['service'] + : 'unavailable'; + return { + outcome: 'identity_mismatch', + reason: `Expected service ${JSON.stringify(candidate.name)} but endpoint identified as ${ + JSON.stringify(actual) + }.`, + }; + } + return { outcome: 'running', spec }; + } + + #request(url: URL, signal: AbortSignal): Promise { + return this.#fetch(url, { + method: 'GET', + headers: { accept: 'application/json' }, + credentials: 'omit', + redirect: 'error', + signal, + }); + } +} + +type BoundedTextResult = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: string }; + +async function readBoundedText(response: Response, maximum: number): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && Number(contentLength) > maximum) { + await response.body?.cancel(); + return { ok: false, reason: `Response exceeded the ${maximum}-byte limit.` }; + } + if (!response.body) return { ok: true, value: '' }; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.length; + if (length > maximum) { + await reader.cancel(); + return { ok: false, reason: `Response exceeded the ${maximum}-byte limit.` }; + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return { ok: true, value: new TextDecoder().decode(bytes) }; +} + +function transportResult(error: unknown, subject: string): ServiceEndpointProbeResult { + const reason = describe(error); + if (/connection refused|no route to host|network is unreachable|dns error/i.test(reason)) { + return { outcome: 'not_running', reason: `${subject} endpoint is not listening: ${reason}` }; + } + return { outcome: 'spec_unavailable', reason: `${subject} could not be fetched: ${reason}` }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value <= 0) { + throw new TypeError(`${name} must be a positive integer`); + } + return value; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/override-endpoint-source.ts b/packages/mcp/src/infrastructure/service-endpoints/override-endpoint-source.ts new file mode 100644 index 000000000..6f704a28a --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/override-endpoint-source.ts @@ -0,0 +1,91 @@ +import { join } from '@std/path'; +import type { + EndpointCandidate, + EndpointSourceContext, + EndpointSourcePort, + SourceOutcome, +} from '../../ports/service-endpoint-directory-port.ts'; +import { normalizeDiscoveredEndpointUrl } from './endpoint-url.ts'; + +/** Injectable filesystem boundary for the override carrier. */ +export interface OverrideEndpointSourceOptions { + /** Read one UTF-8 file. */ + readonly readText?: (path: string) => Promise; +} + +/** Reads explicit endpoints and exclusions from `.netscript/agent-mcp.json`. */ +export class OverrideEndpointSource implements EndpointSourcePort { + readonly #readText: (path: string) => Promise; + + /** Create an override source with an optional filesystem test seam. */ + constructor(options: OverrideEndpointSourceOptions = {}) { + this.#readText = options.readText ?? Deno.readTextFile; + } + + /** Read only the S5-owned `introspection` subsection of the shared carrier. */ + async read(context: EndpointSourceContext, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const path = join(context.projectRoot, '.netscript', 'agent-mcp.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await this.#readText(path)); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return absent(); + return failed(error instanceof SyntaxError ? 'invalid' : 'unreadable', describe(error)); + } + signal?.throwIfAborted(); + if (!isRecord(parsed)) return failed('invalid', 'agent-mcp.json must contain a JSON object'); + const introspection = parsed['introspection']; + if (introspection === undefined) return absent(); + if (!isRecord(introspection)) { + return failed('invalid', 'introspection must contain a JSON object'); + } + + const rawEndpoints = introspection['serviceEndpoints']; + const rawExclusions = introspection['excludeServices']; + if (rawEndpoints !== undefined && !isRecord(rawEndpoints)) { + return failed('invalid', 'introspection.serviceEndpoints must contain a JSON object'); + } + if ( + rawExclusions !== undefined && + (!Array.isArray(rawExclusions) || rawExclusions.some((name) => typeof name !== 'string')) + ) { + return failed('invalid', 'introspection.excludeServices must contain only service names'); + } + + const candidates: EndpointCandidate[] = []; + for (const [name, rawUrl] of Object.entries(rawEndpoints ?? {})) { + const baseUrl = normalizeDiscoveredEndpointUrl(rawUrl, true); + if (!name || !baseUrl) { + return failed('invalid', `invalid override endpoint for service ${JSON.stringify(name)}`); + } + candidates.push({ name, baseUrl, source: 'override', operatorTrusted: true }); + } + const excludedServices = [...new Set((rawExclusions ?? []) as string[])].sort(); + candidates.sort((left, right) => left.name.localeCompare(right.name)); + return { source: 'override', outcome: 'used', candidates, excludedServices }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function absent(): SourceOutcome { + return { source: 'override', outcome: 'absent', candidates: [], excludedServices: [] }; +} + +function failed(code: 'unreadable' | 'invalid', reason: string): SourceOutcome { + return { + source: 'override', + outcome: 'failed', + code, + reason, + candidates: [], + excludedServices: [], + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts b/packages/mcp/src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts new file mode 100644 index 000000000..6483c5ee1 --- /dev/null +++ b/packages/mcp/src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts @@ -0,0 +1,135 @@ +import { join } from '@std/path'; +import type { + EndpointCandidate, + EndpointSourceContext, + EndpointSourcePort, + SourceFailureCode, + SourceOutcome, +} from '../../ports/service-endpoint-directory-port.ts'; +import { normalizeDiscoveredEndpointUrl } from './endpoint-url.ts'; + +/** Injectable filesystem boundary for identity-bound manifest discovery. */ +export interface RunManifestEndpointSourceOptions { + /** Read one UTF-8 file. */ + readonly readText?: (path: string) => Promise; + /** Resolve one path through filesystem links. */ + readonly realPath?: (path: string) => Promise; +} + +/** Reads identity-bound endpoints from `.netscript/run/endpoints.json`. */ +export class RunManifestEndpointSource implements EndpointSourcePort { + readonly #readText: (path: string) => Promise; + readonly #realPath: (path: string) => Promise; + + /** Create a manifest source with optional filesystem test seams. */ + constructor(options: RunManifestEndpointSourceOptions = {}) { + this.#readText = options.readText ?? Deno.readTextFile; + this.#realPath = options.realPath ?? Deno.realPath; + } + + /** Require real-path project identity and an externally supplied current run token. */ + async read(context: EndpointSourceContext, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const path = join(context.projectRoot, '.netscript', 'run', 'endpoints.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await this.#readText(path)); + } catch (error) { + if (error instanceof Deno.errors.NotFound) return absent(); + return failed(error instanceof SyntaxError ? 'invalid' : 'unreadable', describe(error)); + } + if (!context.expectedRunId) { + return failed('expected_run_id_missing', 'current AppHost runId was not supplied'); + } + if (!isRecord(parsed)) return failed('invalid', 'endpoint manifest must contain a JSON object'); + if (typeof parsed['projectRoot'] !== 'string') { + return failed('invalid', 'endpoint manifest projectRoot must be a string'); + } + + let actualRoot: string; + let manifestRoot: string; + try { + [actualRoot, manifestRoot] = await Promise.all([ + this.#realPath(context.projectRoot), + this.#realPath(parsed['projectRoot']), + ]); + } catch (error) { + return failed( + 'project_root_mismatch', + `project root could not be identity-bound: ${describe(error)}`, + ); + } + if (actualRoot !== manifestRoot) { + return failed( + 'project_root_mismatch', + 'endpoint manifest belongs to a different project root', + ); + } + if (parsed['runId'] !== context.expectedRunId) { + return failed( + 'run_id_mismatch', + 'endpoint manifest does not match the current AppHost runId', + ); + } + + const decoded = decodeServices(parsed['services']); + if (typeof decoded === 'string') return failed('invalid', decoded); + signal?.throwIfAborted(); + return { source: 'run-manifest', outcome: 'used', candidates: decoded, excludedServices: [] }; + } +} + +function decodeServices(value: unknown): EndpointCandidate[] | string { + const rows: Array<{ name: string; url: unknown }> = []; + if (Array.isArray(value)) { + for (const entry of value) { + if (!isRecord(entry) || typeof entry['service'] !== 'string') { + return 'endpoint manifest services array contains an invalid entry'; + } + rows.push({ name: entry['service'], url: entry['url'] }); + } + } else if (isRecord(value)) { + for (const [name, entry] of Object.entries(value)) { + if (!isRecord(entry)) return `endpoint manifest service ${name} must contain an object`; + rows.push({ name, url: entry['http'] ?? entry['url'] }); + } + } else { + return 'endpoint manifest services must be an array or object'; + } + + const candidates: EndpointCandidate[] = []; + for (const row of rows) { + const baseUrl = normalizeDiscoveredEndpointUrl(row.url, false); + if (!row.name || !baseUrl) return `endpoint manifest service ${row.name} has an invalid URL`; + candidates.push({ + name: row.name, + baseUrl, + source: 'run-manifest', + operatorTrusted: false, + }); + } + return candidates.sort((left, right) => left.name.localeCompare(right.name)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function absent(): SourceOutcome { + return { source: 'run-manifest', outcome: 'absent', candidates: [], excludedServices: [] }; +} + +function failed(code: SourceFailureCode, reason: string): SourceOutcome { + return { + source: 'run-manifest', + outcome: 'failed', + code, + reason, + candidates: [], + excludedServices: [], + }; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/mcp/src/ports/service-endpoint-directory-port.ts b/packages/mcp/src/ports/service-endpoint-directory-port.ts new file mode 100644 index 000000000..d8681fd88 --- /dev/null +++ b/packages/mcp/src/ports/service-endpoint-directory-port.ts @@ -0,0 +1,242 @@ +/** Finite endpoint sources consulted by the service directory. */ +export const ENDPOINT_SOURCES = [ + 'override', + 'aspire-cli', + 'run-manifest', + 'appsettings', +] as const; + +/** Endpoint discovery source identifier. */ +export type EndpointSource = (typeof ENDPOINT_SOURCES)[number]; + +/** Current per-service arbitration order, highest priority first. */ +export const ENDPOINT_SOURCE_PRECEDENCE: readonly EndpointSource[] = Object.freeze([ + 'override', + 'aspire-cli', + 'run-manifest', + 'appsettings', +]); + +/** Stable source failure classifications exposed to directory consumers. */ +export const SOURCE_FAILURE_CODES = [ + 'unreadable', + 'invalid', + 'project_root_mismatch', + 'expected_run_id_missing', + 'run_id_mismatch', + 'command_not_found', + 'command_failed', + 'parse_failed', + 'source_failed', +] as const; + +/** Machine-readable endpoint source failure classification. */ +export type SourceFailureCode = (typeof SOURCE_FAILURE_CODES)[number]; + +/** One service endpoint fact emitted by a source adapter. */ +export interface EndpointCandidate { + /** Service identity expected from the running process. */ + readonly name: string; + /** Candidate base URL, absent when a configured service has no running endpoint. */ + readonly baseUrl?: string; + /** Adapter that produced this fact. */ + readonly source: EndpointSource; + /** Whether the URL came from an explicit human-written override. */ + readonly operatorTrusted: boolean; +} + +/** A source was read successfully, including a valid empty result. */ +export interface UsedSourceOutcome { + /** Source adapter that completed successfully. */ + readonly source: EndpointSource; + /** Successful source-read discriminator. */ + readonly outcome: 'used'; + /** Endpoint facts emitted by the source, possibly empty. */ + readonly candidates: readonly EndpointCandidate[]; + /** Service names excluded by this source's configuration. */ + readonly excludedServices: readonly string[]; +} + +/** A source carrier was not present. */ +export interface AbsentSourceOutcome { + /** Source adapter whose carrier was absent. */ + readonly source: EndpointSource; + /** Missing-carrier discriminator. */ + readonly outcome: 'absent'; + /** No candidates are available from an absent source. */ + readonly candidates: readonly []; + /** No exclusions are available from an absent source. */ + readonly excludedServices: readonly []; +} + +/** A present or required source could not produce trustworthy facts. */ +export interface FailedSourceOutcome { + /** Source adapter that could not produce trustworthy facts. */ + readonly source: EndpointSource; + /** Failed source-read discriminator. */ + readonly outcome: 'failed'; + /** Machine-readable failure classification. */ + readonly code: SourceFailureCode; + /** Human-readable bounded failure detail. */ + readonly reason: string; + /** Failed sources never contribute candidates. */ + readonly candidates: readonly []; + /** Failed sources never contribute exclusions. */ + readonly excludedServices: readonly []; +} + +/** Honest outcome of consulting one endpoint source. */ +export type SourceOutcome = UsedSourceOutcome | AbsentSourceOutcome | FailedSourceOutcome; + +/** Read context shared by endpoint source adapters. */ +export interface EndpointSourceContext { + /** Project root supplied to the MCP process. */ + readonly projectRoot: string; + /** Current AppHost run token expected in a run manifest. */ + readonly expectedRunId?: string; + /** Exact AppHost path used to disambiguate the Aspire query. */ + readonly appHostPath?: string; +} + +/** Supplies endpoint candidates from one external discovery carrier. */ +export interface EndpointSourcePort { + /** Read one source without hiding absence or failure. */ + read(context: EndpointSourceContext, signal?: AbortSignal): Promise; +} + +/** Finite service states returned by the endpoint directory. */ +export const SERVICE_ENDPOINT_STATUSES = [ + 'running', + 'not_running', + 'spec_unavailable', + 'identity_mismatch', + 'excluded', +] as const; + +/** Service endpoint status after bounded probing. */ +export type ServiceEndpointStatus = (typeof SERVICE_ENDPOINT_STATUSES)[number]; + +/** One lower-precedence endpoint value that disagreed with the selected candidate. */ +export interface EndpointConflict { + /** Lower-precedence source that supplied a different URL. */ + readonly source: EndpointSource; + /** Discarded lower-precedence base URL. */ + readonly baseUrl: string; +} + +/** Successful spec and service-identity probe. */ +export interface RunningServiceEndpointProbeResult { + /** Successful probe discriminator. */ + readonly outcome: 'running'; + /** Parsed OpenAPI document retained opaquely for the later projection slice. */ + readonly spec: unknown; +} + +/** Expected degraded probe result converted to one directory row. */ +export interface FailedServiceEndpointProbeResult { + /** Degraded probe classification. */ + readonly outcome: Exclude; + /** Human-readable bounded failure detail. */ + readonly reason: string; + /** Response status when an HTTP response caused the failure. */ + readonly httpStatus?: number; + /** Operator remediation when the failure has ratified guidance. */ + readonly guidance?: string; +} + +/** Result of probing one endpoint candidate. */ +export type ServiceEndpointProbeResult = + | RunningServiceEndpointProbeResult + | FailedServiceEndpointProbeResult; + +/** Performs bounded OpenAPI and service-identity checks for one candidate. */ +export interface ServiceEndpointProbePort { + /** Probe one candidate while honoring caller cancellation. */ + probe(candidate: EndpointCandidate, signal: AbortSignal): Promise; +} + +/** Identity and arbitration facts shared by every service endpoint row. */ +export interface ServiceEndpointRowBase { + /** Stable service name. */ + readonly name: string; + /** Source selected by precedence for this service. */ + readonly source: EndpointSource; + /** Lower-precedence URLs that disagreed with the selected candidate. */ + readonly conflicts: readonly EndpointConflict[]; +} + +/** A verified service endpoint with an opaque parsed OpenAPI document. */ +export interface RunningServiceEndpointRow extends ServiceEndpointRowBase { + /** Verified-running row discriminator. */ + readonly status: 'running'; + /** Selected and verified service base URL. */ + readonly baseUrl: string; + /** Parsed OpenAPI document retained opaquely for later consumers. */ + readonly spec: unknown; +} + +/** A configured service for which no listener could be reached. */ +export interface NotRunningServiceEndpointRow extends ServiceEndpointRowBase { + /** Not-running row discriminator. */ + readonly status: 'not_running'; + /** Selected URL when discovery found one but no listener was reachable. */ + readonly baseUrl?: string; + /** Human-readable bounded failure detail. */ + readonly reason: string; +} + +/** A listening service whose OpenAPI document could not be consumed. */ +export interface SpecUnavailableServiceEndpointRow extends ServiceEndpointRowBase { + /** Unavailable-spec row discriminator. */ + readonly status: 'spec_unavailable'; + /** Selected service base URL. */ + readonly baseUrl: string; + /** Human-readable bounded failure detail. */ + readonly reason: string; + /** Response status when an HTTP response caused the failure. */ + readonly httpStatus?: number; + /** Operator remediation for an unavailable spec. */ + readonly guidance?: string; +} + +/** An endpoint that did not identify as the selected service. */ +export interface IdentityMismatchServiceEndpointRow extends ServiceEndpointRowBase { + /** Identity-mismatch row discriminator. */ + readonly status: 'identity_mismatch'; + /** Selected URL that identified as another service. */ + readonly baseUrl: string; + /** Human-readable bounded mismatch detail. */ + readonly reason: string; +} + +/** A service deliberately excluded before any network request. */ +export interface ExcludedServiceEndpointRow extends ServiceEndpointRowBase { + /** Excluded row discriminator. */ + readonly status: 'excluded'; + /** Selected URL when discovery produced one before exclusion. */ + readonly baseUrl?: string; + /** Human-readable exclusion detail. */ + readonly reason: string; +} + +/** One stable service row returned by the directory. */ +export type ServiceEndpointRow = + | RunningServiceEndpointRow + | NotRunningServiceEndpointRow + | SpecUnavailableServiceEndpointRow + | IdentityMismatchServiceEndpointRow + | ExcludedServiceEndpointRow; + +/** Complete service directory result, including all consulted source outcomes. */ +export interface ServiceEndpointDirectoryResult { + /** Stable service rows sorted by service name. */ + readonly entries: readonly ServiceEndpointRow[]; + /** Outcome from every source in precedence order. */ + readonly sources: readonly SourceOutcome[]; +} + +/** Application-facing directory consumed by later MCP tool slices. */ +export interface ServiceEndpointDirectoryPort { + /** List stable service rows and every source outcome. */ + list(signal?: AbortSignal): Promise; +} diff --git a/packages/mcp/src/publish-assets.generated.ts b/packages/mcp/src/publish-assets.generated.ts index 8e0390960..137ca480c 100644 --- a/packages/mcp/src/publish-assets.generated.ts +++ b/packages/mcp/src/publish-assets.generated.ts @@ -6,4 +6,4 @@ export const MCP_PACKAGE_VERSION: string = '0.0.4'; /** Published MCP README embedded as the default documentation corpus. */ export const MCP_PACKAGE_README: string = - '# @netscript/mcp\n\n[![JSR](https://jsr.io/badges/@netscript/mcp)](https://jsr.io/@netscript/mcp)\n[![CI](https://github.com/rickylabs/netscript/actions/workflows/ci.yml/badge.svg)](https://github.com/rickylabs/netscript/actions/workflows/ci.yml)\n[![Docs](https://img.shields.io/badge/docs-rickylabs.github.io-blue)](https://rickylabs.github.io/netscript/)\n\n**The Model Context Protocol server for NetScript: 14 token-bounded tools that let a coding agent\nmonitor a running app, debug a correlated execution, read framework-semantic telemetry, run the\ndoctor, and search the docs — all over stdio.**\n\nPoint Claude Code or VS Code at a running NetScript app and the agent can ask _"is the app\nhealthy?"_, _"why did the last import job fail?"_, and _"what is slowing down `checkout`?"_ — and\nget compact, structured answers instead of raw logs. It can correlate one execution\'s spans, logs,\nand outcome by id; rank the queries hammering your database; and trigger allowlisted CLI commands\nthrough a default-deny policy. One command — `netscript agent init` — wires all of it into your\nagent host.\n\nGeneric observability tooling hands an agent raw spans and log lines and lets it burn its context\nwindow re-deriving structure the framework already knows. `@netscript/mcp` answers in NetScript\'s\nown vocabulary — jobs, sagas, triggers, streams, services — and bounds every result server-side, so\nthe agent gets percentiles, error rates, and ranked operations rather than the spans they were\ncomputed from. It reads the same OpenTelemetry data the Aspire dashboard shows you, and complements\nAspire\'s own MCP server: Aspire speaks resources and containers; this server speaks your app.\n\n## Why agents like it\n\n- **14 token-bounded tools** — every successful result is capped server-side (50 array items, 2,000\n characters per string) before it reaches the model; the analytics tools never return raw spans at\n all.\n- **Framework-semantic trace intelligence** — tools classify telemetry into `worker`, `saga`,\n `trigger`, `stream`, and `service` domains and correlate whole executions by id, because they\n understand the `netscript.*` attribute conventions the framework emits.\n- **Default-deny CLI gate** — `execute_command` matches commands against an ordered prefix policy;\n deny beats allow, anything unmatched is denied, and the shipped policy explicitly denies `deploy`,\n `init`, `marketplace`, `db reset`, `plugin remove`, and `ui:remove`.\n- **One-command install** — `netscript agent init` detects your agent host, writes the MCP\n configuration, and installs the matching NetScript skills.\n- **Matched agent surface** — `netscript agent init` writes host configuration pinned to your\n installed CLI version and installs the skills that ship with that same release, so the tool\n catalog the agent sees comes from the release it runs.\n- **Zero npm MCP SDK** — a minimal newline-delimited JSON-RPC transport keeps the dependency graph\n lean and the lockfile stable.\n\n## Architecture\n\n```mermaid\nflowchart LR\n A["Agent host
(Claude Code, VS Code, ...)"] <-- "JSON-RPC / stdio" --> S["netscript agent mcp
14 tools · bounded results"]\n S --> T["Telemetry endpoint
(OTLP read model)"]\n S --> D["Docs corpus
(public Markdown)"]\n S --> P["Command policy
(default-deny allowlist)"]\n T --> R["Running NetScript app"]\n P --> C["netscript CLI"]\n C --> R\n```\n\nThe server is one third of the NetScript agent surface — the CLI is the hands, the skills are the\nplaybook, MCP is the eyes. It deliberately wraps the CLI rather than reimplementing it:\n`list_commands` reflects the live command tree, and `execute_command` shells the CLI through the\npolicy gate. MCP exists for what a shell cannot cheaply give an agent — bounded aggregation,\ncross-domain diagnostics, and documentation lookup.\n\n## Install\n\nMost users never import this package. Install the server into a project with the CLI:\n\n```bash\nnetscript agent init\n```\n\nThat detects your agent host and writes `.mcp.json` (Claude Code) and/or `.vscode/mcp.json` (VS\nCode) pointing at `netscript agent mcp`, and installs the NetScript skills shipped with your CLI\nrelease. Use `--host claude|vscode|all` to choose explicitly.\n\nTo embed the server in your own host process, add it as a library:\n\n```bash\ndeno add jsr:@netscript/mcp@\n```\n\nTo run the standalone stdio entrypoint directly when integrating another MCP host:\n\n```bash\ndeno x -A jsr:@netscript/mcp@/cli\n```\n\nPin `` to match your installed CLI; bare `jsr:@netscript/*` specifiers do not resolve on\nthe pre-release line, and `netscript agent init` writes the correct pinned form for you.\n\n## Quick example\n\n**1. Wire up an agent host.** From a NetScript project root:\n\n```bash\n$ netscript agent init\nInstalled NetScript agent integration for claude, vscode.\n```\n\nThe generated `.mcp.json` runs the server for this project — equivalent to:\n\n```json\n{\n "mcpServers": {\n "netscript": {\n "command": "deno",\n "args": [\n "run",\n "-A",\n "jsr:@netscript/cli@",\n "agent",\n "mcp",\n "--project-root",\n ""\n ]\n }\n }\n}\n```\n\n**2. Ask the agent.** With the app started, the agent turns questions into bounded tool calls:\n\n> **You:** Is the app healthy? Anything in the docs about telemetry?\n>\n> **Agent:** calls `get_app_status` →\n> `{"status": "…", "counts": {…}, "domains": [{"domain": "worker", …}, …]}` — a health verdict with\n> per-domain summaries, not a span dump. Calls `search_docs {"query": "telemetry"}` →\n> `{"count": 1, "matches": [{"slug": "mcp", "title": "@netscript/mcp", "snippet": "…", "score": 35}]}`,\n> then `get_doc` with the winning slug to read just the section it needs.\n\nWhen telemetry is unreachable, nothing crashes: `get_app_status` and the doctor\'s telemetry checks\nreport a structured `warn`/`fail` status, the list and analytics tools return their ordinary empty\nresults, and `get_run` returns a structured `run_not_found` error the agent can reason about.\n\n## Tool catalog\n\n| Tool | Required input | Bounded result |\n| ----------------------------- | ----------------- | ---------------------------------------------------------------------------- |\n| `get_app_status` | — | Health verdict, counts, per-domain summaries |\n| `list_runs` | — | Recent executions filtered by domain, status, service, time |\n| `get_run` | `id` | One correlated execution with bounded spans and logs |\n| `get_recent_errors` | — | Recent errors grouped by service and domain |\n| `get_last_job_result` | — | The latest matching job outcome |\n| `analyze_service_performance` | `service` | Duration percentiles, throughput, error rate |\n| `analyze_db_bottlenecks` | — | Ranked database and KV operations |\n| `doctor` | — | Telemetry, Aspire, wiring, and plugin checks; suggested fixes on problems |\n| `search_docs` | `query` | Ranked public-document matches with snippets |\n| `list_docs` | — | Public-document summaries |\n| `get_doc` | `slug` | One public document, or one named section of it |\n| `list_commands` | — | Live CLI command descriptors |\n| `execute_command` | `command` | Exit code, duration, and bounded output tail; structured denial when blocked |\n| `record_drift` | `resource`, `summary` | Evidence-gated drift entry appended to project drift log |\n\nA top-level input/result field overview for every tool is on the\n[MCP reference](https://rickylabs.github.io/netscript/reference/mcp/); the complete Standard Schema\ncontracts are published as `TOOL_INPUT_SCHEMAS` / `TOOL_OUTPUT_SCHEMAS` and returned by the live\n`tools/list`.\n\n## Record drift\n\n`record_drift` is an evidence-gated mutating tool that records verified architecture or runtime drift into `.netscript/agent/drift.jsonl`.\n\n- **Required evidence**: Requires a fresh successful diagnostic receipt (timestamped within 15 minutes, `exitStatus: 0`) for the target resource. Receipts are automatically produced when calling `doctor`, telemetry tools, or `netscript plugin doctor --resource `.\n- **Target & Scope**: The `resource` argument targets a specific plugin, service, or `\'project\'`. Receipts live at `.netscript/agent/diagnostics/.json`.\n- **Mutation behavior**: Appends a single JSON line to `.netscript/agent/drift.jsonl` under the project root containing `timestamp`, `resource`, `summary`, optional `details`, and the attached evidence receipt.\n- **Failure modes**: If no receipt exists, if the receipt is older than 15 minutes, or if the receipt recorded a non-zero exit status, `record_drift` refuses with structured error code `diagnostic_evidence_required`.\n- **Dry-run / Preview**: Inspecting receipts or running `doctor` / telemetry tools previews current diagnostic state without mutating `drift.jsonl`.\n\n## Embedding as a library\n\nTo run the public stdio composition from your own Deno entrypoint:\n\n```ts\nimport { runMcpStdioServer } from \'@netscript/mcp/cli\';\n\nawait runMcpStdioServer({\n projectRoot: Deno.cwd(),\n // Omit docsRoot to use the package-embedded corpus, or select a filesystem corpus explicitly.\n docsRoot: Deno.env.get(\'NETSCRIPT_DOCS_ROOT\'),\n});\n```\n\n`runMcpStdioServer` owns the newline-delimited stdio transport. It shuts down when the host closes\nstdin or terminates the process; callers do not need to reach into an internal transport API.\n\n## Public surface\n\nTwo entrypoints carry the package:\n\n| Entry | What it gives you |\n| ------- | ------------------------------------------------------------------------------------------------------------------------------------ |\n| `.` | Tool contracts and schemas, the tool registry, the protocol runner (`createMcpServer`), port interfaces, and default adapters |\n| `./cli` | The executable composition (`createMcpCliServer`, `runMcpStdioServer`) that binds real telemetry, docs, doctor, and process adapters |\n\nEvery tool flow depends on a port interface, so embedders and tests supply their own adapters and\nassert against the published schemas. The always-current symbol list is\n[`deno doc jsr:@netscript/mcp@`](https://jsr.io/@netscript/mcp/doc) (pin `` on the\npre-release line, as above).\n\n## Configuration at a glance\n\n- **Telemetry endpoint discovery** (tools and `doctor`): explicit `--endpoint`, then\n `NETSCRIPT_TELEMETRY_ENDPOINT`, then `ASPIRE_DASHBOARD_PORT`, then `http://localhost:18888`.\n- **Docs corpus**: by default the docs tools index the documentation shipped with the installed\n package; set `--docs-root ` (or `NETSCRIPT_DOCS_ROOT`) to serve a project or site corpus\n instead.\n- **Command policy**: the shipped default allows the prefixes\n `db init|generate|migrate|seed|status|introspect`, `generate`, `contract`, `service list`,\n `plugin install|list|sync|doctor`, and `ui:add|ui:init|ui:list|ui:update`, and denies `deploy`,\n `init`, `marketplace`, `db reset`, `plugin remove`, and `ui:remove` — deny beats allow, anything\n unmatched is denied. Embedders can pass their own policy.\n\nThe full flag reference, policy table, and composition options are on the docs site.\n\n## Docs\n\n- **MCP reference — the 14-tool field overview, policy, and exports**:\n [rickylabs.github.io/netscript/reference/mcp/](https://rickylabs.github.io/netscript/reference/mcp/)\n- **Agent tooling — install, flags, troubleshooting, CLI × skills × MCP**:\n [rickylabs.github.io/netscript/capabilities/agent-tooling/](https://rickylabs.github.io/netscript/capabilities/agent-tooling/)\n- **API docs on JSR**: [jsr.io/@netscript/mcp/doc](https://jsr.io/@netscript/mcp/doc)\n\n## Compatibility\n\nThe **server** requires Deno 2.9+ (both entrypoints use `Deno.*` APIs); Node.js and Bun are not\nsupported as server runtimes. The **client** side is unconstrained: any MCP-capable host — Claude\nCode, VS Code, and others — only has to spawn the process and speak JSON-RPC over stdio. The\nexecutable needs `--allow-env`, `--allow-net`, `--allow-read`, and `--allow-run`; the `netscript`\nbinary grants these at its edge. The server never returns project source, environment-variable\nvalues, credentials, or secrets.\n\n## License\n\nApache-2.0 — see [LICENSE](https://github.com/rickylabs/netscript/blob/main/LICENSE). Published to\nJSR with cryptographically verified provenance.\n'; + '# @netscript/mcp\n\n[![JSR](https://jsr.io/badges/@netscript/mcp)](https://jsr.io/@netscript/mcp)\n[![CI](https://github.com/rickylabs/netscript/actions/workflows/ci.yml/badge.svg)](https://github.com/rickylabs/netscript/actions/workflows/ci.yml)\n[![Docs](https://img.shields.io/badge/docs-rickylabs.github.io-blue)](https://rickylabs.github.io/netscript/)\n\n**The Model Context Protocol server for NetScript: 14 token-bounded tools that let a coding agent\nmonitor a running app, debug a correlated execution, read framework-semantic telemetry, run the\ndoctor, and search the docs — all over stdio.**\n\nPoint Claude Code or VS Code at a running NetScript app and the agent can ask _"is the app\nhealthy?"_, _"why did the last import job fail?"_, and _"what is slowing down `checkout`?"_ — and\nget compact, structured answers instead of raw logs. It can correlate one execution\'s spans, logs,\nand outcome by id; rank the queries hammering your database; and trigger allowlisted CLI commands\nthrough a default-deny policy. One command — `netscript agent init` — wires all of it into your\nagent host.\n\nGeneric observability tooling hands an agent raw spans and log lines and lets it burn its context\nwindow re-deriving structure the framework already knows. `@netscript/mcp` answers in NetScript\'s\nown vocabulary — jobs, sagas, triggers, streams, services — and bounds every result server-side, so\nthe agent gets percentiles, error rates, and ranked operations rather than the spans they were\ncomputed from. It reads the same OpenTelemetry data the Aspire dashboard shows you, and complements\nAspire\'s own MCP server: Aspire speaks resources and containers; this server speaks your app.\n\n## Why agents like it\n\n- **14 token-bounded tools** — every successful result is capped server-side (50 array items, 2,000\n characters per string) before it reaches the model; the analytics tools never return raw spans at\n all.\n- **Framework-semantic trace intelligence** — tools classify telemetry into `worker`, `saga`,\n `trigger`, `stream`, and `service` domains and correlate whole executions by id, because they\n understand the `netscript.*` attribute conventions the framework emits.\n- **Default-deny CLI gate** — `execute_command` matches commands against an ordered prefix policy;\n deny beats allow, anything unmatched is denied, and the shipped policy explicitly denies `deploy`,\n `init`, `marketplace`, `db reset`, `plugin remove`, and `ui:remove`.\n- **One-command install** — `netscript agent init` detects your agent host, writes the MCP\n configuration, and installs the matching NetScript skills.\n- **Matched agent surface** — `netscript agent init` writes host configuration pinned to your\n installed CLI version and installs the skills that ship with that same release, so the tool\n catalog the agent sees comes from the release it runs.\n- **Zero npm MCP SDK** — a minimal newline-delimited JSON-RPC transport keeps the dependency graph\n lean and the lockfile stable.\n\n## Architecture\n\n```mermaid\nflowchart LR\n A["Agent host
(Claude Code, VS Code, ...)"] <-- "JSON-RPC / stdio" --> S["netscript agent mcp
14 tools · bounded results"]\n S --> T["Telemetry endpoint
(OTLP read model)"]\n S --> D["Docs corpus
(public Markdown)"]\n S --> P["Command policy
(default-deny allowlist)"]\n T --> R["Running NetScript app"]\n P --> C["netscript CLI"]\n C --> R\n```\n\nThe server is one third of the NetScript agent surface — the CLI is the hands, the skills are the\nplaybook, MCP is the eyes. It deliberately wraps the CLI rather than reimplementing it:\n`list_commands` reflects the live command tree, and `execute_command` shells the CLI through the\npolicy gate. MCP exists for what a shell cannot cheaply give an agent — bounded aggregation,\ncross-domain diagnostics, and documentation lookup.\n\n## Install\n\nMost users never import this package. Install the server into a project with the CLI:\n\n```bash\nnetscript agent init\n```\n\nThat detects your agent host and writes `.mcp.json` (Claude Code) and/or `.vscode/mcp.json` (VS\nCode) pointing at `netscript agent mcp`, and installs the NetScript skills shipped with your CLI\nrelease. Use `--host claude|vscode|all` to choose explicitly.\n\nTo embed the server in your own host process, add it as a library:\n\n```bash\ndeno add jsr:@netscript/mcp@\n```\n\nTo run the standalone stdio entrypoint directly when integrating another MCP host:\n\n```bash\ndeno x -A jsr:@netscript/mcp@/cli\n```\n\nPin `` to match your installed CLI; bare `jsr:@netscript/*` specifiers do not resolve on\nthe pre-release line, and `netscript agent init` writes the correct pinned form for you.\n\n## Quick example\n\n**1. Wire up an agent host.** From a NetScript project root:\n\n```bash\n$ netscript agent init\nInstalled NetScript agent integration for claude, vscode.\n```\n\nThe generated `.mcp.json` runs the server for this project — equivalent to:\n\n```json\n{\n "mcpServers": {\n "netscript": {\n "command": "deno",\n "args": [\n "run",\n "-A",\n "jsr:@netscript/cli@",\n "agent",\n "mcp",\n "--project-root",\n ""\n ]\n }\n }\n}\n```\n\n**2. Ask the agent.** With the app started, the agent turns questions into bounded tool calls:\n\n> **You:** Is the app healthy? Anything in the docs about telemetry?\n>\n> **Agent:** calls `get_app_status` →\n> `{"status": "…", "counts": {…}, "domains": [{"domain": "worker", …}, …]}` — a health verdict with\n> per-domain summaries, not a span dump. Calls `search_docs {"query": "telemetry"}` →\n> `{"count": 1, "matches": [{"slug": "mcp", "title": "@netscript/mcp", "snippet": "…", "score": 35}]}`,\n> then `get_doc` with the winning slug to read just the section it needs.\n\nWhen telemetry is unreachable, nothing crashes: `get_app_status` and the doctor\'s telemetry checks\nreport a structured `warn`/`fail` status, the list and analytics tools return their ordinary empty\nresults, and `get_run` returns a structured `run_not_found` error the agent can reason about.\n\n## Tool catalog\n\n| Tool | Required input | Bounded result |\n| ----------------------------- | ----------------- | ---------------------------------------------------------------------------- |\n| `get_app_status` | — | Health verdict, counts, per-domain summaries |\n| `list_runs` | — | Recent executions filtered by domain, status, service, time |\n| `get_run` | `id` | One correlated execution with bounded spans and logs |\n| `get_recent_errors` | — | Recent errors grouped by service and domain |\n| `get_last_job_result` | — | The latest matching job outcome |\n| `analyze_service_performance` | `service` | Duration percentiles, throughput, error rate |\n| `analyze_db_bottlenecks` | — | Ranked database and KV operations |\n| `doctor` | — | Telemetry, Aspire, wiring, and plugin checks; suggested fixes on problems |\n| `search_docs` | `query` | Ranked public-document matches with snippets |\n| `list_docs` | — | Public-document summaries |\n| `get_doc` | `slug` | One public document, or one named section of it |\n| `list_commands` | — | Live CLI command descriptors |\n| `execute_command` | `command` | Exit code, duration, and bounded output tail; structured denial when blocked |\n| `record_drift` | `resource`, `summary` | Evidence-gated drift entry appended to project drift log |\n\nA top-level input/result field overview for every tool is on the\n[MCP reference](https://rickylabs.github.io/netscript/reference/mcp/); the complete Standard Schema\ncontracts are published as `TOOL_INPUT_SCHEMAS` / `TOOL_OUTPUT_SCHEMAS` and returned by the live\n`tools/list`.\n\n## Record drift\n\n`record_drift` is an evidence-gated mutating tool that records verified architecture or runtime drift into `.netscript/agent/drift.jsonl`.\n\n- **Required evidence**: Requires a fresh successful diagnostic receipt (timestamped within 15 minutes, `exitStatus: 0`) for the target resource. Receipts are automatically produced when calling `doctor`, telemetry tools, or `netscript plugin doctor --resource `.\n- **Target & Scope**: The `resource` argument targets a specific plugin, service, or `\'project\'`. Receipts live at `.netscript/agent/diagnostics/.json`.\n- **Mutation behavior**: Appends a single JSON line to `.netscript/agent/drift.jsonl` under the project root containing `timestamp`, `resource`, `summary`, optional `details`, and the attached evidence receipt.\n- **Failure modes**: If no receipt exists, if the receipt is older than 15 minutes, or if the receipt recorded a non-zero exit status, `record_drift` refuses with structured error code `diagnostic_evidence_required`.\n- **Dry-run / Preview**: Inspecting receipts or running `doctor` / telemetry tools previews current diagnostic state without mutating `drift.jsonl`.\n\n## Embedding as a library\n\nTo run the public stdio composition from your own Deno entrypoint:\n\n```ts\nimport { runMcpStdioServer } from \'@netscript/mcp/cli\';\n\nawait runMcpStdioServer({\n projectRoot: Deno.cwd(),\n // Omit docsRoot to use the package-embedded corpus, or select a filesystem corpus explicitly.\n docsRoot: Deno.env.get(\'NETSCRIPT_DOCS_ROOT\'),\n});\n```\n\n`runMcpStdioServer` owns the newline-delimited stdio transport. It shuts down when the host closes\nstdin or terminates the process; callers do not need to reach into an internal transport API.\n\n## Public surface\n\nTwo entrypoints carry the package:\n\n| Entry | What it gives you |\n| ------- | ---------------------------------------------------------------------------------------------------------------------- |\n| `.` | Tool contracts and schemas, the tool registry, protocol runner, service endpoint directory ports, and default adapters |\n| `./cli` | The executable composition plus every export from `.`, including the service endpoint directory surface |\n\nEvery tool flow depends on a port interface, so embedders and tests supply their own adapters and\nassert against the published schemas. The always-current symbol list is\n[`deno doc jsr:@netscript/mcp@`](https://jsr.io/@netscript/mcp/doc) (pin `` on the\npre-release line, as above).\n\n### Discover service OpenAPI endpoints\n\nEmbedders can compose the four discovery sources and bounded network probe without importing an\nOpenAPI projection layer:\n\n```ts\nimport { createServiceEndpointDirectory } from \'@netscript/mcp\';\n\nconst endpoints = createServiceEndpointDirectory({\n projectRoot: Deno.cwd(),\n});\n\nconst { entries, sources } = await endpoints.list();\n```\n\nThe effective per-service precedence is `override > aspire-cli > run-manifest > appsettings`. Every\nsource remains visible as `used`, `absent`, or `failed`; a failed Aspire CLI query or a stale\nmanifest is never rendered as healthy absence. The manifest at `.netscript/run/endpoints.json` is\neligible only when its real project root and `runId` match the supplied current run. `appHostPath`\ndefaults to `./aspire/apphost.mts`; override it when the active AppHost lives elsewhere. Supply\n`expectedRunId` only when the host owns the current AppHost run token; without that identity proof,\na present run manifest is reported as failed and does not contribute endpoints.\n\nExplicit operator endpoints and exclusions live only in the S5-owned subsection of\n`.netscript/agent-mcp.json`; sibling settings are ignored:\n\n```json\n{\n "introspection": {\n "serviceEndpoints": {\n "orders": "https://orders.example.test"\n },\n "excludeServices": ["internal-admin"]\n }\n}\n```\n\nExclusions are applied before network access. Other rows report `running`, `not_running`,\n`spec_unavailable`, or `identity_mismatch`; parsed OpenAPI is retained as opaque JSON for a later\nconsumer. Probes do not send credentials or follow redirects. A 401/403 explains how to expose only\nthe OpenAPI route anonymously or supply a reachable public spec URL. A running service must return\nJSON containing its selected service name, for example `{ "service": "orders" }`, from its selected\nbase path; this second request prevents a reused port from being mistaken for the intended service.\n\nThe default library composition needs `--allow-read` for carriers and real-path checks,\n`--allow-run` for `aspire describe`, and `--allow-net` for bounded spec/identity requests. Tests and\ncustom hosts can replace every source and the probe through `ServiceEndpointDirectoryOptions`.\n\n## Configuration at a glance\n\n- **Telemetry endpoint discovery** (tools and `doctor`): explicit `--endpoint`, then\n `NETSCRIPT_TELEMETRY_ENDPOINT`, then `ASPIRE_DASHBOARD_PORT`, then `http://localhost:18888`.\n- **Docs corpus**: by default the docs tools index the documentation shipped with the installed\n package; set `--docs-root ` (or `NETSCRIPT_DOCS_ROOT`) to serve a project or site corpus\n instead.\n- **Service endpoint discovery** (library surface): `.netscript/agent-mcp.json` override, then the\n Aspire CLI machine-readable query, then an identity-bound run manifest, then\n `aspire/appsettings.json`; lower-priority disagreements remain visible as conflicts.\n- **Command policy**: the shipped default allows the prefixes\n `db init|generate|migrate|seed|status|introspect`, `generate`, `contract`, `service list`,\n `plugin install|list|sync|doctor`, and `ui:add|ui:init|ui:list|ui:update`, and denies `deploy`,\n `init`, `marketplace`, `db reset`, `plugin remove`, and `ui:remove` — deny beats allow, anything\n unmatched is denied. Embedders can pass their own policy.\n\nThe full flag reference, policy table, and composition options are on the docs site.\n\n## Docs\n\n- **MCP reference — the 14-tool field overview, policy, and exports**:\n [rickylabs.github.io/netscript/reference/mcp/](https://rickylabs.github.io/netscript/reference/mcp/)\n- **Agent tooling — install, flags, troubleshooting, CLI × skills × MCP**:\n [rickylabs.github.io/netscript/capabilities/agent-tooling/](https://rickylabs.github.io/netscript/capabilities/agent-tooling/)\n- **API docs on JSR**: [jsr.io/@netscript/mcp/doc](https://jsr.io/@netscript/mcp/doc)\n\n## Compatibility\n\nThe **server** requires Deno 2.9+ (both entrypoints use `Deno.*` APIs); Node.js and Bun are not\nsupported as server runtimes. The **client** side is unconstrained: any MCP-capable host — Claude\nCode, VS Code, and others — only has to spawn the process and speak JSON-RPC over stdio. The\nexecutable needs `--allow-env`, `--allow-net`, `--allow-read`, and `--allow-run`; the `netscript`\nbinary grants these at its edge. The server never returns project source, environment-variable\nvalues, credentials, or secrets.\n\n## License\n\nApache-2.0 — see [LICENSE](https://github.com/rickylabs/netscript/blob/main/LICENSE). Published to\nJSR with cryptographically verified provenance.\n'; diff --git a/packages/mcp/tests/service-endpoint-directory_test.ts b/packages/mcp/tests/service-endpoint-directory_test.ts new file mode 100644 index 000000000..2bd7cfcad --- /dev/null +++ b/packages/mcp/tests/service-endpoint-directory_test.ts @@ -0,0 +1,384 @@ +import { assert, assertEquals, assertRejects, assertStringIncludes } from '@std/assert'; +import { + createServiceEndpointDirectory, + type ServiceEndpointDirectoryOptions, +} from '../src/application/service-endpoint-directory.ts'; +import type { + EndpointCandidate, + EndpointSource, + EndpointSourcePort, + ServiceEndpointProbePort, + ServiceEndpointProbeResult, + SourceOutcome, +} from '../src/ports/service-endpoint-directory-port.ts'; +import { + FetchServiceEndpointProbe, + SPEC_UNAVAILABLE_AUTH_GUIDANCE, +} from '../src/infrastructure/service-endpoints/fetch-service-endpoint-probe.ts'; + +function candidate( + name: string, + source: EndpointSource, + baseUrl?: string, +): EndpointCandidate { + return { + name, + ...(baseUrl === undefined ? {} : { baseUrl }), + source, + operatorTrusted: source === 'override', + }; +} + +function used( + source: EndpointSource, + candidates: readonly EndpointCandidate[], + excludedServices: readonly string[] = [], +): SourceOutcome { + return { source, outcome: 'used', candidates, excludedServices }; +} + +function absent(source: EndpointSource): SourceOutcome { + return { source, outcome: 'absent', candidates: [], excludedServices: [] }; +} + +function sourceReturning(outcome: SourceOutcome): EndpointSourcePort { + return { read: () => Promise.resolve(outcome) }; +} + +function sourceAdapters(outcomes: Readonly>): Readonly< + Record +> { + return { + override: sourceReturning(outcomes.override), + 'aspire-cli': sourceReturning(outcomes['aspire-cli']), + 'run-manifest': sourceReturning(outcomes['run-manifest']), + appsettings: sourceReturning(outcomes.appsettings), + }; +} + +function directoryOptions( + outcomes: Readonly>, + probe: ServiceEndpointProbePort, + options: Partial> = {}, +): ServiceEndpointDirectoryOptions { + return { + projectRoot: '/project', + sourceAdapters: sourceAdapters(outcomes), + probe, + ...options, + }; +} + +Deno.test('directory exposes all sources, applies precedence, conflicts, statuses, and pre-fetch exclusions', async () => { + const probed: string[] = []; + const probe: ServiceEndpointProbePort = { + probe: (entry): Promise => { + probed.push(entry.name); + if (entry.name === 'auth') { + return Promise.resolve({ + outcome: 'spec_unavailable', + reason: 'OpenAPI document returned HTTP 401.', + httpStatus: 401, + guidance: SPEC_UNAVAILABLE_AUTH_GUIDANCE, + }); + } + if (entry.name === 'reused') { + return Promise.resolve({ + outcome: 'identity_mismatch', + reason: 'Expected service "reused" but endpoint identified as "foreign".', + }); + } + return Promise.resolve({ + outcome: 'running', + spec: { openapi: '3.1.0', service: entry.name }, + }); + }, + }; + const outcomes = { + override: used('override', [ + candidate('secret', 'override', 'http://operator.example.test'), + candidate('users', 'override', 'http://operator.example.test/users'), + ], ['secret']), + 'aspire-cli': used('aspire-cli', [ + candidate('auth', 'aspire-cli', 'http://127.0.0.1:4101'), + candidate('reused', 'aspire-cli', 'http://127.0.0.1:4102'), + candidate('users', 'aspire-cli', 'http://127.0.0.1:4103'), + ]), + 'run-manifest': used('run-manifest', [ + candidate('users', 'run-manifest', 'http://127.0.0.1:4104'), + ]), + appsettings: used('appsettings', [ + candidate('offline', 'appsettings'), + candidate('users', 'appsettings', 'http://127.0.0.1:4105'), + ]), + } satisfies Readonly>; + + const result = await createServiceEndpointDirectory(directoryOptions(outcomes, probe)).list(); + assertEquals(result.sources.map((source) => source.source), [ + 'override', + 'aspire-cli', + 'run-manifest', + 'appsettings', + ]); + assertEquals(result.entries.map((entry) => [entry.name, entry.status, entry.source]), [ + ['auth', 'spec_unavailable', 'aspire-cli'], + ['offline', 'not_running', 'appsettings'], + ['reused', 'identity_mismatch', 'aspire-cli'], + ['secret', 'excluded', 'override'], + ['users', 'running', 'override'], + ]); + const users = result.entries.find((entry) => entry.name === 'users'); + assert(users?.status === 'running'); + assertEquals(users.baseUrl, 'http://operator.example.test/users'); + assertEquals(users.conflicts, [ + { source: 'aspire-cli', baseUrl: 'http://127.0.0.1:4103' }, + { source: 'run-manifest', baseUrl: 'http://127.0.0.1:4104' }, + { source: 'appsettings', baseUrl: 'http://127.0.0.1:4105' }, + ]); + assertEquals(probed.sort(), ['auth', 'reused', 'users']); +}); + +Deno.test('directory isolates source rejection and probe rejection as explicit failed data', async () => { + const adapters = sourceAdapters({ + override: used('override', [candidate('healthy', 'override', 'http://example.test')]), + 'aspire-cli': absent('aspire-cli'), + 'run-manifest': absent('run-manifest'), + appsettings: absent('appsettings'), + }); + const rejectingAdapters = { + ...adapters, + 'aspire-cli': { read: () => Promise.reject(new Error('fixture source exploded')) }, + }; + const directory = createServiceEndpointDirectory({ + projectRoot: '/project', + sourceAdapters: rejectingAdapters, + probe: { probe: () => Promise.reject(new Error('fixture probe exploded')) }, + }); + const result = await directory.list(); + const aspire = result.sources[1]; + assert(aspire?.outcome === 'failed'); + assertEquals(aspire.code, 'source_failed'); + assertStringIncludes(aspire.reason, 'fixture source exploded'); + const row = result.entries[0]; + assert(row?.status === 'spec_unavailable'); + assertStringIncludes(row.reason, 'fixture probe exploded'); +}); + +Deno.test('one hanging row times out while healthy rows return under the concurrency cap', async () => { + let active = 0; + let maximumActive = 0; + const probe: ServiceEndpointProbePort = { + probe: async (entry, signal): Promise => { + active++; + maximumActive = Math.max(maximumActive, active); + try { + if (entry.name === 'hang') { + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + return { outcome: 'running', spec: { service: entry.name } }; + } finally { + active--; + } + }, + }; + const names = ['alpha', 'beta', 'hang', 'omega']; + const outcomes = { + override: used( + 'override', + names.map((name, index) => candidate(name, 'override', `http://127.0.0.1:${4200 + index}`)), + ), + 'aspire-cli': absent('aspire-cli'), + 'run-manifest': absent('run-manifest'), + appsettings: absent('appsettings'), + } satisfies Readonly>; + const result = await createServiceEndpointDirectory( + directoryOptions(outcomes, probe, { timeoutMs: 20, concurrency: 2 }), + ).list(); + assertEquals(result.entries.map((entry) => [entry.name, entry.status]), [ + ['alpha', 'running'], + ['beta', 'running'], + ['hang', 'spec_unavailable'], + ['omega', 'running'], + ]); + const hanging = result.entries[2]; + assert(hanging?.status === 'spec_unavailable'); + assertStringIncludes(hanging.reason, 'timed out after 20ms'); + assert(maximumActive <= 2); + assertEquals(active, 0); +}); + +Deno.test('one non-cooperative hanging spec fetch times out while another directory row returns', async () => { + const outcomes = { + override: used('override', [ + candidate('hang', 'override', 'http://127.0.0.1:4200'), + candidate('healthy', 'override', 'http://127.0.0.1:4201'), + ]), + 'aspire-cli': absent('aspire-cli'), + 'run-manifest': absent('run-manifest'), + appsettings: absent('appsettings'), + } satisfies Readonly>; + const fetchFixture: typeof fetch = (input) => { + const url = new URL(String(input)); + if (url.port === '4200') return new Promise(() => {}); + if (url.pathname === '/api/openapi.json') { + return Promise.resolve(Response.json({ openapi: '3.1.0', paths: {} })); + } + return Promise.resolve(Response.json({ service: 'healthy' })); + }; + + const result = await createServiceEndpointDirectory({ + projectRoot: '/project', + sourceAdapters: sourceAdapters(outcomes), + fetch: fetchFixture, + timeoutMs: 20, + concurrency: 2, + }).list(); + + assertEquals(result.entries.map((entry) => [entry.name, entry.status]), [ + ['hang', 'spec_unavailable'], + ['healthy', 'running'], + ]); + const hanging = result.entries[0]; + assert(hanging?.status === 'spec_unavailable'); + assertStringIncludes(hanging.reason, 'timed out after 20ms'); +}); + +Deno.test('parent cancellation rejects the directory instead of fabricating endpoint rows', async () => { + const outcomes = { + override: used('override', [candidate('slow', 'override', 'http://127.0.0.1:4200')]), + 'aspire-cli': absent('aspire-cli'), + 'run-manifest': absent('run-manifest'), + appsettings: absent('appsettings'), + } satisfies Readonly>; + const probe: ServiceEndpointProbePort = { + probe: (_entry, signal) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }), + }; + const controller = new AbortController(); + const pending = createServiceEndpointDirectory( + directoryOptions(outcomes, probe, { timeoutMs: 1_000 }), + ).list(controller.signal); + controller.abort(new DOMException('fixture cancelled', 'AbortError')); + + await assertRejects(() => pending, DOMException, 'fixture cancelled'); +}); + +Deno.test('fetch probe requests spec before identity without redirects or credentials', async () => { + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const fetchFixture: typeof fetch = (input, init) => { + const url = String(input); + calls.push({ url, init }); + if (url.endsWith('/api/openapi.json')) { + return Promise.resolve(Response.json({ openapi: '3.1.0', paths: {} })); + } + return Promise.resolve(Response.json({ service: 'users', version: '1.0.0' })); + }; + const result = await new FetchServiceEndpointProbe({ fetch: fetchFixture }).probe( + candidate('users', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(result, { outcome: 'running', spec: { openapi: '3.1.0', paths: {} } }); + assertEquals(calls.map((call) => call.url), [ + 'http://127.0.0.1:43127/api/openapi.json', + 'http://127.0.0.1:43127/', + ]); + for (const call of calls) { + assertEquals(call.init?.redirect, 'error'); + assertEquals(call.init?.credentials, 'omit'); + assertEquals(new Headers(call.init?.headers).has('authorization'), false); + } +}); + +Deno.test('fetch probe preserves a path-mounted operator base for spec and identity requests', async () => { + const calls: string[] = []; + const fetchFixture: typeof fetch = (input) => { + const url = String(input); + calls.push(url); + if (url.endsWith('/api/openapi.json')) { + return Promise.resolve(Response.json({ openapi: '3.1.0', paths: {} })); + } + return Promise.resolve(Response.json({ service: 'orders' })); + }; + const result = await new FetchServiceEndpointProbe({ fetch: fetchFixture }).probe( + candidate('orders', 'override', 'https://gateway.example.test/services/orders'), + new AbortController().signal, + ); + + assertEquals(result.outcome, 'running'); + assertEquals(calls, [ + 'https://gateway.example.test/services/orders/api/openapi.json', + 'https://gateway.example.test/services/orders/', + ]); +}); + +Deno.test('fetch probe maps spec failures and uses exact P3 guidance for 401 and 403', async () => { + for (const status of [401, 403]) { + const probe = new FetchServiceEndpointProbe({ + fetch: () => Promise.resolve(new Response('denied', { status })), + }); + const result = await probe.probe( + candidate('auth', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(result, { + outcome: 'spec_unavailable', + reason: `OpenAPI document returned HTTP ${status}.`, + httpStatus: status, + guidance: SPEC_UNAVAILABLE_AUTH_GUIDANCE, + }); + } + + const malformed = new FetchServiceEndpointProbe({ + fetch: () => Promise.resolve(new Response('{ broken', { status: 200 })), + }); + const malformedResult = await malformed.probe( + candidate('users', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(malformedResult.outcome, 'spec_unavailable'); + + const unreachable = new FetchServiceEndpointProbe({ + fetch: () => Promise.reject(new TypeError('tcp connect error: Connection refused')), + }); + const unreachableResult = await unreachable.probe( + candidate('users', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(unreachableResult.outcome, 'not_running'); +}); + +Deno.test('fetch probe maps reused-port identity and response bounds without projecting the spec', async () => { + const responses = [ + Response.json({ openapi: '3.1.0', opaqueFutureField: { kept: true } }), + Response.json({ service: 'foreign' }), + ]; + const mismatch = new FetchServiceEndpointProbe({ + fetch: () => Promise.resolve(responses.shift()!), + }); + const mismatchResult = await mismatch.probe( + candidate('users', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(mismatchResult.outcome, 'identity_mismatch'); + if (mismatchResult.outcome === 'identity_mismatch') { + assertStringIncludes(mismatchResult.reason, 'foreign'); + } + + const oversized = new FetchServiceEndpointProbe({ + responseByteLimit: 8, + fetch: () => Promise.resolve(new Response('{"openapi":"3.1.0"}')), + }); + const oversizedResult = await oversized.probe( + candidate('users', 'aspire-cli', 'http://127.0.0.1:43127'), + new AbortController().signal, + ); + assertEquals(oversizedResult.outcome, 'spec_unavailable'); + if (oversizedResult.outcome === 'spec_unavailable') { + assertStringIncludes(oversizedResult.reason, '8-byte limit'); + } +}); diff --git a/packages/mcp/tests/service-endpoint-source-fixtures.ts b/packages/mcp/tests/service-endpoint-source-fixtures.ts new file mode 100644 index 000000000..88da61d37 --- /dev/null +++ b/packages/mcp/tests/service-endpoint-source-fixtures.ts @@ -0,0 +1,31 @@ +/** Aspire 13.4.6-style describe output with banner noise and top-level resources. */ +export const ASPIRE_DESCRIBE_FIXTURE = `Aspire CLI 13.4.6 +{ + "resources": [ + { + "name": "users-yqkcrqst", + "displayName": "users", + "urls": [{ "name": "http", "url": "http://localhost:43127" }] + }, + { + "name": "billing-abcdefgh", + "urls": [{ "name": "http", "url": "http://127.0.0.2:43128" }] + }, + { + "name": "postgres-qqqqqqqq", + "displayName": "postgres", + "urls": [] + } + ] +}`; + +/** Shared service configuration with pinned, legacy, and dynamically allocated ports. */ +export const APPSETTINGS_FIXTURE = { + NetScript: { + Services: { + billing: { Runtime: 'deno', HostPort: 43128 }, + legacy: { Runtime: 'deno', Port: 43129 }, + users: { Runtime: 'deno' }, + }, + }, +}; diff --git a/packages/mcp/tests/service-endpoint-sources_test.ts b/packages/mcp/tests/service-endpoint-sources_test.ts new file mode 100644 index 000000000..d1c8ddf08 --- /dev/null +++ b/packages/mcp/tests/service-endpoint-sources_test.ts @@ -0,0 +1,227 @@ +import { assertEquals, assertStringIncludes } from '@std/assert'; +import type { SourceOutcome } from '../src/ports/service-endpoint-directory-port.ts'; +import { AppsettingsEndpointSource } from '../src/infrastructure/service-endpoints/appsettings-endpoint-source.ts'; +import { AspireCliEndpointSource } from '../src/infrastructure/service-endpoints/aspire-cli-endpoint-source.ts'; +import { OverrideEndpointSource } from '../src/infrastructure/service-endpoints/override-endpoint-source.ts'; +import { RunManifestEndpointSource } from '../src/infrastructure/service-endpoints/run-manifest-endpoint-source.ts'; +import { + APPSETTINGS_FIXTURE, + ASPIRE_DESCRIBE_FIXTURE, +} from './service-endpoint-source-fixtures.ts'; + +function missingFile(): Promise { + return Promise.reject(new Deno.errors.NotFound('fixture absent')); +} + +function jsonFile(value: unknown): (path: string) => Promise { + const text = JSON.stringify(value); + return () => Promise.resolve(text); +} + +function failedCode(outcome: SourceOutcome): string | undefined { + return outcome.outcome === 'failed' ? outcome.code : undefined; +} + +Deno.test('override source exposes used, absent, and failed outcomes without decoding sibling policy', async () => { + const projectRoot = '/project'; + assertEquals( + (await new OverrideEndpointSource({ readText: missingFile }).read({ projectRoot })).outcome, + 'absent', + ); + + const used = new OverrideEndpointSource({ + readText: jsonFile({ + endpointExecution: { enabled: true }, + futurePolicy: { preserved: true }, + introspection: { + serviceEndpoints: { + public: 'https://api.example.test/base/?ignored=yes#fragment', + users: 'http://127.0.0.1:43127/', + }, + excludeServices: ['secret', 'secret'], + futureIntrospectionPolicy: true, + }, + }), + }); + assertEquals(await used.read({ projectRoot }), { + source: 'override', + outcome: 'used', + candidates: [ + { + name: 'public', + baseUrl: 'https://api.example.test/base', + source: 'override', + operatorTrusted: true, + }, + { + name: 'users', + baseUrl: 'http://127.0.0.1:43127', + source: 'override', + operatorTrusted: true, + }, + ], + excludedServices: ['secret'], + }); + + const invalid = new OverrideEndpointSource({ + readText: () => Promise.resolve('{ "introspection": { "serviceEndpoints": [] } }'), + }); + assertEquals(failedCode(await invalid.read({ projectRoot })), 'invalid'); +}); + +Deno.test('appsettings source preserves unpinned services and distinguishes torn configuration', async () => { + const projectRoot = '/project'; + assertEquals( + (await new AppsettingsEndpointSource({ readText: missingFile }).read({ projectRoot })).outcome, + 'absent', + ); + + const used = new AppsettingsEndpointSource({ readText: jsonFile(APPSETTINGS_FIXTURE) }); + assertEquals(await used.read({ projectRoot }), { + source: 'appsettings', + outcome: 'used', + candidates: [ + { + name: 'billing', + baseUrl: 'http://127.0.0.1:43128', + source: 'appsettings', + operatorTrusted: false, + }, + { + name: 'legacy', + baseUrl: 'http://127.0.0.1:43129', + source: 'appsettings', + operatorTrusted: false, + }, + { name: 'users', source: 'appsettings', operatorTrusted: false }, + ], + excludedServices: [], + }); + + const torn = new AppsettingsEndpointSource({ readText: () => Promise.resolve('{ torn') }); + assertEquals(failedCode(await torn.read({ projectRoot })), 'invalid'); +}); + +Deno.test('run manifest requires real project identity and an expected current run id', async () => { + const projectRoot = '/project'; + assertEquals( + (await new RunManifestEndpointSource({ readText: missingFile }).read({ + projectRoot, + expectedRunId: 'run-1', + })).outcome, + 'absent', + ); + + const manifest = { + schemaVersion: 1, + projectRoot, + runId: 'run-1', + services: [{ service: 'users', endpoint: 'http', url: 'http://localhost:43127' }], + }; + const source = new RunManifestEndpointSource({ + readText: jsonFile(manifest), + realPath: (path) => Promise.resolve(path), + }); + assertEquals(failedCode(await source.read({ projectRoot })), 'expected_run_id_missing'); + assertEquals( + failedCode(await source.read({ projectRoot, expectedRunId: 'old-run' })), + 'run_id_mismatch', + ); + assertEquals(await source.read({ projectRoot, expectedRunId: 'run-1' }), { + source: 'run-manifest', + outcome: 'used', + candidates: [{ + name: 'users', + baseUrl: 'http://127.0.0.1:43127', + source: 'run-manifest', + operatorTrusted: false, + }], + excludedServices: [], + }); + + const foreign = new RunManifestEndpointSource({ + readText: jsonFile({ ...manifest, projectRoot: '/foreign' }), + realPath: (path) => Promise.resolve(path), + }); + assertEquals( + failedCode(await foreign.read({ projectRoot, expectedRunId: 'run-1' })), + 'project_root_mismatch', + ); +}); + +Deno.test('torn manifest remains failed while healthy appsettings remains independently usable', async () => { + const projectRoot = '/project'; + const manifest = await new RunManifestEndpointSource({ + readText: () => Promise.resolve('{ "runId":'), + }).read({ projectRoot, expectedRunId: 'run-1' }); + const appsettings = await new AppsettingsEndpointSource({ + readText: jsonFile(APPSETTINGS_FIXTURE), + }).read({ projectRoot }); + assertEquals(failedCode(manifest), 'invalid'); + assertEquals(appsettings.outcome, 'used'); + assertEquals(appsettings.candidates.length, 3); +}); + +Deno.test('Aspire CLI source uses the 13.4 machine query and parses banner-prefixed resources', async () => { + const calls: Array<{ command: string; args: readonly string[] }> = []; + const source = new AspireCliEndpointSource({ + execute: (command, args) => { + calls.push({ command, args }); + return Promise.resolve({ code: 0, stdout: ASPIRE_DESCRIBE_FIXTURE, stderr: '' }); + }, + }); + const outcome = await source.read({ + projectRoot: '/project', + appHostPath: '/project/apphost.mts', + }); + assertEquals(calls, [{ + command: 'aspire', + args: [ + 'describe', + '--apphost', + '/project/apphost.mts', + '--format', + 'Json', + '--non-interactive', + '--nologo', + ], + }]); + assertEquals(outcome, { + source: 'aspire-cli', + outcome: 'used', + candidates: [ + { + name: 'billing', + baseUrl: 'http://127.0.0.2:43128', + source: 'aspire-cli', + operatorTrusted: false, + }, + { + name: 'users', + baseUrl: 'http://127.0.0.1:43127', + source: 'aspire-cli', + operatorTrusted: false, + }, + ], + excludedServices: [], + }); +}); + +Deno.test('Aspire CLI absence, non-zero exit, and parse failure are explicit failed rows', async () => { + const projectRoot = '/fixture'; + const missing = await new AspireCliEndpointSource({ command: 'missing-aspire-fixture' }).read({ + projectRoot, + }); + assertEquals(failedCode(missing), 'command_not_found'); + + const nonZero = await new AspireCliEndpointSource({ + execute: () => Promise.resolve({ code: 12, stdout: '', stderr: 'dashboard unavailable' }), + }).read({ projectRoot }); + assertEquals(failedCode(nonZero), 'command_failed'); + if (nonZero.outcome === 'failed') assertStringIncludes(nonZero.reason, 'exited 12'); + + const malformed = await new AspireCliEndpointSource({ + execute: () => Promise.resolve({ code: 0, stdout: 'No running AppHost found', stderr: '' }), + }).read({ projectRoot }); + assertEquals(failedCode(malformed), 'parse_failed'); +});