From fce1e1181baaf8be0b5d7d9d12bbee5db8715e87 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 4 Aug 2026 16:33:41 +0000 Subject: [PATCH 1/2] feat: add Granola integration --- apps/docs/docs.json | 1 + apps/docs/integrations/granola.mdx | 29 ++++++++++++++++ apps/docs/integrations/index.mdx | 1 + apps/docs/logo/integrations/granola.svg | 3 ++ apps/docs/snippets/integration-name.jsx | 1 + .../components/settings/Integrations.test.tsx | 16 +++++++++ .../src/components/settings/Integrations.tsx | 2 ++ .../system/custom/logos/brand-icon.tsx | 34 +++++++++++++++++++ .../__tests__/integration-setup.test.ts | 10 ++++++ .../roomote-mcp-server/integration-setup.ts | 8 +++++ .../src/server/mcp-self-setup/catalog.ts | 7 ++++ .../slack-mcp-setup-matching.test.ts | 1 + packages/slack/src/mcp-recommendations.ts | 2 ++ .../types/src/__tests__/mcp-oauth.test.ts | 13 +++++++ packages/types/src/mcp-oauth.ts | 11 ++++++ packages/types/src/mcp-service-detection.ts | 8 +++++ 16 files changed, 147 insertions(+) create mode 100644 apps/docs/integrations/granola.mdx create mode 100644 apps/docs/logo/integrations/granola.svg diff --git a/apps/docs/docs.json b/apps/docs/docs.json index f53fc9698..a4a8bde95 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -137,6 +137,7 @@ "integrations/better-stack", "integrations/braintrust", "integrations/grafana", + "integrations/granola", "integrations/jira", "integrations/linear", "integrations/monday", diff --git a/apps/docs/integrations/granola.mdx b/apps/docs/integrations/granola.mdx new file mode 100644 index 000000000..e0743dfa4 --- /dev/null +++ b/apps/docs/integrations/granola.mdx @@ -0,0 +1,29 @@ +--- +title: Granola +description: Give Roomote access to your Granola meeting notes and transcripts. +icon: '/logo/integrations/granola.svg' +--- + +Connect Granola when decisions, action items, and implementation context live in +your meeting notes and you want that context available in Roomote tasks. + +## When to use it + +- Search past meetings for relevant decisions and discussions +- Read meeting notes and transcripts while planning or implementing work +- Find action items, attendees, and related meeting folders during follow-up work + +## How setup works + +A deployment operator enables Granola from **Settings > Integrations**. Each +teammate then links their own Granola account from +[Personal Settings](/personal-settings) when they need it. + +Granola follows the active workspace selected in that user's Granola app. Access +to personal and public notes depends on the user's Granola plan and workspace +settings. + +## What to expect + +The built-in Granola connection is read-only. Roomote can search and read notes, +folders, meeting details, and transcripts, but it cannot modify Granola content. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 6419ea307..ad18ea5b2 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -55,6 +55,7 @@ from [Personal Settings](/personal-settings). | | Monitoring and incident context | Admin connection once | | | Prompts, runs, and evaluation context | Enable first, then teammates link accounts | | | Dashboards, alerting, and monitoring context | Admin connection once | +| | Meeting notes, transcripts, and decisions | Enable first, then teammates link accounts | | | Issues, projects, and JQL-backed issue context | Admin connection once | | | Turning issues into Roomote work | Workspace plus user identity | | | Board, item, and workspace context | Enable first, then teammates link accounts | diff --git a/apps/docs/logo/integrations/granola.svg b/apps/docs/logo/integrations/granola.svg new file mode 100644 index 000000000..f24b31146 --- /dev/null +++ b/apps/docs/logo/integrations/granola.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/snippets/integration-name.jsx b/apps/docs/snippets/integration-name.jsx index 8346ea341..c4a535c17 100644 --- a/apps/docs/snippets/integration-name.jsx +++ b/apps/docs/snippets/integration-name.jsx @@ -4,6 +4,7 @@ export function IntegrationName({ href, icon, name }) { e2b: '/logo/integrations/e2b.svg', blaxel: '/logo/integrations/blaxel.svg', azure: '/logo/integrations/azure.svg', + granola: '/logo/integrations/granola.svg', monday: '/logo/integrations/monday.svg', }; const iconSrc = diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 47f09c9b7..29485b273 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -801,6 +801,7 @@ describe('Integrations settings', () => { 'Better Stack', 'Braintrust', 'Grafana', + 'Granola', 'Jira', 'monday.com', 'Neon', @@ -1068,6 +1069,21 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows the Granola confirmation dialog copy for highlighted integrations', () => { + state.searchParams = 'highlight=granola'; + + render(); + + expect( + screen.getByRole('heading', { name: 'Enable Granola?' }), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', + ), + ).toBeInTheDocument(); + }); + it('does not show the confirmation dialog for an enabled highlighted integration', () => { state.searchParams = 'highlight=sentry-mcp'; state.deploymentEnablements = [{ mcpId: 'sentry', enabled: true }]; diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index afc51d9f9..1f104f3a1 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -88,6 +88,8 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect prompts, evaluations, and AI run history.', grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', + granola: + 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', github: 'Roomote will be able to inspect PRs, issues, and repository context.', jira: 'Roomote will be able to inspect Jira issues, workflows, and JQL search results.', diff --git a/apps/web/src/components/system/custom/logos/brand-icon.tsx b/apps/web/src/components/system/custom/logos/brand-icon.tsx index 6ef64f4ad..f336645a2 100644 --- a/apps/web/src/components/system/custom/logos/brand-icon.tsx +++ b/apps/web/src/components/system/custom/logos/brand-icon.tsx @@ -163,6 +163,30 @@ function SupermemoryIcon({ ); } +function GranolaIcon({ + name, + className, + isDecorative, +}: { + name: string; + className?: string; + isDecorative: boolean; +}) { + return ( + + + + ); +} + function ZeroIcon({ name, className, @@ -519,6 +543,16 @@ export function BrandIcon({ icon, name, className }: BrandIconProps) { ); } + if (icon === 'granola') { + return ( + + ); + } + if (icon === 'zero') { return ( diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts index 5e77c2262..c0cf9411b 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts @@ -50,6 +50,16 @@ describe('integration setup guide', () => { ); }); + it('documents the user-scoped read-only Granola setup flow', () => { + expect(INTEGRATION_SETUP_CONTENT).toContain('# Granola'); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'Each user connects their own Granola account via OAuth.', + ); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'The built-in connection is read-only.', + ); + }); + it('documents the admin-managed Vercel setup flow', () => { expect(INTEGRATION_SETUP_CONTENT).toContain('# Vercel'); expect(INTEGRATION_SETUP_CONTENT).toContain( 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 42e5a55d4..cb9c40668 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -111,6 +111,14 @@ Braintrust uses OAuth: Once connected, I can access AI evaluation and logging data during tasks. +# Granola + +Granola uses OAuth: +1. A deployment operator enables Granola from Settings > Integrations. +2. Each user connects their own Granola account via OAuth. + +Once connected, I can search and read the user's meeting notes, folders, transcripts, decisions, and action items from their active Granola workspace. The built-in connection is read-only. + # Asana Asana uses an admin-managed access token: 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 0dc0b6245..7e8b22507 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -158,6 +158,13 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< 'Tie AI regressions back to code and prompt changes', ], }, + granola: { + capabilities: [ + 'Search meeting notes for relevant discussions and decisions', + 'Read meeting details and transcripts for implementation context', + 'Review folders, attendees, and action items during follow-up work', + ], + }, supermemory: { capabilities: [ 'Save important decisions and context as shared memories during tasks', diff --git a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts index a3f67f262..6a6d5ed4b 100644 --- a/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts +++ b/packages/cloud-agents/src/server/router/__tests__/slack-mcp-setup-matching.test.ts @@ -65,6 +65,7 @@ describe('matchSlackMcpSetupService', () => { ['https://vercel.com/acme-team/my-app', 'vercel'], ['https://resend.com/emails/123', 'resend'], ['https://resend.com/domains/example.com', 'resend'], + ['https://notes.granola.ai/d/meeting-id', 'granola'], ])('matches %s to %s', (url, serviceId) => { expect(matchServiceIdForUrl(url)).toBe(serviceId); }); diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index 30516ffb7..c726f823e 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -33,6 +33,8 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { 'Roomote will be able to inspect workspaces, projects, tasks, teams, and task comments.', grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', + granola: + 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', sentry: 'Roomote will be able to inspect Sentry issue context and run scheduled Sentry triage through MCP.', posthog: diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index 9261b71a6..b42f2cfca 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -48,6 +48,19 @@ describe('monday.com OAuth', () => { }); }); +describe('Granola OAuth', () => { + it('uses the hosted MCP with a user-scoped read-only connection', () => { + expect(getMcpIntegration('granola')).toMatchObject({ + name: 'Granola', + url: 'https://mcp.granola.ai/mcp', + serverMode: 'upstream_proxy', + }); + expect(getMcpIntegrationConnectionScope('granola')).toBe('user'); + expect(getMcpIntegrationOauthScopeMode('granola')).toBe('read-only'); + expect(getMcpIntegrationDefaultDisabledTools('granola')).toEqual([]); + }); +}); + describe('Resend OAuth', () => { it('uses a deployment-scoped hosted MCP with risky tools disabled initially', () => { expect(getMcpIntegration('resend')).toMatchObject({ diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index 1bb1144a2..b3179cbb1 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -479,6 +479,17 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ description: `Access prompts, runs and results from ${PRODUCT_NAME} tasks`, icon: 'braintrust', }, + { + id: 'granola', + name: 'Granola', + url: 'https://mcp.granola.ai/mcp', + description: `Search meeting notes, transcripts, decisions, and action items from ${PRODUCT_NAME} tasks`, + icon: 'granola', + serverMode: 'upstream_proxy', + oauthScopeMode: 'read-only', + instructions: + "Use Granola to search and read the connected user's meeting notes, transcripts, folders, decisions, and action items. Granola follows the user's active workspace and exposes read-only tools.", + }, { id: 'supermemory', name: 'Supermemory', diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index 37b381f94..eb884fea7 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -259,6 +259,14 @@ export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ deploymentSettingsPath: '/settings/integrations', userSettingsPath: '/settings/personal', }, + { + id: 'granola', + name: 'Granola', + availabilityKind: 'curated_oauth', + hostSuffixes: ['notes.granola.ai'], + deploymentSettingsPath: '/settings/integrations', + userSettingsPath: '/settings/personal', + }, { id: 'supermemory', name: 'Supermemory', From d6d009de3fb7033e5d475fa5d7e33ab77f89539e Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:20:22 +0000 Subject: [PATCH 2/2] feat: use deployment API key for Granola --- .../mcp/__tests__/granola-auth.test.ts | 341 ++++++++++++++++++ apps/api/src/handlers/mcp/granola/api.ts | 81 +++++ .../src/handlers/mcp/granola/connection.ts | 23 ++ apps/api/src/handlers/mcp/granola/index.ts | 141 ++++++++ apps/api/src/handlers/mcp/granola/tools.ts | 175 +++++++++ apps/api/src/handlers/mcp/index.ts | 2 + apps/docs/integrations/granola.mdx | 30 +- apps/docs/integrations/index.mdx | 2 +- .../components/settings/Integrations.test.tsx | 122 ++++++- .../src/components/settings/Integrations.tsx | 233 +++++++++++- apps/web/src/hooks/mcp-connections/index.ts | 2 + .../hooks/mcp-connections/useDisconnectMcp.ts | 3 + .../mcp-connections/useGranolaConnection.ts | 14 + .../useSaveGranolaConnection.ts | 26 ++ .../trpc/commands/mcp-connections/index.ts | 109 ++++++ apps/web/src/trpc/routers/_app.ts | 13 + apps/web/src/types/mcp-connections.ts | 8 + .../__tests__/integration-setup.test.ts | 7 +- .../roomote-mcp-server/integration-setup.ts | 6 +- .../src/server/mcp-self-setup/catalog.ts | 6 +- .../server/routers/mcp-connections.test.ts | 31 ++ .../sdk/src/server/routers/mcp-connections.ts | 2 + packages/slack/src/mcp-recommendations.ts | 2 +- .../types/src/__tests__/mcp-oauth.test.ts | 14 +- packages/types/src/mcp-oauth.ts | 34 +- packages/types/src/mcp-service-detection.ts | 2 +- 26 files changed, 1395 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts create mode 100644 apps/api/src/handlers/mcp/granola/api.ts create mode 100644 apps/api/src/handlers/mcp/granola/connection.ts create mode 100644 apps/api/src/handlers/mcp/granola/index.ts create mode 100644 apps/api/src/handlers/mcp/granola/tools.ts create mode 100644 apps/web/src/hooks/mcp-connections/useGranolaConnection.ts create mode 100644 apps/web/src/hooks/mcp-connections/useSaveGranolaConnection.ts diff --git a/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts new file mode 100644 index 000000000..05cb706cb --- /dev/null +++ b/apps/api/src/handlers/mcp/__tests__/granola-auth.test.ts @@ -0,0 +1,341 @@ +import { Hono } from 'hono'; +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +const { mockFindTaskRun, mockFindConnection, mockEq, mockAnd, mockIsNull } = + vi.hoisted(() => ({ + mockFindTaskRun: vi.fn(), + mockFindConnection: 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 }, + }, + }, + taskRuns: { id: 'id' }, + mcpConnections: { + mcpId: 'mcpId', + enabled: 'enabled', + authStatus: 'authStatus', + userId: 'userId', + }, + eq: mockEq, + and: mockAnd, + isNull: mockIsNull, +})); + +vi.mock('@roomote/db/encryption', () => ({ + decrypt: vi.fn((value: string) => + value.startsWith('enc:') ? value.slice(4) : value, + ), +})); + +import { db } from '@roomote/db/server'; +import { granolaMcp } from '../granola'; + +type JsonRpcErrorBody = { + error: { message: string }; +}; + +function createInitializeRequest(id: number) { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'roomote-api-test', version: '1.0.0' }, + }, + }; +} + +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', granolaMcp); + 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 mockConnectionRow(overrides?: Record) { + return { + id: 'conn-1', + userId: null, + mcpId: 'granola', + enabled: true, + authStatus: 'authenticated', + authConfig: { + type: 'granola', + encryptedApiKey: 'enc:granola-secret-key', + ...(overrides ?? {}), + }, + } as unknown as Awaited>; +} + +function createRunToken(): RunTokenContext { + return { + runId: 42, + userId: 'user-1', + principal: 'user', + tokenType: 'run', + version: 1, + }; +} + +function createToolCall(name: string, args: Record) { + return { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name, arguments: args }, + }; +} + +describe('Granola MCP auth and tool handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindTaskRun.mockResolvedValue({ id: 42 }); + mockFindConnection.mockResolvedValue(mockConnectionRow()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects requests without authentication', async () => { + const response = await postMcp( + createApp(undefined), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(401); + expect(body.error.message).toContain('missing or invalid bearer token'); + }); + + it('requires a task run token', async () => { + const authToken: AuthTokenContext = { + userId: 'user-1', + tokenType: 'auth', + version: 1, + }; + const response = await postMcp( + createApp(authToken), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(403); + expect(body.error.message).toContain('requires a task run token'); + }); + + it('initializes for a valid run token and deployment connection', async () => { + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(1), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { + serverInfo: { name: 'roomote-granola-mcp', version: '1.0.0' }, + }, + }); + expect(mockIsNull).toHaveBeenCalledWith('userId'); + }); + + it('exposes only the three read-only Granola tools', 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; + annotations: Record; + }>; + }; + }; + + expect(body.result.tools.map((tool) => tool.name)).toEqual([ + 'list_notes', + 'get_note', + 'list_folders', + ]); + expect(body.result.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + }), + ]), + ); + }); + + it('lists notes with official filters, pagination, and decrypted bearer auth', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ notes: [], hasMore: true, cursor: 'next-cursor' }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_notes', { + created_before: '2026-02-01', + created_after: '2026-01-01T00:00:00Z', + updated_after: '2026-01-15', + folder_id: 'fol_4y6LduVdwSKC27', + cursor: 'current-cursor', + page_size: 30, + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { + notes: [], + hasMore: true, + cursor: 'next-cursor', + }, + }, + }); + + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/notes?created_before=2026-02-01&created_after=2026-01-01T00%3A00%3A00Z&updated_after=2026-01-15&folder_id=fol_4y6LduVdwSKC27&cursor=current-cursor&page_size=30', + ); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer granola-secret-key', + ); + }); + + it('gets a note with transcript inclusion exactly as documented', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: 'not_1d3tmYTlCICgjy' })); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('get_note', { + note_id: 'not_1d3tmYTlCICgjy', + include: 'transcript', + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { note: { id: 'not_1d3tmYTlCICgjy' } }, + }, + }); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/notes/not_1d3tmYTlCICgjy?include=transcript', + ); + }); + + it('lists folders with official pagination', async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + folders: [{ id: 'fol_4y6LduVdwSKC27', name: 'Recipes' }], + hasMore: false, + cursor: null, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_folders', { cursor: 'folder-cursor', page_size: 5 }), + ); + + await expect(response.json()).resolves.toMatchObject({ + result: { + structuredContent: { + folders: [{ id: 'fol_4y6LduVdwSKC27', name: 'Recipes' }], + hasMore: false, + cursor: null, + }, + }, + }); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toBe( + 'https://public-api.granola.ai/v1/folders?cursor=folder-cursor&page_size=5', + ); + }); + + it('surfaces structured Granola API errors', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + Response.json( + { errors: [{ message: 'The supplied API key is invalid' }] }, + { status: 401 }, + ), + ), + ); + + const response = await postMcp( + createApp(createRunToken()), + createToolCall('list_notes', {}), + ); + const body = (await response.json()) as { + result?: { content?: Array<{ text?: string }> }; + error?: { message?: string }; + }; + + expect(response.status).toBe(200); + expect(body.error?.message ?? body.result?.content?.[0]?.text).toContain( + 'The supplied API key is invalid', + ); + }); + + it('returns 404 when no active Granola deployment connection exists', async () => { + mockFindConnection.mockResolvedValue(undefined); + + const response = await postMcp( + createApp(createRunToken()), + createInitializeRequest(1), + ); + const body = (await response.json()) as JsonRpcErrorBody; + + expect(response.status).toBe(404); + expect(body.error.message).toBe( + 'No active Granola connection found for this workspace', + ); + }); +}); diff --git a/apps/api/src/handlers/mcp/granola/api.ts b/apps/api/src/handlers/mcp/granola/api.ts new file mode 100644 index 000000000..78408a8f5 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/api.ts @@ -0,0 +1,81 @@ +import type { McpConnectionGranolaConfig } from '@roomote/types'; + +import { resolveGranolaApiKey } from './connection'; + +const GRANOLA_API_BASE_URL = 'https://public-api.granola.ai'; + +type QueryValue = string | number | null | undefined; + +function findErrorMessage(payload: unknown): string | null { + if (typeof payload === 'string') { + return payload.trim() || null; + } + + if (!payload || typeof payload !== 'object') { + return null; + } + + const record = payload as Record; + + for (const key of ['message', 'error', 'detail']) { + const message = findErrorMessage(record[key]); + if (message) { + return message; + } + } + + if (Array.isArray(record.errors)) { + const messages = record.errors + .map(findErrorMessage) + .filter((message): message is string => Boolean(message)); + if (messages.length > 0) { + return messages.join('; '); + } + } + + return null; +} + +async function parseErrorMessage(response: Response): Promise { + const body = (await response.text().catch(() => '')).trim(); + + if (body) { + try { + const message = findErrorMessage(JSON.parse(body)); + if (message) { + return message; + } + } catch { + return body; + } + } + + return `Granola API request failed with status ${response.status}`; +} + +export async function granolaApiGetJson(params: { + config: McpConnectionGranolaConfig; + path: string; + query?: Record; +}): Promise { + const url = new URL(params.path, `${GRANOLA_API_BASE_URL}/`); + + for (const [key, value] of Object.entries(params.query ?? {})) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${resolveGranolaApiKey(params.config)}`, + }, + }); + + if (!response.ok) { + throw new Error(await parseErrorMessage(response)); + } + + return (await response.json()) as T; +} diff --git a/apps/api/src/handlers/mcp/granola/connection.ts b/apps/api/src/handlers/mcp/granola/connection.ts new file mode 100644 index 000000000..2f778acc8 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/connection.ts @@ -0,0 +1,23 @@ +import { decrypt } from '@roomote/db/encryption'; +import type { McpConnectionGranolaConfig } from '@roomote/types'; + +class GranolaConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'GranolaConfigError'; + } +} + +export function resolveGranolaApiKey( + config: McpConnectionGranolaConfig, +): string { + const apiKey = decrypt(config.encryptedApiKey).trim(); + + if (!apiKey) { + throw new GranolaConfigError( + 'Granola connection is missing a stored API key', + ); + } + + return apiKey; +} diff --git a/apps/api/src/handlers/mcp/granola/index.ts b/apps/api/src/handlers/mcp/granola/index.ts new file mode 100644 index 000000000..47a612b7f --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/index.ts @@ -0,0 +1,141 @@ +import { Hono } from 'hono'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; +import { + and, + db, + eq, + isNull, + mcpConnections, + taskRuns, +} from '@roomote/db/server'; +import { isMcpConnectionGranolaConfig } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +import { + isRunTokenContext, + McpProxyError, + type McpAuthContext, +} from '../proxy-utils'; +import { registerGranolaTools } from './tools'; + +const GRANOLA_MCP_SERVER_INFO = { + name: 'roomote-granola-mcp', + version: '1.0.0', +} as const; + +async function resolveGranolaMcpAuth( + 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, + 'Granola MCP requires a task run token for server-side credential access', + ); +} + +async function resolveGranolaConnection() { + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + eq(mcpConnections.enabled, true), + eq(mcpConnections.authStatus, 'authenticated'), + ), + }); + + if (!connection) { + throw new McpProxyError( + 404, + 'No active Granola connection found for this workspace', + ); + } + + if (!isMcpConnectionGranolaConfig(connection.authConfig)) { + throw new McpProxyError( + 500, + 'Granola connection is missing a valid stored credential configuration', + ); + } + + return connection.authConfig; +} + +function createGranolaMcpServer( + config: Awaited>, +) { + const server = new McpServer(GRANOLA_MCP_SERVER_INFO, { + instructions: + 'Use these read-only Granola tools to inspect notes, transcripts, and folders through the configured workspace API key.', + }); + + registerGranolaTools(server, config); + return server; +} + +export const granolaMcp = new Hono<{ Variables: Variables }>(); + +granolaMcp.on(['POST', 'GET', 'DELETE'], '/', async (c) => { + const transport = new WebStandardStreamableHTTPServerTransport({ + enableJsonResponse: true, + }); + + try { + await resolveGranolaMcpAuth(c.get('authContext')); + const connectionConfig = await resolveGranolaConnection(); + const server = createGranolaMcpServer(connectionConfig); + + 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 Granola MCP error', + }, + }, + { status: 500 }, + ); + } +}); diff --git a/apps/api/src/handlers/mcp/granola/tools.ts b/apps/api/src/handlers/mcp/granola/tools.ts new file mode 100644 index 000000000..9444b6a71 --- /dev/null +++ b/apps/api/src/handlers/mcp/granola/tools.ts @@ -0,0 +1,175 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpConnectionGranolaConfig } from '@roomote/types'; +import { z } from 'zod'; + +import { toMcpToolResult } from '../proxy-utils'; +import { granolaApiGetJson } from './api'; + +const TOOL_ANNOTATIONS = { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, +} as const; + +const paginationSchema = { + cursor: z + .string() + .optional() + .describe('Cursor returned by the previous Granola response.'), + page_size: z + .number() + .int() + .min(1) + .max(30) + .optional() + .describe( + 'Maximum results per page. Granola defaults to 10 and caps at 30.', + ), +} as const; + +const dateFilterSchema = z + .string() + .min(1) + .optional() + .describe( + 'An ISO 8601 date or date-time, for example 2026-01-27 or 2026-01-27T15:30:00Z.', + ); + +type ListNotesResponse = { + notes: unknown[]; + hasMore: boolean; + cursor: string | null; +}; + +type ListFoldersResponse = { + folders: unknown[]; + hasMore: boolean; + cursor: string | null; +}; + +function registerListNotesTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'list_notes', + { + title: 'List Notes', + description: 'List Granola notes available to the configured API key.', + inputSchema: { + created_before: dateFilterSchema.describe( + 'Return notes created before this ISO 8601 date or date-time.', + ), + created_after: dateFilterSchema.describe( + 'Return notes created after this ISO 8601 date or date-time.', + ), + updated_after: dateFilterSchema.describe( + 'Return notes updated after this ISO 8601 date or date-time.', + ), + folder_id: z + .string() + .regex(/^fol_[a-zA-Z0-9]{14}$/) + .optional() + .describe( + 'Return notes in this folder and its child folders. Use list_folders to discover IDs.', + ), + ...paginationSchema, + }, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ + created_before: createdBefore, + created_after: createdAfter, + updated_after: updatedAfter, + folder_id: folderId, + cursor, + page_size: pageSize, + }) => { + const response = await granolaApiGetJson({ + config, + path: 'v1/notes', + query: { + created_before: createdBefore, + created_after: createdAfter, + updated_after: updatedAfter, + folder_id: folderId, + cursor, + page_size: pageSize, + }, + }); + + return toMcpToolResult(response); + }, + ); +} + +function registerGetNoteTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'get_note', + { + title: 'Get Note', + description: + 'Fetch a Granola note by ID, optionally including its transcript.', + inputSchema: { + note_id: z + .string() + .regex(/^not_[a-zA-Z0-9]{14}$/) + .describe('The Granola note ID.'), + include: z + .literal('transcript') + .optional() + .describe('Set to transcript to include the note transcript.'), + }, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ note_id: noteId, include }) => { + const note = await granolaApiGetJson>({ + config, + path: `v1/notes/${encodeURIComponent(noteId)}`, + query: { include }, + }); + + return toMcpToolResult({ note }); + }, + ); +} + +function registerListFoldersTool( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + server.registerTool( + 'list_folders', + { + title: 'List Folders', + description: 'List Granola folders available to the configured API key.', + inputSchema: paginationSchema, + outputSchema: z.object({}).passthrough(), + annotations: TOOL_ANNOTATIONS, + }, + async ({ cursor, page_size: pageSize }) => { + const response = await granolaApiGetJson({ + config, + path: 'v1/folders', + query: { cursor, page_size: pageSize }, + }); + + return toMcpToolResult(response); + }, + ); +} + +export function registerGranolaTools( + server: McpServer, + config: McpConnectionGranolaConfig, +) { + registerListNotesTool(server, config); + registerGetNoteTool(server, config); + registerListFoldersTool(server, config); +} diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index b71b3b316..981ce324b 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -10,6 +10,7 @@ import { environmentsRouter } from '../environments'; import { customAutomationsRouter } from '../custom-automations'; import { tasksRouter } from '../tasks'; import { createIntegrationMcpProxy } from './integration-mcp'; +import { granolaMcp } from './granola'; import { grafanaMcp } from './grafana'; import { getIntegrationMcpProxyOptions } from './integration-mcp-policy'; import { linearMcp } from './linear'; @@ -36,6 +37,7 @@ for (const integration of MCP_INTEGRATIONS) { } mcp.route('/asana', asanaMcp); +mcp.route('/granola', granolaMcp); mcp.route('/grafana', grafanaMcp); mcp.route('/linear', linearMcp); mcp.route('/snowflake', snowflakeMcp); diff --git a/apps/docs/integrations/granola.mdx b/apps/docs/integrations/granola.mdx index e0743dfa4..378a2873d 100644 --- a/apps/docs/integrations/granola.mdx +++ b/apps/docs/integrations/granola.mdx @@ -1,6 +1,6 @@ --- title: Granola -description: Give Roomote access to your Granola meeting notes and transcripts. +description: Give Roomote read-only access to shared Granola meeting context. icon: '/logo/integrations/granola.svg' --- @@ -9,21 +9,31 @@ your meeting notes and you want that context available in Roomote tasks. ## When to use it -- Search past meetings for relevant decisions and discussions +- Browse past meetings for relevant decisions and discussions - Read meeting notes and transcripts while planning or implementing work - Find action items, attendees, and related meeting folders during follow-up work ## How setup works -A deployment operator enables Granola from **Settings > Integrations**. Each -teammate then links their own Granola account from -[Personal Settings](/personal-settings) when they need it. +A deployment operator connects Granola once from **Settings > Integrations** +with an API key. Granola API keys require a Business or Enterprise plan. -Granola follows the active workspace selected in that user's Granola app. Access -to personal and public notes depends on the user's Granola plan and workspace -settings. +Prefer a **workspace API key** from **Granola Settings > Connectors > Workspace +API keys**. Workspace keys belong to the workspace rather than an individual, +do not expire, and cannot read ordinary private notes. They can read: + +- notes visible to everyone in the workspace, including the Team space +- notes in spaces where **Allow Granola API access** is enabled + +Granola enables API access by default for newly created spaces. Review +**Settings > Spaces** and turn it off anywhere Roomote should not read notes. + +If a workspace key is not available, create a personal key from **Settings > +Connectors > API keys** and select only **Public notes**, leaving **Personal +notes** unchecked. ## What to expect -The built-in Granola connection is read-only. Roomote can search and read notes, -folders, meeting details, and transcripts, but it cannot modify Granola content. +The built-in Granola connection is read-only. Roomote can list and read the +notes, folders, meeting details, and transcripts allowed by the configured key, +but it cannot modify Granola content or manage Granola webhooks. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index ad18ea5b2..8788a3679 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -55,7 +55,7 @@ from [Personal Settings](/personal-settings). | | Monitoring and incident context | Admin connection once | | | Prompts, runs, and evaluation context | Enable first, then teammates link accounts | | | Dashboards, alerting, and monitoring context | Admin connection once | -| | Meeting notes, transcripts, and decisions | Enable first, then teammates link accounts | +| | Meeting notes, transcripts, and decisions | Admin connection once | | | Issues, projects, and JQL-backed issue context | Admin connection once | | | Turning issues into Roomote work | Workspace plus user identity | | | Board, item, and workspace context | Enable first, then teammates link accounts | diff --git a/apps/web/src/components/settings/Integrations.test.tsx b/apps/web/src/components/settings/Integrations.test.tsx index 29485b273..e4161a933 100644 --- a/apps/web/src/components/settings/Integrations.test.tsx +++ b/apps/web/src/components/settings/Integrations.test.tsx @@ -35,6 +35,9 @@ const state = vi.hoisted(() => ({ asanaConnection: null as null | { authStatus?: string | null; }, + granolaConnection: null as null | { + authStatus?: string | null; + }, grafanaConnection: null as null | { authStatus?: string | null; baseUrl: string; @@ -91,6 +94,7 @@ const { mutations, selectMock } = vi.hoisted(() => ({ disconnectMcp: vi.fn(), setDisabledTools: vi.fn(), saveAsanaConnection: vi.fn(), + saveGranolaConnection: vi.fn(), saveGrafanaConnection: vi.fn(), saveSnowflakeConnection: vi.fn(), saveVercelConnection: vi.fn(), @@ -242,6 +246,14 @@ vi.mock('@/hooks/mcp-connections', () => ({ data: state.asanaConnection, isPending: false, }), + useSaveGranolaConnection: () => ({ + isPending: false, + mutate: mutations.saveGranolaConnection, + }), + useGranolaConnection: () => ({ + data: state.granolaConnection, + isPending: false, + }), useSaveGrafanaConnection: () => ({ isPending: false, mutate: mutations.saveGrafanaConnection, @@ -443,6 +455,7 @@ describe('Integrations settings', () => { }; state.linearRedirectPath = ''; state.asanaConnection = null; + state.granolaConnection = null; state.grafanaConnection = null; state.vercelConnection = null; state.isAdmin = true; @@ -862,6 +875,9 @@ describe('Integrations settings', () => { expect( screen.getByRole('button', { name: 'Configure Grafana' }), ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Configure Granola' }), + ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Configure Snowflake' }), ).toBeInTheDocument(); @@ -1079,7 +1095,7 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); expect( screen.getByText( - 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', + 'Roomote will use one deployment-wide Granola connection to browse meeting notes, transcripts, decisions, and action items.', ), ).toBeInTheDocument(); }); @@ -1387,6 +1403,32 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('opens the Granola API-key dialog with deployment access guidance', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Granola' })); + + expect( + screen.getByRole('heading', { name: 'Connect Granola' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Granola API Key')).toBeInTheDocument(); + expect( + screen.getByText( + /We strongly recommend a Granola workspace API key\. Workspace keys can read public notes and spaces where "Allow Granola API access" is enabled\./, + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'You can also use a personal API key with Public notes selected and Personal notes left unchecked.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Store a Granola API key for this deployment. The secret stays encrypted server-side.', + ), + ).toBeInTheDocument(); + }); + it('submits an Asana token from the dialog', () => { render(); @@ -1431,6 +1473,26 @@ describe('Integrations settings', () => { ); }); + it('submits a trimmed Granola API key from the dialog', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Configure Granola' })); + fireEvent.change(screen.getByLabelText('Granola API Key'), { + target: { value: ' granola-secret-key ' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Connect Granola' })); + + expect(mutations.saveGranolaConnection).toHaveBeenCalledWith( + { + apiKey: 'granola-secret-key', + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('shows Asana connected controls and supports editing', () => { state.deploymentEnablements = [{ mcpId: 'asana', enabled: true }]; state.userConnections = [ @@ -1546,6 +1608,64 @@ describe('Integrations settings', () => { ).toBeInTheDocument(); }); + it('shows native Granola connected controls and supports editing', () => { + state.deploymentEnablements = [{ mcpId: 'granola', enabled: true }]; + state.userConnections = [ + { + mcpId: 'granola', + authStatus: 'authenticated', + }, + ]; + state.granolaConnection = { + authStatus: 'authenticated', + }; + + render(); + + const granolaCard = screen + .getByRole('heading', { name: 'Granola' }) + .closest('#integration-granola'); + expect(granolaCard).not.toBeNull(); + expect( + within(granolaCard as HTMLElement) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['Disconnect Granola', 'Edit Granola connection']); + expect( + screen.queryByRole('button', { name: 'Manage Granola tools' }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect Granola' })); + expect(mutations.disconnectMcp).toHaveBeenCalledWith( + { mcpId: 'granola' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Edit Granola connection' }), + ); + + expect( + screen.getByRole('heading', { name: 'Edit Granola' }), + ).toBeInTheDocument(); + expect(screen.getByLabelText('Granola API Key')).toHaveValue(''); + expect( + screen.getByText('Leave blank to keep the existing API key.'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + expect(mutations.saveGranolaConnection).toHaveBeenCalledWith( + { apiKey: '' }, + expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + }), + ); + }); + it('opens the Vercel credential dialog from the integrations page', () => { render(); diff --git a/apps/web/src/components/settings/Integrations.tsx b/apps/web/src/components/settings/Integrations.tsx index 1f104f3a1..65f803993 100644 --- a/apps/web/src/components/settings/Integrations.tsx +++ b/apps/web/src/components/settings/Integrations.tsx @@ -24,10 +24,12 @@ import { useCuratedIntegrationsAvailability, useDisconnectMcp, useGrafanaConnection, + useGranolaConnection, useDeploymentMcpEnablements, useMcpOauthReadiness, useSaveAsanaConnection, useSaveGrafanaConnection, + useSaveGranolaConnection, useSaveSnowflakeConnection, useSaveVercelConnection, useSetDeploymentMcpEnabled, @@ -39,6 +41,7 @@ import { useAuthorizedUser } from '@/hooks/useUser'; import { saveAsanaConnectionSchema, saveGrafanaConnectionSchema, + saveGranolaConnectionSchema, saveSnowflakeConnectionSchema, saveVercelConnectionSchema, } from '@/types'; @@ -89,7 +92,7 @@ const DEEP_LINK_ENABLE_DESCRIPTIONS: Record = { grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', granola: - 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', + 'Roomote will use one deployment-wide Granola connection to browse meeting notes, transcripts, decisions, and action items.', github: 'Roomote will be able to inspect PRs, issues, and repository context.', jira: 'Roomote will be able to inspect Jira issues, workflows, and JQL search results.', @@ -207,6 +210,10 @@ type AsanaFormState = { accessToken: string; }; +type GranolaFormState = { + apiKey: string; +}; + type GrafanaFormState = { baseUrl: string; serviceAccountToken: string; @@ -243,6 +250,12 @@ function buildEmptyAsanaForm(): AsanaFormState { }; } +function buildEmptyGranolaForm(): GranolaFormState { + return { + apiKey: '', + }; +} + function buildEmptyGrafanaForm(): GrafanaFormState { return { baseUrl: '', @@ -305,6 +318,20 @@ function getAsanaFieldErrors( }; } +function getGranolaFieldErrors( + result: ReturnType, +): Partial> { + if (result.success) { + return {}; + } + + const fieldErrors = result.error.flatten().fieldErrors; + + return { + apiKey: fieldErrors.apiKey, + }; +} + function buildGrafanaForm( connection: GrafanaConnectionData | null | undefined, ): GrafanaFormState { @@ -924,6 +951,65 @@ function AsanaConnectionFields({ ); } +function GranolaConnectionFields({ + form, + fieldErrors, + formError, + allowBlankApiKey, + onFieldChange, +}: { + form: GranolaFormState; + fieldErrors: Partial>; + formError: string | null; + allowBlankApiKey: boolean; + onFieldChange: (field: keyof GranolaFormState, value: string) => void; +}) { + const fieldClassName = + 'mt-2 w-full border-border/70 bg-background data-[invalid=true]:border-destructive'; + + return ( + <> +
+ + onFieldChange('apiKey', event.target.value)} + data-invalid={fieldErrors.apiKey ? 'true' : undefined} + className={fieldClassName} + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + data-1p-ignore + /> +

+ We strongly recommend a Granola workspace API key. Workspace keys can + read public notes and spaces where "Allow Granola API + access" is enabled. New spaces enable API access by default, so + admins should review space settings before connecting. +

+

+ You can also use a personal API key with Public notes selected and + Personal notes left unchecked. +

+ {allowBlankApiKey ? ( +

+ Leave blank to keep the existing API key. +

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

{fieldErrors.apiKey[0]}

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

{formError}

+ ) : null} + + ); +} + function GrafanaConnectionFields({ form, fieldErrors, @@ -1119,6 +1205,14 @@ export function Integrations() { Partial> >({}); const [asanaFormError, setAsanaFormError] = useState(null); + const [isGranolaDialogOpen, setIsGranolaDialogOpen] = useState(false); + const [granolaForm, setGranolaForm] = useState( + buildEmptyGranolaForm(), + ); + const [granolaFieldErrors, setGranolaFieldErrors] = useState< + Partial> + >({}); + const [granolaFormError, setGranolaFormError] = useState(null); const [isGrafanaDialogOpen, setIsGrafanaDialogOpen] = useState(false); const [grafanaForm, setGrafanaForm] = useState( buildEmptyGrafanaForm(), @@ -1172,6 +1266,7 @@ export function Integrations() { const disconnectMcp = useDisconnectMcp(); const saveAsanaConnection = useSaveAsanaConnection(); const saveGrafanaConnection = useSaveGrafanaConnection(); + const saveGranolaConnection = useSaveGranolaConnection(); const saveSnowflakeConnection = useSaveSnowflakeConnection(); const saveVercelConnection = useSaveVercelConnection(); const asanaConnectionSummary = useMemo(() => { @@ -1186,6 +1281,18 @@ export function Integrations() { const asanaConnection = useAsanaConnection( isAdmin && (isAsanaConnected || isAsanaDialogOpen), ); + const granolaConnectionSummary = useMemo(() => { + const connection = (userMcpConnections.data ?? []).find( + (entry) => entry.mcpId === 'granola', + ); + + return connection; + }, [userMcpConnections.data]); + const isGranolaConnected = + granolaConnectionSummary?.authStatus === 'authenticated'; + const granolaConnection = useGranolaConnection( + isAdmin && (isGranolaConnected || isGranolaDialogOpen), + ); const grafanaConnectionSummary = useMemo(() => { const connection = (userMcpConnections.data ?? []).find( (entry) => entry.mcpId === 'grafana', @@ -1239,6 +1346,20 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }, [asanaConnection.isPending, isAsanaConnected, isAsanaDialogOpen]); + useEffect(() => { + if (!isGranolaDialogOpen) { + return; + } + + if (granolaConnection.isPending && isGranolaConnected) { + return; + } + + setGranolaFieldErrors({}); + setGranolaFormError(null); + setGranolaForm(buildEmptyGranolaForm()); + }, [granolaConnection.isPending, isGranolaConnected, isGranolaDialogOpen]); + useEffect(() => { if (!isSnowflakeDialogOpen) { return; @@ -1453,6 +1574,26 @@ export function Integrations() { }); } + if (integration.id === 'granola') { + return buildAdminConfiguredIntegrationItem({ + integration, + connection: userConnectionMap.get(integration.id), + orgEnabled: orgEnablementMap.get(integration.id) ?? false, + highlightedIntegrationId, + savePending: saveGranolaConnection.isPending, + disconnectPending: disconnectMcp.isPending, + disconnectingMcpId: disconnectMcp.variables?.mcpId, + dialogOpen: isGranolaDialogOpen, + connectionPending: granolaConnection.isPending, + canConfigure: isAdmin, + canManageTools: isAdmin, + openDialog: () => setIsGranolaDialogOpen(true), + openToolDialog: () => openMcpToolDialog(integration), + disconnectIntegration: () => + disconnectAdminConfiguredIntegration(integration), + }); + } + if (integration.id === 'snowflake') { return buildAdminConfiguredIntegrationItem({ integration, @@ -1652,6 +1793,7 @@ export function Integrations() { disconnectLinear, disconnectMcp, grafanaConnection.isPending, + granolaConnection.isPending, linearInstallation.data, linearInstallation.isPending, linearOauthSetup.isPending, @@ -1660,9 +1802,11 @@ export function Integrations() { oauthReadiness.isPending, isAdmin, isGrafanaDialogOpen, + isGranolaDialogOpen, isLinearOauthSetupOpen, saveAsanaConnection.isPending, saveGrafanaConnection.isPending, + saveGranolaConnection.isPending, saveVercelConnection.isPending, deploymentEnablements.data, pathname, @@ -1754,6 +1898,21 @@ export function Integrations() { setAsanaFormError(null); }; + const handleGranolaFieldChange = ( + field: keyof GranolaFormState, + value: string, + ) => { + setGranolaForm((current) => ({ ...current, [field]: value })); + setGranolaFieldErrors((current) => { + if (!current[field]) { + return current; + } + + return { ...current, [field]: undefined }; + }); + setGranolaFormError(null); + }; + const handleGrafanaFieldChange = ( field: keyof GrafanaFormState, value: string, @@ -1797,6 +1956,19 @@ export function Integrations() { setAsanaForm(buildEmptyAsanaForm()); }; + const handleGranolaDialogOpenChange = (open: boolean) => { + setIsGranolaDialogOpen(open); + + setGranolaFieldErrors({}); + setGranolaFormError(null); + + if (!open) { + return; + } + + setGranolaForm(buildEmptyGranolaForm()); + }; + const handleSnowflakeDialogOpenChange = (open: boolean) => { setIsSnowflakeDialogOpen(open); @@ -1878,6 +2050,42 @@ export function Integrations() { }); }; + const handleGranolaSubmit = (event: FormEvent) => { + event.preventDefault(); + + const parsed = saveGranolaConnectionSchema.safeParse({ + apiKey: granolaForm.apiKey, + }); + if (!parsed.success) { + setGranolaFieldErrors(getGranolaFieldErrors(parsed)); + return; + } + + if (!isGranolaConnected && parsed.data.apiKey.length === 0) { + setGranolaFieldErrors({ + apiKey: ['API key is required'], + }); + return; + } + + setGranolaFieldErrors({}); + setGranolaFormError(null); + + saveGranolaConnection.mutate(parsed.data, { + onSuccess: () => { + toast.success( + isGranolaConnected + ? 'Granola connection updated for this deployment.' + : 'Granola connected for this deployment.', + ); + handleGranolaDialogOpenChange(false); + }, + onError: (error) => { + setGranolaFormError(error.message); + }, + }); + }; + const handleSnowflakeSubmit = (event: FormEvent) => { event.preventDefault(); @@ -2049,6 +2257,29 @@ export function Integrations() { onFieldChange={handleAsanaFieldChange} /> + + Store a Granola API key for this deployment. The secret stays + encrypted server-side. + + } + onSubmit={handleGranolaSubmit} + > + + { + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.deploymentEnablements.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.userConnections.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.mcpConnections.granolaConnection.queryKey(), + }); + }, + }), + ); +} diff --git a/apps/web/src/trpc/commands/mcp-connections/index.ts b/apps/web/src/trpc/commands/mcp-connections/index.ts index ea9cf2941..12b3b73bc 100644 --- a/apps/web/src/trpc/commands/mcp-connections/index.ts +++ b/apps/web/src/trpc/commands/mcp-connections/index.ts @@ -17,6 +17,7 @@ import { getMcpIntegrationDefaultDisabledTools, type McpConnectionRole, isMcpConnectionAsanaConfig, + isMcpConnectionGranolaConfig, isMcpConnectionGrafanaConfig, isMcpConnectionSnowflakeConfig, isMcpConnectionVercelConfig, @@ -40,6 +41,7 @@ import { assertCuratedIntegrationsEnabled } from '@/lib/server/curated-integrati import { MCP_TOOL_CATALOG_REQUIRES_PERSONAL_CONNECTION } from '@/lib/mcp-tool-errors'; import type { SaveAsanaConnectionInput, + SaveGranolaConnectionInput, SaveGrafanaConnectionInput, SaveSnowflakeConnectionInput, SaveVercelConnectionInput, @@ -751,6 +753,29 @@ export async function getAsanaConnectionCommand(auth: UserAuthSuccess) { }; } +export async function getGranolaConnectionCommand(auth: UserAuthSuccess) { + assertAdmin(auth); + + const connection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + authStatus: true, + }, + }); + + if (!connection || !isMcpConnectionGranolaConfig(connection.authConfig)) { + return null; + } + + return { + authStatus: connection.authStatus, + }; +} + export async function getVercelConnectionCommand( auth: UserAuthSuccess, ): Promise { @@ -1037,6 +1062,90 @@ export async function saveAsanaConnectionCommand( }; } +export async function saveGranolaConnectionCommand( + auth: UserAuthSuccess, + input: SaveGranolaConnectionInput, +) { + assertAdmin(auth); + assertCuratedIntegrationsEnabled(); + + const existingConnection = await db.query.mcpConnections.findFirst({ + where: and( + eq(mcpConnections.mcpId, 'granola'), + isNull(mcpConnections.userId), + ), + columns: { + authConfig: true, + }, + }); + + const existingConfig = isMcpConnectionGranolaConfig( + existingConnection?.authConfig, + ) + ? existingConnection.authConfig + : null; + const nextEncryptedApiKey = + input.apiKey.length > 0 + ? encrypt(input.apiKey) + : existingConfig?.encryptedApiKey; + + if (!nextEncryptedApiKey) { + throw new Error( + 'Granola API key is required when no Granola key is already stored.', + ); + } + + const authConfig = { + type: 'granola' as const, + encryptedApiKey: nextEncryptedApiKey, + }; + + await db + .insert(mcpConnections) + .values({ + userId: null, + mcpId: 'granola', + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + }) + .onConflictDoUpdate({ + target: [ + mcpConnections.userId, + mcpConnections.mcpId, + mcpConnections.connectionRole, + ], + set: { + connectionRole: 'default', + authConfig, + enabled: true, + authStatus: 'authenticated', + updatedAt: new Date(), + }, + }); + + await db + .insert(deploymentMcpEnablements) + .values({ + mcpId: 'granola', + enabled: true, + enabledByUserId: auth.userId, + }) + .onConflictDoUpdate({ + target: [deploymentMcpEnablements.mcpId], + set: { + enabled: true, + enabledByUserId: auth.userId, + updatedAt: new Date(), + }, + }); + + return { + authStatus: 'authenticated' as const, + }; +} + export async function saveVercelConnectionCommand( auth: UserAuthSuccess, input: SaveVercelConnectionInput, diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index d510672dd..a0ad4f9c9 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -36,6 +36,7 @@ import { pullRequestAnalyticsOverviewInputSchema, filterSchema, saveAsanaConnectionSchema, + saveGranolaConnectionSchema, saveGrafanaConnectionSchema, saveSnowflakeConnectionSchema, saveVercelConnectionSchema, @@ -195,11 +196,13 @@ import { setDeploymentMcpEnabledCommand, getUserMcpConnectionsCommand, getAsanaConnectionCommand, + getGranolaConnectionCommand, getGrafanaConnectionCommand, getSnowflakeConnectionCommand, getVercelConnectionCommand, listDeploymentMcpIntegrationToolsCommand, saveAsanaConnectionCommand, + saveGranolaConnectionCommand, saveGrafanaConnectionCommand, saveSnowflakeConnectionCommand, saveVercelConnectionCommand, @@ -1592,6 +1595,10 @@ export const appRouter = createRouter({ getAsanaConnectionCommand(auth), ), + granolaConnection: protectedProcedure.query(({ ctx: { auth } }) => + getGranolaConnectionCommand(auth), + ), + grafanaConnection: protectedProcedure.query(({ ctx: { auth } }) => getGrafanaConnectionCommand(auth), ), @@ -1654,6 +1661,12 @@ export const appRouter = createRouter({ saveAsanaConnectionCommand(auth, input), ), + saveGranolaConnection: protectedProcedure + .input(saveGranolaConnectionSchema) + .mutation(({ ctx: { auth }, input }) => + saveGranolaConnectionCommand(auth, input), + ), + saveGrafanaConnection: protectedProcedure .input(saveGrafanaConnectionSchema) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/web/src/types/mcp-connections.ts b/apps/web/src/types/mcp-connections.ts index e0d8d3bf7..b7fdc5514 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 saveGranolaConnectionSchema = z.object({ + apiKey: z.string().transform((value) => value.trim()), +}); + +export type SaveGranolaConnectionInput = z.infer< + typeof saveGranolaConnectionSchema +>; + export const saveVercelConnectionSchema = z.object({ accessToken: z.string().transform((value) => value.trim()), defaultTeamIdOrSlug: z diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts index c0cf9411b..18dfb1262 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/integration-setup.test.ts @@ -50,10 +50,13 @@ describe('integration setup guide', () => { ); }); - it('documents the user-scoped read-only Granola setup flow', () => { + it('documents the deployment-scoped read-only Granola setup flow', () => { expect(INTEGRATION_SETUP_CONTENT).toContain('# Granola'); expect(INTEGRATION_SETUP_CONTENT).toContain( - 'Each user connects their own Granola account via OAuth.', + 'That operator connects Granola once for the deployment with a Granola API key.', + ); + expect(INTEGRATION_SETUP_CONTENT).toContain( + 'If using a personal API key instead, create it with only Public notes selected.', ); expect(INTEGRATION_SETUP_CONTENT).toContain( 'The built-in connection is read-only.', 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 cb9c40668..b5e6b42e0 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/integration-setup.ts @@ -113,11 +113,11 @@ Once connected, I can access AI evaluation and logging data during tasks. # Granola -Granola uses OAuth: +Granola uses an admin-managed API key: 1. A deployment operator enables Granola from Settings > Integrations. -2. Each user connects their own Granola account via OAuth. +2. That operator connects Granola once for the deployment with a Granola API key. -Once connected, I can search and read the user's meeting notes, folders, transcripts, decisions, and action items from their active Granola workspace. The built-in connection is read-only. +Prefer a workspace API key, which cannot read ordinary private notes. Workspace keys can read public notes and notes in spaces where Allow Granola API access is enabled. If using a personal API key instead, create it with only Public notes selected. Once connected, I can browse and read accessible meeting notes, folders, transcripts, decisions, and action items. The built-in connection is read-only. # Asana 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 7e8b22507..3958d9a1e 100644 --- a/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts +++ b/packages/cloud-agents/src/server/mcp-self-setup/catalog.ts @@ -160,9 +160,9 @@ export const MCP_SETUP_INTEGRATION_METADATA: Record< }, granola: { capabilities: [ - 'Search meeting notes for relevant discussions and decisions', - 'Read meeting details and transcripts for implementation context', - 'Review folders, attendees, and action items during follow-up work', + 'Browse workspace meeting notes for relevant discussions and decisions', + 'Read meeting details and transcripts through a deployment API key', + 'Keep access read-only and limited by the Granola key configuration', ], }, supermemory: { diff --git a/packages/sdk/src/server/routers/mcp-connections.test.ts b/packages/sdk/src/server/routers/mcp-connections.test.ts index 80ee051c6..fa746d020 100644 --- a/packages/sdk/src/server/routers/mcp-connections.test.ts +++ b/packages/sdk/src/server/routers/mcp-connections.test.ts @@ -423,6 +423,37 @@ describe('mcpConnectionsRouter.getMcpServerConfigs', () => { }); }); + it('returns Granola proxy config without exposing the API key', async () => { + mockOrderBy.mockResolvedValue([ + buildJoinedConnectionRow({ + id: 'conn-granola', + userId: null, + mcpId: 'granola', + authConfig: { + type: 'granola', + encryptedApiKey: 'enc:secret', + }, + }), + ]); + + const result = await createCaller( + 'https://api.preview.roomote.run/trpc/mcpConnections.getMcpServerConfigs', + ).getMcpServerConfigs(); + + expect(getValidAccessToken).not.toHaveBeenCalled(); + expect(result).toEqual({ + servers: { + granola: { + url: 'https://api.preview.roomote.run/api/mcp/granola', + headers: { + 'X-MCP-Client': 'Roomote', + }, + }, + }, + }); + expect(JSON.stringify(result)).not.toContain('enc:secret'); + }); + it('returns Vercel proxy config without requesting OAuth tokens', async () => { mockOrderBy.mockResolvedValue([ buildJoinedConnectionRow({ diff --git a/packages/sdk/src/server/routers/mcp-connections.ts b/packages/sdk/src/server/routers/mcp-connections.ts index ac82b931b..e77ad4e12 100644 --- a/packages/sdk/src/server/routers/mcp-connections.ts +++ b/packages/sdk/src/server/routers/mcp-connections.ts @@ -17,6 +17,7 @@ import { getMcpIntegrationUpstreamUrl, MCP_INTEGRATIONS, isMcpConnectionAsanaConfig, + isMcpConnectionGranolaConfig, isMcpConnectionGrafanaConfig, getMcpIntegration, getMcpIntegrationConnectionScope, @@ -323,6 +324,7 @@ export const mcpConnectionsRouter = router({ } else if ( isMcpConnectionSnowflakeConfig(authConfig) || isMcpConnectionAsanaConfig(authConfig) || + isMcpConnectionGranolaConfig(authConfig) || isMcpConnectionVercelConfig(authConfig) || isMcpConnectionGrafanaConfig(authConfig) ) { diff --git a/packages/slack/src/mcp-recommendations.ts b/packages/slack/src/mcp-recommendations.ts index c726f823e..425f52635 100644 --- a/packages/slack/src/mcp-recommendations.ts +++ b/packages/slack/src/mcp-recommendations.ts @@ -34,7 +34,7 @@ const SLACK_ENABLE_DESCRIPTIONS: Record = { grafana: 'Roomote will be able to inspect dashboards, alert rules, live alert state, annotations, and data sources.', granola: - 'Roomote will be able to search meeting notes, transcripts, decisions, and action items.', + 'Roomote will be able to browse workspace meeting notes, transcripts, decisions, and action items through a deployment API key.', sentry: 'Roomote will be able to inspect Sentry issue context and run scheduled Sentry triage through MCP.', posthog: diff --git a/packages/types/src/__tests__/mcp-oauth.test.ts b/packages/types/src/__tests__/mcp-oauth.test.ts index b42f2cfca..f4d1eba57 100644 --- a/packages/types/src/__tests__/mcp-oauth.test.ts +++ b/packages/types/src/__tests__/mcp-oauth.test.ts @@ -48,15 +48,17 @@ describe('monday.com OAuth', () => { }); }); -describe('Granola OAuth', () => { - it('uses the hosted MCP with a user-scoped read-only connection', () => { +describe('Granola API key connection', () => { + it('uses a deployment-scoped native MCP with admin-managed credentials', () => { expect(getMcpIntegration('granola')).toMatchObject({ name: 'Granola', - url: 'https://mcp.granola.ai/mcp', - serverMode: 'upstream_proxy', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'native', }); - expect(getMcpIntegrationConnectionScope('granola')).toBe('user'); - expect(getMcpIntegrationOauthScopeMode('granola')).toBe('read-only'); + expect(getMcpIntegration('granola')?.url).toBeUndefined(); + expect(getMcpIntegrationConnectionScope('granola')).toBe('deployment'); + expect(getMcpIntegrationOauthScopeMode('granola')).toBeUndefined(); expect(getMcpIntegrationDefaultDisabledTools('granola')).toEqual([]); }); }); diff --git a/packages/types/src/mcp-oauth.ts b/packages/types/src/mcp-oauth.ts index b3179cbb1..a94a15827 100644 --- a/packages/types/src/mcp-oauth.ts +++ b/packages/types/src/mcp-oauth.ts @@ -123,6 +123,16 @@ export interface McpConnectionAsanaConfig { encryptedToken: string; } +/** + * Deployment-scoped Granola connection config stored in mcpConnections.authConfig. + * + * The API key is expected to be encrypted before persistence. + */ +export interface McpConnectionGranolaConfig { + type: 'granola'; + encryptedApiKey: string; +} + /** * Organization-scoped Vercel connection config stored in mcpConnections.authConfig. * @@ -157,6 +167,7 @@ export type McpConnectionAuthConfig = | McpConnectionOAuthConfig | McpConnectionSnowflakeConfig | McpConnectionAsanaConfig + | McpConnectionGranolaConfig | McpConnectionVercelConfig | McpConnectionGrafanaConfig | Record; @@ -482,13 +493,13 @@ export const MCP_INTEGRATIONS: McpIntegration[] = [ { id: 'granola', name: 'Granola', - url: 'https://mcp.granola.ai/mcp', - description: `Search meeting notes, transcripts, decisions, and action items from ${PRODUCT_NAME} tasks`, + description: `Connect Granola so your agents can browse meeting notes, transcripts, decisions, and action items from ${PRODUCT_NAME} tasks`, icon: 'granola', - serverMode: 'upstream_proxy', - oauthScopeMode: 'read-only', + connectionScope: 'deployment', + connectionMode: 'admin_configured', + serverMode: 'native', instructions: - "Use Granola to search and read the connected user's meeting notes, transcripts, folders, decisions, and action items. Granola follows the user's active workspace and exposes read-only tools.", + 'Use Granola to browse and read meeting notes, transcripts, folders, decisions, and action items through the deployment API key. The built-in tools are read-only.', }, { id: 'supermemory', @@ -798,6 +809,19 @@ export function isMcpConnectionAsanaConfig( ); } +export function isMcpConnectionGranolaConfig( + authConfig: McpConnectionAuthConfig | null | undefined, +): authConfig is McpConnectionGranolaConfig { + return Boolean( + authConfig && + typeof authConfig === 'object' && + 'type' in authConfig && + authConfig.type === 'granola' && + 'encryptedApiKey' in authConfig && + typeof authConfig.encryptedApiKey === 'string', + ); +} + export function isMcpConnectionVercelConfig( authConfig: McpConnectionAuthConfig | null | undefined, ): authConfig is McpConnectionVercelConfig { diff --git a/packages/types/src/mcp-service-detection.ts b/packages/types/src/mcp-service-detection.ts index eb884fea7..0fb0b9206 100644 --- a/packages/types/src/mcp-service-detection.ts +++ b/packages/types/src/mcp-service-detection.ts @@ -262,7 +262,7 @@ export const SLACK_MCP_SETUP_SERVICES: SlackMcpSetupServiceDefinition[] = [ { id: 'granola', name: 'Granola', - availabilityKind: 'curated_oauth', + availabilityKind: 'admin_configured', hostSuffixes: ['notes.granola.ai'], deploymentSettingsPath: '/settings/integrations', userSettingsPath: '/settings/personal',