From f2416e68ba18e76a7246c1c4fbb812ca06e987f3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:16:52 -0800 Subject: [PATCH] feat(connectors): Airtable uses a pre-registered client, wired behind a flag Airtable kept every DCR client in development mode, so other accounts were refused before consent. The entry now presents a hand-registered integration's client id and secret (AIRTABLE_CLIENT_ID/_SECRET), synced from Secret Manager into api-keys only when hostedMcp.airtable.enabled is on. With the flag off the entry reads not_configured, as Google Calendar does. The existing DCR registry rows stay as they are and are no longer read for Airtable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DPFVAooJnqCSwrdxVw --- .../service/hostedMcpDcrRegistry.test.js | 31 ++++--------------- .../unit/services/hostedMcpEntry.test.js | 15 +++++---- .../unit/services/hostedMcpIntake.test.js | 5 +-- .../installableCatalogService.test.js | 9 ++++++ backend/integrations/hostedMcp/airtable.ts | 17 +++++----- .../templates/core/backend-deployment.yaml | 16 ++++++++++ .../templates/ops/ops-runner-deployment.yaml | 16 ++++++++++ .../commonly/templates/secrets/api-keys.yaml | 11 +++++++ k8s/helm/commonly/values-dev.yaml | 3 ++ k8s/helm/commonly/values.yaml | 7 +++++ 10 files changed, 88 insertions(+), 42 deletions(-) diff --git a/backend/__tests__/service/hostedMcpDcrRegistry.test.js b/backend/__tests__/service/hostedMcpDcrRegistry.test.js index 69488783e7..a0066de759 100644 --- a/backend/__tests__/service/hostedMcpDcrRegistry.test.js +++ b/backend/__tests__/service/hostedMcpDcrRegistry.test.js @@ -412,32 +412,13 @@ describe('instance-scoped hosted MCP DCR registry', () => { }); }); - test('Airtable registers once, as a public client, at its captured registration endpoint', async () => { + test('Airtable, now pre-registered, never touches the registry or its registration endpoint', async () => { const airtableCallback = 'https://api.commonly.me/api/integrations/connect/hosted-mcp/airtable/callback'; - const sent = []; - const fetch = jest.fn(async (url, init) => { - sent.push({ url, body: JSON.parse(init.body) }); - return new Response(JSON.stringify({ - client_id: 'airtable-registered', - token_endpoint_auth_method: 'none', - redirect_uris: [airtableCallback], - }), { status: 201 }); - }); - const client = await ensureDcrClient(AIRTABLE_ENTRY, AIRTABLE_AS_CAPTURE.metadata, airtableCallback, fetch); - - expect(client).toEqual({ clientId: 'airtable-registered' }); - expect(sent).toHaveLength(1); - expect(sent[0].url).toBe('https://airtable.com/oauth2/v1/register'); - expect(sent[0].body).toEqual({ - client_name: 'Commonly (Airtable)', - redirect_uris: [airtableCallback], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - token_endpoint_auth_method: 'none', - scope: AIRTABLE_ENTRY.scopes.join(' '), - }); - const stored = await Model.findOne({ entryId: 'airtable', current: true }); - expect(stored).toMatchObject({ issuer: 'https://airtable.com/oauth2/v1', clientSecretRef: null }); + const fetch = registrationFetch([]); + await expect(ensureDcrClient(AIRTABLE_ENTRY, AIRTABLE_AS_CAPTURE.metadata, airtableCallback, fetch)) + .rejects.toMatchObject({ code: 'client_registration_unsupported' }); + expect(fetch).not.toHaveBeenCalled(); + expect(await Model.countDocuments({})).toBe(0); }); test('a CIMD entry never touches the registry or the AS', async () => { diff --git a/backend/__tests__/unit/services/hostedMcpEntry.test.js b/backend/__tests__/unit/services/hostedMcpEntry.test.js index d876ef95d8..1f254ebd19 100644 --- a/backend/__tests__/unit/services/hostedMcpEntry.test.js +++ b/backend/__tests__/unit/services/hostedMcpEntry.test.js @@ -843,18 +843,17 @@ describe('the shipped catalogue cannot land half-wired', () => { expect(capture.tools).toHaveLength(46); }); - test('Airtable registers a public client at the registration endpoint its issuer advertises', () => { - // TASK-318: Airtable refused our metadata URL as a client_id without - // fetching it, so the entry registers by DCR. The registry reads the - // endpoint from discovery; this capture of that document (2026-10-28) pins - // where it points and that a public client is on offer. + test('Airtable presents a pre-registered client its issuer can authenticate', () => { + // TASK-318: Airtable refused our metadata URL as a client_id, then kept + // every DCR client in development mode, so the entry uses an integration + // registered by hand. This capture of discovery (2026-10-28) pins that the + // issuer accepts a client secret at its token endpoint. const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'airtable'); const { url, metadata } = AIRTABLE_AS_CAPTURE; - expect(entry.client).toBe('dcr'); + expect(entry.client).toBe('pre-registered'); expect(url).toBe('https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1'); expect(metadata.issuer).toBe(entry.issuer); - expect(metadata.registration_endpoint).toBe('https://airtable.com/oauth2/v1/register'); - expect(metadata.token_endpoint_auth_methods_supported).toContain('none'); + expect(metadata.token_endpoint_auth_methods_supported).toContain('client_secret_basic'); expect(metadata.code_challenge_methods_supported).toEqual(['S256']); expect([...entry.scopes].every((scope) => metadata.scopes_supported.includes(scope))).toBe(true); }); diff --git a/backend/__tests__/unit/services/hostedMcpIntake.test.js b/backend/__tests__/unit/services/hostedMcpIntake.test.js index 59f3ecd9be..151abe5ec1 100644 --- a/backend/__tests__/unit/services/hostedMcpIntake.test.js +++ b/backend/__tests__/unit/services/hostedMcpIntake.test.js @@ -372,10 +372,11 @@ describe('hosted-mcp intake: RFC 8707\'s resource, on every request', () => { const url = new URL(buildAuthorizeUrl(AIRTABLE_ENTRY, AS_METADATA.authorization_endpoint, { clientId: 'cid', redirectUri: 'https://api.example.test/cb', state: 's', codeChallenge: 'c', })); - const exchange = buildTokenExchangeRequest(AIRTABLE_ENTRY, {}, { clientId: 'cid' }, { + const client = { clientId: 'cid', clientSecret: 'secret' }; + const exchange = buildTokenExchangeRequest(AIRTABLE_ENTRY, {}, client, { redirectUri: 'https://api.example.test/cb', code: 'c', codeVerifier: 'v', }); - const refresh = buildRefreshRequest(AIRTABLE_ENTRY, {}, { clientId: 'cid' }, { refreshToken: 'r' }); + const refresh = buildRefreshRequest(AIRTABLE_ENTRY, {}, client, { refreshToken: 'r' }); expect(url.searchParams.get('resource')).toBe('https://mcp.airtable.com'); expect(exchange.body.get('resource')).toBe('https://mcp.airtable.com'); expect(refresh.body.get('resource')).toBe('https://mcp.airtable.com'); diff --git a/backend/__tests__/unit/services/installableCatalogService.test.js b/backend/__tests__/unit/services/installableCatalogService.test.js index 1964226dc3..48b58dfac5 100644 --- a/backend/__tests__/unit/services/installableCatalogService.test.js +++ b/backend/__tests__/unit/services/installableCatalogService.test.js @@ -410,8 +410,13 @@ it('validates the instance hidden-entry setting', () => { it('offers a hidden hosted entry only to its configured walk user', async () => { const hiddenBefore = process.env.HOSTED_MCP_HIDDEN_ENTRIES; const allowBefore = process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + const idBefore = process.env.AIRTABLE_CLIENT_ID; + const secretBefore = process.env.AIRTABLE_CLIENT_SECRET; process.env.HOSTED_MCP_HIDDEN_ENTRIES = '["airtable"]'; process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ airtable: [userId] }); + // Airtable is pre-registered, so it is offered only with its client configured. + process.env.AIRTABLE_CLIENT_ID = 'airtable-client'; + process.env.AIRTABLE_CLIENT_SECRET = 'airtable-secret'; try { const entry = HOSTED_MCP_ENTRIES.find((row) => row.id === 'airtable'); mockInstallables([], [buildHostedMcpToolInstallable(entry)]); @@ -431,5 +436,9 @@ it('offers a hidden hosted entry only to its configured walk user', async () => else process.env.HOSTED_MCP_HIDDEN_ENTRIES = hiddenBefore; if (allowBefore === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowBefore; + if (idBefore === undefined) delete process.env.AIRTABLE_CLIENT_ID; + else process.env.AIRTABLE_CLIENT_ID = idBefore; + if (secretBefore === undefined) delete process.env.AIRTABLE_CLIENT_SECRET; + else process.env.AIRTABLE_CLIENT_SECRET = secretBefore; } }); diff --git a/backend/integrations/hostedMcp/airtable.ts b/backend/integrations/hostedMcp/airtable.ts index 370e08def0..abd14a5810 100644 --- a/backend/integrations/hostedMcp/airtable.ts +++ b/backend/integrations/hostedMcp/airtable.ts @@ -55,13 +55,16 @@ export const AIRTABLE_ENTRY: HostedMcpEntry = { // consenting tools/list capture used /mcp as the endpoint on 2026-10-09. audience: 'https://mcp.airtable.com', issuer: 'https://airtable.com/oauth2/v1', - // Airtable advertises `client_id_metadata_document_supported: true`, but on - // 2026-10-27 it refused our metadata URL as a client_id without fetching it - // (Cloudflare logged no fetch from Airtable, TASK-318). So the client is - // registered at the advertised registration_endpoint instead. The metadata - // lists `none`, so the registry registers a public client and stores no - // secret. PKCE S256 is still sent. - client: 'dcr', + // A pre-registered integration, made by hand at airtable.com/create/oauth + // with the instance's privacy policy and terms URLs (F67). Airtable lets only + // an integration's own account authorize it until it names both pages, and + // its registration endpoint drops `policy_uri`/`tos_uri` (probed 2026-11-03), + // so the DCR client used from 10-27 refused every other account: "This OAuth + // application cannot be used outside of development" (prod, 11-02). Before + // that, CIMD: Airtable advertises it but refused our metadata URL without + // fetching it (TASK-318, 10-27). The id and secret arrive as + // AIRTABLE_CLIENT_ID / AIRTABLE_CLIENT_SECRET (hostedMcp.airtable in the chart). + client: 'pre-registered', scopes: [ 'data.records:read', 'schema.bases:read', diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index 52f1c8c191..7107b7fcf4 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -445,6 +445,22 @@ spec: name: api-keys key: commonly-bot-runtime-token + # Airtable hosted-MCP client (pre-registered, F67). The names are what + # hostedMcpClientConfigKeys('airtable') derives. Optional: with + # hostedMcp.airtable off the entry reads not_configured. + - name: AIRTABLE_CLIENT_ID + valueFrom: + secretKeyRef: + name: api-keys + key: airtable-client-id + optional: true + - name: AIRTABLE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: airtable-client-secret + optional: true + # Google Calendar hosted-MCP client (pre-registered). The names are # what hostedMcpClientConfigKeys('google-calendar') derives. Optional, # like Slack's below: an instance with hostedMcp.googleCalendar off diff --git a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml index 24d8a052d1..289ed9b514 100644 --- a/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml +++ b/k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml @@ -412,6 +412,22 @@ spec: name: api-keys key: commonly-bot-runtime-token + # Airtable hosted-MCP client (pre-registered, F67). The names are what + # hostedMcpClientConfigKeys('airtable') derives. Optional: with + # hostedMcp.airtable off the entry reads not_configured. + - name: AIRTABLE_CLIENT_ID + valueFrom: + secretKeyRef: + name: api-keys + key: airtable-client-id + optional: true + - name: AIRTABLE_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: api-keys + key: airtable-client-secret + optional: true + # Google Calendar hosted-MCP client (pre-registered). The names are # what hostedMcpClientConfigKeys('google-calendar') derives. Optional, # like Slack's below: an instance with hostedMcp.googleCalendar off diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index 31b4afcd0a..bed252b63b 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -220,6 +220,17 @@ spec: remoteRef: key: {{ $keyPrefix }}google-calendar-client-secret {{- end }} + {{- if .Values.hostedMcp.airtable.enabled }} + # Airtable hosted-MCP client (pre-registered, F67). Both remote keys must + # exist with a version before enabling: a missing remoteRef prevents ESO from + # syncing api-keys. + - secretKey: airtable-client-id + remoteRef: + key: {{ $keyPrefix }}airtable-client-id + - secretKey: airtable-client-secret + remoteRef: + key: {{ $keyPrefix }}airtable-client-secret + {{- end }} {{- if .Values.hostedMcp.githubMcp.enabled }} # Pre-registered GitHub hosted-MCP client. Create both remote keys with an # enabled version before enabling; ESO cannot skip a missing remoteRef. diff --git a/k8s/helm/commonly/values-dev.yaml b/k8s/helm/commonly/values-dev.yaml index 7c56effc60..718c7e7e7f 100644 --- a/k8s/helm/commonly/values-dev.yaml +++ b/k8s/helm/commonly/values-dev.yaml @@ -456,6 +456,9 @@ hostedMcp: - github-mcp googleCalendar: enabled: false + # Flip on once airtable-client-id and -secret each have an enabled version. + airtable: + enabled: false # Commonly Connect App's client ID and secret each have an enabled version # in Secret Manager (verified 2026-10-19). The backend refs remain optional. githubMcp: diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index 0722c09285..0877439683 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -78,6 +78,13 @@ hostedMcp: hiddenEntryUserAllowlist: {} googleCalendar: enabled: false + # Airtable's pre-registered OAuth integration (F67). The backend reads + # AIRTABLE_CLIENT_ID / AIRTABLE_CLIENT_SECRET. Enable only after both + # `airtable-client-id` and `-secret` exist with enabled versions in + # Secret Manager: a missing remoteRef stalls the api-keys sync. While off, the + # entry reads not_configured and the Tools catalogue omits Airtable. + airtable: + enabled: false # Enable only after both pre-registered GitHub App client secrets exist with # enabled versions in Secret Manager. A missing remoteRef stalls api-keys. githubMcp: