Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
f84531a
feat(web/apps): enforce per-app _meta.ui.csp in the sandbox proxy
tobinsouth Jun 19, 2026
08a04a1
feat(web/apps): push live theme to running apps via host-context-changed
tobinsouth Jun 19, 2026
b24237e
feat(web/apps): seed and live-push hostContext.styles to running apps
tobinsouth Jun 19, 2026
cf46794
feat(web/apps): seed and live-push hostContext.containerDimensions
tobinsouth Jun 19, 2026
b32fe34
fix(web/apps): harden sandbox CSP builder against injection
tobinsouth Jun 19, 2026
b0f0710
feat(web/apps): honor view size-changed and resize the app container
tobinsouth Jun 19, 2026
dd9975e
feat(web/apps): wire ui/request-display-mode through the protocol
tobinsouth Jun 19, 2026
fcef59d
feat(web/apps): support ui/download-file in the apps host bridge
tobinsouth Jun 19, 2026
56d0b7e
feat(web/apps): support ui/message — log view-submitted user content
tobinsouth Jun 19, 2026
b6e8b7f
feat(web/apps): surface app log notifications in a collapsible log panel
tobinsouth Jun 19, 2026
983784b
fix(web/apps): harden ui/download-file against scheme abuse and promp…
tobinsouth Jun 19, 2026
b078248
feat(web/apps): replay staged tool-input-partial fragments before too…
tobinsouth Jun 19, 2026
15e4165
fix(web/apps): use \p{Cc} for download-label control-char strip
tobinsouth Jun 19, 2026
5a41862
fix(web/apps): tighten sandbox CSP defaults and download-label sanitizer
tobinsouth Jun 19, 2026
5ba8688
fix(web/apps): build the per-app CSP host-side and wrap untrusted HTM…
tobinsouth Jun 22, 2026
117d91d
feat(cli): add --app-info to probe a tool's MCP App UI metadata
tobinsouth Jun 22, 2026
86c1ad0
fix(web/apps): correctness fixes for resource load, downloads, host-c…
tobinsouth Jun 22, 2026
d826b2f
feat(web): deep-link auto-connect and openApp pre-selection
tobinsouth Jun 22, 2026
f46fdb9
docs: add mcp-app-review recipe and an mcp_app_demo test fixture
tobinsouth Jun 22, 2026
c8428fa
fix(web,test-servers): document deep-link CSRF gate and harden demo w…
tobinsouth Jun 22, 2026
5608c95
refactor(web/apps): reuse existing helpers and SDK affordances
tobinsouth Jun 22, 2026
18faa98
refactor(web/apps): simplify state-reset, dedupe subcomponents, drop …
tobinsouth Jun 22, 2026
ffccea9
docs(test-servers): point mcp_app_demo widget at ext-apps SDK for ori…
tobinsouth Jun 22, 2026
af1d15e
test(web/apps): assert wrapSandboxedHtml introduces no host-authored …
tobinsouth Jun 22, 2026
eb1def0
fix(web,cli): deep-link auto-connect on empty catalog, appArgs pre-fi…
tobinsouth Jun 22, 2026
8d43d91
fix(test-servers): mcp_app_demo widget sends ui/initialize (View→Host…
tobinsouth Jun 22, 2026
8976842
fix(test-servers): mcp_app_demo merges host-context-changed partials
tobinsouth Jun 22, 2026
5c1948f
fix(web,test-servers): deep-link upsert race + ui/notifications/initi…
tobinsouth Jun 22, 2026
6f9a181
fix(core/mcp): redact Authorization/Cookie headers in the fetch-reque…
tobinsouth Jun 23, 2026
95352a3
feat(core/auth): web client stores OAuth via backend; storage getters…
tobinsouth Jun 23, 2026
0760266
feat(cli): --use-stored-auth reads backend-persisted OAuth token
tobinsouth Jun 23, 2026
9e7acbf
fix(core/auth): refuse to generate OAuth state without a cryptographi…
tobinsouth Jun 23, 2026
af8b008
fix(web/apps): drop allow-same-origin from app sandbox; deliver via s…
tobinsouth Jun 23, 2026
3297fc9
fix(web,core): proxy CSP inheritance, case-insensitive sandbox strip,…
tobinsouth Jun 23, 2026
3bb886d
fix(core/auth): normalize serverUrl key via new URL().href on store g…
tobinsouth Jun 24, 2026
9314423
fix(core/storage): keepalive on remote setItem; surface persist error…
tobinsouth Jun 24, 2026
b9351b1
feat(cli): structured error envelope and exit-code map
tobinsouth Jun 24, 2026
39d00af
feat(core/auth): expose getHydrationError so callers can tell unreada…
tobinsouth Jun 24, 2026
5ee5d49
feat(core/auth): expose getHydrationError so callers can tell unreada…
tobinsouth Jun 24, 2026
6b12434
feat(cli): --connect-timeout, --format json, --tool-args-json, --meth…
tobinsouth Jun 24, 2026
f8940fc
fix(test): type mock-fetch params so tsc -b accepts mock.calls[n][1]
tobinsouth Jun 24, 2026
02949b3
feat(web/apps): AppRenderer onAppStatusChange callback + open-app testid
tobinsouth Jun 24, 2026
327b5a1
feat(cli): --wait-for-auth, --list-stored-auth, --print-handoff; hono…
tobinsouth Jun 24, 2026
5abbaad
test(core/auth): integration tests read persisted servers via normali…
tobinsouth Jun 24, 2026
e465406
fix(cli): MCP_CATALOG_PATH no longer breaks --server-url; collectAppI…
tobinsouth Jun 24, 2026
5ee70fd
test(core/storage): assert setItem error names storeId+URL; drop unha…
tobinsouth Jun 24, 2026
0e63026
feat(cli): --app-info on tools/list emits NDJSON, single connect
tobinsouth Jun 24, 2026
706021b
test(cli): restore coverage gate after error-handling rework
tobinsouth Jun 24, 2026
a2cea0d
fix(web): deep-link upsert by full config, fresh-closure connect, sur…
tobinsouth Jun 24, 2026
1192ffa
feat(web): &autoOpen=<token> deep-link param + merge schema defaults …
tobinsouth Jun 24, 2026
ba804a6
feat(web): machine-readable error/status attrs on connection-status
tobinsouth Jun 24, 2026
8006b90
fix(web): reject OAuth callback with an unparseable state parameter
tobinsouth Jun 24, 2026
8b9d529
feat(core/mcp): honor HTTPS_PROXY / HTTP_PROXY in node transport via …
tobinsouth Jun 24, 2026
c3bdba6
fix(core/mcp/apps): match UI resource content by normalized URI, not …
tobinsouth Jun 24, 2026
33fac3f
docs: mcp-app-review §5 OAuth-gated, §6 SSH port-forward, §7 HTTP proxy
tobinsouth Jun 24, 2026
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ v2 is **not** an npm workspace — each client under `clients/*` keeps its own `

After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`.

For automated MCP App review (CLI-first probe + one-shot web render), see [docs/mcp-app-review.md](docs/mcp-app-review.md).

## Repository & Project Board

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

/**
* The default test server has no MCP App tools, so this exercises the
* negative path: `--app-info` prints `{"hasApp":false,...}` on stdout and
* exits 2. The positive path is unit-tested at the `extractAppInfo` level
* (`clients/web/src/test/core/apps.test.ts`) and end-to-end via the
* `mcpAppDemo` fixture.
*/
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 (use --format json or --app-info)", 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 now 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 error 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");
});
});
145 changes: 130 additions & 15 deletions clients/cli/__tests__/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { handleError } from "../src/error-handler.js";
import {
CliExitCodeError,
EXIT_CODES,
classifyError,
formatErrorOutput,
handleError,
} from "../src/error-handler.js";

/**
* `handleError` is the binary's last-resort error sink (wired up in
Expand All @@ -13,33 +19,142 @@ describe("handleError", () => {
vi.restoreAllMocks();
});

it("logs an Error's message and exits with code 1", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
it("emits a JSON error envelope on stderr and exits with the classified code", () => {
const writeSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation((() => true) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);

handleError(new Error("boom"));

expect(errorSpy).toHaveBeenCalledWith("boom");
expect(exitSpy).toHaveBeenCalledWith(1);
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.USAGE);
const written = writeSpy.mock.calls[0]![0] as string;
const parsed = JSON.parse(written) as {
error: { code: string; message: string };
};
expect(parsed.error.code).toBe("error");
expect(parsed.error.message).toBe("boom");
});

it("logs a string error verbatim", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
it("uses a CliExitCodeError's exitCode and envelope code", () => {
vi.spyOn(process.stderr, "write").mockImplementation((() => true) as never);
const exitSpy = vi
.spyOn(process, "exit")
.mockImplementation((() => undefined) as never);

handleError("plain failure");
handleError(
new CliExitCodeError(EXIT_CODES.NO_APP, "no app", { code: "no_app" }),
);

expect(errorSpy).toHaveBeenCalledWith("plain failure");
expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.NO_APP);
});
});

it("falls back to 'Unknown error' for non-Error, non-string values", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
describe("classifyError", () => {
it("classifies a 401 status as AUTH_REQUIRED", () => {
const err = Object.assign(new Error("Unauthorized"), { status: 401 });
const { exitCode, envelope } = classifyError(err, {
url: "https://x.example/mcp",
});
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.code).toBe("auth_required");
expect(envelope.status).toBe(401);
expect(envelope.url).toBe("https://x.example/mcp");
});

it("classifies a WWW-Authenticate message as AUTH_REQUIRED without a status", () => {
const { exitCode } = classifyError(
new Error("Dynamic client registration failed: WWW-Authenticate Bearer"),
);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
});

it("classifies ENOTFOUND / fetch failed as UNREACHABLE", () => {
const err = new Error("fetch failed");
(err as { cause?: unknown }).cause = new Error(
"getaddrinfo ENOTFOUND no.such.host",
);
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.UNREACHABLE);
expect(envelope.cause).toContain("ENOTFOUND");
});

it("classifies a connection-refused cause as UNREACHABLE", () => {
const err = new Error("connect ECONNREFUSED 127.0.0.1:1");
expect(classifyError(err).exitCode).toBe(EXIT_CODES.UNREACHABLE);
});

it("falls back to USAGE for an unrecognized Error", () => {
expect(classifyError(new Error("something else")).exitCode).toBe(
EXIT_CODES.USAGE,
);
});

handleError({ unexpected: true });
it("handles a string error", () => {
const { exitCode, envelope } = classifyError("plain failure");
expect(exitCode).toBe(EXIT_CODES.USAGE);
expect(envelope.message).toBe("plain failure");
});

it("handles a non-Error, non-string value", () => {
const { envelope } = classifyError({ unexpected: true });
expect(envelope.message).toBe("Unknown error");
});

it("preserves a CliExitCodeError's explicit envelope code over the default", () => {
const { envelope } = classifyError(
new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x", {
code: "tool_not_found",
}),
);
expect(envelope.code).toBe("tool_not_found");
});

it("derives a default envelope code for a bare CliExitCodeError", () => {
expect(
classifyError(new CliExitCodeError(EXIT_CODES.UNREACHABLE, "x")).envelope
.code,
).toBe("unreachable");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.NO_APP, "x")).envelope.code,
).toBe("no_app");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.AUTH_REQUIRED, "x"))
.envelope.code,
).toBe("auth_required");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.TOOL_ERROR, "x")).envelope
.code,
).toBe("tool_error");
expect(
classifyError(new CliExitCodeError(EXIT_CODES.USAGE, "x")).envelope.code,
).toBe("error");
});

it("captures a non-Error cause as a string", () => {
const err = new Error("outer");
(err as { cause?: unknown }).cause = { reason: "blocked" };
const { envelope } = classifyError(err);
expect(envelope.cause).toBe("[object Object]");
});

it("reads a numeric SDK-style .code as an HTTP status", () => {
const err = Object.assign(new Error("forbidden"), { code: 403 });
const { exitCode, envelope } = classifyError(err);
expect(exitCode).toBe(EXIT_CODES.AUTH_REQUIRED);
expect(envelope.status).toBe(403);
});
});

expect(errorSpy).toHaveBeenCalledWith("Unknown error");
describe("formatErrorOutput", () => {
it("emits one JSON line on stderr ending in a newline", () => {
const { stderr, exitCode } = formatErrorOutput(new Error("nope"));
expect(stderr.endsWith("\n")).toBe(true);
expect(stderr.split("\n").length).toBe(2);
const parsed = JSON.parse(stderr) as { error: { message: string } };
expect(parsed.error.message).toBe("nope");
expect(exitCode).toBe(EXIT_CODES.USAGE);
});
});
Loading