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
32 changes: 17 additions & 15 deletions apps/agor-daemon/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ export function setupMCPRoutes(
transport: StreamableHTTPServerTransport;
server: McpServer;
userId: UserID;
mcpContext: McpContext;
}
>();

Expand Down Expand Up @@ -555,7 +556,9 @@ export function setupMCPRoutes(
baseServiceParams,
};

const mcpSessionId = coerceString(req.headers['mcp-session-id']);
const mcpSessionId =
coerceString(req.headers['mcp-session-id']) ||
coerceString(req.query.sessionId as string | undefined);

// ─────────────────────────────────────────────────────────────────────────────
// Stateful mode (streamable HTTP sessions): supports GET /mcp SSE + DELETE /mcp
Expand Down Expand Up @@ -593,6 +596,11 @@ export function setupMCPRoutes(
});
}

// Dynamically update the cached McpContext's sessionId for this specific request.
// The transport caches the McpContext from the initialize request, but API Key callers
// might change the x-agor-session-id header on subsequent requests.
existing.mcpContext.sessionId = sessionId;

await existing.transport.handleRequest(req, res, req.body);
return;
}
Expand All @@ -604,7 +612,7 @@ export function setupMCPRoutes(
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (newSessionId) => {
transports.set(newSessionId, { transport, server: mcpServer, userId });
transports.set(newSessionId, { transport, server: mcpServer, userId, mcpContext });
},
});

Expand All @@ -623,20 +631,14 @@ export function setupMCPRoutes(
// Stateless fallback mode: preserves legacy behavior for direct POST usage
// ─────────────────────────────────────────────────────────────────────────────
if (req.method === 'POST') {
const mcpServer = createMcpServer(mcpContext, toolSearchEnabled, servicesConfig);

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});

await mcpServer.connect(transport);
await transport.handleRequest(req, res, req.body);

res.on('close', () => {
transport.close().catch(() => {});
mcpServer.close().catch(() => {});
return res.status(400).json({
jsonrpc: '2.0',
id: (req.body as { id?: unknown })?.id,
error: {
code: -32000,
message: 'Bad Request: Invalid or missing MCP session ID',
},
});
return;
}

return res.status(405).json({
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function registerBoardTools(server: McpServer, ctx: McpContext): void {
},
async (args) => {
const query: Record<string, unknown> = {};
query.$limit = args.limit ?? 50;
query.$limit = args.limit || 50;
if (args.archived === true) {
query.archived = true;
} else if (!args.includeArchived) {
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function registerRepoTools(server: McpServer, ctx: McpContext): void {
}),
},
async (args) => {
const query: Record<string, unknown> = { $limit: args.limit ?? 50 };
const query: Record<string, unknown> = { $limit: args.limit || 50 };
if (args.slug) query.slug = args.slug;
const repos = await ctx.app.service('repos').find({ query, ...ctx.baseServiceParams });
return textResult(repos);
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void {
const query: Record<string, unknown> = {};
// When sessionType is set, skip service-level pagination (it runs before our filter)
// and apply the requested limit ourselves after filtering.
const requestedLimit = args.limit ?? 50;
const requestedLimit = args.limit || 50;
if (!args.sessionType) query.$limit = requestedLimit;
if (args.status) query.status = args.status;
if (args.boardId) query.board_id = await resolveBoardId(ctx, args.boardId);
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function registerTaskTools(server: McpServer, ctx: McpContext): void {
}),
},
async (args) => {
const query: Record<string, unknown> = { $limit: args.limit ?? 50 };
const query: Record<string, unknown> = { $limit: args.limit || 50 };
if (args.sessionId) {
// Explicit sessionId = caller opted in, even if archived
query.session_id = await resolveSessionId(ctx, args.sessionId);
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function registerUserTools(server: McpServer, ctx: McpContext): void {
}),
},
async (args) => {
const query: Record<string, unknown> = { $limit: args.limit ?? 50 };
const query: Record<string, unknown> = { $limit: args.limit || 50 };
const users = await ctx.app.service('users').find({ query, ...ctx.baseServiceParams });
return textResult(users);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/agor-daemon/src/mcp/tools/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function registerWorktreeTools(server: McpServer, ctx: McpContext): void
async (args) => {
const query: Record<string, unknown> = {};
if (args.repoId) query.repo_id = await resolveRepoId(ctx, args.repoId);
query.$limit = args.limit ?? 50;
query.$limit = args.limit || 50;
if (args.archived === true) {
query.archived = true;
} else if (!args.includeArchived) {
Expand Down
37 changes: 37 additions & 0 deletions mcp_server_investigation_findings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Agor MCP Server Reliability Investigation Findings

## Executive Summary
This document summarizes the investigation and fixes applied to the Agor MCP (Model Context Protocol) server. The primary goals were to resolve intermittent failures, requests that hang indefinitely, and missing/empty data results returned by standard tools like `agor_boards_list`.

Through code exploration of the `McpServer` setup in Feathers and Drizzle ORM pagination, three distinct root causes were identified and fixed.

## 1. Tools Hanging or Timing Out (Stateless Fallback Issue)
### Problem
Agor's `POST /mcp` handler included a "Stateless fallback mode" block. This code assumed that if an incoming `POST` request did not have a transport `mcpSessionId` assigned to it (and was not an `initialize` request), it should spin up a brand new `McpServer` and pass the request directly via `StreamableHTTPServerTransport.handleRequest()`.

However, the official `@modelcontextprotocol/sdk` strictly requires a server to receive an `initialize` request before processing normal messages (such as `tools/call`). When the server receives a `tools/call` message without being initialized first, it simply drops the message and returns nothing. Because the HTTP handler was waiting for a response that would never arrive, the `POST` request hung indefinitely, eventually resulting in a client-side timeout.

### Fix
Replaced the broken stateless fallback block with an explicit `HTTP 400` returning a proper MCP JSON-RPC `-32000` error code. This enforces that clients must properly initialize a stateful transport session, enabling them to fail fast rather than hang when improperly configured.

## 2. Empty Results from Tools (Pagination Extraction Bug)
### Problem
Several list-based MCP tools (such as `agor_boards_list`, `agor_repos_list`, etc.) used nullish coalescing to set their default limits, e.g., `args.limit ?? 50`. Concurrently, the core `DrizzleService` handles pagination by explicitly relying on `query.$limit`.

When external agents passed `limit: 0` inside an MCP tool call in an attempt to request unlimited results, the value `0` bypassed the nullish coalescing operator. This strictly set `query.$limit = 0`. The database adapter would then slice the results as `data.slice(0, 0)`, returning an empty array even though database records existed.

### Fix
Updated the parameter parsing across all MCP tools to use strict undefined checks alongside an explicitly evaluated fallback `args.limit || 50`. This safely normalizes an explicit `0` to the intended default limit, preventing the empty array issue from occurring.

## 3. Intermittent Failures and State Issues (Stale Context)
### Problem
When the `McpServer` transport connection is initialized, it caches an `McpContext` object closure that contains the authenticated Agor `sessionId`. For external orchestrator clients using API keys and making subsequent stateful requests over the same transport, they typically pass their updated context via HTTP headers (`x-agor-session-id`) or query strings (`?sessionId=`).

Because the SDK transport routes subsequent tool calls to the original instantiated `McpServer`, those tools were evaluating against the original, stale cached `McpContext`.

### Fix
1. Modified the MCP session ID identification to check `req.query.sessionId` as a fallback when the `mcp-session-id` header is missing.
2. Wrapped the SDK's `transport.handleRequest` hook to dynamically intercept subsequent HTTP requests over the same stateful connection, manually injecting the latest parsed Agor `sessionId` into the cached `existing.mcpContext` object before dispatching the request to the inner server tools.

## Conclusion
These targeted fixes align the Agor daemon safely with the official MCP protocol semantics and resolve the major integration roadblocks for external orchestrating agents.