Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,21 @@ v2/main/
│ │ # callback listener; also stripBrackets. Used across
│ │ # clients/web/server, clients/cli, and core/auth/node — #1795)
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
│ ├── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
│ └── uri/ # RFC 6570 URI Template discovery/expansion/preview
│ # (uriTemplate.ts) — wraps the SDK's UriTemplate, keeping
│ # its parse/operators/separators and correcting where it
│ # departs from the RFC: value encoding is done here
│ # against the RFC 3986 sets, each non-query expression is
│ # rewritten to a synthetic variable (so multi-name
│ # expressions encode, and a name repeated under different
│ # operators encodes per occurrence), and the shapes it
│ # mishandles (`{}`, `;`) are declined. Shared by the web
│ # ResourceTemplatePanel, the TUI's uriTemplateToForm,
│ # and InspectorClient.readResourceFromTemplate, so a
│ # template cannot resolve differently per client — #1919.
│ # Gated by the web coverage `include`; tests live in
│ # clients/web/src/test/core/uri/.
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
│ │ # Tasks extension runtime + tasks/* Express interceptor
Expand Down Expand Up @@ -666,7 +680,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab
- **The test tiers, shallowest first:** unit (`test`, per client) → web integration (`test:integration`, real transports/servers) → out-of-process (`clients/cli/__tests__/e2e.test.ts`, spawns the built binary) → smokes through the built launcher (`npm run smoke`) → Storybook play functions (`test:storybook`) → the published-tarball check (`npm run pack:verify`, local/release only — needs network). `validate` runs the per-client `test` scripts — so web **unit** plus cli's out-of-process `e2e.test.ts` (it's part of cli's `test`), but **not** web's integration project, which runs inside the `coverage` gate. Everything from `smoke` rightward is `npm run ci` only, and is described under [Mandatory pre-push gate](#mandatory-pre-push-gate).
- The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/<name> && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration.
- **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate.
- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. The web coverage `include` (in `clients/web/vite.config.ts`) covers the shared `core/` runtime consumed by the browser — `core/mcp`, `core/react`, `core/auth`, `core/storage`, `core/logging`, `core/node`, **`core/json`, and `core/client`** (the last two folded in by #1689). When adding a `core/json/*` or `core/client/*` module, its tests live under `clients/web/src/test/core/…` and are gated the same ≥90 way.
- Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. The web coverage `include` (in `clients/web/vite.config.ts`) covers the shared `core/` runtime consumed by the browser — `core/mcp`, `core/react`, `core/auth`, `core/storage`, `core/logging`, `core/node`, **`core/json`, and `core/client`** (the last two folded in by #1689), plus **`core/uri`** (#1919). When adding a `core/json/*`, `core/client/*`, or `core/uri/*` module, its tests live under `clients/web/src/test/core/…` and are gated the same ≥90 way.
- The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web:
- **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker.
- **TUI** (`clients/tui`): the gate now covers **all of `src/**`, React surface included** — the former interim exclusion of the Ink components, `App.tsx`, and `hooks/` was lifted in #1501. Components mount through `ink-testing-library` with the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`, `App.tsx` mounts against a controllable mock of the `@inspector/core` surface, and keypresses are driven through stdin. The **only** coverage exclusion left in `clients/tui/vitest.config.ts` is `src/tui-servers.ts` — a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured in `core/` via the web suite; `tui-servers.test.ts` still exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic under `clients/tui/src`, React or not, is held to the gate automatically.
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ inspector/
│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import
│ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection)
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers for the OAuth persist backends
│ ├── storage/ # File I/O helpers for the OAuth persist backends
│ └── uri/ # RFC 6570 URI Template discovery/expansion/preview, shared by all clients
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests
├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify)
├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan)
Expand Down Expand Up @@ -147,6 +148,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) |
| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) |
| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) |
| `rfc6570-templates-http.json` | Resources tab: RFC 6570 template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) |
| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) |
| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
Expand Down Expand Up @@ -237,6 +239,22 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a **

The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render.

#### RFC 6570 resource templates

`rfc6570-templates-http.json` serves the two templates from [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) (preset `rfc6570_templates`): `foobar://events/{topic}` (simple expression) and `foobar://events{?topic}` (query expression). Each echoes back the `topic` it received and the URI that matched. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.

Open the Resources tab and select **events-by-path**, enter `foo/bar` for `topic`, and read it: the preview and the `resources/read` request must both show `foobar://events/foo%2Fbar`. On the broken build the web client substituted the value verbatim, producing `foobar://events/foo/bar` — a second path segment, which a spec-compliant matcher rejects with `-32602 Resource not found` (this server does exactly that, so the failure is visible rather than silent).

Then select **events-by-query**: it must render a `topic` input at all. The old scan was `/\{(\w+)\}/g`, which sees only bare `{name}` expressions, so a query expression declared a variable the form never offered.

All three clients now go through one shared helper, [`core/uri/uriTemplate.ts`](./core/uri/uriTemplate.ts) — the web panel, the TUI's form builder, and `InspectorClient.readResourceFromTemplate` — so a template cannot resolve differently depending on where it is driven from.

It wraps the SDK's `UriTemplate`, keeping what that gets right (the parse, the operators and separators, which expressions appear at all) and correcting where it departs from RFC 6570:

- **Value encoding** is done against the explicit RFC 3986 character sets. The SDK's `encodeURIComponent` leaves the sub-delimiters `!*'()` bare, its `encodeURI` escapes the gen-delims `[` and `]` that reserved expansion exists to pass through, and it double-encodes an existing percent triplet (`%41` → `%2541`).
- **Multi-name expressions** (`{a,b}`) take an SDK branch that skips both encoding and the operator, and a **name repeated under different operators** (`{+a}-{a}`) has to encode differently per occurrence — which values looked up by name cannot express. Each non-query expression is rewritten to its own synthetic variable so both work.
- Two shapes the SDK accepts but mishandles — an expression declaring no variable (`{}`), and the unimplemented `;` path-parameter operator — are **declined** rather than expanded into a knowingly invalid URI, so the panel withholds the request instead of sending it.

#### Advertised extensions

`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`.
Expand Down
21 changes: 16 additions & 5 deletions clients/tui/__tests__/uriTemplateToForm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ describe("uriTemplateToForm", () => {
});

it("logs and returns an empty form when the template cannot be parsed", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// The shared core/uri helper warns and yields no names; this file no longer
// does its own try/catch, so the assertion is on that warning (#1919).
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const form = uriTemplateToForm("file:///{unclosed", "broken");

expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse URI template:",
expect.any(Error),
);
expect(warnSpy).toHaveBeenCalled();
expect(form.sections[0]!.fields).toEqual([]);
});

// Shared with the web panel's field list: the old scan saw only bare
// `{name}` expressions, and a repeated name produced two identical fields.
it("creates a field for a variable inside a query expression", () => {
const form = uriTemplateToForm("foobar://events{?topic}", "events");
expect(form.sections[0]!.fields.map((f) => f.name)).toEqual(["topic"]);
});

it("creates one field for a name repeated across expressions", () => {
const form = uriTemplateToForm("x://{a}/{b}/{a}", "repeat");
expect(form.sections[0]!.fields.map((f) => f.name)).toEqual(["a", "b"]);
});
});
36 changes: 13 additions & 23 deletions clients/tui/src/utils/uriTemplateToForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import type { FormStructure, FormSection, FormField } from "ink-form";
import { UriTemplate } from "@modelcontextprotocol/client";
import { templateVariableNames } from "@inspector/core/uri/uriTemplate.js";

/**
* Converts a URI Template to ink-form structure
Expand All @@ -12,28 +12,18 @@ export function uriTemplateToForm(
uriTemplate: string,
templateName: string,
): FormStructure {
const fields: FormField[] = [];

try {
const template = new UriTemplate(uriTemplate);
/* v8 ignore next -- UriTemplate.variableNames is a getter that always
returns a string[]; the `|| []` fallback is an unreachable guard. */
const variableNames = template.variableNames || [];

for (const variableName of variableNames) {
const field: FormField = {
name: variableName,
label: variableName,
type: "string",
required: false, // URI template variables are typically optional
};

fields.push(field);
}
} catch (error) {
// If parsing fails, return empty form
console.error("Failed to parse URI template:", error);
}
// Shared with the web panel's field list (#1919), so the two clients offer
// the same inputs for a given template — including the variables inside
// non-simple expressions, and one field (not two) for a repeated name. It
// does not throw: a malformed template yields no names, so the form is empty.
const fields: FormField[] = templateVariableNames(uriTemplate).map(
(variableName) => ({
name: variableName,
label: variableName,
type: "string",
required: false, // URI template variables are typically optional
}),
);

const sections: FormSection[] = [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ export const WithAnnotations: Story = {
},
};

/**
* An RFC 6570 query expression. The variable lives inside `{?…}` rather than a
* bare `{…}`, so it only produces an input once discovery goes through a real
* RFC 6570 parser (#1919); the preview shows where the value lands in the
* query string.
*/
export const QueryExpression: Story = {
args: {
template: {
name: "Events",
uriTemplate: "foobar://events{?topic}",
description:
"Filter the event stream by topic. The value is percent-encoded into the query string.",
},
},
};

export const NoDescription: Story = {
args: {
template: {
Expand Down
Loading