diff --git a/backend/__tests__/fixtures/hostedMcp/airtable-authorization-server-2026-10-28.json b/backend/__tests__/fixtures/hostedMcp/airtable-authorization-server-2026-10-28.json new file mode 100644 index 0000000000..3f24ec420e --- /dev/null +++ b/backend/__tests__/fixtures/hostedMcp/airtable-authorization-server-2026-10-28.json @@ -0,0 +1,52 @@ +{ + "measuredAt": "2026-10-28T04:00:12Z", + "url": "https://airtable.com/.well-known/oauth-authorization-server/oauth2/v1", + "metadata": { + "issuer": "https://airtable.com/oauth2/v1", + "registration_endpoint": "https://airtable.com/oauth2/v1/register", + "authorization_endpoint": "https://airtable.com/oauth2/v1/authorize", + "token_endpoint": "https://airtable.com/oauth2/v1/token", + "jwks_uri": "https://airtable.com/oauth2/v1/jwks", + "response_types_supported": [ + "code" + ], + "scopes_supported": [ + "data.records:read", + "data.records:write", + "schema.bases:read", + "schema.bases:write", + "data.recordComments:read", + "data.recordComments:write", + "workspacesAndBases:read" + ], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "none" + ], + "code_challenge_methods_supported": [ + "S256" + ], + "resource_indicators_supported": true, + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256", + "HS256" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "subject_types_supported": [ + "public" + ], + "claims_supported": [ + "iss", + "sub", + "aud", + "iat", + "exp", + "email", + "email_verified", + "nonce" + ], + "client_id_metadata_document_supported": true + } +} diff --git a/backend/__tests__/service/hostedMcpDcrRegistry.test.js b/backend/__tests__/service/hostedMcpDcrRegistry.test.js index c0a730282e..2b94daeca1 100644 --- a/backend/__tests__/service/hostedMcpDcrRegistry.test.js +++ b/backend/__tests__/service/hostedMcpDcrRegistry.test.js @@ -14,6 +14,8 @@ const connectorSecrets = require('../../services/connectorSecrets'); const { buildTokenExchangeRequest, buildRefreshRequest, buildRevocationRequest, } = require('../../services/hostedMcpIntakeService'); +const { AIRTABLE_ENTRY } = require('../../integrations/hostedMcp/airtable'); +const AIRTABLE_AS_CAPTURE = require('../fixtures/hostedMcp/airtable-authorization-server-2026-10-28.json'); const entry = { id: 'close', @@ -377,6 +379,34 @@ describe('instance-scoped hosted MCP DCR registry', () => { }); }); + test('Airtable registers once, as a public client, at its captured 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 }); + }); + test('a CIMD entry never touches the registry or the AS', async () => { const fetch = registrationFetch([]); await expect(ensureDcrClient({ ...entry, client: 'cimd' }, metadata, callback, fetch)) diff --git a/backend/__tests__/unit/services/hostedMcpEntry.test.js b/backend/__tests__/unit/services/hostedMcpEntry.test.js index 8ace7662e3..43e4465848 100644 --- a/backend/__tests__/unit/services/hostedMcpEntry.test.js +++ b/backend/__tests__/unit/services/hostedMcpEntry.test.js @@ -23,6 +23,7 @@ const LINEAR_FIRST_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list const LINEAR_CAPTURE = require('../../fixtures/hostedMcp/linear-tools-list-2026-10-14.json'); const ATLASSIAN_CAPTURE = require('../../fixtures/hostedMcp/atlassian-tools-list-2026-10-19.json'); const AIRTABLE_CAPTURE = require('../../fixtures/hostedMcp/airtable-tools-list-2026-10-09.json'); +const AIRTABLE_AS_CAPTURE = require('../../fixtures/hostedMcp/airtable-authorization-server-2026-10-28.json'); const SENTRY_CAPTURE = require('../../fixtures/hostedMcp/sentry-tools-list-2026-10-19.json'); const NOTION_CAPTURE = require('../../fixtures/hostedMcp/notion-tools-list-2026-10-19.json'); @@ -739,7 +740,6 @@ describe('the shipped catalogue cannot land half-wired', () => { // read-only. Every writer stays out by class. const capture = AIRTABLE_CAPTURE; const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'airtable'); - expect(entry.client).toBe('cimd'); expect(capture.server).toBe(entry.resource); // Root PRM metadata fetched 2026-10-14 names the bare origin, while the // consenting 2026-10-09 tools/list capture used /mcp as its endpoint. @@ -771,6 +771,22 @@ 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. + const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'airtable'); + const { url, metadata } = AIRTABLE_AS_CAPTURE; + expect(entry.client).toBe('dcr'); + 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.code_challenge_methods_supported).toEqual(['S256']); + expect([...entry.scopes].every((scope) => metadata.scopes_supported.includes(scope))).toBe(true); + }); + test('the Sentry pin restricts resource lookup to type and ID and parks its destructive writer', () => { const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, 'sentry'); const capture = SENTRY_CAPTURE; diff --git a/backend/integrations/hostedMcp/airtable.ts b/backend/integrations/hostedMcp/airtable.ts index 27dc4ef703..370e08def0 100644 --- a/backend/integrations/hostedMcp/airtable.ts +++ b/backend/integrations/hostedMcp/airtable.ts @@ -55,9 +55,13 @@ 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', - // The authorization server advertises `client_id_metadata_document_supported: - // true` and S256, so nothing is registered and no secret is stored. - client: 'cimd', + // 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', scopes: [ 'data.records:read', 'schema.bases:read',