Skip to content
Merged
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
7 changes: 7 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--tool-metadata <key=value>` | Tool-specific `_meta` entries for `tools/call`. Same JSON-parsed value handling as `--metadata`. |
| `--connect-timeout <ms>` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level `connectionTimeout` for `--catalog`/`--config` runs — `30000` when the file sets none. `0` disables the timeout. |
| `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name <name>`: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. |
| `--advertise-apps` | Advertise the MCP Apps UI extension (`io.modelcontextprotocol/ui`) at `initialize`. Off by default, because the CLI cannot render an App and a server decides whether to return one from that advertisement. Set it when a server only exposes its App tools to a client that claims App support — typically alongside `--app-info`. |
| `--strict` | With `--method tools/list`: report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit `6` if any is error-severity. Without it, a one-line count is printed instead. See [Schema portability](#schema-portability---strict). |
| `--verify` | With `--method skills/list` or `--method skills/get`: run the SEP-2640 conformance, digest and frontmatter checks over the skills returned, emit one JSON report per skill on stdout, and exit `7` if any fails. See [Skill verification](#skill-verification---verify). |
| `--require-digests` | With `--verify`: exit `9` when a skill advertises no digests (`resources: "dynamic"`), instead of reporting it `unverifiable` and exiting `0`. See [Skill verification](#skill-verification---verify). |
Expand Down Expand Up @@ -161,6 +162,12 @@ mcp-inspector --cli <server> --method tools/call --tool-name my_tool --app-info
mcp-inspector --cli <server> --method tools/list --app-info | jq -c 'select(.hasApp)'
```

The CLI does **not** advertise the MCP Apps UI extension by default, since it cannot render an App. A server that registers its App tools only for a client that advertises Apps support will therefore show no app to a bare probe; add `--advertise-apps` to claim that support for the probe:

```bash
mcp-inspector --cli <server> --method tools/list --app-info --advertise-apps
```

Exit semantics: a tool that **has** an app exits `0`; one with **no** app exits `2` (`no_app`); a **missing** tool exits `5` (`tool_not_found`) — distinct so a typo isn't mistaken for "no app". A probe failure (an unreadable UI resource, or a malformed `_meta.ui.resourceUri`) is tolerated and reported in a `resourceError` field rather than aborting — so in `tools/list --app-info` one bad tool never kills the rest of the listing.

`--format json` wraps any method's output in a single stdout envelope with no banners, so App tools and plain tools both pipe cleanly into `jq`:
Expand Down
71 changes: 70 additions & 1 deletion clients/cli/__tests__/app-info.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it, expect } from "vitest";
import { runCli } from "./helpers/cli-runner.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import { runCli as runCliInProcess } from "../src/cli.js";
import {
createEchoTool,
createTestServerHttp,
createTestServerInfo,
getTestMcpServerCommand,
} from "@modelcontextprotocol/inspector-test-server";

/**
* The default stdio test server advertises exactly one MCP App tool
Expand Down Expand Up @@ -153,3 +159,66 @@ describe("--app-info", () => {
expect(result.output).not.toContain("isError");
});
});

/**
* The CLI cannot render an MCP App, so it must not claim the UI extension by
* default (#2403) — a server decides whether to expose its App tools from that
* advertisement. `--advertise-apps` is the explicit opt-in. Observed from the
* server side: a tool gated on `io.modelcontextprotocol/ui` is listed only when
* the client declared it at `initialize`. A fresh server per case, because the
* gate only ever enables the tool.
*/
describe("--advertise-apps (#2403)", () => {
const UI_EXTENSION = "io.modelcontextprotocol/ui";

async function listToolNames(extraArgs: string[]): Promise<string[]> {
const server = createTestServerHttp({
serverInfo: createTestServerInfo(),
tools: [createEchoTool()],
extensionGatedTools: { [UI_EXTENSION]: "echo" },
});
try {
await server.start();
const result = await runCli([
server.url,
"--cli",
"--method",
"tools/list",
"--transport",
"http",
"--format",
"json",
...extraArgs,
]);
expect(result.exitCode).toBe(0);
const parsed = JSON.parse(result.stdout) as {
result: { tools: { name: string }[] };
};
return parsed.result.tools.map((t) => t.name);
} finally {
await server.stop();
}
}

it("does not advertise the UI extension by default", async () => {
expect(await listToolNames([])).not.toContain("echo");
});

it("advertises the UI extension with --advertise-apps", async () => {
expect(await listToolNames(["--advertise-apps"])).toContain("echo");
});

it.each([
["servers/list", ["--method", "servers/list"]],
["servers/show", ["--method", "servers/show", "--server", "x"]],
["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]],
["--print-handoff", ["--method", "servers/list", "--print-handoff"]],
])(
"is rejected on the %s short-circuit path, which never connects",
async (_label, extra) => {
await expect(
runCliInProcess(["node", "cli", "--cli", "--advertise-apps", ...extra]),
).rejects.toThrow("--advertise-apps requires a command that connects");
},
);
});
29 changes: 29 additions & 0 deletions clients/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { writeFormattedResult } from "./handlers/format-output.js";
import { clearStoredAuthForRelogin } from "./clear-stored-auth-for-relogin.js";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import { cleanRoots } from "@inspector/core/mcp/serverList.js";
import { UI_EXTENSION_KEY } from "@inspector/core/mcp/extensions.js";
import {
createProxyFetch,
createTransportNode,
Expand Down Expand Up @@ -206,6 +207,13 @@ async function callMethod(
...(serverSettings?.protocolEra && {
versionNegotiation: eraToVersionNegotiation(serverSettings.protocolEra),
}),
// The CLI cannot render an MCP App, so it does not advertise the UI
// extension by default (#2403). `--advertise-apps` claims it explicitly,
// for a server that only exposes its App tools to a client that does —
// which is what an `--app-info` probe against such a server needs.
...(args.advertiseApps && {
advertisedExtensions: { [UI_EXTENSION_KEY]: true },
}),
...clientAuthOptions,
});

Expand Down Expand Up @@ -756,6 +764,10 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
"--app-info",
"Probe the tool's MCP App UI metadata (resourceUri, csp, permissions, domain) and emit it as one JSON line; exit 2 when the tool has no app. Use with --method tools/call --tool-name <name> (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).",
)
.option(
"--advertise-apps",
"Advertise the MCP Apps UI extension (io.modelcontextprotocol/ui) at initialize. Off by default because the CLI cannot render an App; set it when a server only exposes its App tools to a client that claims App support, e.g. for an --app-info probe.",
)
.option(
"--strict",
"Report tool-schema portability problems in full (path, issue, suggested fix) on stderr, and exit 6 if any is error-severity. Use with --method tools/list. Without it, a one-line count is printed instead.",
Expand Down Expand Up @@ -877,6 +889,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
serverUrl?: string;
header?: Record<string, string>;
appInfo?: boolean;
advertiseApps?: boolean;
strict?: boolean;
verify?: boolean;
requireDigests?: boolean;
Expand Down Expand Up @@ -970,6 +983,21 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
throw new Error("--require-digests requires --verify.");
}

// `--advertise-apps` is checked here for the same reason: it shapes the
// `initialize` handshake, and the short-circuit paths below never open an
// MCP connection, so accepting it there would silently ignore it.
if (
options.advertiseApps &&
(options.listStoredAuth ||
options.printHandoff ||
options.method === "servers/list" ||
options.method === "servers/show")
) {
throw new Error(
"--advertise-apps requires a command that connects to a server; it has no effect with --list-stored-auth, --print-handoff, or --method servers/list / servers/show.",
);
}

// State-path precedence (getStateFilePath): MCP_INSPECTOR_OAUTH_STATE_PATH →
// <MCP_STORAGE_DIR>/oauth.json → ~/.mcp-inspector/storage/oauth.json — the
// same file the web backend writes, so tokens are shared across surfaces.
Expand Down Expand Up @@ -1200,6 +1228,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
metadata: options.metadata,
toolMeta: options.toolMetadata,
appInfo: options.appInfo === true,
advertiseApps: options.advertiseApps === true,
strict: options.strict === true,
verify: options.verify === true,
requireDigests: options.requireDigests === true,
Expand Down
7 changes: 7 additions & 0 deletions clients/cli/src/handlers/method-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export type MethodArgs = {
toolMeta?: RequestMetadata;
metadata?: RequestMetadata;
appInfo?: boolean;
/**
* `--advertise-apps`: advertise the MCP Apps UI extension
* (`io.modelcontextprotocol/ui`) at `initialize`. The CLI cannot render an
* App, so it does not claim the extension by default; this opts in for a
* server that only exposes its App tools to a client that does (#2403).
*/
advertiseApps?: boolean;
/**
* `--strict`: report tool-schema portability findings in full and exit
* non-zero when any is error-severity (#1005). `tools/list` only.
Expand Down
3 changes: 3 additions & 0 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2000,6 +2000,9 @@ function App() {
? protocolEra
: undefined
}
// Apps render only with a sandbox, and the client claims the UI
// extension by default only then (#2403); the toggle must agree.
rendersApps={sandboxUrl !== undefined}
onClose={onSettingsModalClose}
onSettingsChange={onSettingsChange}
onClearStoredOAuth={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,49 @@ describe("ServerSettingsForm", () => {
expect(tasks).toBeChecked();
});

it.each([
[true, true],
[false, false],
])(
"with rendersApps=%s, shows the MCP Apps UI toggle checked=%s by default (#2403)",
(rendersApps, checked) => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={emptySettings}
rendersApps={rendersApps}
expandedSections={["extensions"]}
/>,
);
const ui = screen.getByRole("checkbox", {
name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/,
});
// With no App renderer the client does not declare the extension, so
// the toggle must not claim it does.
if (checked) expect(ui).toBeChecked();
else expect(ui).not.toBeChecked();
},
);

it("shows an explicit UI override as checked even without a renderer (#2403)", () => {
renderWithMantine(
<ServerSettingsForm
{...baseHandlers}
settings={{
...emptySettings,
advertisedExtensions: { "io.modelcontextprotocol/ui": true },
}}
rendersApps={false}
expandedSections={["extensions"]}
/>,
);
expect(
screen.getByRole("checkbox", {
name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/,
}),
).toBeChecked();
});

it("reflects an advertisedExtensions override that disables Tasks", () => {
renderWithMantine(
<ServerSettingsForm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ import {
isReservedAuthorizationParam,
} from "@inspector/core/auth/authorizationParams.js";
import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js";
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
import {
ADVERTISABLE_EXTENSIONS,
type AdvertisableExtension,
isAdvertisedByDefault,
} from "@inspector/core/mcp/extensions.js";
import {
isSkillCatalogLimit,
resolveSkillCatalogBudget,
Expand All @@ -46,15 +50,19 @@ import type { Root } from "@modelcontextprotocol/client";

/**
* Resolve the advertised state of an extension for the form: the per-server
* override wins, else the registry default. Mirrors `buildClientExtensions`'s
* resolution so the switches show exactly what the client will advertise.
* override wins, else the renderer-aware registry default. Mirrors
* `buildClientExtensions`'s resolution so the switches show exactly what the
* client will advertise (#2403).
*/
function isExtensionAdvertised(
settings: InspectorServerSettings,
key: string,
defaultAdvertised: boolean,
ext: AdvertisableExtension,
rendersApps: boolean,
): boolean {
return settings.advertisedExtensions?.[key] ?? defaultAdvertised;
return (
settings.advertisedExtensions?.[ext.key] ??
isAdvertisedByDefault(ext, rendersApps)
);
}

export type ServerSettingsSection =
Expand Down Expand Up @@ -112,6 +120,13 @@ export interface ServerSettingsFormProps {
* `settings.advertisedExtensions`. (#1739)
*/
onAdvertisedExtensionChange: (key: string, checked: boolean) => void;
/**
* Whether this web session can render MCP Apps — true when the backend
* supplied a sandbox URL. Decides the default position of any extension that
* requires an App renderer (the MCP Apps UI extension), so the toggle shows
* what the client will actually declare (#2403). Defaults to true.
*/
rendersApps?: boolean;
onMaxFetchRequestsChange: (value: number) => void;
/**
* Set one skills verification budget limit. Only ever called with a positive
Expand Down Expand Up @@ -483,6 +498,7 @@ export function ServerSettingsForm({
onPaginatedListsChange,
onSuppressNotificationStreamChange,
onAdvertisedExtensionChange,
rendersApps = true,
onMaxFetchRequestsChange,
onSkillCatalogLimitChange,
onProtocolEraChange,
Expand Down Expand Up @@ -764,11 +780,7 @@ export function ServerSettingsForm({
<Checkbox
key={ext.key}
label={ext.label}
checked={isExtensionAdvertised(
settings,
ext.key,
ext.defaultAdvertised,
)}
checked={isExtensionAdvertised(settings, ext, rendersApps)}
onChange={(e) =>
onAdvertisedExtensionChange(ext.key, e.currentTarget.checked)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,37 @@ describe("ServerSettingsModal", () => {
);
});

it("persists a true UI override when checked without an App renderer (#2403)", async () => {
// With no sandbox the UI extension defaults OFF, so checking it is a real
// override — it must be written, not dropped as "back to the default".
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
rendersApps={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("button", { name: "Advertised Extensions" }),
);
await user.click(
screen.getByRole("checkbox", {
name: /MCP Apps UI \(io\.modelcontextprotocol\/ui\)/,
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({
advertisedExtensions: { "io.modelcontextprotocol/ui": true },
}),
);
});

it("hides the modern log-level control when this server negotiated legacy under 'auto' (#1629)", () => {
renderWithMantine(
<ServerSettingsModal
Expand Down
Loading
Loading