Compare commits

...

7 Commits

Author SHA1 Message Date
Tomu Hirata 139268b057 feat: hot-reload agent spec after bundle PUT
End-to-end spec hot-reload so MCP server changes take effect on the
next turn without reconnecting:

1. Runner: new POST /v1/sessions/{id}/reload-spec endpoint that drops
   _session_spec_cache, _session_skills_cache, and _session_tool_schemas.
   Lighter than /reset-state — does NOT tear down terminals or envs.

2. Server: PUT /v1/sessions/{id}/agent fires a background task to call
   the runner's reload-spec endpoint after updating the bundle and
   agent cache. Fire-and-forget — a runner hiccup doesn't break the PUT.

3. UI: hint changed from "Reconnect the session" to "Changes take
   effect on the next message".

Co-authored-by: Isaac
2026-06-24 23:28:53 +09:00
Tomu Hirata a5d872c18f fix(ci): ruff format e2e test file
Co-authored-by: Isaac
2026-06-24 23:17:17 +09:00
Tomu Hirata 7cf39d9a33 fix(ui): add reconnect hint + fix prettier formatting
The runner caches the agent spec for the session lifetime and doesn't
re-fetch on bundle PUT. Show an amber hint "Reconnect the session for
changes to take effect" after add/remove so users know the change
isn't immediate.

Co-authored-by: Isaac
2026-06-24 23:11:56 +09:00
Tomu Hirata 11960c66f6 test: full add/remove round-trip e2e + dialog unit tests
- E2E test_add_and_remove_mcp_server_round_trip: exercises the complete
  flow — open popover, add server via dialog, verify via REST API,
  remove via popover, verify removal via REST. Covers the real
  PUT /v1/sessions/{id}/agent bundle round-trip.
- 7 unit tests for AddMcpServerDialog: field rendering, validation
  (empty/invalid name, empty command), submit callback with parsed data.

Co-authored-by: Isaac
2026-06-24 22:56:13 +09:00
Tomu Hirata b84189b854 test: add unit + e2e tests for MCP server editing
- 6 unit tests for SessionMcpSection in AgentInfo.test.tsx: add button
  visibility, empty state, pill rendering, dialog open, remove button,
  server details in popover
- E2E test (test_edit_mcp_servers.py): add-button visible in agent info
  popover, dialog opens with validation (invalid names disabled, valid
  name + command enables submit)
- Mock bundleManipulation in AgentInfo tests to avoid network calls

Addresses coverage drop and E2E UI Required gate.

Co-authored-by: Isaac
2026-06-24 22:30:20 +09:00
Tomu Hirata ecbe9dd215 fix: address Polly review — tar round-trip, error surfacing, validation
Fixes all 3 blocking issues from the Polly AI review:

1. **Tar round-trip**: Rewritten to preserve raw 512-byte headers for
   directory (typeflag '5') and PAX (typeflag 'x') entries verbatim.
   Only newly added MCP files get fresh headers. The server's
   extract_safe now accepts the re-uploaded bundle.

2. **Entry name prefix mismatch**: All name comparisons use
   normalizeName() which strips the leading './' prefix that server
   bundles use. Adds detect the existing prefix style and match it
   for new entries. Directory entries are created as needed.

3. **Silent error swallowing**: handleAdd/handleRemove now capture
   errors and display them in the UI via an error message below
   the tools section (matching the destructive text pattern).

Also addresses non-blocking notes:
- Server name validation: [A-Za-z0-9_-] allowlist via
  isValidMcpServerName(), enforced in both dialog (canSubmit) and
  addMcpServerToSession (throws). Inline hint shown when invalid.
- removeInlineMcpFromYaml: now scans ALL child keys for type: mcp
  (not just the first line), handling configs where command/url
  appears before type.
- Removed dead summaryToInput export.

13 unit tests cover the tar round-trip with server-shaped fixtures
(directory entries, ./ prefixes), name normalization, validation,
inline YAML removal, and MCP YAML generation.

Co-authored-by: Isaac
2026-06-24 21:19:53 +09:00
Tomu Hirata 67acbabe6c feat(ui): add/remove MCP servers from agent info panel mid-session
Users can now add and remove MCP tool servers from the TOOLS section
of the agent info popover during an active session.

- New SessionMcpSection replaces the read-only McpServerList when a
  sessionId is present, adding a "+" button and per-server remove via
  popover (mirroring the policies pattern)
- AddMcpServerDialog collects server name, transport (stdio/http),
  and transport-specific fields (command/args/env or url/headers)
- bundleManipulation.ts handles the round-trip: download existing
  bundle → decompress → parse tar → add/remove tools/mcp/<name>.yaml
  → rebuild tar → gzip → PUT /v1/sessions/{id}/agent
- Also handles removing inline MCP declarations from config.yaml

Co-authored-by: Isaac
2026-06-24 20:43:53 +09:00
9 changed files with 1601 additions and 5 deletions
@@ -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>
);
}
+101
View File
@@ -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();
});
});
+158 -5
View File
@@ -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>
+342
View File
@@ -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"');
});
});
+476
View File
@@ -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 };
+30
View File
@@ -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.
+40
View File
@@ -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}"
)