diff --git a/clients/cli/README.md b/clients/cli/README.md index 7a6d9a22d..10923437c 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -109,6 +109,34 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--metadata ` | General metadata (key=value); applied to all methods. | | `--tool-metadata ` | Tool-specific metadata for `tools/call`. | | `--connect-timeout ` | 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 `: 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 ` | 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 --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 --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 --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) diff --git a/clients/cli/__tests__/app-info.test.ts b/clients/cli/__tests__/app-info.test.ts new file mode 100644 index 000000000..88a40c468 --- /dev/null +++ b/clients/cli/__tests__/app-info.test.ts @@ -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"); + }); +}); diff --git a/clients/cli/__tests__/emit-result.test.ts b/clients/cli/__tests__/emit-result.test.ts new file mode 100644 index 000000000..df4e8fc6f --- /dev/null +++ b/clients/cli/__tests__/emit-result.test.ts @@ -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; + 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).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; + 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; + 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); + }); +}); diff --git a/clients/cli/__tests__/format-json.test.ts b/clients/cli/__tests__/format-json.test.ts new file mode 100644 index 000000000..89577dd10 --- /dev/null +++ b/clients/cli/__tests__/format-json.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import { runCli } from "./helpers/cli-runner.js"; +import { + expectCliFailure, + expectCliSuccess, + expectOutputContains, +} from "./helpers/assertions.js"; + +/** + * Covers the `--format json` envelope from #1574: a single JSON object on + * stdout (`result`, plus `appInfo` as a sibling key for App tools), no banner. + */ +describe("--format json", () => { + it("wraps tools/list output in a single {result} envelope", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--method", + "tools/list", + "--format", + "json", + ]); + expectCliSuccess(result); + expect(result.stdout.trim().split("\n").length).toBe(1); + const env = JSON.parse(result.stdout) as { result: { tools: unknown[] } }; + expect(Array.isArray(env.result.tools)).toBe(true); + }); + + it("emits {result, appInfo} as one JSON object for an App tool (no banner)", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--method", + "tools/call", + "--tool-name", + "mcp_app_demo", + "--tool-arg", + "title=hello", + "--format", + "json", + ]); + expectCliSuccess(result); + expect(result.stdout).not.toContain("--- MCP App Info ---"); + const env = JSON.parse(result.stdout) as { + result: unknown; + appInfo: { hasApp: boolean; resourceUri: string }; + }; + expect(env.result).toBeTruthy(); + expect(env.appInfo.hasApp).toBe(true); + expect(env.appInfo.resourceUri).toBe("ui://demo/widget.html"); + }); + + it("omits appInfo for a non-App tool", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--method", + "tools/call", + "--tool-name", + "echo", + "--tool-arg", + "message=hi", + "--format", + "json", + ]); + expectCliSuccess(result); + const env = JSON.parse(result.stdout) as { + result: unknown; + appInfo?: unknown; + }; + expect(env.result).toBeTruthy(); + expect(env.appInfo).toBeUndefined(); + }); + + it("wraps --app-info output under {appInfo} 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", + "--format", + "json", + ]); + expect(result.exitCode).toBe(2); + const env = JSON.parse(result.stdout.trim()) as { + appInfo: { hasApp: boolean }; + }; + expect(env.appInfo.hasApp).toBe(false); + }); + + it("rejects an unknown --format value", async () => { + const { command, args } = getTestMcpServerCommand(); + const result = await runCli([ + command, + ...args, + "--method", + "tools/list", + "--format", + "yaml", + ]); + expectCliFailure(result); + expectOutputContains(result, "--format must be 'text' or 'json'"); + }); +}); diff --git a/clients/cli/__tests__/helpers/assertions.ts b/clients/cli/__tests__/helpers/assertions.ts index 3631c00bf..03da5f7e3 100644 --- a/clients/cli/__tests__/helpers/assertions.ts +++ b/clients/cli/__tests__/helpers/assertions.ts @@ -43,15 +43,6 @@ export function expectValidJson(result: CliResult) { return JSON.parse(result.stdout); } -/** - * Assert that output contains JSON with error flag - */ -export function expectJsonError(result: CliResult) { - const json = expectValidJson(result); - expect(json.isError).toBe(true); - return json; -} - /** * Assert that output contains expected JSON structure */ diff --git a/clients/cli/__tests__/tools.test.ts b/clients/cli/__tests__/tools.test.ts index 3a7ea6a17..8ca50f474 100644 --- a/clients/cli/__tests__/tools.test.ts +++ b/clients/cli/__tests__/tools.test.ts @@ -4,7 +4,6 @@ import { expectCliSuccess, expectCliFailure, expectValidJson, - expectJsonError, } from "./helpers/assertions.js"; import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; @@ -378,8 +377,15 @@ describe("Tool Tests", () => { "message=test", ]); - // CLI returns exit code 0 but includes isError: true in JSON (server returns error) - expectJsonError(result); + // A tool that does not exist on the server exits TOOL_ERROR (5) with a + // stable `tool_not_found` code, distinct from a tool that ran and + // returned isError:true. The error envelope is on stderr; stdout is empty. + 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("should fail when tool name is missing", async () => { diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index df65a95d3..8387a796d 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -25,6 +25,9 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; +import { extractAppInfo } from "@inspector/core/mcp/apps.js"; +import type { AppInfo } from "@inspector/core/mcp/apps.js"; +import { CliExitCodeError, EXIT_CODES } from "./error-handler.js"; import { ConsoleNavigation, MutableRedirectUrlProvider, @@ -68,6 +71,8 @@ const CLI_CLIENT_NAME = "inspector-cli"; */ export const DEFAULT_CONNECT_TIMEOUT_MS = 15000; +type OutputFormat = "text" | "json"; + type MethodArgs = { method?: string; promptName?: string; @@ -78,8 +83,27 @@ type MethodArgs = { toolArg?: Record; toolMeta?: Record; metadata?: Record; + appInfo?: boolean; + format?: OutputFormat; }; +/** + * {@link AppInfo} plus a CLI-only `resourceError` so a `resources/read` failure + * during the probe is reported instead of being silently swallowed (which would + * make "no CSP declared" indistinguishable from "resource unreadable"). + */ +export type CliAppInfo = AppInfo & { resourceError?: string }; + +/** + * Discriminated outcome from {@link callMethod}'s per-method runner. Most + * methods return a `result` (with optional collected `appInfo`) for + * {@link emitResult} to format; the `tools/list --app-info` NDJSON path writes + * its lines itself and reports `emitted` so the caller skips a second write. + */ +type MethodOutcome = + | { kind: "result"; result: McpResponse; appInfo?: CliAppInfo } + | { kind: "emitted" }; + async function callMethod( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, @@ -132,8 +156,9 @@ async function callMethod( null; let managedPromptsState: ManagedPromptsState | null = null; - const runMethod = async (): Promise => { + const runMethod = async (): Promise => { let result: McpResponse; + let appInfo: CliAppInfo | undefined; if (args.method === "tools/list" || args.method === "tools/call") { managedToolsState = new ManagedToolsState(inspectorClient); @@ -158,7 +183,26 @@ async function callMethod( } if (args.method === "tools/list") { - result = { tools: managedToolsState!.getTools() }; + const tools = managedToolsState!.getTools(); + if (args.appInfo) { + // NDJSON: one app-info line per tool, all on a single connection. A + // caller that wants only the App tools can `| jq -c 'select(.hasApp)'`. + // collectAppInfo never throws — a tool with a malformed `_meta.ui` + // surfaces as `{hasApp:false, resourceError}` — so one bad tool can't + // abort the whole listing. Emitted verbatim as NDJSON regardless of + // --format (the list-probe shape is fixed; --format json only reshapes + // the single-result paths). + for (const tool of tools) { + const info = await collectAppInfo( + inspectorClient, + tool, + args.metadata, + ); + await awaitableLog(JSON.stringify(info) + "\n"); + } + return { kind: "emitted" }; + } + result = { tools }; } else if (args.method === "tools/call") { if (!args.toolName) { throw new Error( @@ -170,15 +214,28 @@ async function callMethod( .getTools() .find((t) => t.name === args.toolName); if (!tool) { - result = { - content: [ - { - type: "text" as const, - text: `Tool '${args.toolName}' not found.`, - }, - ], - isError: true, - }; + // Distinct from `isError:true` and (for --app-info) from "tool has no + // app": the named tool does not exist on the server. Exit TOOL_ERROR + // with `code: "tool_not_found"` so a caller can tell a typo/rename + // apart from a real tool failure or a no-app probe result. + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' not found on server.`, + { code: "tool_not_found" }, + ); + } + + // Only collect app-info when the caller asked for it (`--app-info` or + // `--format json`); a plain text-mode `tools/call` shouldn't fail just + // because the tool's `_meta.ui.resourceUri` is malformed or its resource + // is unreadable. + if (args.appInfo || args.format === "json") { + appInfo = await collectAppInfo(inspectorClient, tool, args.metadata); + } + if (args.appInfo) { + // --app-info: probe-only — emit the app metadata and skip the tool + // call entirely. The no-app exit code is handled in emitResult. + result = { ...appInfo }; } else { const invocation = await inspectorClient.callTool( tool, @@ -266,7 +323,7 @@ async function callMethod( ); } - return result; + return { kind: "result", result, appInfo }; }; try { @@ -278,7 +335,7 @@ async function callMethod( serverSettings, ); - const result = await withCliAuthRecoveryRetry( + const outcome = await withCliAuthRecoveryRetry( inspectorClient, redirectUrlProvider, callbackUrlConfig, @@ -286,7 +343,11 @@ async function callMethod( runMethod, ); - await awaitableLog(JSON.stringify(result, null, 2)); + // The NDJSON `tools/list --app-info` path already wrote its lines; every + // other method hands its result to emitResult for format/exit handling. + if (outcome.kind === "result") { + await emitResult(outcome.result, outcome.appInfo, args); + } } finally { managedToolsState?.destroy(); managedResourcesState?.destroy(); @@ -296,6 +357,100 @@ async function callMethod( } } +/** + * Write the method result (and any app-info) to stdout, honouring `--format` + * and `--app-info`, then map `isError`/no-app outcomes onto the exit-code map. + * Extracted from `callMethod` so the format/exit handling is in one place. + */ +export async function emitResult( + result: McpResponse, + appInfo: CliAppInfo | undefined, + args: MethodArgs, +): Promise { + const json = args.format === "json"; + + if (args.appInfo) { + const info: CliAppInfo = appInfo ?? { + hasApp: false, + toolName: args.toolName ?? "", + }; + // Single-line JSON either way; --format json wraps it under an `appInfo` + // key so the envelope shape is uniform with the non-probe path. + await awaitableLog(JSON.stringify(json ? { appInfo: info } : info) + "\n"); + if (!info.hasApp) { + throw new CliExitCodeError( + EXIT_CODES.NO_APP, + `Tool '${args.toolName}' has no MCP App UI resource (_meta.ui.resourceUri).`, + ); + } + return; + } + + if (json) { + // One JSON object on stdout — `result` plus, when present, `appInfo` as a + // sibling key. No `--- MCP App Info ---` banner, so `| jq` works for App + // tools as well as plain ones. + const envelope: Record = { result }; + if (appInfo?.hasApp) envelope.appInfo = appInfo; + await awaitableLog(JSON.stringify(envelope) + "\n"); + } else { + // Text mode emits the result only; app-info is not collected on this path + // (use `--format json` or `--app-info` to get it). + await awaitableLog(JSON.stringify(result, null, 2) + "\n"); + } + + // A tool that returned `isError:true` (or whose call failed) is still printed + // above so the caller sees the payload, but the process exits TOOL_ERROR so + // `&&` chains don't proceed on a failed call. + if ((result as { isError?: unknown }).isError === true) { + throw new CliExitCodeError( + EXIT_CODES.TOOL_ERROR, + `Tool '${args.toolName}' returned isError:true.`, + { code: "tool_is_error" }, + ); + } +} + +/** + * Build the CLI's app-info for a tool: extract the tool-side `_meta.ui` and, + * when the tool advertises a UI resource, follow it with a `resources/read` so + * the resource-side csp/permissions/domain are included. + * + * Never throws — the two failure modes both fold into a `{hasApp:false, + * resourceError}` result rather than propagating: + * - a malformed `_meta.ui.resourceUri` (extractAppInfo throws), so the + * `tools/list --app-info` NDJSON loop stays per-tool tolerant (one bad tool + * can't abort the whole listing); + * - a `resources/read` failure, since "tool says it has an app but the + * resource is unreadable" is itself a useful probe result. + */ +export async function collectAppInfo( + client: Pick, + tool: Parameters[0], + metadata: Record | undefined, +): Promise { + let base: AppInfo; + try { + base = extractAppInfo(tool); + } catch (e) { + return { + hasApp: false, + toolName: tool.name, + resourceError: e instanceof Error ? e.message : String(e), + }; + } + if (!base.hasApp || base.resourceUri === undefined) return base; + try { + const read = await client.readResource(base.resourceUri, metadata); + return extractAppInfo(tool, read.result); + } catch (e) { + return { + ...base, + resourceError: e instanceof Error ? e.message : String(e), + }; + } +} + /** * Apply a connection timeout to a resolved server's settings, building a * minimal {@link InspectorServerSettings} when none came from the file. Ad-hoc @@ -480,6 +635,10 @@ async function parseArgs(argv?: string[]): Promise<{ parseKeyValuePair, {}, ) + .option( + "--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 (the tool itself is not invoked) or --method tools/list (one NDJSON line per tool).", + ) .option( "--connect-timeout ", `Connection timeout in ms (default ${DEFAULT_CONNECT_TIMEOUT_MS} for ad-hoc --server-url / target invocations; 0 = no timeout).`, @@ -491,6 +650,16 @@ async function parseArgs(argv?: string[]): Promise<{ return n; }, ) + .option( + "--format ", + "Output format: text (default; pretty-printed) or json (one JSON object on stdout, no banners).", + (v: string): OutputFormat => { + if (v !== "text" && v !== "json") { + throw new Error(`--format must be 'text' or 'json'.`); + } + return v; + }, + ) .option( "--tool-args-json ", 'Tool arguments as a single JSON object (e.g. \'{"zip":"10001"}\'). Values are passed verbatim — no key=value coercion. Mutually exclusive with --tool-arg.', @@ -536,7 +705,9 @@ async function parseArgs(argv?: string[]): Promise<{ transport?: "sse" | "http" | "stdio"; serverUrl?: string; header?: Record; + appInfo?: boolean; connectTimeout?: number; + format?: OutputFormat; toolArgsJson?: string; clientConfig?: string; clientId?: string; @@ -589,6 +760,16 @@ async function parseArgs(argv?: string[]): Promise<{ ); } + if ( + options.appInfo && + options.method !== "tools/call" && + options.method !== "tools/list" + ) { + throw new Error( + "--app-info requires --method tools/call (with --tool-name) or --method tools/list.", + ); + } + // --tool-args-json passes arguments verbatim with no key=value coercion (so // `"012"` stays a string and nested objects work without shell escaping). let toolArg = options.toolArg; @@ -640,6 +821,8 @@ async function parseArgs(argv?: string[]): Promise<{ ]), ) : undefined, + appInfo: options.appInfo === true, + format: options.format, }; return {