inspector/clients/web/src/components/groups/SamplingRequestPanel/SamplingRequestPanel.tsx
Cliff Hall 9b2c188d11
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
MRTR: drive input_required manually to keep pending-request UX (#1704) (#1719)
* MRTR: drive input_required manually to keep pending-request UX (#1704)

Follow-up to #1627/#1705. Instead of letting the SDK auto-fulfil the modern
(2026-07-28) MRTR retry loop, InspectorClient now drives it itself so the
Inspector keeps its pending-request UX, owns the loop, and shows era-accurate
semantics.

Core (core/mcp/inspectorClient.ts):
- Construct the SDK Client with `inputRequired: { autoFulfill: false }`
  (unconditional; a no-op on legacy, which never returns input_required).
- New `requestWithInputRequired()` driver: issues the request with
  `allowInputRequired` + `withInputRequired(schema)`, and while the result is
  `input_required`, fulfils each embedded request through the SAME pending
  sampling/elicitation plumbing, then retries the original call with
  `inputResponses` + the echoed `requestState` on a fresh id, bounded by
  MRTR_MAX_ROUNDS.
- Route tools/call, prompts/get, and resources/read through the driver.
- Embedded roots/list is auto-answered from configured roots (no pending UX);
  decline/cancel is echoed to the server (not treated as an abort); a tool-call
  abort mid-round settles the pending request and surfaces
  ToolCallCancelledError.
- Extract shared `enqueuePendingSample`/`enqueuePendingElicitation` helpers used
  by both the legacy inbound handlers and the driver; add a per-request `origin`
  ("server-request" | "input-required") to the pending wrappers.

Web UI:
- New `MrtrOriginNote` element shows an "input_required — your answer returns as
  a retry" note on a modern MRTR round; legacy panels are unchanged.
- Thread `origin` App -> PendingClientRequestModal -> the sampling/elicitation
  panels (and the inline panels).

Test servers: new MRTR presets (multi-round, roots, sampling, loop, edge-case
param shaping). Integration tests cover the manual round-trip (asserting the
pending UI pauses and the retry carries inputResponses + requestState on a new
id), multi-round, roots auto-answer, sampling, decline-echoed, maxRounds bound,
abort mid-round, and requestState-only/inputRequests-only param shaping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Review: clarify sampling-reject and between-rounds abort comments (#1704)

Address review nits on PR #1719 (comment-only, no behavior change):
- enqueuePendingSample docstring: sampling has no decline/cancel action; the
  panel either sends a result (resolve) or Rejects (reject → fails the call).
  The shared "declined/cancelled resolves" wording was accurate only for the
  elicitation case.
- callToolWithRetries abort comment: note the between-rounds case — when the
  abort lands while an MRTR round awaits an embedded pending request there is no
  in-flight SDK request, so nothing is sent on the wire; the outcome
  (ToolCallCancelledError, no failed-call record) is identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* test-servers: add mrtr-showcase config exercising every MRTR preset (#1704)

A single modern-era streamable-HTTP server (port 3111) bundling all six MRTR
presets — mrtr_confirm, mrtr_two_step, mrtr_sample, mrtr_roots, mrtr_edge,
mrtr_loop — for manual testing of the manual-driving pending-request UX across
single-round, multi-round, sampling, roots, param-shaping, and maxRounds paths.
Document it in the README alongside modern-mrtr-http.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:15:19 -04:00

260 lines
7.9 KiB
TypeScript

import {
Badge,
Button,
Divider,
Group,
Paper,
ScrollArea,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type {
CreateMessageRequestParams,
CreateMessageResult,
} from "@modelcontextprotocol/client";
import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js";
import { MessageBubble } from "../../elements/MessageBubble/MessageBubble";
import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote";
export interface SamplingRequestPanelProps {
request: CreateMessageRequestParams;
draftResult: CreateMessageResult;
onResultChange: (result: CreateMessageResult) => void;
onSend: () => void;
onReject: () => void;
/**
* How the request reached the Inspector — a legacy server→client request or a
* modern MRTR `input_required` round. Drives the era-accurate note; defaults
* to `"server-request"` (legacy, note hidden). (#1704)
*/
origin?: PendingRequestOrigin;
/**
* A response has been dispatched; lock the actions so a second click can't
* resolve the request twice (the underlying handler throws if called again).
*/
busy?: boolean;
}
function formatPriority(value: number): string {
if (value < 0.4) return `low (${value})`;
if (value <= 0.7) return `medium (${value})`;
return `high (${value})`;
}
function formatOptional(value: unknown, fallback: string): string {
return value !== undefined ? String(value) : fallback;
}
function serializeJson(value: unknown): string {
return JSON.stringify(value);
}
// Section headings. `order: 3` (not 5) keeps them one level below the host
// modal's `h2` title so the outline doesn't skip a level (axe `heading-order`);
// `size: "h5"` preserves the original small visual size.
const SectionTitle = Title.withProps({ order: 3, size: "h5" });
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
});
// Only the request/response content scrolls; the Reject / Send Response actions
// stay pinned below (in normal flow), always in view — the modal has no other
// close affordance, so the actions must never scroll out of reach. Capping the
// content at the modal's max height (≈90dvh) minus the ~10rem of non-content
// chrome (modal header/padding + the pinned actions + gaps) keeps the whole
// modal within the viewport; `ScrollArea.Autosize` sizes to content when the
// request is short (nothing scrolls) and scrolls once it hits the cap. (Mirrors
// ElicitationFormPanel.)
const ContentScroll = ScrollArea.Autosize.withProps({
mah: "calc(90dvh - 10rem)",
scrollbars: "y",
offsetScrollbars: true,
});
// Inner column for the scrolled content, so `ContentScroll` wraps a single
// child and the sections keep their `md` spacing.
const ContentStack = Stack.withProps({ gap: "md" });
const PreferencesContainer = Paper.withProps({
p: "sm",
withBorder: true,
});
const RejectButton = Button.withProps({
variant: "light",
color: "red",
});
export function SamplingRequestPanel({
request,
draftResult,
onResultChange,
onSend,
onReject,
origin = "server-request",
busy = false,
}: SamplingRequestPanelProps) {
const {
messages,
modelPreferences,
maxTokens,
stopSequences,
temperature,
includeContext,
} = request;
const hints =
(modelPreferences?.hints?.map((h) => h.name).filter(Boolean) as string[]) ??
[];
return (
<Stack gap="md">
<ContentScroll>
<ContentStack>
<HintText>The server is requesting an LLM completion.</HintText>
<MrtrOriginNote origin={origin} />
<SectionTitle>Messages:</SectionTitle>
{messages.map((message, index) => (
<MessageBubble key={index} index={index} message={message} />
))}
{modelPreferences && (
<PreferencesContainer>
<Stack gap="xs">
<SectionTitle>Model Preferences:</SectionTitle>
{hints.length > 0 && (
<Group gap="xs">
<Text size="sm">Hints:</Text>
{hints.map((hint) => (
<Badge key={hint}>{hint}</Badge>
))}
</Group>
)}
{modelPreferences?.costPriority !== undefined && (
<Text size="sm">
Cost Priority:{" "}
{formatPriority(modelPreferences.costPriority)}
</Text>
)}
{modelPreferences?.speedPriority !== undefined && (
<Text size="sm">
Speed Priority:{" "}
{formatPriority(modelPreferences.speedPriority)}
</Text>
)}
{modelPreferences?.intelligencePriority !== undefined && (
<Text size="sm">
Intelligence Priority:{" "}
{formatPriority(modelPreferences.intelligencePriority)}
</Text>
)}
</Stack>
</PreferencesContainer>
)}
<SectionTitle>Parameters:</SectionTitle>
<Text size="sm">
Max Tokens: {formatOptional(maxTokens, "not specified")}
</Text>
<Text size="sm">
Stop Sequences:{" "}
{stopSequences ? serializeJson(stopSequences) : "not specified"}
</Text>
<Text size="sm">
Temperature: {formatOptional(temperature, "not specified")}
</Text>
{includeContext && (
<Group gap="xs">
<Text size="sm">Include Context:</Text>
<Badge>{includeContext}</Badge>
</Group>
)}
<Divider />
<SectionTitle>Response:</SectionTitle>
<Textarea
aria-label="Response"
value={
draftResult.content.type === "text"
? draftResult.content.text
: ""
}
onChange={(event) =>
onResultChange({
...draftResult,
content: { type: "text", text: event.currentTarget.value },
})
}
autosize
minRows={3}
rightSectionPointerEvents="auto"
rightSection={
draftResult.content.type === "text" &&
draftResult.content.text ? (
<ClearButton
onClick={() =>
onResultChange({
...draftResult,
content: { type: "text", text: "" },
})
}
/>
) : null
}
/>
<Group>
<TextInput
label="Model Used"
value={draftResult.model}
onChange={(event) =>
onResultChange({
...draftResult,
model: event.currentTarget.value,
})
}
rightSectionPointerEvents="auto"
rightSection={
draftResult.model ? (
<ClearButton
onClick={() =>
onResultChange({ ...draftResult, model: "" })
}
/>
) : null
}
/>
<Select
label="Stop Reason"
data={["endTurn", "stopSequence", "maxTokens"]}
value={draftResult.stopReason ?? null}
onChange={(value) =>
onResultChange({
...draftResult,
stopReason: value ?? undefined,
})
}
/>
</Group>
</ContentStack>
</ContentScroll>
<Group justify="flex-end">
<RejectButton onClick={onReject} disabled={busy}>
Reject
</RejectButton>
<Button onClick={onSend} disabled={busy}>
Send Response
</Button>
</Group>
</Stack>
);
}