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
81 changes: 81 additions & 0 deletions src/mcp/deps-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Deps parity tests — verify that MCP entry points forward all stores
* provided by LocalRuntime into the McpDeps object.
*
* Catches wiring omissions like goalSessionStore not being passed through
* (issue #214).
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { createLocalRuntime, type LocalRuntime } from "../local/runtime.js";
import type { McpDeps } from "./deps.js";

describe("MCP deps parity with LocalRuntime", () => {
let tempDir: string;
let runtime: LocalRuntime;

beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "grove-deps-parity-"));
const groveDir = join(tempDir, ".grove");
await Bun.write(join(groveDir, ".gitkeep"), "");

runtime = createLocalRuntime({
groveDir,
frontierCacheTtlMs: 0,
workspace: true,
parseContract: false, // no GROVE.md in temp dir
});
});

afterEach(async () => {
runtime.close();
await rm(tempDir, { recursive: true, force: true });
});

test("LocalRuntime always provides goalSessionStore", () => {
expect(runtime.goalSessionStore).toBeDefined();
});

test("stdio MCP deps construction includes goalSessionStore", () => {
// Mirror the deps construction from src/mcp/serve.ts
const deps: McpDeps = {
contributionStore: runtime.contributionStore,
claimStore: runtime.claimStore,
bountyStore: runtime.bountyStore,
cas: runtime.cas,
frontier: runtime.frontier,
workspace: runtime.workspace!,
contract: runtime.contract,
onContributionWrite: runtime.onContributionWrite,
workspaceBoundary: runtime.groveRoot,
goalSessionStore: runtime.goalSessionStore,
handoffStore: runtime.handoffStore,
};

expect(deps.goalSessionStore).toBeDefined();
expect(deps.goalSessionStore).toBe(runtime.goalSessionStore);
});

test("HTTP MCP deps construction includes goalSessionStore", () => {
// Mirror the deps construction from src/mcp/serve-http.ts buildScopedDeps
const deps: McpDeps = {
contributionStore: runtime.contributionStore,
claimStore: runtime.claimStore,
bountyStore: runtime.bountyStore,
cas: runtime.cas,
frontier: runtime.frontier,
workspace: runtime.workspace!,
contract: runtime.contract,
onContributionWrite: runtime.onContributionWrite,
workspaceBoundary: runtime.groveRoot,
goalSessionStore: runtime.goalSessionStore,
};

expect(deps.goalSessionStore).toBeDefined();
expect(deps.goalSessionStore).toBe(runtime.goalSessionStore);
});
});
4 changes: 2 additions & 2 deletions src/mcp/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ export function validationError(message: string): CallToolResult {
return toolError(McpErrorCode.ValidationError, message);
}

/** Build a CallToolResult with isError: true. */
function toolError(code: string, message: string): CallToolResult {
/** Build a CallToolResult with isError: true. Format: `[CODE] message`. */
export function toolError(code: string, message: string): CallToolResult {
return {
isError: true,
content: [{ type: "text" as const, text: `[${code}] ${message}` }],
Expand Down
6 changes: 2 additions & 4 deletions src/mcp/operation-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { OperationDeps } from "../core/operations/deps.js";
import type { OperationResult } from "../core/operations/result.js";
import type { McpDeps } from "./deps.js";
import { McpErrorCode, toolError } from "./error-handler.js";

/**
* Convert McpDeps to OperationDeps.
Expand Down Expand Up @@ -73,8 +74,5 @@ export function toMcpResult<T>(result: OperationResult<T>, warning?: string): Ca
* specific field and include an example of correct input.
*/
export function toolValidationError(message: string): CallToolResult {
return {
isError: true,
content: [{ type: "text" as const, text: `VALIDATION_ERROR: ${message}` }],
};
return toolError(McpErrorCode.ValidationError, message);
}
46 changes: 21 additions & 25 deletions src/mcp/serve-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ async function buildScopedDeps(sessionId: string | undefined): Promise<ScopedDep
contract: loadedContract,
onContributionWrite: runtime.onContributionWrite,
workspaceBoundary: runtime.groveRoot,
goalSessionStore: runtime.goalSessionStore,
...(eventBus ? { eventBus } : {}),
...(topologyRouter ? { topologyRouter } : {}),
// Nexus handoff store when available, falls back to local SQLite
Expand Down Expand Up @@ -436,13 +437,18 @@ const reapTimer = setInterval(() => {

// --- HTTP server ------------------------------------------------------------

/** Format an HTTP-level JSON error response body. */
function httpError(code: string, message: string): string {
return JSON.stringify({ error: { code, message } });
}

async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = req.url ?? "/";

// Only handle /mcp endpoint
if (url !== "/mcp") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. Use /mcp endpoint." }));
res.end(httpError("NOT_FOUND", "Not found. Use /mcp endpoint."));
return;
}

Expand All @@ -451,7 +457,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
const authHeader = req.headers.authorization ?? "";
if (authHeader !== `Bearer ${AUTH_TOKEN}`) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized" }));
res.end(httpError("UNAUTHORIZED", "Unauthorized"));
return;
}
}
Expand All @@ -467,7 +473,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
} catch (err) {
if (err instanceof BodyTooLargeError) {
res.writeHead(413, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Request body too large" }));
res.end(httpError("BODY_TOO_LARGE", "Request body too large"));
return;
}
throw err;
Expand All @@ -477,7 +483,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Invalid JSON" }));
res.end(httpError("INVALID_JSON", "Invalid JSON"));
return;
}

Expand Down Expand Up @@ -522,14 +528,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`${msg}\n`);
res.writeHead(503, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: {
code: "SESSION_NOT_READY",
message: msg,
},
}),
);
res.end(httpError("SESSION_NOT_READY", msg));
return;
}

Expand All @@ -542,15 +541,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
if (nexusClient && scoped.sessionId === undefined) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: {
code: "SESSION_NOT_READY",
message:
"grove-mcp-http: no grove session selected — initialize the HTTP MCP " +
"after starting a session via the TUI. Mutations in bootstrap mode " +
"would land outside session scope and are refused.",
},
}),
httpError(
"SESSION_NOT_READY",
"grove-mcp-http: no grove session selected — initialize the HTTP MCP " +
"after starting a session via the TUI. Mutations in bootstrap mode " +
"would land outside session scope and are refused.",
),
);
return;
}
Expand Down Expand Up @@ -589,7 +585,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
const getSession = sessionId ? sessions.get(sessionId) : undefined;
if (!getSession) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing or invalid Mcp-Session-Id header" }));
res.end(httpError("INVALID_SESSION", "Missing or invalid Mcp-Session-Id header"));
return;
}
getSession.lastActivity = Date.now();
Expand All @@ -606,15 +602,15 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
const delSession = sessionId ? sessions.get(sessionId) : undefined;
if (!delSession) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found" }));
res.end(httpError("NOT_FOUND", "Session not found"));
return;
}
await delSession.transport.handleRequest(req, res);
await delSession.server.close();
if (sessionId) sessions.delete(sessionId);
} else {
res.writeHead(405, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Method not allowed" }));
res.end(httpError("METHOD_NOT_ALLOWED", "Method not allowed"));
}
}

Expand Down Expand Up @@ -657,7 +653,7 @@ const httpServer = createServer((req, res) => {
process.stderr.write(`grove-mcp-http: ${String(error)}\n`);
if (!res.headersSent) {
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
res.end(httpError("INTERNAL_ERROR", "Internal server error"));
}
});
});
Expand Down
1 change: 1 addition & 0 deletions src/mcp/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ try {
onContributionWrite: runtime.onContributionWrite,
...(onContributionWritten ? { onContributionWritten } : {}),
workspaceBoundary: runtime.groveRoot,
goalSessionStore: runtime.goalSessionStore,
...(outcomeStore ? { outcomeStore } : {}),
...(eventBus ? { eventBus } : {}),
...(topologyRouter ? { topologyRouter } : {}),
Expand Down
21 changes: 3 additions & 18 deletions src/mcp/tools/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

import type { McpDeps } from "../deps.js";
import { toolError } from "../error-handler.js";

// ---------------------------------------------------------------------------
// Input schemas
Expand Down Expand Up @@ -44,15 +45,7 @@ export function registerGoalTools(server: McpServer, deps: McpDeps): void {
async () => {
const store = deps.goalSessionStore;
if (!store) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[NOT_CONFIGURED] Goal/session store is not configured",
},
],
};
return toolError("NOT_CONFIGURED", "Goal/session store is not configured");
}

const goalData = await store.getGoal();
Expand Down Expand Up @@ -91,15 +84,7 @@ export function registerGoalTools(server: McpServer, deps: McpDeps): void {
async (args) => {
const store = deps.goalSessionStore;
if (!store) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[NOT_CONFIGURED] Goal/session store is not configured",
},
],
};
return toolError("NOT_CONFIGURED", "Goal/session store is not configured");
}

const result = await store.setGoal(args.goal, args.acceptance, "mcp");
Expand Down
34 changes: 7 additions & 27 deletions src/mcp/tools/handoffs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { HandoffStatus } from "../../core/handoff.js";
import type { McpDeps } from "../deps.js";
import { toolError } from "../error-handler.js";

const listHandoffsInputSchema = z.object({
toRole: z
Expand Down Expand Up @@ -60,15 +61,10 @@ export function registerHandoffTools(server: McpServer, deps: McpDeps): void {
async (args) => {
const { handoffStore } = deps;
if (handoffStore === undefined) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[NOT_CONFIGURED] Handoff store is not available. Topology routing must be active.",
},
],
};
return toolError(
"NOT_CONFIGURED",
"Handoff store is not available. Topology routing must be active.",
);
}

// Expire stale handoffs before listing so callers always see fresh status.
Expand Down Expand Up @@ -97,28 +93,12 @@ export function registerHandoffTools(server: McpServer, deps: McpDeps): void {
async (args) => {
const { handoffStore } = deps;
if (handoffStore === undefined) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[NOT_CONFIGURED] Handoff store is not available.",
},
],
};
return toolError("NOT_CONFIGURED", "Handoff store is not available.");
}

const handoff = await handoffStore.get(args.handoffId);
if (handoff === undefined) {
return {
isError: true,
content: [
{
type: "text" as const,
text: `[NOT_FOUND] Handoff '${args.handoffId}' not found.`,
},
],
};
return toolError("NOT_FOUND", `Handoff '${args.handoffId}' not found.`);
}

return {
Expand Down
25 changes: 6 additions & 19 deletions src/mcp/tools/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { assertWithinBoundary } from "../../core/path-safety.js";
import { ingestGitDiff } from "../../local/ingest/git-diff.js";
import { ingestGitTree } from "../../local/ingest/git-tree.js";
import type { McpDeps } from "../deps.js";
import { handleToolError } from "../error-handler.js";
import { handleToolError, toolError } from "../error-handler.js";

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -98,26 +98,13 @@ export function registerIngestTools(server: McpServer, deps: McpDeps): void {

// Validate: exactly one of content or filePath must be provided
if (content !== undefined && filePath !== undefined) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[VALIDATION_ERROR] Provide exactly one of `content` or `filePath`, not both.",
},
],
};
return toolError(
"VALIDATION_ERROR",
"Provide exactly one of `content` or `filePath`, not both.",
);
}
if (content === undefined && filePath === undefined) {
return {
isError: true,
content: [
{
type: "text" as const,
text: "[VALIDATION_ERROR] Provide exactly one of `content` or `filePath`.",
},
],
};
return toolError("VALIDATION_ERROR", "Provide exactly one of `content` or `filePath`.");
}

const putOptions = mediaType !== undefined ? { mediaType } : undefined;
Expand Down
Loading
Loading