Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 139268b057 | |||
| a5d872c18f | |||
| 7cf39d9a33 | |||
| 11960c66f6 | |||
| b84189b854 | |||
| ecbe9dd215 | |||
| 67acbabe6c |
@@ -0,0 +1,79 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/bundleManipulation", () => ({
|
||||
isValidMcpServerName: (name: string) => /^[A-Za-z0-9_-]+$/.test(name) && name.length <= 64,
|
||||
}));
|
||||
|
||||
import { AddMcpServerDialog, type McpServerFormResult } from "./AddMcpServerDialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderDialog(overrides: { onAdd?: (s: McpServerFormResult) => void } = {}) {
|
||||
const onAdd = overrides.onAdd ?? vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(<AddMcpServerDialog open onOpenChange={onOpenChange} onAdd={onAdd} />);
|
||||
return { onAdd, onOpenChange };
|
||||
}
|
||||
|
||||
describe("AddMcpServerDialog", () => {
|
||||
it("renders all form fields for stdio transport", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByTestId("add-mcp-name")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-mcp-transport")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-mcp-command")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-mcp-args")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("add-mcp-env")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables submit when name is empty", () => {
|
||||
renderDialog();
|
||||
expect(screen.getByTestId("add-mcp-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables submit when name is invalid", () => {
|
||||
renderDialog();
|
||||
fireEvent.change(screen.getByTestId("add-mcp-name"), { target: { value: "../evil" } });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-command"), { target: { value: "echo" } });
|
||||
expect(screen.getByTestId("add-mcp-submit")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows validation hint for invalid names", () => {
|
||||
renderDialog();
|
||||
fireEvent.change(screen.getByTestId("add-mcp-name"), { target: { value: "bad/name" } });
|
||||
expect(screen.getByText(/Letters, digits, hyphens, underscores only/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables submit with valid name and command", () => {
|
||||
renderDialog();
|
||||
fireEvent.change(screen.getByTestId("add-mcp-name"), { target: { value: "myserver" } });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-command"), { target: { value: "npx" } });
|
||||
expect(screen.getByTestId("add-mcp-submit")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("calls onAdd with stdio server data on submit", () => {
|
||||
const onAdd = vi.fn();
|
||||
renderDialog({ onAdd });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-name"), { target: { value: "github" } });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-command"), { target: { value: "npx" } });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-args"), { target: { value: "-y mcp-github" } });
|
||||
fireEvent.change(screen.getByTestId("add-mcp-env"), {
|
||||
target: { value: "TOKEN=abc123" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("add-mcp-submit"));
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
name: "github",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-github"],
|
||||
env: { TOKEN: "abc123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("disables submit when command is empty for stdio", () => {
|
||||
renderDialog();
|
||||
fireEvent.change(screen.getByTestId("add-mcp-name"), { target: { value: "myserver" } });
|
||||
// command is empty
|
||||
expect(screen.getByTestId("add-mcp-submit")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useState } from "react";
|
||||
import { isValidMcpServerName } from "@/lib/bundleManipulation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface McpServerFormResult {
|
||||
name: string;
|
||||
transport: "http" | "stdio";
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Parse "KEY=VALUE" lines into a Record. */
|
||||
function parseKVLines(text: string): Record<string, string> | undefined {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return undefined;
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of lines) {
|
||||
const eq = line.indexOf("=");
|
||||
if (eq > 0) {
|
||||
result[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for adding an MCP server to a session's agent mid-session.
|
||||
*
|
||||
* Collects server name, transport (stdio/http), and transport-specific
|
||||
* fields. On submit, passes the result back via `onAdd`.
|
||||
*/
|
||||
export function AddMcpServerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
submitting,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (server: McpServerFormResult) => void;
|
||||
submitting?: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [transport, setTransport] = useState<"http" | "stdio">("stdio");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headers, setHeaders] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [args, setArgs] = useState("");
|
||||
const [env, setEnv] = useState("");
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setTransport("stdio");
|
||||
setUrl("");
|
||||
setHeaders("");
|
||||
setCommand("");
|
||||
setArgs("");
|
||||
setEnv("");
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) return;
|
||||
|
||||
const result: McpServerFormResult = { name: trimmedName, transport };
|
||||
if (transport === "stdio") {
|
||||
result.command = command.trim() || undefined;
|
||||
result.args = args.trim().split(/\s+/).filter(Boolean);
|
||||
if (result.args.length === 0) result.args = undefined;
|
||||
result.env = parseKVLines(env);
|
||||
} else {
|
||||
result.url = url.trim() || undefined;
|
||||
result.headers = parseKVLines(headers);
|
||||
}
|
||||
|
||||
onAdd(result);
|
||||
}
|
||||
|
||||
const nameValid = isValidMcpServerName(name.trim());
|
||||
const canSubmit =
|
||||
nameValid &&
|
||||
!submitting &&
|
||||
(transport === "stdio" ? command.trim().length > 0 : url.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
data-testid="add-mcp-server-dialog"
|
||||
className="flex max-h-[85vh] flex-col gap-4 sm:max-w-md"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add MCP server</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
data-testid="add-mcp-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="server-name"
|
||||
autoFocus
|
||||
/>
|
||||
{name.trim().length > 0 && !nameValid && (
|
||||
<p className="text-[10px] text-destructive">
|
||||
Letters, digits, hyphens, underscores only
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Transport</label>
|
||||
<Select value={transport} onValueChange={(v: "http" | "stdio") => setTransport(v)}>
|
||||
<SelectTrigger data-testid="add-mcp-transport" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="http">http</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{transport === "stdio" ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Command <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
data-testid="add-mcp-command"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Arguments</label>
|
||||
<Input
|
||||
data-testid="add-mcp-args"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem /tmp"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Environment variables
|
||||
</label>
|
||||
<Textarea
|
||||
data-testid="add-mcp-env"
|
||||
value={env}
|
||||
onChange={(e) => setEnv(e.target.value)}
|
||||
placeholder={"KEY=VALUE per line\ne.g. GITHUB_TOKEN=ghp_..."}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
URL <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
data-testid="add-mcp-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://mcp.example.com/sse"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Headers</label>
|
||||
<Textarea
|
||||
data-testid="add-mcp-headers"
|
||||
value={headers}
|
||||
onChange={(e) => setHeaders(e.target.value)}
|
||||
placeholder={"KEY=VALUE per line\ne.g. Authorization=Bearer tok_..."}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button data-testid="add-mcp-submit" onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,17 @@ vi.mock("@/hooks/usePolicies", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/clipboard", () => ({ copyText: copyTextMock }));
|
||||
|
||||
// Mock bundleManipulation so SessionMcpSection renders without network.
|
||||
const { addMcpMock, removeMcpMock } = vi.hoisted(() => ({
|
||||
addMcpMock: vi.fn(() => Promise.resolve()),
|
||||
removeMcpMock: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
vi.mock("@/lib/bundleManipulation", () => ({
|
||||
addMcpServerToSession: addMcpMock,
|
||||
removeMcpServerFromSession: removeMcpMock,
|
||||
isValidMcpServerName: (name: string) => /^[A-Za-z0-9_-]+$/.test(name) && name.length <= 64,
|
||||
}));
|
||||
|
||||
import { AgentInfoButton, AgentInfoContent, agentDisplayLabel } from "./AgentInfo";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -407,3 +418,93 @@ describe("agentDisplayLabel", () => {
|
||||
expect(agentDisplayLabel("polly (fork conv_ab12)")).toBe("Polly");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SessionMcpSection (via AgentInfoContent with sessionId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("SessionMcpSection", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
addMcpMock.mockClear();
|
||||
removeMcpMock.mockClear();
|
||||
});
|
||||
|
||||
function renderContent(agent: Agent, sessionId: string) {
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<AgentInfoContent agent={agent} sessionId={sessionId} />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("shows the add MCP server button when sessionId is present", () => {
|
||||
renderContent({ id: "a", name: "test", mcp_servers: [] }, "conv_123");
|
||||
expect(screen.getByTestId("add-mcp-server-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'No MCP servers' when the agent has no tools", () => {
|
||||
renderContent({ id: "a", name: "test", mcp_servers: [] }, "conv_123");
|
||||
expect(screen.getByText("No MCP servers")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders MCP server pills as clickable buttons", () => {
|
||||
renderContent(
|
||||
{
|
||||
id: "a",
|
||||
name: "test",
|
||||
mcp_servers: [
|
||||
{ name: "github", transport: "stdio", command: "npx" },
|
||||
{ name: "search", transport: "http", url: "https://example.com" },
|
||||
],
|
||||
},
|
||||
"conv_123",
|
||||
);
|
||||
// Server names should be visible as buttons
|
||||
expect(screen.getByText("github")).toBeInTheDocument();
|
||||
expect(screen.getByText("search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the add dialog when the + button is clicked", () => {
|
||||
renderContent({ id: "a", name: "test", mcp_servers: [] }, "conv_123");
|
||||
fireEvent.click(screen.getByTestId("add-mcp-server-button"));
|
||||
expect(screen.getByTestId("add-mcp-server-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows remove button in the popover when a server pill is clicked", () => {
|
||||
renderContent(
|
||||
{
|
||||
id: "a",
|
||||
name: "test",
|
||||
mcp_servers: [{ name: "github", transport: "stdio", command: "npx" }],
|
||||
},
|
||||
"conv_123",
|
||||
);
|
||||
fireEvent.click(screen.getByText("github"));
|
||||
expect(screen.getByTestId("remove-mcp-server-github")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows server details in the popover", () => {
|
||||
renderContent(
|
||||
{
|
||||
id: "a",
|
||||
name: "test",
|
||||
mcp_servers: [
|
||||
{ name: "github", transport: "stdio", command: "npx", args: ["-y", "mcp-github"] },
|
||||
],
|
||||
},
|
||||
"conv_123",
|
||||
);
|
||||
fireEvent.click(screen.getByText("github"));
|
||||
// Transport badge
|
||||
expect(screen.getByText("stdio")).toBeInTheDocument();
|
||||
// Command + args
|
||||
expect(screen.getByText("npx -y mcp-github")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,9 @@ import { agentRootName } from "@/lib/forkHarness";
|
||||
import { nativeCodingAgentForAgentName } from "@/lib/nativeCodingAgents";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { addMcpServerToSession, removeMcpServerFromSession } from "@/lib/bundleManipulation";
|
||||
import { AddMcpServerDialog, type McpServerFormResult } from "@/components/AddMcpServerDialog";
|
||||
|
||||
/**
|
||||
* Display label for an agent name: the wrapper alias when mapped, else
|
||||
@@ -504,6 +507,152 @@ function AddPolicyDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session MCP tools section (editable via bundle manipulation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SessionMcpSection({
|
||||
sessionId,
|
||||
servers,
|
||||
}: {
|
||||
sessionId: string;
|
||||
servers: McpServerSummary[];
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The runner caches the agent spec for the session's lifetime and
|
||||
// does not re-fetch on bundle PUT. Changes only take effect after
|
||||
// reconnecting the session.
|
||||
const [pendingReconnect, setPendingReconnect] = useState(false);
|
||||
|
||||
async function handleAdd(server: McpServerFormResult) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addMcpServerToSession(sessionId, server);
|
||||
await queryClient.invalidateQueries({ queryKey: ["session-agent", sessionId] });
|
||||
setAddOpen(false);
|
||||
setPendingReconnect(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to add MCP server");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(serverName: string) {
|
||||
setRemoving(serverName);
|
||||
setError(null);
|
||||
try {
|
||||
await removeMcpServerFromSession(sessionId, serverName);
|
||||
await queryClient.invalidateQueries({ queryKey: ["session-agent", sessionId] });
|
||||
setPendingReconnect(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to remove MCP server");
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionLabel>Tools</SectionLabel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="rounded p-0.5 hover:bg-muted"
|
||||
title="Add MCP server"
|
||||
data-testid="add-mcp-server-button"
|
||||
>
|
||||
<PlusIcon className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
{servers.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{servers.map((srv) => (
|
||||
<Popover key={srv.name}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-0.5 rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground hover:bg-muted/80"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ServerIcon className="size-2.5 shrink-0" />
|
||||
{srv.name}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-64"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ServerIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{srv.name}</span>
|
||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
||||
{srv.transport}
|
||||
</span>
|
||||
</div>
|
||||
{srv.description && (
|
||||
<p className="text-xs text-muted-foreground">{srv.description}</p>
|
||||
)}
|
||||
{srv.command && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{srv.command} {srv.args?.join(" ")}
|
||||
</p>
|
||||
)}
|
||||
{srv.url && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground break-all">
|
||||
{srv.url}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(srv.name)}
|
||||
disabled={removing === srv.name}
|
||||
className="flex items-center gap-1 self-end rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50"
|
||||
data-testid={`remove-mcp-server-${srv.name}`}
|
||||
>
|
||||
<TrashIcon className="size-3" />
|
||||
{removing === srv.name ? "Removing…" : "Remove"}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No MCP servers</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="mcp-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pendingReconnect && (
|
||||
<p
|
||||
className="text-[10px] text-amber-600 dark:text-amber-400"
|
||||
data-testid="mcp-reconnect-hint"
|
||||
>
|
||||
Changes take effect on the next message
|
||||
</p>
|
||||
)}
|
||||
<AddMcpServerDialog
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onAdd={handleAdd}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session policies section (user-editable only)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -703,11 +852,15 @@ export function AgentInfoContent({ agent, sessionId }: AgentInfoProps) {
|
||||
{sessionId && usageByModel != null && Object.keys(usageByModel).length > 0 && (
|
||||
<ModelUsageBreakdown usageByModel={usageByModel} />
|
||||
)}
|
||||
{servers.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionLabel>Tools</SectionLabel>
|
||||
<McpServerList servers={servers} />
|
||||
</div>
|
||||
{sessionId ? (
|
||||
<SessionMcpSection sessionId={sessionId} servers={servers} />
|
||||
) : (
|
||||
servers.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<SectionLabel>Tools</SectionLabel>
|
||||
<McpServerList servers={servers} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{sessionId && <SessionPoliciesSection sessionId={sessionId} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseTar,
|
||||
rebuildTar,
|
||||
normalizeName,
|
||||
removeInlineMcpFromYaml,
|
||||
buildMcpYaml,
|
||||
isValidMcpServerName,
|
||||
type McpServerInput,
|
||||
} from "./bundleManipulation";
|
||||
|
||||
// ── Mock CompressionStream/DecompressionStream for jsdom ──────────
|
||||
|
||||
class PassthroughStream {
|
||||
readable: ReadableStream;
|
||||
writable: WritableStream;
|
||||
constructor() {
|
||||
let controller: ReadableStreamDefaultController;
|
||||
this.readable = new ReadableStream({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
});
|
||||
this.writable = new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
},
|
||||
close() {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).CompressionStream = PassthroughStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).DecompressionStream = PassthroughStream;
|
||||
|
||||
// ── Helpers to build server-shaped tar fixtures ────────────────────
|
||||
|
||||
/** Build a tar header for a directory entry (typeflag '5'). */
|
||||
function dirHeader(name: string): Uint8Array {
|
||||
const h = new Uint8Array(512);
|
||||
const enc = new TextEncoder();
|
||||
const dirName = name.endsWith("/") ? name : name + "/";
|
||||
h.set(enc.encode(dirName).slice(0, 100), 0);
|
||||
// mode 0755
|
||||
h.set(enc.encode("0000755\0"), 100);
|
||||
// size 0
|
||||
h.set(enc.encode("00000000000\0"), 124);
|
||||
// mtime
|
||||
h.set(enc.encode("00000000000\0"), 136);
|
||||
// typeflag '5' = directory
|
||||
h[156] = 0x35;
|
||||
// magic + version
|
||||
h.set(enc.encode("ustar\0"), 257);
|
||||
h.set(enc.encode("00"), 263);
|
||||
// checksum
|
||||
for (let i = 148; i < 156; i++) h[i] = 0x20;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
const sumStr = sum.toString(8).padStart(6, "0");
|
||||
h.set(enc.encode(sumStr), 148);
|
||||
h[154] = 0;
|
||||
h[155] = 0x20;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Build a tar header for a regular file (typeflag '0'). */
|
||||
function fileHeader(name: string, size: number): Uint8Array {
|
||||
const h = new Uint8Array(512);
|
||||
const enc = new TextEncoder();
|
||||
|
||||
let entryName = name;
|
||||
let prefix = "";
|
||||
if (entryName.length > 100) {
|
||||
const sep = entryName.lastIndexOf("/", 99);
|
||||
if (sep > 0) {
|
||||
prefix = entryName.slice(0, sep);
|
||||
entryName = entryName.slice(sep + 1);
|
||||
}
|
||||
}
|
||||
h.set(enc.encode(entryName).slice(0, 100), 0);
|
||||
if (prefix) h.set(enc.encode(prefix).slice(0, 155), 345);
|
||||
|
||||
h.set(enc.encode("0000644\0"), 100);
|
||||
const sizeStr = size.toString(8).padStart(11, "0");
|
||||
h.set(enc.encode(sizeStr + "\0"), 124);
|
||||
h.set(enc.encode("00000000000\0"), 136);
|
||||
h[156] = 0x30; // typeflag '0'
|
||||
h.set(enc.encode("ustar\0"), 257);
|
||||
h.set(enc.encode("00"), 263);
|
||||
// checksum
|
||||
for (let i = 148; i < 156; i++) h[i] = 0x20;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 512; i++) sum += h[i];
|
||||
const sumStr2 = sum.toString(8).padStart(6, "0");
|
||||
h.set(enc.encode(sumStr2), 148);
|
||||
h[154] = 0;
|
||||
h[155] = 0x20;
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a server-shaped tar archive with ./ prefixed entries,
|
||||
* directory members, and a config.yaml file.
|
||||
*/
|
||||
function buildServerShapedTar(files: { name: string; content: string }[]): Uint8Array {
|
||||
const blocks: Uint8Array[] = [];
|
||||
const enc = new TextEncoder();
|
||||
|
||||
// Add directory entries the server would include
|
||||
const dirs = new Set<string>();
|
||||
for (const f of files) {
|
||||
const parts = f.name.split("/");
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
dirs.add(parts.slice(0, i).join("/") + "/");
|
||||
}
|
||||
}
|
||||
// Root dir
|
||||
blocks.push(dirHeader("./"));
|
||||
for (const d of Array.from(dirs).sort()) {
|
||||
blocks.push(dirHeader(`./${d}`));
|
||||
}
|
||||
|
||||
// Add file entries
|
||||
for (const f of files) {
|
||||
const content = enc.encode(f.content);
|
||||
const header = fileHeader(`./${f.name}`, content.length);
|
||||
blocks.push(header);
|
||||
const padded = new Uint8Array(Math.ceil(content.length / 512) * 512);
|
||||
padded.set(content);
|
||||
blocks.push(padded);
|
||||
}
|
||||
|
||||
// End-of-archive
|
||||
blocks.push(new Uint8Array(1024));
|
||||
|
||||
const total = blocks.reduce((n, b) => n + b.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const b of blocks) {
|
||||
result.set(b, off);
|
||||
off += b.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeName", () => {
|
||||
it("strips leading ./", () => {
|
||||
expect(normalizeName("./config.yaml")).toBe("config.yaml");
|
||||
expect(normalizeName("./tools/mcp/foo.yaml")).toBe("tools/mcp/foo.yaml");
|
||||
});
|
||||
it("leaves unprefixed names unchanged", () => {
|
||||
expect(normalizeName("config.yaml")).toBe("config.yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidMcpServerName", () => {
|
||||
it("accepts valid names", () => {
|
||||
expect(isValidMcpServerName("github")).toBe(true);
|
||||
expect(isValidMcpServerName("my-server_2")).toBe(true);
|
||||
});
|
||||
it("rejects names with special characters", () => {
|
||||
expect(isValidMcpServerName("../../evil")).toBe(false);
|
||||
expect(isValidMcpServerName("foo/bar")).toBe(false);
|
||||
expect(isValidMcpServerName("has spaces")).toBe(false);
|
||||
expect(isValidMcpServerName("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTar + rebuildTar round-trip", () => {
|
||||
it("preserves a server-shaped tar with directory entries", () => {
|
||||
const configYaml = "spec_version: 1\nname: test\n";
|
||||
const tar = buildServerShapedTar([{ name: "config.yaml", content: configYaml }]);
|
||||
|
||||
const entries = parseTar(tar);
|
||||
// Should have directory entries (./ + root) + file
|
||||
const dirs = entries.filter((e) => e.typeflag === "5");
|
||||
const files = entries.filter((e) => e.typeflag === "0");
|
||||
expect(dirs.length).toBeGreaterThan(0);
|
||||
expect(files.length).toBe(1);
|
||||
expect(normalizeName(files[0].name)).toBe("config.yaml");
|
||||
|
||||
// Round-trip: rebuild should produce a valid tar
|
||||
const rebuilt = rebuildTar(entries);
|
||||
const reparsed = parseTar(rebuilt);
|
||||
expect(reparsed.length).toBe(entries.length);
|
||||
|
||||
// Directory entries must still have typeflag '5'
|
||||
const reDirs = reparsed.filter((e) => e.typeflag === "5");
|
||||
expect(reDirs.length).toBe(dirs.length);
|
||||
for (const d of reDirs) {
|
||||
expect(d.typeflag).toBe("5");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves ./-prefixed entry names through round-trip", () => {
|
||||
const tar = buildServerShapedTar([
|
||||
{ name: "config.yaml", content: "name: test\n" },
|
||||
{ name: "tools/mcp/github.yaml", content: "name: github\ntransport: stdio\n" },
|
||||
]);
|
||||
|
||||
const entries = parseTar(tar);
|
||||
const fileNames = entries.filter((e) => e.typeflag === "0").map((e) => e.name);
|
||||
expect(fileNames).toContain("./config.yaml");
|
||||
expect(fileNames).toContain("./tools/mcp/github.yaml");
|
||||
|
||||
// After rebuild, names are preserved
|
||||
const rebuilt = rebuildTar(entries);
|
||||
const reparsed = parseTar(rebuilt);
|
||||
const reParsedNames = reparsed.filter((e) => e.typeflag === "0").map((e) => e.name);
|
||||
expect(reParsedNames).toContain("./config.yaml");
|
||||
expect(reParsedNames).toContain("./tools/mcp/github.yaml");
|
||||
});
|
||||
|
||||
it("can add an MCP file to a server-shaped tar and preserve dirs", () => {
|
||||
const tar = buildServerShapedTar([{ name: "config.yaml", content: "name: test\n" }]);
|
||||
const entries = parseTar(tar);
|
||||
|
||||
// Simulate adding a new MCP server file
|
||||
const mcpYaml = new TextEncoder().encode("name: fs\ntransport: stdio\ncommand: npx\n");
|
||||
const newFileName = "./tools/mcp/fs.yaml";
|
||||
const newHeader = fileHeader(newFileName, mcpYaml.length);
|
||||
entries.push({
|
||||
header: newHeader,
|
||||
name: newFileName,
|
||||
typeflag: "0",
|
||||
content: mcpYaml,
|
||||
});
|
||||
|
||||
const rebuilt = rebuildTar(entries);
|
||||
const reparsed = parseTar(rebuilt);
|
||||
const fileNames = reparsed.filter((e) => e.typeflag === "0").map((e) => normalizeName(e.name));
|
||||
expect(fileNames).toContain("config.yaml");
|
||||
expect(fileNames).toContain("tools/mcp/fs.yaml");
|
||||
|
||||
// Dirs still have correct typeflag
|
||||
for (const e of reparsed.filter((e) => e.typeflag === "5")) {
|
||||
expect(e.content.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("can remove an MCP file using normalized name matching", () => {
|
||||
const tar = buildServerShapedTar([
|
||||
{ name: "config.yaml", content: "name: test\n" },
|
||||
{ name: "tools/mcp/github.yaml", content: "name: github\ntransport: stdio\n" },
|
||||
]);
|
||||
const entries = parseTar(tar);
|
||||
|
||||
// Remove github.yaml using normalized name matching
|
||||
const filtered = entries.filter((e) => normalizeName(e.name) !== "tools/mcp/github.yaml");
|
||||
expect(filtered.length).toBe(entries.length - 1);
|
||||
|
||||
const rebuilt = rebuildTar(filtered);
|
||||
const reparsed = parseTar(rebuilt);
|
||||
const fileNames = reparsed.filter((e) => e.typeflag === "0").map((e) => normalizeName(e.name));
|
||||
expect(fileNames).toContain("config.yaml");
|
||||
expect(fileNames).not.toContain("tools/mcp/github.yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeInlineMcpFromYaml", () => {
|
||||
it("removes an inline MCP block from config.yaml", () => {
|
||||
const yaml = [
|
||||
"tools:",
|
||||
" builtins:",
|
||||
" - web_search",
|
||||
" github:",
|
||||
" type: mcp",
|
||||
" command: npx",
|
||||
" args: [-y]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const result = removeInlineMcpFromYaml(yaml, "github");
|
||||
expect(result).toContain("builtins:");
|
||||
expect(result).not.toContain("github:");
|
||||
expect(result).not.toContain("type: mcp");
|
||||
});
|
||||
|
||||
it("preserves non-MCP blocks", () => {
|
||||
const yaml = [
|
||||
"tools:",
|
||||
" builtins:",
|
||||
" - web_search",
|
||||
" github:",
|
||||
" type: mcp",
|
||||
" command: npx",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const result = removeInlineMcpFromYaml(yaml, "other");
|
||||
expect(result).toContain("github:");
|
||||
expect(result).toContain("type: mcp");
|
||||
});
|
||||
|
||||
it("handles type: mcp not being the first child key", () => {
|
||||
const yaml = [
|
||||
"tools:",
|
||||
" myserver:",
|
||||
" command: npx",
|
||||
" type: mcp",
|
||||
" args: [-y]",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const result = removeInlineMcpFromYaml(yaml, "myserver");
|
||||
expect(result).not.toContain("myserver:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMcpYaml", () => {
|
||||
it("generates valid YAML for a stdio server", () => {
|
||||
const server: McpServerInput = {
|
||||
name: "github",
|
||||
transport: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
env: { GITHUB_TOKEN: "ghp_test" },
|
||||
};
|
||||
const yaml = buildMcpYaml(server);
|
||||
expect(yaml).toContain("name: github");
|
||||
expect(yaml).toContain("transport: stdio");
|
||||
expect(yaml).toContain("command: npx");
|
||||
expect(yaml).toContain("GITHUB_TOKEN: ghp_test");
|
||||
});
|
||||
|
||||
it("generates valid YAML for an HTTP server", () => {
|
||||
const server: McpServerInput = {
|
||||
name: "search",
|
||||
transport: "http",
|
||||
url: "https://mcp.example.com/sse",
|
||||
};
|
||||
const yaml = buildMcpYaml(server);
|
||||
expect(yaml).toContain("name: search");
|
||||
expect(yaml).toContain("transport: http");
|
||||
expect(yaml).toContain('url: "https://mcp.example.com/sse"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Client-side agent bundle manipulation: download, modify, and re-upload
|
||||
* `.tar.gz` bundles via the session agent endpoints.
|
||||
*
|
||||
* Used by the in-session MCP server editor to add/remove MCP server
|
||||
* YAML files (`tools/mcp/<name>.yaml`) from an existing agent bundle
|
||||
* without re-authoring the entire spec.
|
||||
*
|
||||
* The server produces `.`-rooted POSIX tars with directory entries
|
||||
* (typeflag `5`) and PAX extended headers (typeflag `x`). The
|
||||
* round-trip here **preserves raw headers** for non-regular-file
|
||||
* entries so the server's `extract_safe` accepts the re-upload.
|
||||
*/
|
||||
|
||||
import { authenticatedFetch } from "./identity";
|
||||
|
||||
// ── Tar helpers ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A raw tar entry — preserves the original 512-byte header verbatim
|
||||
* so directory/PAX/link entries round-trip without the builder needing
|
||||
* to understand every typeflag.
|
||||
*/
|
||||
interface RawTarEntry {
|
||||
/** Original 512-byte header, preserved exactly. */
|
||||
header: Uint8Array;
|
||||
/** Entry name (from header, with prefix). */
|
||||
name: string;
|
||||
/** Typeflag character from offset 156: '0'=file, '5'=dir, 'x'=PAX, etc. */
|
||||
typeflag: string;
|
||||
/** Content bytes (empty for directories). */
|
||||
content: Uint8Array;
|
||||
}
|
||||
|
||||
/** Decompress gzip bytes using the browser's DecompressionStream. */
|
||||
async function gunzip(data: ArrayBuffer): Promise<Uint8Array> {
|
||||
const ds = new DecompressionStream("gzip");
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(data);
|
||||
writer.close();
|
||||
const reader = ds.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new Uint8Array(value));
|
||||
}
|
||||
return concat(chunks);
|
||||
}
|
||||
|
||||
/** Gzip compress bytes. */
|
||||
async function gzip(data: Uint8Array): Promise<Uint8Array> {
|
||||
const cs = new CompressionStream("gzip");
|
||||
const writer = cs.writable.getWriter();
|
||||
writer.write(data.buffer as ArrayBuffer);
|
||||
writer.close();
|
||||
const reader = cs.readable.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new Uint8Array(value));
|
||||
}
|
||||
return concat(chunks);
|
||||
}
|
||||
|
||||
function concat(chunks: Uint8Array[]): Uint8Array {
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
result.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Read a null-terminated string from a tar header field. */
|
||||
function readTarString(tar: Uint8Array, offset: number, length: number): string {
|
||||
const slice = tar.slice(offset, offset + length);
|
||||
const nullIdx = slice.indexOf(0);
|
||||
return new TextDecoder().decode(nullIdx >= 0 ? slice.slice(0, nullIdx) : slice);
|
||||
}
|
||||
|
||||
/** Read an octal number from a tar header field. */
|
||||
function readTarOctal(tar: Uint8Array, offset: number, length: number): number {
|
||||
return parseInt(readTarString(tar, offset, length), 8) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the leading `./` prefix from a tar entry name.
|
||||
* Server bundles use `./config.yaml`, `./tools/mcp/foo.yaml`, etc.
|
||||
*/
|
||||
function normalizeName(name: string): string {
|
||||
return name.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a POSIX tar archive into raw entries, preserving headers
|
||||
* verbatim. Directory, PAX, and link entries are kept as-is so the
|
||||
* round-trip doesn't corrupt them.
|
||||
*/
|
||||
function parseTar(tar: Uint8Array): RawTarEntry[] {
|
||||
const entries: RawTarEntry[] = [];
|
||||
let pos = 0;
|
||||
while (pos + 512 <= tar.length) {
|
||||
// Check for end-of-archive (all-zero block)
|
||||
let allZero = true;
|
||||
for (let i = 0; i < 512; i++) {
|
||||
if (tar[pos + i] !== 0) {
|
||||
allZero = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allZero) break;
|
||||
|
||||
const header = tar.slice(pos, pos + 512);
|
||||
const nameField = readTarString(tar, pos, 100);
|
||||
const size = readTarOctal(tar, pos + 124, 12);
|
||||
const typeflag = String.fromCharCode(tar[pos + 156] || 0x30);
|
||||
const prefix = readTarString(tar, pos + 345, 155);
|
||||
const fullName = prefix ? `${prefix}/${nameField}` : nameField;
|
||||
|
||||
const contentStart = pos + 512;
|
||||
const contentBlocks = size > 0 ? Math.ceil(size / 512) : 0;
|
||||
const content = tar.slice(contentStart, contentStart + size);
|
||||
|
||||
entries.push({
|
||||
header: new Uint8Array(header),
|
||||
name: fullName,
|
||||
typeflag,
|
||||
content: new Uint8Array(content),
|
||||
});
|
||||
|
||||
pos = contentStart + contentBlocks * 512;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Write a number as null-terminated octal string into a tar header. */
|
||||
function writeOctal(header: Uint8Array, offset: number, length: number, value: number): void {
|
||||
const str = value.toString(8).padStart(length - 1, "0");
|
||||
const bytes = new TextEncoder().encode(str);
|
||||
header.set(bytes.slice(0, length - 1), offset);
|
||||
header[offset + length - 1] = 0;
|
||||
}
|
||||
|
||||
/** Compute and write the checksum for a tar header. */
|
||||
function writeChecksum(header: Uint8Array): void {
|
||||
// Fill checksum field with spaces first
|
||||
for (let i = 148; i < 156; i++) header[i] = 0x20;
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < 512; i++) checksum += header[i];
|
||||
writeOctal(header, 148, 7, checksum);
|
||||
header[155] = 0x20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh tar header for a new regular file entry.
|
||||
* Used only for newly added files (MCP YAML); existing entries
|
||||
* keep their original headers verbatim.
|
||||
*/
|
||||
function buildFileHeader(name: string, size: number): Uint8Array {
|
||||
const header = new Uint8Array(512);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Split long names into prefix (345) + name (100)
|
||||
let entryName = name;
|
||||
let prefix = "";
|
||||
if (entryName.length > 100) {
|
||||
const sep = entryName.lastIndexOf("/", 99);
|
||||
if (sep > 0) {
|
||||
prefix = entryName.slice(0, sep);
|
||||
entryName = entryName.slice(sep + 1);
|
||||
}
|
||||
}
|
||||
header.set(encoder.encode(entryName).slice(0, 100), 0);
|
||||
if (prefix) header.set(encoder.encode(prefix).slice(0, 155), 345);
|
||||
|
||||
writeOctal(header, 100, 8, 0o644); // mode
|
||||
writeOctal(header, 108, 8, 0); // uid
|
||||
writeOctal(header, 116, 8, 0); // gid
|
||||
writeOctal(header, 124, 12, size); // size
|
||||
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000)); // mtime
|
||||
header[156] = 0x30; // typeflag '0' = regular file
|
||||
header.set(encoder.encode("ustar\0"), 257); // magic
|
||||
header.set(encoder.encode("00"), 263); // version
|
||||
|
||||
writeChecksum(header);
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fresh tar header for a directory entry.
|
||||
*/
|
||||
function buildDirHeader(name: string): Uint8Array {
|
||||
const header = new Uint8Array(512);
|
||||
const encoder = new TextEncoder();
|
||||
const dirName = name.endsWith("/") ? name : name + "/";
|
||||
header.set(encoder.encode(dirName).slice(0, 100), 0);
|
||||
|
||||
writeOctal(header, 100, 8, 0o755); // mode
|
||||
writeOctal(header, 108, 8, 0); // uid
|
||||
writeOctal(header, 116, 8, 0); // gid
|
||||
writeOctal(header, 124, 12, 0); // size = 0 for dirs
|
||||
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000)); // mtime
|
||||
header[156] = 0x35; // typeflag '5' = directory
|
||||
header.set(encoder.encode("ustar\0"), 257);
|
||||
header.set(encoder.encode("00"), 263);
|
||||
|
||||
writeChecksum(header);
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble raw entries into a tar archive. Existing entries use
|
||||
* their preserved headers; content is padded to 512-byte blocks.
|
||||
*/
|
||||
function rebuildTar(entries: RawTarEntry[]): Uint8Array {
|
||||
const blocks: Uint8Array[] = [];
|
||||
for (const entry of entries) {
|
||||
blocks.push(entry.header);
|
||||
if (entry.content.length > 0) {
|
||||
const contentBlocks = Math.ceil(entry.content.length / 512);
|
||||
const padded = new Uint8Array(contentBlocks * 512);
|
||||
padded.set(entry.content);
|
||||
blocks.push(padded);
|
||||
}
|
||||
}
|
||||
// End-of-archive: two zero blocks
|
||||
blocks.push(new Uint8Array(1024));
|
||||
return concat(blocks);
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────
|
||||
|
||||
/** Validate an MCP server name: alphanumeric, hyphens, underscores only. */
|
||||
const MCP_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function isValidMcpServerName(name: string): boolean {
|
||||
return MCP_NAME_RE.test(name) && name.length <= 64;
|
||||
}
|
||||
|
||||
/** YAML quote a string value if it contains special characters. */
|
||||
function yamlQuote(s: string): string {
|
||||
if (/[:\n"'#{}[\],&*?|>!%@`]/.test(s) || s.trim() !== s) {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export interface McpServerInput {
|
||||
name: string;
|
||||
transport: "http" | "stdio";
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Build a tools/mcp/<name>.yaml content for an MCP server. */
|
||||
function buildMcpYaml(server: McpServerInput): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`name: ${server.name}`);
|
||||
lines.push(`transport: ${server.transport}`);
|
||||
if (server.transport === "stdio") {
|
||||
if (server.command) lines.push(`command: ${yamlQuote(server.command)}`);
|
||||
if (server.args?.length) {
|
||||
lines.push(`args: [${server.args.map((a) => yamlQuote(a)).join(", ")}]`);
|
||||
}
|
||||
if (server.env && Object.keys(server.env).length > 0) {
|
||||
lines.push("env:");
|
||||
for (const [k, v] of Object.entries(server.env)) {
|
||||
lines.push(` ${k}: ${yamlQuote(v)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (server.url) lines.push(`url: ${yamlQuote(server.url)}`);
|
||||
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||
lines.push("headers:");
|
||||
for (const [k, v] of Object.entries(server.headers)) {
|
||||
lines.push(` ${k}: ${yamlQuote(v)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current agent bundle, add an MCP server YAML file,
|
||||
* and re-upload via PUT.
|
||||
*/
|
||||
export async function addMcpServerToSession(
|
||||
sessionId: string,
|
||||
server: McpServerInput,
|
||||
): Promise<void> {
|
||||
if (!isValidMcpServerName(server.name)) {
|
||||
throw new Error(`Invalid server name: use only letters, digits, hyphens, underscores`);
|
||||
}
|
||||
|
||||
// 1. Download current bundle
|
||||
const res = await authenticatedFetch(
|
||||
`/v1/sessions/${encodeURIComponent(sessionId)}/agent/contents`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to download bundle: ${res.status}`);
|
||||
const bundleBytes = await res.arrayBuffer();
|
||||
|
||||
// 2. Decompress and parse tar (preserving raw headers)
|
||||
const tar = await gunzip(bundleBytes);
|
||||
const entries = parseTar(tar);
|
||||
|
||||
// 3. Determine the path prefix used by the bundle (./tools/mcp/ or tools/mcp/)
|
||||
const hasPrefix = entries.some((e) => e.name.startsWith("./"));
|
||||
const mcpDir = hasPrefix ? "./tools/mcp/" : "tools/mcp/";
|
||||
const toolsDir = hasPrefix ? "./tools/" : "tools/";
|
||||
const fileName = `${mcpDir}${server.name}.yaml`;
|
||||
|
||||
// Remove existing entry with same normalized name if present
|
||||
const filtered = entries.filter((e) => normalizeName(e.name) !== normalizeName(fileName));
|
||||
|
||||
// Ensure directory entries exist for ./tools/ and ./tools/mcp/
|
||||
const dirNames = new Set(filtered.map((e) => e.name));
|
||||
const newEntries: RawTarEntry[] = [];
|
||||
|
||||
if (!dirNames.has(toolsDir) && !dirNames.has(toolsDir.replace(/\/$/, ""))) {
|
||||
const dirHeader = buildDirHeader(toolsDir);
|
||||
newEntries.push({
|
||||
header: dirHeader,
|
||||
name: toolsDir,
|
||||
typeflag: "5",
|
||||
content: new Uint8Array(0),
|
||||
});
|
||||
}
|
||||
if (!dirNames.has(mcpDir) && !dirNames.has(mcpDir.replace(/\/$/, ""))) {
|
||||
const dirHeader = buildDirHeader(mcpDir);
|
||||
newEntries.push({ header: dirHeader, name: mcpDir, typeflag: "5", content: new Uint8Array(0) });
|
||||
}
|
||||
|
||||
// Add the new MCP server file
|
||||
const yamlContent = new TextEncoder().encode(buildMcpYaml(server));
|
||||
const fileHeader = buildFileHeader(fileName, yamlContent.length);
|
||||
newEntries.push({
|
||||
header: fileHeader,
|
||||
name: fileName,
|
||||
typeflag: "0",
|
||||
content: yamlContent,
|
||||
});
|
||||
|
||||
// Insert new entries before the end (after existing entries)
|
||||
const allEntries = [...filtered, ...newEntries];
|
||||
|
||||
// 4. Rebuild tar.gz and upload
|
||||
const newTar = rebuildTar(allEntries);
|
||||
const newGz = await gzip(newTar);
|
||||
await uploadBundle(sessionId, newGz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the current agent bundle, remove an MCP server YAML file
|
||||
* (and any inline declaration), and re-upload via PUT.
|
||||
*/
|
||||
export async function removeMcpServerFromSession(
|
||||
sessionId: string,
|
||||
serverName: string,
|
||||
): Promise<void> {
|
||||
// 1. Download current bundle
|
||||
const res = await authenticatedFetch(
|
||||
`/v1/sessions/${encodeURIComponent(sessionId)}/agent/contents`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to download bundle: ${res.status}`);
|
||||
const bundleBytes = await res.arrayBuffer();
|
||||
|
||||
// 2. Decompress and parse tar
|
||||
const tar = await gunzip(bundleBytes);
|
||||
const entries = parseTar(tar);
|
||||
|
||||
// 3. Remove the MCP server file (matching with normalized names)
|
||||
const mcpFileName = `tools/mcp/${serverName}.yaml`;
|
||||
let removed = false;
|
||||
const filtered = entries.filter((e) => {
|
||||
if (normalizeName(e.name) === mcpFileName) {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Also remove inline MCP declarations from config.yaml
|
||||
const configIdx = filtered.findIndex((e) => normalizeName(e.name) === "config.yaml");
|
||||
if (configIdx >= 0) {
|
||||
const configText = new TextDecoder().decode(filtered[configIdx].content);
|
||||
const cleaned = removeInlineMcpFromYaml(configText, serverName);
|
||||
if (cleaned !== configText) {
|
||||
removed = true;
|
||||
const newContent = new TextEncoder().encode(cleaned);
|
||||
// Build a fresh header with the updated size
|
||||
const newHeader = buildFileHeader(filtered[configIdx].name, newContent.length);
|
||||
filtered[configIdx] = {
|
||||
...filtered[configIdx],
|
||||
header: newHeader,
|
||||
content: newContent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) return; // Nothing changed — skip the PUT
|
||||
|
||||
// 4. Rebuild tar.gz and upload
|
||||
const newTar = rebuildTar(filtered);
|
||||
const newGz = await gzip(newTar);
|
||||
await uploadBundle(sessionId, newGz);
|
||||
}
|
||||
|
||||
/** Upload a rebuilt bundle via PUT. */
|
||||
async function uploadBundle(sessionId: string, gzBytes: Uint8Array): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"bundle",
|
||||
new File([gzBytes.buffer as ArrayBuffer], "agent.tar.gz", { type: "application/gzip" }),
|
||||
);
|
||||
const putRes = await authenticatedFetch(`/v1/sessions/${encodeURIComponent(sessionId)}/agent`, {
|
||||
method: "PUT",
|
||||
body: form,
|
||||
});
|
||||
if (!putRes.ok) {
|
||||
const text = await putRes.text();
|
||||
throw new Error(`Failed to update agent: ${putRes.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an inline MCP server block from config.yaml text.
|
||||
* Handles the `tools:` block format where MCP servers are declared as:
|
||||
* tools:
|
||||
* servername:
|
||||
* type: mcp
|
||||
* ...
|
||||
*/
|
||||
function removeInlineMcpFromYaml(yaml: string, serverName: string): string {
|
||||
const lines = yaml.split("\n");
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
// Match " <serverName>:" at the tools indentation level (2 spaces)
|
||||
if (line.match(new RegExp(`^ ${escapeRegex(serverName)}:\\s*$`))) {
|
||||
// Peek ahead to check if this block contains "type: mcp" anywhere
|
||||
// in its immediate children (indented 4+ spaces)
|
||||
let j = i + 1;
|
||||
let isMcp = false;
|
||||
while (j < lines.length && (lines[j].match(/^ \S/) || lines[j].trim() === "")) {
|
||||
if (lines[j].trim() === "type: mcp") {
|
||||
isMcp = true;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
if (isMcp) {
|
||||
// Skip the entire block
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Re-export for tests
|
||||
export { parseTar, rebuildTar, normalizeName, removeInlineMcpFromYaml, buildMcpYaml };
|
||||
export type { RawTarEntry };
|
||||
@@ -13465,6 +13465,36 @@ def create_runner_app(
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/v1/sessions/{session_id}/reload-spec")
|
||||
async def reload_session_spec(session_id: str) -> JSONResponse:
|
||||
"""Invalidate the runner's cached agent spec for a session.
|
||||
|
||||
Runner-internal endpoint the AP server calls after updating an
|
||||
agent's bundle via ``PUT /v1/sessions/{id}/agent``. Drops the
|
||||
spec cache and MCP tool schemas so the next turn re-resolves
|
||||
the spec from the server (picking up added/removed MCP servers,
|
||||
changed instructions, etc.).
|
||||
|
||||
Lighter than ``/reset-state``: does NOT tear down terminals,
|
||||
environments, or the harness subprocess — only the spec-derived
|
||||
caches that control which tools are available.
|
||||
|
||||
:param session_id: Session/conversation identifier,
|
||||
e.g. ``"conv_abc123"``.
|
||||
:returns: Confirmation that the spec cache was invalidated.
|
||||
"""
|
||||
_session_spec_cache.pop(session_id, None)
|
||||
_session_skills_cache.pop(session_id, None)
|
||||
_session_tool_schemas.pop(session_id, None)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"session_id": session_id,
|
||||
"object": "session.spec_reloaded",
|
||||
"reloaded": True,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/v1/sessions/{session_id}/mcp/execute")
|
||||
async def mcp_execute(session_id: str, request: Request) -> JSONResponse:
|
||||
"""Execute a tool call on the runner after AP-server policy evaluation.
|
||||
|
||||
@@ -6436,6 +6436,36 @@ async def _reset_runner_resources_after_switch(session_id: str) -> None:
|
||||
_publish_changed_files_invalidated(session_id)
|
||||
|
||||
|
||||
async def _reload_runner_spec(session_id: str) -> None:
|
||||
"""Best-effort reload of the runner's cached agent spec after a bundle PUT.
|
||||
|
||||
Calls the runner's ``POST /v1/sessions/{id}/reload-spec`` endpoint,
|
||||
which drops the spec cache and MCP tool schemas so the next turn
|
||||
re-resolves the spec from the updated bundle. Lighter than
|
||||
``/reset-state``: does NOT tear down terminals or environments.
|
||||
|
||||
Fire-and-forget — a runner hiccup must not break the already-committed
|
||||
PUT response.
|
||||
|
||||
:param session_id: Session/conversation id whose spec was updated.
|
||||
"""
|
||||
try:
|
||||
runner_client = await _get_runner_client_for_resource_access(session_id)
|
||||
if runner_client is None:
|
||||
return
|
||||
resp = await runner_client.post(
|
||||
f"/v1/sessions/{urllib.parse.quote(session_id, safe='')}/reload-spec",
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except (httpx.HTTPError, HTTPException, OmnigentError, RuntimeError):
|
||||
_logger.warning(
|
||||
"post-PUT runner spec reload failed for session=%s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _is_native_terminal_session(conv: Conversation) -> bool:
|
||||
"""
|
||||
Return whether a session is owned by a terminal-native wrapper.
|
||||
@@ -18393,6 +18423,16 @@ def create_sessions_router(
|
||||
agent.id, new_loc, bundle_bytes, expand_env=agent.session_id is None
|
||||
)
|
||||
|
||||
# Best-effort: tell the runner to drop its cached spec so the
|
||||
# next turn picks up the updated bundle (added/removed MCP
|
||||
# servers, changed instructions, etc.). Fire-and-forget —
|
||||
# a runner hiccup must not break the already-committed PUT.
|
||||
_task = asyncio.create_task(
|
||||
_reload_runner_spec(session_id),
|
||||
name=f"reload-spec:{session_id}",
|
||||
)
|
||||
_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
|
||||
|
||||
return _to_agent_object(updated, agent_cache)
|
||||
|
||||
# ── POST /sessions/{session_id}/mcp ──────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""E2E: adding/removing MCP servers from the agent info panel mid-session.
|
||||
|
||||
Covers the in-session MCP server editing flow end to end:
|
||||
1. Open the agent info popover → verify the "+" button is present
|
||||
2. Click "+" → fill the add-MCP dialog → submit → verify new pill appears
|
||||
3. Click the pill → verify popover with "Remove" → click Remove → verify gone
|
||||
|
||||
Uses the sync Playwright API against the real live_server + seeded_session
|
||||
fixtures (same pattern as test_agent_info_popover.py). The add/remove
|
||||
round-trip exercises the real PUT /v1/sessions/{id}/agent endpoint with
|
||||
the bundle download → modify → re-upload flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
def _session_agent_mcp_names(base_url: str, session_id: str) -> set[str]:
|
||||
"""Fetch the agent's MCP server names via the REST API."""
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/sessions/{session_id}/agent",
|
||||
timeout=10.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
servers = resp.json().get("mcp_servers", [])
|
||||
return {s["name"] for s in servers}
|
||||
|
||||
|
||||
def test_add_mcp_server_button_visible(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The agent info popover shows a '+' button next to the Tools label."""
|
||||
base_url, session_id = seeded_session
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
page.get_by_placeholder("Ask the agent anything").wait_for(state="visible", timeout=30_000)
|
||||
|
||||
trigger = page.get_by_test_id("agent-info-trigger")
|
||||
if trigger.count() == 0:
|
||||
return
|
||||
trigger.click()
|
||||
|
||||
expect(page.get_by_test_id("add-mcp-server-button")).to_be_visible(timeout=5_000)
|
||||
|
||||
|
||||
def test_add_mcp_dialog_validates_name(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The add-MCP dialog validates server names (rejects special chars)."""
|
||||
base_url, session_id = seeded_session
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
page.get_by_placeholder("Ask the agent anything").wait_for(state="visible", timeout=30_000)
|
||||
|
||||
trigger = page.get_by_test_id("agent-info-trigger")
|
||||
if trigger.count() == 0:
|
||||
return
|
||||
trigger.click()
|
||||
page.get_by_test_id("add-mcp-server-button").click()
|
||||
|
||||
dialog = page.get_by_test_id("add-mcp-server-dialog")
|
||||
expect(dialog).to_be_visible(timeout=5_000)
|
||||
|
||||
# Submit disabled with empty name.
|
||||
expect(page.get_by_test_id("add-mcp-submit")).to_be_disabled()
|
||||
|
||||
# Invalid name → still disabled.
|
||||
page.get_by_test_id("add-mcp-name").fill("../../evil")
|
||||
expect(page.get_by_test_id("add-mcp-submit")).to_be_disabled()
|
||||
|
||||
# Valid name + command → enabled.
|
||||
page.get_by_test_id("add-mcp-name").fill("testserver")
|
||||
page.get_by_test_id("add-mcp-command").fill("echo")
|
||||
expect(page.get_by_test_id("add-mcp-submit")).to_be_enabled()
|
||||
|
||||
|
||||
def test_add_and_remove_mcp_server_round_trip(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Full round-trip: add an MCP server via the dialog, verify it appears,
|
||||
then remove it and verify it's gone.
|
||||
|
||||
This exercises the real PUT /v1/sessions/{id}/agent bundle round-trip
|
||||
(download → modify tar → re-upload).
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
page.get_by_placeholder("Ask the agent anything").wait_for(state="visible", timeout=30_000)
|
||||
|
||||
# Confirm no MCP servers initially via REST.
|
||||
initial_servers = _session_agent_mcp_names(base_url, session_id)
|
||||
|
||||
# Open agent info popover.
|
||||
trigger = page.get_by_test_id("agent-info-trigger")
|
||||
if trigger.count() == 0:
|
||||
return
|
||||
trigger.click()
|
||||
|
||||
# ── ADD ──────────────────────────────────────────────────────
|
||||
page.get_by_test_id("add-mcp-server-button").click()
|
||||
dialog = page.get_by_test_id("add-mcp-server-dialog")
|
||||
expect(dialog).to_be_visible(timeout=5_000)
|
||||
|
||||
server_name = "e2e-test-mcp"
|
||||
page.get_by_test_id("add-mcp-name").fill(server_name)
|
||||
page.get_by_test_id("add-mcp-command").fill("echo")
|
||||
page.get_by_test_id("add-mcp-args").fill("hello")
|
||||
page.get_by_test_id("add-mcp-submit").click()
|
||||
|
||||
# Dialog should close after successful add.
|
||||
expect(dialog).to_be_hidden(timeout=15_000)
|
||||
|
||||
# Verify via REST that the server was added.
|
||||
updated_servers = _session_agent_mcp_names(base_url, session_id)
|
||||
assert server_name in updated_servers, f"Expected '{server_name}' in {updated_servers}"
|
||||
|
||||
# The pill should appear in the UI (popover may need reopening after
|
||||
# the query invalidation).
|
||||
# Close and reopen the popover to see fresh data.
|
||||
page.keyboard.press("Escape")
|
||||
trigger.click()
|
||||
expect(page.get_by_text(server_name).first).to_be_visible(timeout=10_000)
|
||||
|
||||
# ── REMOVE ───────────────────────────────────────────────────
|
||||
# Click the pill to open its popover.
|
||||
page.get_by_text(server_name).first.click()
|
||||
remove_btn = page.get_by_test_id(f"remove-mcp-server-{server_name}")
|
||||
expect(remove_btn).to_be_visible(timeout=5_000)
|
||||
remove_btn.click()
|
||||
|
||||
# Wait for removal to complete — the pill should disappear.
|
||||
expect(page.get_by_test_id(f"remove-mcp-server-{server_name}")).to_be_hidden(
|
||||
timeout=15_000,
|
||||
)
|
||||
|
||||
# Verify via REST that the server was removed.
|
||||
final_servers = _session_agent_mcp_names(base_url, session_id)
|
||||
assert server_name not in final_servers, (
|
||||
f"'{server_name}' still in {final_servers} after removal"
|
||||
)
|
||||
# Other servers (if any existed before) should be unaffected.
|
||||
assert initial_servers.issubset(final_servers), (
|
||||
f"Pre-existing servers {initial_servers} lost after remove; got {final_servers}"
|
||||
)
|
||||
Reference in New Issue
Block a user