From 5a1ad5f9da7d3000ce66cd2b548df2836d60e64d Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:17:19 -0400 Subject: [PATCH 1/2] Add deployment-wide Notion access modes --- .changeset/calm-notion-automations.md | 5 + .../mcp/__tests__/integration-mcp.test.ts | 110 +- apps/api/src/handlers/mcp/integration-mcp.ts | 21 +- apps/api/src/handlers/mcp/proxy-utils.ts | 13 +- apps/docs/integrations/index.mdx | 2 +- apps/docs/integrations/notion.mdx | 33 +- .../home/OnboardingCard.client.test.tsx | 6 +- .../(authenticated)/home/OnboardingCard.tsx | 6 +- .../components/settings/Integrations.test.tsx | 100 +- .../src/components/settings/Integrations.tsx | 2 +- .../settings/LinkedAccounts.test.tsx | 5 +- .../settings/McpToolManagementDialog.tsx | 126 +- .../src/components/system/primitives/icons.ts | 2 + .../trpc/commands/mcp-connections/index.ts | 68 +- apps/web/src/trpc/routers/_app.ts | 2 + .../src/mcp/roomote-mcp-server/about-me.ts | 2 +- .../roomote-mcp-server/integration-setup.ts | 9 +- .../src/server/mcp-self-setup/catalog.ts | 4 +- .../db/drizzle/0040_regular_the_order.sql | 1 + packages/db/drizzle/meta/0040_snapshot.json | 11090 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 2 + .../__tests__/mcp-setup-suggestion.test.ts | 8 +- packages/slack/src/mcp-recommendations.ts | 2 +- .../types/src/__tests__/mcp-oauth.test.ts | 11 + .../src/__tests__/mcp-tool-policy.test.ts | 51 + packages/types/src/mcp-oauth.ts | 1 + packages/types/src/mcp-tool-policy.ts | 74 + 28 files changed, 11683 insertions(+), 80 deletions(-) create mode 100644 .changeset/calm-notion-automations.md create mode 100644 packages/db/drizzle/0040_regular_the_order.sql create mode 100644 packages/db/drizzle/meta/0040_snapshot.json diff --git a/.changeset/calm-notion-automations.md b/.changeset/calm-notion-automations.md new file mode 100644 index 000000000..fce8d28df --- /dev/null +++ b/.changeset/calm-notion-automations.md @@ -0,0 +1,5 @@ +--- +"@roomote/web": minor +--- + +Add a deployment-wide Notion connection for tasks and automations, with admin-configurable read-only or read-write tool access. 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 68648ca6c..8e49d2daa 100644 --- a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts @@ -35,6 +35,7 @@ vi.mock('@roomote/db/server', () => ({ deploymentMcpEnablements: { mcpId: 'mcpId', enabled: 'enabled', + toolAccessMode: 'toolAccessMode', }, eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), and: vi.fn((...clauses: unknown[]) => clauses), @@ -147,7 +148,10 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); - mockFindEnablement.mockResolvedValue({ disabledTools: null }); + mockFindEnablement.mockResolvedValue({ + disabledTools: null, + toolAccessMode: null, + }); mockGetValidAccessToken.mockResolvedValue('valid-access-token'); }); @@ -182,13 +186,27 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { expect(response.status).toBe(200); }); - it('rejects a user-scoped integration on a run with no human actor', async () => { + it('serves Notion on a run with no human actor through its deployment connection', async () => { mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-notion', userId: null }); + stubUpstreamFetch(); const response = await postMcp( createApp('notion', createRunToken()), createInitializeRequest(1), ); + + expect(response.status).toBe(200); + expect(mockFindConnection).toHaveBeenCalledTimes(1); + }); + + it('rejects a user-scoped integration on a run with no human actor', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + + const response = await postMcp( + createApp('monday', createRunToken()), + createInitializeRequest(1), + ); const body = (await response.json()) as JsonRpcErrorBody; expect(response.status).toBe(403); @@ -196,6 +214,94 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { expect(mockFindConnection).not.toHaveBeenCalled(); }); + it('blocks direct Notion mutation calls in the default read-only mode', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-notion', userId: null }); + const fetchMock = stubUpstreamFetch(); + + const response = await postMcp( + createApp('notion', createRunToken()), + createToolCallRequest(1, 'notion-update-page'), + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('allows Notion mutation calls after read-write is explicitly enabled', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-notion', userId: null }); + mockFindEnablement.mockResolvedValue({ + disabledTools: null, + toolAccessMode: 'read_write', + }); + const fetchMock = stubUpstreamFetch(); + + const response = await postMcp( + createApp('notion', createRunToken()), + createToolCallRequest(1, 'notion-update-page'), + ); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('filters Notion mutation tools from tools/list in read-only mode', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-notion', userId: null }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { + tools: [ + { name: 'notion-fetch' }, + { name: 'notion-update-page' }, + { name: 'notion-new-upstream-mutation' }, + ], + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ), + ); + + const response = await postMcp( + createApp('notion', createRunToken()), + createToolsListRequest(1), + ); + const body = (await response.json()) as { + result: { tools: Array<{ name: string }> }; + }; + + expect(response.status).toBe(200); + expect(body.result.tools).toEqual([{ name: 'notion-fetch' }]); + }); + + it('keeps individually disabled Notion tools blocked in read-write mode', async () => { + mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: null }); + mockFindConnection.mockResolvedValue({ id: 'conn-notion', userId: null }); + mockFindEnablement.mockResolvedValue({ + disabledTools: ['notion-update-page'], + toolAccessMode: 'read_write', + }); + const fetchMock = stubUpstreamFetch(); + + const response = await postMcp( + createApp('notion', createRunToken()), + createToolCallRequest(1, 'notion-update-page'), + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('resolves a user-scoped monday.com connection for the live acting user', async () => { mockFindTaskRun.mockResolvedValue({ id: 42, actingUserId: 'user-2' }); mockFindConnection.mockResolvedValue({ id: 'conn-2', userId: 'user-2' }); diff --git a/apps/api/src/handlers/mcp/integration-mcp.ts b/apps/api/src/handlers/mcp/integration-mcp.ts index 6a1df4a28..07deb4975 100644 --- a/apps/api/src/handlers/mcp/integration-mcp.ts +++ b/apps/api/src/handlers/mcp/integration-mcp.ts @@ -11,6 +11,7 @@ import { getValidAccessToken } from '@roomote/sdk/server'; import { getMcpIntegrationUpstreamUrl, getMcpIntegrationConnectionScope, + getAllowedIntegrationMcpToolNames, isMcpConnectionXConfig, type McpIntegration, } from '@roomote/types'; @@ -76,9 +77,7 @@ async function resolveUpstreamAccessToken( }; } -async function resolveDeploymentDisabledToolNames( - mcpId: string, -): Promise { +async function resolveDeploymentToolPolicy(mcpId: string) { const enablement = await db.query.deploymentMcpEnablements.findFirst({ where: and( eq(deploymentMcpEnablements.mcpId, mcpId), @@ -86,10 +85,16 @@ async function resolveDeploymentDisabledToolNames( ), columns: { disabledTools: true, + toolAccessMode: true, }, }); - return enablement?.disabledTools ?? null; + return { + disabledToolNames: enablement?.disabledTools ?? null, + allowedToolNames: + getAllowedIntegrationMcpToolNames(mcpId, enablement?.toolAccessMode) ?? + null, + }; } export function createIntegrationMcpProxy( @@ -128,7 +133,7 @@ export function createIntegrationMcpProxy( : await resolveActingUserId(auth); let accessToken: string | null; - let disabledToolNames: string[] | null = null; + let toolPolicy: Awaited>; try { const resolvedConnection = await resolveUpstreamAccessToken( integration.id, @@ -136,9 +141,7 @@ export function createIntegrationMcpProxy( actingUserId, ); accessToken = resolvedConnection.accessToken; - disabledToolNames = await resolveDeploymentDisabledToolNames( - integration.id, - ); + toolPolicy = await resolveDeploymentToolPolicy(integration.id); } catch (error) { if (error instanceof McpProxyError) { throw error; @@ -163,7 +166,7 @@ export function createIntegrationMcpProxy( return { authHeader: accessToken, - disabledToolNames, + ...toolPolicy, }; }, }); diff --git a/apps/api/src/handlers/mcp/proxy-utils.ts b/apps/api/src/handlers/mcp/proxy-utils.ts index 8583d4f4c..c0e64373e 100644 --- a/apps/api/src/handlers/mcp/proxy-utils.ts +++ b/apps/api/src/handlers/mcp/proxy-utils.ts @@ -295,6 +295,11 @@ interface ResolvedCredentials { /** `null` for upstreams that take no Authorization header. */ authHeader: string | null; extraHeaders?: Record; + /** + * Per-request allowlist override. `null` explicitly removes a static + * allowlist, while `undefined` keeps the proxy's configured default. + */ + allowedToolNames?: readonly string[] | null; disabledToolNames?: readonly string[] | null; /** * Per-request upstream URL. Required when the proxy was constructed without @@ -907,8 +912,12 @@ export function createMcpProxy(config: McpProxyConfig) { } try { + const resolvedAllowedToolNames = + credentials.allowedToolNames === undefined + ? allowedToolNames + : (credentials.allowedToolNames ?? undefined); const effectiveAllowedToolNames = getEffectiveAllowedMcpToolNames({ - allowedToolNames, + allowedToolNames: resolvedAllowedToolNames, disabledToolNames: credentials.disabledToolNames, }); const hasToolRestrictions = Boolean( @@ -941,7 +950,7 @@ export function createMcpProxy(config: McpProxyConfig) { if ( toolName && !isMcpToolAllowed(toolName, { - allowedToolNames, + allowedToolNames: resolvedAllowedToolNames, disabledToolNames: credentials.disabledToolNames, }) ) { diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index b36998d41..cbdd738f3 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -60,7 +60,7 @@ from [Personal Settings](/personal-settings). | | Turning issues into Roomote work | Workspace plus user identity | | | Board, item, and workspace context | Enable first, then teammates link accounts | | | Database inspection in Neon | Enable first, then teammates link accounts | -| | Shared docs and database context | Enable first, then teammates link accounts | +| | Shared docs and database context | Admin connection once | | | Product analytics, experiments, and error context | Admin connection once | | | Customer issue and account context | Admin connection once | | | Project and service context from Railway | Admin connection once | diff --git a/apps/docs/integrations/notion.mdx b/apps/docs/integrations/notion.mdx index 2a5909f1f..61ca07c6a 100644 --- a/apps/docs/integrations/notion.mdx +++ b/apps/docs/integrations/notion.mdx @@ -5,21 +5,40 @@ icon: 'https://api.iconify.design/simple-icons:notion.svg?color=currentColor' --- Connect Notion when product specs, runbooks, notes, or operating context already -live there and Roomote should be able to inspect that material during a task. +live there and Roomote should be able to use that material during tasks and +automations. ## When to use it - Pull a spec or runbook into a planning or debugging task - Inspect database-backed project context without copying it into the prompt -- Keep task context close to the docs your team already maintains +- Optionally create or update pages from approved Roomote workflows ## How setup works -Admins enable Notion from **Settings > Integrations**. Each teammate then links -their own Notion account from [Personal Settings](/personal-settings) when they -need it. +An admin connects Notion once from **Settings > Integrations**. Roomote stores +that OAuth connection for the deployment, so interactive tasks and automations +use the same Notion workspace access without each teammate linking an account. + + + After upgrading from personal Notion connections, an admin must reconnect + Notion once from **Settings > Integrations**. Roomote does not promote an + existing teammate's credential into a deployment credential automatically. + + +Notion starts in **Read only** mode. In **Manage tools**, an admin can choose: + +- **Read only (recommended)** — allows reviewed search and read tools while + blocking content changes +- **Read and write** — also allows tools that create, update, move, duplicate, + or comment on content + +The selected mode applies to every Roomote task and automation in the +deployment. Individual tools can still be disabled to narrow access further. ## What to expect -Notion gives Roomote shared document context. The resulting engineering work -still gets reviewed in the Roomote task view and normal repository workflow. +The connection has the same Notion access as the account that authorizes it. +Use an account whose workspace permissions match what Roomote should be able to +reach, and enable read-write mode only when unattended automations are expected +to change Notion content. diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx index 9bd7e6421..9f2841a08 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.client.test.tsx @@ -274,12 +274,14 @@ it('opens the highlighted integration settings for admin setup', () => { ); }); -it('does not show workspace setup to non-admins and prompts enabled personal MCP links', () => { +it('does not offer deployment-scoped Notion setup to non-admins', () => { isAdmin = false; enabledMcpIds = ['notion']; render(); - expect(screen.getByText('Link your Notion account')).toBeInTheDocument(); + expect( + screen.queryByText('Link your Notion account'), + ).not.toBeInTheDocument(); expect( screen.queryByText('Enable Notion for your workspace'), ).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx index e3cc93134..b0a29bf77 100644 --- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx +++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx @@ -61,11 +61,7 @@ const ADMIN_INTEGRATION_ORDER = [ 'asana', ] as const; -const PERSONAL_MCP_INTEGRATION_ORDER = [ - 'notion', - 'monday', - 'supabase', -] as const; +const PERSONAL_MCP_INTEGRATION_ORDER = ['monday', 'supabase'] as const; const CARD_EXIT_TRANSITION = { duration: 0.4, diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index a367c4a7c..0059c7dc1 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -25,10 +25,12 @@ const state = vi.hoisted(() => ({ }>, mcpTools: null as null | { mcpId: string; + toolAccessMode?: 'read_only' | 'read_write' | null; tools: Array<{ name: string; description: string | null; enabled: boolean; + availableInReadOnly?: boolean | null; }>; }, mcpToolsError: null as Error | null, @@ -92,7 +94,7 @@ const state = vi.hoisted(() => ({ searchParams: '', })); -const { mutations, selectMock } = vi.hoisted(() => ({ +const { mutations, selectMock, radioMock } = vi.hoisted(() => ({ mutations: { connectLinear: vi.fn(), disconnectLinear: vi.fn(), @@ -113,6 +115,10 @@ const { mutations, selectMock } = vi.hoisted(() => ({ selectMock: { latestOnValueChange: null as null | ((value: string) => void), }, + radioMock: { + currentValue: null as string | null, + latestOnValueChange: null as null | ((value: string) => void), + }, })); vi.mock('@tanstack/react-query', () => ({ @@ -400,6 +406,28 @@ vi.mock('@/components/system', () => ({ RefreshCw: ({ className }: { className?: string }) => ( ), + RadioGroup: ({ + children, + value, + onValueChange, + }: { + children: ReactNode; + value: string; + onValueChange: (value: string) => void; + }) => { + radioMock.currentValue = value; + radioMock.latestOnValueChange = onValueChange; + return
{children}
; + }, + RadioGroupItem: ({ id, value }: { id: string; value: string }) => ( + radioMock.latestOnValueChange?.(value)} + /> + ), Select: ({ children, onValueChange, @@ -447,16 +475,19 @@ vi.mock('@/components/system', () => ({ Trash: () => , Switch: ({ checked, + disabled, onCheckedChange, 'aria-label': ariaLabel, }: { checked: boolean; + disabled?: boolean; onCheckedChange: (checked: boolean) => void; 'aria-label'?: string; }) => (