Skip to content

Commit 8e056ee

Browse files
authored
feat: expand MCP tool surface (#39)
* feat: expand MCP tool surface * refactor: trim MCP navigation tools
1 parent d8dad4a commit 8e056ee

8 files changed

Lines changed: 544 additions & 70 deletions

File tree

src/mcp/client.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import type { AppliedCommentResult, HunkSessionRegistration, HunkSessionSnapshot, SessionClientMessage, SessionServerMessage } from "./types";
1+
import type {
2+
AppliedCommentResult,
3+
HunkSessionRegistration,
4+
HunkSessionSnapshot,
5+
NavigatedSelectionResult,
6+
SessionClientMessage,
7+
SessionCommandResult,
8+
SessionServerMessage,
9+
} from "./types";
210
import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config";
311
import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "./daemonLauncher";
412

@@ -9,6 +17,7 @@ const HEARTBEAT_INTERVAL_MS = 10_000;
917

1018
export interface HunkAppBridge {
1119
applyComment: (message: Extract<SessionServerMessage, { command: "comment" }>) => Promise<AppliedCommentResult>;
20+
navigateToHunk: (message: Extract<SessionServerMessage, { command: "navigate_to_hunk" }>) => Promise<NavigatedSelectionResult>;
1221
}
1322

1423
/** Keep one running Hunk TUI session registered with the local MCP daemon. */
@@ -220,7 +229,7 @@ export class HunkHostClient {
220229
}
221230

222231
try {
223-
const result = await this.bridge.applyComment(message);
232+
const result = await this.dispatchCommand(message);
224233
this.send({
225234
type: "command-result",
226235
requestId: message.requestId,
@@ -237,6 +246,19 @@ export class HunkHostClient {
237246
}
238247
}
239248

249+
private dispatchCommand(message: SessionServerMessage): Promise<SessionCommandResult> {
250+
if (!this.bridge) {
251+
throw new Error("Hunk MCP bridge is not connected.");
252+
}
253+
254+
switch (message.command) {
255+
case "comment":
256+
return this.bridge.applyComment(message);
257+
case "navigate_to_hunk":
258+
return this.bridge.navigateToHunk(message);
259+
}
260+
}
261+
240262
private async flushQueuedMessages() {
241263
if (!this.bridge || this.queuedMessages.length === 0) {
242264
return;

src/mcp/daemonState.ts

Lines changed: 108 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import { randomUUID } from "node:crypto";
2-
import type { AppliedCommentResult, CommentToolInput, HunkSessionRegistration, HunkSessionSnapshot, ListedSession } from "./types";
2+
import type {
3+
AppliedCommentResult,
4+
CommentToolInput,
5+
HunkSessionRegistration,
6+
HunkSessionSnapshot,
7+
ListedSession,
8+
NavigateToHunkToolInput,
9+
NavigatedSelectionResult,
10+
SelectedSessionContext,
11+
SessionCommandResult,
12+
SessionServerMessage,
13+
SessionTargetInput,
14+
} from "./types";
315

416
interface PendingCommand {
517
sessionId: string;
6-
resolve: (result: AppliedCommentResult) => void;
18+
resolve: (result: SessionCommandResult) => void;
719
reject: (error: Error) => void;
820
timeout: Timer;
921
}
@@ -29,6 +41,14 @@ function describeSessionChoices(sessions: ListedSession[]) {
2941
return sessions.map((session) => `${session.sessionId} (${session.title})`).join(", ");
3042
}
3143

44+
function findSelectedFile(session: ListedSession) {
45+
return session.files.find(
46+
(file) => file.id === session.snapshot.selectedFileId
47+
|| file.path === session.snapshot.selectedFilePath
48+
|| file.previousPath === session.snapshot.selectedFilePath,
49+
) ?? null;
50+
}
51+
3252
/** Resolve which live Hunk session one external command should target. */
3353
export function resolveSessionTarget(sessions: ListedSession[], selector: SessionTargetSelector) {
3454
if (selector.sessionId) {
@@ -98,6 +118,29 @@ export class HunkDaemonState {
98118
return resolveSessionTarget(this.listSessions(), selector);
99119
}
100120

121+
getSelectedContext(selector: SessionTargetSelector): SelectedSessionContext {
122+
const session = this.getSession(selector);
123+
const selectedFile = findSelectedFile(session);
124+
125+
return {
126+
sessionId: session.sessionId,
127+
title: session.title,
128+
sourceLabel: session.sourceLabel,
129+
repoRoot: session.repoRoot,
130+
inputKind: session.inputKind,
131+
selectedFile,
132+
selectedHunk: selectedFile
133+
? {
134+
index: session.snapshot.selectedHunkIndex,
135+
oldRange: session.snapshot.selectedHunkOldRange,
136+
newRange: session.snapshot.selectedHunkNewRange,
137+
}
138+
: null,
139+
showAgentNotes: session.snapshot.showAgentNotes,
140+
liveCommentCount: session.snapshot.liveCommentCount,
141+
};
142+
}
143+
101144
getPendingCommandCount() {
102145
return this.pendingCommands.size;
103146
}
@@ -182,52 +225,25 @@ export class HunkDaemonState {
182225
return removed;
183226
}
184227

185-
async sendComment(input: CommentToolInput) {
186-
const session = resolveSessionTarget(this.listSessions(), {
187-
sessionId: input.sessionId,
188-
repoRoot: input.repoRoot,
189-
});
190-
const requestId = randomUUID();
191-
192-
return new Promise<AppliedCommentResult>((resolve, reject) => {
193-
const timeout = setTimeout(() => {
194-
this.pendingCommands.delete(requestId);
195-
reject(new Error("Timed out waiting for the Hunk session to apply the comment."));
196-
}, 15_000);
197-
198-
this.pendingCommands.set(requestId, {
199-
sessionId: session.sessionId,
200-
resolve,
201-
reject,
202-
timeout,
203-
});
204-
205-
const entry = this.sessions.get(session.sessionId);
206-
if (!entry) {
207-
clearTimeout(timeout);
208-
this.pendingCommands.delete(requestId);
209-
reject(new Error("The targeted Hunk session is no longer connected."));
210-
return;
211-
}
228+
sendComment(input: CommentToolInput) {
229+
return this.sendCommand<AppliedCommentResult, "comment">(
230+
{ sessionId: input.sessionId, repoRoot: input.repoRoot },
231+
"comment",
232+
input,
233+
"Timed out waiting for the Hunk session to apply the comment.",
234+
);
235+
}
212236

213-
try {
214-
entry.socket.send(
215-
JSON.stringify({
216-
type: "command",
217-
requestId,
218-
command: "comment",
219-
input,
220-
}),
221-
);
222-
} catch (error) {
223-
clearTimeout(timeout);
224-
this.pendingCommands.delete(requestId);
225-
reject(error instanceof Error ? error : new Error("The targeted Hunk session could not receive the command."));
226-
}
227-
});
237+
sendNavigateToHunk(input: NavigateToHunkToolInput) {
238+
return this.sendCommand<NavigatedSelectionResult, "navigate_to_hunk">(
239+
{ sessionId: input.sessionId, repoRoot: input.repoRoot },
240+
"navigate_to_hunk",
241+
input,
242+
"Timed out waiting for the Hunk session to navigate to the requested hunk.",
243+
);
228244
}
229245

230-
handleCommandResult(message: { requestId: string; ok: boolean; result?: AppliedCommentResult; error?: string }) {
246+
handleCommandResult(message: { requestId: string; ok: boolean; result?: SessionCommandResult; error?: string }) {
231247
const pending = this.pendingCommands.get(message.requestId);
232248
if (!pending) {
233249
return;
@@ -255,6 +271,53 @@ export class HunkDaemonState {
255271
this.sessions.clear();
256272
}
257273

274+
private sendCommand<ResultType extends SessionCommandResult, CommandName extends SessionServerMessage["command"]>(
275+
selector: SessionTargetInput,
276+
command: CommandName,
277+
input: Extract<SessionServerMessage, { command: CommandName }>["input"],
278+
timeoutMessage: string,
279+
) {
280+
const session = resolveSessionTarget(this.listSessions(), selector);
281+
const requestId = randomUUID();
282+
283+
return new Promise<ResultType>((resolve, reject) => {
284+
const timeout = setTimeout(() => {
285+
this.pendingCommands.delete(requestId);
286+
reject(new Error(timeoutMessage));
287+
}, 15_000);
288+
289+
this.pendingCommands.set(requestId, {
290+
sessionId: session.sessionId,
291+
resolve: (result) => resolve(result as ResultType),
292+
reject,
293+
timeout,
294+
});
295+
296+
const entry = this.sessions.get(session.sessionId);
297+
if (!entry) {
298+
clearTimeout(timeout);
299+
this.pendingCommands.delete(requestId);
300+
reject(new Error("The targeted Hunk session is no longer connected."));
301+
return;
302+
}
303+
304+
try {
305+
const message = {
306+
type: "command",
307+
requestId,
308+
command,
309+
input,
310+
} as Extract<SessionServerMessage, { command: CommandName }>;
311+
312+
entry.socket.send(JSON.stringify(message));
313+
} catch (error) {
314+
clearTimeout(timeout);
315+
this.pendingCommands.delete(requestId);
316+
reject(error instanceof Error ? error : new Error("The targeted Hunk session could not receive the command."));
317+
}
318+
});
319+
}
320+
258321
private removeSession(sessionId: string, reason: string) {
259322
const entry = this.sessions.get(sessionId);
260323
if (!entry) {

src/mcp/server.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ interface McpTransportEntry {
1515
transport: WebStandardStreamableHTTPServerTransport;
1616
}
1717

18+
const sessionSelectorSchema = z.object({
19+
sessionId: z.string().optional().describe("Explicit Hunk session id."),
20+
repoRoot: z.string().optional().describe("Repo root fallback when exactly one session matches."),
21+
});
22+
23+
const navigateToHunkSchema = sessionSelectorSchema.extend({
24+
filePath: z.string().describe("Diff file path as shown by Hunk."),
25+
hunkIndex: z.number().int().nonnegative().optional().describe("0-based hunk index within the file."),
26+
side: z.enum(["old", "new"]).optional().describe("Optional diff side when resolving by line number."),
27+
line: z.number().int().positive().optional().describe("Optional 1-based diff line number when resolving by line number."),
28+
}).refine(
29+
(input) => input.hunkIndex !== undefined || (input.side !== undefined && input.line !== undefined),
30+
{
31+
error: "Provide either hunkIndex or both side and line.",
32+
path: ["hunkIndex"],
33+
},
34+
);
35+
1836
function formatToolJson(value: unknown) {
1937
return JSON.stringify(value, null, 2);
2038
}
@@ -74,10 +92,7 @@ function createHunkMcpServer(state: HunkDaemonState) {
7492
{
7593
title: "Get one live Hunk session",
7694
description: "Fetch details for one live Hunk session by session id or repo root.",
77-
inputSchema: z.object({
78-
sessionId: z.string().optional().describe("Explicit Hunk session id."),
79-
repoRoot: z.string().optional().describe("Repo root fallback when exactly one session matches."),
80-
}) as any,
95+
inputSchema: sessionSelectorSchema as any,
8196
} as any,
8297
(async (input: { sessionId?: string; repoRoot?: string }) => {
8398
const session = state.getSession({ sessionId: input.sessionId, repoRoot: input.repoRoot });
@@ -91,14 +106,57 @@ function createHunkMcpServer(state: HunkDaemonState) {
91106
}) as any,
92107
);
93108

109+
server.registerTool(
110+
"get_selected_context",
111+
{
112+
title: "Get the selected file and hunk",
113+
description: "Inspect which file and hunk the live Hunk session is currently focused on.",
114+
inputSchema: sessionSelectorSchema as any,
115+
} as any,
116+
(async (input: { sessionId?: string; repoRoot?: string }) => {
117+
const context = state.getSelectedContext({ sessionId: input.sessionId, repoRoot: input.repoRoot });
118+
119+
return {
120+
content: textContent(formatToolJson(context)),
121+
structuredContent: {
122+
context,
123+
},
124+
};
125+
}) as any,
126+
);
127+
128+
server.registerTool(
129+
"navigate_to_hunk",
130+
{
131+
title: "Focus one diff hunk",
132+
description: "Move the live Hunk session to one diff hunk by index or by a specific diff line.",
133+
inputSchema: navigateToHunkSchema as any,
134+
} as any,
135+
(async (input: {
136+
sessionId?: string;
137+
repoRoot?: string;
138+
filePath: string;
139+
hunkIndex?: number;
140+
side?: "old" | "new";
141+
line?: number;
142+
}) => {
143+
const result = await state.sendNavigateToHunk(input);
144+
145+
return {
146+
content: textContent(formatToolJson(result)),
147+
structuredContent: {
148+
result,
149+
},
150+
};
151+
}) as any,
152+
);
153+
94154
server.registerTool(
95155
"comment",
96156
{
97157
title: "Comment on a live Hunk diff",
98158
description: "Attach an inline review note to a specific diff line in a live Hunk session.",
99-
inputSchema: z.object({
100-
sessionId: z.string().optional().describe("Explicit Hunk session id."),
101-
repoRoot: z.string().optional().describe("Repo root fallback when exactly one session matches."),
159+
inputSchema: sessionSelectorSchema.extend({
102160
filePath: z.string().describe("Diff file path as shown by Hunk."),
103161
side: z.enum(["old", "new"]).describe("Which side of the diff the line belongs to."),
104162
line: z.number().int().positive().describe("1-based diff line number on the chosen side."),

src/mcp/sessionRegistration.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { randomUUID } from "node:crypto";
22
import type { AppBootstrap } from "../core/types";
3+
import { hunkLineRange } from "../core/liveComments";
34
import type { HunkSessionRegistration, HunkSessionSnapshot } from "./types";
45

56
function inferRepoRoot(bootstrap: AppBootstrap) {
@@ -32,10 +33,16 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR
3233

3334
/** Start with an empty-but-valid snapshot until the UI reports its first selection. */
3435
export function createInitialSessionSnapshot(bootstrap: AppBootstrap): HunkSessionSnapshot {
36+
const firstFile = bootstrap.changeset.files[0];
37+
const firstHunk = firstFile?.metadata.hunks[0];
38+
const firstRange = firstHunk ? hunkLineRange(firstHunk) : null;
39+
3540
return {
36-
selectedFileId: bootstrap.changeset.files[0]?.id,
37-
selectedFilePath: bootstrap.changeset.files[0]?.path,
41+
selectedFileId: firstFile?.id,
42+
selectedFilePath: firstFile?.path,
3843
selectedHunkIndex: 0,
44+
selectedHunkOldRange: firstRange?.oldRange,
45+
selectedHunkNewRange: firstRange?.newRange,
3946
showAgentNotes: bootstrap.initialShowAgentNotes ?? false,
4047
liveCommentCount: 0,
4148
updatedAt: new Date().toISOString(),

0 commit comments

Comments
 (0)