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
85 changes: 49 additions & 36 deletions cli/src/commands/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function registerToolsCommands(program: Command): void {
| undefined;
let inspectorRenderSkipped = false;
let inspectorRenderIssue: InspectorRenderIssue | undefined;
const requireRender = options.requireRender === true;

if (options.ui) {
const serverName =
Expand Down Expand Up @@ -363,8 +364,12 @@ export function registerToolsCommands(program: Command): void {
};
}

const inspectorRenderClassification =
classifyInspectorRenderError(inspectorRenderError);
const inspectorRenderClassification = classifyInspectorRenderError(
inspectorRenderError,
{
noActiveClientIsSkippable: options.attachOnly !== true,
},
);
inspectorRenderSkipped = inspectorRenderClassification.skippable;
inspectorRenderIssue = buildInspectorRenderIssue(
inspectorRenderError,
Expand All @@ -376,7 +381,6 @@ export function registerToolsCommands(program: Command): void {
remediation: inspectorRenderClassification.remediation,
issue: inspectorRenderIssue,
});
const requireRender = options.requireRender === true;
const renderFailure =
inspectorRenderError &&
(!inspectorRenderSkipped || requireRender);
Expand All @@ -401,9 +405,7 @@ export function registerToolsCommands(program: Command): void {
...(inspectorRenderError
? inspectorRenderSkipped && !requireRender
? { warning: inspectorRenderIssue ?? inspectorRenderError }
: inspectorRenderSkipped
? { error: inspectorRenderIssue ?? inspectorRenderError }
: { error: inspectorRenderError }
: { error: inspectorRenderIssue ?? inspectorRenderError }
: {}),
};
outputPayload = compactOutputPayload;
Expand All @@ -422,7 +424,23 @@ export function registerToolsCommands(program: Command): void {
});
}

const requireRender = options.requireRender === true;
const renderIsFailure = Boolean(
inspectorRenderError && (!inspectorRenderSkipped || requireRender),
);
const debugOutcomeError = renderIsFailure
? (inspectorRenderIssue ?? inspectorRenderError)
: validationFailed
? {
code: "validation_failed",
message: "Tool call validation failed.",
details: validationResult,
}
: toolResultError
? {
code: "tool_result_error",
message: "Tool returned an error result.",
}
: undefined;

await writeCommandDebugArtifact({
outputPath: options.debugOut,
Expand All @@ -434,21 +452,16 @@ export function registerToolsCommands(program: Command): void {
params,
},
target: targetSummary,
outcome:
inspectorRenderError &&
(!inspectorRenderSkipped || requireRender)
? {
status: "error",
error:
inspectorRenderSkipped && requireRender
? inspectorRenderIssue ?? inspectorRenderError
: inspectorRenderError,
result: debugOutputPayload,
}
: {
status: "success",
result: debugOutputPayload,
},
outcome: debugOutcomeError
? {
status: "error",
error: debugOutcomeError,
result: debugOutputPayload,
}
: {
status: "success",
result: debugOutputPayload,
},
snapshot: options.debugOut
? {
input: {
Expand Down Expand Up @@ -514,15 +527,11 @@ type InspectorRenderIssue = {
};

type InspectorRenderErrorClassification =
| {
skippable: true;
code: InspectorRenderSkippableCode;
remediation: InspectorRenderRemediation;
}
| {
skippable: false;
remediation: InspectorRenderRemediation;
};
{
skippable: boolean;
remediation: InspectorRenderRemediation;
code?: InspectorRenderSkippableCode;
};

function parseInspectorUiTtyOverride(name: string): boolean | undefined {
const value = process.env[name]?.trim().toLowerCase();
Expand Down Expand Up @@ -678,15 +687,17 @@ function writeInspectorRenderWarning(options: {

function classifyInspectorRenderError(
error: { code: string; message: string } | undefined,
options: { noActiveClientIsSkippable?: boolean } = {},
): InspectorRenderErrorClassification {
if (!error) {
return { skippable: false, remediation: "none" };
}

const noActiveClientIsSkippable = options.noActiveClientIsSkippable ?? true;
const code = error.code.toLowerCase();
if (code === "no_active_client" || isNoActiveClientMessage(error)) {
return {
skippable: true,
skippable: noActiveClientIsSkippable,
code: "no_active_client",
remediation: "open_browser",
};
Expand Down Expand Up @@ -721,7 +732,7 @@ function buildInspectorRenderIssue(
render: Record<string, unknown>,
classification: InspectorRenderErrorClassification,
): InspectorRenderIssue | undefined {
if (!error || !classification.skippable) {
if (!error || !classification.code) {
return undefined;
}

Expand Down Expand Up @@ -776,9 +787,11 @@ function buildCompactInspectorRender(
: uiResult.status === "error" && isRecord(uiResult.error)
? { warning: uiResult.error }
: {}
: uiResult.status === "error" && isRecord(uiResult.error)
? { error: uiResult.error }
: {};
: options.issue
? { error: options.issue }
: uiResult.status === "error" && isRecord(uiResult.error)
? { error: uiResult.error }
: {};

return {
status:
Expand Down
1 change: 0 additions & 1 deletion cli/src/lib/inspector-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,6 @@ async function executeInspectorCommandWithClient(
const retryable =
response.status === "error" &&
(response.error.code === "no_active_client" ||
response.error.code === "unsupported_in_mode" ||
response.error.code === "disconnected_server");
Comment on lines 222 to 223
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore retry on unsupported_in_mode during UI startup

Keep unsupported_in_mode in the retryable set here. The browser client registers Inspector command handlers asynchronously in App.tsx (via registerInspectorCommandHandler(...) inside an effect), so the first command after tools call --ui --open can briefly return unsupported_in_mode before the app finishes bootstrapping. After this change, that transient response is treated as final, so rendering is skipped permanently even though retrying a few hundred milliseconds later would succeed.

Useful? React with 👍 / 👎.

if (!retryable) {
return response;
Expand Down
42 changes: 42 additions & 0 deletions cli/tests/tools-call-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,48 @@ test("tools call --ui rejects attach-only with open", async () => {
);
});

test("tools call --ui --attach-only keeps missing browser clients as hard errors", async () => {
const toolResult = { content: [{ type: "text", text: "view created" }] };
const server = await startMockServer({ hasActiveClient: false, toolResult });

try {
const result = await runCli([
"--format",
"json",
"tools",
"call",
"--ui",
"--attach-only",
"--inspector-url",
`http://127.0.0.1:${server.port}`,
"--url",
`http://127.0.0.1:${server.port}/mcp`,
"--tool-name",
"create_view",
"--tool-args",
"{}",
]);

assert.equal(result.exitCode, 1, result.stderr);
const payload = JSON.parse(lastJsonLine(result.stdout)) as Record<
string,
any
>;
assert.equal(payload.success, false);
assert.equal(payload.warning, undefined);
assert.equal(payload.error.code, "no_active_client");
assert.equal(payload.error.remediation, "open_browser");
assert.equal(payload.inspectorRender.status, "error");
assert.equal(payload.inspectorRender.error.code, "no_active_client");
assert.equal(
server.requests.some((entry) => entry.url === "/api/mcp/command"),
false,
);
} finally {
await server.stop();
}
});

test("tools call --ui accepts frontend-url with open", async () => {
const toolResult = { content: [{ type: "text", text: "view created" }] };
const server = await startMockServer({
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Uses shared connection flags.

Plus shared connection flags.

Without `--ui`, `tools call` returns the raw tool result. With `--ui`, it opens Inspector by default in a TTY and returns a compact envelope with `result`, `inspectorBrowserUrl`, and `inspectorRender` status. `inspectorRender.status` is `rendered` when Inspector accepted the render, `skipped` when the tool succeeded but Inspector had no active browser client, an unsatisfied render precondition, or a render timeout, and `error` for non-recoverable render command failures. `inspectorRender.remediation` is always present and is one of `open_browser`, `retry`, `reconnect_server`, or `none`. Skipped renders are emitted as a stable root `warning` plus `inspectorRender.warning`, both with the shape `{ code, message, remediation, browserUrl?, hasActiveClient?, inspectorStarted? }`. Stable skipped-render codes are `no_active_client`, `timeout`, `disconnected_server`, and `unsupported_in_mode`. Skipped renders keep the tool-call exit code unless `--require-render` is set; tool failures, validation failures, non-skippable render command errors, and `--require-render` skipped renders all exit nonzero. `--inspector-url` points to the Inspector backend/API; pass `--frontend-url` when you already know the browser/client URL and want to skip health-advertised frontend checks and local dev port discovery. Use `--no-open` when browser automation already opened `inspectorBrowserUrl`; use `--attach-only` when startup, browser opening, and discovery should all be disallowed. Default non-TTY `--ui` runs do not open a browser unless `--open` is passed. TTY stderr runs print the App Builder URL and initial browser-client wait progress unless `--quiet` is set; the elapsed-seconds heartbeat only appears when stderr is a TTY. The Inspector path injects the completed tool result through `renderToolResult`; it does not call the tool a second time. Fresh tabs do not hydrate the injected render state; use the active Inspector client that received the render. Use `--debug-out` for the full render envelope including params and command responses. `--ui` cannot be combined with `--reporter`.
Without `--ui`, `tools call` returns the raw tool result. With `--ui`, it opens Inspector by default in a TTY and returns a compact envelope with `result`, `inspectorBrowserUrl`, and `inspectorRender` status. `inspectorRender.status` is `rendered` when Inspector accepted the render, `skipped` when the tool succeeded but Inspector had no active browser client, an unsatisfied render precondition, or a render timeout, and `error` for non-recoverable render command failures. `inspectorRender.remediation` is always present and is one of `open_browser`, `retry`, `reconnect_server`, or `none`. Skipped renders are emitted as a stable root `warning` plus `inspectorRender.warning`, both with the shape `{ code, message, remediation, browserUrl?, hasActiveClient?, inspectorStarted? }`. Stable skipped-render codes are `no_active_client`, `timeout`, `disconnected_server`, and `unsupported_in_mode`. Skipped renders keep the tool-call exit code unless `--require-render` is set; tool failures, validation failures, non-skippable render command errors, and `--require-render` skipped renders all exit nonzero. `--attach-only` is an exception to the skipped-render rule for `no_active_client`: by default a missing browser client yields `inspectorRender.status = "skipped"` with `inspectorRender.remediation = "open_browser"`, but when `--attach-only` is set, `no_active_client` is treated as non-skippable, surfaces as a root `error` (not a downgraded `warning`), and exits nonzero like other non-skippable render failures. `--inspector-url` points to the Inspector backend/API; pass `--frontend-url` when you already know the browser/client URL and want to skip health-advertised frontend checks and local dev port discovery. Use `--no-open` when browser automation already opened `inspectorBrowserUrl`; use `--attach-only` when startup, browser opening, and discovery should all be disallowed. Default non-TTY `--ui` runs do not open a browser unless `--open` is passed. When `--open` is in effect (default in TTYs, opt-in elsewhere), the App Builder URL and the initial browser-client wait progress are emitted to stderr unless `--quiet` is set, regardless of whether stderr is a TTY; only the elapsed-seconds heartbeat is gated on stderr being a TTY. The Inspector path injects the completed tool result through `renderToolResult`; it does not call the tool a second time. Fresh tabs do not hydrate the injected render state; use the active Inspector client that received the render. Use `--debug-out` for the full render envelope including params and command responses. `--ui` cannot be combined with `--reporter`.

### Reading `tools call --ui` output as an agent

Expand Down
2 changes: 1 addition & 1 deletion docs/cli/tools-resources-prompts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ echo '{"query":"setup guide"}' | mcpjam tools call --url $URL --access-token $TO
--tool-name search_docs --tool-args - --quiet --format json
```

`--tool-args-stdin` is equivalent to `--tool-args -`:
`--tool-args-stdin` is equivalent to `--tool-args -` and cannot be combined with `--tool-args` or `--params` in the same command:

```bash
generate-params | mcpjam tools call --url $URL --access-token $TOKEN \
Expand Down
13 changes: 13 additions & 0 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@ export default function App() {
clearLocalFallbackWorkspaceSelection,
pendingDashboardOAuth,
isCloudSyncActive,
persistRuntimeServerToWorkspaceIfNeeded,
} = useAppState({
currentUserId: workOsUser?.id ?? null,
hasOrganizations: effectiveOrganizations.length > 0,
Expand All @@ -761,6 +762,11 @@ export default function App() {
workspaceServersRef.current = workspaceServers;
const selectedServerRef = useRef(appState.selectedServer);
selectedServerRef.current = appState.selectedServer;
const persistRuntimeServerToWorkspaceRef = useRef(
persistRuntimeServerToWorkspaceIfNeeded,
);
persistRuntimeServerToWorkspaceRef.current =
persistRuntimeServerToWorkspaceIfNeeded;
const getInspectorServerState = useCallback((serverName: string) => {
const runtimeServer = oauthDebuggerServersRef.current[serverName];
const workspaceServer = workspaceServersRef.current[serverName];
Expand Down Expand Up @@ -1318,6 +1324,13 @@ export default function App() {
}

setSelectedServer(command.payload.serverName);
const runtimeForPersist = serverState.runtimeServer;
if (runtimeForPersist?.connectionStatus === "connected") {
void persistRuntimeServerToWorkspaceRef.current(
command.payload.serverName,
runtimeForPersist,
);
}
}

applyNavigation("app-builder", { updateHash: true });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { generateId } from "ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useChatSession } from "../use-chat-session";
import { useTrafficLogStore } from "@/stores/traffic-log-store";
Expand Down Expand Up @@ -258,13 +259,16 @@ describe("useChatSession hosted mode", () => {
mockState.convexAuth.isLoading = false;
mockState.authFetch.mockReset();
mockState.authFetch.mockResolvedValue(new Response(null, { status: 200 }));
mockState.setMessages.mockReset();
mockState.buildHostedServerRequest.mockReset();
mockState.getAccessToken.mockReset();
mockState.getAccessToken.mockResolvedValue("access-token");
mockState.getGuestBearerToken.mockReset();
mockState.getGuestBearerToken.mockResolvedValue("guest-token");
mockState.selectedModelId = "anthropic/claude-haiku-4.5";
mockState.latestOnData = undefined;
vi.mocked(generateId).mockReset();
vi.mocked(generateId).mockReturnValue("chat-session-id");
useTrafficLogStore.getState().clear();
});

Expand Down Expand Up @@ -343,6 +347,63 @@ describe("useChatSession hosted mode", () => {
unmount();
});

it("resets the thread when the hosted scope changes under the same auth header", async () => {
vi.mocked(generateId)
.mockImplementationOnce(() => "chat-session-id")
.mockImplementation(() => "chat-session-id-2");
const onReset = vi.fn();

const { result, rerender } = renderHook(
({
hostedContext,
}: {
hostedContext: {
workspaceId: string;
selectedServerIds: string[];
shareToken: string;
};
}) =>
useChatSession({
selectedServers: ["server-1"],
hostedContext,
onReset,
}),
{
initialProps: {
hostedContext: {
workspaceId: "workspace-1",
selectedServerIds: ["server-id-1"],
shareToken: "share-token-1",
},
},
}
);

await waitFor(() => {
expect(result.current.isSessionBootstrapComplete).toBe(true);
});

expect(result.current.chatSessionId).toBe("chat-session-id");

mockState.setMessages.mockClear();
onReset.mockClear();

rerender({
hostedContext: {
workspaceId: "workspace-2",
selectedServerIds: ["server-id-2"],
shareToken: "share-token-2",
},
});

await waitFor(() => {
expect(result.current.chatSessionId).toBe("chat-session-id-2");
});

expect(mockState.setMessages).toHaveBeenCalledTimes(1);
expect(onReset).toHaveBeenCalledWith("auth-bootstrap");
});

it("marks session bootstrap complete only after auth setup finishes", async () => {
let resolveAccessToken: (value: string) => void = () => {};
mockState.getAccessToken.mockImplementation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function renderHostedServerState(
dispatch,
isLoading: false,
isAuthenticated: true,
hasSignedInUser: true,
isAuthLoading: false,
isLoadingWorkspaces: false,
useLocalFallback: false,
Expand Down Expand Up @@ -229,6 +230,7 @@ describe("useServerState hosted OAuth callback guards", () => {
dispatch: vi.fn(),
isLoading: false,
isAuthenticated: true,
hasSignedInUser: true,
isAuthLoading: false,
isLoadingWorkspaces: false,
useLocalFallback: false,
Expand Down Expand Up @@ -282,6 +284,7 @@ describe("useServerState hosted OAuth callback guards", () => {
dispatch,
isLoading: false,
isAuthenticated: true,
hasSignedInUser: true,
isAuthLoading: false,
isLoadingWorkspaces: false,
useLocalFallback: false,
Expand Down Expand Up @@ -359,6 +362,7 @@ describe("useServerState hosted OAuth callback guards", () => {
dispatch: vi.fn(),
isLoading: false,
isAuthenticated: true,
hasSignedInUser: true,
isAuthLoading: false,
isLoadingWorkspaces: false,
useLocalFallback: false,
Expand Down
Loading
Loading