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
28 changes: 28 additions & 0 deletions clients/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,34 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
| `--metadata <key=value>` | General metadata (key=value); applied to all methods. |
| `--tool-metadata <key=value>` | Tool-specific metadata for `tools/call`. |
| `--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 timeout for `--catalog`/`--config` runs. `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. |
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |

#### App probing (`--app-info`) and machine-readable output (`--format json`)

`--app-info` inspects a tool's [MCP App](https://modelcontextprotocol.io) UI posture **without calling the tool**, so a pipeline can decide whether to open a browser before touching one:

```bash
# Probe one tool. Exits 0 (has app) or 2 (no_app). One JSON line on stdout.
mcp-inspector --cli <server> --method tools/call --tool-name my_tool --app-info
# → {"hasApp":true,"toolName":"my_tool","resourceUri":"ui://…","csp":{…},"permissions":{…},"prefersBorder":true,"resourceMimeType":"text/html"}

# Probe every tool at once — NDJSON, one line per tool, single connection.
mcp-inspector --cli <server> --method tools/list --app-info | jq -c 'select(.hasApp)'
```

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`:

```bash
mcp-inspector --cli <server> --method tools/call --tool-name my_app_tool --format json
# → {"result":{…tool result…},"appInfo":{"hasApp":true,"resourceUri":"ui://…",…}}
```

> `tools/list --app-info` always emits NDJSON (one raw app-info object per line) **regardless of `--format`** — the per-tool list shape is fixed. `--format json` only reshapes the single-result paths (`tools/call`, `tools/list` without `--app-info`, etc.) into the `{result[, appInfo]}` envelope.

A `tools/call` that returns `isError:true` still prints its payload but exits `5` (`tool_is_error`) so `&&` chains don't proceed on a failed call.

### CLI-specific (OAuth for HTTP servers)

Expand Down
155 changes: 155 additions & 0 deletions clients/cli/__tests__/app-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import { runCli } from "./helpers/cli-runner.js";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";

/**
* The default stdio test server advertises exactly one MCP App tool
* (`mcp_app_demo` + its `mcp_app_demo_widget` UI resource). These cover both
* the positive probe (csp/permissions/exit-0) and the negative path
* (`{hasApp:false}` + exit 2), plus the exit-5 tool-not-found distinction and
* the NDJSON `tools/list --app-info` shape. The `extractAppInfo` unit is
* covered in the web core suite; here we exercise the CLI wiring end-to-end.
*/
describe("--app-info", () => {
it("rejects --app-info on a method other than tools/call or tools/list", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"resources/list",
"--app-info",
]);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
"--app-info requires --method tools/call (with --tool-name) or --method tools/list",
);
});

it("emits NDJSON (one app-info line per tool) on --method tools/list --app-info", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/list",
"--app-info",
]);
expect(result.exitCode).toBe(0);
const lines = result.stdout.trim().split("\n");
expect(lines.length).toBeGreaterThan(1);
const infos = lines.map(
(l) => JSON.parse(l) as { hasApp: boolean; toolName: string },
);
// Every line is a valid app-info object with a toolName.
expect(infos.every((i) => typeof i.toolName === "string")).toBe(true);
// The fixture's `mcp_app_demo` is the (only) App tool.
const demo = infos.find((i) => i.toolName === "mcp_app_demo");
expect(demo?.hasApp).toBe(true);
expect(infos.filter((i) => i.hasApp).length).toBe(1);
});

it("emits {hasApp:false} as one JSON line and exits 2 for a non-App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"echo",
"--app-info",
]);
expect(result.exitCode).toBe(2);
const line = result.stdout.trim().split("\n")[0];
const info = JSON.parse(line) as { hasApp: boolean; toolName: string };
expect(info).toEqual({ hasApp: false, toolName: "echo" });
expect(result.stderr).toContain("has no MCP App UI resource");
});

it("exits 5 (TOOL_ERROR, code:tool_not_found) when the tool is not found — distinct from the no-app exit-2", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"no-such-tool",
"--app-info",
]);
expect(result.exitCode).toBe(5);
expect(result.stdout).toBe("");
const envelope = JSON.parse(result.stderr.trim()) as {
error: { code: string };
};
expect(envelope.error.code).toBe("tool_not_found");
});

it("emits the resource-side csp/permissions and exits 0 for an App tool", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"mcp_app_demo",
"--app-info",
]);
expect(result.exitCode).toBe(0);
const info = JSON.parse(result.stdout.trim().split("\n")[0]) as {
hasApp: boolean;
toolName: string;
resourceUri: string;
csp: unknown;
permissions: unknown;
prefersBorder: boolean;
resourceMimeType: string;
};
expect(info.hasApp).toBe(true);
expect(info.toolName).toBe("mcp_app_demo");
expect(info.resourceUri).toBe("ui://demo/widget.html");
expect(info.csp).toEqual({ connectDomains: [], resourceDomains: [] });
expect(info.permissions).toEqual({ clipboard: false });
expect(info.prefersBorder).toBe(true);
expect(info.resourceMimeType).toBe("text/html");
});

it("does not collect app-info on a plain text-mode tools/call", async () => {
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"mcp_app_demo",
"--tool-arg",
"title=hello",
]);
expect(result.exitCode).toBe(0);
expect(result.stdout).not.toContain("--- MCP App Info ---");
expect(result.stdout).not.toContain("hasApp");
// stdout is a single JSON value (the tool result) so `| jq` works.
expect(() => JSON.parse(result.stdout)).not.toThrow();
});

it("does not invoke the tool when --app-info is set", async () => {
// get_sum requires numeric a/b args; without --app-info this would fail
// with a tool error. With --app-info the tool is never called, so the
// only outcome is the no-app exit-2.
const { command, args } = getTestMcpServerCommand();
const result = await runCli([
command,
...args,
"--method",
"tools/call",
"--tool-name",
"get_sum",
"--app-info",
]);
expect(result.exitCode).toBe(2);
expect(result.output).not.toContain("isError");
});
});
132 changes: 132 additions & 0 deletions clients/cli/__tests__/emit-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { emitResult, collectAppInfo } from "../src/cli.js";
import { CliExitCodeError } from "../src/error-handler.js";
import type { InspectorClient } from "@inspector/core/mcp/index.js";

/**
* Direct unit coverage for the two output-side branches that the end-to-end
* CLI tests can't reach with the default stdio fixture: a tool that returns
* `isError:true` (exit 5), and a UI-resource read that fails during an
* `--app-info` probe (`resourceError`). Both functions are exported from
* `cli.ts` purely for this test, mirroring the `withConnectTimeout` pattern.
*/
describe("emitResult", () => {
let stdout: string;
let originalWrite: typeof process.stdout.write;

beforeEach(() => {
stdout = "";
originalWrite = process.stdout.write;
process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
stdout += typeof chunk === "string" ? chunk : String(chunk);
const cb = rest.find((r) => typeof r === "function") as
| (() => void)
| undefined;
cb?.();
return true;
}) as typeof process.stdout.write;
});

afterEach(() => {
process.stdout.write = originalWrite;
});

it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => {
const promise = emitResult({ isError: true }, undefined, {
toolName: "boom",
});
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
await promise.catch((e: CliExitCodeError) => {
expect(e.exitCode).toBe(5);
expect(e.envelope?.code).toBe("tool_is_error");
});
// The payload is still printed before the throw.
expect(stdout).toContain("isError");
});

it("does not throw for a successful result (text mode)", async () => {
await expect(
emitResult({ ok: true }, undefined, { format: "text" }),
).resolves.toBeUndefined();
expect(JSON.parse(stdout.trim())).toEqual({ ok: true });
});

it("wraps result + appInfo under a json envelope", async () => {
await emitResult(
{ value: 1 },
{ hasApp: true, toolName: "t", resourceUri: "ui://x" },
{ format: "json" },
);
expect(JSON.parse(stdout.trim())).toEqual({
result: { value: 1 },
appInfo: { hasApp: true, toolName: "t", resourceUri: "ui://x" },
});
});
});

describe("collectAppInfo", () => {
it("returns base info for a non-App tool without reading a resource", async () => {
const client = {
readResource: vi.fn(),
} as unknown as Pick<InspectorClient, "readResource">;
const info = await collectAppInfo(
client,
{ name: "plain", inputSchema: { type: "object" as const } },
undefined,
);
expect(info).toEqual({ hasApp: false, toolName: "plain" });
expect(
(client.readResource as ReturnType<typeof vi.fn>).mock.calls.length,
).toBe(0);
});

it("captures resourceError when the UI resource read fails", async () => {
const client = {
readResource: vi.fn().mockRejectedValue(new Error("unreadable")),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(true);
expect(info.resourceUri).toBe("ui://app/widget.html");
expect(info.resourceError).toBe("unreadable");
});

it("stringifies a non-Error rejection into resourceError", async () => {
const client = {
readResource: vi.fn().mockRejectedValue("string failure"),
} as unknown as Pick<InspectorClient, "readResource">;
const tool = {
name: "app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "ui://app/widget.html" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.resourceError).toBe("string failure");
});

it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => {
// extractAppInfo throws when the advertised UI URI is not a `ui://` string;
// collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop
// stays per-tool robust. readResource must never be reached here.
const readResource = vi.fn();
const client = { readResource } as unknown as Pick<
InspectorClient,
"readResource"
>;
const tool = {
name: "bad-app",
inputSchema: { type: "object" as const },
_meta: { ui: { resourceUri: "http://not-a-ui-uri" } },
};
const info = await collectAppInfo(client, tool, undefined);
expect(info.hasApp).toBe(false);
expect(info.toolName).toBe("bad-app");
expect(typeof info.resourceError).toBe("string");
expect(info.resourceError!.length).toBeGreaterThan(0);
expect(readResource.mock.calls.length).toBe(0);
});
});
Loading
Loading