From 105a1a4018affb5189779f237e75bf0510f8c49e Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 6 Jun 2026 06:31:38 +0000 Subject: [PATCH 1/3] Add MCP setup guidance and OAuth flow --- apps/api/src/api/endpoints.test.ts | 45 ++ apps/api/src/api/endpoints.ts | 46 +- apps/api/src/api/handlers/index.ts | 14 +- apps/api/src/api/handlers/mcp-oauth.ts | 61 ++ apps/api/src/application/mcp-oauth.test.ts | 250 +++++++ apps/api/src/application/mcp-oauth.ts | 685 ++++++++++++++++++ apps/api/src/db/migrations/017_mcp_oauth.ts | 117 +++ apps/api/src/db/schemas.ts | 137 ++++ apps/api/src/log-templates.ts | 9 +- apps/api/src/mcp/auth.test.ts | 26 +- apps/api/src/mcp/auth.ts | 72 ++ apps/api/src/mcp/endpoint.ts | 34 +- apps/api/src/mcp/oauth-endpoints.ts | 214 ++++++ apps/api/src/mcp/server.ts | 4 +- apps/api/src/mcp/tools.test.ts | 4 +- apps/api/src/mcp/tools.ts | 10 +- apps/api/src/security/api-auth.ts | 14 +- apps/api/src/server.ts | 29 + .../app/(app)/settings/preferences/page.tsx | 25 +- apps/web/app/(auth)/login/page.tsx | 27 +- apps/web/app/auth/callback/route.ts | 23 +- apps/web/app/external-api/[...path]/route.ts | 3 + apps/web/app/mcp/oauth/authorize/page.tsx | 188 +++++ .../web/components/api-keys-settings.test.tsx | 48 ++ apps/web/components/api-keys-settings.tsx | 269 ++++++- apps/web/components/forms/login-form.tsx | 17 +- apps/web/lib/actions.ts | 80 +- apps/web/next.config.ts | 10 +- packages/contracts/src/api.test.ts | 4 + packages/contracts/src/api.ts | 43 ++ packages/contracts/src/schemas.ts | 97 +++ tests/e2e/workflows.spec.ts | 2 + 32 files changed, 2557 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/api/handlers/mcp-oauth.ts create mode 100644 apps/api/src/application/mcp-oauth.test.ts create mode 100644 apps/api/src/application/mcp-oauth.ts create mode 100644 apps/api/src/db/migrations/017_mcp_oauth.ts create mode 100644 apps/api/src/mcp/oauth-endpoints.ts create mode 100644 apps/web/app/mcp/oauth/authorize/page.tsx create mode 100644 apps/web/components/api-keys-settings.test.tsx diff --git a/apps/api/src/api/endpoints.test.ts b/apps/api/src/api/endpoints.test.ts index a95cebd..dff195f 100644 --- a/apps/api/src/api/endpoints.test.ts +++ b/apps/api/src/api/endpoints.test.ts @@ -208,4 +208,49 @@ describe('api endpoint map', () => { await runningServer.close(); } }); + + it('serves MCP OAuth discovery metadata and challenges unauthenticated MCP requests', async () => { + const server = buildServer(testServerConfig(), testLogger(), { + knex: {}, + db: {} + } as never); + const runningServer = await server.listen(0, '127.0.0.1'); + + try { + const port = runningServer.address?.port; + expect(port).toBeTypeOf('number'); + + const baseUrl = `http://127.0.0.1:${port}`; + const authorizationServer = await fetch( + `${baseUrl}/.well-known/oauth-authorization-server` + ); + await expect(authorizationServer.json()).resolves.toMatchObject({ + issuer: 'http://localhost:3000', + authorization_endpoint: + 'http://localhost:3000/mcp/oauth/authorize', + token_endpoint: + 'http://localhost:3000/external-api/oauth/token', + registration_endpoint: + 'http://localhost:3000/external-api/oauth/register', + code_challenge_methods_supported: ['S256'] + }); + + const protectedResource = await fetch( + `${baseUrl}/.well-known/oauth-protected-resource/external-api/mcp` + ); + await expect(protectedResource.json()).resolves.toMatchObject({ + resource: 'http://localhost:3000/external-api/mcp', + authorization_servers: ['http://localhost:3000'], + scopes_supported: ['mcp'] + }); + + const mcp = await fetch(`${baseUrl}/api/mcp`, { method: 'POST' }); + expect(mcp.status).toBe(401); + expect(mcp.headers.get('www-authenticate')).toBe( + 'Bearer resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource/external-api/mcp"' + ); + } finally { + await runningServer.close(); + } + }); }); diff --git a/apps/api/src/api/endpoints.ts b/apps/api/src/api/endpoints.ts index d7db992..fb5b934 100644 --- a/apps/api/src/api/endpoints.ts +++ b/apps/api/src/api/endpoints.ts @@ -139,6 +139,44 @@ export const RevokeApiKeyEndpoint = api.users.revokeApiKey .tags('api-keys') .operationId('revokeApiKey'); +export const ListMcpOAuthConnectionsEndpoint = api.users.listMcpOAuthConnections + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('List MCP connections') + .description('Lists active MCP OAuth connections for the current user.') + .tags('mcp') + .operationId('listMcpOAuthConnections'); + +export const RevokeMcpOAuthConnectionEndpoint = + api.users.revokeMcpOAuthConnection + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Revoke MCP connection') + .description( + 'Revokes an MCP OAuth connection owned by the current user.' + ) + .tags('mcp') + .operationId('revokeMcpOAuthConnection'); + +export const McpOAuthAuthorizationRequestEndpoint = + api.oauth.authorizationRequest + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('MCP OAuth authorization request') + .description( + 'Validates an MCP OAuth authorization request before user approval.' + ) + .tags('mcp') + .operationId('mcpOAuthAuthorizationRequest'); + +export const McpOAuthAuthorizeEndpoint = api.oauth.authorize + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .summary('Approve MCP OAuth') + .description('Approves an MCP OAuth authorization request for the user.') + .tags('mcp') + .operationId('mcpOAuthAuthorize'); + export const LinkTelegramEndpoint = api.telegram.link .inject({ db: DbToken, config: ConfigToken }) .summary('Link Telegram account') @@ -413,7 +451,13 @@ export const endpoints = { disconnectTelegram: DisconnectTelegramEndpoint, listApiKeys: ListApiKeysEndpoint, createApiKey: CreateApiKeyEndpoint, - revokeApiKey: RevokeApiKeyEndpoint + revokeApiKey: RevokeApiKeyEndpoint, + listMcpOAuthConnections: ListMcpOAuthConnectionsEndpoint, + revokeMcpOAuthConnection: RevokeMcpOAuthConnectionEndpoint + }, + oauth: { + authorizationRequest: McpOAuthAuthorizationRequestEndpoint, + authorize: McpOAuthAuthorizeEndpoint }, telegram: { link: LinkTelegramEndpoint, diff --git a/apps/api/src/api/handlers/index.ts b/apps/api/src/api/handlers/index.ts index 3042698..e3d8e05 100644 --- a/apps/api/src/api/handlers/index.ts +++ b/apps/api/src/api/handlers/index.ts @@ -23,6 +23,12 @@ import { updateCategoryHandler } from './categories.js'; import { convertCurrencyHandler, listCurrenciesHandler } from './currencies.js'; +import { + listMcpOAuthConnectionsHandler, + mcpOAuthAuthorizationRequestHandler, + mcpOAuthAuthorizeHandler, + revokeMcpOAuthConnectionHandler +} from './mcp-oauth.js'; import { createTelegramLinkTokenHandler, disconnectTelegramHandler, @@ -77,7 +83,13 @@ export const handlers = { disconnectTelegram: disconnectTelegramHandler, listApiKeys: listApiKeysHandler, createApiKey: createApiKeyHandler, - revokeApiKey: revokeApiKeyHandler + revokeApiKey: revokeApiKeyHandler, + listMcpOAuthConnections: listMcpOAuthConnectionsHandler, + revokeMcpOAuthConnection: revokeMcpOAuthConnectionHandler + }, + oauth: { + authorizationRequest: mcpOAuthAuthorizationRequestHandler, + authorize: mcpOAuthAuthorizeHandler }, telegram: { link: linkTelegramHandler, diff --git a/apps/api/src/api/handlers/mcp-oauth.ts b/apps/api/src/api/handlers/mcp-oauth.ts new file mode 100644 index 0000000..92076ba --- /dev/null +++ b/apps/api/src/api/handlers/mcp-oauth.ts @@ -0,0 +1,61 @@ +import { ActionResult, type Handler } from '@cleverbrush/server'; +import { + authorizeMcpOAuthRequest, + getMcpOAuthAuthorizationRequest, + listMcpOAuthConnections, + McpOAuthConnectionNotFoundError, + OAuthError, + revokeMcpOAuthConnection +} from '../../application/mcp-oauth.js'; +import type { + ListMcpOAuthConnectionsEndpoint, + McpOAuthAuthorizationRequestEndpoint, + McpOAuthAuthorizeEndpoint, + RevokeMcpOAuthConnectionEndpoint +} from '../endpoints.js'; + +export const listMcpOAuthConnectionsHandler: Handler< + typeof ListMcpOAuthConnectionsEndpoint +> = async ({ principal }, { db }) => { + return listMcpOAuthConnections(db, principal.userId); +}; + +export const revokeMcpOAuthConnectionHandler: Handler< + typeof RevokeMcpOAuthConnectionEndpoint +> = async ({ params, principal }, { db }) => { + try { + await revokeMcpOAuthConnection(db, principal.userId, params.id); + return ActionResult.noContent(); + } catch (err) { + if (err instanceof McpOAuthConnectionNotFoundError) { + return ActionResult.notFound({ message: err.message }); + } + throw err; + } +}; + +export const mcpOAuthAuthorizationRequestHandler: Handler< + typeof McpOAuthAuthorizationRequestEndpoint +> = async ({ query }, { db }) => { + try { + return await getMcpOAuthAuthorizationRequest(db, query); + } catch (err) { + if (err instanceof OAuthError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; + +export const mcpOAuthAuthorizeHandler: Handler< + typeof McpOAuthAuthorizeEndpoint +> = async ({ body, principal }, { db }) => { + try { + return await authorizeMcpOAuthRequest(db, principal.userId, body); + } catch (err) { + if (err instanceof OAuthError) { + return ActionResult.badRequest({ message: err.message }); + } + throw err; + } +}; diff --git a/apps/api/src/application/mcp-oauth.test.ts b/apps/api/src/application/mcp-oauth.test.ts new file mode 100644 index 0000000..dcb2aae --- /dev/null +++ b/apps/api/src/application/mcp-oauth.test.ts @@ -0,0 +1,250 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { Config } from '../config.js'; +import type { AppDb } from '../db/schemas.js'; +import { + authenticateMcpOAuthAccessToken, + authorizeMcpOAuthRequest, + exchangeMcpOAuthToken, + getMcpOAuthAuthorizationRequest, + listMcpOAuthConnections, + OAuthError, + registerMcpOAuthClient, + revokeMcpOAuthConnection +} from './mcp-oauth.js'; + +type Row = Record & { id: number }; + +const config = { + jwt: { secret: 'x'.repeat(32) } +} as Config; + +function propertyProxy() { + return new Proxy( + {}, + { + get: (_target, prop) => String(prop) + } + ); +} + +type AwaitableQuery = Query & PromiseLike; + +class Query { + private readonly filters: Array<(row: T) => boolean> = []; + private order: + | { readonly key: keyof T; readonly direction: 'asc' | 'desc' } + | undefined; + + constructor(private readonly table: Table) {} + + where(selector: (row: T) => unknown, value: unknown): this { + const key = selector(propertyProxy() as T) as keyof T; + this.filters.push(row => row[key] === value); + return this; + } + + orderBy(selector: (row: T) => unknown, direction: 'asc' | 'desc'): this { + this.order = { + key: selector(propertyProxy() as T) as keyof T, + direction + }; + return this; + } + + async first(): Promise { + return this.toArray()[0]; + } + + async update(patch: Partial): Promise { + for (const row of this.toArray()) { + Object.assign(row, patch); + } + } + + toArray(): T[] { + const filtered = this.table.rows.filter(row => + this.filters.every(filter => filter(row)) + ); + if (!this.order) { + return filtered; + } + const { direction, key } = this.order; + return [...filtered].sort((left, right) => { + const leftValue = left[key]; + const rightValue = right[key]; + const delta = + leftValue instanceof Date && rightValue instanceof Date + ? leftValue.getTime() - rightValue.getTime() + : String(leftValue).localeCompare(String(rightValue)); + return direction === 'asc' ? delta : -delta; + }); + } +} + +function query(table: Table): AwaitableQuery { + const result = new Query(table) as AwaitableQuery; + Object.defineProperty(result, 'then', { + value: ( + onfulfilled?: ((value: T[]) => unknown) | null, + onrejected?: ((reason: unknown) => unknown) | null + ) => Promise.resolve(result.toArray()).then(onfulfilled, onrejected) + }); + return result; +} + +class Table { + readonly rows: T[] = []; + private nextId = 1; + + constructor(initialRows: T[] = []) { + this.rows.push(...initialRows); + this.nextId = + Math.max(0, ...initialRows.map(row => Number(row.id))) + 1; + } + + where(selector: (row: T) => unknown, value: unknown): AwaitableQuery { + return query(this).where(selector, value); + } + + async find(id: number): Promise { + return this.rows.find(row => row.id === id); + } + + async insert(value: Omit, 'id'>): Promise { + const row = { + id: this.nextId++, + createdAt: new Date(), + ...value + } as unknown as T; + this.rows.push(row); + return row; + } +} + +function testDb(): AppDb { + const db = { + users: new Table([ + { + id: 1, + email: 'jane@example.com', + role: 'user', + emailVerified: true + } as Row + ]), + mcpOAuthClients: new Table(), + mcpOAuthGrants: new Table(), + mcpOAuthAuthorizationCodes: new Table(), + mcpOAuthRefreshTokens: new Table(), + transaction: async (callback: (trx: AppDb) => Promise) => + callback(db as unknown as AppDb) + }; + return db as unknown as AppDb; +} + +function pkceChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url'); +} + +describe('MCP OAuth helpers', () => { + it('registers public clients and rejects unsafe redirect URIs', async () => { + const db = testDb(); + + await expect( + registerMcpOAuthClient(db, { + client_name: 'Claude', + redirect_uris: ['http://example.com/callback'] + }) + ).rejects.toBeInstanceOf(OAuthError); + + const registered = await registerMcpOAuthClient(db, { + client_name: 'Claude', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'] + }); + + expect(registered.client_name).toBe('Claude'); + expect(registered.token_endpoint_auth_method).toBe('none'); + expect(registered.grant_types).toEqual([ + 'authorization_code', + 'refresh_token' + ]); + }); + + it('issues, refreshes, authenticates, and revokes MCP OAuth tokens', async () => { + const db = testDb(); + const verifier = 'verifier-'.repeat(8); + const registered = await registerMcpOAuthClient(db, { + client_name: 'Codex', + redirect_uris: ['http://localhost/callback'] + }); + const query = { + response_type: 'code', + client_id: registered.client_id, + redirect_uri: 'http://localhost/callback', + code_challenge: pkceChallenge(verifier), + code_challenge_method: 'S256', + state: 'state-1', + scope: 'mcp' + }; + + await expect( + getMcpOAuthAuthorizationRequest(db, query) + ).resolves.toMatchObject({ + clientName: 'Codex', + redirectUri: 'http://localhost/callback' + }); + + const approved = await authorizeMcpOAuthRequest(db, 1, query); + const code = new URL(approved.redirectUrl).searchParams.get('code'); + expect(code).toBeTruthy(); + + const token = await exchangeMcpOAuthToken(db, config, { + grant_type: 'authorization_code', + client_id: registered.client_id, + code: code ?? '', + redirect_uri: query.redirect_uri, + code_verifier: verifier + }); + + expect(token.token_type).toBe('Bearer'); + await expect( + exchangeMcpOAuthToken(db, config, { + grant_type: 'authorization_code', + client_id: registered.client_id, + code: code ?? '', + redirect_uri: query.redirect_uri, + code_verifier: verifier + }) + ).rejects.toMatchObject({ error: 'invalid_grant' }); + + const refreshed = await exchangeMcpOAuthToken(db, config, { + grant_type: 'refresh_token', + client_id: registered.client_id, + refresh_token: token.refresh_token + }); + expect(refreshed.refresh_token).not.toBe(token.refresh_token); + await expect( + exchangeMcpOAuthToken(db, config, { + grant_type: 'refresh_token', + client_id: registered.client_id, + refresh_token: token.refresh_token + }) + ).rejects.toMatchObject({ error: 'invalid_grant' }); + + await expect( + authenticateMcpOAuthAccessToken(db, config, refreshed.access_token) + ).resolves.toMatchObject({ + authType: 'mcp_oauth', + userId: 1, + mcpClientId: registered.client_id + }); + + const connections = await listMcpOAuthConnections(db, 1); + expect(connections).toHaveLength(1); + await revokeMcpOAuthConnection(db, 1, connections[0]!.id); + await expect(listMcpOAuthConnections(db, 1)).resolves.toEqual([]); + await expect( + authenticateMcpOAuthAccessToken(db, config, refreshed.access_token) + ).resolves.toBeUndefined(); + }); +}); diff --git a/apps/api/src/application/mcp-oauth.ts b/apps/api/src/application/mcp-oauth.ts new file mode 100644 index 0000000..f101e72 --- /dev/null +++ b/apps/api/src/application/mcp-oauth.ts @@ -0,0 +1,685 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; +import { jwtScheme, signJwt } from '@cleverbrush/auth'; +import type { + McpOAuthAuthorizationQuery, + McpOAuthAuthorizationRequest, + McpOAuthAuthorizeResponse, + McpOAuthConnection +} from '@xpenser/contracts'; +import type { Config } from '../config.js'; +import type { + AppDb, + McpOAuthAuthorizationCodeDb, + McpOAuthClientDb, + McpOAuthGrantDb, + McpOAuthRefreshTokenDb, + UserDb +} from '../db/schemas.js'; + +const mcpScope = 'mcp'; +const accessTokenTtlSeconds = 60 * 60; +const authorizationCodeTtlSeconds = 10 * 60; +const refreshTokenTtlSeconds = 30 * 24 * 60 * 60; + +type DynamicClientRegistrationRequest = { + readonly client_name?: unknown; + readonly redirect_uris?: unknown; + readonly grant_types?: unknown; + readonly response_types?: unknown; + readonly scope?: unknown; + readonly token_endpoint_auth_method?: unknown; +}; + +type DynamicClientRegistrationResponse = { + readonly client_id: string; + readonly client_id_issued_at: number; + readonly client_name: string; + readonly redirect_uris: readonly string[]; + readonly grant_types: readonly string[]; + readonly response_types: readonly string[]; + readonly scope: string; + readonly token_endpoint_auth_method: 'none'; +}; + +export type McpOAuthTokenRequest = { + readonly grant_type?: string; + readonly client_id?: string; + readonly code?: string; + readonly redirect_uri?: string; + readonly code_verifier?: string; + readonly refresh_token?: string; +}; + +export type McpOAuthTokenResponse = { + readonly access_token: string; + readonly token_type: 'Bearer'; + readonly expires_in: number; + readonly refresh_token: string; + readonly scope: string; +}; + +export type McpOAuthAccessPrincipal = { + readonly userId: number; + readonly role: string; + readonly authType: 'mcp_oauth'; + readonly mcpGrantId: number; + readonly mcpClientId: string; +}; + +export class OAuthError extends Error { + readonly status: number; + readonly error: string; + + constructor(error: string, message: string, status = 400) { + super(message); + this.error = error; + this.status = status; + } +} + +export class McpOAuthConnectionNotFoundError extends Error {} + +function randomToken(): string { + return randomBytes(32).toString('base64url'); +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function epochSeconds(value: Date): number { + return Math.floor(value.getTime() / 1000); +} + +function tokenExpiresAt(ttlSeconds: number, now = new Date()): Date { + return new Date(now.getTime() + ttlSeconds * 1000); +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every(item => typeof item === 'string') + ); +} + +function redirectUris(client: McpOAuthClientDb): string[] { + const parsed = JSON.parse(client.redirectUrisJson) as unknown; + return isStringArray(parsed) ? parsed : []; +} + +function oauthClientDisplayName(value: unknown): string { + if (typeof value !== 'string') { + return 'MCP client'; + } + const trimmed = value.trim(); + return trimmed || 'MCP client'; +} + +function allowedRedirectUri(value: string): boolean { + try { + const url = new URL(value); + if (url.protocol === 'https:') { + return true; + } + if (url.protocol !== 'http:') { + return false; + } + return ( + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]' + ); + } catch { + return false; + } +} + +function normalizeScope(scope: unknown): string { + if (scope === undefined || scope === null || scope === '') { + return mcpScope; + } + if (typeof scope !== 'string') { + throw new OAuthError('invalid_scope', 'OAuth scope must be a string.'); + } + const scopes = scope + .split(/\s+/) + .map(item => item.trim()) + .filter(Boolean); + if (scopes.length === 0) { + return mcpScope; + } + if (scopes.some(item => item !== mcpScope)) { + throw new OAuthError( + 'invalid_scope', + 'xpenser MCP only supports the mcp scope.' + ); + } + return mcpScope; +} + +function requireSupportedRegistration(body: DynamicClientRegistrationRequest) { + if ( + body.token_endpoint_auth_method !== undefined && + body.token_endpoint_auth_method !== 'none' + ) { + throw new OAuthError( + 'invalid_client_metadata', + 'xpenser MCP OAuth supports public clients only.' + ); + } + if ( + isStringArray(body.grant_types) && + !body.grant_types.includes('authorization_code') + ) { + throw new OAuthError( + 'invalid_client_metadata', + 'authorization_code grant type is required.' + ); + } + if ( + isStringArray(body.response_types) && + !body.response_types.includes('code') + ) { + throw new OAuthError( + 'invalid_client_metadata', + 'code response type is required.' + ); + } +} + +function validateAuthorizationQuery( + client: McpOAuthClientDb, + query: McpOAuthAuthorizationQuery +): string { + if (query.response_type !== 'code') { + throw new OAuthError( + 'unsupported_response_type', + 'xpenser MCP OAuth supports authorization code only.' + ); + } + if (query.code_challenge_method !== 'S256') { + throw new OAuthError( + 'invalid_request', + 'xpenser MCP OAuth requires S256 PKCE.' + ); + } + if (!query.code_challenge) { + throw new OAuthError( + 'invalid_request', + 'PKCE code challenge required.' + ); + } + if (!redirectUris(client).includes(query.redirect_uri)) { + throw new OAuthError( + 'invalid_request', + 'Redirect URI is not registered for this MCP client.' + ); + } + return normalizeScope(query.scope); +} + +function appendOAuthRedirectParams( + redirectUri: string, + params: Record +): string { + const url = new URL(redirectUri); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + +function verifyPkce(verifier: string, challenge: string): boolean { + const actual = Buffer.from( + createHash('sha256').update(verifier).digest('base64url') + ); + const expected = Buffer.from(challenge); + return ( + actual.length === expected.length && timingSafeEqual(actual, expected) + ); +} + +async function findOAuthClient( + db: AppDb, + clientId: string +): Promise { + return (await db.mcpOAuthClients + .where(client => client.clientId, clientId) + .first()) as McpOAuthClientDb | undefined; +} + +async function requireOAuthClient( + db: AppDb, + clientId: string +): Promise { + const client = await findOAuthClient(db, clientId); + if (!client) { + throw new OAuthError('invalid_client', 'MCP OAuth client not found.'); + } + return client; +} + +async function activeGrant( + db: AppDb, + userId: number, + clientId: number, + scope: string +): Promise { + const rows = (await db.mcpOAuthGrants + .where(grant => grant.userId, userId) + .where(grant => grant.clientId, clientId) + .where(grant => grant.scope, scope)) as McpOAuthGrantDb[]; + return rows.find(grant => !grant.revokedAt); +} + +async function requireActiveGrant( + db: AppDb, + grantId: number +): Promise { + const grant = (await db.mcpOAuthGrants + .where(candidate => candidate.id, grantId) + .first()) as McpOAuthGrantDb | undefined; + if (!grant || grant.revokedAt) { + throw new OAuthError('invalid_grant', 'MCP OAuth grant is not active.'); + } + return grant; +} + +function issueAccessToken({ + client, + config, + grant, + user +}: { + readonly client: McpOAuthClientDb; + readonly config: Config; + readonly grant: McpOAuthGrantDb; + readonly user: Pick; +}): string { + const exp = Math.floor(Date.now() / 1000) + accessTokenTtlSeconds; + return signJwt( + { + sub: String(user.id), + role: user.role, + auth_type: 'mcp_oauth', + mcp_grant_id: String(grant.id), + mcp_client_id: client.clientId, + exp + }, + config.jwt.secret + ); +} + +async function createRefreshToken( + db: AppDb, + grant: McpOAuthGrantDb +): Promise { + const refreshToken = randomToken(); + await db.mcpOAuthRefreshTokens.insert({ + userId: grant.userId, + clientId: grant.clientId, + grantId: grant.id, + tokenHash: hashToken(refreshToken), + expiresAt: tokenExpiresAt(refreshTokenTtlSeconds), + revokedAt: undefined, + lastUsedAt: undefined + }); + return refreshToken; +} + +async function tokenResponse({ + client, + config, + db, + grant, + user +}: { + readonly client: McpOAuthClientDb; + readonly config: Config; + readonly db: AppDb; + readonly grant: McpOAuthGrantDb; + readonly user: Pick; +}): Promise { + await db.mcpOAuthGrants + .where(candidate => candidate.id, grant.id) + .update({ lastUsedAt: new Date() }); + + return { + access_token: issueAccessToken({ client, config, grant, user }), + token_type: 'Bearer', + expires_in: accessTokenTtlSeconds, + refresh_token: await createRefreshToken(db, grant), + scope: grant.scope + }; +} + +export async function registerMcpOAuthClient( + db: AppDb, + body: DynamicClientRegistrationRequest +): Promise { + requireSupportedRegistration(body); + const scope = normalizeScope(body.scope); + if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) { + throw new OAuthError( + 'invalid_client_metadata', + 'At least one redirect URI is required.' + ); + } + const redirectUris = Array.from(new Set(body.redirect_uris)); + if (redirectUris.some(uri => !allowedRedirectUri(uri))) { + throw new OAuthError( + 'invalid_client_metadata', + 'Redirect URIs must use HTTPS, except localhost loopback HTTP.' + ); + } + + const created = (await db.mcpOAuthClients.insert({ + clientId: `xpenser_mcp_${randomBytes(18).toString('base64url')}`, + clientName: oauthClientDisplayName(body.client_name), + redirectUrisJson: JSON.stringify(redirectUris), + scope + })) as McpOAuthClientDb; + + return { + client_id: created.clientId, + client_id_issued_at: epochSeconds(created.createdAt), + client_name: created.clientName, + redirect_uris: redirectUris, + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope, + token_endpoint_auth_method: 'none' + }; +} + +export async function getMcpOAuthAuthorizationRequest( + db: AppDb, + query: McpOAuthAuthorizationQuery +): Promise { + const client = await requireOAuthClient(db, query.client_id); + const scope = validateAuthorizationQuery(client, query); + return { + clientName: client.clientName, + redirectUri: query.redirect_uri, + scope + }; +} + +export async function authorizeMcpOAuthRequest( + db: AppDb, + userId: number, + query: McpOAuthAuthorizationQuery +): Promise { + const client = await requireOAuthClient(db, query.client_id); + const scope = validateAuthorizationQuery(client, query); + const code = randomToken(); + const redirectUrl = await db.transaction(async trx => { + let grant = await activeGrant(trx, userId, client.id, scope); + if (!grant) { + grant = (await trx.mcpOAuthGrants.insert({ + userId, + clientId: client.id, + scope, + revokedAt: undefined, + lastUsedAt: undefined + })) as McpOAuthGrantDb; + } + + await trx.mcpOAuthAuthorizationCodes.insert({ + userId, + clientId: client.id, + grantId: grant.id, + codeHash: hashToken(code), + redirectUri: query.redirect_uri, + codeChallenge: query.code_challenge, + codeChallengeMethod: query.code_challenge_method, + scope, + expiresAt: tokenExpiresAt(authorizationCodeTtlSeconds), + consumedAt: undefined + }); + + return appendOAuthRedirectParams(query.redirect_uri, { + code, + state: query.state + }); + }); + + return { redirectUrl }; +} + +export function denyMcpOAuthRequest( + query: McpOAuthAuthorizationQuery +): McpOAuthAuthorizeResponse { + return { + redirectUrl: appendOAuthRedirectParams(query.redirect_uri, { + error: 'access_denied', + state: query.state + }) + }; +} + +async function exchangeAuthorizationCode( + db: AppDb, + config: Config, + request: McpOAuthTokenRequest +): Promise { + if ( + !request.client_id || + !request.code || + !request.redirect_uri || + !request.code_verifier + ) { + throw new OAuthError( + 'invalid_request', + 'client_id, code, redirect_uri, and code_verifier are required.' + ); + } + + const client = await requireOAuthClient(db, request.client_id); + const code = (await db.mcpOAuthAuthorizationCodes + .where(candidate => candidate.codeHash, hashToken(request.code ?? '')) + .first()) as McpOAuthAuthorizationCodeDb | undefined; + if ( + !code || + code.clientId !== client.id || + code.redirectUri !== request.redirect_uri || + code.consumedAt || + code.expiresAt.getTime() <= Date.now() + ) { + throw new OAuthError( + 'invalid_grant', + 'Authorization code is invalid or expired.' + ); + } + if (!verifyPkce(request.code_verifier, code.codeChallenge)) { + throw new OAuthError('invalid_grant', 'PKCE verifier is invalid.'); + } + + const grant = await requireActiveGrant(db, code.grantId); + const user = await db.users.find(code.userId); + if (!user) { + throw new OAuthError('invalid_grant', 'User was not found.'); + } + + await db.mcpOAuthAuthorizationCodes + .where(candidate => candidate.id, code.id) + .update({ consumedAt: new Date() }); + + return tokenResponse({ client, config, db, grant, user }); +} + +async function exchangeRefreshToken( + db: AppDb, + config: Config, + request: McpOAuthTokenRequest +): Promise { + if (!request.client_id || !request.refresh_token) { + throw new OAuthError( + 'invalid_request', + 'client_id and refresh_token are required.' + ); + } + + const client = await requireOAuthClient(db, request.client_id); + const refreshToken = (await db.mcpOAuthRefreshTokens + .where( + candidate => candidate.tokenHash, + hashToken(request.refresh_token ?? '') + ) + .first()) as McpOAuthRefreshTokenDb | undefined; + if ( + !refreshToken || + refreshToken.clientId !== client.id || + refreshToken.revokedAt || + refreshToken.expiresAt.getTime() <= Date.now() + ) { + throw new OAuthError( + 'invalid_grant', + 'Refresh token is invalid or expired.' + ); + } + + const grant = await requireActiveGrant(db, refreshToken.grantId); + const user = await db.users.find(refreshToken.userId); + if (!user) { + throw new OAuthError('invalid_grant', 'User was not found.'); + } + + await db.mcpOAuthRefreshTokens + .where(candidate => candidate.id, refreshToken.id) + .update({ lastUsedAt: new Date(), revokedAt: new Date() }); + + return tokenResponse({ client, config, db, grant, user }); +} + +export async function exchangeMcpOAuthToken( + db: AppDb, + config: Config, + request: McpOAuthTokenRequest +): Promise { + if (request.grant_type === 'authorization_code') { + return exchangeAuthorizationCode(db, config, request); + } + if (request.grant_type === 'refresh_token') { + return exchangeRefreshToken(db, config, request); + } + throw new OAuthError( + 'unsupported_grant_type', + 'xpenser MCP OAuth supports authorization_code and refresh_token.' + ); +} + +export async function listMcpOAuthConnections( + db: AppDb, + userId: number +): Promise { + const grants = (await db.mcpOAuthGrants + .where(grant => grant.userId, userId) + .orderBy(grant => grant.createdAt, 'desc')) as McpOAuthGrantDb[]; + + const active = grants.filter(grant => !grant.revokedAt); + const clients = await Promise.all( + active.map(grant => db.mcpOAuthClients.find(grant.clientId)) + ); + + return active.flatMap((grant, index) => { + const client = clients[index] as McpOAuthClientDb | undefined; + if (!client) { + return []; + } + return [ + { + id: grant.id, + clientId: client.clientId, + clientName: client.clientName, + createdAt: grant.createdAt, + lastUsedAt: grant.lastUsedAt ?? undefined + } + ]; + }); +} + +export async function revokeMcpOAuthConnection( + db: AppDb, + userId: number, + grantId: number +): Promise { + const grant = (await db.mcpOAuthGrants + .where(candidate => candidate.id, grantId) + .where(candidate => candidate.userId, userId) + .first()) as McpOAuthGrantDb | undefined; + if (!grant || grant.revokedAt) { + throw new McpOAuthConnectionNotFoundError( + 'MCP connection was not found.' + ); + } + + const revokedAt = new Date(); + await db.transaction(async trx => { + await trx.mcpOAuthGrants + .where(candidate => candidate.id, grantId) + .update({ revokedAt }); + await trx.mcpOAuthRefreshTokens + .where(candidate => candidate.grantId, grantId) + .update({ revokedAt }); + }); +} + +export async function authenticateMcpOAuthAccessToken( + db: AppDb, + config: Config, + token: string +): Promise { + const scheme = jwtScheme({ + secret: config.jwt.secret, + mapClaims: claims => ({ + userId: Number(claims.sub), + role: claims.role as string, + authType: claims.auth_type, + mcpGrantId: Number(claims.mcp_grant_id), + mcpClientId: claims.mcp_client_id + }) + }); + const result = await scheme.authenticate({ + headers: { authorization: `Bearer ${token}` }, + cookies: {}, + items: new Map() + }); + if (!result.succeeded || result.principal.value?.authType !== 'mcp_oauth') { + return undefined; + } + + const { mcpClientId, mcpGrantId, role, userId } = result.principal.value; + if ( + typeof role !== 'string' || + typeof mcpClientId !== 'string' || + !Number.isSafeInteger(userId) || + !Number.isSafeInteger(mcpGrantId) + ) { + return undefined; + } + + let grant: McpOAuthGrantDb; + try { + grant = await requireActiveGrant(db, mcpGrantId); + } catch { + return undefined; + } + const client = await db.mcpOAuthClients.find(grant.clientId); + if (!client || client.clientId !== mcpClientId || grant.userId !== userId) { + return undefined; + } + + await db.mcpOAuthGrants + .where(candidate => candidate.id, grant.id) + .update({ lastUsedAt: new Date() }); + + return { + userId, + role, + authType: 'mcp_oauth', + mcpGrantId, + mcpClientId + }; +} diff --git a/apps/api/src/db/migrations/017_mcp_oauth.ts b/apps/api/src/db/migrations/017_mcp_oauth.ts new file mode 100644 index 0000000..0c9ecb4 --- /dev/null +++ b/apps/api/src/db/migrations/017_mcp_oauth.ts @@ -0,0 +1,117 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('mcp_oauth_clients', table => { + table.increments('id').primary(); + table.string('client_id', 80).notNullable().unique(); + table.string('client_name', 200).notNullable(); + table.text('redirect_uris_json').notNullable(); + table.string('scope', 120).notNullable().defaultTo('mcp'); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.index(['client_id'], 'idx_mcp_oauth_clients_client_id'); + }); + + await knex.schema.createTable('mcp_oauth_grants', table => { + table.increments('id').primary(); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table + .integer('client_id') + .notNullable() + .references('id') + .inTable('mcp_oauth_clients') + .onDelete('CASCADE'); + table.string('scope', 120).notNullable().defaultTo('mcp'); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.timestamp('last_used_at', { useTz: true }).nullable(); + table.timestamp('revoked_at', { useTz: true }).nullable(); + table.index(['user_id'], 'idx_mcp_oauth_grants_user_id'); + table.index(['client_id'], 'idx_mcp_oauth_grants_client_id'); + }); + + await knex.schema.createTable('mcp_oauth_authorization_codes', table => { + table.increments('id').primary(); + table.string('code_hash', 64).notNullable().unique(); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table + .integer('client_id') + .notNullable() + .references('id') + .inTable('mcp_oauth_clients') + .onDelete('CASCADE'); + table + .integer('grant_id') + .notNullable() + .references('id') + .inTable('mcp_oauth_grants') + .onDelete('CASCADE'); + table.text('redirect_uri').notNullable(); + table.string('code_challenge', 160).notNullable(); + table.string('code_challenge_method', 16).notNullable(); + table.string('scope', 120).notNullable().defaultTo('mcp'); + table.timestamp('expires_at', { useTz: true }).notNullable(); + table.timestamp('consumed_at', { useTz: true }).nullable(); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.index( + ['code_hash'], + 'idx_mcp_oauth_authorization_codes_code_hash' + ); + }); + + await knex.schema.createTable('mcp_oauth_refresh_tokens', table => { + table.increments('id').primary(); + table.string('token_hash', 64).notNullable().unique(); + table + .integer('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table + .integer('client_id') + .notNullable() + .references('id') + .inTable('mcp_oauth_clients') + .onDelete('CASCADE'); + table + .integer('grant_id') + .notNullable() + .references('id') + .inTable('mcp_oauth_grants') + .onDelete('CASCADE'); + table.timestamp('expires_at', { useTz: true }).notNullable(); + table + .timestamp('created_at', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()); + table.timestamp('last_used_at', { useTz: true }).nullable(); + table.timestamp('revoked_at', { useTz: true }).nullable(); + table.index(['token_hash'], 'idx_mcp_oauth_refresh_tokens_token_hash'); + table.index(['grant_id'], 'idx_mcp_oauth_refresh_tokens_grant_id'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('mcp_oauth_refresh_tokens'); + await knex.schema.dropTableIfExists('mcp_oauth_authorization_codes'); + await knex.schema.dropTableIfExists('mcp_oauth_grants'); + await knex.schema.dropTableIfExists('mcp_oauth_clients'); +} diff --git a/apps/api/src/db/schemas.ts b/apps/api/src/db/schemas.ts index 427828b..41d02af 100644 --- a/apps/api/src/db/schemas.ts +++ b/apps/api/src/db/schemas.ts @@ -119,6 +119,85 @@ export const ApiKeyDbSchema = object({ revokedAt: date().optional().hasColumnName('revoked_at') }).hasTableName('api_keys'); +export const McpOAuthClientDbSchema = object({ + id: number().primaryKey(), + clientId: string() + .hasColumnName('client_id') + .index('idx_mcp_oauth_clients_client_id'), + clientName: string().hasColumnName('client_name'), + redirectUrisJson: string().hasColumnName('redirect_uris_json'), + scope: string().defaultTo('mcp'), + createdAt: date().hasColumnName('created_at').defaultTo('now') +}).hasTableName('mcp_oauth_clients'); + +export const McpOAuthGrantDbSchema = object({ + id: number().primaryKey(), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE') + .index('idx_mcp_oauth_grants_user_id'), + clientId: number() + .hasColumnName('client_id') + .references('mcp_oauth_clients', 'id') + .onDelete('CASCADE') + .index('idx_mcp_oauth_grants_client_id'), + scope: string().defaultTo('mcp'), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + lastUsedAt: date().optional().hasColumnName('last_used_at'), + revokedAt: date().optional().hasColumnName('revoked_at') +}).hasTableName('mcp_oauth_grants'); + +export const McpOAuthAuthorizationCodeDbSchema = object({ + id: number().primaryKey(), + codeHash: string() + .hasColumnName('code_hash') + .index('idx_mcp_oauth_authorization_codes_code_hash'), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE'), + clientId: number() + .hasColumnName('client_id') + .references('mcp_oauth_clients', 'id') + .onDelete('CASCADE'), + grantId: number() + .hasColumnName('grant_id') + .references('mcp_oauth_grants', 'id') + .onDelete('CASCADE'), + redirectUri: string().hasColumnName('redirect_uri'), + codeChallenge: string().hasColumnName('code_challenge'), + codeChallengeMethod: string().hasColumnName('code_challenge_method'), + scope: string().defaultTo('mcp'), + expiresAt: date().hasColumnName('expires_at'), + consumedAt: date().optional().hasColumnName('consumed_at'), + createdAt: date().hasColumnName('created_at').defaultTo('now') +}).hasTableName('mcp_oauth_authorization_codes'); + +export const McpOAuthRefreshTokenDbSchema = object({ + id: number().primaryKey(), + tokenHash: string() + .hasColumnName('token_hash') + .index('idx_mcp_oauth_refresh_tokens_token_hash'), + userId: number() + .hasColumnName('user_id') + .references('users', 'id') + .onDelete('CASCADE'), + clientId: number() + .hasColumnName('client_id') + .references('mcp_oauth_clients', 'id') + .onDelete('CASCADE'), + grantId: number() + .hasColumnName('grant_id') + .references('mcp_oauth_grants', 'id') + .onDelete('CASCADE') + .index('idx_mcp_oauth_refresh_tokens_grant_id'), + expiresAt: date().hasColumnName('expires_at'), + createdAt: date().hasColumnName('created_at').defaultTo('now'), + lastUsedAt: date().optional().hasColumnName('last_used_at'), + revokedAt: date().optional().hasColumnName('revoked_at') +}).hasTableName('mcp_oauth_refresh_tokens'); + export const CategoryDbSchema = object({ id: number().primaryKey(), userId: number() @@ -297,6 +376,14 @@ export const ExternalIdentityEntity = defineEntity(ExternalIdentityDbSchema); export const TelegramAccountEntity = defineEntity(TelegramAccountDbSchema); export const TelegramLinkTokenEntity = defineEntity(TelegramLinkTokenDbSchema); export const ApiKeyEntity = defineEntity(ApiKeyDbSchema); +export const McpOAuthClientEntity = defineEntity(McpOAuthClientDbSchema); +export const McpOAuthGrantEntity = defineEntity(McpOAuthGrantDbSchema); +export const McpOAuthAuthorizationCodeEntity = defineEntity( + McpOAuthAuthorizationCodeDbSchema +); +export const McpOAuthRefreshTokenEntity = defineEntity( + McpOAuthRefreshTokenDbSchema +); export const CategoryEntity = defineEntity(CategoryDbSchema); export const VendorEntity = defineEntity(VendorDbSchema); export const TransactionEntity = defineEntity(TransactionDbSchema).belongsTo( @@ -320,6 +407,10 @@ export const entityMap = { telegramAccounts: TelegramAccountEntity, telegramLinkTokens: TelegramLinkTokenEntity, apiKeys: ApiKeyEntity, + mcpOAuthClients: McpOAuthClientEntity, + mcpOAuthGrants: McpOAuthGrantEntity, + mcpOAuthAuthorizationCodes: McpOAuthAuthorizationCodeEntity, + mcpOAuthRefreshTokens: McpOAuthRefreshTokenEntity, categories: CategoryEntity, vendors: VendorEntity, transactions: TransactionEntity, @@ -418,6 +509,52 @@ export type ApiKeyDb = { readonly revokedAt?: Date | null; }; +export type McpOAuthClientDb = { + readonly id: number; + readonly clientId: string; + readonly clientName: string; + readonly redirectUrisJson: string; + readonly scope: string; + readonly createdAt: Date; +}; + +export type McpOAuthGrantDb = { + readonly id: number; + readonly userId: number; + readonly clientId: number; + readonly scope: string; + readonly createdAt: Date; + readonly lastUsedAt?: Date | null; + readonly revokedAt?: Date | null; +}; + +export type McpOAuthAuthorizationCodeDb = { + readonly id: number; + readonly codeHash: string; + readonly userId: number; + readonly clientId: number; + readonly grantId: number; + readonly redirectUri: string; + readonly codeChallenge: string; + readonly codeChallengeMethod: string; + readonly scope: string; + readonly expiresAt: Date; + readonly consumedAt?: Date | null; + readonly createdAt: Date; +}; + +export type McpOAuthRefreshTokenDb = { + readonly id: number; + readonly tokenHash: string; + readonly userId: number; + readonly clientId: number; + readonly grantId: number; + readonly expiresAt: Date; + readonly createdAt: Date; + readonly lastUsedAt?: Date | null; + readonly revokedAt?: Date | null; +}; + export type TransactionDb = { readonly id: number; readonly userId: number; diff --git a/apps/api/src/log-templates.ts b/apps/api/src/log-templates.ts index c83cac1..2d840b5 100644 --- a/apps/api/src/log-templates.ts +++ b/apps/api/src/log-templates.ts @@ -27,9 +27,14 @@ export const McpTransportError = parseString( ); export const McpToolCalled = parseString( - object({ ToolName: string(), UserId: number(), ApiKeyId: number() }), + object({ + ToolName: string(), + UserId: number(), + CredentialType: string(), + CredentialId: string() + }), $t => - $t`MCP tool ${t => t.ToolName} called by ${t => t.UserId} using API key ${t => t.ApiKeyId}` + $t`MCP tool ${t => t.ToolName} called by ${t => t.UserId} using ${t => t.CredentialType} ${t => t.CredentialId}` ); export const FrankfurterCurrencyCatalogFallback = parseString( diff --git a/apps/api/src/mcp/auth.test.ts b/apps/api/src/mcp/auth.test.ts index d861ab8..6e317f9 100644 --- a/apps/api/src/mcp/auth.test.ts +++ b/apps/api/src/mcp/auth.test.ts @@ -1,6 +1,10 @@ import { UnauthorizedError } from '@cleverbrush/server'; import { describe, expect, it } from 'vitest'; -import { isMcpApiKeyPrincipal, requireMcpApiKeyPrincipal } from './auth.js'; +import { + isMcpApiKeyPrincipal, + isMcpPrincipal, + requireMcpApiKeyPrincipal +} from './auth.js'; describe('MCP API key principal checks', () => { it('accepts principals authenticated with a xpenser API key', () => { @@ -12,9 +16,22 @@ describe('MCP API key principal checks', () => { } as const; expect(isMcpApiKeyPrincipal(principal)).toBe(true); + expect(isMcpPrincipal(principal)).toBe(true); expect(requireMcpApiKeyPrincipal(principal)).toBe(principal); }); + it('accepts MCP OAuth principals for MCP access', () => { + expect( + isMcpPrincipal({ + userId: 1, + role: 'user', + authType: 'mcp_oauth', + mcpGrantId: 12, + mcpClientId: 'client-1' + } as never) + ).toBe(true); + }); + it('rejects JWT and missing API key principals', () => { expect( isMcpApiKeyPrincipal({ @@ -23,6 +40,13 @@ describe('MCP API key principal checks', () => { authType: 'jwt' }) ).toBe(false); + expect( + isMcpPrincipal({ + userId: 1, + role: 'user', + authType: 'jwt' + }) + ).toBe(false); expect(() => requireMcpApiKeyPrincipal({ diff --git a/apps/api/src/mcp/auth.ts b/apps/api/src/mcp/auth.ts index b8aa609..3f706f2 100644 --- a/apps/api/src/mcp/auth.ts +++ b/apps/api/src/mcp/auth.ts @@ -1,11 +1,20 @@ import { UnauthorizedError } from '@cleverbrush/server'; import type { Principal } from '@xpenser/contracts'; +import { authenticateApiKey, parseApiKey } from '../application/api-keys.js'; +import { + authenticateMcpOAuthAccessToken, + type McpOAuthAccessPrincipal +} from '../application/mcp-oauth.js'; +import type { Config } from '../config.js'; +import type { AppDb } from '../db/schemas.js'; export type McpApiKeyPrincipal = Principal & { readonly authType: 'api_key'; readonly apiKeyId: number; }; +export type McpPrincipal = McpApiKeyPrincipal | McpOAuthAccessPrincipal; + export function isMcpApiKeyPrincipal( principal: Principal ): principal is McpApiKeyPrincipal { @@ -24,3 +33,66 @@ export function requireMcpApiKeyPrincipal( return principal; } + +export function isMcpPrincipal( + principal: Principal +): principal is McpPrincipal { + return ( + isMcpApiKeyPrincipal(principal) || principal.authType === 'mcp_oauth' + ); +} + +function bearerToken(headers: Record): string | undefined { + const header = headers.authorization; + if (!header?.startsWith('Bearer ')) { + return undefined; + } + const token = header.slice(7).trim(); + return token || undefined; +} + +function headerApiKey(headers: Record): string | undefined { + const value = headers['x-api-key']?.trim(); + return value || undefined; +} + +export async function authenticateMcpPrincipal({ + config, + db, + headers +}: { + readonly config: Config; + readonly db: AppDb; + readonly headers: Record; +}): Promise { + const explicitApiKey = headerApiKey(headers); + if (explicitApiKey) { + const principal = await authenticateApiKey(db, explicitApiKey); + return principal + ? { + userId: principal.userId, + role: principal.role, + authType: 'api_key', + apiKeyId: principal.apiKeyId + } + : undefined; + } + + const token = bearerToken(headers); + if (!token) { + return undefined; + } + if (parseApiKey(token)) { + const principal = await authenticateApiKey(db, token); + return principal + ? { + userId: principal.userId, + role: principal.role, + authType: 'api_key', + apiKeyId: principal.apiKeyId + } + : undefined; + } + + return authenticateMcpOAuthAccessToken(db, config, token); +} diff --git a/apps/api/src/mcp/endpoint.ts b/apps/api/src/mcp/endpoint.ts index 078373c..ee75b96 100644 --- a/apps/api/src/mcp/endpoint.ts +++ b/apps/api/src/mcp/endpoint.ts @@ -1,14 +1,12 @@ import { ActionResult, endpoint, type Handler } from '@cleverbrush/server'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { PrincipalSchema } from '@xpenser/contracts'; import { ConfigToken, DbToken, KnexToken, LoggerToken } from '../di/tokens.js'; import { McpTransportError } from '../log-templates.js'; -import { requireMcpApiKeyPrincipal } from './auth.js'; +import { authenticateMcpPrincipal } from './auth.js'; import { createXpenserMcpServer } from './server.js'; export const McpEndpoint = endpoint .post('/api/mcp') - .authorize(PrincipalSchema) .inject({ config: ConfigToken, db: DbToken, @@ -21,17 +19,25 @@ export const McpEndpoint = endpoint .operationId('xpenserMcp'); export const mcpHandler: Handler = async ( - { context, principal }, + { context }, { config, db, knex, logger } ) => { - let apiKeyPrincipal: ReturnType; - try { - apiKeyPrincipal = requireMcpApiKeyPrincipal(principal); - } catch (err) { - if (err instanceof Error) { - return ActionResult.unauthorized({ message: err.message }); - } - throw err; + const principal = await authenticateMcpPrincipal({ + config, + db, + headers: context.headers + }); + if (!principal) { + context.response.setHeader( + 'WWW-Authenticate', + `Bearer resource_metadata="${new URL( + '/.well-known/oauth-protected-resource/external-api/mcp', + config.app.url + ).toString()}"` + ); + return ActionResult.unauthorized({ + message: 'MCP access requires a xpenser API key or MCP OAuth token.' + }); } const mcpServer = createXpenserMcpServer({ @@ -39,7 +45,7 @@ export const mcpHandler: Handler = async ( db, knex, logger, - principal: apiKeyPrincipal + principal }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined @@ -47,7 +53,7 @@ export const mcpHandler: Handler = async ( transport.onerror = error => { logger.error(error, McpTransportError, { - UserId: apiKeyPrincipal.userId + UserId: principal.userId }); }; context.response.on('close', () => { diff --git a/apps/api/src/mcp/oauth-endpoints.ts b/apps/api/src/mcp/oauth-endpoints.ts new file mode 100644 index 0000000..2b135e2 --- /dev/null +++ b/apps/api/src/mcp/oauth-endpoints.ts @@ -0,0 +1,214 @@ +import { endpoint, type Handler } from '@cleverbrush/server'; +import { + exchangeMcpOAuthToken, + type McpOAuthTokenRequest, + OAuthError, + registerMcpOAuthClient +} from '../application/mcp-oauth.js'; +import { ConfigToken, DbToken } from '../di/tokens.js'; + +export const OAuthProtectedResourceEndpoint = endpoint + .get('/.well-known/oauth-protected-resource') + .inject({ config: ConfigToken }) + .summary('MCP OAuth protected resource metadata') + .description( + 'Returns OAuth protected resource metadata for the MCP server.' + ) + .tags('mcp') + .operationId('mcpOAuthProtectedResource'); + +export const OAuthProtectedResourceMcpEndpoint = endpoint + .get('/.well-known/oauth-protected-resource/external-api/mcp') + .inject({ config: ConfigToken }) + .summary('MCP OAuth protected resource metadata') + .description( + 'Returns OAuth protected resource metadata for the MCP server.' + ) + .tags('mcp') + .operationId('mcpOAuthProtectedResourceForMcp'); + +export const OAuthAuthorizationServerEndpoint = endpoint + .get('/.well-known/oauth-authorization-server') + .inject({ config: ConfigToken }) + .summary('MCP OAuth authorization server metadata') + .description('Returns OAuth authorization server metadata for MCP clients.') + .tags('mcp') + .operationId('mcpOAuthAuthorizationServer'); + +export const OAuthClientRegistrationEndpoint = endpoint + .post('/api/oauth/register') + .inject({ db: DbToken }) + .summary('MCP OAuth client registration') + .description('Registers a public MCP OAuth client using DCR.') + .tags('mcp') + .operationId('mcpOAuthClientRegistration'); + +export const OAuthTokenEndpoint = endpoint + .post('/api/oauth/token') + .inject({ config: ConfigToken, db: DbToken }) + .summary('MCP OAuth token') + .description('Exchanges MCP OAuth authorization codes and refresh tokens.') + .tags('mcp') + .operationId('mcpOAuthToken'); + +type RawContext = Parameters>[0]['context']; + +function jsonResponse( + context: RawContext, + status: number, + body: unknown, + headers: Record = {} +) { + context.response.writeHead(status, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + ...headers + }); + context.response.end(JSON.stringify(body)); + context.responded = true; + return undefined; +} + +async function readBody(context: RawContext): Promise { + const chunks: Buffer[] = []; + for await (const chunk of context.request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +async function readJsonBody(context: RawContext): Promise { + const raw = await readBody(context); + if (raw.trim() === '') { + return {}; + } + return JSON.parse(raw) as unknown; +} + +async function readTokenBody( + context: RawContext +): Promise { + const raw = await readBody(context); + const contentType = context.headers['content-type'] ?? ''; + if (contentType.includes('application/json')) { + return JSON.parse(raw || '{}') as McpOAuthTokenRequest; + } + + const params = new URLSearchParams(raw); + return { + grant_type: params.get('grant_type') ?? undefined, + client_id: params.get('client_id') ?? undefined, + code: params.get('code') ?? undefined, + redirect_uri: params.get('redirect_uri') ?? undefined, + code_verifier: params.get('code_verifier') ?? undefined, + refresh_token: params.get('refresh_token') ?? undefined + }; +} + +function protectedResourceMetadata(appUrl: string) { + return { + resource: new URL('/external-api/mcp', appUrl).toString(), + authorization_servers: [appUrl], + bearer_methods_supported: ['header'], + scopes_supported: ['mcp'], + resource_name: 'xpenser MCP' + }; +} + +export const oauthProtectedResourceHandler: Handler< + typeof OAuthProtectedResourceEndpoint +> = async ({ context }, { config }) => { + return jsonResponse( + context, + 200, + protectedResourceMetadata(config.app.url) + ); +}; + +export const oauthProtectedResourceMcpHandler: Handler< + typeof OAuthProtectedResourceMcpEndpoint +> = async ({ context }, { config }) => { + return jsonResponse( + context, + 200, + protectedResourceMetadata(config.app.url) + ); +}; + +export const oauthAuthorizationServerHandler: Handler< + typeof OAuthAuthorizationServerEndpoint +> = async ({ context }, { config }) => { + const appUrl = config.app.url; + return jsonResponse(context, 200, { + issuer: appUrl, + authorization_endpoint: new URL( + '/mcp/oauth/authorize', + appUrl + ).toString(), + token_endpoint: new URL('/external-api/oauth/token', appUrl).toString(), + registration_endpoint: new URL( + '/external-api/oauth/register', + appUrl + ).toString(), + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['none'], + code_challenge_methods_supported: ['S256'], + scopes_supported: ['mcp'] + }); +}; + +export const oauthClientRegistrationHandler: Handler< + typeof OAuthClientRegistrationEndpoint +> = async ({ context }, { db }) => { + try { + const body = await readJsonBody(context); + return jsonResponse( + context, + 201, + await registerMcpOAuthClient(db, body as never) + ); + } catch (err) { + if (err instanceof OAuthError) { + return jsonResponse(context, err.status, { + error: err.error, + error_description: err.message + }); + } + if (err instanceof SyntaxError) { + return jsonResponse(context, 400, { + error: 'invalid_request', + error_description: 'Request body must be valid JSON.' + }); + } + throw err; + } +}; + +export const oauthTokenHandler: Handler = async ( + { context }, + { config, db } +) => { + try { + const body = await readTokenBody(context); + return jsonResponse( + context, + 200, + await exchangeMcpOAuthToken(db, config, body) + ); + } catch (err) { + if (err instanceof OAuthError) { + return jsonResponse(context, err.status, { + error: err.error, + error_description: err.message + }); + } + if (err instanceof SyntaxError) { + return jsonResponse(context, 400, { + error: 'invalid_request', + error_description: 'Request body could not be parsed.' + }); + } + throw err; + } +}; diff --git a/apps/api/src/mcp/server.ts b/apps/api/src/mcp/server.ts index 9f9a36d..11b882a 100644 --- a/apps/api/src/mcp/server.ts +++ b/apps/api/src/mcp/server.ts @@ -3,7 +3,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import type { Knex } from 'knex'; import type { Config } from '../config.js'; import type { AppDb } from '../db/schemas.js'; -import type { McpApiKeyPrincipal } from './auth.js'; +import type { McpPrincipal } from './auth.js'; import { createXpenserMcpDataAccess, registerXpenserMcpTools @@ -14,7 +14,7 @@ export type XpenserMcpServerOptions = { readonly db: AppDb; readonly knex: Knex; readonly logger: Logger; - readonly principal: McpApiKeyPrincipal; + readonly principal: McpPrincipal; }; export function createXpenserMcpServer({ diff --git a/apps/api/src/mcp/tools.test.ts b/apps/api/src/mcp/tools.test.ts index 5879d46..73babad 100644 --- a/apps/api/src/mcp/tools.test.ts +++ b/apps/api/src/mcp/tools.test.ts @@ -11,7 +11,7 @@ import type { import { beforeEach, describe, expect, it, vi } from 'vitest'; import { TransactionCategoryError } from '../application/transactions.js'; import { VendorNameError } from '../application/vendors.js'; -import type { McpApiKeyPrincipal } from './auth.js'; +import type { McpPrincipal } from './auth.js'; import { createXpenserMcpTools, handleCreateCategory, @@ -35,7 +35,7 @@ import { type XpenserMcpToolContext } from './tools.js'; -const principal: McpApiKeyPrincipal = { +const principal: McpPrincipal = { userId: 7, role: 'user', authType: 'api_key', diff --git a/apps/api/src/mcp/tools.ts b/apps/api/src/mcp/tools.ts index a9f949a..aad7427 100644 --- a/apps/api/src/mcp/tools.ts +++ b/apps/api/src/mcp/tools.ts @@ -81,7 +81,7 @@ import { TransactionCreated, VendorUpdateValidationRejected } from '../log-templates.js'; -import type { McpApiKeyPrincipal } from './auth.js'; +import type { McpPrincipal } from './auth.js'; type JsonValue = | string @@ -198,7 +198,7 @@ export type XpenserMcpDataAccess = { }; export type XpenserMcpToolContext = { - readonly principal: McpApiKeyPrincipal; + readonly principal: McpPrincipal; readonly data: XpenserMcpDataAccess; readonly logger: Pick; }; @@ -834,7 +834,11 @@ function logToolCall(context: XpenserMcpToolContext, toolName: string): void { context.logger.info(McpToolCalled, { ToolName: toolName, UserId: context.principal.userId, - ApiKeyId: context.principal.apiKeyId + CredentialType: context.principal.authType, + CredentialId: + context.principal.authType === 'api_key' + ? String(context.principal.apiKeyId) + : context.principal.mcpClientId }); } diff --git a/apps/api/src/security/api-auth.ts b/apps/api/src/security/api-auth.ts index 91dad90..91b6737 100644 --- a/apps/api/src/security/api-auth.ts +++ b/apps/api/src/security/api-auth.ts @@ -105,7 +105,19 @@ export function xpenserAuthScheme( return authenticateKey(token); } - return jwt.authenticate(context); + const result = await jwt.authenticate(context); + if ( + result.succeeded && + result.principal.hasClaim('auth_type', 'mcp_oauth') + ) { + return { + succeeded: false, + failure: + 'MCP OAuth tokens are only accepted by the MCP endpoint' + }; + } + + return result; }, challenge() { return { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6b45946..aec7eb9 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -12,6 +12,18 @@ import { handlers } from './api/handlers/index.js'; import type { Config } from './config.js'; import { configureDI, type DbResources } from './di/setup.js'; import { McpEndpoint, mcpHandler } from './mcp/endpoint.js'; +import { + OAuthAuthorizationServerEndpoint, + OAuthClientRegistrationEndpoint, + OAuthProtectedResourceEndpoint, + OAuthProtectedResourceMcpEndpoint, + OAuthTokenEndpoint, + oauthAuthorizationServerHandler, + oauthClientRegistrationHandler, + oauthProtectedResourceHandler, + oauthProtectedResourceMcpHandler, + oauthTokenHandler +} from './mcp/oauth-endpoints.js'; import { xpenserAuthScheme } from './security/api-auth.js'; /** @@ -110,6 +122,23 @@ export function buildServer( // reach `serveOpenApi()`. server.handle(openApi.endpoint, openApi.handler); + server.handle( + OAuthProtectedResourceEndpoint, + oauthProtectedResourceHandler + ); + server.handle( + OAuthProtectedResourceMcpEndpoint, + oauthProtectedResourceMcpHandler + ); + server.handle( + OAuthAuthorizationServerEndpoint, + oauthAuthorizationServerHandler + ); + server.handle( + OAuthClientRegistrationEndpoint, + oauthClientRegistrationHandler + ); + server.handle(OAuthTokenEndpoint, oauthTokenHandler); server.handle(McpEndpoint, mcpHandler); server.handleAll(mapHandlers(endpoints, handlers)); diff --git a/apps/web/app/(app)/settings/preferences/page.tsx b/apps/web/app/(app)/settings/preferences/page.tsx index 701b650..59e21c9 100644 --- a/apps/web/app/(app)/settings/preferences/page.tsx +++ b/apps/web/app/(app)/settings/preferences/page.tsx @@ -16,17 +16,20 @@ import { disconnectTelegramAction } from '@/lib/actions'; import { getApiClient } from '@/lib/api'; +import { publicAppUrl } from '@/lib/public-url'; export const dynamic = 'force-dynamic'; export default async function PreferencesPage() { const client = await getApiClient(); - const [me, currencies, telegram, apiKeys] = await Promise.all([ - client.auth.me(), - client.currencies.list(), - client.users.telegramStatus(), - client.users.listApiKeys() - ]); + const [me, currencies, telegram, apiKeys, mcpConnections] = + await Promise.all([ + client.auth.me(), + client.currencies.list(), + client.users.telegramStatus(), + client.users.listApiKeys(), + client.users.listMcpOAuthConnections() + ]); const telegramName = telegram.telegramUsername ? `@${telegram.telegramUsername}` : [telegram.telegramFirstName, telegram.telegramLastName] @@ -142,13 +145,17 @@ export default async function PreferencesPage() { - API keys + API keys and MCP - Use API keys from scripts and external tools. + Connect scripts and AI clients to your xpenser account. - + diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 354da06..82cf1b4 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -13,8 +13,24 @@ import { getGoogleSignInProvider } from '@/lib/config'; export const dynamic = 'force-dynamic'; -export default function LoginPage() { +function safeCallback(value: string | string[] | undefined) { + const candidate = Array.isArray(value) ? value[0] : value; + if (!candidate?.startsWith('/') || candidate.startsWith('//')) { + return undefined; + } + return candidate; +} + +export default async function LoginPage({ + searchParams +}: { + readonly searchParams?: Promise<{ + readonly callbackUrl?: string | string[]; + }>; +}) { + const params = searchParams ? await searchParams : {}; const googleSignInEnabled = getGoogleSignInProvider() !== 'disabled'; + const redirectTo = safeCallback(params.callbackUrl); return (
@@ -24,9 +40,16 @@ export default function LoginPage() { Sign in to continue. - + {googleSignInEnabled ? (
+ {redirectTo ? ( + + ) : null} +
+
+ + + + +
+ +
+ ); +} diff --git a/apps/web/components/api-keys-settings.test.tsx b/apps/web/components/api-keys-settings.test.tsx new file mode 100644 index 0000000..4e83f69 --- /dev/null +++ b/apps/web/components/api-keys-settings.test.tsx @@ -0,0 +1,48 @@ +/** + * @vitest-environment jsdom + */ + +import { render, screen } from '@testing-library/react'; +import type { McpOAuthConnection } from '@xpenser/contracts'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiKeysSettings } from './api-keys-settings'; + +vi.mock('@/lib/actions', () => ({ + createApiKeyAction: vi.fn(), + revokeApiKeyAction: vi.fn(), + revokeMcpOAuthConnectionAction: vi.fn() +})); + +const connection: McpOAuthConnection = { + id: 1, + clientId: 'xpenser_mcp_client', + clientName: 'Codex', + createdAt: new Date('2026-06-01T12:00:00.000Z'), + lastUsedAt: new Date('2026-06-02T12:00:00.000Z') +}; + +describe('ApiKeysSettings', () => { + it('renders MCP setup instructions and active OAuth connections', () => { + render( + + ); + + expect(screen.getByText('MCP server')).toBeTruthy(); + expect( + screen.getAllByText(/\/external-api\/mcp/).length + ).toBeGreaterThan(0); + expect(screen.getByText('Claude')).toBeTruthy(); + expect(screen.getAllByText('Codex').length).toBeGreaterThan(0); + expect(screen.getByText('Cursor')).toBeTruthy(); + expect(screen.getByText('API key fallback')).toBeTruthy(); + expect(screen.getByText(/\[mcp_servers\.xpenser\]/)).toBeTruthy(); + expect( + screen.getAllByText(/"type": "streamable-http"/).length + ).toBeGreaterThan(0); + expect(screen.getByText('xpenser_mcp_client')).toBeTruthy(); + }); +}); diff --git a/apps/web/components/api-keys-settings.tsx b/apps/web/components/api-keys-settings.tsx index 91dfd7a..731305c 100644 --- a/apps/web/components/api-keys-settings.tsx +++ b/apps/web/components/api-keys-settings.tsx @@ -3,7 +3,8 @@ import { type ApiKey, type CreateApiKeyResponse, - FieldLimits + FieldLimits, + type McpOAuthConnection } from '@xpenser/contracts'; import { Badge, @@ -18,24 +19,141 @@ import { CheckIcon, ClipboardIcon, KeyRoundIcon, + PlugZapIcon, Trash2Icon } from 'lucide-react'; -import { type FormEvent, useState } from 'react'; -import { createApiKeyAction, revokeApiKeyAction } from '@/lib/actions'; +import { type FormEvent, useEffect, useMemo, useState } from 'react'; +import { + createApiKeyAction, + revokeApiKeyAction, + revokeMcpOAuthConnectionAction +} from '@/lib/actions'; import { formatDateTime } from '@/lib/format'; type ApiKeysSettingsProps = { readonly apiKeys: readonly ApiKey[]; + readonly mcpConnections: readonly McpOAuthConnection[]; + readonly mcpUrl: string; }; -export function ApiKeysSettings({ apiKeys }: ApiKeysSettingsProps) { +const apiKeyAuthorizationHeader = 'Bearer $' + '{XPENSER_API_KEY}'; + +function CopyButton({ + copiedLabel = 'Copied', + text +}: { + readonly copiedLabel?: string; + readonly text: string; +}) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await navigator.clipboard.writeText(text); + setCopied(true); + } + + return ( + + ); +} + +function Snippet({ + label, + value +}: { + readonly label: string; + readonly value: string; +}) { + return ( +
+
+
{label}
+ +
+
+                {value}
+            
+
+ ); +} + +export function ApiKeysSettings({ + apiKeys, + mcpConnections, + mcpUrl +}: ApiKeysSettingsProps) { const [keys, setKeys] = useState(apiKeys); + const [connections, setConnections] = + useState(mcpConnections); const [created, setCreated] = useState(null); const [name, setName] = useState(''); const [error, setError] = useState(null); const [pendingCreate, setPendingCreate] = useState(false); const [pendingRevokeId, setPendingRevokeId] = useState(null); + const [pendingConnectionRevokeId, setPendingConnectionRevokeId] = useState< + number | null + >(null); const [copied, setCopied] = useState(false); + const [currentMcpUrl, setCurrentMcpUrl] = useState(mcpUrl); + + useEffect(() => { + setConnections(mcpConnections); + }, [mcpConnections]); + + useEffect(() => { + setKeys(apiKeys); + }, [apiKeys]); + + useEffect(() => { + setCurrentMcpUrl(`${window.location.origin}/external-api/mcp`); + }, []); + + const codexSnippet = useMemo( + () => `[mcp_servers.xpenser]\nurl = "${currentMcpUrl}"`, + [currentMcpUrl] + ); + const cursorSnippet = useMemo( + () => + JSON.stringify( + { + mcpServers: { + xpenser: { + type: 'streamable-http', + url: currentMcpUrl + } + } + }, + null, + 2 + ), + [currentMcpUrl] + ); + const apiKeySnippet = useMemo( + () => + JSON.stringify( + { + mcpServers: { + xpenser: { + type: 'streamable-http', + url: currentMcpUrl, + headers: { + Authorization: apiKeyAuthorizationHeader + } + } + } + }, + null, + 2 + ), + [currentMcpUrl] + ); async function handleCreate(event: FormEvent) { event.preventDefault(); @@ -92,8 +210,149 @@ export function ApiKeysSettings({ apiKeys }: ApiKeysSettingsProps) { } } + async function handleConnectionRevoke(connectionId: number) { + const formData = new FormData(); + formData.set('id', String(connectionId)); + setPendingConnectionRevokeId(connectionId); + setError(null); + try { + await revokeMcpOAuthConnectionAction(formData); + setConnections(current => + current.filter(connection => connection.id !== connectionId) + ); + } catch { + setError('Could not revoke MCP connection.'); + } finally { + setPendingConnectionRevokeId(null); + } + } + return ( -
+
+
+
+
+
+
+ + MCP server +
+

+ Connect AI clients to your vendors, categories, + transactions, dashboards, and reports. +

+

+ {currentMcpUrl} +

+
+ +
+
+ +
+
+
Claude
+

+ Add a custom connector in Claude settings and use + the MCP URL above. Claude will open the xpenser + approval screen. +

+
+
+
Codex
+

+ Add this to{' '} + + ~/.codex/config.toml + + , then run{' '} + + codex mcp login xpenser + + . +

+
+ +
+
Cursor
+

+ Add this to{' '} + .cursor/mcp.json{' '} + or{' '} + + ~/.cursor/mcp.json + + , then authenticate from Cursor if prompted. +

+
+ +
+
API key fallback
+

+ Use this only for clients that support custom bearer + headers but not MCP OAuth. +

+
+ +
+ +
+ {connections.length === 0 ? ( +
+ No MCP OAuth connections. +
+ ) : ( + connections.map(connection => ( +
+
+
+ {connection.clientName} +
+

+ {connection.clientId} +

+

+ Connected{' '} + {formatDateTime(connection.createdAt)} + {connection.lastUsedAt + ? ` - Last used ${formatDateTime( + connection.lastUsedAt + )}` + : ''} +

+
+ +
+ )) + )} +
+
+
diff --git a/apps/web/components/forms/login-form.tsx b/apps/web/components/forms/login-form.tsx index 435f123..4ef69e7 100644 --- a/apps/web/components/forms/login-form.tsx +++ b/apps/web/components/forms/login-form.tsx @@ -8,7 +8,11 @@ import { loginAction } from '@/lib/actions'; import { isNextRedirectError, valuesToFormData } from './form-utils'; import { ResendEmailConfirmationForm } from './resend-email-confirmation-form'; -export function LoginForm() { +export function LoginForm({ + redirectTo +}: { + readonly redirectTo?: string; +} = {}) { const form = useSchemaForm(LoginBodySchema); const [error, setError] = useState(null); const [pending, setPending] = useState(false); @@ -26,7 +30,9 @@ export function LoginForm() { setError(null); setUnverifiedEmail(null); try { - const response = await loginAction(valuesToFormData(result.object)); + const response = await loginAction( + valuesToFormData({ ...result.object, redirectTo }) + ); if (response && 'error' in response && response.error) { setError(response.error); setUnverifiedEmail( @@ -49,6 +55,13 @@ export function LoginForm() {
+ {redirectTo ? ( + + ) : null} { api.users.listApiKeys, api.users.createApiKey, api.users.revokeApiKey, + api.users.listMcpOAuthConnections, + api.users.revokeMcpOAuthConnection, + api.oauth.authorizationRequest, + api.oauth.authorize, api.categories.list, api.categories.create, api.categories.update, diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 7132a08..b44fb7e 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -27,6 +27,11 @@ import { LinkTelegramAccountBodySchema, LinkTelegramAccountResponseSchema, LoginBodySchema, + McpOAuthAuthorizationQuerySchema, + McpOAuthAuthorizationRequestSchema, + McpOAuthAuthorizeBodySchema, + McpOAuthAuthorizeResponseSchema, + McpOAuthConnectionSchema, MoveAndDeleteCategoryBodySchema, PassportExchangeBodySchema, PassportResolveUserBodySchema, @@ -97,6 +102,9 @@ const stats = endpoint.resource('/api/stats').authorize(PrincipalSchema); const apiKeys = endpoint .resource('/api/users/me/api-keys') .authorize(PrincipalSchema); +const mcpConnections = endpoint + .resource('/api/users/me/mcp-connections') + .authorize(PrincipalSchema); /** * Public xpenser HTTP API contract. @@ -234,6 +242,41 @@ export const api = defineApi({ 204: null, 401: ErrorResponseSchema, 404: ErrorResponseSchema + }), + listMcpOAuthConnections: mcpConnections + .get() + .cacheTag('mcp-connections') + .responses({ + 200: array(McpOAuthConnectionSchema), + 401: ErrorResponseSchema + }), + revokeMcpOAuthConnection: mcpConnections + .delete(ById) + .clearsCacheTag('mcp-connections') + .responses({ + 204: null, + 401: ErrorResponseSchema, + 404: ErrorResponseSchema + }) + }, + oauth: { + authorizationRequest: endpoint + .get('/api/oauth/authorize-request') + .authorize(PrincipalSchema) + .query(McpOAuthAuthorizationQuerySchema) + .responses({ + 200: McpOAuthAuthorizationRequestSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema + }), + authorize: endpoint + .post('/api/oauth/authorize') + .authorize(PrincipalSchema) + .body(McpOAuthAuthorizeBodySchema) + .responses({ + 200: McpOAuthAuthorizeResponseSchema, + 400: ErrorResponseSchema, + 401: ErrorResponseSchema }) }, telegram: { diff --git a/packages/contracts/src/schemas.ts b/packages/contracts/src/schemas.ts index 46dc268..300bf0a 100644 --- a/packages/contracts/src/schemas.ts +++ b/packages/contracts/src/schemas.ts @@ -493,6 +493,90 @@ export const CreateApiKeyResponseSchema = object({ apiKey: ApiKeySchema }).schemaName('CreateApiKeyResponse'); +export const McpOAuthConnectionSchema = object({ + /** Unique MCP OAuth connection identifier. */ + id: number().describe('Unique MCP OAuth connection identifier.'), + /** MCP client identifier issued during registration. */ + clientId: string().describe( + 'MCP client identifier issued during registration.' + ), + /** Client-provided name shown to the user. */ + clientName: string().describe('Client-provided name shown to the user.'), + /** Creation timestamp. */ + createdAt: date().coerce().describe('Creation timestamp.'), + /** Last time this connection authenticated MCP access, when available. */ + lastUsedAt: date() + .coerce() + .optional() + .describe( + 'Last time this connection authenticated MCP access, when available.' + ) +}).schemaName('McpOAuthConnection'); + +const McpOAuthAuthorizationFields = { + /** OAuth response type. xpenser supports authorization code only. */ + response_type: string() + .required('response_type is required') + .nonempty('response_type is required') + .describe('OAuth response type. xpenser supports code only.'), + /** Registered MCP OAuth client identifier. */ + client_id: string() + .required('client_id is required') + .nonempty('client_id is required') + .describe('Registered MCP OAuth client identifier.'), + /** Redirect URI registered by the MCP client. */ + redirect_uri: string() + .required('redirect_uri is required') + .nonempty('redirect_uri is required') + .describe('Redirect URI registered by the MCP client.'), + /** PKCE S256 code challenge. */ + code_challenge: string() + .required('code_challenge is required') + .nonempty('code_challenge is required') + .describe('PKCE S256 code challenge.'), + /** PKCE challenge method. xpenser requires S256. */ + code_challenge_method: string() + .required('code_challenge_method is required') + .nonempty('code_challenge_method is required') + .describe('PKCE challenge method. xpenser requires S256.'), + /** Opaque client state returned unchanged to the redirect URI. */ + state: string() + .optional() + .describe( + 'Opaque client state returned unchanged to the redirect URI.' + ), + /** Requested OAuth scope. xpenser supports the mcp scope. */ + scope: string() + .optional() + .describe('Requested OAuth scope. xpenser supports the mcp scope.') +}; + +export const McpOAuthAuthorizationQuerySchema = object( + McpOAuthAuthorizationFields +).schemaName('McpOAuthAuthorizationQuery'); + +export const McpOAuthAuthorizationRequestSchema = object({ + /** Client-provided name shown to the user. */ + clientName: string().describe('Client-provided name shown to the user.'), + /** Redirect URI that will receive the authorization code. */ + redirectUri: string().describe( + 'Redirect URI that will receive the authorization code.' + ), + /** OAuth scope that will be granted. */ + scope: string().describe('OAuth scope that will be granted.') +}).schemaName('McpOAuthAuthorizationRequest'); + +export const McpOAuthAuthorizeBodySchema = object( + McpOAuthAuthorizationFields +).schemaName('McpOAuthAuthorizeBody'); + +export const McpOAuthAuthorizeResponseSchema = object({ + /** Redirect URL containing either the authorization code or OAuth error. */ + redirectUrl: string().describe( + 'Redirect URL containing either the authorization code or OAuth error.' + ) +}).schemaName('McpOAuthAuthorizeResponse'); + export const UpdateUserPreferenceBodySchema = object({ /** Default currency used for reports and new transactions. */ defaultCurrency: CurrencyCodeSchema.describe( @@ -2101,6 +2185,19 @@ export type UserPreference = InferType; export type ApiKey = InferType; export type CreateApiKeyBody = InferType; export type CreateApiKeyResponse = InferType; +export type McpOAuthConnection = InferType; +export type McpOAuthAuthorizationQuery = InferType< + typeof McpOAuthAuthorizationQuerySchema +>; +export type McpOAuthAuthorizationRequest = InferType< + typeof McpOAuthAuthorizationRequestSchema +>; +export type McpOAuthAuthorizeBody = InferType< + typeof McpOAuthAuthorizeBodySchema +>; +export type McpOAuthAuthorizeResponse = InferType< + typeof McpOAuthAuthorizeResponseSchema +>; export type TelegramConnectionStatus = InferType< typeof TelegramConnectionStatusSchema >; diff --git a/tests/e2e/workflows.spec.ts b/tests/e2e/workflows.spec.ts index 27bef6d..267d669 100644 --- a/tests/e2e/workflows.spec.ts +++ b/tests/e2e/workflows.spec.ts @@ -93,6 +93,8 @@ test.describe('authenticated app workflows', () => { await expect( page.getByRole('heading', { name: 'User preferences' }) ).toBeVisible(); + await expect(page.getByText('MCP server')).toBeVisible(); + await expect(page.getByText('/external-api/mcp')).toBeVisible(); }); test('creates categories and manages a transaction lifecycle', async ({ From e87d0cdfdc985eb57d249cd742f93f9348c59d43 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 6 Jun 2026 06:42:03 +0000 Subject: [PATCH 2/3] Fix MCP settings e2e assertion --- tests/e2e/workflows.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/workflows.spec.ts b/tests/e2e/workflows.spec.ts index 267d669..40b5ef7 100644 --- a/tests/e2e/workflows.spec.ts +++ b/tests/e2e/workflows.spec.ts @@ -94,7 +94,7 @@ test.describe('authenticated app workflows', () => { page.getByRole('heading', { name: 'User preferences' }) ).toBeVisible(); await expect(page.getByText('MCP server')).toBeVisible(); - await expect(page.getByText('/external-api/mcp')).toBeVisible(); + await expect(page.getByText('/external-api/mcp').first()).toBeVisible(); }); test('creates categories and manages a transaction lifecycle', async ({ From bb82fa99bdf93db266df00e10dd2fd45ea244416 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 6 Jun 2026 06:57:15 +0000 Subject: [PATCH 3/3] Use schema validation for MCP OAuth string arrays --- apps/api/src/application/mcp-oauth.ts | 38 ++++++++++++++------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/api/src/application/mcp-oauth.ts b/apps/api/src/application/mcp-oauth.ts index f101e72..4436097 100644 --- a/apps/api/src/application/mcp-oauth.ts +++ b/apps/api/src/application/mcp-oauth.ts @@ -1,5 +1,6 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; import { jwtScheme, signJwt } from '@cleverbrush/auth'; +import { array, string } from '@cleverbrush/schema'; import type { McpOAuthAuthorizationQuery, McpOAuthAuthorizationRequest, @@ -20,6 +21,7 @@ const mcpScope = 'mcp'; const accessTokenTtlSeconds = 60 * 60; const authorizationCodeTtlSeconds = 10 * 60; const refreshTokenTtlSeconds = 30 * 24 * 60 * 60; +const StringArraySchema = array(string()); type DynamicClientRegistrationRequest = { readonly client_name?: unknown; @@ -95,15 +97,18 @@ function tokenExpiresAt(ttlSeconds: number, now = new Date()): Date { return new Date(now.getTime() + ttlSeconds * 1000); } -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every(item => typeof item === 'string') - ); +function redirectUris(client: McpOAuthClientDb): string[] { + return parseStringArray(JSON.parse(client.redirectUrisJson)); } -function redirectUris(client: McpOAuthClientDb): string[] { - const parsed = JSON.parse(client.redirectUrisJson) as unknown; - return isStringArray(parsed) ? parsed : []; +function parseStringArray(value: unknown): string[] { + const result = StringArraySchema.safeParse(value); + return result.valid ? (result.object ?? []) : []; +} + +function stringArray(value: unknown): string[] | undefined { + const result = StringArraySchema.safeParse(value); + return result.valid ? (result.object ?? []) : undefined; } function oauthClientDisplayName(value: unknown): string { @@ -167,18 +172,14 @@ function requireSupportedRegistration(body: DynamicClientRegistrationRequest) { ); } if ( - isStringArray(body.grant_types) && - !body.grant_types.includes('authorization_code') + stringArray(body.grant_types)?.includes('authorization_code') === false ) { throw new OAuthError( 'invalid_client_metadata', 'authorization_code grant type is required.' ); } - if ( - isStringArray(body.response_types) && - !body.response_types.includes('code') - ) { + if (stringArray(body.response_types)?.includes('code') === false) { throw new OAuthError( 'invalid_client_metadata', 'code response type is required.' @@ -360,14 +361,15 @@ export async function registerMcpOAuthClient( ): Promise { requireSupportedRegistration(body); const scope = normalizeScope(body.scope); - if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) { + const redirectUris = stringArray(body.redirect_uris); + if (!redirectUris || redirectUris.length === 0) { throw new OAuthError( 'invalid_client_metadata', 'At least one redirect URI is required.' ); } - const redirectUris = Array.from(new Set(body.redirect_uris)); - if (redirectUris.some(uri => !allowedRedirectUri(uri))) { + const uniqueRedirectUris = Array.from(new Set(redirectUris)); + if (uniqueRedirectUris.some(uri => !allowedRedirectUri(uri))) { throw new OAuthError( 'invalid_client_metadata', 'Redirect URIs must use HTTPS, except localhost loopback HTTP.' @@ -377,7 +379,7 @@ export async function registerMcpOAuthClient( const created = (await db.mcpOAuthClients.insert({ clientId: `xpenser_mcp_${randomBytes(18).toString('base64url')}`, clientName: oauthClientDisplayName(body.client_name), - redirectUrisJson: JSON.stringify(redirectUris), + redirectUrisJson: JSON.stringify(uniqueRedirectUris), scope })) as McpOAuthClientDb; @@ -385,7 +387,7 @@ export async function registerMcpOAuthClient( client_id: created.clientId, client_id_issued_at: epochSeconds(created.createdAt), client_name: created.clientName, - redirect_uris: redirectUris, + redirect_uris: uniqueRedirectUris, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope,