Skip to content

Commit 99ed986

Browse files
sweetmantechclaude
andauthored
refactor: Use HTTP transport for MCP to properly pass authInfo (#139)
* refactor: Use HTTP transport for MCP to properly pass authInfo Replace InMemoryTransport with StreamableHTTPClientTransport to make HTTP requests to /api/mcp endpoint with forwarded auth token. This ensures MCP tool handlers receive proper authInfo through the established withMcpAuth middleware flow. - Add authToken to ChatRequestBody type - Extract auth token from request headers in validateChatRequest - Use HTTP transport to /api/mcp with Authorization header - Update tests to include authToken in request bodies Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Make authToken optional for internal flows Internal flows like email processing don't have an auth token from HTTP headers. Make authToken optional and skip MCP tools when not present. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Extract getBaseUrl to lib/networking Move getBaseUrl utility to its own file following SRP. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Extract getMcpTools to lib/mcp Move MCP tools fetching logic to its own file following SRP. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: Add unit tests for getBaseUrl and getMcpTools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: Use AI SDK built-in SSE transport for MCP Replace StreamableHTTPClientTransport with AI SDK's built-in transport config which provides a simpler API: transport: { type: "sse", url: "...", headers: { Authorization: "..." } } This should fix the "Streamable HTTP error" issue. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Correct MCP endpoint URL to /mcp The MCP route is at app/mcp/route.ts, not app/api/mcp/route.ts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Use HTTP transport instead of SSE for MCP The mcp-handler server only supports HTTP transport, not SSE. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3b9d4b7 commit 99ed986

File tree

7 files changed

+171
-73
lines changed

7 files changed

+171
-73
lines changed

lib/chat/__tests__/setupToolsForRequest.test.ts

Lines changed: 24 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
22
import { ChatRequestBody } from "../validateChatRequest";
33

44
// Mock external dependencies
5-
vi.mock("@ai-sdk/mcp", () => ({
6-
experimental_createMCPClient: vi.fn(),
7-
}));
8-
9-
vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
10-
StreamableHTTPClientTransport: vi.fn().mockImplementation(() => ({})),
11-
}));
12-
13-
vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
14-
McpServer: vi.fn().mockImplementation(() => ({
15-
connect: vi.fn(),
16-
})),
17-
}));
18-
19-
vi.mock("@modelcontextprotocol/sdk/inMemory.js", () => ({
20-
InMemoryTransport: {
21-
createLinkedPair: vi.fn().mockReturnValue([{}, {}]),
22-
},
23-
}));
24-
25-
vi.mock("@/lib/mcp/tools", () => ({
26-
registerAllTools: vi.fn(),
5+
vi.mock("@/lib/mcp/getMcpTools", () => ({
6+
getMcpTools: vi.fn(),
277
}));
288

299
vi.mock("@/lib/agents/googleSheetsAgent", () => ({
@@ -32,10 +12,10 @@ vi.mock("@/lib/agents/googleSheetsAgent", () => ({
3212

3313
// Import after mocks
3414
import { setupToolsForRequest } from "../setupToolsForRequest";
35-
import { experimental_createMCPClient } from "@ai-sdk/mcp";
15+
import { getMcpTools } from "@/lib/mcp/getMcpTools";
3616
import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent";
3717

38-
const mockCreateMCPClient = vi.mocked(experimental_createMCPClient);
18+
const mockGetMcpTools = vi.mocked(getMcpTools);
3919
const mockGetGoogleSheetsTools = vi.mocked(getGoogleSheetsTools);
4020

4121
describe("setupToolsForRequest", () => {
@@ -56,32 +36,32 @@ describe("setupToolsForRequest", () => {
5636
beforeEach(() => {
5737
vi.clearAllMocks();
5838

59-
// Default mock for MCP client
60-
mockCreateMCPClient.mockResolvedValue({
61-
tools: vi.fn().mockResolvedValue(mockMcpTools),
62-
} as any);
39+
// Default mock for MCP tools
40+
mockGetMcpTools.mockResolvedValue(mockMcpTools);
6341

6442
// Default mock for Google Sheets tools - returns login tool (not authenticated)
6543
mockGetGoogleSheetsTools.mockResolvedValue(mockGoogleSheetsLoginTool);
6644
});
6745

6846
describe("MCP tools integration", () => {
69-
it("creates MCP client with correct URL", async () => {
47+
it("calls getMcpTools with authToken", async () => {
7048
const body: ChatRequestBody = {
7149
accountId: "account-123",
7250
orgId: null,
51+
authToken: "test-token-123",
7352
messages: [{ id: "1", role: "user", content: "Hello" }],
7453
};
7554

7655
await setupToolsForRequest(body);
7756

78-
expect(mockCreateMCPClient).toHaveBeenCalled();
57+
expect(mockGetMcpTools).toHaveBeenCalledWith("test-token-123");
7958
});
8059

8160
it("fetches tools from MCP client", async () => {
8261
const body: ChatRequestBody = {
8362
accountId: "account-123",
8463
orgId: null,
64+
authToken: "test-token-123",
8565
messages: [{ id: "1", role: "user", content: "Hello" }],
8666
};
8767

@@ -91,7 +71,7 @@ describe("setupToolsForRequest", () => {
9171
expect(result).toHaveProperty("tool2");
9272
});
9373

94-
it("passes accountId to MCP client via authenticated transport", async () => {
74+
it("skips MCP tools when authToken is not provided", async () => {
9575
const body: ChatRequestBody = {
9676
accountId: "account-123",
9777
orgId: null,
@@ -100,25 +80,7 @@ describe("setupToolsForRequest", () => {
10080

10181
await setupToolsForRequest(body);
10282

103-
// Verify MCP client was created with a transport that includes auth info
104-
expect(mockCreateMCPClient).toHaveBeenCalledWith(
105-
expect.objectContaining({
106-
transport: expect.any(Object),
107-
}),
108-
);
109-
});
110-
111-
it("passes orgId to MCP client via authenticated transport", async () => {
112-
const body: ChatRequestBody = {
113-
accountId: "account-123",
114-
orgId: "org-456",
115-
messages: [{ id: "1", role: "user", content: "Hello" }],
116-
};
117-
118-
await setupToolsForRequest(body);
119-
120-
// Verify MCP client was created
121-
expect(mockCreateMCPClient).toHaveBeenCalled();
83+
expect(mockGetMcpTools).not.toHaveBeenCalled();
12284
});
12385
});
12486

@@ -127,6 +89,7 @@ describe("setupToolsForRequest", () => {
12789
const body: ChatRequestBody = {
12890
accountId: "account-123",
12991
orgId: null,
92+
authToken: "test-token-123",
13093
messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }],
13194
};
13295

@@ -141,6 +104,7 @@ describe("setupToolsForRequest", () => {
141104
const body: ChatRequestBody = {
142105
accountId: "account-123",
143106
orgId: null,
107+
authToken: "test-token-123",
144108
messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }],
145109
};
146110

@@ -156,6 +120,7 @@ describe("setupToolsForRequest", () => {
156120
const body: ChatRequestBody = {
157121
accountId: "account-123",
158122
orgId: null,
123+
authToken: "test-token-123",
159124
messages: [{ id: "1", role: "user", content: "Create a spreadsheet" }],
160125
};
161126

@@ -172,6 +137,7 @@ describe("setupToolsForRequest", () => {
172137
const body: ChatRequestBody = {
173138
accountId: "account-123",
174139
orgId: null,
140+
authToken: "test-token-123",
175141
messages: [{ id: "1", role: "user", content: "Hello" }],
176142
};
177143

@@ -185,11 +151,9 @@ describe("setupToolsForRequest", () => {
185151
});
186152

187153
it("Google Sheets tools take precedence over MCP tools with same name", async () => {
188-
mockCreateMCPClient.mockResolvedValue({
189-
tools: vi.fn().mockResolvedValue({
190-
googlesheets_create: { description: "MCP version", parameters: {} },
191-
}),
192-
} as any);
154+
mockGetMcpTools.mockResolvedValue({
155+
googlesheets_create: { description: "MCP version", parameters: {} },
156+
});
193157

194158
mockGetGoogleSheetsTools.mockResolvedValue({
195159
googlesheets_create: { description: "Composio version", parameters: {} },
@@ -198,6 +162,7 @@ describe("setupToolsForRequest", () => {
198162
const body: ChatRequestBody = {
199163
accountId: "account-123",
200164
orgId: null,
165+
authToken: "test-token-123",
201166
messages: [{ id: "1", role: "user", content: "Hello" }],
202167
};
203168

@@ -215,6 +180,7 @@ describe("setupToolsForRequest", () => {
215180
const body: ChatRequestBody = {
216181
accountId: "account-123",
217182
orgId: null,
183+
authToken: "test-token-123",
218184
messages: [{ id: "1", role: "user", content: "Hello" }],
219185
excludeTools: ["tool1"],
220186
};
@@ -231,6 +197,7 @@ describe("setupToolsForRequest", () => {
231197
const body: ChatRequestBody = {
232198
accountId: "account-123",
233199
orgId: null,
200+
authToken: "test-token-123",
234201
messages: [{ id: "1", role: "user", content: "Hello" }],
235202
excludeTools: ["tool1", "googlesheets_create"],
236203
};
@@ -247,6 +214,7 @@ describe("setupToolsForRequest", () => {
247214
const body: ChatRequestBody = {
248215
accountId: "account-123",
249216
orgId: null,
217+
authToken: "test-token-123",
250218
messages: [{ id: "1", role: "user", content: "Hello" }],
251219
};
252220

@@ -260,6 +228,7 @@ describe("setupToolsForRequest", () => {
260228
const body: ChatRequestBody = {
261229
accountId: "account-123",
262230
orgId: null,
231+
authToken: "test-token-123",
263232
messages: [{ id: "1", role: "user", content: "Hello" }],
264233
excludeTools: [],
265234
};

lib/chat/setupToolsForRequest.ts

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,23 @@
11
import { ToolSet } from "ai";
22
import { filterExcludedTools } from "./filterExcludedTools";
33
import { ChatRequestBody } from "./validateChatRequest";
4-
import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp";
5-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7-
import { registerAllTools } from "@/lib/mcp/tools";
84
import { getGoogleSheetsTools } from "@/lib/agents/googleSheetsAgent";
5+
import { getMcpTools } from "@/lib/mcp/getMcpTools";
96

107
/**
118
* Sets up and filters tools for a chat request.
129
* Aggregates tools from:
13-
* - MCP server (in-process via in-memory transport, no HTTP overhead)
10+
* - MCP server (via HTTP transport to /api/mcp for proper auth)
1411
* - Google Sheets (via Composio integration)
1512
*
1613
* @param body - The chat request body
1714
* @returns Filtered tool set ready for use
1815
*/
1916
export async function setupToolsForRequest(body: ChatRequestBody): Promise<ToolSet> {
20-
const { excludeTools } = body;
17+
const { excludeTools, authToken } = body;
2118

22-
// Create in-memory MCP server and client (no HTTP call needed)
23-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
24-
25-
const server = new McpServer({
26-
name: "recoup-mcp",
27-
version: "0.0.1",
28-
});
29-
registerAllTools(server);
30-
await server.connect(serverTransport);
31-
32-
const mcpClient = await createMCPClient({ transport: clientTransport });
33-
const mcpClientTools = (await mcpClient.tools()) as ToolSet;
19+
// Only fetch MCP tools if we have an auth token
20+
const mcpClientTools = authToken ? await getMcpTools(authToken) : {};
3421

3522
// Fetch Google Sheets tools (authenticated tools or login tool)
3623
const googleSheetsTools = await getGoogleSheetsTools(body);

lib/chat/validateChatRequest.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type BaseChatRequestBody = z.infer<typeof chatRequestSchema>;
4848
export type ChatRequestBody = BaseChatRequestBody & {
4949
accountId: string;
5050
orgId: string | null;
51+
authToken?: string;
5152
};
5253

5354
/**
@@ -192,10 +193,14 @@ export async function validateChatRequest(
192193
memoryId: lastMessage.id,
193194
});
194195

196+
// Extract the auth token to forward to MCP server
197+
const authToken = hasApiKey ? apiKey! : authHeader!.replace(/^Bearer\s+/i, "");
198+
195199
return {
196200
...validatedBody,
197201
accountId,
198202
orgId,
199203
roomId: finalRoomId,
204+
authToken,
200205
} as ChatRequestBody;
201206
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
vi.mock("@ai-sdk/mcp", () => ({
4+
experimental_createMCPClient: vi.fn(),
5+
}));
6+
7+
vi.mock("@/lib/networking/getBaseUrl", () => ({
8+
getBaseUrl: vi.fn().mockReturnValue("https://test.vercel.app"),
9+
}));
10+
11+
import { getMcpTools } from "../getMcpTools";
12+
import { experimental_createMCPClient } from "@ai-sdk/mcp";
13+
14+
const mockCreateMCPClient = vi.mocked(experimental_createMCPClient);
15+
16+
describe("getMcpTools", () => {
17+
const mockTools = {
18+
tool1: { description: "Tool 1", parameters: {} },
19+
tool2: { description: "Tool 2", parameters: {} },
20+
};
21+
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
25+
mockCreateMCPClient.mockResolvedValue({
26+
tools: vi.fn().mockResolvedValue(mockTools),
27+
} as any);
28+
});
29+
30+
it("creates MCP client with HTTP transport config", async () => {
31+
await getMcpTools("test-token");
32+
33+
expect(mockCreateMCPClient).toHaveBeenCalledWith({
34+
transport: {
35+
type: "http",
36+
url: "https://test.vercel.app/mcp",
37+
headers: {
38+
Authorization: "Bearer test-token",
39+
},
40+
},
41+
});
42+
});
43+
44+
it("returns tools from MCP client", async () => {
45+
const result = await getMcpTools("test-token");
46+
47+
expect(result).toEqual(mockTools);
48+
});
49+
50+
it("passes different auth tokens correctly", async () => {
51+
await getMcpTools("different-token");
52+
53+
expect(mockCreateMCPClient).toHaveBeenCalledWith({
54+
transport: {
55+
type: "http",
56+
url: "https://test.vercel.app/mcp",
57+
headers: {
58+
Authorization: "Bearer different-token",
59+
},
60+
},
61+
});
62+
});
63+
});

lib/mcp/getMcpTools.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ToolSet } from "ai";
2+
import { experimental_createMCPClient as createMCPClient } from "@ai-sdk/mcp";
3+
import { getBaseUrl } from "@/lib/networking/getBaseUrl";
4+
5+
/**
6+
* Fetches MCP tools via HTTP transport with authentication.
7+
*
8+
* @param authToken - The auth token to use for MCP endpoint authentication
9+
* @returns The MCP tools as a ToolSet
10+
*/
11+
export async function getMcpTools(authToken: string): Promise<ToolSet> {
12+
const mcpClient = await createMCPClient({
13+
transport: {
14+
type: "http",
15+
url: `${getBaseUrl()}/mcp`,
16+
headers: {
17+
Authorization: `Bearer ${authToken}`,
18+
},
19+
},
20+
});
21+
22+
return (await mcpClient.tools()) as ToolSet;
23+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { getBaseUrl } from "../getBaseUrl";
3+
4+
describe("getBaseUrl", () => {
5+
const originalEnv = process.env;
6+
7+
beforeEach(() => {
8+
vi.resetModules();
9+
process.env = { ...originalEnv };
10+
});
11+
12+
afterEach(() => {
13+
process.env = originalEnv;
14+
});
15+
16+
it("returns HTTPS URL when VERCEL_URL is set", () => {
17+
process.env.VERCEL_URL = "my-app.vercel.app";
18+
19+
const result = getBaseUrl();
20+
21+
expect(result).toBe("https://my-app.vercel.app");
22+
});
23+
24+
it("returns localhost when VERCEL_URL is not set", () => {
25+
delete process.env.VERCEL_URL;
26+
27+
const result = getBaseUrl();
28+
29+
expect(result).toBe("http://localhost:3000");
30+
});
31+
32+
it("returns localhost when VERCEL_URL is empty string", () => {
33+
process.env.VERCEL_URL = "";
34+
35+
const result = getBaseUrl();
36+
37+
expect(result).toBe("http://localhost:3000");
38+
});
39+
});

0 commit comments

Comments
 (0)