Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions backend/__tests__/service/hostedMcpDcrRegistry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
15 changes: 7 additions & 8 deletions backend/__tests__/unit/services/hostedMcpEntry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
5 changes: 3 additions & 2 deletions backend/__tests__/unit/services/hostedMcpIntake.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]);
Expand All @@ -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;
}
});
17 changes: 10 additions & 7 deletions backend/integrations/hostedMcp/airtable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
16 changes: 16 additions & 0 deletions k8s/helm/commonly/templates/core/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions k8s/helm/commonly/templates/ops/ops-runner-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions k8s/helm/commonly/templates/secrets/api-keys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions k8s/helm/commonly/values-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions k8s/helm/commonly/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# `<prefix>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:
Expand Down
Loading