Skip to content
Open
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
147 changes: 98 additions & 49 deletions src/lib/mcp/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { getSwarmAccessByWorkspaceId } from "@/lib/helpers/swarm-access";
import {
mcpListConcepts,
mcpLearnConcept,
mcpStakgraphSearch,
mcpStakgraphAsk,
mcpListFeatures,
mcpReadFeature,
mcpCreateFeature,
Expand All @@ -25,9 +27,12 @@ import {
} from "@/lib/mcp/mcpTools";

// Available tools registry
// TODO: add "stakgraph_map", "stakgraph_nodes", "stakgraph_code"
const AVAILABLE_TOOLS = [
"list_concepts",
"learn_concept",
"stakgraph_search",
"stakgraph_ask",
"list_features",
"read_feature",
"create_feature",
Expand Down Expand Up @@ -145,10 +150,7 @@ async function getWorkspaceAuth(

// Create a fresh McpServer with tools registered
function createServer(): McpServer {
const server = new McpServer(
{ name: "hive", version: "1.0.0" },
{ capabilities: { tools: {} } },
);
const server = new McpServer({ name: "hive", version: "1.0.0" }, { capabilities: { tools: {} } });

server.registerTool(
"list_concepts",
Expand All @@ -173,9 +175,7 @@ function createServer(): McpServer {
description:
"Fetch documentation for a specific concept by ID. Returns the documentation content for the concept.",
inputSchema: {
conceptId: z
.string()
.describe("The ID of the concept to retrieve documentation for"),
conceptId: z.string().describe("The ID of the concept to retrieve documentation for"),
},
},
async ({ conceptId }: { conceptId: string }, extra) => {
Expand All @@ -186,6 +186,75 @@ function createServer(): McpServer {
},
);

// ----- Stakgraph code-graph tools -----

server.registerTool(
"stakgraph_search",
{
title: "Search Codebase",
description:
"Search the code graph by keyword (fulltext), semantic meaning (vector), or both combined (hybrid). Use hybrid for best recall. Returns ranked code nodes such as functions, classes, and endpoints.",
inputSchema: {
query: z.string().describe("Search query — keywords or natural language"),
method: z
.enum(["fulltext", "vector", "hybrid"])
.optional()
.describe(
"Search strategy: fulltext (BM25 keyword), vector (semantic similarity), or hybrid (both combined via RRF). Defaults to hybrid.",
)
.default("hybrid"),
node_types: z
.array(z.string())
.optional()
.describe('Filter results to specific node types, e.g. ["Function", "Class", "Endpoint"]'),
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 25."),
language: z.string().optional().describe('Filter by programming language, e.g. "typescript" or "python"'),
concise: z.boolean().optional().describe("If true, return only node name and filename without code bodies."),
},
},
async (
{
query,
method,
node_types,
limit,
language,
concise,
}: {
query: string;
method?: "fulltext" | "vector" | "hybrid";
node_types?: string[];
limit?: number;
language?: string;
concise?: boolean;
},
extra,
) => {
const authExtra = extra.authInfo?.extra as McpAuthExtra | undefined;
const result = getCredentialsFromAuth(authExtra, "stakgraph_search");
if (result.error) return result.error;
return mcpStakgraphSearch(result.credentials, { query, method, node_types, limit, language, concise });
},
);

server.registerTool(
"stakgraph_ask",
{
title: "Ask Codebase",
description:
'Ask a natural-language question about the codebase. Runs an AI pipeline that decomposes the question, explores the code graph with hybrid search, and synthesises a coherent answer. Best for multi-hop understanding queries like "How does authentication work?"',
inputSchema: {
question: z.string().describe("The question to ask about the codebase"),
},
},
async ({ question }: { question: string }, extra) => {
const authExtra = extra.authInfo?.extra as McpAuthExtra | undefined;
const result = getCredentialsFromAuth(authExtra, "stakgraph_ask");
if (result.error) return result.error;
return mcpStakgraphAsk(result.credentials, { question });
},
);

// ----- Feature tools (DB-direct) -----

server.registerTool(
Expand All @@ -211,9 +280,7 @@ function createServer(): McpServer {
description:
"Read a feature's plan details and full chat message history. Also indicates whether the planning workflow is currently running.",
inputSchema: {
featureId: z
.string()
.describe("The ID of the feature to read"),
featureId: z.string().describe("The ID of the feature to read"),
},
},
async ({ featureId }: { featureId: string }, extra) => {
Expand All @@ -228,21 +295,15 @@ function createServer(): McpServer {
"create_feature",
{
title: "Create Feature",
description:
"Create a new feature in the workspace with a brief description and optional requirements.",
description: "Create a new feature in the workspace with a brief description and optional requirements.",
inputSchema: {
title: z.string().describe("The title of the feature"),
brief: z.string().describe("A brief description of the feature"),
requirements: z
.string()
.optional()
.describe("Optional detailed requirements for the feature"),
requirements: z.string().optional().describe("Optional detailed requirements for the feature"),
creator: z
.string()
.optional()
.describe(
"Name of the creator (matched against name or alias). Falls back to workspace owner if not found.",
),
.describe("Name of the creator (matched against name or alias). Falls back to workspace owner if not found."),
},
},
async (
Expand Down Expand Up @@ -286,9 +347,7 @@ function createServer(): McpServer {
description:
"Read a task's details and full chat message history. Also indicates whether the task workflow is currently running.",
inputSchema: {
taskId: z
.string()
.describe("The ID of the task to read"),
taskId: z.string().describe("The ID of the task to read"),
},
},
async ({ taskId }: { taskId: string }, extra) => {
Expand All @@ -303,24 +362,18 @@ function createServer(): McpServer {
"create_task",
{
title: "Create Task",
description:
"Create a new task in the workspace with a title and optional description and priority.",
description: "Create a new task in the workspace with a title and optional description and priority.",
inputSchema: {
title: z.string().describe("The title of the task"),
description: z
.string()
.optional()
.describe("A description of the task"),
description: z.string().optional().describe("A description of the task"),
priority: z
.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"])
.optional()
.describe("Priority level (LOW, MEDIUM, HIGH, CRITICAL). Defaults to MEDIUM."),
creator: z
.string()
.optional()
.describe(
"Name of the creator (matched against name or alias). Falls back to workspace owner if not found.",
),
.describe("Name of the creator (matched against name or alias). Falls back to workspace owner if not found."),
},
},
async (
Expand Down Expand Up @@ -362,9 +415,7 @@ function createServer(): McpServer {
if (result.error) return result.error;
// Resolve the creator separately — only filter when explicitly provided.
// If the name doesn't match anyone, return all (no filter).
const filterUserId = creator
? await findWorkspaceUser(result.auth!.workspaceId, creator)
: undefined;
const filterUserId = creator ? await findWorkspaceUser(result.auth!.workspaceId, creator) : undefined;
return mcpCheckStatus(result.auth!, filterUserId);
},
);
Expand All @@ -378,26 +429,24 @@ function createServer(): McpServer {
description:
"Send a message to a feature's planning chat or a task's agent chat. Provide exactly one of featureId or taskId. For features this triggers the AI planning workflow; for tasks it triggers the agent workflow.",
inputSchema: {
featureId: z
.string()
.optional()
.describe("The ID of the feature to send a message to"),
taskId: z
.string()
.optional()
.describe("The ID of the task to send a message to"),
message: z
.string()
.describe("The message text to send"),
featureId: z.string().optional().describe("The ID of the feature to send a message to"),
taskId: z.string().optional().describe("The ID of the task to send a message to"),
message: z.string().describe("The message text to send"),
creator: z
.string()
.optional()
.describe(
"Name of the sender (matched against name or alias). Falls back to workspace owner if not found.",
),
.describe("Name of the sender (matched against name or alias). Falls back to workspace owner if not found."),
},
},
async ({ featureId, taskId, message, creator }: { featureId?: string; taskId?: string; message: string; creator?: string }, extra) => {
async (
{
featureId,
taskId,
message,
creator,
}: { featureId?: string; taskId?: string; message: string; creator?: string },
extra,
) => {
const authExtra = extra.authInfo?.extra as McpAuthExtra | undefined;
const result = await getWorkspaceAuth(authExtra, "send_message", creator);
if (result.error) return result.error;
Expand Down
77 changes: 77 additions & 0 deletions src/lib/mcp/mcpTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,83 @@ function daysAgo(n: number): Date {
* When filterUserId is provided, only items created by or assigned to that
* user are returned.
*/
// ---------------------------------------------------------------------------
// Stakgraph code-graph tools (swarm-backed)
// TODO: add mcpStakgraphMap, mcpStakgraphNodes, mcpStakgraphCode
// ---------------------------------------------------------------------------

/**
* Search the code graph using fulltext, vector (semantic), or hybrid (RRF) search.
*/
export async function mcpStakgraphSearch(
credentials: SwarmCredentials,
params: {
query: string;
method?: string;
node_types?: string[];
limit?: number;
language?: string;
concise?: boolean;
},
): Promise<McpToolResult> {
try {
const url = new URL(`${credentials.swarmUrl}/search`);
url.searchParams.set("query", params.query);
url.searchParams.set("method", params.method || "hybrid");
url.searchParams.set("output", "json");
if (params.node_types?.length)
url.searchParams.set("node_types", params.node_types.join(","));
if (params.limit != null)
url.searchParams.set("limit", String(params.limit));
if (params.language)
url.searchParams.set("language", params.language);
if (params.concise)
url.searchParams.set("concise", "true");

const res = await fetch(url.toString(), {
method: "GET",
headers: { "x-api-token": credentials.swarmApiKey },
});

if (!res.ok)
return mcpError(`Error: Stakgraph search failed (${res.status})`);

const data = await res.json();
return mcpOk(data);
} catch (error) {
console.error("Error in stakgraph search:", error);
return mcpError("Error: Could not search the code graph");
}
}

/**
* Ask a natural-language question about the codebase.
* Runs the decompose → explore (hybrid search) → recompose pipeline and returns a synthesised answer.
*/
export async function mcpStakgraphAsk(
credentials: SwarmCredentials,
params: { question: string },
): Promise<McpToolResult> {
try {
const url = new URL(`${credentials.swarmUrl}/ask`);
url.searchParams.set("question", params.question);

const res = await fetch(url.toString(), {
method: "GET",
headers: { "x-api-token": credentials.swarmApiKey },
});

if (!res.ok)
return mcpError(`Error: Stakgraph ask failed (${res.status})`);

const data = await res.json();
return mcpOk(data);
} catch (error) {
console.error("Error in stakgraph ask:", error);
return mcpError("Error: Could not query the code graph");
}
}

export async function mcpCheckStatus(
auth: WorkspaceAuth,
filterUserId?: string,
Expand Down
Loading