Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,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 +238,16 @@ 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.

Both surfaces now go through the SDK's `UriTemplate` — the same RFC 6570 implementation the TUI's form builder and `InspectorClient.readResourceFromTemplate` already used — so web, CLI, and TUI agree on a template's variables and on how a value is encoded.

#### 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
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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ const noVarTemplate: ResourceTemplate = {
uriTemplate: "file:///static.txt",
};

// #1919: a query expression declares a variable the old `/\{(\w+)\}/g` scan
// could not see, so it rendered no input at all.
const queryVarTemplate: ResourceTemplate = {
name: "Events",
uriTemplate: "foobar://events{?topic}",
};

const simpleVarTemplate: ResourceTemplate = {
name: "Events",
uriTemplate: "foobar://events/{topic}",
};

describe("ResourceTemplatePanel", () => {
it("renders the template title (or name) and description", () => {
renderWithMantine(
Expand Down Expand Up @@ -105,6 +117,58 @@ describe("ResourceTemplatePanel", () => {
expect(screen.getByText("file:///users/bob/profile")).toBeInTheDocument();
});

it("renders an input for a variable declared by a query expression", () => {
renderWithMantine(
<ResourceTemplatePanel
template={queryVarTemplate}
onReadResource={vi.fn()}
/>,
);
expect(screen.getByLabelText("topic")).toBeInTheDocument();
});

it("expands a query expression per RFC 6570 when submitted", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={queryVarTemplate}
onReadResource={onReadResource}
/>,
);
await user.type(screen.getByLabelText("topic"), "weather");
await user.click(screen.getByRole("button", { name: "Read Resource" }));
expect(onReadResource).toHaveBeenCalledWith(
"foobar://events?topic=weather",
);
});

it("percent-encodes a reserved character rather than emitting a new path segment", async () => {
const user = userEvent.setup();
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={simpleVarTemplate}
onReadResource={onReadResource}
/>,
);
await user.type(screen.getByLabelText("topic"), "foo/bar");
await user.click(screen.getByRole("button", { name: "Read Resource" }));
expect(onReadResource).toHaveBeenCalledWith("foobar://events/foo%2Fbar");
});

it("previews the encoded value, not the raw input", async () => {
const user = userEvent.setup();
renderWithMantine(
<ResourceTemplatePanel
template={simpleVarTemplate}
onReadResource={vi.fn()}
/>,
);
await user.type(screen.getByLabelText("topic"), "a b");
expect(screen.getByText("foobar://events/a%20b")).toBeInTheDocument();
});

it("clears a variable via its Clear button (non-autocomplete branch)", async () => {
const user = userEvent.setup();
renderWithMantine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { useValueChange } from "../../../hooks/useValueChange";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import {
expandTemplate,
previewTemplate,
templateVariableNames,
} from "../../../utils/uriTemplate";

export interface ResourceTemplatePanelProps {
template: ResourceTemplate;
Expand All @@ -39,34 +44,6 @@ export interface ResourceTemplatePanelProps {

const COMPLETION_DEBOUNCE_MS = 300;

function parseVariableNames(uriTemplate: string): string[] {
const names: string[] = [];
const regex = /\{(\w+)\}/g;
let match: RegExpExecArray | null;

while ((match = regex.exec(uriTemplate)) !== null) {
names.push(match[1]);
}

return names;
}

function resolveUri(
uriTemplate: string,
variables: Record<string, string>,
): string {
return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]);
}

function previewUri(
uriTemplate: string,
variables: Record<string, string>,
): string {
return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) =>
variables[key]?.length > 0 ? variables[key] : match,
);
}

const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
Expand Down Expand Up @@ -109,7 +86,7 @@ export function ResourceTemplatePanel({
const { name, title, uriTemplate, description, annotations } = template;

const variableNames = useMemo(
() => parseVariableNames(uriTemplate),
() => templateVariableNames(uriTemplate),
[uriTemplate],
);

Expand Down Expand Up @@ -235,10 +212,10 @@ export function ResourceTemplatePanel({
const canSubmit = variableNames.every((n) => variables[n]?.length > 0);

function handleSubmit() {
onReadResource(resolveUri(uriTemplate, variables));
onReadResource(expandTemplate(uriTemplate, variables));
}

const preview = previewUri(uriTemplate, variables);
const preview = previewTemplate(uriTemplate, variables);

return (
<Stack gap="md">
Expand Down
155 changes: 155 additions & 0 deletions clients/web/src/test/integration/mcp/rfc6570-templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect, afterEach } from "vitest";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import {
expandTemplate,
templateVariableNames,
} from "../../../utils/uriTemplate";
import {
createTestServerHttp,
type TestServerHttp,
createTestServerInfo,
loadConfig,
resolveConfig,
} from "@modelcontextprotocol/inspector-test-server";

/**
* Live coverage of `test-servers/configs/rfc6570-templates-http.json` — the
* documented manual reproduction for #1919.
*
* The helper's unit tests assert what `expandTemplate` *produces*; they cannot
* assert that the produced URI is what a spec-compliant server *accepts*. That
* second half is the whole bug: the old string substitution emitted a URI the
* Inspector was perfectly happy with and the server rejected. So this test
* drives both directions against a real server over a real transport — the
* encoded URI must resolve, and the unencoded one the old code produced must
* still be refused, so a regression cannot pass by loosening the server.
*
* The server is built by **resolving the checked-in config** rather than by
* calling the fixture factory, so a misspelt preset name in `preset-registry.ts`
* (or a config naming a preset that no longer exists) fails here instead of
* only when someone runs the repro by hand.
*/
describe("RFC 6570 resource templates over the wire (#1919)", () => {
let client: InspectorClient | null = null;
let server: TestServerHttp | null = null;

const configPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../../../test-servers/configs/rfc6570-templates-http.json",
);

afterEach(async () => {
if (client) {
try {
await client.disconnect();
} catch {
// ignore
}
client = null;
}
if (server) {
try {
await server.stop();
} catch {
// ignore
}
server = null;
}
});

/**
* Boot the showcase config. The harness picks the port rather than using the
* config's fixed one, so this cannot collide with a showcase server someone
* is running by hand.
*/
async function connectToShowcase(): Promise<InspectorClient> {
const resolved = resolveConfig(loadConfig(configPath));
const started = createTestServerHttp({
serverInfo: createTestServerInfo("rfc6570-templates-test", "1.0.0"),
resourceTemplates: resolved.resourceTemplates,
});
await started.start();
server = started;

const connected = new InspectorClient(
{ type: "streamable-http", url: started.url },
{ environment: { transport: createTransportNode } },
);
await connected.connect();
client = connected;
return connected;
}

/**
* Read the sole content block as JSON. `contents[]` is a text-or-blob union,
* so narrow rather than cast — a fixture that started returning a blob should
* fail here with a clear message, not at `JSON.parse(undefined)`.
*/
async function readJson(
connected: InspectorClient,
uri: string,
): Promise<unknown> {
const { result } = await connected.readResource(uri);
const [content] = result.contents;
expect(content).toBeDefined();
if (!("text" in content)) {
throw new Error(`expected a text content block for ${uri}`);
}
return JSON.parse(content.text);
}

it("resolves the preset the config names", () => {
const resolved = resolveConfig(loadConfig(configPath));
expect(resolved.resourceTemplates?.map((t) => t.uriTemplate)).toEqual([
"foobar://events/{topic}",
"foobar://events{?topic}",
]);
});

it("advertises both templates, including the query expression", async () => {
const connected = await connectToShowcase();
const { resourceTemplates } = await connected.listAllResourceTemplates();
const byName = Object.fromEntries(
resourceTemplates.map((t) => [t.name, t.uriTemplate]),
);
expect(byName["events-by-path"]).toBe("foobar://events/{topic}");
expect(byName["events-by-query"]).toBe("foobar://events{?topic}");
});

it("discovers a variable in each expression form", () => {
expect(templateVariableNames("foobar://events/{topic}")).toEqual(["topic"]);
expect(templateVariableNames("foobar://events{?topic}")).toEqual(["topic"]);
});

it("reads a reserved-character value through the simple expression", async () => {
const connected = await connectToShowcase();
const uri = expandTemplate("foobar://events/{topic}", { topic: "foo/bar" });
expect(uri).toBe("foobar://events/foo%2Fbar");

expect(await readJson(connected, uri)).toEqual({
topic: "foo%2Fbar",
matchedUri: uri,
});
});

it("reads through the query expression", async () => {
const connected = await connectToShowcase();
const uri = expandTemplate("foobar://events{?topic}", { topic: "weather" });
expect(uri).toBe("foobar://events?topic=weather");

expect(await readJson(connected, uri)).toMatchObject({ topic: "weather" });
});

// The old behavior, pinned from the server's side. If this ever starts
// succeeding, the repro server has stopped reproducing and the test above
// would keep passing while proving nothing.
it("rejects the unencoded URI the old string substitution produced", async () => {
const connected = await connectToShowcase();
await expect(
connected.readResource("foobar://events/foo/bar"),
).rejects.toThrow(/not found/i);
});
});
Loading