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 { 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) {