From 44e9287389ecbcd93513061d8007359c611506e9 Mon Sep 17 00:00:00 2001 From: Fabian Fischer Date: Fri, 5 Jun 2026 16:29:47 +0200 Subject: [PATCH 1/2] fix(claude): preserve alwaysLoad on MCP server configs (#1333) * fix(claude): preserve alwaysLoad on MCP server configs Paseo's Claude provider normalizes McpServerConfig via toClaudeSdkMcpConfig before passing it to claude-agent-sdk. The function copied type/command/args/ env (stdio) and type/url/headers (http, sse) but dropped the alwaysLoad field for all three transports. Without alwaysLoad making it to the SDK, every MCP server's tools were deferred behind tool search. In practice the agent never discovered them via search and would respond 'no chrome-devtools tools available' even with the server fully running and connected. Add alwaysLoad to the McpServerConfig types and zod schemas, copy it through in toClaudeSdkMcpConfig for stdio/http/sse, and add unit tests covering all three transports plus the default-undefined case. Other providers ignore the field, consistent with the existing pattern where each provider normalizes only what its CLI/SDK supports. * test(claude): drop ternary in toClaudeSdkMcpConfig assertion The 'alwaysLoad' field is now declared on every branch of the discriminated union returned by toClaudeSdkMcpConfig, so the in-operator guard is no longer needed. Direct property access expresses the intent without embedding a conditional in the assertion. --------- Co-authored-by: Mohamed Boudra --- packages/protocol/src/agent-types.ts | 15 +++++ packages/protocol/src/messages.ts | 3 + .../src/server/agent/agent-sdk-types.ts | 15 +++++ .../agent/providers/claude/agent.test.ts | 61 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 5 +- 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 7a1f9c04b..e0728f592 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -14,6 +14,11 @@ export interface McpStdioServerConfig { command: string; args?: string[]; env?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** @@ -23,6 +28,11 @@ export interface McpHttpServerConfig { type: "http"; url: string; headers?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** @@ -32,6 +42,11 @@ export interface McpSseServerConfig { type: "sse"; url: string; headers?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b6124749e..0756754ff 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -266,18 +266,21 @@ const McpStdioServerConfigSchema = z.object({ command: z.string(), args: z.array(z.string()).optional(), env: z.record(z.string()).optional(), + alwaysLoad: z.boolean().optional(), }); const McpHttpServerConfigSchema = z.object({ type: z.literal("http"), url: z.string(), headers: z.record(z.string()).optional(), + alwaysLoad: z.boolean().optional(), }); const McpSseServerConfigSchema = z.object({ type: z.literal("sse"), url: z.string(), headers: z.record(z.string()).optional(), + alwaysLoad: z.boolean().optional(), }); const McpServerConfigSchema = z.discriminatedUnion("type", [ diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 9e1f69447..93d92be51 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -15,6 +15,11 @@ export interface McpStdioServerConfig { command: string; args?: string[]; env?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** @@ -24,6 +29,11 @@ export interface McpHttpServerConfig { type: "http"; url: string; headers?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** @@ -33,6 +43,11 @@ export interface McpSseServerConfig { type: "sse"; url: string; headers?: Record; + /** + * When true, all tools from this server are always included in the prompt + * and never deferred behind tool search. Honored by the Claude provider. + */ + alwaysLoad?: boolean; } /** diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 382885e8e..6264a0ad2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -10,6 +10,7 @@ import { ClaudeAgentClient, convertClaudeHistoryEntry, normalizeClaudeAskUserQuestionUpdatedInput, + toClaudeSdkMcpConfig, } from "./agent.js"; import type { AgentTimelineItem, AgentUsage, AgentStreamEvent } from "../../agent-sdk-types.js"; @@ -1674,3 +1675,63 @@ describe("ClaudeAgentSession context window usage", () => { ]); }); }); + +describe("toClaudeSdkMcpConfig", () => { + test("preserves alwaysLoad on stdio servers", () => { + expect( + toClaudeSdkMcpConfig({ + type: "stdio", + command: "npx", + args: ["-y", "chrome-devtools-mcp@latest"], + alwaysLoad: true, + }), + ).toEqual({ + type: "stdio", + command: "npx", + args: ["-y", "chrome-devtools-mcp@latest"], + env: undefined, + alwaysLoad: true, + }); + }); + + test("preserves alwaysLoad on http servers", () => { + expect( + toClaudeSdkMcpConfig({ + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + alwaysLoad: true, + }), + ).toEqual({ + type: "http", + url: "https://example.com/mcp", + headers: { Authorization: "Bearer x" }, + alwaysLoad: true, + }); + }); + + test("preserves alwaysLoad on sse servers", () => { + expect( + toClaudeSdkMcpConfig({ + type: "sse", + url: "https://example.com/sse", + alwaysLoad: true, + }), + ).toEqual({ + type: "sse", + url: "https://example.com/sse", + headers: undefined, + alwaysLoad: true, + }); + }); + + test("leaves alwaysLoad undefined when not provided (preserves default deferral)", () => { + const result = toClaudeSdkMcpConfig({ + type: "stdio", + command: "uvx", + args: ["markitdown-mcp"], + }); + expect(result.type).toBe("stdio"); + expect(result.alwaysLoad).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 60bbf0ee5..852471e42 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -800,7 +800,7 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial Date: Fri, 5 Jun 2026 16:31:38 +0100 Subject: [PATCH 2/2] Fix worktree checkout validation for existing git branch refs (#1358) * fix(server): allow valid existing worktree branch refs (#1357) * Fix existing branch checkout validation --------- Co-authored-by: Mohamed Boudra --- .../server/src/utils/worktree.posix.test.ts | 102 ++++++++++++++++++ packages/server/src/utils/worktree.ts | 25 ++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d27cdd792..1dc9a6389 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -6,6 +6,7 @@ import { createWorktree as createWorktreePrimitive, deriveWorktreeProjectHash, deletePaseoWorktree, + InvalidGitBranchNameError, getScriptConfigs, getWorktreeSetupCommands, getWorktreeTerminalSpecs, @@ -270,6 +271,26 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" }); }); + it("checks out an existing local branch whose name contains uppercase letters and dots", async () => { + execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir }); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "release-worktree", + source: { kind: "checkout-branch", branchName: "release/1.1.15" }, + runSetup: true, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(true); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("release/1.1.15"); + }); + it("throws a typed error when checking out a branch already checked out in the main repo", async () => { let caughtError: unknown; try { @@ -340,6 +361,51 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ baseRefName: "main" }); }); + it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-b", "Feature.X"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-uppercase-pr\n"); + execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "uppercase pr branch"], { + cwd: remoteCloneDir, + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "Feature.X"], { cwd: remoteCloneDir }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/43/head", prHead]); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "pr-43", + source: { + kind: "checkout-github-pr", + githubPrNumber: 43, + headRef: "Feature.X", + baseRefName: "main", + }, + runSetup: true, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe( + "from-uppercase-pr\n", + ); + const currentBranch = execFileSync("git", ["branch", "--show-current"], { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(currentBranch).toBe("Feature.X"); + }); + it("prefers origin/{branch} over local {branch} when both exist", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); @@ -425,6 +491,42 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).rejects.toThrow("Invalid branch name"); }); + it("throws a typed error when checking out an invalid existing branch name", async () => { + let caughtError: unknown; + try { + await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "invalid-existing-branch", + source: { kind: "checkout-branch", branchName: "bad..name" }, + runSetup: true, + paseoHome, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError); + expect((caughtError as InvalidGitBranchNameError).branchName).toBe("bad..name"); + }); + + it("throws a typed error when checking out a ref that is valid but not a branch name", async () => { + let caughtError: unknown; + try { + await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "invalid-option-like-branch", + source: { kind: "checkout-branch", branchName: "-bad" }, + runSetup: true, + paseoHome, + }); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBeInstanceOf(InvalidGitBranchNameError); + expect((caughtError as InvalidGitBranchNameError).branchName).toBe("-bad"); + }); + it("handles branch name collision by adding suffix", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); // Create a branch named "hello" first diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 1e23676f2..8c3a62c2c 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -206,6 +206,16 @@ export class UnknownBranchError extends Error { } } +export class InvalidGitBranchNameError extends Error { + readonly branchName: string; + + constructor(branchName: string) { + super(`Invalid branch name: Git rejected ref name '${branchName}'`); + this.name = "InvalidGitBranchNameError"; + this.branchName = branchName; + } +} + export type ReadPaseoConfigResult = | { ok: true; config: PaseoConfig | null } | { ok: false; configPath: string; error: unknown }; @@ -1261,7 +1271,7 @@ async function resolveWorktreeSourcePlan({ }; } case "checkout-branch": { - validateWorktreeBranchName(source.branchName); + await validateExistingWorktreeBranchName(cwd, source.branchName); if (!(await localBranchExists(cwd, source.branchName))) { try { await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { @@ -1284,7 +1294,7 @@ async function resolveWorktreeSourcePlan({ } case "checkout-github-pr": { const localBranchCandidate = source.localBranchName ?? source.headRef; - validateWorktreeBranchName(localBranchCandidate); + await validateExistingWorktreeBranchName(cwd, localBranchCandidate); const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); await runGitCommand( @@ -1350,6 +1360,17 @@ function validateWorktreeBranchName(branchName: string): void { } } +async function validateExistingWorktreeBranchName(cwd: string, branchName: string): Promise { + const result = await runGitCommand(["check-ref-format", "--branch", branchName], { + cwd, + timeout: 30_000, + acceptExitCodes: [0, 1, 128], + }); + if (result.exitCode !== 0) { + throw new InvalidGitBranchNameError(branchName); + } +} + function normalizeRequiredBaseBranch(baseBranch: string): string { const normalizedBaseBranch = normalizeBaseRefName(baseBranch); if (!normalizedBaseBranch) {