diff --git a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts index d21eb82488..49e29fb36f 100644 --- a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts @@ -296,6 +296,30 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { expect(body.error.message).toContain('valid credentials'); }); + it('forwards the decrypted Stripe restricted API key upstream', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ + id: 'conn-stripe', + userId: null, + authConfig: { type: 'stripe', encryptedApiKey: 'encrypted-key' }, + }); + mockDecrypt.mockReturnValue('rk_test_restricted'); + const fetchMock = stubUpstreamFetch(); + + const response = await postMcp( + createApp('stripe', createRunToken()), + createInitializeRequest(1), + ); + + expect(response.status).toBe(200); + expect(mockDecrypt).toHaveBeenCalledWith('encrypted-key'); + expect(mockGetValidAccessToken).not.toHaveBeenCalled(); + const upstreamHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers; + expect(upstreamHeaders.get('authorization')).toBe( + 'Bearer rk_test_restricted', + ); + }); + it('forwards the decrypted Exa API key only as x-api-key', async () => { mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); mockFindConnection.mockResolvedValue({ diff --git a/apps/api/src/handlers/mcp/integration-mcp.ts b/apps/api/src/handlers/mcp/integration-mcp.ts index 829c9bef75..9d6d2f4a42 100644 --- a/apps/api/src/handlers/mcp/integration-mcp.ts +++ b/apps/api/src/handlers/mcp/integration-mcp.ts @@ -14,6 +14,7 @@ import { getAllowedIntegrationMcpToolNames, isMcpConnectionExaConfig, isMcpConnectionXConfig, + isMcpConnectionStripeConfig, type McpIntegration, } from '@roomote/types'; @@ -87,6 +88,12 @@ async function resolveUpstreamCredentials( }; } + if (isMcpConnectionStripeConfig(connection.authConfig)) { + const apiKey = decrypt(connection.authConfig.encryptedApiKey).trim(); + + return { authHeader: apiKey.length > 0 ? apiKey : null }; + } + return { authHeader: (await getValidAccessToken(connection.id, mcpUrl)) ?? null, }; diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 030bcc6ae4..cca47bf59d 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -173,6 +173,7 @@ "integrations/rippling", "integrations/sentry", "integrations/snowflake", + "integrations/stripe", "integrations/supabase", "integrations/supermemory", "integrations/vercel", diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 55c75527f9..43dea28c30 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -97,6 +97,7 @@ from [Personal Settings](/personal-settings). | | Authoritative employee and reporting context | Admin connection once | | | Error and performance investigation | Admin connection once | | | Data warehouse exploration | Admin connection once | +| | Payments, billing, and Stripe API context | Admin connection once | | | Read-only database access in Supabase | Enable first, then teammates link accounts | | | Shared memory across tasks and sessions | Admin connection once | | | Deployments, logs, and domain availability | Admin connection once | diff --git a/apps/docs/integrations/stripe.mdx b/apps/docs/integrations/stripe.mdx new file mode 100644 index 0000000000..bc994117b1 --- /dev/null +++ b/apps/docs/integrations/stripe.mdx @@ -0,0 +1,33 @@ +--- +title: Stripe +description: Inspect Stripe payments, billing, and API context from Roomote tasks. +icon: 'https://api.iconify.design/simple-icons:stripe.svg?color=currentColor' +--- + +Connect Stripe when tasks need account, payment, customer, billing, analytics, +or Stripe API context. + +## How setup works + +A deployment operator creates a restricted API key in the Stripe Dashboard and +stores it through **Settings > Integrations**. The key is encrypted at rest and +forwarded only from Roomote's control-plane proxy to Stripe's hosted MCP server. + +Grant the key only the read permissions Roomote needs. Stripe administrators +can also disable MCP access for the team in the Stripe Dashboard. + +## Safer defaults + +Roomote disables `stripe_api_write` by default. The API search, details, and +read tools remain available along with account, analytics, documentation, and +implementation-planning tools. An admin can opt in to the general write tool +from **Settings > Integrations > Stripe > Manage tools**. + +When writes are enabled, Stripe may still require human confirmation for +sensitive actions such as refunds or outbound payments. + +## Verify the connection + +Start with a sandbox or test-mode key and ask Roomote to retrieve account +information or list a non-sensitive resource. Live account access depends on +the restricted key's permissions. diff --git a/apps/web/src/components/settings/CredentialIntegrations.tsx b/apps/web/src/components/settings/CredentialIntegrations.tsx index e221c4a081..b4f1b93de0 100644 --- a/apps/web/src/components/settings/CredentialIntegrations.tsx +++ b/apps/web/src/components/settings/CredentialIntegrations.tsx @@ -17,6 +17,8 @@ import { useSaveNotionConnection, useSaveRipplingConnection, useSaveXConnection, + useSaveStripeConnection, + useStripeConnection, useXConnection, type useEffectiveMcpIntegrations, } from '@/hooks/mcp-connections'; @@ -26,6 +28,7 @@ import { saveNotionConnectionSchema, saveRipplingConnectionSchema, saveXConnectionSchema, + saveStripeConnectionSchema, } from '@/types'; import { Button, @@ -53,6 +56,7 @@ type CredentialIntegrationId = | 'notion' | 'rippling' | 'granola' + | 'stripe' | 'x'; type CredentialConnection = { @@ -141,6 +145,20 @@ function useXCredentialMutation(): CredentialMutation<{ }; } +function useStripeCredentialMutation(): CredentialMutation<{ + apiKey: string; +}> { + const mutation = useSaveStripeConnection(); + return { + isPending: mutation.isPending, + mutate: (input, options) => + mutation.mutate(input, { + onSuccess: options.onSuccess, + onError: options.onError, + }), + }; +} + type CredentialDefinition = { id: CredentialIntegrationId; fieldId: string; @@ -744,6 +762,44 @@ const credentialDefinitions = { } as const); }, }, + stripe: { + id: 'stripe', + fieldId: 'stripe-restricted-api-key', + fieldLabel: 'Stripe Restricted API Key', + fieldPlaceholder: 'rk_...', + help: ( +

+ Create a restricted key in the{' '} + + Stripe Dashboard + {' '} + with only the read permissions Roomote needs. Start with a sandbox or + test-mode key before connecting live data. +

+ ), + blankHelp: 'Leave blank to keep the existing restricted key.', + dialogDescription: + 'Store a deployment-wide Stripe restricted API key. The key stays encrypted server-side and the general Stripe write tool starts disabled.', + requiredMessage: 'Restricted API key is required', + connectedMessage: 'Stripe connected for this deployment.', + updatedMessage: 'Stripe connection updated for this deployment.', + canManageTools: true, + getCredential: (input) => input.apiKey ?? '', + parse: (secret: string) => { + const result = saveStripeConnectionSchema.safeParse({ apiKey: secret }); + return result.success + ? ({ success: true, data: result.data } as const) + : ({ + success: false, + errors: result.error.flatten().fieldErrors.apiKey, + } as const); + }, + }, } satisfies { [Id in CredentialIntegrationId]: CredentialDefinition>; }; @@ -836,7 +892,13 @@ export function useCredentialIntegrations({ useConnection: useXConnection, useSave: useXCredentialMutation, }); - const runtimes = [asana, notion, rippling, granola, x]; + const stripe = useCredentialIntegration({ + ...buildRuntimeOptions('stripe'), + definition: credentialDefinitions.stripe, + useConnection: useStripeConnection, + useSave: useStripeCredentialMutation, + }); + const runtimes = [asana, notion, rippling, granola, stripe, x]; return { itemsById: new Map(runtimes.map((runtime) => [runtime.id, runtime.item])), diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index b622689748..a6cac1ad4e 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -70,6 +70,9 @@ const state = vi.hoisted(() => ({ xConnection: null as null | { authStatus?: string | null; }, + stripeConnection: null as null | { + authStatus?: string | null; + }, isAdmin: true, snowflakeConnection: null as null | { authStatus?: string | null; @@ -130,6 +133,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ saveSnowflakeConnection: vi.fn(), saveVercelConnection: vi.fn(), saveXConnection: vi.fn(), + saveStripeConnection: vi.fn(), saveLinearOauthSetup: vi.fn(), removeLinearOauthSetup: vi.fn(), }, @@ -394,6 +398,14 @@ vi.mock('@/hooks/mcp-connections', () => ({ data: state.xConnection, isPending: false, }), + useSaveStripeConnection: () => ({ + isPending: false, + mutate: mutations.saveStripeConnection, + }), + useStripeConnection: () => ({ + data: state.stripeConnection, + isPending: false, + }), })); vi.mock('@/trpc/client', () => ({ @@ -622,6 +634,7 @@ describe('Integrations settings', () => { state.grafanaConnection = null; state.vercelConnection = null; state.xConnection = null; + state.stripeConnection = null; state.isAdmin = true; state.snowflakeConnection = null; state.searchParams = ''; @@ -1962,6 +1975,12 @@ describe('Integrations settings', () => { requiredMessage: 'Bearer token is required', saveMutation: mutations.saveXConnection, }, + { + integration: 'Stripe', + inputLabel: 'Stripe Restricted API Key', + requiredMessage: 'Restricted API key is required', + saveMutation: mutations.saveStripeConnection, + }, ])( 'rejects a whitespace-only $integration credential before saving', ({ integration, inputLabel, requiredMessage, saveMutation }) => { @@ -2000,6 +2019,21 @@ describe('Integrations settings', () => { ); }); + it('rejects unrestricted Stripe secret keys before saving', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Stripe' })); + fireEvent.change(screen.getByLabelText('Stripe Restricted API Key'), { + target: { value: 'sk_live_unrestricted' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect Stripe' })); + + expect( + screen.getByText('Use a Stripe restricted API key starting with rk_'), + ).toBeInTheDocument(); + expect(mutations.saveStripeConnection).not.toHaveBeenCalled(); + }); + it('keeps Exa off by default and enables keyless access explicitly', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index e364fa2957..635a49653e 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -145,6 +145,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { sentry: 'Roomote will be able to inspect Sentry issue context and run scheduled Sentry triage through MCP.', supabase: 'Roomote will get read-only database access and platform context.', + stripe: + 'Roomote will use one deployment-wide restricted Stripe key to inspect account, payment, and billing data. The general write tool starts disabled.', supermemory: 'Roomote will be able to save shared memories and recall context from earlier tasks.', vercel: diff --git a/apps/web/src/components/system/custom/logos/brand-icon.tsx b/apps/web/src/components/system/custom/logos/brand-icon.tsx index 12a4533a42..afef5cea2d 100644 --- a/apps/web/src/components/system/custom/logos/brand-icon.tsx +++ b/apps/web/src/components/system/custom/logos/brand-icon.tsx @@ -28,6 +28,7 @@ import { siSentry, siSnowflake, siSupabase, + siStripe, siTelegram, siVercel, siX, @@ -67,6 +68,7 @@ const SIMPLE_ICONS: Record = { resend: siResend, snowflake: siSnowflake, supabase: siSupabase, + stripe: siStripe, telegram: siTelegram, sentry: siSentry, vercel: siVercel, diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index 5a3486bc06..85b34f210d 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -33,4 +33,6 @@ export { useSaveVercelConnection } from './useSaveVercelConnection'; export { useVercelConnection } from './useVercelConnection'; export { useSaveXConnection } from './useSaveXConnection'; export { useXConnection } from './useXConnection'; +export { useSaveStripeConnection } from './useSaveStripeConnection'; +export { useStripeConnection } from './useStripeConnection'; export { useSetDisabledMcpTools } from './useSetDisabledMcpTools'; diff --git a/apps/web/src/hooks/mcp-connections/useSaveStripeConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveStripeConnection.ts new file mode 100644 index 0000000000..a31d225b83 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useSaveStripeConnection.ts @@ -0,0 +1,22 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; +import { invalidateMcpIntegrationStatusQueries } from './invalidateMcpIntegrationStatusQueries'; + +export function useSaveStripeConnection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.mcpConnections.saveStripeConnection.mutationOptions({ + onSuccess: () => { + void invalidateMcpIntegrationStatusQueries(queryClient, trpc); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.stripeConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/hooks/mcp-connections/useStripeConnection.ts b/apps/web/src/hooks/mcp-connections/useStripeConnection.ts new file mode 100644 index 0000000000..b80e3271e9 --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useStripeConnection.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useStripeConnection(enabled = true) { + const trpc = useTRPC(); + + return useQuery({ + ...trpc.mcpConnections.stripeConnection.queryOptions(), + enabled, + }); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.test.ts b/apps/web/src/trpc/commands/mcp-connections/index.test.ts index b297e4c167..ac7eca0c76 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.test.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.test.ts @@ -8,6 +8,7 @@ import { } from '@roomote/db/server'; import { isMcpConnectionExaConfig, + isMcpConnectionStripeConfig, isMcpConnectionVoiceConfig, } from '@roomote/types'; @@ -48,6 +49,7 @@ import { removeExaApiKeyCommand, saveAsanaConnectionCommand, saveExaConnectionCommand, + saveStripeConnectionCommand, saveVoiceConnectionCommand, setDeploymentMcpEnabledCommand, } from './index'; @@ -64,7 +66,15 @@ const memberAuth = { isAdmin: false, } as UserAuthSuccess; -const testMcpIds = ['asana', 'exa', 'linear', 'monday', 'sentry', 'voice']; +const testMcpIds = [ + 'asana', + 'exa', + 'linear', + 'monday', + 'sentry', + 'stripe', + 'voice', +]; async function cleanup() { await db @@ -132,6 +142,31 @@ describe('MCP connection lifecycle telemetry', () => { expect(enablements).toHaveLength(0); }); + it('encrypts a Stripe restricted key and starts with writes disabled', async () => { + await saveStripeConnectionCommand(adminAuth, { + apiKey: 'rk_test_restricted', + }); + + const [connection] = await db + .select() + .from(mcpConnections) + .where(eq(mcpConnections.mcpId, 'stripe')); + expect(connection?.authStatus).toBe('authenticated'); + expect(isMcpConnectionStripeConfig(connection?.authConfig)).toBe(true); + expect(JSON.stringify(connection?.authConfig)).not.toContain( + 'rk_test_restricted', + ); + + const [enablement] = await db + .select() + .from(deploymentMcpEnablements) + .where(eq(deploymentMcpEnablements.mcpId, 'stripe')); + expect(enablement).toMatchObject({ + enabled: true, + disabledTools: ['stripe_api_write'], + }); + }); + it('does not persist an Exa connection when upstream validation fails', async () => { vi.stubGlobal( 'fetch', diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index d9cebdfa94..9a42a29f64 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -32,6 +32,7 @@ import { isMcpConnectionSnowflakeConfig, isMcpConnectionVercelConfig, isMcpConnectionXConfig, + isMcpConnectionStripeConfig, isSelfServeMcpIntegration, isMcpToolAllowed, isDeploymentScopedMcpIntegration, @@ -66,6 +67,7 @@ import type { SaveSnowflakeConnectionInput, SaveVercelConnectionInput, SaveXConnectionInput, + SaveStripeConnectionInput, } from '@/types'; type VercelConnectionData = { @@ -238,6 +240,14 @@ async function resolveUpstreamCatalogAuth( : null; } + if (isMcpConnectionStripeConfig(connection?.authConfig)) { + const apiKey = decrypt(connection.authConfig.encryptedApiKey).trim(); + + return apiKey.length > 0 + ? { headers: { authorization: `Bearer ${apiKey}` } } + : null; + } + if (isMcpConnectionExaConfig(connection?.authConfig)) { const apiKey = decrypt(connection.authConfig.encryptedApiKey).trim(); @@ -1139,6 +1149,27 @@ export async function getXConnectionCommand(auth: UserAuthSuccess) { }; } +export async function getStripeConnectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'stripe'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + authStatus: true, + }, + }); + + if (!connection || !isMcpConnectionStripeConfig(connection.authConfig)) { + return null; + } + + return { authStatus: connection.authStatus }; +} + export async function getVercelConnectionCommand( auth: UserAuthSuccess, ): Promise { @@ -2104,6 +2135,101 @@ export async function saveXConnectionCommand( }; } +export async function saveStripeConnectionCommand( + auth: UserAuthSuccess, + input: SaveStripeConnectionInput, +) { + assertAdmin(auth); + assertCuratedIntegrationsEnabled(); + + const existingConnection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'stripe'), + isNull(mcpConnections.userId), + ), + columns: { authConfig: true }, + }); + const existingConfig = isMcpConnectionStripeConfig( + existingConnection?.authConfig, + ) + ? existingConnection.authConfig + : null; + const nextEncryptedApiKey = + input.apiKey.length > 0 + ? encrypt(input.apiKey) + : existingConfig?.encryptedApiKey; + + if (!nextEncryptedApiKey) { + throw new Error( + 'Stripe restricted API key is required when no Stripe key is already stored.', + ); + } + + const authConfig = { + type: 'stripe' as const, + encryptedApiKey: nextEncryptedApiKey, + }; + + await db + .insert(mcpConnections) + .values({ + userId: null, + mcpId: 'stripe', + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + }) + .onConflictDoUpdate({ + target: [ + mcpConnections.userId, + mcpConnections.mcpId, + mcpConnections.connectionRole, + ], + set: { + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + updatedAt: new Date(), + }, + }); + + const defaultDisabledTools = getMcpIntegrationDefaultDisabledTools('stripe'); + await db + .insert(deploymentMcpEnablements) + .values({ + mcpId: 'stripe', + enabled: true, + enabledByUserId: auth.userId, + disabledTools: + defaultDisabledTools.length > 0 ? [...defaultDisabledTools] : null, + }) + .onConflictDoUpdate({ + target: [deploymentMcpEnablements.mcpId], + set: { + enabled: true, + enabledByUserId: auth.userId, + updatedAt: new Date(), + }, + }); + + if (!existingConnection) { + captureIntegrationLifecycleEvent( + 'integration_connected', + 'stripe', + auth.userId, + ); + captureIntegrationLifecycleEvent( + 'integration_enabled', + 'stripe', + auth.userId, + ); + } + + return { authStatus: 'authenticated' as const }; +} + export async function saveVercelConnectionCommand( auth: UserAuthSuccess, input: SaveVercelConnectionInput, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 07132eb0b3..3f0c9efe7e 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -103,6 +103,7 @@ import { saveSnowflakeConnectionSchema, saveVercelConnectionSchema, saveXConnectionSchema, + saveStripeConnectionSchema, timePeriodFilterSchema, PERSONAL_COLOR_THEMES, } from '@/types'; @@ -288,6 +289,7 @@ import { getSnowflakeConnectionCommand, getVercelConnectionCommand, getXConnectionCommand, + getStripeConnectionCommand, listDeploymentMcpIntegrationToolsCommand, saveAsanaConnectionCommand, saveNotionConnectionCommand, @@ -301,6 +303,7 @@ import { saveSnowflakeConnectionCommand, saveVercelConnectionCommand, saveXConnectionCommand, + saveStripeConnectionCommand, setDeploymentDisabledMcpIntegrationToolsCommand, connectMcpCommand, disconnectMcpCommand, @@ -2103,6 +2106,10 @@ export const appRouter = createRouter({ getXConnectionCommand(auth), ), + stripeConnection: protectedProcedure.query(({ ctx: { auth } }) => + getStripeConnectionCommand(auth), + ), + listTools: protectedProcedure .input(z.object({ mcpId: z.string() })) .query(({ ctx: { auth }, input }) => @@ -2214,6 +2221,12 @@ export const appRouter = createRouter({ .mutation(({ ctx: { auth }, input }) => saveXConnectionCommand(auth, input), ), + + saveStripeConnection: protectedProcedure + .input(saveStripeConnectionSchema) + .mutation(({ ctx: { auth }, input }) => + saveStripeConnectionCommand(auth, input), + ), }), auth: createRouter({ diff --git a/apps/web/src/types/mcp-connections.client.test.ts b/apps/web/src/types/mcp-connections.client.test.ts new file mode 100644 index 0000000000..7efac2a2a3 --- /dev/null +++ b/apps/web/src/types/mcp-connections.client.test.ts @@ -0,0 +1,24 @@ +import { saveStripeConnectionSchema } from './mcp-connections'; + +describe('saveStripeConnectionSchema', () => { + it('accepts and trims restricted API keys', () => { + expect( + saveStripeConnectionSchema.parse({ apiKey: ' rk_test_restricted ' }), + ).toEqual({ apiKey: 'rk_test_restricted' }); + }); + + it('allows an empty value so edits can retain the stored key', () => { + expect(saveStripeConnectionSchema.parse({ apiKey: ' ' })).toEqual({ + apiKey: '', + }); + }); + + it.each(['sk_test_unrestricted', 'sk_live_unrestricted', 'pk_test_public'])( + 'rejects non-restricted Stripe key %s', + (apiKey) => { + expect(() => saveStripeConnectionSchema.parse({ apiKey })).toThrow( + 'Use a Stripe restricted API key starting with rk_', + ); + }, + ); +}); diff --git a/apps/web/src/types/mcp-connections.ts b/apps/web/src/types/mcp-connections.ts index 1de08572e3..f299b72f83 100644 --- a/apps/web/src/types/mcp-connections.ts +++ b/apps/web/src/types/mcp-connections.ts @@ -109,6 +109,20 @@ export const saveXConnectionSchema = z.object({ export type SaveXConnectionInput = z.infer; +export const saveStripeConnectionSchema = z.object({ + apiKey: z + .string() + .transform((value) => value.trim()) + .refine( + (value) => value.length === 0 || value.startsWith('rk_'), + 'Use a Stripe restricted API key starting with rk_', + ), +}); + +export type SaveStripeConnectionInput = z.infer< + typeof saveStripeConnectionSchema +>; + export const saveGrafanaConnectionSchema = z.object({ baseUrl: z .string() diff --git a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts index 2823ced78e..d7eecbd583 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -53,6 +53,14 @@ Sentry uses the workspace MCP integration: Once connected, tasks can inspect Sentry issue and project context, and scheduled Sentry triage automation uses the same read-only MCP connection. +# Stripe + +Stripe uses an admin-managed restricted API key: +1. A deployment operator enables Stripe from Settings > Integrations. +2. That operator connects Stripe once for the deployment with a restricted key that grants only the required read permissions. + +Once connected, I can inspect Stripe accounts, payments, billing data, API details, analytics, and documentation. The general stripe_api_write tool stays disabled until an administrator enables it from Manage tools. Stripe's own human-confirmation requirements still apply to sensitive writes. + # Buildkite Buildkite uses OAuth: diff --git a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts index 2819790f61..b9802da253 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -38,6 +38,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< string, SetupMcpIntegrationMetadata > = { + stripe: { + capabilities: [ + 'Inspect Stripe accounts, payments, customers, and billing data', + 'Search Stripe APIs and documentation during implementation work', + 'Opt in to the general Stripe write tool only when needed', + ], + }, buildkite: { capabilities: [ 'Inspect Buildkite pipelines, builds, jobs, and logs', diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 7d3561685d..9f0244a1a8 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -41,6 +41,7 @@ import { isMcpConnectionSnowflakeConfig, isMcpConnectionVercelConfig, isMcpConnectionXConfig, + isMcpConnectionStripeConfig, isDeploymentScopedMcpIntegration, BRAIN_MCP_ID, BRAIN_PROXY_PATH, @@ -671,6 +672,7 @@ async function buildCuratedMcpServerConfigs(ctx: { isMcpConnectionGrafanaConfig(authConfig) || isMcpConnectionGbrainConfig(authConfig) || isMcpConnectionExaConfig(authConfig) || + isMcpConnectionStripeConfig(authConfig) || isMcpConnectionXConfig(authConfig) ) { servers[connection.mcpId] = { diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index f27376b93e..2ab5067637 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -47,6 +47,8 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { pylon: 'Roomote will be able to inspect customer issues, message history, and account context.', supabase: 'Roomote will get read-only database access and platform context.', + stripe: + 'Roomote will use one deployment-wide restricted Stripe key to inspect account, payment, and billing data. The general write tool starts disabled.', supermemory: 'Roomote will be able to save shared memories and recall context from earlier tasks.', betterstack: diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index 8c7d1426d1..772d064d91 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -13,11 +13,13 @@ import { isMcpConnectionElevenLabsConfig, isMcpConnectionVoiceConfig, isMcpConnectionExaConfig, + isMcpConnectionStripeConfig, OPENAI_REALTIME_VOICE_OPTIONS, isMcpConnectionGbrainConfig, LINEAR_APP_OAUTH_SCOPES, MONDAY_MCP_READ_ONLY_OAUTH_SCOPES, RESEND_DEFAULT_DISABLED_TOOL_NAMES, + STRIPE_DEFAULT_DISABLED_TOOL_NAMES, } from '../mcp-oauth'; describe('integration data policy', () => { @@ -89,6 +91,34 @@ describe('monday.com OAuth', () => { }); }); +describe('Stripe restricted key connection', () => { + it('uses the hosted MCP with a deployment-scoped encrypted key', () => { + expect(getMcpIntegration('stripe')).toMatchObject({ + name: 'Stripe', + url: 'https://mcp.stripe.com', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'upstream_proxy', + }); + expect(getMcpIntegrationDefaultDisabledTools('stripe')).toEqual( + STRIPE_DEFAULT_DISABLED_TOOL_NAMES, + ); + expect(STRIPE_DEFAULT_DISABLED_TOOL_NAMES).toEqual(['stripe_api_write']); + }); + + it('recognizes only encrypted Stripe key configs', () => { + expect( + isMcpConnectionStripeConfig({ + type: 'stripe', + encryptedApiKey: 'encrypted', + }), + ).toBe(true); + expect(isMcpConnectionStripeConfig({ type: 'stripe' } as never)).toBe( + false, + ); + }); +}); + describe('Buildkite OAuth', () => { it('uses the provider-enforced read-only MCP endpoint with DCR', () => { expect(getMcpIntegration('buildkite')).toMatchObject({ diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 3a15f67c64..44e52176ac 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -246,6 +246,12 @@ export interface McpConnectionXConfig { encryptedBearerToken: string; } +/** Deployment-scoped Stripe restricted API key stored encrypted at rest. */ +export interface McpConnectionStripeConfig { + type: 'stripe'; + encryptedApiKey: string; +} + /** * Deployment-scoped Exa connection config stored in mcpConnections.authConfig. * @@ -329,6 +335,7 @@ export type McpConnectionAuthConfig = | McpConnectionGrafanaConfig | McpConnectionGbrainConfig | McpConnectionXConfig + | McpConnectionStripeConfig | McpConnectionExaConfig | Record; @@ -547,6 +554,8 @@ export const RESEND_DEFAULT_DISABLED_TOOL_NAMES = [ 'update-webhook', ] as const; +export const STRIPE_DEFAULT_DISABLED_TOOL_NAMES = ['stripe_api_write'] as const; + /** * Path prefixes of the API-hosted MCP proxy mounts. URL producers build proxy * URLs from these, and consumers (e.g. the Fast integration broker) use the @@ -652,6 +661,19 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ instructions: 'Sentry advertises only a few tools directly (find_organizations, find_projects, search_issues, search_events, get_sentry_resource). Reach everything else (issue details, event stack traces, breadcrumbs, tag values, issue events, releases, traces, replays, attachments, monitors, alert rules, docs) by calling search_sentry_tools with a short query, then execute_sentry_tool with the returned tool name and arguments. Which tools exist depends on the access the admin granted when connecting. Treat Sentry as read-only unless the request explicitly asks to change Sentry state: do not resolve, assign, ignore, or otherwise update issues, and do not create or modify projects, teams, DSNs, or monitors on your own initiative.', }, + { + id: 'stripe', + name: 'Stripe', + url: 'https://mcp.stripe.com', + description: `Inspect Stripe accounts, payments, billing, and API documentation from ${PRODUCT_NAME} tasks`, + icon: 'stripe', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'upstream_proxy', + defaultDisabledTools: [...STRIPE_DEFAULT_DISABLED_TOOL_NAMES], + instructions: + 'Use Stripe to inspect account, payment, billing, and API data through a deployment restricted key. The general stripe_api_write tool is disabled until an administrator enables it in Manage tools. When enabled, use writes only for explicit user requests and preserve Stripe human-confirmation requirements for sensitive actions.', + }, { id: 'buildkite', name: 'Buildkite', @@ -1289,6 +1311,19 @@ export function isMcpConnectionXConfig( ); } +export function isMcpConnectionStripeConfig( + authConfig: McpConnectionAuthConfig | null | undefined, +): authConfig is McpConnectionStripeConfig { + return Boolean( + authConfig && + typeof authConfig === 'object' && + 'type' in authConfig && + authConfig.type === 'stripe' && + 'encryptedApiKey' in authConfig && + typeof authConfig.encryptedApiKey === 'string', + ); +} + export function isMcpConnectionGbrainConfig( authConfig: McpConnectionAuthConfig | null | undefined, ): authConfig is McpConnectionGbrainConfig { diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index c84c2c82cc..031ded9859 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -108,6 +108,14 @@ const BUILDKITE_ORGANIZATION_PATH_REGEX = new RegExp( ); export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ + { + id: 'stripe', + name: 'Stripe', + availabilityKind: 'admin_configured', + hostSuffixes: ['dashboard.stripe.com'], + deploymentSettingsPath: '/integrations', + userSettingsPath: '/settings/personal', + }, { id: 'buildkite', name: 'Buildkite',