Wires the ported Tasks screen + core task subsystem through App.tsx: sticky Clear Completed, cancel/refresh error toasts, core-side per-task progress correlation, Run-as-task on the Tools screen (gated on server support), per-server Task TTL, and live task-status toasts.
This commit is contained in:
parent
8923a9bbf9
commit
ed6eda7492
7 changed files with 1192 additions and 40 deletions
|
|
@ -1517,16 +1517,43 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
if (taskOptions?.ttl != null) {
|
||||
streamParams.task = { ttl: taskOptions.ttl };
|
||||
}
|
||||
const stream = this.client.experimental.tasks.callToolStream(
|
||||
streamParams as CallToolRequest["params"],
|
||||
undefined, // Use default CallToolResultSchema
|
||||
this.getRequestOptions(metadata?.progressToken),
|
||||
);
|
||||
|
||||
let finalResult: CallToolResult | undefined;
|
||||
let taskId: string | undefined;
|
||||
let error: Error | undefined;
|
||||
|
||||
// Correlate progress → task. getRequestOptions already wires onprogress to
|
||||
// dispatch the generic progressNotification (keyed by the caller's
|
||||
// progressToken). Wrap it so each tick that arrives after the task is
|
||||
// created also dispatches requestorTaskProgress tagged with the taskId
|
||||
// this stream owns — the only place that mapping is known. Ticks before
|
||||
// taskCreated (rare) just fall through to the generic event.
|
||||
//
|
||||
// Gate on `this.progress`, mirroring getRequestOptions: when progress is
|
||||
// globally disabled there's no inner handler to wrap, and we must not
|
||||
// attach one here either — doing so would request a progress token (and
|
||||
// emit requestorTaskProgress) for task calls only, bypassing the toggle
|
||||
// that governs every other call path.
|
||||
const requestOptions = this.getRequestOptions(metadata?.progressToken);
|
||||
if (this.progress) {
|
||||
const innerOnProgress = requestOptions.onprogress;
|
||||
requestOptions.onprogress = (progress: Progress) => {
|
||||
innerOnProgress?.(progress);
|
||||
if (taskId) {
|
||||
this.dispatchTypedEvent("requestorTaskProgress", {
|
||||
taskId,
|
||||
progress,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const stream = this.client.experimental.tasks.callToolStream(
|
||||
streamParams as CallToolRequest["params"],
|
||||
undefined, // Use default CallToolResultSchema
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
// Iterate through the async generator
|
||||
for await (const message of stream) {
|
||||
switch (message.type) {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,13 @@ export interface InspectorClientEventMap {
|
|||
error?: McpError;
|
||||
};
|
||||
taskCancelled: { taskId: string };
|
||||
/**
|
||||
* Fired from callToolStream when a progress notification arrives for a
|
||||
* task-augmented tool call, tagged with the taskId callToolStream owns so
|
||||
* consumers can correlate progress → task (the generic progressNotification
|
||||
* event carries only the caller's progressToken, not the taskId).
|
||||
*/
|
||||
requestorTaskProgress: { taskId: string; progress: Progress };
|
||||
tasksChange: Task[];
|
||||
// Signal events (no payload)
|
||||
connect: void;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { streamSSE } from "hono/streaming";
|
|||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import { createTransportNode } from "../../node/transport.js";
|
||||
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
|
||||
import { DEFAULT_TASK_TTL_MS } from "../../types.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
|
|
@ -939,6 +940,13 @@ export function createRemoteApp(
|
|||
);
|
||||
delete valObj.requestTimeout;
|
||||
}
|
||||
if ("taskTtl" in valObj && !isNonNegNumber(valObj.taskTtl)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "taskTtl" },
|
||||
"Dropping malformed `taskTtl` field — expected non-negative number.",
|
||||
);
|
||||
delete valObj.taskTtl;
|
||||
}
|
||||
if ("oauth" in valObj && !isOauthObject(valObj.oauth)) {
|
||||
logWarn(
|
||||
{ route: "/api/servers", id, droppedKey: "oauth" },
|
||||
|
|
@ -1068,6 +1076,17 @@ export function createRemoteApp(
|
|||
error: "settings.requestTimeout must be a non-negative number",
|
||||
};
|
||||
}
|
||||
// taskTtl is optional on the wire (older clients won't send it); when
|
||||
// present it must be a non-negative number, otherwise it defaults below.
|
||||
if (
|
||||
obj.taskTtl !== undefined &&
|
||||
(typeof obj.taskTtl !== "number" || obj.taskTtl < 0)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.taskTtl must be a non-negative number",
|
||||
};
|
||||
}
|
||||
for (const optional of [
|
||||
"oauthClientId",
|
||||
"oauthClientSecret",
|
||||
|
|
@ -1089,6 +1108,12 @@ export function createRemoteApp(
|
|||
metadata: obj.metadata as { key: string; value: string }[],
|
||||
connectionTimeout: obj.connectionTimeout as number,
|
||||
requestTimeout: obj.requestTimeout as number,
|
||||
// Absent → product default, matching the read side
|
||||
// (storedFieldsToInspectorSettings). The default is the omit-sentinel in
|
||||
// inspectorSettingsToStoredFields, so this won't write a spurious taskTtl
|
||||
// to disk for a client that didn't send one.
|
||||
taskTtl:
|
||||
typeof obj.taskTtl === "number" ? obj.taskTtl : DEFAULT_TASK_TTL_MS,
|
||||
};
|
||||
if (typeof obj.oauthClientId === "string" && obj.oauthClientId !== "") {
|
||||
value.oauthClientId = obj.oauthClientId;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
* remote-server route handlers.
|
||||
*/
|
||||
|
||||
import { DEFAULT_TASK_TTL_MS } from "./types.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPConfig,
|
||||
|
|
@ -64,7 +65,12 @@ export function normalizeServerType(
|
|||
*/
|
||||
type StoredInspectorFields = Pick<
|
||||
StoredMCPServer,
|
||||
"headers" | "metadata" | "connectionTimeout" | "requestTimeout" | "oauth"
|
||||
| "headers"
|
||||
| "metadata"
|
||||
| "connectionTimeout"
|
||||
| "requestTimeout"
|
||||
| "taskTtl"
|
||||
| "oauth"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
|
@ -87,6 +93,7 @@ export function storedFieldsToInspectorSettings(
|
|||
stored.metadata !== undefined ||
|
||||
stored.connectionTimeout !== undefined ||
|
||||
stored.requestTimeout !== undefined ||
|
||||
stored.taskTtl !== undefined ||
|
||||
stored.oauth !== undefined;
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
|
|
@ -99,6 +106,9 @@ export function storedFieldsToInspectorSettings(
|
|||
metadata: stored.metadata ?? [],
|
||||
connectionTimeout: stored.connectionTimeout ?? 0,
|
||||
requestTimeout: stored.requestTimeout ?? 0,
|
||||
// Unlike the timeouts (0 = "SDK default"), task TTL has a concrete product
|
||||
// default so the form shows it and "Run as task" has a value to send.
|
||||
taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS,
|
||||
};
|
||||
// Truthiness drops empty-string OAuth fields — mirrors the write-side
|
||||
// coercion in `validateSettings` (server.ts) so a round-trip can't
|
||||
|
|
@ -148,6 +158,13 @@ export function inspectorSettingsToStoredFields(
|
|||
if (settings.requestTimeout > 0) {
|
||||
out.requestTimeout = settings.requestTimeout;
|
||||
}
|
||||
// Persist taskTtl only when it's a non-default positive value. The product
|
||||
// default (DEFAULT_TASK_TTL_MS) is the omit-sentinel here — an absent field
|
||||
// reads back as the default (above), so writing the default would inject it
|
||||
// into hand-edited files that never had it and break byte-stable round-trips.
|
||||
if (settings.taskTtl > 0 && settings.taskTtl !== DEFAULT_TASK_TTL_MS) {
|
||||
out.taskTtl = settings.taskTtl;
|
||||
}
|
||||
|
||||
const oauthFields: {
|
||||
clientId?: string;
|
||||
|
|
@ -184,6 +201,7 @@ const INSPECTOR_FIELD_KEY_MAP = {
|
|||
metadata: true,
|
||||
connectionTimeout: true,
|
||||
requestTimeout: true,
|
||||
taskTtl: true,
|
||||
oauth: true,
|
||||
} as const satisfies Record<keyof StoredInspectorFields, true>;
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ import { mergeTaskIntoList } from "./mergeTaskIntoList.js";
|
|||
|
||||
const MAX_PAGES = 100;
|
||||
|
||||
// Terminal task states — a task in one of these can no longer change, so
|
||||
// "Clear Completed" targets exactly these.
|
||||
const TERMINAL_STATUSES: ReadonlySet<Task["status"]> = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export interface ManagedRequestorTasksStateEventMap {
|
||||
tasksChange: Task[];
|
||||
}
|
||||
|
|
@ -31,6 +39,11 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
private tasks: Task[] = [];
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
// Task ids the user dismissed via clearCompleted(). Sticky for the session:
|
||||
// these are filtered out of refresh() results and ignored by the live-merge
|
||||
// handlers so a late status update (or a server that still lists the task)
|
||||
// can't resurrect a cleared task. Reset on disconnect / destroy.
|
||||
private dismissedTaskIds = new Set<string>();
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
|
|
@ -44,6 +57,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
const onStatusChange = (): void => {
|
||||
if (this.client?.getStatus() === "disconnected") {
|
||||
this.tasks = [];
|
||||
this.dismissedTaskIds.clear();
|
||||
this.dispatchTypedEvent("tasksChange", []);
|
||||
}
|
||||
};
|
||||
|
|
@ -51,6 +65,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
e: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
|
||||
): void => {
|
||||
const { taskId, task } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
|
||||
this.dispatchTypedEvent("tasksChange", this.tasks);
|
||||
};
|
||||
|
|
@ -58,6 +73,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
e: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
|
||||
): void => {
|
||||
const { taskId, task } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
|
||||
this.dispatchTypedEvent("tasksChange", this.tasks);
|
||||
};
|
||||
|
|
@ -65,6 +81,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
e: TypedEventGeneric<InspectorClientEventMap, "taskCancelled">,
|
||||
): void => {
|
||||
const { taskId } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
const idx = this.tasks.findIndex((t) => t.taskId === taskId);
|
||||
if (idx >= 0) {
|
||||
const next = [...this.tasks];
|
||||
|
|
@ -123,9 +140,12 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
let pageCount = 0;
|
||||
do {
|
||||
const result = await client.listRequestorTasks(cursor);
|
||||
this.tasks = cursor
|
||||
? [...this.tasks, ...result.tasks]
|
||||
: result.tasks;
|
||||
// Filter out tasks the user dismissed via clearCompleted() so a server
|
||||
// that still lists them (or an in-flight refresh) can't resurrect them.
|
||||
const page = result.tasks.filter(
|
||||
(t) => !this.dismissedTaskIds.has(t.taskId),
|
||||
);
|
||||
this.tasks = cursor ? [...this.tasks, ...page] : page;
|
||||
cursor = result.nextCursor;
|
||||
pageCount++;
|
||||
if (pageCount >= MAX_PAGES) {
|
||||
|
|
@ -138,9 +158,32 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
return this.getTasks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop terminal-state tasks (completed / failed / cancelled) from the list.
|
||||
* Their ids are remembered in `dismissedTaskIds` so they stay gone for the
|
||||
* rest of the session — a subsequent refresh() or live update won't bring
|
||||
* them back. No-op (and no event) when there's nothing terminal to clear.
|
||||
*/
|
||||
clearCompleted(): void {
|
||||
const remaining: Task[] = [];
|
||||
let dismissedAny = false;
|
||||
for (const task of this.tasks) {
|
||||
if (TERMINAL_STATUSES.has(task.status)) {
|
||||
this.dismissedTaskIds.add(task.taskId);
|
||||
dismissedAny = true;
|
||||
} else {
|
||||
remaining.push(task);
|
||||
}
|
||||
}
|
||||
if (!dismissedAny) return;
|
||||
this.tasks = remaining;
|
||||
this.dispatchTypedEvent("tasksChange", this.tasks);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.tasks = [];
|
||||
this.dismissedTaskIds.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
connectionTimeout?: number;
|
||||
/** Inspector-specific request timeout (ms). */
|
||||
requestTimeout?: number;
|
||||
/** Inspector-specific TTL (ms) for tasks created via "Run as task". */
|
||||
taskTtl?: number;
|
||||
/**
|
||||
* Pre-configured OAuth client credentials for HTTP transports. Nested to
|
||||
* match Claude Code's `.mcp.json` shape; lifted into the flat `oauthClientId`
|
||||
|
|
@ -341,6 +343,12 @@ export interface OAuthSettings {
|
|||
scopes: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default TTL (ms) for tasks created via "Run as task". Mirrors v1/v1.5's
|
||||
* `MCP_TASK_TTL` config default. Used when a server has no explicit `taskTtl`.
|
||||
*/
|
||||
export const DEFAULT_TASK_TTL_MS = 60000;
|
||||
|
||||
/**
|
||||
* Runtime settings for a configured server. A subset of
|
||||
* InspectorClientOptions (v1.5) relevant to the settings form:
|
||||
|
|
@ -351,6 +359,8 @@ export interface InspectorServerSettings {
|
|||
metadata: { key: string; value: string }[];
|
||||
connectionTimeout: number;
|
||||
requestTimeout: number;
|
||||
/** TTL (ms) for tasks created via "Run as task". Defaults to 60000. */
|
||||
taskTtl: number;
|
||||
oauthClientId?: string;
|
||||
oauthClientSecret?: string;
|
||||
oauthScopes?: string;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { TypedEventGeneric } from "../mcp/typedEventTarget.js";
|
|||
export interface UseManagedRequestorTasksResult {
|
||||
tasks: Task[];
|
||||
refresh: () => Promise<Task[]>;
|
||||
clearCompleted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,5 +55,9 @@ export function useManagedRequestorTasks(
|
|||
return next;
|
||||
}, [client, managedRequestorTasksState]);
|
||||
|
||||
return { tasks, refresh };
|
||||
const clearCompleted = useCallback((): void => {
|
||||
managedRequestorTasksState?.clearCompleted();
|
||||
}, [managedRequestorTasksState]);
|
||||
|
||||
return { tasks, refresh, clearCompleted };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue