diff --git a/apps/docs/content/docs/guides/mcp.mdx b/apps/docs/content/docs/guides/mcp.mdx index c428d6f9..7f67c7ef 100644 --- a/apps/docs/content/docs/guides/mcp.mdx +++ b/apps/docs/content/docs/guides/mcp.mdx @@ -32,6 +32,22 @@ The agent can call `dubstack.log` and receive structured JSON for the tracked st | `dubstack.children` | Returns direct children for a branch | | `dubstack.trunk` | Returns the trunk/root branch for a branch | | `dubstack.history` | Returns recent Dub command history | +| `dubstack.branch` | Returns single-branch stack metadata | +| `dubstack.diff` | Returns a branch diff vs its parent or an explicit base | +| `dubstack.ready` | Returns a pre-submit readiness checklist | +| `dubstack.merge_check` | Returns a PR/branch/stack mergeability snapshot | + +## AI-cooperative tools + +These tools call the configured AI provider but do not mutate git, DubStack +state, or GitHub. They are available in every MCP mode when the repo has +`dub config ai-assistant on` and a provider configured. + +| Tool | Description | +|---|---| +| `dubstack.propose_branch_name` | Suggests a branch name from a diff | +| `dubstack.propose_commit_message` | Suggests a Conventional Commit message from a diff | +| `dubstack.propose_pr_description` | Suggests PR markdown from branch/base/message/diff context | ## Mutating tools @@ -43,9 +59,57 @@ The agent can call `dubstack.log` and receive structured JSON for the tracked st | `dubstack.sync` | Sync tracked branches with the remote and reconcile divergence | | `dubstack.checkout` | Switch to a tracked branch (stack-aware) | | `dubstack.delete` | Delete a local branch and update DubStack metadata | +| `dubstack.reorder` | Reorder or drop commits on the current tracked branch from a supplied todo | +| `dubstack.freeze` | Mark a branch or stack scope as frozen | +| `dubstack.unfreeze` | Clear the frozen marker from a branch or stack scope | +| `dubstack.split` | Split the current branch by file, commit, or AI proposal (`by-hunk` remains terminal-only) | +| `dubstack.absorb` | Distribute fixup commits to their targets | +| `dubstack.squash` | Collapse branch commits into one commit | +| `dubstack.fold` | Combine the current branch into its parent | +| `dubstack.pop` | Pop recent commits into staged changes | +| `dubstack.rename` | Rename a tracked branch and update stack metadata | +| `dubstack.move` | Move a branch before/after another branch in the stack | +| `dubstack.unlink` | Detach a branch from its parent without deleting branches | +| `dubstack.revert` | Create a tracked revert branch on trunk | +| `dubstack.stash` | Create a branch-aware stash entry | +| `dubstack.stash-pop` | Pop the most recent branch-aware stash | Mutating tools are gated by the configured **MCP mode** (see below). +The Tier 3 command family is still landing incrementally. `dubstack.back` will +be added to the MCP surface when its underlying CLI command lands. + +## Return shapes + +Read-only and AI-cooperative tools return JSON as both MCP text content and +`structuredContent`: + +```json +{ + "content": [{ "type": "text", "text": "{ ... }" }], + "structuredContent": { + "branch": "feat/example", + "base": "main", + "diff": "..." + } +} +``` + +Mutating tools wrap the command result and captured terminal output: + +```json +{ + "content": [{ "type": "text", "text": "{ ... }" }], + "structuredContent": { + "result": { "branch": "feat/example" }, + "output": "..." + } +} +``` + +Refusals and user-facing command failures return `isError: true` with a +text payload suitable for agent display. + ## Security model Choose how aggressive the agent's automation is for this repository: @@ -90,7 +154,9 @@ Every MCP tool invocation — successful, refused, or failed — is appended to dub history ``` -This gives teams a local audit trail for agent-driven stack operations. +For mutating tools, the audit command includes redacted arguments plus an +`args_sha256` fingerprint so reviewers can compare what an agent passed without +exposing secrets in history. ## Protocol diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index b87bd40f..4b958383 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -3,6 +3,8 @@ import { PassThrough } from 'node:stream'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createTestRepo, gitInRepo } from '../../test/helpers'; +import type { AiMetadataDependencies } from '../lib/ai-metadata'; +import { writeConfig } from '../lib/config'; import { getCurrentBranch } from '../lib/git'; import { readHistory } from '../lib/history'; import { type DubState, initState, writeState } from '../lib/state'; @@ -25,6 +27,7 @@ afterEach(async () => { await stopMcpChild(child); } child = null; + vi.unstubAllEnvs(); await cleanup(); }); @@ -96,6 +99,13 @@ describe('mcp command', () => { 'dubstack.children', 'dubstack.trunk', 'dubstack.history', + 'dubstack.branch', + 'dubstack.diff', + 'dubstack.ready', + 'dubstack.merge_check', + 'dubstack.propose_branch_name', + 'dubstack.propose_commit_message', + 'dubstack.propose_pr_description', 'dubstack.create', 'dubstack.modify', 'dubstack.submit', @@ -106,6 +116,12 @@ describe('mcp command', () => { 'dubstack.unfreeze', 'dubstack.revert', 'dubstack.absorb', + 'dubstack.split', + 'dubstack.squash', + 'dubstack.fold', + 'dubstack.pop', + 'dubstack.rename', + 'dubstack.move', 'dubstack.unlink', 'dubstack.delete', 'dubstack.stash', @@ -168,6 +184,101 @@ describe('mcp command', () => { postShutdownEntries.some((entry) => entry.command === 'dub mcp'), ).toBe(false); }); + + it('calls additional read-only tools', async () => { + await gitInRepo(dir, ['checkout', '-b', 'feat/a']); + await gitInRepo(dir, ['commit', '--allow-empty', '-m', 'feat a']); + const state: DubState = { + stacks: [ + { + id: 'stack-1', + branches: [ + { + name: 'main', + type: 'root', + parent: null, + pr_number: null, + pr_link: null, + }, + { + name: 'feat/a', + parent: 'main', + pr_number: null, + pr_link: null, + }, + ], + }, + ], + }; + await writeState(state, dir); + + const branchResponse = await runMcpCall(dir, vi.fn(), { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'dubstack.branch', + arguments: { branch: 'feat/a' }, + }, + }); + expect(branchResponse.result).toMatchObject({ + structuredContent: { + currentBranch: 'feat/a', + tracked: true, + parent: 'main', + }, + }); + + const diffResponse = await runMcpCall(dir, vi.fn(), { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'dubstack.diff', + arguments: { branch: 'feat/a' }, + }, + }); + expect(diffResponse.result).toMatchObject({ + structuredContent: { + branch: 'feat/a', + base: 'main', + }, + }); + }); + + it('runs AI proposal tools without mutating confirmation', async () => { + vi.stubEnv('DUBSTACK_GEMINI_API_KEY', 'test-key'); + await writeConfig({ aiAssistantEnabled: true, mcpMode: 'read-only' }, dir); + const confirm = vi.fn(); + const generateTextMock = vi.fn().mockResolvedValue({ + text: '{"branch":"feat/mcp","message":"feat: add mcp"}', + }); + const aiMetadataDeps = { + generateText: generateTextMock, + createGoogleGenerativeAI: vi.fn(() => vi.fn(() => 'model')), + createGateway: vi.fn(), + } as unknown as AiMetadataDependencies; + + const response = await runMcpCall( + dir, + confirm, + { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'dubstack.propose_branch_name', + arguments: { diff: 'diff --git a/file b/file' }, + }, + }, + { aiMetadataDeps }, + ); + + expect(response.result).toMatchObject({ + structuredContent: { branchName: 'feat/mcp' }, + }); + expect(confirm).not.toHaveBeenCalled(); + }); }); describe('mcp mutating tools', () => { @@ -225,7 +336,29 @@ describe('mcp mutating tools', () => { entries.some( (entry) => entry.status === 'error' && - entry.command.startsWith('dub mcp tools/call dubstack.create'), + entry.command.startsWith('dub mcp tools/call dubstack.create') && + entry.command.includes('args_sha256='), + ), + ).toBe(true); + + await runMcpCall(dir, confirm, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'dubstack.squash', + arguments: {}, + }, + }); + + const entriesAfterEmptyArgs = await readHistory(dir, { limit: 5 }); + expect( + entriesAfterEmptyArgs.some( + (entry) => + entry.status === 'error' && + entry.command.startsWith( + 'dub mcp tools/call dubstack.squash {} args_sha256=', + ), ), ).toBe(true); }); @@ -387,6 +520,7 @@ async function runMcpCall( cwd: string, confirmMutating: ConfirmMutatingFn, message: unknown, + options: { aiMetadataDeps?: AiMetadataDependencies } = {}, ): Promise> { const input = new PassThrough(); const output = new PassThrough(); @@ -405,7 +539,12 @@ async function runMcpCall( } }); - const done = mcp(cwd, { input, output, confirmMutating }); + const done = mcp(cwd, { + input, + output, + confirmMutating, + aiMetadataDeps: options.aiMetadataDeps, + }); input.write(`${JSON.stringify(message)}\n`); input.end(); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 90114aa5..14117727 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -1,26 +1,48 @@ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as readline from 'node:readline/promises'; import type { Readable, Writable } from 'node:stream'; +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; +import { createGateway, generateText } from 'ai'; +import { buildAiDiffContext } from '../lib/ai-diff-context'; +import { + type AiMetadataDependencies, + generateCreateMetadata, + generatePrDescriptionSummary, +} from '../lib/ai-metadata'; import { type McpMode, readConfig } from '../lib/config'; import { DubError, formatDubError } from '../lib/errors'; -import { getCurrentBranch } from '../lib/git'; +import { getCurrentBranch, getDiffBetween } from '../lib/git'; import { appendHistoryEntry, redactSensitiveText } from '../lib/history'; +import { readMetadataTemplates } from '../lib/metadata-templates'; import { detectActiveOperation } from '../lib/operation-state'; import type { RebaseTodoEntry } from '../lib/rebase-todo'; +import type { ScopeMode } from '../lib/scope'; import { getStackOverviewBatch } from '../lib/stack-overview'; import { absorb } from './absorb'; +import { branchInfo } from './branch'; import { checkout } from './checkout'; import { children } from './children'; import { create } from './create'; import { deleteCommand } from './delete'; import { doctor } from './doctor'; +import { fold } from './fold'; import { freeze } from './freeze'; import { history } from './history'; import { logJson } from './log'; +import { mergeCheck } from './merge-check'; import { modify } from './modify'; +import { move } from './move'; import { parent } from './parent'; +import { pop } from './pop'; +import { ready } from './ready'; +import { rename } from './rename'; import { reorder } from './reorder'; import { revert } from './revert'; +import { type SplitMode, split } from './split'; +import { squash } from './squash'; import { stashList, stashPop, stashPush } from './stash'; import { status } from './status'; import { submit } from './submit'; @@ -61,6 +83,7 @@ interface McpServerOptions { output?: Writable; version?: string; confirmMutating?: ConfirmMutatingFn; + aiMetadataDeps?: AiMetadataDependencies; } interface ToolDefinition { @@ -80,6 +103,15 @@ const PROTOCOL_VERSION = '2025-11-25'; const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2025-11-25', '2025-06-18']); const MAX_HISTORY_ARGS_LENGTH = 500; +const DEFAULT_AI_METADATA_DEPS: AiMetadataDependencies = { + generateText, + createGoogleGenerativeAI, + createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, +}; + const HISTORY_ARG_KEYS: Record = { 'dubstack.log': ['stack', 'all', 'reverse', 'prs', 'ci', 'refresh'], 'dubstack.doctor': ['all', 'fetch'], @@ -88,6 +120,18 @@ const HISTORY_ARG_KEYS: Record = { 'dubstack.children': ['branch'], 'dubstack.trunk': ['branch'], 'dubstack.history': ['limit'], + 'dubstack.branch': ['branch'], + 'dubstack.diff': ['branch', 'base'], + 'dubstack.ready': ['scope'], + 'dubstack.merge_check': ['pr', 'branch', 'scope'], + 'dubstack.propose_branch_name': ['diff'], + 'dubstack.propose_commit_message': ['diff'], + 'dubstack.propose_pr_description': [ + 'branch', + 'baseBranch', + 'commitMessage', + 'diff', + ], 'dubstack.create': ['name', 'message', 'ai'], 'dubstack.modify': ['message', 'commit', 'all'], 'dubstack.submit': [ @@ -111,6 +155,22 @@ const HISTORY_ARG_KEYS: Record = { 'dubstack.freeze': ['branch', 'upstack', 'downstack'], 'dubstack.unfreeze': ['branch', 'upstack', 'downstack'], 'dubstack.absorb': ['ai', 'stack', 'dryRun'], + 'dubstack.split': [ + 'mode', + 'files', + 'name', + 'commitPicks', + 'commitPicksRaw', + 'closeOldPr', + 'noRestack', + 'dryRun', + 'yes', + ], + 'dubstack.squash': ['message', 'ai'], + 'dubstack.fold': ['squash', 'force'], + 'dubstack.pop': ['steps'], + 'dubstack.rename': ['oldName', 'newName', 'noPush'], + 'dubstack.move': ['branch', 'before', 'after'], }; const BRANCH_SCHEMA = { @@ -231,6 +291,133 @@ const TOOLS: ToolDefinition[] = [ additionalProperties: false, }, }, + { + name: 'dubstack.branch', + description: + 'Return tracked stack metadata for one branch, including parent, children, root, and tree position.', + inputSchema: BRANCH_SCHEMA, + }, + { + name: 'dubstack.diff', + description: + 'Return a git diff for a branch against its tracked parent, or against an explicit base ref.', + inputSchema: { + type: 'object', + properties: { + branch: { + type: 'string', + description: 'Branch to diff. Defaults to the current branch.', + }, + base: { + type: 'string', + description: + 'Base ref to diff against. Defaults to the branch tracked parent.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.ready', + description: + 'Return a pre-submit readiness checklist for the current branch and chosen submit scope.', + inputSchema: { + type: 'object', + properties: { + scope: { + type: 'string', + enum: ['current', 'downstack', 'stack'], + description: 'Submit scope to check. Defaults to downstack.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.merge_check', + description: + 'Return a mergeability snapshot for one PR, branch, or stack scope.', + inputSchema: { + type: 'object', + properties: { + pr: { + type: 'integer', + minimum: 1, + description: 'PR number to check.', + }, + branch: { + type: 'string', + description: 'Branch whose PR should be checked.', + }, + scope: { + type: 'string', + enum: ['current', 'downstack', 'stack'], + description: 'Stack scope to check. Defaults to current.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.propose_branch_name', + description: + 'Use the configured AI provider to suggest a branch name from a diff. Does not mutate git or DubStack state.', + inputSchema: { + type: 'object', + properties: { + diff: { + type: 'string', + description: 'Unified diff or concise change description.', + }, + }, + required: ['diff'], + additionalProperties: false, + }, + }, + { + name: 'dubstack.propose_commit_message', + description: + 'Use the configured AI provider to suggest a Conventional Commit message from a diff. Does not mutate git or DubStack state.', + inputSchema: { + type: 'object', + properties: { + diff: { + type: 'string', + description: 'Unified diff or concise change description.', + }, + }, + required: ['diff'], + additionalProperties: false, + }, + }, + { + name: 'dubstack.propose_pr_description', + description: + 'Use the configured AI provider to suggest a PR description from branch metadata and a diff. Does not mutate git or DubStack state.', + inputSchema: { + type: 'object', + properties: { + branch: { + type: 'string', + description: 'Branch name for the proposed PR.', + }, + baseBranch: { + type: 'string', + description: 'Base branch for the proposed PR.', + }, + commitMessage: { + type: 'string', + description: 'Commit message or PR title context.', + }, + diff: { + type: 'string', + description: 'Unified diff or concise change description.', + }, + }, + required: ['branch', 'baseBranch', 'commitMessage', 'diff'], + additionalProperties: false, + }, + }, { name: 'dubstack.create', description: @@ -499,6 +686,170 @@ const TOOLS: ToolDefinition[] = [ additionalProperties: false, }, }, + { + name: 'dubstack.split', + description: + 'Split the current branch into smaller sibling branches by commit, file, or AI proposal.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['by-commit', 'by-file', 'ai'], + description: 'Split mode to run.', + }, + files: { + type: 'array', + items: { type: 'string' }, + description: "Files to extract when mode is 'by-file'.", + }, + name: { + type: 'string', + description: 'New branch name for by-file, by-commit, or by-hunk.', + }, + commitPicks: { + type: 'array', + items: { type: 'integer', minimum: 1 }, + description: + "1-indexed commit positions to extract when mode is 'by-commit'.", + }, + commitPicksRaw: { + type: 'string', + description: + "Raw commit selection such as '1,3-4' when mode is 'by-commit'.", + }, + closeOldPr: { + type: 'boolean', + description: "Close the source branch's existing PR.", + }, + noRestack: { + type: 'boolean', + description: 'Skip automatic descendant restack after split.', + }, + dryRun: { + type: 'boolean', + description: 'AI mode only: return the proposal without applying it.', + }, + yes: { + type: 'boolean', + description: 'AI mode only: approve the generated split proposal.', + }, + }, + required: ['mode'], + additionalProperties: false, + }, + }, + { + name: 'dubstack.squash', + description: + 'Collapse every commit on the current branch since its parent into one commit.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + message: { + type: 'string', + description: 'Commit message for the squashed commit.', + }, + ai: { + type: 'boolean', + description: + 'Generate a Conventional Commit message from the squashed commits.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.fold', + description: + 'Combine the current branch into its parent and delete the folded branch.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + squash: { + type: 'boolean', + description: 'Collapse the folded branch into one parent commit.', + }, + force: { + type: 'boolean', + description: + 'Skip the command confirmation prompt. Defaults to true for MCP because MCP mode already gates mutation.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.pop', + description: + 'Pop the last commit(s) off the current branch into the staging area.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + steps: { + type: 'integer', + minimum: 1, + description: 'Number of commits to pop. Defaults to 1.', + }, + }, + additionalProperties: false, + }, + }, + { + name: 'dubstack.rename', + description: + 'Rename a tracked branch and propagate the change through state, children, and remote branch pushes.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + oldName: { + type: 'string', + description: + 'Branch to rename. Defaults to the current branch when omitted.', + }, + newName: { + type: 'string', + description: 'New branch name.', + }, + noPush: { + type: 'boolean', + description: 'Skip pushing the renamed branch even if a PR exists.', + }, + }, + required: ['newName'], + additionalProperties: false, + }, + }, + { + name: 'dubstack.move', + description: + 'Move a tracked branch before or after another branch in the same stack.', + mutating: true, + inputSchema: { + type: 'object', + properties: { + branch: { + type: 'string', + description: 'Branch to move.', + }, + before: { + type: 'string', + description: 'Insert branch as the new parent of this target.', + }, + after: { + type: 'string', + description: 'Insert branch as the new child of this target.', + }, + }, + required: ['branch'], + additionalProperties: false, + }, + }, { name: 'dubstack.unlink', description: @@ -612,6 +963,7 @@ export async function mcp( const output = options.output ?? process.stdout; const serverVersion = options.version ?? '0.0.0'; const confirmMutating = options.confirmMutating ?? confirmMutatingTool; + const aiMetadataDeps = options.aiMetadataDeps ?? DEFAULT_AI_METADATA_DEPS; let buffer = ''; let queue: Promise = Promise.resolve(); @@ -622,7 +974,14 @@ export async function mcp( queue = queue .catch(() => undefined) .then(() => - handleLineSafely(line, cwd, output, serverVersion, confirmMutating), + handleLineSafely( + line, + cwd, + output, + serverVersion, + confirmMutating, + aiMetadataDeps, + ), ); }; @@ -651,9 +1010,17 @@ async function handleLineSafely( output: Writable, serverVersion: string, confirmMutating: ConfirmMutatingFn, + aiMetadataDeps: AiMetadataDependencies, ): Promise { try { - await handleLine(line, cwd, output, serverVersion, confirmMutating); + await handleLine( + line, + cwd, + output, + serverVersion, + confirmMutating, + aiMetadataDeps, + ); } catch (error) { const requestId = parseRequestId(line); const message = error instanceof Error ? error.message : String(error); @@ -670,6 +1037,7 @@ async function handleLine( output: Writable, serverVersion: string, confirmMutating: ConfirmMutatingFn, + aiMetadataDeps: AiMetadataDependencies, ): Promise { const trimmed = line.trim(); if (!trimmed) return; @@ -697,6 +1065,7 @@ async function handleLine( cwd, serverVersion, confirmMutating, + aiMetadataDeps, ); writeMessage(output, response); } @@ -712,6 +1081,7 @@ async function handleRequest( cwd: string, serverVersion: string, confirmMutating: ConfirmMutatingFn, + aiMetadataDeps: AiMetadataDependencies, ): Promise { switch (request.method) { case 'initialize': @@ -724,7 +1094,12 @@ async function handleRequest( case 'tools/list': return jsonRpcResult(request.id ?? null, { tools: TOOLS }); case 'tools/call': - return handleToolCallRequest(request, cwd, confirmMutating); + return handleToolCallRequest( + request, + cwd, + confirmMutating, + aiMetadataDeps, + ); default: return jsonRpcError( request.id ?? null, @@ -738,6 +1113,7 @@ async function handleToolCallRequest( request: JsonRpcRequest, cwd: string, confirmMutating: ConfirmMutatingFn, + aiMetadataDeps: AiMetadataDependencies, ): Promise { const params = asRecord(request.params); const name = typeof params?.name === 'string' ? params.name : null; @@ -762,7 +1138,7 @@ async function handleToolCallRequest( if (mode === 'read-only') { const text = `${name} refused: repo is in read-only MCP mode. Run \`dub config mcp-mode interactive\` to enable mutating tools.`; - await appendMcpHistory(cwd, name, args, startedAt, 'error', [text]); + await appendMcpHistory(cwd, name, args, startedAt, 'error', [text], true); return jsonRpcResult(request.id ?? null, { content: [{ type: 'text', text }], isError: true, @@ -772,9 +1148,15 @@ async function handleToolCallRequest( if (mode === 'interactive') { const confirmation = await confirmMutating(name, args); if (!confirmation.confirmed) { - await appendMcpHistory(cwd, name, args, startedAt, 'error', [ - confirmation.reason, - ]); + await appendMcpHistory( + cwd, + name, + args, + startedAt, + 'error', + [confirmation.reason], + true, + ); return jsonRpcResult(request.id ?? null, { content: [{ type: 'text', text: confirmation.reason }], isError: true, @@ -784,10 +1166,16 @@ async function handleToolCallRequest( } try { - const result = await callTool(cwd, name, args); - await appendMcpHistory(cwd, name, args, startedAt, 'success', [ - `MCP tool '${name}' returned structured JSON.`, - ]); + const result = await callTool(cwd, name, args, aiMetadataDeps); + await appendMcpHistory( + cwd, + name, + args, + startedAt, + 'success', + [`MCP tool '${name}' returned structured JSON.`], + tool.mutating === true, + ); return jsonRpcResult(request.id ?? null, result); } catch (error) { const message = @@ -796,7 +1184,15 @@ async function handleToolCallRequest( : error instanceof Error ? error.message : String(error); - await appendMcpHistory(cwd, name, args, startedAt, 'error', [message]); + await appendMcpHistory( + cwd, + name, + args, + startedAt, + 'error', + [message], + tool.mutating === true, + ); return jsonRpcResult(request.id ?? null, { content: [{ type: 'text', text: message }], isError: true, @@ -871,6 +1267,7 @@ async function callTool( cwd: string, name: string, args: Record, + aiMetadataDeps: AiMetadataDependencies, ): Promise { switch (name) { case 'dubstack.log': { @@ -920,6 +1317,25 @@ async function callTool( limit: optionalPositiveInteger(args.limit) ?? 20, }), ); + case 'dubstack.branch': + return jsonToolResult(await branchInfo(cwd, optionalString(args.branch))); + case 'dubstack.diff': + return jsonToolResult(await diffTool(cwd, args)); + case 'dubstack.ready': + return jsonToolResult( + await ready(cwd, { scope: optionalScopeMode(args.scope) }), + ); + case 'dubstack.merge_check': + return jsonToolResult(await mergeCheckTool(cwd, args)); + case 'dubstack.propose_branch_name': + case 'dubstack.propose_commit_message': + return jsonToolResult( + await proposeCreateMetadataTool(cwd, name, args, aiMetadataDeps), + ); + case 'dubstack.propose_pr_description': + return jsonToolResult( + await proposePrDescriptionTool(cwd, args, aiMetadataDeps), + ); case 'dubstack.create': return mutatingToolResult(() => create(optionalString(args.name), cwd, { @@ -1073,6 +1489,95 @@ async function callTool( quiet: true, }), ); + case 'dubstack.split': { + const mode = optionalSplitMode(args.mode); + if (!mode) { + throw new DubError("'mode' is required for dubstack.split.", [ + "Pass {'mode': 'by-file' | 'by-commit' | 'by-hunk' | 'ai'}.", + ]); + } + if (mode === 'by-hunk') { + throw new DubError( + "'by-hunk' split is not supported via MCP because it requires an interactive patch TTY.", + [ + "Run 'dub split --by-hunk' from a terminal instead.", + "Use mode 'by-file' or 'by-commit' for non-interactive MCP splits.", + ], + ); + } + const dryRun = optionalBoolean(args.dryRun); + const yes = optionalBoolean(args.yes); + if (mode === 'ai' && !dryRun && !yes) { + throw new DubError( + "'ai' split requires explicit approval via {'yes': true} when called through MCP.", + [ + "Call dubstack.split with {'mode': 'ai', 'dryRun': true} first to inspect the proposal.", + "Call dubstack.split with {'mode': 'ai', 'yes': true} to apply the generated proposal.", + ], + ); + } + return mutatingToolResult(() => + split(cwd, { + mode, + files: optionalStringArray(args.files), + name: optionalString(args.name), + commitPicks: optionalPositiveIntegerArray(args.commitPicks), + commitPicksRaw: optionalString(args.commitPicksRaw), + closeOldPr: optionalBoolean(args.closeOldPr), + noRestack: optionalBoolean(args.noRestack), + dryRun, + yes, + interactive: false, + }), + ); + } + case 'dubstack.squash': + return mutatingToolResult(() => + squash(cwd, { + message: optionalString(args.message), + ai: optionalBoolean(args.ai), + }), + ); + case 'dubstack.fold': + return mutatingToolResult(() => + fold(cwd, { + squash: optionalBoolean(args.squash), + force: optionalBoolean(args.force) ?? true, + interactive: false, + }), + ); + case 'dubstack.pop': + return mutatingToolResult(() => + pop(cwd, { steps: optionalPositiveInteger(args.steps) }), + ); + case 'dubstack.rename': { + const newName = optionalString(args.newName); + if (!newName) { + throw new DubError("'newName' is required for dubstack.rename.", [ + "Pass {'newName': ''} in the tool arguments.", + ]); + } + const oldName = optionalString(args.oldName); + return mutatingToolResult(() => + rename(cwd, oldName ?? newName, oldName ? newName : undefined, { + noPush: optionalBoolean(args.noPush), + }), + ); + } + case 'dubstack.move': { + const branch = optionalString(args.branch); + if (!branch) { + throw new DubError("'branch' is required for dubstack.move.", [ + "Pass {'branch': '', 'before': ''} or {'branch': '', 'after': ''}.", + ]); + } + return mutatingToolResult(() => + move(cwd, branch, { + before: optionalString(args.before), + after: optionalString(args.after), + }), + ); + } default: throw new DubError(`Unknown MCP tool '${name}'.`, [ 'Call tools/list to discover the available dubstack.* tool names.', @@ -1081,6 +1586,131 @@ async function callTool( } } +async function diffTool( + cwd: string, + args: Record, +): Promise<{ branch: string; base: string; diff: string }> { + const branch = optionalString(args.branch) ?? (await getCurrentBranch(cwd)); + const explicitBase = optionalString(args.base); + let base = explicitBase; + + if (!base) { + const info = await branchInfo(cwd, branch); + if (!info.parent) { + throw new DubError(`Could not determine a diff base for '${branch}'.`, [ + "Pass {'base': ''} to diff against an explicit git ref.", + "Run 'dub branch info ' to confirm the branch is tracked and has a parent.", + ]); + } + base = info.parent; + } + + return { + branch, + base, + diff: await getDiffBetween(base, branch, cwd), + }; +} + +async function mergeCheckTool( + cwd: string, + args: Record, +): Promise { + try { + return await mergeCheck(cwd, { + pr: optionalPositiveInteger(args.pr), + branch: optionalString(args.branch), + scope: optionalScopeMode(args.scope), + }); + } catch (error) { + if (error instanceof DubError) { + return { + ok: false, + reason: error.message, + fixes: error.recovery, + }; + } + throw error; + } +} + +async function proposeCreateMetadataTool( + cwd: string, + name: string, + args: Record, + aiMetadataDeps: AiMetadataDependencies, +): Promise<{ branchName: string } | { commitMessage: string }> { + const diff = optionalString(args.diff); + if (!diff) { + throw new DubError("'diff' is required for AI proposal tools.", [ + "Pass {'diff': ''}.", + ]); + } + + const config = await readConfig(cwd); + if (!config.aiAssistantEnabled) { + throw new DubError('AI assistant is disabled for this repo.', [ + "Run 'dub config ai-assistant on' to enable AI proposal tools.", + "Use 'dubstack.diff' first if you only need a deterministic read-only inspection.", + ]); + } + + const templates = await readMetadataTemplates(cwd); + const generated = await generateCreateMetadata( + buildAiDiffContext({ rawDiff: diff }), + aiMetadataDeps, + { commitTemplate: templates.commitTemplate }, + config.ai.provider, + ); + + if (name === 'dubstack.propose_branch_name') { + return { branchName: generated.branch }; + } + return { commitMessage: generated.message }; +} + +async function proposePrDescriptionTool( + cwd: string, + args: Record, + aiMetadataDeps: AiMetadataDependencies, +): Promise<{ prDescription: string }> { + const branch = optionalString(args.branch); + const baseBranch = optionalString(args.baseBranch); + const commitMessage = optionalString(args.commitMessage); + const diff = optionalString(args.diff); + if (!branch || !baseBranch || !commitMessage || !diff) { + throw new DubError( + "'branch', 'baseBranch', 'commitMessage', and 'diff' are required for dubstack.propose_pr_description.", + [ + "Pass {'branch': '', 'baseBranch': '', 'commitMessage': '', 'diff': ''}.", + ], + ); + } + + const config = await readConfig(cwd); + if (!config.aiAssistantEnabled) { + throw new DubError('AI assistant is disabled for this repo.', [ + "Run 'dub config ai-assistant on' to enable AI proposal tools.", + "Use 'dubstack.diff' first if you only need a deterministic read-only inspection.", + ]); + } + + const templates = await readMetadataTemplates(cwd); + const prDescription = await generatePrDescriptionSummary( + { + branch, + baseBranch, + commitMessage, + diff: buildAiDiffContext({ rawDiff: diff }), + }, + aiMetadataDeps, + { prTemplate: templates.prTemplate }, + config.ai.provider, + ); + + return { prDescription }; +} + let stdioCaptureActive = false; interface MutatingToolResultOptions { @@ -1203,10 +1833,11 @@ async function appendMcpHistory( startedAt: number, status: 'success' | 'error', output: string[], + includeArgHash = false, ): Promise { const currentBranch = await getCurrentBranch(cwd).catch(() => undefined); const operation = await detectActiveOperation(cwd).catch(() => undefined); - const argsText = formatHistoryArgs(name, args); + const argsText = formatHistoryArgs(name, args, includeArgHash); const command = argsText === null ? `dub mcp tools/call ${name}` @@ -1231,8 +1862,11 @@ async function appendMcpHistory( function formatHistoryArgs( name: string, args: Record, + includeHash: boolean, ): string | null { - const keys = HISTORY_ARG_KEYS[name] ?? []; + const keys = includeHash + ? Object.keys(args).sort() + : (HISTORY_ARG_KEYS[name] ?? []); const filteredArgs: Record = {}; for (const key of keys) { @@ -1242,14 +1876,19 @@ function formatHistoryArgs( } const argsText = JSON.stringify(filteredArgs); - if (!argsText || argsText === '{}') return null; + if (!argsText) return null; + if (!includeHash && argsText === '{}') return null; const redactedArgs = redactSensitiveText(argsText); - if (redactedArgs.length <= MAX_HISTORY_ARGS_LENGTH) { - return redactedArgs; - } + const suffix = includeHash ? ` args_sha256=${hashArgs(redactedArgs)}` : ''; + if (redactedArgs.length <= MAX_HISTORY_ARGS_LENGTH) + return `${redactedArgs}${suffix}`; + + return `${redactedArgs.slice(0, MAX_HISTORY_ARGS_LENGTH)}...${suffix}`; +} - return `${redactedArgs.slice(0, MAX_HISTORY_ARGS_LENGTH)}...`; +function hashArgs(redactedArgs: string): string { + return createHash('sha256').update(redactedArgs).digest('hex').slice(0, 16); } function jsonToolResult(value: unknown): ToolCallResult { @@ -1333,12 +1972,48 @@ function optionalPositiveInteger(value: unknown): number | undefined { return value; } +function optionalPositiveIntegerArray(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.filter( + (entry): entry is number => + typeof entry === 'number' && Number.isInteger(entry) && entry >= 1, + ); + return entries.length === value.length ? entries : undefined; +} + +function optionalStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const entries = value.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0, + ); + return entries.length === value.length ? entries : undefined; +} + +function optionalScopeMode(value: unknown): ScopeMode | undefined { + if (value === 'current' || value === 'downstack' || value === 'stack') { + return value; + } + return undefined; +} + +function optionalSplitMode(value: unknown): SplitMode | undefined { + if ( + value === 'by-commit' || + value === 'by-file' || + value === 'by-hunk' || + value === 'ai' + ) { + return value; + } + return undefined; +} + /** * Validates the `entries` argument of the `dubstack.reorder` tool call and * normalises it to a `RebaseTodoEntry[]`. Surfaces a `DubError` with * recovery hints when the shape is wrong so AI clients can self-correct. * - * Enforces non-emptiness, ≥2 entries (matches `reorder()` which rejects + * Enforces non-emptiness, >=2 entries (matches `reorder()` which rejects * single-commit branches), well-formed shape, no duplicate SHAs, and at * least one `pick`. The "every commit between parent and HEAD must appear" * invariant is enforced downstream by `reorder()` itself once it can read @@ -1349,7 +2024,7 @@ function parseReorderEntries(value: unknown): RebaseTodoEntry[] { throw new DubError( "'entries' must be a non-empty array of {sha, action} objects.", [ - "Pass the full rebase todo, oldest commit first, e.g. [{'sha': '', 'action': 'pick'}, …].", + "Pass the full rebase todo, oldest commit first, e.g. [{'sha': '', 'action': 'pick'}, ...].", 'Call dubstack.log or git log to discover the commits to reorder.', ], );