From 568ff5a6ca4573617554e333eb0b1342e0366b67 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:15:45 -0500 Subject: [PATCH 1/8] feat: accept api_key for authentication in create_new_artist MCP tool - Add optional api_key parameter to resolve account_id automatically - Use getApiKeyDetails to validate API key and get accountId - Support account_id override for org API keys with access validation - Return clear error messages for invalid keys or access denied - Add comprehensive tests for new authentication flow Co-Authored-By: Claude Opus 4.5 --- .../registerCreateNewArtistTool.test.ts | 133 ++++++++++++++++++ .../artists/registerCreateNewArtistTool.ts | 46 +++++- 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts index 1d8ce004..dcd8480d 100644 --- a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -3,6 +3,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; const mockCreateArtistInDb = vi.fn(); const mockCopyRoom = vi.fn(); +const mockGetApiKeyDetails = vi.fn(); +const mockCanAccessAccount = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ createArtistInDb: (...args: unknown[]) => mockCreateArtistInDb(...args), @@ -12,6 +14,14 @@ vi.mock("@/lib/rooms/copyRoom", () => ({ copyRoom: (...args: unknown[]) => mockCopyRoom(...args), })); +vi.mock("@/lib/keys/getApiKeyDetails", () => ({ + getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), +})); + import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; describe("registerCreateNewArtistTool", () => { @@ -148,4 +158,127 @@ describe("registerCreateNewArtistTool", () => { ], }); }); + + it("resolves account_id from api_key", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "resolved-account-123", + orgId: null, + }); + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + const result = await registeredHandler({ + name: "Test Artist", + api_key: "valid-api-key", + }); + + expect(mockGetApiKeyDetails).toHaveBeenCalledWith("valid-api-key"); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "resolved-account-123", + undefined, + ); + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Successfully created artist"), + }, + ], + }); + }); + + it("returns error for invalid api_key", async () => { + mockGetApiKeyDetails.mockResolvedValue(null); + + const result = await registeredHandler({ + name: "Test Artist", + api_key: "invalid-api-key", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Invalid API key"), + }, + ], + }); + }); + + it("allows account_id override for org API keys with access", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(true); + const mockArtist = { + id: "artist-123", + account_id: "artist-123", + name: "Test Artist", + account_info: [{ image: null }], + account_socials: [], + }; + mockCreateArtistInDb.mockResolvedValue(mockArtist); + + await registeredHandler({ + name: "Test Artist", + api_key: "org-api-key", + account_id: "target-account-456", + }); + + expect(mockCanAccessAccount).toHaveBeenCalledWith({ + orgId: "org-account-id", + targetAccountId: "target-account-456", + }); + expect(mockCreateArtistInDb).toHaveBeenCalledWith( + "Test Artist", + "target-account-456", + undefined, + ); + }); + + it("returns error when org API key lacks access to account_id", async () => { + mockGetApiKeyDetails.mockResolvedValue({ + accountId: "org-account-id", + orgId: "org-account-id", + }); + mockCanAccessAccount.mockResolvedValue(false); + + const result = await registeredHandler({ + name: "Test Artist", + api_key: "org-api-key", + account_id: "target-account-456", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Access denied to specified account_id"), + }, + ], + }); + }); + + it("returns error when neither api_key nor account_id is provided", async () => { + const result = await registeredHandler({ + name: "Test Artist", + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: expect.stringContaining("Either api_key or account_id is required"), + }, + ], + }); + }); }); diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index ea11c6ed..564f6b9c 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -7,14 +7,23 @@ import { import { copyRoom } from "@/lib/rooms/copyRoom"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; const createNewArtistSchema = z.object({ name: z.string().describe("The name of the artist to be created"), + api_key: z + .string() + .optional() + .describe( + "The API key to authenticate the request. Use this to automatically resolve the account_id.", + ), account_id: z .string() .optional() .describe( - "The account ID to create the artist for. Only required for organization API keys creating artists on behalf of other accounts.", + "The account ID to create the artist for. Only required for organization API keys creating artists on behalf of other accounts. " + + "If api_key is provided, this can be omitted and will be resolved from the API key.", ), active_conversation_id: z .string() @@ -57,6 +66,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { { description: "Create a new artist account in the system. " + + "Requires either api_key (to authenticate and resolve account) or account_id from the system prompt. " + "The account_id parameter is optional — only provide it when using an organization API key to create artists on behalf of other accounts. " + "The active_conversation_id parameter is optional — when omitted, use the active_conversation_id from the system prompt " + "to copy the conversation. Never ask the user to provide a room ID. " + @@ -65,18 +75,44 @@ export function registerCreateNewArtistTool(server: McpServer): void { }, async (args: CreateNewArtistArgs) => { try { - const { name, account_id, active_conversation_id, organization_id } = args; + const { name, api_key, account_id, active_conversation_id, organization_id } = args; + + // Resolve accountId from api_key or use provided account_id + let resolvedAccountId: string | null = null; + let keyDetails: { accountId: string; orgId: string | null } | null = null; + + if (api_key) { + keyDetails = await getApiKeyDetails(api_key); + if (!keyDetails) { + return getToolResultError("Invalid API key"); + } + resolvedAccountId = keyDetails.accountId; + + // If account_id override is provided, validate access (for org API keys) + if (account_id && account_id !== keyDetails.accountId) { + const hasAccess = await canAccessAccount({ + orgId: keyDetails.orgId, + targetAccountId: account_id, + }); + if (!hasAccess) { + return getToolResultError("Access denied to specified account_id"); + } + resolvedAccountId = account_id; + } + } else if (account_id) { + resolvedAccountId = account_id; + } - if (!account_id) { + if (!resolvedAccountId) { return getToolResultError( - "account_id is required. Provide it from the system prompt context.", + "Either api_key or account_id is required. Provide api_key or account_id from the system prompt context.", ); } // Create the artist account (with optional org linking) const artist = await createArtistInDb( name, - account_id, + resolvedAccountId, organization_id ?? undefined, ); From c56a52c948237cd51c83a0967b17d00bd3a859c2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:31:43 -0500 Subject: [PATCH 2/8] refactor: move API key authentication to MCP server level - Implement withMcpAuth wrapper for MCP server-level authentication - Remove api_key parameter from create_new_artist tool schema - Tool now receives accountId via extra.authInfo from the auth layer - MCP server accepts API key via Authorization: Bearer or x-api-key header - Update tests to use new auth flow with mock authInfo Co-Authored-By: Claude Opus 4.5 --- app/mcp/route.ts | 67 ++++-- .../registerCreateNewArtistTool.test.ts | 216 +++++++++--------- .../artists/registerCreateNewArtistTool.ts | 41 ++-- 3 files changed, 178 insertions(+), 146 deletions(-) diff --git a/app/mcp/route.ts b/app/mcp/route.ts index 465a06b9..5e36ebc2 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -1,30 +1,57 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { registerAllTools } from "@/lib/mcp/tools"; -import { createMcpHandler } from "mcp-handler"; - -let handler: ReturnType | null = null; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; /** - * Gets the MCP handler for the API. + * Verifies an API key and returns auth info with account details. * - * @returns The MCP handler. + * @param req - The request object. + * @param bearerToken - The bearer token from the Authorization header. + * @returns AuthInfo with accountId and orgId, or undefined if invalid. */ -async function getHandler(): Promise> { - if (!handler) { - handler = createMcpHandler( - server => { - registerAllTools(server); - }, - { - serverInfo: { - name: "recoup-mcp", - version: "0.0.1", - }, - }, - ); +async function verifyApiKey(req: Request, bearerToken?: string): Promise { + // Try Authorization header first, then x-api-key header + const apiKey = bearerToken || req.headers.get("x-api-key"); + + if (!apiKey) { + return undefined; } - return handler; + + const keyDetails = await getApiKeyDetails(apiKey); + + if (!keyDetails) { + return undefined; + } + + return { + token: apiKey, + scopes: ["mcp:tools"], + clientId: keyDetails.accountId, + extra: { + accountId: keyDetails.accountId, + orgId: keyDetails.orgId, + }, + }; } +const baseHandler = createMcpHandler( + server => { + registerAllTools(server); + }, + { + serverInfo: { + name: "recoup-mcp", + version: "0.0.1", + }, + }, +); + +// Wrap with auth - auth is optional (tools can work without it) +const handler = withMcpAuth(baseHandler, verifyApiKey, { + required: false, +}); + /** * GET handler for the MCP API. * @@ -32,7 +59,6 @@ async function getHandler(): Promise> { * @returns The response from the MCP handler. */ export async function GET(req: Request) { - const handler = await getHandler(); return handler(req); } @@ -43,6 +69,5 @@ export async function GET(req: Request) { * @returns The response from the MCP handler. */ export async function POST(req: Request) { - const handler = await getHandler(); return handler(req); } diff --git a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts index dcd8480d..42056f75 100644 --- a/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts +++ b/lib/mcp/tools/artists/__tests__/registerCreateNewArtistTool.test.ts @@ -1,9 +1,10 @@ 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"; const mockCreateArtistInDb = vi.fn(); const mockCopyRoom = vi.fn(); -const mockGetApiKeyDetails = vi.fn(); const mockCanAccessAccount = vi.fn(); vi.mock("@/lib/artists/createArtistInDb", () => ({ @@ -14,19 +15,39 @@ vi.mock("@/lib/rooms/copyRoom", () => ({ copyRoom: (...args: unknown[]) => mockCopyRoom(...args), })); -vi.mock("@/lib/keys/getApiKeyDetails", () => ({ - getApiKeyDetails: (...args: unknown[]) => mockGetApiKeyDetails(...args), -})); - vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), })); import { registerCreateNewArtistTool } from "../registerCreateNewArtistTool"; +type ServerRequestHandlerExtra = RequestHandlerExtra; + +/** + * Creates a mock extra object with optional authInfo. + */ +function createMockExtra(authInfo?: { + accountId?: string; + orgId?: string | null; +}): ServerRequestHandlerExtra { + return { + authInfo: authInfo + ? { + token: "test-token", + scopes: ["mcp:tools"], + clientId: authInfo.accountId, + extra: { + accountId: authInfo.accountId, + orgId: authInfo.orgId ?? null, + }, + } + : undefined, + } as unknown as ServerRequestHandlerExtra; +} + describe("registerCreateNewArtistTool", () => { let mockServer: McpServer; - let registeredHandler: (args: unknown) => Promise; + let registeredHandler: (args: unknown, extra: ServerRequestHandlerExtra) => Promise; beforeEach(() => { vi.clearAllMocks(); @@ -50,7 +71,7 @@ describe("registerCreateNewArtistTool", () => { ); }); - it("creates an artist and returns success", async () => { + it("creates an artist and returns success with account_id", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -60,10 +81,13 @@ describe("registerCreateNewArtistTool", () => { }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - const result = await registeredHandler({ - name: "Test Artist", - account_id: "owner-456", - }); + const result = await registeredHandler( + { + name: "Test Artist", + account_id: "owner-456", + }, + createMockExtra(), + ); expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", undefined); expect(result).toEqual({ @@ -76,7 +100,7 @@ describe("registerCreateNewArtistTool", () => { }); }); - it("copies room when active_conversation_id is provided", async () => { + it("creates an artist using auth info accountId", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -85,26 +109,26 @@ describe("registerCreateNewArtistTool", () => { account_socials: [], }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - mockCopyRoom.mockResolvedValue("new-room-789"); - const result = await registeredHandler({ - name: "Test Artist", - account_id: "owner-456", - active_conversation_id: "source-room-111", - }); + const result = await registeredHandler( + { + name: "Test Artist", + }, + createMockExtra({ accountId: "auth-account-123" }), + ); - expect(mockCopyRoom).toHaveBeenCalledWith("source-room-111", "artist-123"); + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "auth-account-123", undefined); expect(result).toEqual({ content: [ { type: "text", - text: expect.stringContaining("new-room-789"), + text: expect.stringContaining("Successfully created artist"), }, ], }); }); - it("passes organization_id to createArtistInDb", async () => { + it("copies room when active_conversation_id is provided", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -113,57 +137,29 @@ describe("registerCreateNewArtistTool", () => { account_socials: [], }; mockCreateArtistInDb.mockResolvedValue(mockArtist); + mockCopyRoom.mockResolvedValue("new-room-789"); - await registeredHandler({ - name: "Test Artist", - account_id: "owner-456", - organization_id: "org-999", - }); - - expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", "org-999"); - }); - - it("returns error when artist creation fails", async () => { - mockCreateArtistInDb.mockResolvedValue(null); - - const result = await registeredHandler({ - name: "Test Artist", - account_id: "owner-456", - }); - - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining("Failed to create artist"), - }, - ], - }); - }); - - it("returns error with message when exception is thrown", async () => { - mockCreateArtistInDb.mockRejectedValue(new Error("Database connection failed")); - - const result = await registeredHandler({ - name: "Test Artist", - account_id: "owner-456", - }); + const result = await registeredHandler( + { + name: "Test Artist", + account_id: "owner-456", + active_conversation_id: "source-room-111", + }, + createMockExtra(), + ); + expect(mockCopyRoom).toHaveBeenCalledWith("source-room-111", "artist-123"); expect(result).toEqual({ content: [ { type: "text", - text: expect.stringContaining("Database connection failed"), + text: expect.stringContaining("new-room-789"), }, ], }); }); - it("resolves account_id from api_key", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "resolved-account-123", - orgId: null, - }); + it("passes organization_id to createArtistInDb", async () => { const mockArtist = { id: "artist-123", account_id: "artist-123", @@ -173,50 +169,61 @@ describe("registerCreateNewArtistTool", () => { }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - const result = await registeredHandler({ - name: "Test Artist", - api_key: "valid-api-key", - }); + await registeredHandler( + { + name: "Test Artist", + account_id: "owner-456", + organization_id: "org-999", + }, + createMockExtra(), + ); - expect(mockGetApiKeyDetails).toHaveBeenCalledWith("valid-api-key"); - expect(mockCreateArtistInDb).toHaveBeenCalledWith( - "Test Artist", - "resolved-account-123", - undefined, + expect(mockCreateArtistInDb).toHaveBeenCalledWith("Test Artist", "owner-456", "org-999"); + }); + + it("returns error when artist creation fails", async () => { + mockCreateArtistInDb.mockResolvedValue(null); + + const result = await registeredHandler( + { + name: "Test Artist", + account_id: "owner-456", + }, + createMockExtra(), ); + expect(result).toEqual({ content: [ { type: "text", - text: expect.stringContaining("Successfully created artist"), + text: expect.stringContaining("Failed to create artist"), }, ], }); }); - it("returns error for invalid api_key", async () => { - mockGetApiKeyDetails.mockResolvedValue(null); + it("returns error with message when exception is thrown", async () => { + mockCreateArtistInDb.mockRejectedValue(new Error("Database connection failed")); - const result = await registeredHandler({ - name: "Test Artist", - api_key: "invalid-api-key", - }); + const result = await registeredHandler( + { + name: "Test Artist", + account_id: "owner-456", + }, + createMockExtra(), + ); expect(result).toEqual({ content: [ { type: "text", - text: expect.stringContaining("Invalid API key"), + text: expect.stringContaining("Database connection failed"), }, ], }); }); - it("allows account_id override for org API keys with access", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", - }); + it("allows account_id override for org auth with access", async () => { mockCanAccessAccount.mockResolvedValue(true); const mockArtist = { id: "artist-123", @@ -227,11 +234,13 @@ describe("registerCreateNewArtistTool", () => { }; mockCreateArtistInDb.mockResolvedValue(mockArtist); - await registeredHandler({ - name: "Test Artist", - api_key: "org-api-key", - account_id: "target-account-456", - }); + await registeredHandler( + { + name: "Test Artist", + account_id: "target-account-456", + }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); expect(mockCanAccessAccount).toHaveBeenCalledWith({ orgId: "org-account-id", @@ -244,18 +253,16 @@ describe("registerCreateNewArtistTool", () => { ); }); - it("returns error when org API key lacks access to account_id", async () => { - mockGetApiKeyDetails.mockResolvedValue({ - accountId: "org-account-id", - orgId: "org-account-id", - }); + it("returns error when org auth lacks access to account_id", async () => { mockCanAccessAccount.mockResolvedValue(false); - const result = await registeredHandler({ - name: "Test Artist", - api_key: "org-api-key", - account_id: "target-account-456", - }); + const result = await registeredHandler( + { + name: "Test Artist", + account_id: "target-account-456", + }, + createMockExtra({ accountId: "org-account-id", orgId: "org-account-id" }), + ); expect(result).toEqual({ content: [ @@ -267,16 +274,19 @@ describe("registerCreateNewArtistTool", () => { }); }); - it("returns error when neither api_key nor account_id is provided", async () => { - const result = await registeredHandler({ - name: "Test Artist", - }); + it("returns error when neither auth nor account_id is provided", async () => { + const result = await registeredHandler( + { + name: "Test Artist", + }, + createMockExtra(), + ); expect(result).toEqual({ content: [ { type: "text", - text: expect.stringContaining("Either api_key or account_id is required"), + text: expect.stringContaining("Authentication required"), }, ], }); diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index 564f6b9c..11ea801b 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -1,4 +1,6 @@ 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 { z } from "zod"; import { createArtistInDb, @@ -7,23 +9,16 @@ import { import { copyRoom } from "@/lib/rooms/copyRoom"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; const createNewArtistSchema = z.object({ name: z.string().describe("The name of the artist to be created"), - api_key: z - .string() - .optional() - .describe( - "The API key to authenticate the request. Use this to automatically resolve the account_id.", - ), account_id: z .string() .optional() .describe( "The account ID to create the artist for. Only required for organization API keys creating artists on behalf of other accounts. " + - "If api_key is provided, this can be omitted and will be resolved from the API key.", + "If not provided, the account ID will be resolved from the authenticated API key.", ), active_conversation_id: z .string() @@ -66,32 +61,34 @@ export function registerCreateNewArtistTool(server: McpServer): void { { description: "Create a new artist account in the system. " + - "Requires either api_key (to authenticate and resolve account) or account_id from the system prompt. " + + "Requires authentication via API key (x-api-key header or Authorization: Bearer header). " + "The account_id parameter is optional — only provide it when using an organization API key to create artists on behalf of other accounts. " + "The active_conversation_id parameter is optional — when omitted, use the active_conversation_id from the system prompt " + "to copy the conversation. Never ask the user to provide a room ID. " + "The organization_id parameter is optional — use the organization_id from the system prompt context to link the artist to the user's selected organization.", inputSchema: createNewArtistSchema, }, - async (args: CreateNewArtistArgs) => { + async (args: CreateNewArtistArgs, extra: RequestHandlerExtra) => { try { - const { name, api_key, account_id, active_conversation_id, organization_id } = args; + const { name, account_id, active_conversation_id, organization_id } = args; + + // Get auth info from the MCP auth layer + const authInfo = extra.authInfo as + | { extra?: { accountId?: string; orgId?: string | null } } + | undefined; + const authAccountId = authInfo?.extra?.accountId; + const authOrgId = authInfo?.extra?.orgId; - // Resolve accountId from api_key or use provided account_id + // Resolve accountId from auth or use provided account_id let resolvedAccountId: string | null = null; - let keyDetails: { accountId: string; orgId: string | null } | null = null; - if (api_key) { - keyDetails = await getApiKeyDetails(api_key); - if (!keyDetails) { - return getToolResultError("Invalid API key"); - } - resolvedAccountId = keyDetails.accountId; + if (authAccountId) { + resolvedAccountId = authAccountId; // If account_id override is provided, validate access (for org API keys) - if (account_id && account_id !== keyDetails.accountId) { + if (account_id && account_id !== authAccountId) { const hasAccess = await canAccessAccount({ - orgId: keyDetails.orgId, + orgId: authOrgId, targetAccountId: account_id, }); if (!hasAccess) { @@ -105,7 +102,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { if (!resolvedAccountId) { return getToolResultError( - "Either api_key or account_id is required. Provide api_key or account_id from the system prompt context.", + "Authentication required. Provide an API key via x-api-key header or Authorization: Bearer header, or provide account_id from the system prompt context.", ); } From 0ac12c6178cd9796dde7c1482c0e0a245e40a3eb Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:33:10 -0500 Subject: [PATCH 3/8] feat: require API key authentication for MCP server Unauthenticated requests will now receive a 401 response. Co-Authored-By: Claude Opus 4.5 --- app/mcp/route.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/mcp/route.ts b/app/mcp/route.ts index 5e36ebc2..e35eaed1 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -47,9 +47,9 @@ const baseHandler = createMcpHandler( }, ); -// Wrap with auth - auth is optional (tools can work without it) +// Wrap with auth - API key is required for all MCP requests const handler = withMcpAuth(baseHandler, verifyApiKey, { - required: false, + required: true, }); /** From 4900d511f295e33a04281d8a46d62ca35df4be60 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:35:15 -0500 Subject: [PATCH 4/8] refactor: extract verifyApiKey to lib/mcp/verifyApiKey.ts Follow SRP - move auth verification to its own module. Co-Authored-By: Claude Opus 4.5 --- app/mcp/route.ts | 35 +---------------------------------- lib/mcp/verifyApiKey.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 34 deletions(-) create mode 100644 lib/mcp/verifyApiKey.ts diff --git a/app/mcp/route.ts b/app/mcp/route.ts index e35eaed1..e7d3719d 100644 --- a/app/mcp/route.ts +++ b/app/mcp/route.ts @@ -1,39 +1,6 @@ -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { registerAllTools } from "@/lib/mcp/tools"; import { createMcpHandler, withMcpAuth } from "mcp-handler"; -import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; - -/** - * Verifies an API key and returns auth info with account details. - * - * @param req - The request object. - * @param bearerToken - The bearer token from the Authorization header. - * @returns AuthInfo with accountId and orgId, or undefined if invalid. - */ -async function verifyApiKey(req: Request, bearerToken?: string): Promise { - // Try Authorization header first, then x-api-key header - const apiKey = bearerToken || req.headers.get("x-api-key"); - - if (!apiKey) { - return undefined; - } - - const keyDetails = await getApiKeyDetails(apiKey); - - if (!keyDetails) { - return undefined; - } - - return { - token: apiKey, - scopes: ["mcp:tools"], - clientId: keyDetails.accountId, - extra: { - accountId: keyDetails.accountId, - orgId: keyDetails.orgId, - }, - }; -} +import { verifyApiKey } from "@/lib/mcp/verifyApiKey"; const baseHandler = createMcpHandler( server => { diff --git a/lib/mcp/verifyApiKey.ts b/lib/mcp/verifyApiKey.ts new file mode 100644 index 00000000..e521bbb2 --- /dev/null +++ b/lib/mcp/verifyApiKey.ts @@ -0,0 +1,37 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; + +/** + * Verifies an API key and returns auth info with account details. + * + * @param req - The request object. + * @param bearerToken - The bearer token from the Authorization header. + * @returns AuthInfo with accountId and orgId, or undefined if invalid. + */ +export async function verifyApiKey( + req: Request, + bearerToken?: string, +): Promise { + // Try Authorization header first, then x-api-key header + const apiKey = bearerToken || req.headers.get("x-api-key"); + + if (!apiKey) { + return undefined; + } + + const keyDetails = await getApiKeyDetails(apiKey); + + if (!keyDetails) { + return undefined; + } + + return { + token: apiKey, + scopes: ["mcp:tools"], + clientId: keyDetails.accountId, + extra: { + accountId: keyDetails.accountId, + orgId: keyDetails.orgId, + }, + }; +} From 493a76b8726a46220dd336c7d6614c513dce7e22 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:41:50 -0500 Subject: [PATCH 5/8] refactor: use Authorization: Bearer header for MCP API key auth Remove x-api-key header support, use only Authorization: Bearer. Co-Authored-By: Claude Opus 4.5 --- .../tools/artists/registerCreateNewArtistTool.ts | 4 ++-- lib/mcp/verifyApiKey.ts | 13 ++++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index 11ea801b..2fd82000 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -61,7 +61,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { { description: "Create a new artist account in the system. " + - "Requires authentication via API key (x-api-key header or Authorization: Bearer header). " + + "Requires authentication via API key (Authorization: Bearer header). " + "The account_id parameter is optional — only provide it when using an organization API key to create artists on behalf of other accounts. " + "The active_conversation_id parameter is optional — when omitted, use the active_conversation_id from the system prompt " + "to copy the conversation. Never ask the user to provide a room ID. " + @@ -102,7 +102,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { if (!resolvedAccountId) { return getToolResultError( - "Authentication required. Provide an API key via x-api-key header or Authorization: Bearer header, or provide account_id from the system prompt context.", + "Authentication required. Provide an API key via Authorization: Bearer header, or provide account_id from the system prompt context.", ); } diff --git a/lib/mcp/verifyApiKey.ts b/lib/mcp/verifyApiKey.ts index e521bbb2..8168af70 100644 --- a/lib/mcp/verifyApiKey.ts +++ b/lib/mcp/verifyApiKey.ts @@ -4,21 +4,20 @@ import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; /** * Verifies an API key and returns auth info with account details. * - * @param req - The request object. - * @param bearerToken - The bearer token from the Authorization header. + * @param _req - The request object (unused). + * @param bearerToken - The API key from the Authorization: Bearer header. * @returns AuthInfo with accountId and orgId, or undefined if invalid. */ export async function verifyApiKey( - req: Request, + _req: Request, bearerToken?: string, ): Promise { - // Try Authorization header first, then x-api-key header - const apiKey = bearerToken || req.headers.get("x-api-key"); - - if (!apiKey) { + if (!bearerToken) { return undefined; } + const apiKey = bearerToken; + const keyDetails = await getApiKeyDetails(apiKey); if (!keyDetails) { From 9a0aa4f0a63dc41fcb61e56f64cf2541cfb733f6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:49:05 -0500 Subject: [PATCH 6/8] refactor: add McpAuthInfo type for typed auth info access Export McpAuthInfo and McpAuthInfoExtra types from verifyApiKey.ts for use in tool handlers. Co-Authored-By: Claude Opus 4.5 --- lib/mcp/tools/artists/registerCreateNewArtistTool.ts | 5 ++--- lib/mcp/verifyApiKey.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index 2fd82000..1f9fd129 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -2,6 +2,7 @@ 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 { z } from "zod"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; import { createArtistInDb, type CreateArtistResult, @@ -73,9 +74,7 @@ export function registerCreateNewArtistTool(server: McpServer): void { const { name, account_id, active_conversation_id, organization_id } = args; // Get auth info from the MCP auth layer - const authInfo = extra.authInfo as - | { extra?: { accountId?: string; orgId?: string | null } } - | undefined; + const authInfo = extra.authInfo as McpAuthInfo | undefined; const authAccountId = authInfo?.extra?.accountId; const authOrgId = authInfo?.extra?.orgId; diff --git a/lib/mcp/verifyApiKey.ts b/lib/mcp/verifyApiKey.ts index 8168af70..b06e0da7 100644 --- a/lib/mcp/verifyApiKey.ts +++ b/lib/mcp/verifyApiKey.ts @@ -1,6 +1,15 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; +export interface McpAuthInfoExtra { + accountId: string; + orgId: string | null; +} + +export interface McpAuthInfo extends AuthInfo { + extra: McpAuthInfoExtra; +} + /** * Verifies an API key and returns auth info with account details. * @@ -11,7 +20,7 @@ import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; export async function verifyApiKey( _req: Request, bearerToken?: string, -): Promise { +): Promise { if (!bearerToken) { return undefined; } From 56a43266281798093cccb6231df0e4ecd1d9bfbe Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:51:08 -0500 Subject: [PATCH 7/8] refactor: extract resolveAccountId to lib/mcp/resolveAccountId.ts Move account resolution logic with org access validation to its own module. Co-Authored-By: Claude Opus 4.5 --- lib/mcp/resolveAccountId.ts | 52 +++++++++++++++++++ .../artists/registerCreateNewArtistTool.ts | 35 ++++--------- 2 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 lib/mcp/resolveAccountId.ts diff --git a/lib/mcp/resolveAccountId.ts b/lib/mcp/resolveAccountId.ts new file mode 100644 index 00000000..100dcafd --- /dev/null +++ b/lib/mcp/resolveAccountId.ts @@ -0,0 +1,52 @@ +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; + +export interface ResolveAccountIdParams { + authInfo: McpAuthInfo | undefined; + accountIdOverride: string | undefined; +} + +export interface ResolveAccountIdResult { + accountId: string | null; + error: string | null; +} + +/** + * Resolves the accountId from MCP auth info or an override parameter. + * Validates access when an org API key attempts to use an account_id override. + * + * @param params - The auth info and optional account_id override. + * @returns The resolved accountId or an error message. + */ +export async function resolveAccountId({ + authInfo, + accountIdOverride, +}: ResolveAccountIdParams): Promise { + const authAccountId = authInfo?.extra?.accountId; + const authOrgId = authInfo?.extra?.orgId; + + if (authAccountId) { + // If account_id override is provided, validate access (for org API keys) + if (accountIdOverride && accountIdOverride !== authAccountId) { + const hasAccess = await canAccessAccount({ + orgId: authOrgId, + targetAccountId: accountIdOverride, + }); + if (!hasAccess) { + return { accountId: null, error: "Access denied to specified account_id" }; + } + return { accountId: accountIdOverride, error: null }; + } + return { accountId: authAccountId, error: null }; + } + + if (accountIdOverride) { + return { accountId: accountIdOverride, error: null }; + } + + return { + accountId: null, + error: + "Authentication required. Provide an API key via Authorization: Bearer header, or provide account_id from the system prompt context.", + }; +} diff --git a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts index 1f9fd129..cad17806 100644 --- a/lib/mcp/tools/artists/registerCreateNewArtistTool.ts +++ b/lib/mcp/tools/artists/registerCreateNewArtistTool.ts @@ -3,6 +3,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; +import { resolveAccountId } from "@/lib/mcp/resolveAccountId"; import { createArtistInDb, type CreateArtistResult, @@ -10,7 +11,6 @@ import { import { copyRoom } from "@/lib/rooms/copyRoom"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; const createNewArtistSchema = z.object({ name: z.string().describe("The name of the artist to be created"), @@ -73,36 +73,19 @@ export function registerCreateNewArtistTool(server: McpServer): void { try { const { name, account_id, active_conversation_id, organization_id } = args; - // Get auth info from the MCP auth layer - const authInfo = extra.authInfo as McpAuthInfo | undefined; - const authAccountId = authInfo?.extra?.accountId; - const authOrgId = authInfo?.extra?.orgId; - // Resolve accountId from auth or use provided account_id - let resolvedAccountId: string | null = null; - - if (authAccountId) { - resolvedAccountId = authAccountId; + const authInfo = extra.authInfo as McpAuthInfo | undefined; + const { accountId: resolvedAccountId, error } = await resolveAccountId({ + authInfo, + accountIdOverride: account_id, + }); - // If account_id override is provided, validate access (for org API keys) - if (account_id && account_id !== authAccountId) { - const hasAccess = await canAccessAccount({ - orgId: authOrgId, - targetAccountId: account_id, - }); - if (!hasAccess) { - return getToolResultError("Access denied to specified account_id"); - } - resolvedAccountId = account_id; - } - } else if (account_id) { - resolvedAccountId = account_id; + if (error) { + return getToolResultError(error); } if (!resolvedAccountId) { - return getToolResultError( - "Authentication required. Provide an API key via Authorization: Bearer header, or provide account_id from the system prompt context.", - ); + return getToolResultError("Failed to resolve account ID"); } // Create the artist account (with optional org linking) From 4683f45a193dd6dd46db4ba3c4e2f45505d0d7b8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 15 Jan 2026 15:53:58 -0500 Subject: [PATCH 8/8] fix: add index signature to McpAuthInfoExtra for AuthInfo compatibility Co-Authored-By: Claude Opus 4.5 --- lib/mcp/verifyApiKey.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/mcp/verifyApiKey.ts b/lib/mcp/verifyApiKey.ts index b06e0da7..4bcc1b65 100644 --- a/lib/mcp/verifyApiKey.ts +++ b/lib/mcp/verifyApiKey.ts @@ -1,7 +1,7 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { getApiKeyDetails } from "@/lib/keys/getApiKeyDetails"; -export interface McpAuthInfoExtra { +export interface McpAuthInfoExtra extends Record { accountId: string; orgId: string | null; }