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',