diff --git a/.changeset/calm-notion-automations.md b/.changeset/calm-notion-automations.md index fce8d28df..d5d3a4ff2 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 connection for tasks and automations, with admin-configurable read-only or read-write tool access. +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. 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 8e49d2daa..ac5bdb508 100644 --- a/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/integration-mcp.test.ts @@ -186,20 +186,6 @@ describe('createIntegrationMcpProxy acting-user scoping', () => { expect(response.status).toBe(200); }); - 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 }); @@ -214,94 +200,6 @@ 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/__tests__/notion-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts new file mode 100644 index 000000000..da742fc32 --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/notion-auth.test.ts @@ -0,0 +1,243 @@ +import { Hono } from 'hono'; +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +const { + mockFindTaskRun, + mockFindConnection, + mockFindEnablement, + mockEq, + mockAnd, + mockIsNull, +} = vi.hoisted(() => ({ + mockFindTaskRun: vi.fn(), + mockFindConnection: vi.fn(), + mockFindEnablement: vi.fn(), + mockEq: vi.fn((column: unknown, value: unknown) => ({ column, value })), + mockAnd: vi.fn((...clauses: unknown[]) => clauses), + mockIsNull: vi.fn((column: unknown) => ({ type: 'isNull', column })), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { findFirst: mockFindTaskRun }, + mcpConnections: { findFirst: mockFindConnection }, + deploymentMcpEnablements: { findFirst: mockFindEnablement }, + }, + }, + taskRuns: { id: 'taskRun.id' }, + mcpConnections: { + mcpId: 'connection.mcpId', + enabled: 'connection.enabled', + authStatus: 'connection.authStatus', + userId: 'connection.userId', + }, + deploymentMcpEnablements: { + mcpId: 'enablement.mcpId', + enabled: 'enablement.enabled', + }, + eq: mockEq, + and: mockAnd, + isNull: mockIsNull, +})); + +vi.mock('@roomote/db/encryption', () => ({ + decrypt: vi.fn((value: string) => value.replace(/^enc:/, '')), +})); + +import { notionMcp } from '../notion'; + +function createRunToken(overrides?: Partial): RunTokenContext { + return { + runId: 42, + userId: null, + principal: 'deployment', + tokenType: 'run', + version: 1, + ...overrides, + }; +} + +function createApp(authContext: Variables['authContext']) { + const app = new Hono<{ Variables: Variables }>(); + app.use('*', async (c, next) => { + c.set('authContext', authContext); + await next(); + }); + app.route('/mcp', notionMcp); + return app; +} + +async function postMcp(app: Hono<{ Variables: Variables }>, body: unknown) { + return app.request('/mcp', { + method: 'POST', + headers: { + accept: 'application/json, text/event-stream', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); +} + +function createToolCallRequest(name: string, args: Record) { + return { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args }, + }; +} + +describe('native Notion MCP', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + mockFindTaskRun.mockResolvedValue({ id: 42 }); + mockFindConnection.mockResolvedValue({ + id: 'conn-notion', + userId: null, + mcpId: 'notion', + enabled: true, + authStatus: 'authenticated', + authConfig: { + type: 'notion', + encryptedToken: 'enc:notion-internal-secret', + }, + }); + mockFindEnablement.mockResolvedValue({ + disabledTools: null, + toolAccessMode: 'read_only', + }); + }); + + it('rejects user auth tokens', async () => { + const authToken: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + const response = await postMcp(createApp(authToken), { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + }); + + expect(response.status).toBe(403); + }); + + it('exposes only read tools by default', async () => { + 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 }> }; + }; + const toolNames = body.result.tools.map((tool) => tool.name); + + expect(response.status).toBe(200); + expect(toolNames).toEqual( + expect.arrayContaining([ + 'notion-search', + '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', + 'notion-create-comment', + ]), + ); + }); + + 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: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCallRequest('notion-search', { query: 'roadmap' }), + ); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://api.notion.com/v1/search'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer notion-internal-secret', + 'Notion-Version': '2026-03-11', + }), + }), + ); + }); + + it('rejects legacy hosted-MCP OAuth credentials', async () => { + mockFindConnection.mockResolvedValue({ + authConfig: { + type: 'oauth_client', + client_id: 'legacy', + registered_redirect_uri: 'https://example.com/callback', + }, + }); + + const response = await postMcp(createApp(createRunToken()), { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + }); + const body = (await response.json()) as { error: { message: string } }; + + expect(response.status).toBe(500); + expect(body.error.message).toContain('internal integration configuration'); + }); +}); diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index 493df91cf..3b0643618 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -25,6 +25,7 @@ import { grafanaMcp } from './grafana'; import { getIntegrationMcpProxyOptions } from './integration-mcp-policy'; import { linearMcp } from './linear'; import { mcpAuthMiddleware } from './middleware'; +import { notionMcp } from './notion'; import { slackMcp } from './slack'; import { snowflakeMcp } from './snowflake'; import { vercelMcp } from './vercel'; @@ -72,6 +73,7 @@ mcp.route('/asana', asanaMcp); mcp.route('/granola', granolaMcp); mcp.route('/grafana', grafanaMcp); mcp.route('/linear', linearMcp); +mcp.route('/notion', notionMcp); mcp.route('/snowflake', snowflakeMcp); mcp.route('/vercel', vercelMcp); diff --git a/apps/api/src/handlers/mcp/notion/api.ts b/apps/api/src/handlers/mcp/notion/api.ts new file mode 100644 index 000000000..3a7afc02c --- /dev/null +++ b/apps/api/src/handlers/mcp/notion/api.ts @@ -0,0 +1,51 @@ +import type { McpConnectionNotionConfig } from '@roomote/types'; + +import { resolveNotionAccessToken } from './connection'; + +const NOTION_API_BASE_URL = 'https://api.notion.com/v1/'; +const NOTION_API_VERSION = '2026-03-11'; + +type NotionErrorResponse = { + code?: string; + message?: string; +}; + +export async function notionApiRequestJson(params: { + config: McpConnectionNotionConfig; + path: string; + method?: 'GET' | 'POST' | 'PATCH'; + query?: Record; + body?: unknown; +}): Promise { + const url = new URL(params.path, NOTION_API_BASE_URL); + for (const [key, value] of Object.entries(params.query ?? {})) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + method: params.method ?? 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${resolveNotionAccessToken(params.config)}`, + 'Content-Type': 'application/json', + 'Notion-Version': NOTION_API_VERSION, + }, + ...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }), + }); + + if (!response.ok) { + const payload = (await response + .json() + .catch(() => null)) as NotionErrorResponse | null; + const detail = payload?.message?.trim(); + const code = payload?.code?.trim(); + throw new Error( + detail || + `Notion API request failed with status ${response.status}${code ? ` (${code})` : ''}`, + ); + } + + return (await response.json()) as T; +} diff --git a/apps/api/src/handlers/mcp/notion/connection.ts b/apps/api/src/handlers/mcp/notion/connection.ts new file mode 100644 index 000000000..4502af3e3 --- /dev/null +++ b/apps/api/src/handlers/mcp/notion/connection.ts @@ -0,0 +1,23 @@ +import { decrypt } from '@roomote/db/encryption'; +import type { McpConnectionNotionConfig } from '@roomote/types'; + +class NotionConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotionConfigError'; + } +} + +export function resolveNotionAccessToken( + config: McpConnectionNotionConfig, +): string { + const token = decrypt(config.encryptedToken).trim(); + + if (!token) { + throw new NotionConfigError( + 'Notion connection is missing a stored internal integration secret', + ); + } + + return token; +} diff --git a/apps/api/src/handlers/mcp/notion/index.ts b/apps/api/src/handlers/mcp/notion/index.ts new file mode 100644 index 000000000..374f57926 --- /dev/null +++ b/apps/api/src/handlers/mcp/notion/index.ts @@ -0,0 +1,165 @@ +import { Hono } from 'hono'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { + and, + db, + deploymentMcpEnablements, + eq, + isNull, + mcpConnections, + taskRuns, +} from '@roomote/db/server'; +import { + getAllowedIntegrationMcpToolNames, + isMcpConnectionNotionConfig, +} from '@roomote/types'; + +import type { Variables } from '../../../types'; + +import { + isRunTokenContext, + McpProxyError, + type McpAuthContext, +} from '../proxy-utils'; +import { registerNotionTools } from './tools'; + +const NOTION_MCP_SERVER_INFO = { + name: 'roomote-notion-mcp', + version: '1.0.0', +} as const; + +async function resolveNotionMcpAuth( + authContext: Variables['authContext'], +): Promise { + if (!authContext) { + throw new McpProxyError( + 401, + 'Unauthorized: missing or invalid bearer token', + ); + } + + if (isRunTokenContext(authContext)) { + const taskRun = await db.query.taskRuns.findFirst({ + columns: { id: true }, + where: eq(taskRuns.id, authContext.runId), + }); + + if (!taskRun) { + throw new McpProxyError(404, 'Task run not found for this MCP token'); + } + + return { + userId: authContext.userId, + tokenType: 'run', + runId: authContext.runId, + }; + } + + throw new McpProxyError( + 403, + 'Notion MCP requires a task run token for server-side credential access', + ); +} + +async function resolveNotionConnectionAndPolicy() { + const [connection, enablement] = await Promise.all([ + db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'notion'), + isNull(mcpConnections.userId), + eq(mcpConnections.enabled, true), + eq(mcpConnections.authStatus, 'authenticated'), + ), + }), + db.query.deploymentMcpEnablements.findFirst({ + where: and( + eq(deploymentMcpEnablements.mcpId, 'notion'), + eq(deploymentMcpEnablements.enabled, true), + ), + columns: { + disabledTools: true, + toolAccessMode: true, + }, + }), + ]); + + if (!connection || !enablement) { + throw new McpProxyError( + 404, + 'No active Notion connection found for this workspace', + ); + } + + if (!isMcpConnectionNotionConfig(connection.authConfig)) { + throw new McpProxyError( + 500, + 'Notion connection is missing a valid internal integration configuration', + ); + } + + return { + config: connection.authConfig, + policy: { + allowedToolNames: + getAllowedIntegrationMcpToolNames( + 'notion', + enablement.toolAccessMode, + ) ?? undefined, + disabledToolNames: enablement.disabledTools, + }, + }; +} + +function createNotionMcpServer( + resolved: 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); + return server; +} + +export const notionMcp = new Hono<{ Variables: Variables }>(); + +notionMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + + try { + await resolveNotionMcpAuth(c.get('authContext')); + const connectionAndPolicy = await resolveNotionConnectionAndPolicy(); + const server = createNotionMcpServer(connectionAndPolicy); + + await server.connect(transport); + return await transport.handleRequest(c.req.raw); + } catch (error) { + if (error instanceof McpProxyError) { + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { code: -32000, message: error.message }, + }, + { status: error.httpStatus }, + ); + } + + return Response.json( + { + jsonrpc: '2.0', + id: null, + error: { + code: -32603, + message: + error instanceof Error ? error.message : 'Unknown Notion MCP error', + }, + }, + { status: 500 }, + ); + } +}); diff --git a/apps/api/src/handlers/mcp/notion/tools.ts b/apps/api/src/handlers/mcp/notion/tools.ts new file mode 100644 index 000000000..ee354befb --- /dev/null +++ b/apps/api/src/handlers/mcp/notion/tools.ts @@ -0,0 +1,376 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpConnectionNotionConfig, McpToolPolicy } from '@roomote/types'; +import { isMcpToolAllowed } from '@roomote/types'; +import { z } from 'zod'; + +import { toMcpToolResult } from '../proxy-utils'; +import { notionApiRequestJson } from './api'; + +const READ_ONLY_ANNOTATIONS = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +} as const; + +const WRITE_ANNOTATIONS = { + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + readOnlyHint: false, +} as const; + +const nonEmptyStringSchema = z.string().trim().min(1); +const jsonObjectSchema = z.record(z.string(), z.unknown()); +const paginationSchema = { + start_cursor: z.string().optional(), + 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, + }, + 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); + }, + ); + }); +} + +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, + }, + 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>({ + 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, + }, + 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, + }, + 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, + }, + 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, + }, + 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); + }, + ); + }); +} + +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 }); + }, + ); + }); +} + +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); +} diff --git a/apps/docs/integrations/notion.mdx b/apps/docs/integrations/notion.mdx index 61ca07c6a..b08c19b74 100644 --- a/apps/docs/integrations/notion.mdx +++ b/apps/docs/integrations/notion.mdx @@ -1,44 +1,54 @@ --- title: Notion -description: Bring Notion pages and databases into Roomote tasks. +description: Bring approved Notion pages and data sources into Roomote tasks. 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 use that material during tasks and -automations. +live there and Roomote should use that material during tasks and automations. -## When to use it +## Access boundary -- Pull a spec or runbook into a planning or debugging task -- Inspect database-backed project context without copying it into the prompt -- Optionally create or update pages from approved Roomote workflows +Roomote uses a Notion **internal integration**, not Notion's hosted MCP OAuth +connection. Notion itself restricts the token to pages and data sources that +have been explicitly shared with the internal integration. Unshared content, +including private pages, is unavailable to Roomote. -## How setup works +Sharing a parent page may also grant access to its children. Review the content +access list in Notion whenever the page hierarchy changes. -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. +## Set up Notion + +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. +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. +4. Copy the internal integration secret. +5. In Roomote, open **Settings > Integrations**, choose **Configure Notion**, + and paste the secret. + +The secret is encrypted server-side and is never sent to task sandboxes. - 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. + A previous hosted-MCP OAuth connection is not reused because it inherits the + authorizing person's full Notion permissions. Configure an internal + integration before enabling this deployment-wide connection. +## Choose read or write access + 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 +- **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 to narrow access further. - -## What to expect - -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. +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. diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 0059c7dc1..35530aff9 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -37,6 +37,9 @@ const state = vi.hoisted(() => ({ asanaConnection: null as null | { authStatus?: string | null; }, + notionConnection: null as null | { + authStatus?: string | null; + }, granolaConnection: null as null | { authStatus?: string | null; }, @@ -103,6 +106,7 @@ const { mutations, selectMock, radioMock } = vi.hoisted(() => ({ disconnectMcp: vi.fn(), setDisabledTools: vi.fn(), saveAsanaConnection: vi.fn(), + saveNotionConnection: vi.fn(), saveGranolaConnection: vi.fn(), saveElevenLabsConnection: vi.fn(), saveGrafanaConnection: vi.fn(), @@ -261,6 +265,14 @@ vi.mock('@/hooks/mcp-connections', () => ({ data: state.asanaConnection, isPending: false, }), + useSaveNotionConnection: () => ({ + isPending: false, + mutate: mutations.saveNotionConnection, + }), + useNotionConnection: () => ({ + data: state.notionConnection, + isPending: false, + }), useSaveGranolaConnection: () => ({ isPending: false, mutate: mutations.saveGranolaConnection, @@ -521,6 +533,7 @@ describe('Integrations settings', () => { }; state.linearRedirectPath = ''; state.asanaConnection = null; + state.notionConnection = null; state.granolaConnection = null; state.grafanaConnection = null; state.vercelConnection = null; @@ -1434,6 +1447,7 @@ describe('Integrations settings', () => { state.userConnections = [ { id: 'conn-notion', mcpId: 'notion', authStatus: 'authenticated' }, ]; + state.notionConnection = { authStatus: 'authenticated' }; state.mcpTools = { mcpId: 'notion', toolAccessMode: 'read_only', @@ -1470,7 +1484,7 @@ describe('Integrations settings', () => { expect( screen.getByText( - 'Read and write access uses the permissions of the Notion account connected for this deployment.', + "Read and write access remains limited to pages and data sources explicitly shared with the deployment's Notion internal integration.", ), ).toBeInTheDocument(); @@ -1528,6 +1542,48 @@ describe('Integrations settings', () => { ).toHaveAttribute('href', 'https://app.asana.com/0/my-apps'); }); + it('opens the Notion internal integration dialog with page-sharing guidance', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + + expect( + screen.getByRole('heading', { name: 'Connect Notion' }), + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Internal integration secret'), + ).toHaveAttribute('type', 'password'); + expect( + screen.getByText( + /share only the approved pages or data sources with it/i, + ), + ).toBeInTheDocument(); + }); + + it('lets admins replace a legacy Notion OAuth connection in place', () => { + state.deploymentEnablements = [{ mcpId: 'notion', enabled: true }]; + state.userConnections = [ + { id: 'conn-notion', mcpId: 'notion', authStatus: 'authenticated' }, + ]; + state.notionConnection = null; + + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Edit Notion connection' }), + ); + + expect( + screen.getByRole('heading', { name: 'Connect Notion' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Connect Notion' })); + + expect( + screen.getByText('Internal integration secret is required'), + ).toBeInTheDocument(); + expect(mutations.saveNotionConnection).not.toHaveBeenCalled(); + }); + it('opens the Grafana credential dialog from the integrations page', () => { render(); @@ -1588,6 +1644,24 @@ describe('Integrations settings', () => { ); }); + it('submits a Notion internal integration secret', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Notion' })); + fireEvent.change(screen.getByLabelText('Internal integration secret'), { + target: { value: 'ntn_restricted-secret' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect Notion' })); + + expect(mutations.saveNotionConnection).toHaveBeenCalledWith( + { internalIntegrationSecret: 'ntn_restricted-secret' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('submits a Grafana connection from the dialog', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index abefb10dc..e9544917c 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -28,7 +28,9 @@ import { useElevenLabsConnection, useDeploymentMcpEnablements, useMcpOauthReadiness, + useNotionConnection, useSaveAsanaConnection, + useSaveNotionConnection, useSaveGrafanaConnection, useSaveGranolaConnection, useSaveElevenLabsConnection, @@ -50,6 +52,7 @@ import { import { useCustomMcpServers } from './CustomMcpServers'; import { saveAsanaConnectionSchema, + saveNotionConnectionSchema, saveGrafanaConnectionSchema, saveGranolaConnectionSchema, saveElevenLabsConnectionSchema, @@ -106,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 connection. It starts read-only, and admins can optionally allow writes.', + '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.', pylon: 'Roomote will be able to inspect customer issues, message history, and account context.', posthog: @@ -178,6 +181,10 @@ type AsanaFormState = { accessToken: string; }; +type NotionFormState = { + internalIntegrationSecret: string; +}; + type GranolaFormState = { apiKey: string; }; @@ -232,6 +239,10 @@ function buildEmptyAsanaForm(): AsanaFormState { }; } +function buildEmptyNotionForm(): NotionFormState { + return { internalIntegrationSecret: '' }; +} + function buildEmptyGranolaForm(): GranolaFormState { return { apiKey: '', @@ -340,6 +351,19 @@ function getAsanaFieldErrors( }; } +function getNotionFieldErrors( + result: ReturnType, +): Partial> { + if (result.success) { + return {}; + } + + return { + internalIntegrationSecret: + result.error.flatten().fieldErrors.internalIntegrationSecret, + }; +} + function getGranolaFieldErrors( result: ReturnType, ): Partial> { @@ -491,7 +515,7 @@ function buildAdminConfiguredIntegrationItem({ secondaryAction: canManageTools && enabled && - integration.serverMode !== 'native' && + (integration.serverMode !== 'native' || integration.id === 'notion') && integration.serverMode !== 'credential_only' ? { label: 'Manage tools', @@ -839,6 +863,76 @@ function AsanaConnectionFields({ ); } +function NotionConnectionFields({ + form, + fieldErrors, + formError, + allowBlankSecret, + onFieldChange, +}: { + form: NotionFormState; + fieldErrors: Partial>; + formError: string | null; + allowBlankSecret: boolean; + onFieldChange: (field: keyof NotionFormState, value: string) => void; +}) { + const fieldClassName = + 'mt-2 w-full border-border/70 bg-background data-[invalid=true]:border-destructive'; + + return ( + <> +
+ + + onFieldChange('internalIntegrationSecret', event.target.value) + } + data-invalid={ + fieldErrors.internalIntegrationSecret ? 'true' : undefined + } + className={fieldClassName} + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + data-1p-ignore + /> +

+ Create an internal integration in{' '} + + 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. +

+ {allowBlankSecret ? ( +

+ Leave blank to keep the existing secret. +

+ ) : null} + {fieldErrors.internalIntegrationSecret ? ( +

+ {fieldErrors.internalIntegrationSecret[0]} +

+ ) : null} +
+ {formError ? ( +

{formError}

+ ) : null} + + ); +} + function XConnectionFields({ form, fieldErrors, @@ -1234,6 +1328,14 @@ export function Integrations() { Partial> >({}); const [asanaFormError, setAsanaFormError] = useState(null); + const [isNotionDialogOpen, setIsNotionDialogOpen] = useState(false); + const [notionForm, setNotionForm] = useState( + buildEmptyNotionForm(), + ); + const [notionFieldErrors, setNotionFieldErrors] = useState< + Partial> + >({}); + const [notionFormError, setNotionFormError] = useState(null); const [isGranolaDialogOpen, setIsGranolaDialogOpen] = useState(false); const [granolaForm, setGranolaForm] = useState( buildEmptyGranolaForm(), @@ -1310,6 +1412,7 @@ export function Integrations() { const connectMcp = useConnectMcp(); const disconnectMcp = useDisconnectMcp(); const saveAsanaConnection = useSaveAsanaConnection(); + const saveNotionConnection = useSaveNotionConnection(); const saveGrafanaConnection = useSaveGrafanaConnection(); const saveGranolaConnection = useSaveGranolaConnection(); const saveElevenLabsConnection = useSaveElevenLabsConnection(); @@ -1328,6 +1431,19 @@ export function Integrations() { const asanaConnection = useAsanaConnection( isAdmin && (isAsanaConnected || isAsanaDialogOpen), ); + const notionConnectionSummary = useMemo( + () => + (userMcpConnections.data ?? []).find((entry) => entry.mcpId === 'notion'), + [userMcpConnections.data], + ); + const notionConnection = useNotionConnection( + isAdmin && + (notionConnectionSummary?.authStatus === 'authenticated' || + isNotionDialogOpen), + ); + const isNotionConnected = + notionConnectionSummary?.authStatus === 'authenticated' && + notionConnection.data?.authStatus === 'authenticated'; const granolaConnectionSummary = useMemo(() => { const connection = (userMcpConnections.data ?? []).find( (entry) => entry.mcpId === 'granola', @@ -1416,6 +1532,20 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }, [asanaConnection.isPending, isAsanaConnected, isAsanaDialogOpen]); + useEffect(() => { + if (!isNotionDialogOpen) { + return; + } + + if (notionConnection.isPending && isNotionConnected) { + return; + } + + setNotionFieldErrors({}); + setNotionFormError(null); + setNotionForm(buildEmptyNotionForm()); + }, [isNotionConnected, isNotionDialogOpen, notionConnection.isPending]); + useEffect(() => { if (!isGranolaDialogOpen) { return; @@ -1696,6 +1826,26 @@ export function Integrations() { }); } + if (integration.id === 'notion') { + return buildAdminConfiguredIntegrationItem({ + integration, + connection: notionConnectionSummary, + orgEnabled: orgEnablementMap.get(integration.id) ?? false, + highlightedIntegrationId, + savePending: saveNotionConnection.isPending, + disconnectPending: disconnectMcp.isPending, + disconnectingMcpId: disconnectMcp.variables?.mcpId, + dialogOpen: isNotionDialogOpen, + connectionPending: notionConnection.isPending, + canConfigure: isAdmin, + canManageTools: isAdmin, + openDialog: () => setIsNotionDialogOpen(true), + openToolDialog: () => openMcpToolDialog(integration), + disconnectIntegration: () => + disconnectAdminConfiguredIntegration(integration), + }); + } + if (integration.id === 'granola') { return buildAdminConfiguredIntegrationItem({ integration, @@ -1971,6 +2121,7 @@ export function Integrations() { isElevenLabsDialogOpen, isLinearOauthSetupOpen, saveAsanaConnection.isPending, + saveNotionConnection.isPending, saveGrafanaConnection.isPending, saveGranolaConnection.isPending, saveElevenLabsConnection.isPending, @@ -1981,6 +2132,9 @@ export function Integrations() { saveSnowflakeConnection.isPending, asanaConnection.isPending, isAsanaDialogOpen, + isNotionDialogOpen, + notionConnectionSummary, + notionConnection.isPending, snowflakeConnection.isPending, isSnowflakeDialogOpen, vercelConnection.isPending, @@ -2078,6 +2232,21 @@ export function Integrations() { setAsanaFormError(null); }; + const handleNotionFieldChange = ( + field: keyof NotionFormState, + value: string, + ) => { + setNotionForm((current) => ({ ...current, [field]: value })); + setNotionFieldErrors((current) => { + if (!current[field]) { + return current; + } + + return { ...current, [field]: undefined }; + }); + setNotionFormError(null); + }; + const handleGranolaFieldChange = ( field: keyof GranolaFormState, value: string, @@ -2212,6 +2381,16 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }; + const handleNotionDialogOpenChange = (open: boolean) => { + setIsNotionDialogOpen(open); + setNotionFieldErrors({}); + setNotionFormError(null); + + if (open) { + setNotionForm(buildEmptyNotionForm()); + } + }; + const handleGranolaDialogOpenChange = (open: boolean) => { setIsGranolaDialogOpen(open); @@ -2319,6 +2498,40 @@ export function Integrations() { }); }; + const handleNotionSubmit = (event: FormEvent) => { + event.preventDefault(); + + const parsed = saveNotionConnectionSchema.safeParse(notionForm); + if (!parsed.success) { + setNotionFieldErrors(getNotionFieldErrors(parsed)); + return; + } + + if ( + !isNotionConnected && + parsed.data.internalIntegrationSecret.length === 0 + ) { + setNotionFieldErrors({ + internalIntegrationSecret: ['Internal integration secret is required'], + }); + return; + } + + setNotionFieldErrors({}); + setNotionFormError(null); + saveNotionConnection.mutate(parsed.data, { + onSuccess: () => { + toast.success( + isNotionConnected + ? 'Notion connection updated for this deployment.' + : 'Notion connected for this deployment.', + ); + handleNotionDialogOpenChange(false); + }, + onError: (error) => setNotionFormError(error.message), + }); + }; + const handleGranolaSubmit = (event: FormEvent) => { event.preventDefault(); @@ -2581,6 +2794,30 @@ export function Integrations() { onFieldChange={handleAsanaFieldChange} /> + + 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. + + } + onSubmit={handleNotionSubmit} + > + + - Read and write access uses the permissions of the Notion - account connected for this deployment. + Read and write access remains limited to pages and data + sources explicitly shared with the deployment's + Notion internal integration. ) : null} diff --git a/apps/web/src/hooks/mcp-connections/index.ts b/apps/web/src/hooks/mcp-connections/index.ts index 1c1d26503..b9a096967 100644 --- a/apps/web/src/hooks/mcp-connections/index.ts +++ b/apps/web/src/hooks/mcp-connections/index.ts @@ -11,6 +11,8 @@ export { useConnectMcp } from './useConnectMcp'; export { useDisconnectMcp } from './useDisconnectMcp'; export { useAsanaConnection } from './useAsanaConnection'; export { useSaveAsanaConnection } from './useSaveAsanaConnection'; +export { useNotionConnection } from './useNotionConnection'; +export { useSaveNotionConnection } from './useSaveNotionConnection'; export { useGranolaConnection } from './useGranolaConnection'; export { useSaveGranolaConnection } from './useSaveGranolaConnection'; export { useElevenLabsConnection } from './useElevenLabsConnection'; diff --git a/apps/web/src/hooks/mcp-connections/useNotionConnection.ts b/apps/web/src/hooks/mcp-connections/useNotionConnection.ts new file mode 100644 index 000000000..4bf950b3d --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useNotionConnection.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useNotionConnection(enabled = true) { + const trpc = useTRPC(); + + return useQuery({ + ...trpc.mcpConnections.notionConnection.queryOptions(), + enabled, + }); +} diff --git a/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts b/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts new file mode 100644 index 000000000..73fe32e0b --- /dev/null +++ b/apps/web/src/hooks/mcp-connections/useSaveNotionConnection.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useTRPC } from '@/trpc/client'; + +export function useSaveNotionConnection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + return useMutation( + trpc.mcpConnections.saveNotionConnection.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.userConnections.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.notionConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index 0f28b1341..39609c706 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -16,8 +16,11 @@ import { getMcpIntegration, getMcpIntegrationConnectionScope, getMcpIntegrationDefaultDisabledTools, + NOTION_MCP_TOOL_DEFINITIONS, + NOTION_READ_ONLY_TOOL_NAMES, type McpConnectionRole, isMcpConnectionAsanaConfig, + isMcpConnectionNotionConfig, isMcpConnectionGranolaConfig, isMcpConnectionElevenLabsConfig, isMcpConnectionGrafanaConfig, @@ -46,6 +49,7 @@ import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrati import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { SaveAsanaConnectionInput, + SaveNotionConnectionInput, SaveGranolaConnectionInput, SaveElevenLabsConnectionInput, SaveGrafanaConnectionInput, @@ -181,6 +185,7 @@ async function getVisibleMcpConnectionForToolCatalog( columns: { id: true, mcpId: true, + authConfig: true, }, }); @@ -192,7 +197,16 @@ async function getVisibleMcpConnectionForToolCatalog( ); } - return connection; + if ( + mcpId === 'notion' && + !isMcpConnectionNotionConfig(connection.authConfig) + ) { + throw new Error( + 'Configure a Notion internal integration before managing tools for this deployment.', + ); + } + + return { id: connection.id, mcpId: connection.mcpId }; } /** @@ -634,7 +648,7 @@ export async function setDeploymentMcpEnabledCommand( eq(mcpConnections.authStatus, 'authenticated'), isNull(mcpConnections.userId), ), - columns: { id: true }, + columns: { id: true, authConfig: true }, }); if (!connection) { @@ -642,6 +656,15 @@ export async function setDeploymentMcpEnabledCommand( 'This MCP integration must be connected before it can be enabled.', ); } + + if ( + input.mcpId === 'notion' && + !isMcpConnectionNotionConfig(connection.authConfig) + ) { + throw new Error( + 'Configure a Notion internal integration before enabling it.', + ); + } } if ( @@ -797,6 +820,27 @@ export async function getAsanaConnectionCommand(auth: UserAuthSuccess) { }; } +export async function getNotionConnectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'notion'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + authStatus: true, + }, + }); + + if (!connection || !isMcpConnectionNotionConfig(connection.authConfig)) { + return null; + } + + return { authStatus: connection.authStatus }; +} + export async function getGranolaConnectionCommand(auth: UserAuthSuccess) { assertAdmin(auth); @@ -1150,6 +1194,91 @@ export async function saveAsanaConnectionCommand( }; } +export async function saveNotionConnectionCommand( + auth: UserAuthSuccess, + input: SaveNotionConnectionInput, +) { + assertAdmin(auth); + assertCuratedIntegrationsEnabled(); + + const existingConnection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'notion'), + isNull(mcpConnections.userId), + ), + columns: { authConfig: true }, + }); + const existingConfig = isMcpConnectionNotionConfig( + existingConnection?.authConfig, + ) + ? existingConnection.authConfig + : null; + const nextEncryptedToken = + input.internalIntegrationSecret.length > 0 + ? encrypt(input.internalIntegrationSecret) + : existingConfig?.encryptedToken; + + if (!nextEncryptedToken) { + throw new Error( + 'A Notion internal integration secret is required when no secret is already stored.', + ); + } + + const authConfig = { + type: 'notion' as const, + encryptedToken: nextEncryptedToken, + }; + + await db + .insert(mcpConnections) + .values({ + userId: null, + mcpId: 'notion', + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + }) + .onConflictDoUpdate({ + target: [ + mcpConnections.userId, + mcpConnections.mcpId, + mcpConnections.connectionRole, + ], + set: { + connectionRole: 'default', + authConfig, + accessToken: null, + refreshToken: null, + tokenExpiresAt: null, + scopes: null, + enabled: true, + authStatus: 'authenticated', + updatedAt: new Date(), + }, + }); + + await db + .insert(deploymentMcpEnablements) + .values({ + 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 } : {}), + updatedAt: new Date(), + }, + }); + + return { authStatus: 'authenticated' as const }; +} + export async function saveGranolaConnectionCommand( auth: UserAuthSuccess, input: SaveGranolaConnectionInput, @@ -1592,15 +1721,27 @@ export async function listDeploymentMcpIntegrationToolsCommand( 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: await fetchUpstreamMcpTools({ - id: connection.id, - mcpId: connection.mcpId, - disabledTools: enablement.disabledTools, - }), + tools: + input.mcpId === 'notion' + ? nativeNotionTools + : await fetchUpstreamMcpTools({ + id: connection.id, + mcpId: connection.mcpId, + disabledTools: enablement.disabledTools, + }), }; } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 879c51faa..fc80ce144 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -41,6 +41,7 @@ import { pullRequestAnalyticsOverviewInputSchema, filterSchema, saveAsanaConnectionSchema, + saveNotionConnectionSchema, saveGranolaConnectionSchema, saveElevenLabsConnectionSchema, saveGrafanaConnectionSchema, @@ -208,6 +209,7 @@ import { setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, getAsanaConnectionCommand, + getNotionConnectionCommand, getGranolaConnectionCommand, getElevenLabsConnectionCommand, getGrafanaConnectionCommand, @@ -216,6 +218,7 @@ import { getXConnectionCommand, listDeploymentMcpIntegrationToolsCommand, saveAsanaConnectionCommand, + saveNotionConnectionCommand, saveGranolaConnectionCommand, saveElevenLabsConnectionCommand, saveGrafanaConnectionCommand, @@ -1761,6 +1764,10 @@ export const appRouter = createRouter({ getAsanaConnectionCommand(auth), ), + notionConnection: protectedProcedure.query(({ ctx: { auth } }) => + getNotionConnectionCommand(auth), + ), + granolaConnection: protectedProcedure.query(({ ctx: { auth } }) => getGranolaConnectionCommand(auth), ), @@ -1836,6 +1843,12 @@ export const appRouter = createRouter({ saveAsanaConnectionCommand(auth, input), ), + saveNotionConnection: protectedProcedure + .input(saveNotionConnectionSchema) + .mutation(({ ctx: { auth }, input }) => + saveNotionConnectionCommand(auth, input), + ), + saveGranolaConnection: protectedProcedure .input(saveGranolaConnectionSchema) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/mcp-connections.ts b/apps/web/src/types/mcp-connections.ts index bc134e16d..9e010e0fa 100644 --- a/apps/web/src/types/mcp-connections.ts +++ b/apps/web/src/types/mcp-connections.ts @@ -36,6 +36,14 @@ export type SaveAsanaConnectionInput = z.infer< typeof saveAsanaConnectionSchema >; +export const saveNotionConnectionSchema = z.object({ + internalIntegrationSecret: z.string().transform((value) => value.trim()), +}); + +export type SaveNotionConnectionInput = z.infer< + typeof saveNotionConnectionSchema +>; + export const saveGranolaConnectionSchema = z.object({ apiKey: z.string().transform((value) => value.trim()), }); diff --git a/apps/worker/src/commands/setup/__tests__/setup-mcps.test.ts b/apps/worker/src/commands/setup/__tests__/setup-mcps.test.ts index efb3b9c8c..2c9005594 100644 --- a/apps/worker/src/commands/setup/__tests__/setup-mcps.test.ts +++ b/apps/worker/src/commands/setup/__tests__/setup-mcps.test.ts @@ -843,7 +843,7 @@ describe('resolveBuiltInMcpServers', () => { ); }); - it('skips user Notion MCP when it points at the raw upstream URL', () => { + it('skips Notion MCP when it points at the hosted MCP instead of the native proxy', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const parsed = { @@ -867,7 +867,7 @@ describe('resolveBuiltInMcpServers', () => { expect(parsed.mcpServers).not.toHaveProperty('notion'); expect(warnSpy).toHaveBeenCalledWith( - '[resolveBuiltInMcpServers] Skipping Notion MCP: raw upstream URL is not allowed (https://mcp.notion.com/mcp)', + "[resolveBuiltInMcpServers] Skipping Notion MCP: expected proxy path '/api/mcp/notion' but received 'https://mcp.notion.com/mcp'", ); }); 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 f2934a0f9..79ffc4cd9 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -25,12 +25,13 @@ GitHub is connected via a GitHub App installation. An admin installs the Roomote # Notion -Notion uses one deployment-wide OAuth connection: +Notion uses one deployment-wide internal integration whose content access is enforced by Notion: 1. A deployment operator opens Settings > Integrations. -2. That operator connects Notion once for the deployment via OAuth. -3. The operator keeps the default read-only access or explicitly enables read and write access in Manage tools. +2. In Notion, that operator creates an internal integration 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. Read-write access applies deployment-wide. +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. # 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 cafe3a179..ff3c69a2e 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -68,9 +68,9 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< }, notion: { capabilities: [ - 'Search and read Notion pages and databases', + 'Search and read only explicitly shared Notion pages and data sources', 'Pull requirements and product docs into task context', - 'Optionally create and update content when an admin enables read-write access', + 'Optionally create and update shared content when an admin enables read-write access', ], }, jira: { diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 067985144..8f1363165 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -189,17 +189,23 @@ function buildJoinedConnectionRow({ id = 'conn-1', userId = null, mcpId = 'notion', - authConfig = { - type: 'oauth_client', - client_id: 'client-id', - registered_redirect_uri: 'https://example.com/callback', - }, + authConfig, }: { id?: string; userId?: string | null; mcpId?: string; authConfig?: Record; } = {}) { + const resolvedAuthConfig = + authConfig ?? + (mcpId === 'notion' + ? { type: 'notion', encryptedToken: 'enc:notion-secret' } + : { + type: 'oauth_client', + client_id: 'client-id', + registered_redirect_uri: 'https://example.com/callback', + }); + return { enabledMcpId: mcpId, connection: { @@ -207,7 +213,7 @@ function buildJoinedConnectionRow({ userId, mcpId, enabled: true, - authConfig, + authConfig: resolvedAuthConfig, createdAt: new Date('2026-03-12T00:00:00.000Z'), }, }; @@ -242,17 +248,12 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { expect(mockGetValidAccessToken).not.toHaveBeenCalled(); }); - it('returns Notion proxy config without raw OAuth bearer token', async () => { - mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); - + it('returns the native Notion proxy without resolving an OAuth token', async () => { const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', ).getMcpServerConfigs(); - expect(getValidAccessToken).toHaveBeenCalledWith( - 'conn-1', - 'https://mcp.notion.com/mcp', - ); + expect(getValidAccessToken).not.toHaveBeenCalled(); expect(result).toEqual({ servers: { @@ -264,7 +265,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }, }, }); - expect(JSON.stringify(result)).not.toContain('notion-raw-access-token'); + expect(JSON.stringify(result)).not.toContain('notion-secret'); }); it.each([ @@ -642,8 +643,6 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }); it('falls back to a proxy path when request origin is unavailable', async () => { - mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); - const result = await createCaller().getMcpServerConfigs(); expect(result).toEqual({ @@ -663,8 +662,6 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { buildEnabledOnlyRow('posthog'), buildJoinedConnectionRow(), ]); - mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); - const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', ).getMcpServerConfigs(); @@ -685,8 +682,16 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { ); }); - it('skips Notion connections when no valid token can be resolved', async () => { - mockGetValidAccessToken.mockResolvedValue(undefined); + it('skips Notion connections with a legacy OAuth config', async () => { + mockOrderBy.mockResolvedValue([ + buildJoinedConnectionRow({ + authConfig: { + type: 'oauth_client', + client_id: 'legacy-client-id', + registered_redirect_uri: 'https://example.com/callback', + }, + }), + ]); const result = await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', @@ -694,7 +699,7 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { expect(result).toEqual({ servers: {} }); expect(consoleWarnSpy).toHaveBeenCalledWith( - '[getMcpServerConfigs] No tokens found for connection conn-1, skipping', + '[getMcpServerConfigs] Missing upstream URL for OAuth-backed MCP notion, skipping', ); }); @@ -702,8 +707,6 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { mockFindTaskRun.mockResolvedValueOnce({ actingUserId: 'actor-user', }); - mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); - await createJobCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', ).getMcpServerConfigs(); @@ -918,8 +921,6 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }); it('does not log build failures during the happy path', async () => { - mockGetValidAccessToken.mockResolvedValue('notion-raw-access-token'); - await createCaller( 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', ).getMcpServerConfigs(); diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index 6d3dec108..770bfd191 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -25,6 +25,7 @@ import { getMcpIntegrationUpstreamUrl, MCP_INTEGRATIONS, isMcpConnectionAsanaConfig, + isMcpConnectionNotionConfig, isMcpConnectionGranolaConfig, isMcpConnectionGbrainConfig, isMcpConnectionGrafanaConfig, @@ -507,6 +508,7 @@ async function buildCuratedMcpServerConfigs(ctx: { } else if ( isMcpConnectionSnowflakeConfig(authConfig) || isMcpConnectionAsanaConfig(authConfig) || + isMcpConnectionNotionConfig(authConfig) || isMcpConnectionGranolaConfig(authConfig) || isMcpConnectionVercelConfig(authConfig) || isMcpConnectionGrafanaConfig(authConfig) || diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index 77d5d1dd8..6041ed8a6 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 connection. It starts read-only, and admins can optionally allow writes.', + '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.', 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-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index 2c9369c0b..b4018e471 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -5,6 +5,7 @@ import { getMcpIntegrationDefaultDisabledTools, getMcpIntegrationOauthScopeMode, getMcpIntegrationOauthScopes, + isMcpConnectionNotionConfig, isMcpConnectionElevenLabsConfig, isMcpConnectionGbrainConfig, LINEAR_APP_OAUTH_SCOPES, @@ -50,15 +51,33 @@ describe('monday.com OAuth', () => { }); }); -describe('Notion OAuth', () => { - it('uses one deployment-scoped OAuth connection', () => { +describe('Notion internal integration', () => { + it('uses a deployment-scoped native MCP with admin-managed credentials', () => { expect(getMcpIntegration('notion')).toMatchObject({ name: 'Notion', - url: 'https://mcp.notion.com/mcp', connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'native', }); + expect(getMcpIntegration('notion')?.url).toBeUndefined(); expect(getMcpIntegrationConnectionScope('notion')).toBe('deployment'); }); + + it('recognizes only stored Notion internal integration configs', () => { + expect( + isMcpConnectionNotionConfig({ + type: 'notion', + encryptedToken: 'encrypted', + }), + ).toBe(true); + expect( + isMcpConnectionNotionConfig({ + type: 'oauth_client', + client_id: 'legacy-hosted-mcp', + registered_redirect_uri: 'https://example.com/callback', + }), + ).toBe(false); + }); }); describe('Better Stack OAuth', () => { diff --git a/packages/types/src/__tests__/mcp-tool-policy.test.ts b/packages/types/src/__tests__/mcp-tool-policy.test.ts index 19439f8e8..6740dff37 100644 --- a/packages/types/src/__tests__/mcp-tool-policy.test.ts +++ b/packages/types/src/__tests__/mcp-tool-policy.test.ts @@ -34,12 +34,12 @@ describe('Notion MCP tool access modes', () => { 'notion-fetch', 'notion-query-data-sources', 'notion-get-comments', - 'notion-get-users', ]), ); 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', () => { diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 019bcf0cd..c35a1387e 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -123,6 +123,18 @@ export interface McpConnectionAsanaConfig { encryptedToken: string; } +/** + * Deployment-scoped Notion internal integration configuration. + * + * Notion enforces the content boundary: this token can only access pages and + * data sources explicitly shared with the internal integration. The secret is + * expected to be encrypted before persistence. + */ +export interface McpConnectionNotionConfig { + type: 'notion'; + encryptedToken: string; +} + /** * Deployment-scoped Granola connection config stored in mcpConnections.authConfig. * @@ -225,6 +237,7 @@ export type McpConnectionAuthConfig = | McpConnectionOAuthConfig | McpConnectionSnowflakeConfig | McpConnectionAsanaConfig + | McpConnectionNotionConfig | McpConnectionGranolaConfig | McpConnectionElevenLabsConfig | McpConnectionVercelConfig @@ -392,10 +405,13 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ { id: 'notion', name: 'Notion', - url: 'https://mcp.notion.com/mcp', - description: `Access your Notion pages, databases, and content within ${PRODUCT_NAME} tasks`, + description: `Access only the Notion pages and data sources explicitly shared with ${PRODUCT_NAME}`, icon: 'notion', connectionScope: 'deployment', + 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.', }, { id: 'jira', @@ -925,6 +941,19 @@ export function isMcpConnectionAsanaConfig( ); } +export function isMcpConnectionNotionConfig( + authConfig: McpConnectionAuthConfig | null | undefined, +): authConfig is McpConnectionNotionConfig { + return Boolean( + authConfig && + typeof authConfig === 'object' && + 'type' in authConfig && + authConfig.type === 'notion' && + 'encryptedToken' in authConfig && + typeof authConfig.encryptedToken === 'string', + ); +} + export function isMcpConnectionGranolaConfig( authConfig: McpConnectionAuthConfig | null | undefined, ): authConfig is McpConnectionGranolaConfig { diff --git a/packages/types/src/mcp-tool-policy.ts b/packages/types/src/mcp-tool-policy.ts index b2dc7bc82..f3b9acbb7 100644 --- a/packages/types/src/mcp-tool-policy.ts +++ b/packages/types/src/mcp-tool-policy.ts @@ -10,23 +10,48 @@ export type McpToolAccessModeConfig = { readonly readOnlyToolNames: readonly string[]; }; -/** - * Notion documents these as non-mutating tools. `fetch` and `search` are - * aliases that Notion may advertise to OpenAI MCP clients in place of their - * `notion-`-prefixed names. - */ +/** Roomote's native Notion tools that do not mutate content. */ export const NOTION_READ_ONLY_TOOL_NAMES = [ 'notion-search', - 'search', 'notion-fetch', - 'fetch', 'notion-query-data-sources', - 'notion-query-database-view', 'notion-get-comments', - 'notion-get-teams', - 'notion-get-users', - 'notion-get-user', - 'notion-get-self', +] 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<