diff --git a/.changeset/calm-notion-automations.md b/.changeset/calm-notion-automations.md index d5d3a4ff2..528e6a97d 100644 --- a/.changeset/calm-notion-automations.md +++ b/.changeset/calm-notion-automations.md @@ -2,4 +2,4 @@ "@roomote/web": minor --- -Add a deployment-wide Notion internal integration for tasks and automations. Notion restricts it to explicitly shared content, while admins choose read-only or read-write tool access in Roomote. +Add a deployment-wide Notion internal integration for tasks and automations. Notion controls the connection's capabilities and restricts it to explicitly shared content. 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 ac5bdb508..08deee448 100644 --- a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts @@ -35,7 +35,6 @@ 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), @@ -150,7 +149,6 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { vi.unstubAllGlobals(); mockFindEnablement.mockResolvedValue({ disabledTools: null, - toolAccessMode: null, }); mockGetValidAccessToken.mockResolvedValue('valid-access-token'); }); diff --git a/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts index da742fc32..423a83224 100644 --- a/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts @@ -107,8 +107,7 @@ describe('native Notion MCP', () => { }, }); mockFindEnablement.mockResolvedValue({ - disabledTools: null, - toolAccessMode: 'read_only', + mcpId: 'notion', }); }); @@ -127,7 +126,7 @@ describe('native Notion MCP', () => { expect(response.status).toBe(403); }); - it('exposes only read tools by default', async () => { + it('exposes tools whose permissions are enforced by Notion capabilities', async () => { const response = await postMcp(createApp(createRunToken()), { jsonrpc: '2.0', id: 1, @@ -145,28 +144,6 @@ describe('native Notion MCP', () => { 'notion-fetch', 'notion-query-data-sources', 'notion-get-comments', - ]), - ); - expect(toolNames).not.toContain('notion-update-page'); - expect(toolNames).not.toContain('notion-create-pages'); - }); - - it('exposes writes only after read-write is enabled', async () => { - mockFindEnablement.mockResolvedValue({ - disabledTools: null, - toolAccessMode: 'read_write', - }); - const response = await postMcp(createApp(createRunToken()), { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - }); - const body = (await response.json()) as { - result: { tools: Array<{ name: string }> }; - }; - - expect(body.result.tools.map((tool) => tool.name)).toEqual( - expect.arrayContaining([ 'notion-create-pages', 'notion-update-page', 'notion-append-blocks', @@ -175,25 +152,6 @@ describe('native Notion MCP', () => { ); }); - it('keeps individually disabled write tools unavailable', async () => { - mockFindEnablement.mockResolvedValue({ - disabledTools: ['notion-update-page'], - toolAccessMode: 'read_write', - }); - const response = await postMcp(createApp(createRunToken()), { - jsonrpc: '2.0', - id: 1, - method: 'tools/list', - }); - const body = (await response.json()) as { - result: { tools: Array<{ name: string }> }; - }; - - expect(body.result.tools.map((tool) => tool.name)).not.toContain( - 'notion-update-page', - ); - }); - it('searches through the Notion API using only the stored integration secret', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ object: 'list', results: [] }), { diff --git a/apps/api/src/handlers/mcp/integration-mcp.ts b/apps/api/src/handlers/mcp/integration-mcp.ts index 07deb4975..ce81c182a 100644 --- a/apps/api/src/handlers/mcp/integration-mcp.ts +++ b/apps/api/src/handlers/mcp/integration-mcp.ts @@ -85,15 +85,12 @@ async function resolveDeploymentToolPolicy(mcpId: string) { ), columns: { disabledTools: true, - toolAccessMode: true, }, }); return { disabledToolNames: enablement?.disabledTools ?? null, - allowedToolNames: - getAllowedIntegrationMcpToolNames(mcpId, enablement?.toolAccessMode) ?? - null, + allowedToolNames: getAllowedIntegrationMcpToolNames(mcpId) ?? null, }; } diff --git a/apps/api/src/handlers/mcp/notion/index.ts b/apps/api/src/handlers/mcp/notion/index.ts index 374f57926..dce5ec960 100644 --- a/apps/api/src/handlers/mcp/notion/index.ts +++ b/apps/api/src/handlers/mcp/notion/index.ts @@ -10,10 +10,7 @@ import { mcpConnections, taskRuns, } from '@roomote/db/server'; -import { - getAllowedIntegrationMcpToolNames, - isMcpConnectionNotionConfig, -} from '@roomote/types'; +import { isMcpConnectionNotionConfig } from '@roomote/types'; import type { Variables } from '../../../types'; @@ -62,7 +59,7 @@ async function resolveNotionMcpAuth( ); } -async function resolveNotionConnectionAndPolicy() { +async function resolveNotionConnection() { const [connection, enablement] = await Promise.all([ db.query.mcpConnections.findFirst({ where: and( @@ -77,10 +74,7 @@ async function resolveNotionConnectionAndPolicy() { eq(deploymentMcpEnablements.mcpId, 'notion'), eq(deploymentMcpEnablements.enabled, true), ), - columns: { - disabledTools: true, - toolAccessMode: true, - }, + columns: { mcpId: true }, }), ]); @@ -98,28 +92,18 @@ async function resolveNotionConnectionAndPolicy() { ); } - return { - config: connection.authConfig, - policy: { - allowedToolNames: - getAllowedIntegrationMcpToolNames( - 'notion', - enablement.toolAccessMode, - ) ?? undefined, - disabledToolNames: enablement.disabledTools, - }, - }; + return connection.authConfig; } function createNotionMcpServer( - resolved: Awaited>, + config: Awaited>, ) { const server = new McpServer(NOTION_MCP_SERVER_INFO, { instructions: 'Use these Notion tools only for content explicitly shared with the deployment internal integration. Unshared pages, including private pages, are inaccessible to the stored token.', }); - registerNotionTools(server, resolved.config, resolved.policy); + registerNotionTools(server, config); return server; } @@ -132,8 +116,8 @@ notionMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { try { await resolveNotionMcpAuth(c.get('authContext')); - const connectionAndPolicy = await resolveNotionConnectionAndPolicy(); - const server = createNotionMcpServer(connectionAndPolicy); + const connection = await resolveNotionConnection(); + const server = createNotionMcpServer(connection); await server.connect(transport); return await transport.handleRequest(c.req.raw); diff --git a/apps/api/src/handlers/mcp/notion/tools.ts b/apps/api/src/handlers/mcp/notion/tools.ts index ee354befb..ef859f6f5 100644 --- a/apps/api/src/handlers/mcp/notion/tools.ts +++ b/apps/api/src/handlers/mcp/notion/tools.ts @@ -1,6 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { McpConnectionNotionConfig, McpToolPolicy } from '@roomote/types'; -import { isMcpToolAllowed } from '@roomote/types'; +import type { McpConnectionNotionConfig } from '@roomote/types'; import { z } from 'zod'; import { toMcpToolResult } from '../proxy-utils'; @@ -27,350 +26,313 @@ const paginationSchema = { page_size: z.number().int().min(1).max(100).optional(), } as const; -function registerIfAllowed( - server: McpServer, - policy: McpToolPolicy, - toolName: string, - register: () => void, -) { - if (isMcpToolAllowed(toolName, policy)) { - register(); - } -} - function registerSearchTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-search'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Search Notion', - description: - 'Search pages and data sources explicitly shared with the deployment Notion integration.', - inputSchema: { - query: z.string().optional(), - object_type: z.enum(['page', 'data_source']).optional(), - ...paginationSchema, - }, - outputSchema: z.object({}).passthrough(), - annotations: READ_ONLY_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Search Notion', + description: + 'Search pages and data sources explicitly shared with the deployment Notion integration.', + inputSchema: { + query: z.string().optional(), + object_type: z.enum(['page', 'data_source']).optional(), + ...paginationSchema, }, - async ({ query, object_type: objectType, start_cursor, page_size }) => { - const response = await notionApiRequestJson>({ - config, - path: 'search', - method: 'POST', - body: { - ...(query ? { query } : {}), - ...(objectType - ? { filter: { property: 'object', value: objectType } } - : {}), - ...(start_cursor ? { start_cursor } : {}), - ...(page_size ? { page_size } : {}), - }, - }); + outputSchema: z.object({}).passthrough(), + annotations: READ_ONLY_ANNOTATIONS, + }, + async ({ query, object_type: objectType, start_cursor, page_size }) => { + const response = await notionApiRequestJson>({ + config, + path: 'search', + method: 'POST', + body: { + ...(query ? { query } : {}), + ...(objectType + ? { filter: { property: 'object', value: objectType } } + : {}), + ...(start_cursor ? { start_cursor } : {}), + ...(page_size ? { page_size } : {}), + }, + }); - return toMcpToolResult(response); - }, - ); - }); + return toMcpToolResult(response); + }, + ); } function registerFetchTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-fetch'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Fetch Notion Content', - description: - 'Fetch a page, data source, or block explicitly shared with the deployment Notion integration. Page and block fetches include one page of child blocks.', - inputSchema: { - id: nonEmptyStringSchema, - object_type: z.enum(['page', 'data_source', 'block']).default('page'), - ...paginationSchema, - }, - outputSchema: z.object({}).passthrough(), - annotations: READ_ONLY_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Fetch Notion Content', + description: + 'Fetch a page, data source, or block explicitly shared with the deployment Notion integration. Page and block fetches include one page of child blocks.', + inputSchema: { + id: nonEmptyStringSchema, + object_type: z.enum(['page', 'data_source', 'block']).default('page'), + ...paginationSchema, }, - async ({ id, object_type: objectType, start_cursor, page_size }) => { - const encodedId = encodeURIComponent(id); - if (objectType === 'data_source') { - const dataSource = await notionApiRequestJson< - Record - >({ config, path: `data_sources/${encodedId}` }); - return toMcpToolResult({ data_source: dataSource }); - } - - const object = await notionApiRequestJson>({ + outputSchema: z.object({}).passthrough(), + annotations: READ_ONLY_ANNOTATIONS, + }, + async ({ id, object_type: objectType, start_cursor, page_size }) => { + const encodedId = encodeURIComponent(id); + if (objectType === 'data_source') { + const dataSource = await notionApiRequestJson>({ config, - path: - objectType === 'page' - ? `pages/${encodedId}` - : `blocks/${encodedId}`, - }); - const children = await notionApiRequestJson>({ - config, - path: `blocks/${encodedId}/children`, - query: { start_cursor, page_size }, + path: `data_sources/${encodedId}`, }); + return toMcpToolResult({ data_source: dataSource }); + } - return toMcpToolResult({ - [objectType]: object, - children, - }); - }, - ); - }); + const object = await notionApiRequestJson>({ + config, + path: + objectType === 'page' ? `pages/${encodedId}` : `blocks/${encodedId}`, + }); + const children = await notionApiRequestJson>({ + config, + path: `blocks/${encodedId}/children`, + query: { start_cursor, page_size }, + }); + + return toMcpToolResult({ + [objectType]: object, + children, + }); + }, + ); } function registerQueryDataSourceTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-query-data-sources'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Query Notion Data Source', - description: - 'Query rows from a data source explicitly shared with the deployment Notion integration.', - inputSchema: { - data_source_id: nonEmptyStringSchema, - filter: jsonObjectSchema.optional(), - sorts: z.array(jsonObjectSchema).optional(), - ...paginationSchema, - }, - outputSchema: z.object({}).passthrough(), - annotations: READ_ONLY_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Query Notion Data Source', + description: + 'Query rows from a data source explicitly shared with the deployment Notion integration.', + inputSchema: { + data_source_id: nonEmptyStringSchema, + filter: jsonObjectSchema.optional(), + sorts: z.array(jsonObjectSchema).optional(), + ...paginationSchema, }, - async ({ - data_source_id: dataSourceId, - filter, - sorts, - start_cursor, - page_size, - }) => { - const response = await notionApiRequestJson>({ - config, - path: `data_sources/${encodeURIComponent(dataSourceId)}/query`, - method: 'POST', - body: { - ...(filter ? { filter } : {}), - ...(sorts ? { sorts } : {}), - ...(start_cursor ? { start_cursor } : {}), - ...(page_size ? { page_size } : {}), - }, - }); - return toMcpToolResult(response); - }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: READ_ONLY_ANNOTATIONS, + }, + async ({ + data_source_id: dataSourceId, + filter, + sorts, + start_cursor, + page_size, + }) => { + const response = await notionApiRequestJson>({ + config, + path: `data_sources/${encodeURIComponent(dataSourceId)}/query`, + method: 'POST', + body: { + ...(filter ? { filter } : {}), + ...(sorts ? { sorts } : {}), + ...(start_cursor ? { start_cursor } : {}), + ...(page_size ? { page_size } : {}), + }, + }); + return toMcpToolResult(response); + }, + ); } function registerGetCommentsTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-get-comments'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Get Notion Comments', - description: - 'List comments on a page or block explicitly shared with the deployment Notion integration.', - inputSchema: { - block_id: nonEmptyStringSchema, - ...paginationSchema, - }, - outputSchema: z.object({}).passthrough(), - annotations: READ_ONLY_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Get Notion Comments', + description: + 'List comments on a page or block explicitly shared with the deployment Notion integration.', + inputSchema: { + block_id: nonEmptyStringSchema, + ...paginationSchema, }, - async ({ block_id: blockId, start_cursor, page_size }) => { - const response = await notionApiRequestJson>({ - config, - path: 'comments', - query: { block_id: blockId, start_cursor, page_size }, - }); - return toMcpToolResult(response); - }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: READ_ONLY_ANNOTATIONS, + }, + async ({ block_id: blockId, start_cursor, page_size }) => { + const response = await notionApiRequestJson>({ + config, + path: 'comments', + query: { block_id: blockId, start_cursor, page_size }, + }); + return toMcpToolResult(response); + }, + ); } function registerCreatePagesTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-create-pages'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Create Notion Page', - description: - 'Create a page beneath a parent page or data source available to the deployment Notion integration.', - inputSchema: { - parent: jsonObjectSchema, - properties: jsonObjectSchema, - children: z.array(jsonObjectSchema).optional(), - icon: jsonObjectSchema.optional(), - cover: jsonObjectSchema.optional(), - }, - outputSchema: z.object({}).passthrough(), - annotations: WRITE_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Create Notion Page', + description: + 'Create a page beneath a parent page or data source available to the deployment Notion integration.', + inputSchema: { + parent: jsonObjectSchema, + properties: jsonObjectSchema, + children: z.array(jsonObjectSchema).optional(), + icon: jsonObjectSchema.optional(), + cover: jsonObjectSchema.optional(), }, - async ({ parent, properties, children, icon, cover }) => { - const page = await notionApiRequestJson>({ - config, - path: 'pages', - method: 'POST', - body: { - parent, - properties, - ...(children ? { children } : {}), - ...(icon ? { icon } : {}), - ...(cover ? { cover } : {}), - }, - }); - return toMcpToolResult({ page }); - }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: WRITE_ANNOTATIONS, + }, + async ({ parent, properties, children, icon, cover }) => { + const page = await notionApiRequestJson>({ + config, + path: 'pages', + method: 'POST', + body: { + parent, + properties, + ...(children ? { children } : {}), + ...(icon ? { icon } : {}), + ...(cover ? { cover } : {}), + }, + }); + return toMcpToolResult({ page }); + }, + ); } function registerUpdatePageTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-update-page'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Update Notion Page', - description: - 'Update properties or trash state for a page available to the deployment Notion integration.', - inputSchema: { - page_id: nonEmptyStringSchema, - properties: jsonObjectSchema.optional(), - icon: jsonObjectSchema.nullable().optional(), - cover: jsonObjectSchema.nullable().optional(), - in_trash: z.boolean().optional(), - }, - outputSchema: z.object({}).passthrough(), - annotations: WRITE_ANNOTATIONS, + server.registerTool( + toolName, + { + title: 'Update Notion Page', + description: + 'Update properties or trash state for a page available to the deployment Notion integration.', + inputSchema: { + page_id: nonEmptyStringSchema, + properties: jsonObjectSchema.optional(), + icon: jsonObjectSchema.nullable().optional(), + cover: jsonObjectSchema.nullable().optional(), + in_trash: z.boolean().optional(), }, - async ({ page_id: pageId, properties, icon, cover, in_trash }) => { - const page = await notionApiRequestJson>({ - config, - path: `pages/${encodeURIComponent(pageId)}`, - method: 'PATCH', - body: { - ...(properties ? { properties } : {}), - ...(icon !== undefined ? { icon } : {}), - ...(cover !== undefined ? { cover } : {}), - ...(in_trash !== undefined ? { in_trash } : {}), - }, - }); - return toMcpToolResult({ page }); - }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: WRITE_ANNOTATIONS, + }, + async ({ page_id: pageId, properties, icon, cover, in_trash }) => { + const page = await notionApiRequestJson>({ + config, + path: `pages/${encodeURIComponent(pageId)}`, + method: 'PATCH', + body: { + ...(properties ? { properties } : {}), + ...(icon !== undefined ? { icon } : {}), + ...(cover !== undefined ? { cover } : {}), + ...(in_trash !== undefined ? { in_trash } : {}), + }, + }); + return toMcpToolResult({ page }); + }, + ); } function registerAppendBlocksTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-append-blocks'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Append Notion Blocks', - description: - 'Append content blocks to a page or block available to the deployment Notion integration.', - inputSchema: { - block_id: nonEmptyStringSchema, - children: z.array(jsonObjectSchema).min(1).max(100), - after: z.string().optional(), - }, - outputSchema: z.object({}).passthrough(), - annotations: WRITE_ANNOTATIONS, - }, - async ({ block_id: blockId, children, after }) => { - const response = await notionApiRequestJson>({ - config, - path: `blocks/${encodeURIComponent(blockId)}/children`, - method: 'PATCH', - body: { children, ...(after ? { after } : {}) }, - }); - return toMcpToolResult(response); + server.registerTool( + toolName, + { + title: 'Append Notion Blocks', + description: + 'Append content blocks to a page or block available to the deployment Notion integration.', + inputSchema: { + block_id: nonEmptyStringSchema, + children: z.array(jsonObjectSchema).min(1).max(100), + after: z.string().optional(), }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: WRITE_ANNOTATIONS, + }, + async ({ block_id: blockId, children, after }) => { + const response = await notionApiRequestJson>({ + config, + path: `blocks/${encodeURIComponent(blockId)}/children`, + method: 'PATCH', + body: { children, ...(after ? { after } : {}) }, + }); + return toMcpToolResult(response); + }, + ); } function registerCreateCommentTool( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { const toolName = 'notion-create-comment'; - registerIfAllowed(server, policy, toolName, () => { - server.registerTool( - toolName, - { - title: 'Create Notion Comment', - description: - 'Create a comment on a page available to the deployment Notion integration.', - inputSchema: { - parent: jsonObjectSchema, - rich_text: z.array(jsonObjectSchema).min(1), - }, - outputSchema: z.object({}).passthrough(), - annotations: WRITE_ANNOTATIONS, - }, - async ({ parent, rich_text: richText }) => { - const comment = await notionApiRequestJson>({ - config, - path: 'comments', - method: 'POST', - body: { parent, rich_text: richText }, - }); - return toMcpToolResult({ comment }); + server.registerTool( + toolName, + { + title: 'Create Notion Comment', + description: + 'Create a comment on a page available to the deployment Notion integration.', + inputSchema: { + parent: jsonObjectSchema, + rich_text: z.array(jsonObjectSchema).min(1), }, - ); - }); + outputSchema: z.object({}).passthrough(), + annotations: WRITE_ANNOTATIONS, + }, + async ({ parent, rich_text: richText }) => { + const comment = await notionApiRequestJson>({ + config, + path: 'comments', + method: 'POST', + body: { parent, rich_text: richText }, + }); + return toMcpToolResult({ comment }); + }, + ); } export function registerNotionTools( server: McpServer, config: McpConnectionNotionConfig, - policy: McpToolPolicy, ) { - registerSearchTool(server, config, policy); - registerFetchTool(server, config, policy); - registerQueryDataSourceTool(server, config, policy); - registerGetCommentsTool(server, config, policy); - registerCreatePagesTool(server, config, policy); - registerUpdatePageTool(server, config, policy); - registerAppendBlocksTool(server, config, policy); - registerCreateCommentTool(server, config, policy); + registerSearchTool(server, config); + registerFetchTool(server, config); + registerQueryDataSourceTool(server, config); + registerGetCommentsTool(server, config); + registerCreatePagesTool(server, config); + registerUpdatePageTool(server, config); + registerAppendBlocksTool(server, config); + registerCreateCommentTool(server, config); } diff --git a/apps/docs/integrations/notion.mdx b/apps/docs/integrations/notion.mdx index b08c19b74..d8151243b 100644 --- a/apps/docs/integrations/notion.mdx +++ b/apps/docs/integrations/notion.mdx @@ -21,9 +21,9 @@ access list in Notion whenever the page hierarchy changes. 1. In [Notion integrations](https://www.notion.so/profile/integrations/internal), create an internal integration for Roomote. -2. Enable read-content capabilities. If you intend to use Roomote's **Read and - write** mode, also enable the required update, insert, and comment - capabilities in Notion. +2. In the integration's **Configuration** tab, enable only the capabilities + Roomote should have: read, update, insert, and comment access are controlled + independently by Notion. 3. In Notion, share only the approved pages and data sources with the new integration. You can manage this from the integration's **Content access** settings or a page's **Connections** menu. @@ -39,16 +39,9 @@ The secret is encrypted server-side and is never sent to task sandboxes. integration before enabling this deployment-wide connection. -## Choose read or write access +## Control capabilities -Notion starts in **Read only** mode. In **Manage tools**, an admin can choose: - -- **Read only (recommended)** — search, fetch, query, and read comments within - the explicitly shared content -- **Read and write** — additionally create and update pages, append blocks, and - create comments within that same content boundary - -The selected mode applies to every Roomote task and automation in the -deployment. Individual tools can still be disabled. The Notion integration's -own capabilities remain authoritative, so Roomote cannot perform a write that -the token does not permit. +Notion's **Configuration** tab is the source of truth for what Roomote can do. +Disable update, insert, or comment capabilities there when the connection +should be read-only. Roomote exposes its Notion tools, but Notion rejects any +operation the integration's capabilities do not permit. diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 35530aff9..625444844 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -25,12 +25,10 @@ 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, @@ -97,7 +95,7 @@ const state = vi.hoisted(() => ({ searchParams: '', })); -const { mutations, selectMock, radioMock } = vi.hoisted(() => ({ +const { mutations, selectMock } = vi.hoisted(() => ({ mutations: { connectLinear: vi.fn(), disconnectLinear: vi.fn(), @@ -119,10 +117,6 @@ const { mutations, selectMock, radioMock } = 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', () => ({ @@ -418,28 +412,6 @@ 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, @@ -1442,62 +1414,21 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); - it('lets admins opt a deployment-wide Notion connection into read-write access', () => { + it('does not show duplicate tool management for Notion', () => { state.deploymentEnablements = [{ mcpId: 'notion', enabled: true }]; state.userConnections = [ { id: 'conn-notion', mcpId: 'notion', authStatus: 'authenticated' }, ]; state.notionConnection = { authStatus: 'authenticated' }; - state.mcpTools = { - mcpId: 'notion', - toolAccessMode: 'read_only', - tools: [ - { - name: 'notion-fetch', - description: 'Fetch a Notion page', - enabled: true, - availableInReadOnly: true, - }, - { - name: 'notion-update-page', - description: 'Update a Notion page', - enabled: true, - availableInReadOnly: false, - }, - ], - }; render(); - fireEvent.click( - screen.getByRole('button', { name: 'Manage Notion tools' }), - ); - - expect(screen.getByLabelText('Read only (recommended)')).toBeChecked(); - expect( - screen.getByRole('button', { name: 'Enable notion-update-page' }), - ).toBeDisabled(); - fireEvent.click(screen.getByLabelText('Read and write')); expect( - screen.getByRole('button', { name: 'Disable notion-update-page' }), - ).toBeEnabled(); - + screen.queryByRole('button', { name: 'Manage Notion tools' }), + ).not.toBeInTheDocument(); expect( - screen.getByText( - "Read and write access remains limited to pages and data sources explicitly shared with the deployment's Notion internal integration.", - ), + screen.getByRole('button', { name: 'Edit Notion connection' }), ).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); - - expect(mutations.setDisabledTools).toHaveBeenCalledWith( - { - mcpId: 'notion', - disabledTools: [], - toolAccessMode: 'read_write', - }, - expect.any(Object), - ); }); it('opens the Snowflake credential dialog from the integrations page', () => { @@ -1558,6 +1489,11 @@ describe('Integrations settings', () => { /share only the approved pages or data sources with it/i, ), ).toBeInTheDocument(); + expect( + screen.getByText( + /choose its read, update, insert, and comment capabilities/i, + ), + ).toBeInTheDocument(); }); it('lets admins replace a legacy Notion OAuth connection in place', () => { diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index e9544917c..c500c574a 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -109,7 +109,7 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect monday.com boards, items, updates, docs, and workspace context.', neon: 'Roomote will get database access to inspect schemas and query data.', notion: - 'Roomote will use one deployment-wide Notion internal integration. Only explicitly shared pages and data sources are accessible. It starts read-only, and admins can optionally allow writes.', + 'Roomote will use one deployment-wide Notion internal integration. Notion controls its capabilities and which pages and data sources it can access.', pylon: 'Roomote will be able to inspect customer issues, message history, and account context.', posthog: @@ -515,7 +515,7 @@ function buildAdminConfiguredIntegrationItem({ secondaryAction: canManageTools && enabled && - (integration.serverMode !== 'native' || integration.id === 'notion') && + integration.serverMode !== 'native' && integration.serverMode !== 'credential_only' ? { label: 'Manage tools', @@ -912,8 +912,10 @@ function NotionConnectionFields({ > Notion integrations - , then share only the approved pages or data sources with it. Roomote - cannot access anything that has not been shared with this connection. + . In Notion, choose its read, update, insert, and comment + capabilities, then share only the approved pages or data sources with + it. Roomote cannot access anything that has not been shared with this + connection.

{allowBlankSecret ? (

@@ -1838,7 +1840,7 @@ export function Integrations() { dialogOpen: isNotionDialogOpen, connectionPending: notionConnection.isPending, canConfigure: isAdmin, - canManageTools: isAdmin, + canManageTools: false, openDialog: () => setIsNotionDialogOpen(true), openToolDialog: () => openMcpToolDialog(integration), disconnectIntegration: () => @@ -2804,8 +2806,9 @@ export function Integrations() { description={ <> Store a Notion internal integration secret for this deployment. - Notion limits it to pages and data sources explicitly shared with - that integration; the secret stays encrypted server-side. + Notion controls the connection's capabilities and limits it to + pages and data sources explicitly shared with that integration; the + secret stays encrypted server-side. } onSubmit={handleNotionSubmit} diff --git a/apps/web/src/components/settings/McpToolManagementDialog.tsx b/apps/web/src/components/settings/McpToolManagementDialog.tsx index 858a261e4..e58e96b61 100644 --- a/apps/web/src/components/settings/McpToolManagementDialog.tsx +++ b/apps/web/src/components/settings/McpToolManagementDialog.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { toast } from 'sonner'; -import type { McpToolAccessMode } from '@roomote/types'; import { Alert, @@ -18,13 +17,10 @@ import { DialogHeader, DialogTitle, Label, - RadioGroup, - RadioGroupItem, Spinner, Switch, ToggleLeft, ToggleRight, - TriangleAlert, } from '@/components/system'; import { useMcpConnectionTools, @@ -111,8 +107,6 @@ export function McpToolManagementDialog({ const toolsQuery = useMcpConnectionTools(open ? mcpId : null); const setDisabledTools = useSetDisabledMcpTools(); const [disabledToolNames, setDisabledToolNames] = useState([]); - const [toolAccessMode, setToolAccessMode] = - useState(null); const lastSyncedToolStateKey = useRef(null); const initialDisabledToolNames = useMemo( @@ -128,8 +122,6 @@ export function McpToolManagementDialog({ () => initialDisabledToolNames.join('\n'), [initialDisabledToolNames], ); - const initialToolAccessMode = toolsQuery.data?.toolAccessMode ?? null; - useEffect(() => { if (!open) { lastSyncedToolStateKey.current = null; @@ -140,7 +132,7 @@ export function McpToolManagementDialog({ return; } - const nextToolStateKey = `${mcpId}\n${initialToolAccessMode ?? ''}\n${initialDisabledToolNamesKey}`; + const nextToolStateKey = `${mcpId}\n${initialDisabledToolNamesKey}`; if (lastSyncedToolStateKey.current === nextToolStateKey) { return; @@ -148,11 +140,9 @@ export function McpToolManagementDialog({ lastSyncedToolStateKey.current = nextToolStateKey; setDisabledToolNames(initialDisabledToolNames); - setToolAccessMode(initialToolAccessMode); }, [ initialDisabledToolNames, initialDisabledToolNamesKey, - initialToolAccessMode, mcpId, open, toolsQuery.status, @@ -166,8 +156,7 @@ export function McpToolManagementDialog({ const isDirty = initialDisabledToolNames.join('\n') !== - normalizedDisabledToolNames.join('\n') || - initialToolAccessMode !== toolAccessMode; + normalizedDisabledToolNames.join('\n'); const loadedTools = toolsQuery.data?.tools ?? []; const hasLoadedTools = !toolsQuery.isPending && @@ -175,9 +164,7 @@ export function McpToolManagementDialog({ toolsQuery.data != null && loadedTools.length > 0; const showBulkToolActions = loadedTools.length > 3; - const isToolAvailableInSelectedMode = (tool: (typeof loadedTools)[number]) => - toolAccessMode !== 'read_only' || tool.availableInReadOnly !== false; - const availableTools = loadedTools.filter(isToolAvailableInSelectedMode); + const availableTools = loadedTools; const hasEnabledTools = availableTools.some( (tool) => !normalizedDisabledToolNames.includes(tool.name), ); @@ -220,7 +207,6 @@ export function McpToolManagementDialog({ { mcpId, disabledTools: normalizedDisabledToolNames, - ...(toolAccessMode ? { toolAccessMode } : {}), }, { onSuccess: () => { @@ -246,9 +232,7 @@ export function McpToolManagementDialog({ Manage {integrationName ?? 'integration'} tools - {initialToolAccessMode - ? 'Choose the deployment access level and manage individual MCP tools.' - : 'Enable or disable MCP tools for this integration.'} + Enable or disable MCP tools for this integration. @@ -286,74 +270,10 @@ export function McpToolManagementDialog({ {hasLoadedTools ? (

- {toolAccessMode ? ( -
-
- -

- This setting applies to every Roomote task and automation - in this deployment. -

-
- { - if (value === 'read_only' || value === 'read_write') { - setToolAccessMode(value); - } - }} - className="space-y-3" - > -
- -
- -

- Allow searching and reading content, while blocking - changes. -

-
-
-
- -
- -

- Allow tasks and unattended automations to create, - update, move, and comment on accessible content. -

-
-
-
- {toolAccessMode === 'read_write' ? ( - - - - Read and write access remains limited to pages and data - sources explicitly shared with the deployment's - Notion internal integration. - - - ) : null} -
- ) : null} {loadedTools.map((tool, index) => { - const available = isToolAvailableInSelectedMode(tool); - const enabled = - available && !normalizedDisabledToolNames.includes(tool.name); + const enabled = !normalizedDisabledToolNames.includes( + tool.name, + ); const switchId = `mcp-tool-${mcpId ?? 'unknown'}-${index}`; return ( @@ -367,7 +287,7 @@ export function McpToolManagementDialog({ id={switchId} checked={enabled} aria-label={`${enabled ? 'Disable' : 'Enable'} ${tool.name}`} - disabled={setDisabledTools.isPending || !available} + disabled={setDisabledTools.isPending} onCheckedChange={(nextEnabled) => handleToggle(tool.name, nextEnabled) } @@ -379,11 +299,6 @@ export function McpToolManagementDialog({ {prettifyToolName(tool.name, integrationName)}
- {!available ? ( -

- Requires read and write access. -

- ) : null} ); diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 39609c706..62173bdc1 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -12,12 +12,9 @@ import { filterMcpToolDefinitions, getDefaultMcpConnectionRole, getAllowedIntegrationMcpToolNames, - getMcpIntegrationToolAccessModeConfig, getMcpIntegration, getMcpIntegrationConnectionScope, getMcpIntegrationDefaultDisabledTools, - NOTION_MCP_TOOL_DEFINITIONS, - NOTION_READ_ONLY_TOOL_NAMES, type McpConnectionRole, isMcpConnectionAsanaConfig, isMcpConnectionNotionConfig, @@ -33,10 +30,8 @@ import { MCP_INTEGRATIONS, normalizeGrafanaBaseUrl, type McpIntegration, - type McpToolAccessMode, type McpToolsListJsonRpcPayload, parseMcpJsonRpcPayload, - resolveMcpIntegrationToolAccessMode, } from '@roomote/types'; import { decrypt, encrypt } from '@roomote/db/encryption'; import { getValidAccessToken } from '@roomote/sdk/server'; @@ -126,7 +121,6 @@ type ListedMcpTool = { name: string; description: string | null; enabled: boolean; - availableInReadOnly: boolean | null; }; type VisibleMcpConnectionForCatalog = { @@ -138,7 +132,6 @@ type DeploymentMcpEnablementForTools = { mcpId: string; enabled: boolean; disabledTools: string[] | null; - toolAccessMode: McpToolAccessMode | null; }; async function getDeploymentMcpEnablementForTools( @@ -150,7 +143,6 @@ async function getDeploymentMcpEnablementForTools( mcpId: true, enabled: true, disabledTools: true, - toolAccessMode: true, }, }); @@ -547,22 +539,17 @@ async function fetchUpstreamMcpTools(input: { ] : [], ); - const accessModeConfig = - getMcpIntegrationToolAccessModeConfig(resolvedIntegration); const allowedToolNames = getAllowedIntegrationMcpToolNames(resolvedIntegration); - const visibleTools = accessModeConfig - ? toolDefinitions - : filterMcpToolDefinitions(toolDefinitions, { allowedToolNames }); + const visibleTools = filterMcpToolDefinitions(toolDefinitions, { + allowedToolNames, + }); return visibleTools.map((tool) => ({ ...tool, enabled: isMcpToolAllowed(tool.name, { disabledToolNames: input.disabledTools, }), - availableInReadOnly: accessModeConfig - ? accessModeConfig.readOnlyToolNames.includes(tool.name) - : null, })); } @@ -1264,14 +1251,13 @@ export async function saveNotionConnectionCommand( mcpId: 'notion', enabled: true, enabledByUserId: auth.userId, - toolAccessMode: 'read_only', }) .onConflictDoUpdate({ target: [deploymentMcpEnablements.mcpId], set: { enabled: true, enabledByUserId: auth.userId, - ...(!existingConfig ? { toolAccessMode: 'read_only' as const } : {}), + disabledTools: null, updatedAt: new Date(), }, }); @@ -1717,31 +1703,13 @@ export async function listDeploymentMcpIntegrationToolsCommand( auth, input.mcpId, ); - const toolAccessMode = resolveMcpIntegrationToolAccessMode( - input.mcpId, - enablement.toolAccessMode, - ); - const nativeNotionTools = NOTION_MCP_TOOL_DEFINITIONS.map((tool) => ({ - ...tool, - enabled: isMcpToolAllowed(tool.name, { - disabledToolNames: enablement.disabledTools, - }), - availableInReadOnly: NOTION_READ_ONLY_TOOL_NAMES.includes( - tool.name as (typeof NOTION_READ_ONLY_TOOL_NAMES)[number], - ), - })); - return { mcpId: enablement.mcpId, - toolAccessMode: toolAccessMode ?? null, - tools: - input.mcpId === 'notion' - ? nativeNotionTools - : await fetchUpstreamMcpTools({ - id: connection.id, - mcpId: connection.mcpId, - disabledTools: enablement.disabledTools, - }), + tools: await fetchUpstreamMcpTools({ + id: connection.id, + mcpId: connection.mcpId, + disabledTools: enablement.disabledTools, + }), }; } @@ -1750,7 +1718,6 @@ export async function setDeploymentDisabledMcpIntegrationToolsCommand( input: { mcpId: string; disabledTools: string[]; - toolAccessMode?: McpToolAccessMode; }, ) { assertAdmin(auth); @@ -1762,17 +1729,6 @@ export async function setDeploymentDisabledMcpIntegrationToolsCommand( await getDeploymentMcpEnablementForTools(input.mcpId); - const accessModeConfig = getMcpIntegrationToolAccessModeConfig(integration); - if ( - input.toolAccessMode !== undefined && - (!accessModeConfig || - !accessModeConfig.supportedModes.includes(input.toolAccessMode)) - ) { - throw new Error( - `${integration.name} does not support the requested tool access mode.`, - ); - } - const disabledTools = Array.from( new Set( input.disabledTools @@ -1785,16 +1741,12 @@ export async function setDeploymentDisabledMcpIntegrationToolsCommand( .update(deploymentMcpEnablements) .set({ disabledTools: disabledTools.length > 0 ? disabledTools : null, - ...(input.toolAccessMode !== undefined - ? { toolAccessMode: input.toolAccessMode } - : {}), updatedAt: new Date(), }) .where(eq(deploymentMcpEnablements.mcpId, input.mcpId)) .returning({ mcpId: deploymentMcpEnablements.mcpId, disabledTools: deploymentMcpEnablements.disabledTools, - toolAccessMode: deploymentMcpEnablements.toolAccessMode, }); if (!updatedEnablement) { diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index fc80ce144..39a09010a 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -11,7 +11,6 @@ import { launchCodingHarnesses, computeProviders, environmentConfigSchema, - MCP_TOOL_ACCESS_MODES, workspaceRoutingSettingsSchema, ENVIRONMENT_DEFINITION_SETUP_GUIDANCE_MAX_LENGTH, REASONING_EFFORT_VALUES, @@ -1799,7 +1798,6 @@ export const appRouter = createRouter({ z.object({ mcpId: z.string(), disabledTools: z.array(z.string().min(1)), - toolAccessMode: z.enum(MCP_TOOL_ACCESS_MODES).optional(), }), ) .mutation(({ ctx: { auth }, input }) => 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 79ffc4cd9..8462bf8cd 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -27,11 +27,10 @@ GitHub is connected via a GitHub App installation. An admin installs the Roomote Notion uses one deployment-wide internal integration whose content access is enforced by Notion: 1. A deployment operator opens Settings > Integrations. -2. In Notion, that operator creates an internal integration and shares only approved pages and data sources with it. +2. In Notion, that operator creates an internal integration, selects its capabilities, and shares only approved pages and data sources with it. 3. The operator stores the internal integration secret in Roomote. -4. The operator keeps the default read-only access or explicitly enables read and write access in Manage tools. -Once connected, I can use the permitted Notion tools during both interactive tasks and automations. Unshared content, including private pages, is unavailable. Read-write access applies deployment-wide but remains inside the same Notion-enforced content boundary. +Once connected, I can use the permitted Notion tools during both interactive tasks and automations. Notion remains the source of truth for both capabilities and content access. Unshared content, including private pages, is unavailable. # Jira 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 ff3c69a2e..845063dc8 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -70,7 +70,7 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< capabilities: [ 'Search and read only explicitly shared Notion pages and data sources', 'Pull requirements and product docs into task context', - 'Optionally create and update shared content when an admin enables read-write access', + 'Create and update shared content when the Notion integration capabilities permit it', ], }, jira: { diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index e7be8fce6..5ed139d3e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -3564,6 +3564,7 @@ export const deploymentMcpEnablements = pgTable( onDelete: 'set null', }), disabledTools: text('disabled_tools').array(), + // N-1 rollback: retained for the previous release's Notion access-mode code. toolAccessMode: text('tool_access_mode').$type(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index 6041ed8a6..9a449c970 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -40,7 +40,7 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { posthog: 'Roomote will be able to inspect analytics, feature flags, and experiments.', notion: - 'Roomote will use one deployment-wide Notion internal integration. Only explicitly shared pages and data sources are accessible. It starts read-only, and admins can optionally allow writes.', + 'Roomote will use one deployment-wide Notion internal integration. Notion controls its capabilities and which pages and data sources are accessible.', jira: 'Roomote will be able to inspect Jira issues, workflows, and JQL search results.', neon: 'Roomote will get database access to inspect schemas and query data.', pylon: diff --git a/packages/types/src/__tests__/mcp-tool-policy.test.ts b/packages/types/src/__tests__/mcp-tool-policy.test.ts index 6740dff37..55cfb6ddc 100644 --- a/packages/types/src/__tests__/mcp-tool-policy.test.ts +++ b/packages/types/src/__tests__/mcp-tool-policy.test.ts @@ -1,59 +1,8 @@ import { filterMcpToolDefinitions, getAllowedIntegrationMcpToolNames, - getMcpIntegrationToolAccessModeConfig, - NOTION_READ_ONLY_TOOL_NAMES, - resolveMcpIntegrationToolAccessMode, } from '../mcp-tool-policy'; -describe('Notion MCP tool access modes', () => { - it('defaults missing and invalid values to a fail-closed read-only policy', () => { - expect(resolveMcpIntegrationToolAccessMode('notion', null)).toBe( - 'read_only', - ); - expect(resolveMcpIntegrationToolAccessMode('notion', 'unexpected')).toBe( - 'read_only', - ); - expect(getAllowedIntegrationMcpToolNames('notion')).toEqual( - NOTION_READ_ONLY_TOOL_NAMES, - ); - expect(getAllowedIntegrationMcpToolNames('notion', 'unexpected')).toEqual( - NOTION_READ_ONLY_TOOL_NAMES, - ); - }); - - it('allows only documented non-mutating tools in read-only mode', () => { - const allowedToolNames = getAllowedIntegrationMcpToolNames( - 'notion', - 'read_only', - ); - - expect(allowedToolNames).toEqual( - expect.arrayContaining([ - 'notion-search', - 'notion-fetch', - 'notion-query-data-sources', - 'notion-get-comments', - ]), - ); - expect(allowedToolNames).not.toContain('notion-create-pages'); - expect(allowedToolNames).not.toContain('notion-update-page'); - expect(allowedToolNames).not.toContain('notion-create-comment'); - expect(allowedToolNames).not.toContain('notion-append-blocks'); - }); - - it('removes the allowlist only after read-write is explicitly selected', () => { - expect( - getAllowedIntegrationMcpToolNames('notion', 'read_write'), - ).toBeUndefined(); - expect(getMcpIntegrationToolAccessModeConfig('notion')).toMatchObject({ - defaultMode: 'read_only', - supportedModes: ['read_only', 'read_write'], - }); - expect(getMcpIntegrationToolAccessModeConfig('sentry')).toBeUndefined(); - }); -}); - describe('Better Stack MCP tool policy', () => { it('allows current read-only tools and excludes obsolete and mutating names', () => { const allowedToolNames = getAllowedIntegrationMcpToolNames('betterstack'); diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index c35a1387e..839c93756 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -411,7 +411,7 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ connectionMode: 'admin_configured', serverMode: 'native', instructions: - 'Use Notion for pages and data sources explicitly shared with the deployment integration. Content outside that connection boundary, including unshared private pages, is unavailable. Writes are available only when a deployment admin enables read-write mode.', + 'Use Notion for pages and data sources explicitly shared with the deployment integration. Content outside that connection boundary, including unshared private pages, is unavailable. Notion controls whether the connection may read, update, insert, or comment.', }, { id: 'jira', diff --git a/packages/types/src/mcp-tool-policy.ts b/packages/types/src/mcp-tool-policy.ts index f3b9acbb7..a889d2825 100644 --- a/packages/types/src/mcp-tool-policy.ts +++ b/packages/types/src/mcp-tool-policy.ts @@ -4,66 +4,6 @@ export const MCP_TOOL_ACCESS_MODES = ['read_only', 'read_write'] as const; export type McpToolAccessMode = (typeof MCP_TOOL_ACCESS_MODES)[number]; -export type McpToolAccessModeConfig = { - readonly defaultMode: McpToolAccessMode; - readonly supportedModes: readonly McpToolAccessMode[]; - readonly readOnlyToolNames: readonly string[]; -}; - -/** Roomote's native Notion tools that do not mutate content. */ -export const NOTION_READ_ONLY_TOOL_NAMES = [ - 'notion-search', - 'notion-fetch', - 'notion-query-data-sources', - 'notion-get-comments', -] as const; - -export const NOTION_MCP_TOOL_DEFINITIONS = [ - { - name: 'notion-search', - description: 'Search explicitly shared Notion pages and data sources.', - }, - { - name: 'notion-fetch', - description: - 'Fetch an explicitly shared Notion page, data source, or block.', - }, - { - name: 'notion-query-data-sources', - description: 'Query rows from an explicitly shared Notion data source.', - }, - { - name: 'notion-get-comments', - description: 'List comments on an explicitly shared Notion page or block.', - }, - { - name: 'notion-create-pages', - description: 'Create a page beneath an available Notion parent.', - }, - { - name: 'notion-update-page', - description: 'Update an available Notion page.', - }, - { - name: 'notion-append-blocks', - description: 'Append content blocks to an available Notion page or block.', - }, - { - name: 'notion-create-comment', - description: 'Create a comment on an available Notion page.', - }, -] as const; - -const INTEGRATION_MCP_TOOL_ACCESS_MODE_CONFIGS: Readonly< - Partial> -> = { - notion: { - defaultMode: 'read_only', - supportedModes: MCP_TOOL_ACCESS_MODES, - readOnlyToolNames: NOTION_READ_ONLY_TOOL_NAMES, - }, -}; - const BETTER_STACK_READ_ONLY_UPTIME_TOOL_NAMES = [ 'escalation_policy', 'heartbeat_availability', @@ -289,48 +229,13 @@ export type McpToolPolicy = { export function getAllowedIntegrationMcpToolNames( integrationOrId: McpIntegration | string, - toolAccessMode?: string | null, ): readonly string[] | undefined { const integrationId = typeof integrationOrId === 'string' ? integrationOrId : integrationOrId.id; - const accessModeConfig = - INTEGRATION_MCP_TOOL_ACCESS_MODE_CONFIGS[integrationId]; - if (accessModeConfig) { - return resolveMcpIntegrationToolAccessMode( - integrationId, - toolAccessMode, - ) === 'read_write' - ? undefined - : accessModeConfig.readOnlyToolNames; - } - return INTEGRATION_MCP_ALLOWED_TOOL_NAMES[integrationId]; } -export function getMcpIntegrationToolAccessModeConfig( - integrationOrId: McpIntegration | string, -): McpToolAccessModeConfig | undefined { - const integrationId = - typeof integrationOrId === 'string' ? integrationOrId : integrationOrId.id; - - return INTEGRATION_MCP_TOOL_ACCESS_MODE_CONFIGS[integrationId]; -} - -export function resolveMcpIntegrationToolAccessMode( - integrationOrId: McpIntegration | string, - storedMode?: string | null, -): McpToolAccessMode | undefined { - const config = getMcpIntegrationToolAccessModeConfig(integrationOrId); - if (!config) { - return undefined; - } - - return config.supportedModes.includes(storedMode as McpToolAccessMode) - ? (storedMode as McpToolAccessMode) - : config.defaultMode; -} - export function isMcpToolAllowed( toolName: string, policy: McpToolPolicy,