Skip to content

Commit 4253d59

Browse files
committed
Fixed Run as Task support (UX and CallToolStream support). Fixed resources/prompts/tools tab layout issue where the lists didn't take up all available vertical space.
1 parent a43fe87 commit 4253d59

8 files changed

Lines changed: 206 additions & 176 deletions

File tree

docs/inspector-client-todo.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,20 @@ Goal: Parity with v1 client
8585
Goal: Bring Inspector Web support to current spec
8686

8787
- URL elicitation (already in InspectorClient, just need UX)
88+
- https://github.com/modelcontextprotocol/inspector/issues/929
89+
- https://github.com/modelcontextprotocol/inspector/pull/994
8890
- Add "sampling with tools" support
8991
- https://github.com/modelcontextprotocol/inspector/issues/932
9092
- Review v1 project boards for any feature deficiencies
9193

9294
Goal: Inspector Web quality
9395

96+
- Flaky test (fails maybe 1 time out of 20):
97+
- **tests**/inspectorClient-oauth-e2e.test.ts > InspectorClient OAuth E2E > Storage path (custom) ('SSE') > should persist OAuth state to custom storagePath
98+
```
99+
Error: waitForStateFile failed: JSON parse error (file may be mid-write or corrupt). File: /var/folders/c8/jr_qy1fs1cj3hfhr5m_2f4c40000gn/T/mcp-inspector-e2e-1771550464080-66tdoqzpfxo.json. Attempts: 40. Raw snippet: {"state":{"servers":{"http://localhost:51796/sse":{"preregisteredClientInformation":{"client_id":"test-storage-path","client_secret":"test-secret-sp"},"codeVerifier":"U8mEkBln9JtFLMzC3b50kj0QtubtwpDPU... (405 chars total). Run with DEBUG_WAIT_FOR_STATE_FILE=1 for per-attempt logs.
100+
❯ vi.waitFor.timeout.timeout test/test-helpers.ts:138:15
101+
```
94102
- Review open v1 bugs (esp auth bugs) to see which ones still apply
95103
96104
Misc

web/src/App.tsx

Lines changed: 107 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,142 +1280,123 @@ const App = () => {
12801280
throw new Error("InspectorClient is not connected");
12811281
}
12821282

1283-
try {
1284-
// Find the tool schema to clean parameters properly
1285-
const tool = inspectorTools.find((t) => t.name === name);
1286-
const cleanedParams = tool?.inputSchema
1287-
? cleanParams(params, tool.inputSchema as JsonSchemaType)
1288-
: params;
1289-
1290-
// Merge general metadata with tool-specific metadata
1291-
// Tool-specific metadata takes precedence over general metadata
1292-
const generalMetadata = {
1293-
...metadata, // General metadata
1294-
progressToken: String(progressTokenRef.current++),
1295-
};
1296-
const toolSpecificMetadata = toolMetadata
1297-
? Object.fromEntries(
1298-
Object.entries(toolMetadata).map(([k, v]) => [k, String(v)]),
1299-
)
1300-
: undefined;
1283+
const tool = inspectorTools.find((t) => t.name === name);
1284+
const taskSupport = tool?.execution?.taskSupport ?? "forbidden";
1285+
const effectiveRunAsTask =
1286+
taskSupport === "required" ||
1287+
(taskSupport === "optional" && runAsTask === true);
1288+
1289+
const cleanedParams = tool?.inputSchema
1290+
? cleanParams(params, tool.inputSchema as JsonSchemaType)
1291+
: params;
1292+
const generalMetadata = {
1293+
...metadata,
1294+
progressToken: String(progressTokenRef.current++),
1295+
};
1296+
const toolSpecificMetadata = toolMetadata
1297+
? Object.fromEntries(
1298+
Object.entries(toolMetadata).map(([k, v]) => [k, String(v)]),
1299+
)
1300+
: undefined;
1301+
const taskOptions = effectiveRunAsTask
1302+
? { ttl: getMCPTaskTtl(config) }
1303+
: undefined;
13011304

1302-
const taskOptions =
1303-
runAsTask === true ? { ttl: getMCPTaskTtl(config) } : undefined;
1305+
try {
1306+
if (effectiveRunAsTask) {
1307+
// Use callToolStream for task-augmented execution (required or optional+checked)
1308+
let currentTaskId: string | undefined;
1309+
1310+
const onTaskCreated = (
1311+
e: CustomEvent<{ taskId: string; task: { taskId: string } }>,
1312+
) => {
1313+
const { taskId } = e.detail;
1314+
currentTaskId = taskId;
1315+
setToolResult({
1316+
content: [
1317+
{
1318+
type: "text",
1319+
text: `Task created: ${taskId}. Polling for status...`,
1320+
},
1321+
],
1322+
_meta: {
1323+
"io.modelcontextprotocol/related-task": { taskId },
1324+
},
1325+
} as CompatibilityCallToolResult);
1326+
};
13041327

1305-
const invocation = await inspectorClient.callTool(
1306-
name,
1307-
cleanedParams as Record<string, JsonValue>,
1308-
generalMetadata,
1309-
toolSpecificMetadata,
1310-
taskOptions,
1311-
);
1328+
const onTaskStatusChange = (
1329+
e: CustomEvent<{
1330+
taskId: string;
1331+
task: { status: string; statusMessage?: string };
1332+
}>,
1333+
) => {
1334+
const { taskId, task } = e.detail;
1335+
if (currentTaskId !== taskId) return;
1336+
setToolResult({
1337+
content: [
1338+
{
1339+
type: "text",
1340+
text: `Task status: ${task.status}${task.statusMessage ? ` - ${task.statusMessage}` : ""}. Polling...`,
1341+
},
1342+
],
1343+
_meta: {
1344+
"io.modelcontextprotocol/related-task": { taskId },
1345+
},
1346+
} as CompatibilityCallToolResult);
1347+
void inspectorClient.listRequestorTasks();
1348+
};
13121349

1313-
// Check if server returned a task reference (task-augmented execution)
1314-
const rawResult = invocation.result as
1315-
| (Record<string, unknown> & {
1316-
task?: { taskId: string; status: string; pollInterval?: number };
1317-
})
1318-
| null
1319-
| undefined;
1320-
const isTaskResult = (
1321-
res: unknown,
1322-
): res is {
1323-
task: { taskId: string; status: string; pollInterval?: number };
1324-
} =>
1325-
!!res &&
1326-
typeof res === "object" &&
1327-
"task" in res &&
1328-
!!(res as Record<string, unknown>).task &&
1329-
typeof (res as Record<string, unknown>).task === "object" &&
1330-
"taskId" in (res as { task: Record<string, unknown> }).task;
1331-
1332-
if (runAsTask && rawResult && isTaskResult(rawResult)) {
1333-
const taskId = rawResult.task.taskId;
1334-
const pollInterval = rawResult.task.pollInterval ?? 1000;
1350+
inspectorClient.addEventListener("taskCreated", onTaskCreated);
1351+
inspectorClient.addEventListener(
1352+
"taskStatusChange",
1353+
onTaskStatusChange,
1354+
);
13351355
setIsPollingTask(true);
1336-
const initialResponseMeta =
1337-
rawResult && typeof rawResult === "object" && "_meta" in rawResult
1338-
? ((rawResult as { _meta?: Record<string, unknown> })._meta ?? {})
1339-
: undefined;
1340-
setToolResult({
1341-
content: [
1342-
{
1343-
type: "text",
1344-
text: `Task created: ${taskId}. Polling for status...`,
1345-
},
1346-
],
1347-
_meta: {
1348-
...(initialResponseMeta || {}),
1349-
"io.modelcontextprotocol/related-task": { taskId },
1350-
},
1351-
} as CompatibilityCallToolResult);
1352-
1353-
let taskCompleted = false;
1354-
while (!taskCompleted) {
1355-
try {
1356-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
1357-
const taskStatus = await inspectorClient.getRequestorTask(taskId);
1358-
1359-
if (
1360-
taskStatus.status === "completed" ||
1361-
taskStatus.status === "failed" ||
1362-
taskStatus.status === "cancelled"
1363-
) {
1364-
taskCompleted = true;
1365-
if (taskStatus.status === "completed") {
1366-
const result =
1367-
await inspectorClient.getRequestorTaskResult(taskId);
1368-
setToolResult(result as CompatibilityCallToolResult);
1369-
} else {
1370-
setToolResult({
1356+
1357+
try {
1358+
const invocation = await inspectorClient.callToolStream(
1359+
name,
1360+
cleanedParams as Record<string, JsonValue>,
1361+
generalMetadata,
1362+
toolSpecificMetadata,
1363+
taskOptions,
1364+
);
1365+
1366+
const compatibilityResult: CompatibilityCallToolResult =
1367+
invocation.result
1368+
? {
1369+
content: invocation.result.content || [],
1370+
isError: false,
1371+
}
1372+
: {
13711373
content: [
13721374
{
13731375
type: "text",
1374-
text: `Task ${taskStatus.status}: ${taskStatus.statusMessage ?? "No additional information"}`,
1376+
text: invocation.error || "Tool call failed",
13751377
},
13761378
],
13771379
isError: true,
1378-
});
1379-
}
1380-
void inspectorClient.listRequestorTasks();
1381-
} else {
1382-
const pollingResponseMeta =
1383-
rawResult &&
1384-
typeof rawResult === "object" &&
1385-
"_meta" in rawResult
1386-
? ((rawResult as { _meta?: Record<string, unknown> })._meta ??
1387-
{})
1388-
: undefined;
1389-
setToolResult({
1390-
content: [
1391-
{
1392-
type: "text",
1393-
text: `Task status: ${taskStatus.status}${taskStatus.statusMessage ? ` - ${taskStatus.statusMessage}` : ""}. Polling...`,
1394-
},
1395-
],
1396-
_meta: {
1397-
...(pollingResponseMeta || {}),
1398-
"io.modelcontextprotocol/related-task": { taskId },
1399-
},
1400-
} as CompatibilityCallToolResult);
1401-
void inspectorClient.listRequestorTasks();
1402-
}
1403-
} catch (pollingError) {
1404-
setToolResult({
1405-
content: [
1406-
{
1407-
type: "text",
1408-
text: `Error polling task status: ${pollingError instanceof Error ? pollingError.message : String(pollingError)}`,
1409-
},
1410-
],
1411-
isError: true,
1412-
});
1413-
taskCompleted = true;
1414-
}
1380+
};
1381+
setToolResult(compatibilityResult);
1382+
} finally {
1383+
inspectorClient.removeEventListener("taskCreated", onTaskCreated);
1384+
inspectorClient.removeEventListener(
1385+
"taskStatusChange",
1386+
onTaskStatusChange,
1387+
);
1388+
setIsPollingTask(false);
14151389
}
1416-
setIsPollingTask(false);
14171390
} else {
1418-
// Convert ToolCallInvocation to CompatibilityCallToolResult
1391+
// Use callTool for non-task execution
1392+
const invocation = await inspectorClient.callTool(
1393+
name,
1394+
cleanedParams as Record<string, JsonValue>,
1395+
generalMetadata,
1396+
toolSpecificMetadata,
1397+
undefined, // no task options
1398+
);
1399+
14191400
const compatibilityResult: CompatibilityCallToolResult =
14201401
invocation.result
14211402
? {
@@ -1433,21 +1414,18 @@ const App = () => {
14331414
};
14341415
setToolResult(compatibilityResult);
14351416
}
1436-
// Clear any validation errors since tool execution completed
14371417
setErrors((prev) => ({ ...prev, tools: null }));
14381418
} catch (e) {
14391419
setIsPollingTask(false);
1440-
const toolResult: CompatibilityCallToolResult = {
1420+
setToolResult({
14411421
content: [
14421422
{
14431423
type: "text",
14441424
text: (e as Error).message ?? String(e),
14451425
},
14461426
],
14471427
isError: true,
1448-
};
1449-
setToolResult(toolResult);
1450-
// Clear validation errors - tool execution errors are shown in ToolResults
1428+
});
14511429
setErrors((prev) => ({ ...prev, tools: null }));
14521430
}
14531431
};

web/src/components/ListPane.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ type ListPaneProps<T> = {
1212
title: string;
1313
buttonText: string;
1414
isButtonDisabled?: boolean;
15+
/** When true, the list scroll area fills available vertical space instead of max-h-96 */
16+
fillHeight?: boolean;
1517
};
1618

1719
const ListPane = <T extends object>({
@@ -23,6 +25,7 @@ const ListPane = <T extends object>({
2325
title,
2426
buttonText,
2527
isButtonDisabled,
28+
fillHeight = false,
2629
}: ListPaneProps<T>) => {
2730
const [searchQuery, setSearchQuery] = useState("");
2831
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
@@ -56,8 +59,10 @@ const ListPane = <T extends object>({
5659
};
5760

5861
return (
59-
<div className="bg-card border border-border rounded-lg shadow">
60-
<div className="p-4 border-b border-gray-200 dark:border-border">
62+
<div
63+
className={`bg-card border border-border rounded-lg shadow ${fillHeight ? "flex flex-col h-full min-h-0 overflow-hidden" : ""}`}
64+
>
65+
<div className="p-4 flex-shrink-0 border-b border-gray-200 dark:border-border">
6166
<div className="flex items-center justify-between gap-4">
6267
<h3 className="font-semibold dark:text-white flex-shrink-0">
6368
{title}
@@ -92,7 +97,9 @@ const ListPane = <T extends object>({
9297
</div>
9398
</div>
9499
</div>
95-
<div className="p-4">
100+
<div
101+
className={`p-4 ${fillHeight ? "flex flex-col flex-1 min-h-0 overflow-hidden" : ""}`}
102+
>
96103
<Button
97104
variant="outline"
98105
className="w-full mb-4"
@@ -111,7 +118,9 @@ const ListPane = <T extends object>({
111118
Clear
112119
</Button>
113120
)}
114-
<div className="space-y-2 overflow-y-auto max-h-96">
121+
<div
122+
className={`space-y-2 overflow-y-auto ${fillHeight ? "flex-1 min-h-0" : "max-h-96"}`}
123+
>
115124
{filteredItems.map((item, index) => (
116125
<div
117126
key={index}

web/src/components/PromptsTab.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,12 @@ const PromptsTab = ({
101101
};
102102

103103
return (
104-
<TabsContent value="prompts">
105-
<div className="grid grid-cols-2 gap-4">
104+
<TabsContent value="prompts" className="flex-1 flex flex-col min-h-0 mt-0">
105+
<div className="grid flex-1 min-h-0 grid-cols-2 grid-rows-[1fr] gap-4">
106106
<ListPane
107107
items={prompts}
108108
listItems={listPrompts}
109+
fillHeight
109110
clearItems={() => {
110111
clearPrompts();
111112
setSelectedPrompt(null);
@@ -133,8 +134,8 @@ const PromptsTab = ({
133134
isButtonDisabled={!nextCursor && prompts.length > 0}
134135
/>
135136

136-
<div className="bg-card border border-border rounded-lg shadow">
137-
<div className="p-4 border-b border-gray-200 dark:border-border">
137+
<div className="flex flex-col h-full min-h-0 overflow-hidden bg-card border border-border rounded-lg shadow">
138+
<div className="p-4 flex-shrink-0 border-b border-gray-200 dark:border-border">
138139
<div className="flex items-center gap-2">
139140
{selectedPrompt && (
140141
<IconDisplay
@@ -147,7 +148,7 @@ const PromptsTab = ({
147148
</h3>
148149
</div>
149150
</div>
150-
<div className="p-4">
151+
<div className="flex-1 min-h-0 overflow-y-auto p-4">
151152
{error ? (
152153
<Alert variant="destructive">
153154
<AlertCircle className="h-4 w-4" />

0 commit comments

Comments
 (0)