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
85 changes: 85 additions & 0 deletions lib/mcp/tools/__tests__/registerGetApiKeyTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js";

import { registerGetApiKeyTool } from "../registerGetApiKeyTool";

type ServerRequestHandlerExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;

type ToolResult = {
content: { type: string; text: string }[];
};

/**
* Creates a mock extra object with optional bearer token in authInfo.
*
* @param token - The Bearer token to embed in authInfo, or undefined to simulate no auth.
*/
function createMockExtra(token?: string): ServerRequestHandlerExtra {
return {
authInfo: token
? {
token,
scopes: ["mcp:tools"],
clientId: "test-account",
extra: { accountId: "test-account" },
}
: undefined,
} as unknown as ServerRequestHandlerExtra;
}

describe("registerGetApiKeyTool", () => {
let mockServer: McpServer;
let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise<ToolResult>;

beforeEach(() => {
vi.clearAllMocks();

mockServer = {
registerTool: vi.fn((_name, _config, handler) => {
registeredHandler = handler as typeof registeredHandler;
}),
} as unknown as McpServer;

registerGetApiKeyTool(mockServer);
});

it("registers the get_api_key tool", () => {
expect(mockServer.registerTool).toHaveBeenCalledWith(
"get_api_key",
expect.objectContaining({
description: expect.stringContaining("Recoup API key"),
}),
expect.any(Function),
);
});

it("returns the bearer token as the api_key when authenticated", async () => {
const result = await registeredHandler({}, createMockExtra("recoup_sk_test_value"));
const payload = JSON.parse(result.content[0].text);
expect(payload).toEqual({ api_key: "recoup_sk_test_value" });
});

it("returns an error when authInfo is missing", async () => {
const result = await registeredHandler({}, createMockExtra(undefined));
const payload = JSON.parse(result.content[0].text);
expect(payload.success).toBe(false);
expect(payload.message).toContain("No authentication credential available");
});

it("returns an error when authInfo exists but token is empty", async () => {
const extra = {
authInfo: {
token: "",
scopes: ["mcp:tools"],
clientId: "test-account",
extra: { accountId: "test-account" },
},
} as unknown as ServerRequestHandlerExtra;

const result = await registeredHandler({}, extra);
const payload = JSON.parse(result.content[0].text);
expect(payload.success).toBe(false);
});
});
2 changes: 2 additions & 0 deletions lib/mcp/tools/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerGetApiKeyTool } from "./registerGetApiKeyTool";
import { registerGetLocalTimeTool } from "./registerGetLocalTimeTool";
import { registerAllTaskTools } from "./tasks";
import { registerAllImageTools } from "./images";
Expand Down Expand Up @@ -46,6 +47,7 @@ export const registerAllTools = (server: McpServer): void => {
registerAllTaskTools(server);
registerTranscribeTools(server);
registerContactTeamTool(server);
registerGetApiKeyTool(server);
registerGetLocalTimeTool(server);
registerWebDeepResearchTool(server);
registerArtistDeepResearchTool(server);
Expand Down
41 changes: 41 additions & 0 deletions lib/mcp/tools/registerGetApiKeyTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey";
import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess";
import { getToolResultError } from "@/lib/mcp/getToolResultError";

/**
* Registers the "get_api_key" tool on the MCP server.
*
* Returns the Recoup API key the caller authenticated this MCP connection
* with so the LLM can use it for direct HTTP requests to api.recoupable.com
* (via the x-api-key header). The MCP Bearer header is opaque to the LLM by
* design, so without this tool, skills that curl /api/* endpoints have no
* credential to send.
*
* @param server - The MCP server instance to register the tool on.
*/
export function registerGetApiKeyTool(server: McpServer): void {
server.registerTool(
"get_api_key",
{
description:
"Return the Recoup API key for this session so the LLM can use it for direct HTTP calls to api.recoupable.com (x-api-key header, or Authorization: Bearer). Call this once when invoking any skill that makes raw HTTPS requests to the Recoup REST API — for example the recoup-api skill. The returned value is the same credential the customer used to authenticate this MCP connection. Endpoint reference: https://developers.recoupable.com (and https://developers.recoupable.com/llms.txt for the LLM-readable index).",
inputSchema: z.object({}),
},
async (_args, extra: RequestHandlerExtra<ServerRequest, ServerNotification>) => {
const authInfo = extra.authInfo as McpAuthInfo | undefined;
const token = authInfo?.token;

if (!token) {
return getToolResultError(
"No authentication credential available. The MCP server must be authenticated with a Recoup API key via the Authorization: Bearer header.",
);
}

return getToolResultSuccess({ api_key: token });
},
);
}
Loading