Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
@@ -1,7 +1,11 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import {
renderWithMantine,
screen,
fireEvent,
} from "../../../test/renderWithMantine";
import { ResourceTemplatePanel } from "./ResourceTemplatePanel";

const singleVarTemplate: ResourceTemplate = {
Expand All @@ -27,6 +31,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 +121,82 @@ 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();
});

// The SDK refuses a value past its 1,000,000-character ceiling at expansion
// time, and the input has no matching limit. Withhold the request rather than
// send a URI we know is wrong.
it("keeps Read Resource disabled when the value cannot be expanded", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const onReadResource = vi.fn();
renderWithMantine(
<ResourceTemplatePanel
template={simpleVarTemplate}
onReadResource={onReadResource}
/>,
);
// fireEvent, not user.type — typing a million characters key by key would
// take longer than the suite's timeout.
fireEvent.change(screen.getByLabelText("topic"), {
target: { value: "z".repeat(1_000_001) },
});
expect(
screen.getByRole("button", { name: "Read Resource" }),
).toBeDisabled();
expect(onReadResource).not.toHaveBeenCalled();
warn.mockRestore();
});

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 @@ -232,13 +209,21 @@ export function ResourceTemplatePanel({
void runCompletion(varName, value, buildContext(varName));
}

const canSubmit = variableNames.every((n) => variables[n]?.length > 0);
const allFilled = variableNames.every((n) => variables[n]?.length > 0);
// `null` means the template or a value could not be expanded (a malformed
// template, or a value past the SDK's length ceiling). Withhold the request
// rather than send a URI we know is wrong.
const expandedUri = allFilled ? expandTemplate(uriTemplate, variables) : null;
const canSubmit = expandedUri !== null;

function handleSubmit() {
onReadResource(resolveUri(uriTemplate, variables));
/* v8 ignore next -- unreachable: the button is disabled unless
`expandedUri` is non-null. */
if (expandedUri === null) return;
onReadResource(expandedUri);
}

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

return (
<Stack gap="md">
Expand Down
Loading