Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion apps/docs/content/docs/guides/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
143 changes: 141 additions & 2 deletions packages/cli/src/commands/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +27,7 @@ afterEach(async () => {
await stopMcpChild(child);
}
child = null;
vi.unstubAllEnvs();
await cleanup();
});

Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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<ConfirmMutatingFn>(), {
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<ConfirmMutatingFn>(), {
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<ConfirmMutatingFn>();
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', () => {
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -387,6 +520,7 @@ async function runMcpCall(
cwd: string,
confirmMutating: ConfirmMutatingFn,
message: unknown,
options: { aiMetadataDeps?: AiMetadataDependencies } = {},
): Promise<Record<string, unknown>> {
const input = new PassThrough();
const output = new PassThrough();
Expand All @@ -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();
Expand Down
Loading
Loading