Skip to content
Merged
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
26 changes: 26 additions & 0 deletions backend/__tests__/unit/routes/hostedMcpConnect.callback.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,32 @@ describe('hosted-mcp connect: callback', () => {
expect(Integration.findOne).not.toHaveBeenCalled();
});

// Airtable refused every account but the client's own with this text (11-02):
// the member had clicked Allow, and "you didn't approve" told them otherwise.
// A known vendor refusal maps to its own fixed code and names the entry,
// never forwarding the vendor's words.
it('maps a vendor refusing the app itself to vendor_app_unavailable and names the entry', async () => {
const description = "This OAuth application cannot be used outside of development.\nPlease check your OAuth application's setting page VENDOR-SENTINEL";
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
let res;
try {
res = await callback(`state=st-1&error=access_denied&error_description=${encodeURIComponent(description)}`);
// The entry id only: vendor text never reaches the log.
expect(warn).toHaveBeenCalledWith('[hosted-mcp] vendor refused the app for this account', JSON.stringify({ entryId: 'linear' }));
expect(JSON.stringify(warn.mock.calls)).not.toContain('VENDOR-SENTINEL');
} finally {
warn.mockRestore();
}
const url = new URL(res.headers.location, 'https://commonly.me');
expect(res.status).toBe(302);
expect(url.searchParams.get('hostedMcp')).toBe('error');
expect(url.searchParams.get('code')).toBe('vendor_app_unavailable');
expect(url.searchParams.get('entryId')).toBe('linear');
expect(res.headers.location).not.toMatch(/development|setting/i);
expect(res.headers.location).not.toContain('VENDOR-SENTINEL');
expect(Integration.findOneAndUpdate).not.toHaveBeenCalled();
});

it.each([
['access_denied', 'consent_denied'],
['temporarily_unavailable', 'vendor_error'],
Expand Down
16 changes: 16 additions & 0 deletions backend/routes/hostedMcpConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,13 @@ const callbackRedirect = (
code?: string,
options?: {
manualRevoke?: { entryId: string; revokeAt: string };
/** A catalogue entry the page may name in its notice; never vendor text. */
entryId?: string;
},
) => {
const query = new URLSearchParams({ hostedMcp: status });
if (code) query.set('code', code);
if (options?.entryId) query.set('entryId', options.entryId);
if (options?.manualRevoke) {
query.set('entryId', options.manualRevoke.entryId);
query.set('revokeAt', options.manualRevoke.revokeAt);
Expand Down Expand Up @@ -491,6 +494,10 @@ const settleUnstoredGrant = async (
return undefined;
};

// Vendor descriptions that mean the vendor refused our app for this account,
// whatever the member clicked. Matched, never echoed.
const VENDOR_APP_UNAVAILABLE = /cannot be used outside of development/i;

const finishCallback = async (req: Request, res: Response, flow: CallbackFlow) => {
const entry = findHostedMcpEntry(HOSTED_MCP_ENTRIES, req.params.entryId);
if (!entry || !isOAuthEntry(entry)) {
Expand All @@ -502,6 +509,15 @@ const finishCallback = async (req: Request, res: Response, flow: CallbackFlow) =
if (vendorError) {
// The provider denied authorization before issuing a code. Leave the
// pending flow intact so the member can retry; never forward vendor text.
// A refusal of the app itself, not the member's choice, is told apart by
// a known description and gets its own fixed code (Airtable, 11-02).
const description = typeof req.query.error_description === 'string' ? req.query.error_description : '';
if (vendorError === 'access_denied' && VENDOR_APP_UNAVAILABLE.test(description)) {
// Our app's status at the vendor, not the member's choice: worth a line
// without anyone having to report it. The entry id only, never vendor text.
console.warn('[hosted-mcp] vendor refused the app for this account', JSON.stringify({ entryId: entry.id }));
return callbackRedirect(res, 'error', 'vendor_app_unavailable', { entryId: entry.id });
}
return callbackRedirect(res, 'error', vendorError === 'access_denied' ? 'consent_denied' : 'vendor_error');
}
if (!state || !code) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,8 @@
"hostedMcpAuthenticationRequired": "Sign in to Commonly, then try connecting again.",
"hostedMcpClientSetup": "This connector’s sign-in setup needs attention. Ask an administrator to check it.",
"hostedMcpConsentDenied": "You didn’t approve this connection. Nothing was saved.",
"hostedMcpVendorAppUnavailable": "The service refused Commonly’s app for your account, so nothing was saved. This is a setting on our side, not something you did.",
"hostedMcpVendorAppUnavailableNamed": "{{provider}} refused Commonly’s app for your account, so nothing was saved. This is a setting on our side with {{provider}}, not something you did.",
"hostedMcpExchangeIncomplete": "The provider returned an incomplete authorization. Nothing was saved.",
"hostedMcpExchangeRefused": "The provider refused this connection. Try again or check its permissions.",
"hostedMcpExchangeUnreachable": "Couldn’t reach the provider to finish connecting. Try again.",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,8 @@
"hostedMcpAuthenticationRequired": "请先登录 Commonly,再重新连接。",
"hostedMcpClientSetup": "此连接器的登录配置有问题,请联系管理员检查。",
"hostedMcpConsentDenied": "你没有同意此次授权。连接未保存。",
"hostedMcpVendorAppUnavailable": "该服务不允许你的账号使用 Commonly 的应用,连接未保存。这是我们这边的设置问题,与你的操作无关。",
"hostedMcpVendorAppUnavailableNamed": "{{provider}} 不允许你的账号使用 Commonly 的应用,连接未保存。这是我们在 {{provider}} 上的设置问题,与你的操作无关。",
"hostedMcpExchangeIncomplete": "提供方返回的授权信息不完整。连接未保存。",
"hostedMcpExchangeRefused": "提供方拒绝了连接。请重试或检查授权设置。",
"hostedMcpExchangeUnreachable": "无法联系提供方以完成连接,请重试。",
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,7 @@ describe('Connectors page: channels', () => {
['browser_mismatch', 'hostedMcpInvalidState'],
['issuer_mismatch', 'hostedMcpInvalidState'],
['consent_denied', 'hostedMcpConsentDenied'],
['vendor_app_unavailable', 'hostedMcpVendorAppUnavailable'],
['vendor_error', 'hostedMcpVendorError'],
['rate_limited', 'hostedMcpRateLimited'],
['issuer_unreachable', 'hostedMcpIssuerUnavailable'],
Expand Down Expand Up @@ -688,6 +689,33 @@ describe('Connectors page: channels', () => {
expect(screen.queryByText(/spoofed/)).toBeNull();
});

// Airtable refused the app for every account but one (11-02), and the page
// said "You didn't approve". The callback names the entry, and the page names
// the provider only when its own catalogue holds that entry.
it('names the provider when it refused the app for this account', async () => {
window.history.replaceState({}, '', '/v2/connectors?hostedMcp=error&code=vendor_app_unavailable&entryId=linear');
mockGets([], [{ ...hostedLinearCatalogEntry(), connections: [] }]);
renderPage();

const alert = await screen.findByRole('alert');
await waitFor(() => expect(alert).toHaveTextContent(
en.connectors.hostedMcpVendorAppUnavailableNamed.replace(/\{\{provider\}\}/g, 'Linear'),
));
expect(window.location.search).toBe('');
});

it('keeps the unnamed notice for an entry its catalogue does not hold', async () => {
window.history.replaceState({}, '', '/v2/connectors?hostedMcp=error&code=vendor_app_unavailable&entryId=Evil%20Corp');
mockGets([], [{ ...hostedLinearCatalogEntry(), connections: [] }]);
renderPage();

const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(en.connectors.hostedMcpVendorAppUnavailable);
await act(async () => { await new Promise((resolve) => { setTimeout(resolve, 0); }); });
expect(alert).toHaveTextContent(en.connectors.hostedMcpVendorAppUnavailable);
expect(document.body.textContent).not.toContain('Evil Corp');
});

it('reloads connectors after a successful Slack callback and clears its query', async () => {
window.history.replaceState({}, '', '/v2/connectors?slack=pending');
mockGets([]);
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/v2/components/hostedMcpErrorNotice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const noticeKeyByCode: Record<string, string> = {
browser_mismatch: 'hostedMcpInvalidState',
issuer_mismatch: 'hostedMcpInvalidState',
consent_denied: 'hostedMcpConsentDenied',
// The vendor refused the app for this account, whatever the member clicked.
vendor_app_unavailable: 'hostedMcpVendorAppUnavailable',
vendor_error: 'hostedMcpVendorError',
rate_limited: 'hostedMcpRateLimited',
issuer_unreachable: 'hostedMcpIssuerUnavailable',
Expand All @@ -31,6 +33,16 @@ const noticeKeyByCode: Record<string, string> = {
authentication_required: 'hostedMcpAuthenticationRequired',
};

export const hostedMcpErrorNotice = (t: TFunction, code: string | null | undefined): string => (
t(`connectors.${noticeKeyByCode[code || ''] || 'hostedMcpUnknownError'}`)
);
// Notices that can name the provider. The name comes from this instance's own
// catalogue entry, never from the callback URL.
const namedNoticeKeys = new Set(['hostedMcpVendorAppUnavailable']);

export const hostedMcpErrorNotice = (
t: TFunction,
code: string | null | undefined,
provider?: string,
): string => {
const key = noticeKeyByCode[code || ''] || 'hostedMcpUnknownError';
if (provider && namedNoticeKeys.has(key)) return t(`connectors.${key}Named`, { provider });
return t(`connectors.${key}`);
};
17 changes: 16 additions & 1 deletion frontend/src/v2/connectors/useChannelConnectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export const useChannelConnectors = () => {
// what the callback URL claimed; `hostedMcpCallbackRevoke` is set only once
// the catalogue confirms it.
const [pendingCallbackRevoke, setPendingCallbackRevoke] = useState<{ entryId: string; revokeAt: string } | null>(null);
// A callback notice that may name its provider once the catalogue confirms the entry.
const [pendingCallbackProvider, setPendingCallbackProvider] = useState<{ entryId: string; code: string } | null>(null);
const [hostedMcpCallbackRevoke, setHostedMcpCallbackRevoke] = useState<{
entryId: string; provider: string; revokePage: string;
} | null>(null);
Expand Down Expand Up @@ -143,10 +145,12 @@ export const useChannelConnectors = () => {
useEffect(() => {
const query = new URLSearchParams(window.location.search);
if (query.get('hostedMcp') !== 'error') return;
setHostedMcpCallbackError(hostedMcpErrorNotice(t, query.get('code')));
const code = query.get('code');
setHostedMcpCallbackError(hostedMcpErrorNotice(t, code));
const entryId = query.get('entryId');
const revokeAt = query.get('revokeAt');
if (entryId && revokeAt) setPendingCallbackRevoke({ entryId, revokeAt });
if (entryId && code === 'vendor_app_unavailable') setPendingCallbackProvider({ entryId, code });
query.delete('hostedMcp');
query.delete('code');
query.delete('entryId');
Expand Down Expand Up @@ -185,6 +189,17 @@ export const useChannelConnectors = () => {
);
}, [load, t]);

// The provider is named only from the catalogue's own entry, so a forged
// entryId leaves the unnamed notice as it is.
useEffect(() => {
if (!pendingCallbackProvider || loading || hostedCatalog === null) return;
const entry = hostedCatalog.find((candidate) => candidate.entryId === pendingCallbackProvider.entryId);
if (entry?.label) {
setHostedMcpCallbackError(hostedMcpErrorNotice(t, pendingCallbackProvider.code, entry.label));
}
setPendingCallbackProvider(null);
}, [hostedCatalog, loading, pendingCallbackProvider, t]);

// A refused connect has no row to read, so the catalogue entry is the
// authority: the page is linked only when it is the one the entry names.
useEffect(() => {
Expand Down
Loading