From 187692acb8298a9b36ef1934b4e0c95bd32774ee Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 10:13:45 +0200 Subject: [PATCH 1/6] Rate limits, actionable errors, adapter fixes, onboarding nudges - REST engine: a 429 (or a 503 with Retry-After) gets at most one more attempt, after the API's Retry-After when it is 3 s or less, after ~1 s when there is none, and none when it asks for longer. Retrying 429s three times multiplied load on APIs that were already limiting us. - Errors for a missing variable or a never-authorized OAuth connector link to the connector's page; a host that does not resolve is reported as 'Host not found' instead of 'SSRF guard: cannot resolve'. - Error hints for Telegram (wrong bot token, unreachable chat) and Lexware (overdue cannot be combined with other statuses); Lexware list_vouchers marks voucherType and voucherStatus required; api-football healthPath typo fixed. - Onboarding drip: reminders depend on the workspace having no connector (a teammate's counts), not on the Skip flag of /welcome. A user who connected Claude/ChatGPT to an empty workspace gets the first nudge 2 h later, naming the client. New server-only event ai_client_connected. --- .../src/adapters/de/lexware-office.json | 10 +- .../src/adapters/intl/api-football.json | 2 +- .../src/audit/product-event.service.ts | 12 ++- packages/backend/src/common/ssrf.util.spec.ts | 13 +++ packages/backend/src/common/ssrf.util.ts | 15 ++- .../unresolved-placeholders.util.spec.ts | 10 ++ .../common/unresolved-placeholders.util.ts | 4 +- packages/backend/src/common/url.util.spec.ts | 22 +++- packages/backend/src/common/url.util.ts | 12 +++ .../src/connectors/connectors.service.ts | 4 +- .../engines/oauth2-token.service.ts | 4 +- .../connectors/engines/rest.engine.spec.ts | 62 ++++++++++- .../src/connectors/engines/rest.engine.ts | 52 ++++++++- .../ee/cloud/onboarding-cron.service.spec.ts | 95 +++++++++++++++++ .../src/ee/cloud/onboarding-cron.service.ts | 100 +++++++++++++++--- .../src/mcp-server/dynamic-mcp-tools.ts | 2 + .../src/mcp-server/error-hints.spec.ts | 30 ++++++ .../backend/src/mcp-server/error-hints.ts | 31 ++++++ .../mcp-connection-grant.service.spec.ts | 33 ++++++ .../mcp-connection-grant.service.ts | 45 ++++++-- .../backend/src/settings/email.service.ts | 48 ++++++--- 21 files changed, 555 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/adapters/de/lexware-office.json b/packages/backend/src/adapters/de/lexware-office.json index 62e92645..73f5f6cd 100644 --- a/packages/backend/src/adapters/de/lexware-office.json +++ b/packages/backend/src/adapters/de/lexware-office.json @@ -109,11 +109,11 @@ "properties": { "voucherType": { "type": "string", - "description": "Comma-separated types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote, invoice, creditnote, orderconfirmation, quotation, deliverynote, downpaymentinvoice." + "description": "Required by Lexware. Comma-separated types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote, invoice, creditnote, orderconfirmation, quotation, deliverynote, downpaymentinvoice, or any." }, "voucherStatus": { "type": "string", - "description": "Comma-separated statuses: draft, open, paid, paidoff, voided, transferred, sepadebit, overdue, accepted, rejected." + "description": "Required by Lexware. Comma-separated statuses: draft, open, paid, paidoff, voided, transferred, sepadebit, overdue, accepted, rejected, or any. overdue cannot be combined with other statuses: ask for it on its own (Lexware answers 400 otherwise)." }, "archived": { "type": "boolean", @@ -139,7 +139,11 @@ "type": "number", "description": "Page size, 1–250 (default 25)." } - } + }, + "required": [ + "voucherType", + "voucherStatus" + ] }, "endpointMapping": { "method": "GET", diff --git a/packages/backend/src/adapters/intl/api-football.json b/packages/backend/src/adapters/intl/api-football.json index 2fcac4f4..be9124db 100644 --- a/packages/backend/src/adapters/intl/api-football.json +++ b/packages/backend/src/adapters/intl/api-football.json @@ -15,7 +15,7 @@ "name": "API-Football v3", "type": "REST", "baseUrl": "https://v3.football.api-sports.io", - "healthPath": "/status", + "healthcheckPath": "/status", "authType": "API_KEY", "authConfig": { "headerName": "x-apisports-key", diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index 0c628b95..b35b34bb 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -41,6 +41,13 @@ export const ProductEvents = { * brings sign-ups, verified sign-ups and paying customers. */ SIGNUP_ATTRIBUTED: 'signup_attributed', + /** + * A user connected an AI client (Claude, ChatGPT…) through the OAuth flow + * for the first time; metadata.client = the client's name. Server-only. + * Answers: how many sign-ups reach the client, and how many of those then + * add a connector (read against the connectors table). + */ + AI_CLIENT_CONNECTED: 'ai_client_connected', } as const; export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents]; @@ -49,7 +56,10 @@ export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents * Events only the server writes. A signed-in user could otherwise post a * `signup_attributed` of their own and skew the channel report. */ -const SERVER_ONLY = new Set([ProductEvents.SIGNUP_ATTRIBUTED]); +const SERVER_ONLY = new Set([ + ProductEvents.SIGNUP_ATTRIBUTED, + ProductEvents.AI_CLIENT_CONNECTED, +]); const CLIENT_REPORTABLE = new Set( Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)), ); diff --git a/packages/backend/src/common/ssrf.util.spec.ts b/packages/backend/src/common/ssrf.util.spec.ts index 63e7bd1b..de7aef29 100644 --- a/packages/backend/src/common/ssrf.util.spec.ts +++ b/packages/backend/src/common/ssrf.util.spec.ts @@ -14,6 +14,10 @@ describe('extractSsrfBlockedHostname', () => { "SSRF guard: cannot resolve 'other-mcp-server': getaddrinfo ENOTFOUND other-mcp-server", 'other-mcp-server', ], + [ + "Host not found: 'nina.api.proxy.bund.dev' could not be resolved (ENOTFOUND). Check the address in the connector settings.", + 'nina.api.proxy.bund.dev', + ], ])('extracts the host from %s', (message, expected) => { expect(extractSsrfBlockedHostname(message)).toBe(expected); }); @@ -28,3 +32,12 @@ describe('extractSsrfBlockedHostname', () => { expect(extractSsrfBlockedHostname(message)).toBeUndefined(); }); }); + +describe('assertSafeOutboundHost on a name that does not resolve', () => { + it('says the host was not found instead of reporting a policy block', async () => { + const { assertSafeOutboundHost } = await import('./ssrf.util'); + await expect( + assertSafeOutboundHost('no-such-host.invalid', { SSRF_GUARD: 'enabled' } as NodeJS.ProcessEnv), + ).rejects.toThrow(/^Host not found: 'no-such-host\.invalid' could not be resolved \(ENOTFOUND\)/); + }); +}); diff --git a/packages/backend/src/common/ssrf.util.ts b/packages/backend/src/common/ssrf.util.ts index 1e51e289..f8b25c22 100644 --- a/packages/backend/src/common/ssrf.util.ts +++ b/packages/backend/src/common/ssrf.util.ts @@ -243,8 +243,15 @@ async function vetHost( try { resolved = await dns.lookup(hostname, { all: true }); } catch (e: any) { + // Not a policy decision: the name simply has no address (a typo, a + // retired API, a DNS hiccup). Worded as such, because "SSRF guard" made + // users and the model read a security block into a wrong host name. + const temporary = e?.code === 'EAI_AGAIN'; throw new SsrfBlockedError( - `SSRF guard: cannot resolve '${hostname}': ${e?.message || e}`, + `Host not found: '${hostname}' could not be resolved (${e?.code || e?.message || e}). ` + + (temporary + ? 'This is usually a temporary DNS failure; try again.' + : 'Check the address in the connector settings.'), ); } @@ -386,9 +393,9 @@ export function createSsrfGuardedAgents(env: NodeJS.ProcessEnv = process.env): { export function extractSsrfBlockedHostname( message: string, ): string | undefined { - const match = /SSRF guard:\s*(?:address|hostname|cannot resolve)\s*'([^']+)'/.exec( - message || '', - ); + const match = + /SSRF guard:\s*(?:address|hostname|cannot resolve)\s*'([^']+)'/.exec(message || '') ?? + /Host not found:\s*'([^']+)'/.exec(message || ''); return match?.[1]; } diff --git a/packages/backend/src/common/unresolved-placeholders.util.spec.ts b/packages/backend/src/common/unresolved-placeholders.util.spec.ts index 1f6697fc..dc57bc53 100644 --- a/packages/backend/src/common/unresolved-placeholders.util.spec.ts +++ b/packages/backend/src/common/unresolved-placeholders.util.spec.ts @@ -69,6 +69,16 @@ describe('assertNoUnresolvedPlaceholders', () => { ).toThrow(/The "Acme" connector is missing a value for X/); }); + it('links to the connector page when given one', () => { + expect(() => + assertNoUnresolvedPlaceholders( + { authConfig: { token: '{{X}}' } }, + 'the "Acme" connector', + 'https://cloud.example.com/connectors/c1', + ), + ).toThrow(/Open the connector \(https:\/\/cloud\.example\.com\/connectors\/c1\) and set that variable/); + }); + it('falls back to a generic subject', () => { expect(() => assertNoUnresolvedPlaceholders({ authConfig: { token: '{{X}}' } }), diff --git a/packages/backend/src/common/unresolved-placeholders.util.ts b/packages/backend/src/common/unresolved-placeholders.util.ts index 5cd76dfd..2fc6d1a3 100644 --- a/packages/backend/src/common/unresolved-placeholders.util.ts +++ b/packages/backend/src/common/unresolved-placeholders.util.ts @@ -62,6 +62,8 @@ export function assertNoUnresolvedPlaceholders( request: RequestShape, /** How to name the thing in the error, e.g. `the connector behind etsy_get_shop`. */ subject?: string, + /** The connector's page in the dashboard, so the reader can go straight there. */ + fixUrl?: string, ): void { const missing = findUnresolvedPlaceholders({ baseUrl: request.baseUrl, @@ -78,7 +80,7 @@ export function assertNoUnresolvedPlaceholders( `${which} is missing ${missing.length === 1 ? 'a value' : 'values'} for ${names}. ` + 'The request was not sent, because it would have carried the placeholder text ' + 'instead of the credential and the upstream API would have rejected it with a ' + - 'misleading error. Open the connector and set ' + + `misleading error. Open the connector${fixUrl ? ` (${fixUrl})` : ''} and set ` + `${missing.length === 1 ? 'that variable' : 'those variables'}, then try again.`, ); } diff --git a/packages/backend/src/common/url.util.spec.ts b/packages/backend/src/common/url.util.spec.ts index d729e2e6..d1e1a70f 100644 --- a/packages/backend/src/common/url.util.spec.ts +++ b/packages/backend/src/common/url.util.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException } from '@nestjs/common'; -import { normalizeConnectorBaseUrl, resolveMcpEndpointUrl } from './url.util'; +import { connectorPageUrl, normalizeConnectorBaseUrl, resolveMcpEndpointUrl } from './url.util'; describe('normalizeConnectorBaseUrl', () => { it('keeps a well-formed https URL untouched', () => { @@ -155,3 +155,23 @@ describe('resolveMcpEndpointUrl credential handling', () => { ).toBe('http://mcp.example.com:8931/tenant/a'); }); }); + +describe('connectorPageUrl', () => { + const saved = process.env.FRONTEND_URL; + afterEach(() => { + if (saved === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = saved; + }); + + it('builds the dashboard link from FRONTEND_URL', () => { + process.env.FRONTEND_URL = 'https://cloud.example.com/'; + expect(connectorPageUrl('c1')).toBe('https://cloud.example.com/connectors/c1'); + }); + + it('gives nothing without a usable FRONTEND_URL or id', () => { + delete process.env.FRONTEND_URL; + expect(connectorPageUrl('c1')).toBeUndefined(); + process.env.FRONTEND_URL = 'https://cloud.example.com'; + expect(connectorPageUrl(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/common/url.util.ts b/packages/backend/src/common/url.util.ts index 0a6fb14d..2c57673b 100644 --- a/packages/backend/src/common/url.util.ts +++ b/packages/backend/src/common/url.util.ts @@ -144,3 +144,15 @@ function withPath(base: URL, pathname: string, search: string): URL { url.hash = ''; return url; } + +/** + * Public dashboard address of a connector's page, for messages a person reads + * in a chat client ("open the connector and set X"). FRONTEND_URL is where the + * dashboard is served; undefined when it is not configured, so callers can + * fall back to wording without a link. + */ +export function connectorPageUrl(connectorId: string | undefined | null): string | undefined { + const base = (process.env.FRONTEND_URL || '').trim().replace(/\/+$/, ''); + if (!connectorId || !/^https?:\/\//i.test(base)) return undefined; + return `${base}/connectors/${encodeURIComponent(connectorId)}`; +} diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index c0107756..d92c342c 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -18,7 +18,7 @@ import { CALLER_CONTEXT_PREFIX } from '../common/caller-context.util'; import { assertNoUnresolvedPlaceholders } from '../common/unresolved-placeholders.util'; import { assertAbsoluteBaseUrl } from '../common/base-url-variable.util'; import { extractSsrfBlockedHostname } from '../common/ssrf.util'; -import { normalizeConnectorBaseUrl } from '../common/url.util'; +import { connectorPageUrl, normalizeConnectorBaseUrl } from '../common/url.util'; import { resolveAdapterIcon } from './connector-icon.util'; import { applySchemaDefaults } from '../common/schema-defaults.util'; import { renderStaticResponse } from './static-response.util'; @@ -251,6 +251,7 @@ export class ConnectorsService { assertNoUnresolvedPlaceholders( { baseUrl, headers, authConfig }, `the "${connector.name}" connector`, + connectorPageUrl(connector.id), ); assertAbsoluteBaseUrl( { @@ -502,6 +503,7 @@ export class ConnectorsService { authConfig, }, toolName ? `the connector behind ${toolName}` : `the "${connector.name}" connector`, + connectorPageUrl(connector.id), ); assertAbsoluteBaseUrl( { diff --git a/packages/backend/src/connectors/engines/oauth2-token.service.ts b/packages/backend/src/connectors/engines/oauth2-token.service.ts index ed169524..2a0129f2 100644 --- a/packages/backend/src/connectors/engines/oauth2-token.service.ts +++ b/packages/backend/src/connectors/engines/oauth2-token.service.ts @@ -12,6 +12,7 @@ import { isPrivateKeyJwt, } from './client-assertion.util'; import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; +import { connectorPageUrl } from '../../common/url.util'; /** Refresh tokens that expire within this window (5 minutes). */ const PROACTIVE_REFRESH_BUFFER_MS = 5 * 60 * 1000; @@ -147,9 +148,10 @@ export class OAuth2TokenService { ); } if (!authConfig.refreshToken && authConfig.authorizationUrl) { + const page = connectorPageUrl(connectorId); throw unauthorized( 'OAuth2: this connector has not been authorized yet. No request was sent to the API. ' + - 'Open the connector in AnythingMCP and click Authorize with Provider.', + `Open the connector in AnythingMCP${page ? ` (${page})` : ''} and click Authorize with Provider.`, ); } } diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index bdcdef30..e756b61f 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -1,4 +1,4 @@ -import { RestEngine, serializeRepeatedParams } from './rest.engine'; +import { RestEngine, parseRetryAfterMs, serializeRepeatedParams } from './rest.engine'; import { OAuth2TokenService } from './oauth2-token.service'; import { LoginTokenService } from './login-token.service'; import axios, { AxiosError } from 'axios'; @@ -743,6 +743,49 @@ describe('RestEngine', () => { expect(mockedAxios).toHaveBeenCalledTimes(1); }); + // A rate limit is not an outage: at most one more attempt, and only when + // the API's Retry-After fits inside a tool call. + describe('rate limits', () => { + const limited = (status: number, retryAfter?: string) => + new AxiosError('limited', undefined, undefined, {}, { + status, + data: {}, + headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter }, + } as any); + const call = () => + engine.execute( + { baseUrl: 'https://api.example.com', authType: 'NONE' }, + { method: 'GET', path: '/' }, + {}, + ); + + it('tries a 429 without Retry-After only once more', async () => { + mockedAxios.mockRejectedValue(limited(429)); + await expect(call()).rejects.toBeInstanceOf(AxiosError); + expect(mockedAxios).toHaveBeenCalledTimes(2); + }); + + it('honours a short Retry-After and returns the success', async () => { + mockedAxios + .mockRejectedValueOnce(limited(429, '0')) + .mockResolvedValueOnce({ data: { ok: true } }); + await expect(call()).resolves.toEqual({ ok: true }); + expect(mockedAxios).toHaveBeenCalledTimes(2); + }); + + it('does not retry when Retry-After is longer than a tool call can wait', async () => { + mockedAxios.mockRejectedValue(limited(429, '60')); + await expect(call()).rejects.toBeInstanceOf(AxiosError); + expect(mockedAxios).toHaveBeenCalledTimes(1); + }); + + it('treats a 503 with a long Retry-After the same way', async () => { + mockedAxios.mockRejectedValue(limited(503, '120')); + await expect(call()).rejects.toBeInstanceOf(AxiosError); + expect(mockedAxios).toHaveBeenCalledTimes(1); + }); + }); + it('gives up after exhausting retries on persistent 503', async () => { mockedAxios.mockRejectedValue(err(503)); @@ -1460,3 +1503,20 @@ describe('RestEngine — bodyTemplate that will not parse', () => { expect(err.message).not.toMatch(/super-secret/); }); }); + +describe('parseRetryAfterMs', () => { + it('reads delay-seconds', () => { + expect(parseRetryAfterMs('2')).toBe(2000); + expect(parseRetryAfterMs(['5'])).toBe(5000); + }); + it('reads an HTTP date relative to now', () => { + const now = Date.parse('Sat, 03 Oct 2026 10:00:00 GMT'); + expect(parseRetryAfterMs('Sat, 03 Oct 2026 10:00:02 GMT', now)).toBe(2000); + expect(parseRetryAfterMs('Sat, 03 Oct 2026 09:00:00 GMT', now)).toBe(0); + }); + it('returns null for missing or unreadable values', () => { + expect(parseRetryAfterMs(undefined)).toBeNull(); + expect(parseRetryAfterMs('')).toBeNull(); + expect(parseRetryAfterMs('soon')).toBeNull(); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index 57412fea..486bf05c 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -26,6 +26,23 @@ import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; * Supports OAuth2 token refresh: if a request returns 401 and a refreshToken + tokenUrl * are available, it will attempt to refresh the access token and retry the request once. */ +/** Longest Retry-After we honour inside a tool call; beyond it we give up. */ +const MAX_RETRY_AFTER_MS = 3000; + +/** + * Retry-After as milliseconds: delay-seconds or an HTTP date (RFC 9110 + * 10.2.3). Null when absent or unreadable. + */ +export function parseRetryAfterMs(value: unknown, now = Date.now()): number | null { + if (value === undefined || value === null) return null; + const text = String(Array.isArray(value) ? value[0] : value).trim(); + if (!text) return null; + if (/^\d+$/.test(text)) return Number(text) * 1000; + const at = Date.parse(text); + if (Number.isNaN(at)) return null; + return Math.max(0, at - now); +} + @Injectable() export class RestEngine { private readonly logger = new Logger(RestEngine.name); @@ -388,6 +405,34 @@ export class RestEngine { ].includes(error.code ?? ''); } + /** + * How long to wait before the next attempt, or null to give up. + * + * A rate limit is not an outage: retrying a 429 three times in 3.7 s sent + * four requests to an API that had just asked for fewer, and a customer + * whose backend hit its own Supabase limit saw ~54k rejections become ~216k + * requests in two days. So a 429, and a 503 that says when to come back, + * get at most one more attempt: after the API's Retry-After when it is + * short, after about a second when it gives none, and none at all when it + * asks for longer than we can wait inside a tool call. The model still sees + * the status and the Retry-After header in the error. + */ + private retryDelayMs( + error: unknown, + attempt: number, + delaysMs: number[], + ): number | null { + const response = (error as AxiosError).response; + const status = response?.status; + const retryAfter = parseRetryAfterMs(response?.headers?.['retry-after']); + if (status === 429 || (status === 503 && retryAfter !== null)) { + if (attempt > 0) return null; + if (retryAfter === null) return 1000 + Math.floor(Math.random() * 250); + return retryAfter <= MAX_RETRY_AFTER_MS ? retryAfter : null; + } + return attempt < delaysMs.length ? delaysMs[attempt] : null; + } + /** * Human-readable replacements for connection-level failures. Without these * the caller — and the model reading the tool result — gets the raw OpenSSL @@ -441,7 +486,8 @@ export class RestEngine { return await axios(axiosConfig); } catch (error) { const transient = this.isTransientError(error); - if (attempt >= delaysMs.length || !transient) { + const delay = transient ? this.retryDelayMs(error, attempt, delaysMs) : null; + if (delay === null) { // Warn, not debug: production runs at info, so a call that burned // every retry used to look identical to one that failed outright — // there was no way to tell a flaky upstream from a broken connector. @@ -456,9 +502,9 @@ export class RestEngine { throw this.describeConnectionError(error, attempt + 1); } this.logger.debug( - `Transient error (attempt ${attempt + 1}), retrying in ${delaysMs[attempt]}ms`, + `Transient error (attempt ${attempt + 1}), retrying in ${delay}ms`, ); - await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt])); + await new Promise((resolve) => setTimeout(resolve, delay)); } } } diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts index c3d27b18..b7771753 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.spec.ts @@ -202,3 +202,98 @@ describe('OnboardingCronService — trial repair', () => { expect(out.licensesDeactivated).toBe(1); }); }); + +describe('OnboardingCronService — onboarding pass', () => { + const HOUR = 60 * 60 * 1000; + function makeService(opts: { + candidates: any[]; + orgConnectors?: Record; + grants?: { userId: string; clientId: string }[]; + }) { + const update = jest.fn().mockResolvedValue({}); + const prisma = { + user: { + findMany: jest + .fn() + .mockResolvedValueOnce(opts.candidates) + .mockResolvedValueOnce([]), + update, + }, + connector: { + groupBy: jest.fn().mockResolvedValue( + Object.entries(opts.orgConnectors ?? {}).map(([organizationId, n]) => ({ + organizationId, + _count: { _all: n }, + })), + ), + }, + mcpConnectionGrant: { findMany: jest.fn().mockResolvedValue(opts.grants ?? []) }, + oAuthClient: { + findMany: jest.fn().mockResolvedValue([{ clientId: 'claude-client', clientName: 'Claude' }]), + }, + license: { + findMany: jest.fn().mockResolvedValue([]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + } as any; + const email = { + sendOnboardingReminderEmail: jest.fn().mockResolvedValue(true), + sendActivationReminderEmail: jest.fn().mockResolvedValue(true), + } as any; + return { service: new OnboardingCronService(prisma, email, makeLicense()), email, update, prisma }; + } + const user = (over: Partial) => ({ + id: 'u1', + email: 'u1@example.com', + name: 'Ada', + organizationId: 'org-1', + onboardingCompletedAt: null, + onboardingReminderCount: 0, + onboardingLastReminderAt: null, + _count: { connectors: 0 }, + createdAt: new Date(Date.now() - 3 * HOUR), + ...over, + }); + + it('nudges a user who connected Claude to an empty workspace, naming the client', async () => { + const { service, email, update } = makeService({ + candidates: [user({})], + grants: [{ userId: 'u1', clientId: 'claude-client' }], + }); + await service.run(); + expect(email.sendOnboardingReminderEmail).toHaveBeenCalledWith('u1@example.com', 'Ada', 1, { + aiClient: 'Claude', + }); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ onboardingReminderCount: 1 }) }), + ); + }); + + it('waits the usual 24h for a user with no AI client yet', async () => { + const { service, email } = makeService({ candidates: [user({})] }); + await service.run(); + expect(email.sendOnboardingReminderEmail).not.toHaveBeenCalled(); + }); + + it('still reminds a user who pressed Skip on /welcome with an empty workspace', async () => { + const { service, email } = makeService({ + candidates: [ + user({ onboardingCompletedAt: new Date(), createdAt: new Date(Date.now() - 30 * HOUR) }), + ], + }); + await service.run(); + expect(email.sendOnboardingReminderEmail).toHaveBeenCalledWith('u1@example.com', 'Ada', 1, undefined); + }); + + it('counts a teammate\'s connector: no reminder, completion stamped', async () => { + const { service, email, update } = makeService({ + candidates: [user({ createdAt: new Date(Date.now() - 30 * HOUR) })], + orgConnectors: { 'org-1': 1 }, + }); + await service.run(); + expect(email.sendOnboardingReminderEmail).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ data: { onboardingCompletedAt: expect.any(Date) } }), + ); + }); +}); diff --git a/packages/backend/src/ee/cloud/onboarding-cron.service.ts b/packages/backend/src/ee/cloud/onboarding-cron.service.ts index e8d5390c..59c68b37 100644 --- a/packages/backend/src/ee/cloud/onboarding-cron.service.ts +++ b/packages/backend/src/ee/cloud/onboarding-cron.service.ts @@ -4,6 +4,9 @@ import { EmailService } from '../../settings/email.service'; import { LicenseService } from '../../license/license.service'; const HOURS = (n: number) => n * 60 * 60 * 1000; + +/** How long after connecting an AI client to an empty workspace we nudge. */ +const AI_CLIENT_NUDGE_AFTER = HOURS(2); const DAYS = (n: number) => n * 24 * 60 * 60 * 1000; /** @@ -67,18 +70,23 @@ export class OnboardingCronService { skipped: 0, }; - // Candidate set: verified, no completion, ≤2 reminders, not opted out, - // and registered between 24h and 14d ago. We bound at 14d so a user + // Candidate set: verified, ≤2 reminders, not opted out, registered + // between AI_CLIENT_NUDGE_AFTER and 14d ago. We bound at 14d so a user // who signed up months ago doesn't suddenly get woken up if we ever // backfill columns. + // + // onboardingCompletedAt is NOT a filter any more: the Skip button on + // /welcome sets it, and people who skipped the page with an empty + // workspace stopped getting the reminders that were meant for exactly + // them (13 of the 428 sign-ups of 1-3 Oct 2026). What counts is whether + // the workspace has a connector, checked below. const candidates = await this.prisma.user.findMany({ where: { emailVerified: true, emailMarketingOptOut: false, - onboardingCompletedAt: null, onboardingReminderCount: { lt: 2 }, createdAt: { - lte: new Date(now - HOURS(24)), + lte: new Date(now - AI_CLIENT_NUDGE_AFTER), gte: new Date(now - HOURS(24 * 14)), }, }, @@ -87,25 +95,56 @@ export class OnboardingCronService { email: true, name: true, createdAt: true, + organizationId: true, + onboardingCompletedAt: true, onboardingReminderCount: true, onboardingLastReminderAt: true, _count: { select: { connectors: true } }, }, }); + // Connectors per workspace: a teammate's connector serves this user too. + const orgIds = [ + ...new Set(candidates.map((u) => u.organizationId).filter((id): id is string => !!id)), + ]; + const orgConnectors = new Map(); + if (orgIds.length > 0) { + const rows = await this.prisma.connector.groupBy({ + by: ['organizationId'], + where: { organizationId: { in: orgIds } }, + _count: { _all: true }, + }); + for (const r of rows) { + if (r.organizationId) orgConnectors.set(r.organizationId, r._count._all); + } + } + + // Who already connected an AI client (Claude, ChatGPT…) and how long ago. + // These are the warmest leads of all: the client is waiting on a + // workspace with nothing in it. + const aiClients = await this.connectedAiClients( + candidates.map((u) => u.id), + now, + ); + for (const u of candidates) { out.examined++; - // Race-safe: a user that created a connector between candidate - // pull and now should never receive a nudge. - if (u._count.connectors > 0) { + // Race-safe: a workspace that got a connector between candidate pull + // and now should never receive a nudge. + const connectors = + (u.organizationId ? orgConnectors.get(u.organizationId) : undefined) ?? + u._count.connectors; + if (connectors > 0) { // Auto-stamp completion so we never see them again. - await this.prisma.user - .update({ - where: { id: u.id }, - data: { onboardingCompletedAt: new Date() }, - }) - .catch(() => {}); + if (!u.onboardingCompletedAt) { + await this.prisma.user + .update({ + where: { id: u.id }, + data: { onboardingCompletedAt: new Date() }, + }) + .catch(() => {}); + } out.skipped++; continue; } @@ -115,12 +154,16 @@ export class OnboardingCronService { ? now - u.onboardingLastReminderAt.getTime() : Infinity; - // First nudge: 24-72h after signup, count == 0. - if (u.onboardingReminderCount === 0 && age >= HOURS(24)) { + // First nudge: 24h after signup, or as soon as an AI client has been + // connected for AI_CLIENT_NUDGE_AFTER, whichever comes first. The + // second one names the client, because that is what the user just did. + const aiClient = aiClients.get(u.id); + if (u.onboardingReminderCount === 0 && (age >= HOURS(24) || aiClient)) { const ok = await this.email.sendOnboardingReminderEmail( u.email, u.name || 'there', 1, + aiClient ? { aiClient } : undefined, ); if (ok) { await this.prisma.user.update({ @@ -310,6 +353,33 @@ export class OnboardingCronService { * `firstSuccessfulInvocationAt: null` = never activated; `activationReminderAt` * caps it at one send. */ + /** + * Users among `userIds` with a live AI-client connection made at least + * AI_CLIENT_NUDGE_AFTER ago, mapped to the client's display name. + */ + private async connectedAiClients(userIds: string[], now: number): Promise> { + const out = new Map(); + if (userIds.length === 0) return out; + const grants = await this.prisma.mcpConnectionGrant.findMany({ + where: { + userId: { in: userIds }, + revokedAt: null, + createdAt: { lte: new Date(now - AI_CLIENT_NUDGE_AFTER) }, + }, + select: { userId: true, clientId: true }, + }); + if (grants.length === 0) return out; + const clients = await this.prisma.oAuthClient.findMany({ + where: { clientId: { in: [...new Set(grants.map((g) => g.clientId))] } }, + select: { clientId: true, clientName: true }, + }); + const names = new Map(clients.map((c) => [c.clientId, c.clientName])); + for (const g of grants) { + if (!out.has(g.userId)) out.set(g.userId, names.get(g.clientId) || 'your AI client'); + } + return out; + } + private async runActivationPass( now: number, out: { diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index 611f1ca4..6cffc921 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -35,6 +35,7 @@ import { processGauges } from '../common/process-vitals'; import { applySchemaDefaults } from '../common/schema-defaults.util'; import { renderStaticResponse } from '../connectors/static-response.util'; import { ODataEngine, isODataBuiltinMethod } from '../connectors/engines/odata.engine'; +import { connectorPageUrl } from '../common/url.util'; /** * ToolExecutor — executes dynamically registered MCP tools. @@ -351,6 +352,7 @@ export class DynamicMcpTools { authConfig, }, `the connector behind ${tool.name}`, + connectorPageUrl(tool.connectorId), ); // A base URL without https:// (a variable typed as `shop.example.com` // on an older install) would otherwise fail in the SSRF guard as diff --git a/packages/backend/src/mcp-server/error-hints.spec.ts b/packages/backend/src/mcp-server/error-hints.spec.ts index 0127a720..580b44ba 100644 --- a/packages/backend/src/mcp-server/error-hints.spec.ts +++ b/packages/backend/src/mcp-server/error-hints.spec.ts @@ -127,4 +127,34 @@ describe('deriveErrorHint — SQL-backed customer APIs', () => { it('still says nothing about an error it does not recognise', () => { expect(deriveErrorHint({ status: 500, body: { message: 'boom' } })).toBeUndefined(); }); + + describe('Telegram', () => { + const host = 'api.telegram.org'; + it('points at the bot token on a 404 Not Found', () => { + expect( + deriveErrorHint({ host, status: 404, body: { ok: false, error_code: 404, description: 'Not Found' } }), + ).toMatch(/TELEGRAM_BOT_TOKEN/); + }); + it('explains how a bot reaches a chat', () => { + expect( + deriveErrorHint({ host, status: 400, body: { description: 'Bad Request: chat not found' } }), + ).toMatch(/pressed Start/); + expect( + deriveErrorHint({ host, status: 403, body: { description: "Forbidden: bot can't initiate conversation with a user" } }), + ).toMatch(/pressed Start/); + }); + it('stays quiet for another host answering Not Found', () => { + expect(deriveErrorHint({ host: 'api.example.com', status: 404, body: '"Not Found"' })).toBeUndefined(); + }); + }); + + it('tells the model to split Lexware overdue from other statuses', () => { + expect( + deriveErrorHint({ + host: 'api.lexware.io', + status: 400, + body: { message: "voucherStatus filter 'overdue' cannot be used in combination with other states" }, + }), + ).toMatch(/one call with voucherStatus=overdue/); + }); }); diff --git a/packages/backend/src/mcp-server/error-hints.ts b/packages/backend/src/mcp-server/error-hints.ts index 154fdb1b..da61a8b1 100644 --- a/packages/backend/src/mcp-server/error-hints.ts +++ b/packages/backend/src/mcp-server/error-hints.ts @@ -87,6 +87,24 @@ const TYPESAFE_INVALID_REQUEST_HINT = 'state, model and questions go in the request: there is no field for a list ' + 'of records, send one call per record instead.'; +const TELEGRAM_BAD_TOKEN_HINT = + 'Telegram answers 404 "Not Found" (or 401) for every method when the bot token ' + + 'in the URL is wrong, so this is the TELEGRAM_BOT_TOKEN, not the method. Tell ' + + 'the user to copy the token again from @BotFather (format 123456789:AA...) into ' + + 'the connector settings. Retrying or calling another method will fail the same way.'; + +const TELEGRAM_CHAT_HINT = + 'The bot cannot reach that chat. A bot may only write to a user who has opened ' + + 'it and pressed Start, or to a group/channel it was added to (channels: as an ' + + 'administrator). Use the numeric chat id from telegram_bot_get_updates after ' + + 'the user has sent the bot a message; @usernames only work for public channels. ' + + 'Ask the user to do that instead of trying other ids.'; + +const LEXWARE_OVERDUE_HINT = + 'Lexware does not accept `overdue` together with other statuses in ' + + '`voucherStatus`. Make one call with voucherStatus=overdue and a separate one ' + + 'for the other statuses.'; + function bodyText(body: unknown): string { if (body === undefined || body === null) return ''; if (typeof body === 'string') return body; @@ -142,6 +160,19 @@ export function deriveErrorHint(input: ErrorHintInput): string | undefined { } } + if (hostMatches(input.host, 'api.telegram.org')) { + if (input.status === 401 || (input.status === 404 && /"Not Found"/.test(text))) { + return TELEGRAM_BAD_TOKEN_HINT; + } + if (/chat not found|bot is not a member|can't initiate conversation|need administrator rights|bot was blocked by the user/i.test(text)) { + return TELEGRAM_CHAT_HINT; + } + } + + if (/voucherStatus filter 'overdue' cannot be used in combination/i.test(text)) { + return LEXWARE_OVERDUE_HINT; + } + // TypeSafe answers a question with an unknown type (and any other field it // does not expect) with a bare "Invalid request.", which names nothing the // model could fix. diff --git a/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts b/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts index 71996b13..7572f611 100644 --- a/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts +++ b/packages/backend/src/mcp-servers/mcp-connection-grant.service.spec.ts @@ -346,3 +346,36 @@ describe('McpConnectionGrantService writing a grant', () => { expect(calls.upsert[0].update.revokedAt).toBeNull(); }); }); + +describe('McpConnectionGrantService reports the first connection of a client', () => { + function withEvents(existing: object | null) { + const { prisma } = build({ + grant: existing as any, + servers: [{ id: 'srv-1', organizationId: 'org-1' }], + memberCount: 1, + }); + prisma.oAuthClient = { + findUnique: jest.fn().mockResolvedValue({ clientName: 'Claude' }), + }; + const events = { log: jest.fn().mockResolvedValue(undefined) }; + const svc = new McpConnectionGrantService(prisma, events as any); + return { svc, events }; + } + + it('logs ai_client_connected with the client name on a new grant', async () => { + const { svc, events } = withEvents(null); + await svc.grantServers('client-1', 'user-1', ['srv-1']); + expect(events.log).toHaveBeenCalledWith({ + event: 'ai_client_connected', + userId: 'user-1', + organizationId: 'org-1', + metadata: { client: 'Claude' }, + }); + }); + + it('does not log again when the user changes an existing grant', async () => { + const { svc, events } = withEvents({ id: 'g1', organizationId: null, serverIds: ['srv-1'] }); + await svc.grantWholeOrganization('client-1', 'user-1', 'org-1'); + expect(events.log).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts b/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts index fdd1c747..4bf2347e 100644 --- a/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts +++ b/packages/backend/src/mcp-servers/mcp-connection-grant.service.ts @@ -1,5 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { PrismaService } from '../common/prisma.service'; +import { ProductEvents, ProductEventService } from '../audit/product-event.service'; /** * What a grant resolved to, for the caller that is about to build a tool list. @@ -42,7 +43,10 @@ export type ResolvedGrant = export class McpConnectionGrantService { private readonly logger = new Logger(McpConnectionGrantService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Optional() private readonly events?: ProductEventService, + ) {} /** * Resolve what `clientId` may see on behalf of `userId`, re-validating every @@ -93,7 +97,7 @@ export class McpConnectionGrantService { ); return false; } - await this.upsert(clientId, userId, { organizationId, serverIds: [] }); + await this.upsert(clientId, userId, { organizationId, serverIds: [] }, organizationId); return true; } @@ -118,10 +122,12 @@ export class McpConnectionGrantService { } if (valid.length === 0) return []; - await this.upsert(clientId, userId, { - organizationId: null, - serverIds: valid.map((s) => s.id), - }); + await this.upsert( + clientId, + userId, + { organizationId: null, serverIds: valid.map((s) => s.id) }, + valid[0].organizationId, + ); return valid.map((s) => s.id); } @@ -251,7 +257,13 @@ export class McpConnectionGrantService { clientId: string, userId: string, data: { organizationId: string | null; serverIds: string[] }, + /** Workspace the grant points into, for the product event. */ + eventOrganizationId?: string, ): Promise { + const existing = await this.prisma.mcpConnectionGrant.findUnique({ + where: { clientId_userId: { clientId, userId } }, + select: { id: true }, + }); await this.prisma.mcpConnectionGrant.upsert({ where: { clientId_userId: { clientId, userId } }, create: { clientId, userId, ...data }, @@ -259,5 +271,24 @@ export class McpConnectionGrantService { // client may reach, which is the opposite of having revoked it. update: { ...data, revokedAt: null }, }); + if (!existing) await this.reportConnected(clientId, userId, eventOrganizationId); + } + + /** First connection of this client for this user: the funnel step between sign-up and first call. */ + private async reportConnected( + clientId: string, + userId: string, + organizationId?: string, + ): Promise { + if (!this.events) return; + const client = await this.prisma.oAuthClient + .findUnique({ where: { clientId }, select: { clientName: true } }) + .catch(() => null); + await this.events.log({ + event: ProductEvents.AI_CLIENT_CONNECTED, + userId, + organizationId: organizationId ?? null, + metadata: { client: client?.clientName ?? 'unknown' }, + }); } } diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index c1f113bd..e9bd1be9 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -6,6 +6,16 @@ import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; +/** Escape text placed into an email's HTML (names and client names are user-chosen). */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + // Production always talks to anythingmcp.com. The licence site decides // which plan an installation runs; a URL taken from the environment would let // any self-hosted operator point verification at a server of their own and @@ -471,6 +481,8 @@ export class EmailService { to: string, name: string, dayNumber: 1 | 2, + /** Set when the user already connected an AI client to the empty workspace. */ + opts?: { aiClient?: string }, ): Promise { const transport = await this.createTransporter(); if (!transport) { @@ -483,20 +495,28 @@ export class EmailService { const cloudUrl = process.env.CLOUD_PUBLIC_URL || 'https://cloud.anythingmcp.com'; const welcomeUrl = `${cloudUrl}/welcome`; + const storeUrl = `${cloudUrl}/connectors/store`; const unsubUrl = `${cloudUrl}/settings/profile`; + const client = opts?.aiClient ? escapeHtml(opts.aiClient) : undefined; + const safeName = escapeHtml(name); - const subject = - dayNumber === 1 + const subject = client + ? `${opts!.aiClient} is connected. Now give it something to work with` + : dayNumber === 1 ? 'Connect your first tool in 60 seconds — AnythingMCP' : 'Still here? Pick a tool to try — AnythingMCP'; - const body = - dayNumber === 1 - ? `

Hi ${name},

-

You signed up for AnythingMCP yesterday but haven't connected anything yet. The fastest path to your first AI superpower is picking a ready-made connector from the marketplace — Sendcloud, Stripe, GitHub, Slack, Help Scout… 180+ are pre-wired.

+ const body = client + ? `

Hi ${safeName},

+

You connected ${client} to AnythingMCP, but your workspace has no connectors yet, so ${client} has nothing to reach.

+

Add the app you want it to work with: Etsy, Odoo, weclapp, Lexware, Telegram, Shopify and 260 more are ready to install. As soon as one is in, ask ${client} about it in the same chat.

+

Add your first connector →

` + : dayNumber === 1 + ? `

Hi ${safeName},

+

You signed up for AnythingMCP yesterday but haven't connected anything yet. The fastest path to your first AI superpower is picking a ready-made connector from the marketplace: Etsy, Odoo, weclapp, Lexware, Sendcloud, GitHub… 265 are pre-wired.

Open the welcome wizard →

Should take about a minute.

` - : `

Hi ${name},

+ : `

Hi ${safeName},

Just checking in — your AnythingMCP account is still waiting for its first connector. If anything got in your way, hit reply and tell us what; we read every reply.

Pick a connector →

`; @@ -516,13 +536,17 @@ export class EmailService { `, text: `Hi ${name},\n\n${ - dayNumber === 1 - ? "You signed up for AnythingMCP yesterday but haven't connected anything yet." - : 'Your AnythingMCP account is still waiting for its first connector.' - }\n\nOpen the wizard: ${welcomeUrl}\n\nUnsubscribe: ${unsubUrl}`, + opts?.aiClient + ? `You connected ${opts.aiClient} to AnythingMCP, but your workspace has no connectors yet.\n\nAdd your first connector: ${storeUrl}` + : `${ + dayNumber === 1 + ? "You signed up for AnythingMCP yesterday but haven't connected anything yet." + : 'Your AnythingMCP account is still waiting for its first connector.' + }\n\nOpen the wizard: ${welcomeUrl}` + }\n\nUnsubscribe: ${unsubUrl}`, }); this.logger.log( - `Onboarding-reminder email (day ${dayNumber}) sent to ${to}`, + `Onboarding-reminder email (day ${dayNumber}${opts?.aiClient ? ', AI client connected' : ''}) sent to ${to}`, ); return true; } catch (err) { From a2e651191cb59eee40719b1f6d321a2cf1c7c738 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 10:22:45 +0200 Subject: [PATCH 2/6] Connector OAuth: persistent attempts, completed only by the user who started - Pending authorizations move from an in-memory map to connector_oauth_attempts (state hashed, verifier and client credentials encrypted, 15 min, single use), so a restart or blue/green deploy between consent and callback no longer loses them. - The provider callback no longer exchanges the code: it checks the state and forwards code + state to /connectors/oauth/complete, which posts them to POST /api/mcp-oauth/complete. The code is exchanged only for the user who started the flow; anyone else gets 403 and the attempt is spent. Before, a consent link started on one person's connector could be completed by someone else, handing over that person's tokens. - Provider errors (access_denied, invalid_scope, invalid_client) are explained on the complete page, with a way back to the connector. - authorize accepts an internal returnTo; GET /api/connectors/oauth/redirect-uri returns the redirect URI from SERVER_URL, used by the connector form instead of guessing it from the browser's location. --- .../migration.sql | 18 ++ packages/backend/prisma/schema.prisma | 26 ++ .../src/connectors/connectors.controller.ts | 28 +- .../mcp-oauth-callback.controller.spec.ts | 125 +++++--- .../mcp-oauth-callback.controller.ts | 266 +++++++++++------- .../src/connectors/mcp-oauth.service.spec.ts | 93 +++++- .../src/connectors/mcp-oauth.service.ts | 136 +++++++-- .../frontend/src/app/connectors/new/page.tsx | 13 +- .../app/connectors/oauth/complete/layout.tsx | 9 + .../app/connectors/oauth/complete/page.tsx | 82 ++++++ packages/frontend/src/lib/api.ts | 12 +- .../e2e/connector-oauth-complete.spec.ts | 89 ++++++ 12 files changed, 729 insertions(+), 168 deletions(-) create mode 100644 packages/backend/prisma/migrations/20261003100000_connector_oauth_attempts/migration.sql create mode 100644 packages/frontend/src/app/connectors/oauth/complete/layout.tsx create mode 100644 packages/frontend/src/app/connectors/oauth/complete/page.tsx create mode 100644 packages/frontend/tests/e2e/connector-oauth-complete.spec.ts diff --git a/packages/backend/prisma/migrations/20261003100000_connector_oauth_attempts/migration.sql b/packages/backend/prisma/migrations/20261003100000_connector_oauth_attempts/migration.sql new file mode 100644 index 00000000..4cc79f05 --- /dev/null +++ b/packages/backend/prisma/migrations/20261003100000_connector_oauth_attempts/migration.sql @@ -0,0 +1,18 @@ +-- Connector OAuth authorizations in flight. They used to live in an in-memory +-- map, which lost every pending consent on a restart or blue/green deploy and +-- never recorded more than which user started the flow. See the model comment. +CREATE TABLE "connector_oauth_attempts" ( + "id" TEXT NOT NULL, + "state_hash" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "connector_id" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "return_to" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "connector_oauth_attempts_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "connector_oauth_attempts_state_hash_key" ON "connector_oauth_attempts"("state_hash"); +CREATE INDEX "connector_oauth_attempts_expires_at_idx" ON "connector_oauth_attempts"("expires_at"); diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index aa494f63..bba2b325 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -991,6 +991,32 @@ model UserIdentity { /// Everything security-relevant lives here rather than in a cookie that has to /// survive the hop to the IdP and back: the CSRF `state`, the replay-guarding /// `nonce`, and the PKCE verifier. Consumed atomically exactly once. +/// A connector OAuth authorization in flight ("Authorize with Provider"): +/// created when the user starts it, consumed once when the code comes back. +/// +/// Persisted rather than held in memory so a restart or a blue/green deploy +/// between consent and callback does not lose it. The state is stored as a +/// SHA-256 hash and the rest (PKCE verifier, client credentials, signing +/// settings) encrypted with ENCRYPTION_KEY. `userId` is who started it: the +/// code is only exchanged in an authenticated request by that same user, so a +/// consent link started on someone else's connector cannot be completed by a +/// victim on the attacker's behalf. +model ConnectorOAuthAttempt { + id String @id @default(cuid()) + stateHash String @unique @map("state_hash") + userId String @map("user_id") + connectorId String @map("connector_id") + /// Encrypted JSON of the pending flow. + payload String + /// Internal path to land on afterwards; validated when written and when read. + returnTo String? @map("return_to") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + + @@index([expiresAt]) + @@map("connector_oauth_attempts") +} + model SsoLoginAttempt { id String @id @default(cuid()) diff --git a/packages/backend/src/connectors/connectors.controller.ts b/packages/backend/src/connectors/connectors.controller.ts index ae9dafd9..771af71e 100644 --- a/packages/backend/src/connectors/connectors.controller.ts +++ b/packages/backend/src/connectors/connectors.controller.ts @@ -1096,6 +1096,22 @@ export class ConnectorsController { return this.connectorsService.testConnection(id); } + @Get('oauth/redirect-uri') + @ApiOperation({ + summary: 'The OAuth redirect URI to register in a provider app', + description: + 'Computed by the server from SERVER_URL, so it matches what the authorization request sends.', + }) + oauthRedirectUri() { + return { redirectUri: this.oauthCallbackUrl() }; + } + + /** Where providers send the browser back; must equal what the user registered. */ + private oauthCallbackUrl(): string { + const base = (this.configService.get('SERVER_URL') || 'http://localhost:4000').replace(/\/+$/, ''); + return `${base}/api/mcp-oauth/callback`; + } + @Post(':id/oauth/authorize') @ApiOperation({ summary: 'Initiate OAuth2 authorization for a connector', @@ -1104,7 +1120,11 @@ export class ConnectorsController { 'For REST/GraphQL connectors: uses authorizationUrl and tokenUrl from authConfig. ' + 'Returns an authorization URL for the user to visit.', }) - async initiateOAuth(@Req() req: any, @Param('id') id: string) { + async initiateOAuth( + @Req() req: any, + @Param('id') id: string, + @Body() body?: { returnTo?: string }, + ) { const connector = await this.connectorsService.findById(id); this.assertCanWrite(connector, req); @@ -1113,7 +1133,7 @@ export class ConnectorsController { } try { - const callbackUrl = `${this.configService.get('SERVER_URL') || 'http://localhost:4000'}/api/mcp-oauth/callback`; + const callbackUrl = this.oauthCallbackUrl(); const authConfig = connector.authConfig ? JSON.parse(decrypt(connector.authConfig, this.encryptionKey)) : {}; @@ -1200,7 +1220,7 @@ export class ConnectorsController { const state = this.mcpOAuthService.generateState(); // Store pending flow - this.mcpOAuthService.storePendingFlow(state, { + await this.mcpOAuthService.storePendingFlow(state, { codeVerifier, connectorId: connector.id, userId: req.user.sub, @@ -1212,7 +1232,7 @@ export class ConnectorsController { clientAssertion, persistAuthConfig, createdAt: Date.now(), - }); + }, { returnTo: body?.returnTo }); // Build authorization URL const authorizationUrl = this.mcpOAuthService.buildAuthorizationUrl({ diff --git a/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts b/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts index 2d91c5ec..48e991f6 100644 --- a/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts +++ b/packages/backend/src/connectors/mcp-oauth-callback.controller.spec.ts @@ -11,28 +11,32 @@ function makeController(overrides: { connectorType?: string; remoteTools?: Array<{ name: string }>; flow?: Record; + noFlow?: boolean; + returnTo?: string; } = {}) { const reloadConnectorTools = jest.fn().mockResolvedValue(undefined); const updateAuthConfigMerge = jest.fn().mockResolvedValue(undefined); - const deletePendingFlow = jest.fn(); + const flow = { + connectorId: 'conn-1', + userId: 'user-1', + tokenUrl: 'https://sandbox-api.datev.de/token', + redirectUri: 'https://cloud.example.com/api/mcp-oauth/callback', + clientId: 'cid', + clientSecret: 'sec', + codeVerifier: 'verifier', + tokenAuthMethod: 'basic', + ...overrides.flow, + }; + const record = overrides.noFlow ? undefined : { flow, returnTo: overrides.returnTo }; const mcpOAuthService: any = { - getPendingFlow: jest.fn().mockReturnValue({ - connectorId: 'conn-1', - tokenUrl: 'https://sandbox-api.datev.de/token', - redirectUri: 'https://cloud.example.com/api/mcp-oauth/callback', - clientId: 'cid', - clientSecret: 'sec', - codeVerifier: 'verifier', - tokenAuthMethod: 'basic', - ...overrides.flow, - }), + getPendingFlow: jest.fn().mockResolvedValue(record), + takePendingFlow: jest.fn().mockResolvedValue(record), exchangeCodeForTokens: jest.fn().mockResolvedValue({ accessToken: 'AT', refreshToken: 'RT', expiresIn: 3600, }), - deletePendingFlow, }; const connectorsService: any = { updateAuthConfigMerge, @@ -73,13 +77,78 @@ function makeRes() { return { redirect: jest.fn() } as any; } -describe('McpOAuthCallbackController', () => { +const asUser = (sub: string) => ({ user: { sub } }); + +describe('McpOAuthCallbackController — provider redirect', () => { + it('forwards code and state to the dashboard and exchanges nothing itself', async () => { + const { controller, mcpOAuthService, updateAuthConfigMerge } = makeController(); + const res = makeRes(); + await controller.oauthCallback('the-code', 'the-state', undefined, undefined, res); + expect(res.redirect).toHaveBeenCalledWith( + 'https://cloud.example.com/connectors/oauth/complete?state=the-state&code=the-code', + ); + expect(mcpOAuthService.exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(mcpOAuthService.takePendingFlow).not.toHaveBeenCalled(); + expect(updateAuthConfigMerge).not.toHaveBeenCalled(); + }); + + it('does not forward a state it never issued', async () => { + const { controller } = makeController({ noFlow: true }); + const res = makeRes(); + await controller.oauthCallback('the-code', 'forged', undefined, undefined, res); + expect(res.redirect.mock.calls[0][0]).toMatch(/complete\?error=/); + expect(res.redirect.mock.calls[0][0]).not.toContain('code='); + }); + + it('redirects with an error when code/state are missing', async () => { + const { controller, reloadConnectorTools } = makeController(); + const res = makeRes(); + await controller.oauthCallback('', '', undefined, undefined, res); + expect(reloadConnectorTools).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); + }); + + it('spends the attempt and explains a refusal at the provider', async () => { + const { controller, mcpOAuthService } = makeController(); + const res = makeRes(); + await controller.oauthCallback('', 'the-state', 'access_denied', 'User said no', res); + expect(mcpOAuthService.takePendingFlow).toHaveBeenCalledWith('the-state'); + const url = new URL(res.redirect.mock.calls[0][0]); + expect(url.searchParams.get('error')).toMatch(/cancelled at the provider/); + expect(url.searchParams.get('connectorId')).toBe('conn-1'); + }); +}); + +describe('McpOAuthCallbackController — completion by the dashboard', () => { + it('refuses a user other than the one who started the flow, and kills the attempt', async () => { + const { controller, mcpOAuthService, updateAuthConfigMerge } = makeController(); + await expect( + controller.complete(asUser('attacker'), { state: 'the-state', code: 'the-code' }), + ).rejects.toThrow(/started by another account/); + expect(mcpOAuthService.takePendingFlow).toHaveBeenCalledWith('the-state'); + expect(mcpOAuthService.exchangeCodeForTokens).not.toHaveBeenCalled(); + expect(updateAuthConfigMerge).not.toHaveBeenCalled(); + }); + + it('answers 410 for an expired or reused state', async () => { + const { controller } = makeController({ noFlow: true }); + await expect( + controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }), + ).rejects.toThrow(/expired or was already used/); + }); + + it('returns where the dashboard should land', async () => { + const { controller } = makeController({ returnTo: '/connectors/setup/etsy?step=done' }); + await expect( + controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }), + ).resolves.toEqual({ connectorId: 'conn-1', toolsImported: 0, returnTo: '/connectors/setup/etsy?step=done' }); + }); + it('reloads connector tools after storing the token even when MCP discovery throws (REST connector)', async () => { const { controller, reloadConnectorTools, updateAuthConfigMerge } = makeController({ listToolsThrows: true }); - const res = makeRes(); - await controller.oauthCallback('the-code', 'the-state', res); + await controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }); // Token was persisted via a MERGE (preserves authorizationUrl/scopes)... expect(updateAuthConfigMerge).toHaveBeenCalledWith( @@ -88,10 +157,6 @@ describe('McpOAuthCallbackController', () => { ); // ...and the registry was reloaded despite discovery throwing. expect(reloadConnectorTools).toHaveBeenCalledWith('conn-1'); - // Redirects to success. - expect(res.redirect).toHaveBeenCalledWith( - expect.stringContaining('oauth=success'), - ); }); it('does not import MCP tools into a REST connector whose host also speaks MCP', async () => { @@ -102,13 +167,12 @@ describe('McpOAuthCallbackController', () => { connectorType: 'REST', remoteTools: [{ name: 'get' }, { name: 'query' }, { name: 'sites_list' }], }); - const res = makeRes(); - await controller.oauthCallback('the-code', 'the-state', res); + const out = await controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }); expect(mcpClientEngine.listTools).not.toHaveBeenCalled(); expect(prisma.mcpTool.create).not.toHaveBeenCalled(); - expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('tools=0')); + expect(out.toolsImported).toBe(0); }); it('still discovers tools for an MCP connector', async () => { @@ -116,21 +180,12 @@ describe('McpOAuthCallbackController', () => { connectorType: 'MCP', remoteTools: [{ name: 'search' }, { name: 'fetch' }], }); - const res = makeRes(); - await controller.oauthCallback('the-code', 'the-state', res); + const out = await controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }); expect(mcpClientEngine.listTools).toHaveBeenCalled(); expect(prisma.mcpTool.create).toHaveBeenCalledTimes(2); - expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('tools=2')); - }); - - it('redirects with an error when code/state are missing', async () => { - const { controller, reloadConnectorTools } = makeController(); - const res = makeRes(); - await controller.oauthCallback('', '', res); - expect(reloadConnectorTools).not.toHaveBeenCalled(); - expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('error=')); + expect(out.toolsImported).toBe(2); }); it('writes what the flow took from the catalog next to the tokens, and not the resolved client', async () => { @@ -150,7 +205,7 @@ describe('McpOAuthCallbackController', () => { }, }); - await controller.oauthCallback('the-code', 'the-state', makeRes()); + await controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }); const patch = updateAuthConfigMerge.mock.calls[0][1]; expect(patch).toMatchObject({ @@ -166,7 +221,7 @@ describe('McpOAuthCallbackController', () => { it('still writes the client settings when the flow does not say otherwise (MCP)', async () => { const { controller, updateAuthConfigMerge } = makeController({ connectorType: 'MCP' }); - await controller.oauthCallback('the-code', 'the-state', makeRes()); + await controller.complete(asUser('user-1'), { state: 'the-state', code: 'the-code' }); expect(updateAuthConfigMerge.mock.calls[0][1]).toMatchObject({ clientId: 'cid', clientSecret: 'sec', diff --git a/packages/backend/src/connectors/mcp-oauth-callback.controller.ts b/packages/backend/src/connectors/mcp-oauth-callback.controller.ts index 5438b68d..f8ff9155 100644 --- a/packages/backend/src/connectors/mcp-oauth-callback.controller.ts +++ b/packages/backend/src/connectors/mcp-oauth-callback.controller.ts @@ -1,16 +1,38 @@ -import { Controller, Get, Query, Res, Logger } from '@nestjs/common'; +import { + Body, + Controller, + ForbiddenException, + Get, + GoneException, + HttpCode, + Logger, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; import type { Response } from 'express'; -import { McpOAuthService } from './mcp-oauth.service'; +import { McpOAuthService, PendingOAuthFlow } from './mcp-oauth.service'; import { ConnectorsService } from './connectors.service'; import { McpClientEngine } from './engines/mcp-client.engine'; import { PrismaService } from '../common/prisma.service'; import { McpServerService } from '../mcp-server/mcp-server.service'; /** - * Separate controller for the OAuth2 callback — no JWT guard. - * The remote MCP server redirects the user's browser here after login. + * Connector OAuth: where the provider sends the browser back, and where the + * dashboard completes the authorization. + * + * The two steps are split on purpose. The provider's redirect carries no + * proof of who is at the keyboard, so the callback only checks that the state + * is one we issued and forwards code + state to the dashboard. The dashboard + * then posts them in an authenticated request, and the code is exchanged only + * if that user is the one who started the flow. Exchanging in the callback, + * as before, let anyone who started an authorization on their own connector + * send the consent link to someone else and receive that person's tokens. */ @ApiTags('MCP OAuth') @Controller('api/mcp-oauth') @@ -26,125 +48,164 @@ export class McpOAuthCallbackController { private readonly configService: ConfigService, ) {} + private frontendUrl(): string { + return ( + this.configService.get('FRONTEND_URL') || 'http://localhost:3000' + ).replace(/\/+$/, ''); + } + + private completePage(params: Record): string { + return `${this.frontendUrl()}/connectors/oauth/complete?${new URLSearchParams(params).toString()}`; + } + @Get('callback') @ApiOperation({ - summary: 'OAuth2 callback handler for MCP connector authorization', + summary: 'OAuth2 redirect target for connector authorization', description: - 'Handles the redirect from a remote MCP server after user authorization. ' + - 'Exchanges the auth code for tokens and auto-discovers MCP tools.', + 'Checks the state and forwards the code to the dashboard, which completes ' + + 'the authorization with POST /api/mcp-oauth/complete.', }) async oauthCallback( @Query('code') code: string, @Query('state') state: string, + @Query('error') providerError: string | undefined, + @Query('error_description') providerErrorDescription: string | undefined, @Res() res: Response, ) { - const frontendUrl = - this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + if (providerError) { + // The user declined, or the provider refused the request. The attempt + // is spent either way. + const record = state ? await this.mcpOAuthService.takePendingFlow(state) : undefined; + return res.redirect( + this.completePage({ + error: describeProviderError(providerError, providerErrorDescription), + ...(record ? { connectorId: record.flow.connectorId } : {}), + }), + ); + } if (!code || !state) { return res.redirect( - `${frontendUrl}/connectors?error=${encodeURIComponent('Missing code or state in OAuth callback')}`, + this.completePage({ error: 'The provider came back without an authorization code. Start the authorization again.' }), ); } - const flow = this.mcpOAuthService.getPendingFlow(state); - if (!flow) { - this.logger.warn(`OAuth callback with unknown state: ${state}`); + const record = await this.mcpOAuthService.getPendingFlow(state); + if (!record) { + this.logger.warn('OAuth callback with an unknown or expired state'); return res.redirect( - `${frontendUrl}/connectors?error=${encodeURIComponent('OAuth session expired or invalid state')}`, + this.completePage({ error: 'This authorization expired or was already used. Start it again from the connector.' }), ); } - try { - // 1. Exchange auth code for tokens - const tokens = await this.mcpOAuthService.exchangeCodeForTokens({ - tokenUrl: flow.tokenUrl, - code, - redirectUri: flow.redirectUri, - clientId: flow.clientId, - clientSecret: flow.clientSecret, - codeVerifier: flow.codeVerifier, - tokenAuthMethod: flow.tokenAuthMethod, - clientAssertion: flow.clientAssertion, - }); - - this.logger.log( - `OAuth tokens obtained for connector ${flow.connectorId}`, + return res.redirect(this.completePage({ state, code })); + } + + @Post('complete') + @UseGuards(AuthGuard('jwt')) + @HttpCode(200) + @ApiOperation({ + summary: 'Complete a connector authorization (dashboard, authenticated)', + description: + 'Exchanges the authorization code, but only for the user who started the flow.', + }) + async complete( + @Req() req: any, + @Body() body: { state?: string; code?: string }, + ): Promise<{ connectorId: string; toolsImported: number; returnTo?: string }> { + const record = await this.mcpOAuthService.takePendingFlow(String(body?.state || '')); + if (!record) { + throw new GoneException('This authorization expired or was already used. Start it again from the connector.'); + } + const { flow, returnTo } = record; + if (flow.userId !== req.user?.sub) { + // Consumed above on purpose: a link someone else started is dead now. + this.logger.warn( + `Refused OAuth completion: connector ${flow.connectorId} was authorized by another user`, + ); + throw new ForbiddenException( + 'This authorization was started by another account. Sign in as that account, or start it again yourself.', ); + } + if (!body?.code) { + throw new GoneException('The provider came back without an authorization code. Start the authorization again.'); + } - // 2. Store tokens (encrypted) in the connector's authConfig. Merge, don't - // replace — preserves static config (authorizationUrl, scopes) needed for - // later re-authorization. - const clientSettings = flow.persistAuthConfig ?? { - tokenUrl: flow.tokenUrl, - clientId: flow.clientId, - clientSecret: flow.clientSecret, - tokenAuthMethod: flow.tokenAuthMethod, - }; - await this.connectorsService.updateAuthConfigMerge(flow.connectorId, { - ...clientSettings, - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken, - expiresIn: tokens.expiresIn, - expiresAt: Date.now() + (tokens.expiresIn || 3600) * 1000, - authorizedAt: new Date().toISOString(), - }); - - // Reload the connector's tools into the in-memory MCP registry so the - // freshly-stored access token takes effect immediately. The registry - // caches a snapshot of authConfig (incl. the token) per tool, so without - // this a just-authorized connector would keep serving with the stale - // (token-less) snapshot. For REST/GraphQL OAuth connectors this is the - // ONLY reload — the MCP auto-discovery block below throws for non-MCP - // servers and never reaches its own reloadConnectorTools() call. - try { - await this.mcpServer.reloadConnectorTools(flow.connectorId); - } catch (reloadErr: any) { - this.logger.warn( - `Failed to reload tools after OAuth for connector ${flow.connectorId}: ${reloadErr.message}`, - ); - } + const toolsImported = await this.exchangeAndStore(flow, String(body.code)); + return { connectorId: flow.connectorId, toolsImported, ...(returnTo ? { returnTo } : {}) }; + } - // 3. Auto-discover tools from the remote MCP server (MCP connectors only) - let toolsImported = 0; - try { - const connector = await this.connectorsService.findByIdInternal( - flow.connectorId, - ); + /** Exchange the code, store the tokens, reload the tools. Throws on failure. */ + private async exchangeAndStore(flow: PendingOAuthFlow, code: string): Promise { + // 1. Exchange auth code for tokens + const tokens = await this.mcpOAuthService.exchangeCodeForTokens({ + tokenUrl: flow.tokenUrl, + code, + redirectUri: flow.redirectUri, + clientId: flow.clientId, + clientSecret: flow.clientSecret, + codeVerifier: flow.codeVerifier, + tokenAuthMethod: flow.tokenAuthMethod, + clientAssertion: flow.clientAssertion, + }); - // A REST or GraphQL connector already has its tools. Discovery used - // to run for them too and relied on the host not speaking MCP; Google - // does (searchconsole.googleapis.com), so authorising the Search - // Console connector added three MCP tools mapped as REST calls. - if (connector.type === 'MCP') { - toolsImported = await this.importRemoteTools( - flow.connectorId, - connector, - tokens.accessToken, - ); - } - } catch (discoverErr: any) { - this.logger.warn( - `Tool discovery failed after OAuth (will proceed anyway): ${discoverErr.message}`, - ); - } + this.logger.log(`OAuth tokens obtained for connector ${flow.connectorId}`); - // 4. Clean up - this.mcpOAuthService.deletePendingFlow(state); + // 2. Store tokens (encrypted) in the connector's authConfig. Merge, don't + // replace — preserves static config (authorizationUrl, scopes) needed for + // later re-authorization. + const clientSettings = flow.persistAuthConfig ?? { + tokenUrl: flow.tokenUrl, + clientId: flow.clientId, + clientSecret: flow.clientSecret, + tokenAuthMethod: flow.tokenAuthMethod, + }; + await this.connectorsService.updateAuthConfigMerge(flow.connectorId, { + ...clientSettings, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + expiresAt: Date.now() + (tokens.expiresIn || 3600) * 1000, + authorizedAt: new Date().toISOString(), + }); - // 5. Redirect to frontend - return res.redirect( - `${frontendUrl}/connectors/${flow.connectorId}?oauth=success&tools=${toolsImported}`, - ); - } catch (error: any) { - this.logger.error( - `OAuth callback failed for connector ${flow.connectorId}: ${error.message}`, + // Reload the connector's tools into the in-memory MCP registry so the + // freshly-stored access token takes effect immediately. The registry + // caches a snapshot of authConfig (incl. the token) per tool, so without + // this a just-authorized connector would keep serving with the stale + // (token-less) snapshot. For REST/GraphQL OAuth connectors this is the + // ONLY reload — the MCP auto-discovery block below throws for non-MCP + // servers and never reaches its own reloadConnectorTools() call. + try { + await this.mcpServer.reloadConnectorTools(flow.connectorId); + } catch (reloadErr: any) { + this.logger.warn( + `Failed to reload tools after OAuth for connector ${flow.connectorId}: ${reloadErr.message}`, ); - this.mcpOAuthService.deletePendingFlow(state); - return res.redirect( - `${frontendUrl}/connectors/${flow.connectorId}?oauth=error&message=${encodeURIComponent(error.message)}`, + } + + // 3. Auto-discover tools from the remote MCP server (MCP connectors only) + let toolsImported = 0; + try { + const connector = await this.connectorsService.findByIdInternal(flow.connectorId); + + // A REST or GraphQL connector already has its tools. Discovery used + // to run for them too and relied on the host not speaking MCP; Google + // does (searchconsole.googleapis.com), so authorising the Search + // Console connector added three MCP tools mapped as REST calls. + if (connector.type === 'MCP') { + toolsImported = await this.importRemoteTools( + flow.connectorId, + connector, + tokens.accessToken, + ); + } + } catch (discoverErr: any) { + this.logger.warn( + `Tool discovery failed after OAuth (will proceed anyway): ${discoverErr.message}`, ); } + return toolsImported; } /** Import the tools a remote MCP server lists, skipping ones already present. */ @@ -200,3 +261,18 @@ export class McpOAuthCallbackController { return toolsImported; } } + +/** The provider's refusal in words a user can act on. */ +function describeProviderError(code: string, description?: string): string { + const detail = description ? ` (${description.slice(0, 200)})` : ''; + if (code === 'access_denied') { + return `The authorization was cancelled at the provider${detail}. Nothing was changed; start it again when you are ready.`; + } + if (code === 'invalid_scope') { + return `The provider refused the requested permissions${detail}. Check the scopes in the connector's OAuth settings.`; + } + if (code === 'unauthorized_client' || code === 'invalid_client') { + return `The provider does not accept this app${detail}. Check the client ID and that the redirect URI shown in AnythingMCP is registered in the app.`; + } + return `The provider returned an error: ${code.slice(0, 80)}${detail}.`; +} diff --git a/packages/backend/src/connectors/mcp-oauth.service.spec.ts b/packages/backend/src/connectors/mcp-oauth.service.spec.ts index 76c7a2c3..c80f590e 100644 --- a/packages/backend/src/connectors/mcp-oauth.service.spec.ts +++ b/packages/backend/src/connectors/mcp-oauth.service.spec.ts @@ -324,7 +324,7 @@ describe('McpOAuthService PKCE', () => { expect(params.get('client_id')).toBe('keystring'); }); - it('keeps the verifier with its state, for ten minutes', () => { + it('keeps the verifier with its state, until it expires or is taken', async () => { const s = new McpOAuthService(); const flow = { codeVerifier: 'v', @@ -335,10 +335,91 @@ describe('McpOAuthService PKCE', () => { tokenUrl: 't', createdAt: Date.now(), }; - s.storePendingFlow('state-a', flow); - expect(s.getPendingFlow('state-a')?.codeVerifier).toBe('v'); - expect(s.getPendingFlow('state-b')).toBeUndefined(); - s.storePendingFlow('state-old', { ...flow, createdAt: Date.now() - 11 * 60 * 1000 }); - expect(s.getPendingFlow('state-old')).toBeUndefined(); + await s.storePendingFlow('state-a', flow, { returnTo: '/connectors/c' }); + expect((await s.getPendingFlow('state-a'))?.flow.codeVerifier).toBe('v'); + expect((await s.getPendingFlow('state-a'))?.returnTo).toBe('/connectors/c'); + expect(await s.getPendingFlow('state-b')).toBeUndefined(); + // Taking it consumes it. + expect((await s.takePendingFlow('state-a'))?.flow.codeVerifier).toBe('v'); + expect(await s.takePendingFlow('state-a')).toBeUndefined(); + }); + +}); + +describe('McpOAuthService pending flows in the database', () => { + const flow = { + codeVerifier: 'the-verifier', + connectorId: 'conn-1', + userId: 'user-1', + redirectUri: 'https://cloud.example.com/api/mcp-oauth/callback', + clientId: 'cid', + clientSecret: 'very-secret', + tokenUrl: 'https://example.com/token', + createdAt: Date.now(), + }; + const saved = process.env.ENCRYPTION_KEY; + beforeAll(() => { + process.env.ENCRYPTION_KEY = 'k'.repeat(32); + }); + afterAll(() => { + if (saved === undefined) delete process.env.ENCRYPTION_KEY; + else process.env.ENCRYPTION_KEY = saved; + }); + + function fakePrisma() { + const rows = new Map(); + return { + rows, + connectorOAuthAttempt: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + create: jest.fn(async ({ data }: any) => { + rows.set(data.stateHash, data); + return data; + }), + findUnique: jest.fn(async ({ where }: any) => rows.get(where.stateHash) ?? null), + delete: jest.fn(async ({ where }: any) => { + const row = rows.get(where.stateHash); + if (!row) throw new Error('P2025'); + rows.delete(where.stateHash); + return row; + }), + }, + }; + } + + it('stores the state hashed and the secrets encrypted', async () => { + const prisma = fakePrisma(); + const s = new McpOAuthService(prisma as any); + await s.storePendingFlow('the-state', flow); + const [row] = [...prisma.rows.values()]; + expect(row.stateHash).not.toContain('the-state'); + expect(row.payload).not.toContain('very-secret'); + expect(row.payload).not.toContain('the-verifier'); + expect(row.userId).toBe('user-1'); + expect((await s.getPendingFlow('the-state'))?.flow.clientSecret).toBe('very-secret'); + }); + + it('can be taken only once', async () => { + const s = new McpOAuthService(fakePrisma() as any); + await s.storePendingFlow('the-state', flow); + expect((await s.takePendingFlow('the-state'))?.flow.userId).toBe('user-1'); + expect(await s.takePendingFlow('the-state')).toBeUndefined(); + }); + + it('ignores an expired attempt', async () => { + const prisma = fakePrisma(); + const s = new McpOAuthService(prisma as any); + await s.storePendingFlow('the-state', flow); + for (const row of prisma.rows.values()) row.expiresAt = new Date(Date.now() - 1000); + expect(await s.getPendingFlow('the-state')).toBeUndefined(); + expect(await s.takePendingFlow('the-state')).toBeUndefined(); + }); + + it('drops a returnTo that would leave the dashboard', async () => { + const s = new McpOAuthService(fakePrisma() as any); + for (const bad of ['https://evil.example/x', '//evil.example', '/\\evil.example', 'connectors/1']) { + await s.storePendingFlow(`st-${bad}`, flow, { returnTo: bad }); + expect((await s.getPendingFlow(`st-${bad}`))?.returnTo).toBeUndefined(); + } }); }); diff --git a/packages/backend/src/connectors/mcp-oauth.service.ts b/packages/backend/src/connectors/mcp-oauth.service.ts index cb14c1a5..0069e216 100644 --- a/packages/backend/src/connectors/mcp-oauth.service.ts +++ b/packages/backend/src/connectors/mcp-oauth.service.ts @@ -1,5 +1,7 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; import { createHash, randomBytes } from 'crypto'; +import { PrismaService } from '../common/prisma.service'; +import { decrypt, encrypt } from '../common/crypto/encryption.util'; import axios from 'axios'; import { assertSafeOutboundUrl } from '../common/ssrf.util'; import { @@ -18,7 +20,7 @@ interface OAuthMetadata { code_challenge_methods_supported?: string[]; } -interface PendingOAuthFlow { +export interface PendingOAuthFlow { codeVerifier: string; connectorId: string; userId: string; @@ -55,9 +57,13 @@ interface PendingOAuthFlow { export class McpOAuthService { private readonly logger = new Logger(McpOAuthService.name); - // In-memory store for pending OAuth flows, keyed by state. - // Entries auto-expire after 10 minutes. - private pendingFlows = new Map(); + /** + * Pending flows when no database is wired (unit tests). In the application + * they live in `connector_oauth_attempts`, see storePendingFlow. + */ + private memoryFlows = new Map(); + + constructor(@Optional() private readonly prisma?: PrismaService) {} /** * Discover the OAuth metadata of a remote MCP server. @@ -412,33 +418,115 @@ export class McpOAuthService { } // --- Pending Flow Storage --- + // + // An authorization in flight is stored in the database (state hashed, the + // rest encrypted), so it survives a restart or a blue/green deploy between + // consent and callback. It is read twice: peeked by the provider callback, + // which only forwards the code to the dashboard, and taken (deleted) by the + // authenticated request that exchanges the code, after checking that the + // same user started it. + + private stateHash(state: string): string { + return createHash('sha256').update(state).digest('hex'); + } - storePendingFlow(state: string, data: PendingOAuthFlow): void { - // Clean up expired entries (>10 min) - const now = Date.now(); - for (const [key, flow] of this.pendingFlows) { - if (now - flow.createdAt > 10 * 60 * 1000) { - this.pendingFlows.delete(key); - } - } + private encryptionKey(): string { + const key = process.env.ENCRYPTION_KEY; + if (!key) throw new Error('ENCRYPTION_KEY is not set'); + return key; + } - this.pendingFlows.set(state, data); + async storePendingFlow( + state: string, + data: PendingOAuthFlow, + opts: { returnTo?: string } = {}, + ): Promise { + const expiresAt = Date.now() + PENDING_FLOW_TTL_MS; + const returnTo = safeReturnTo(opts.returnTo); + if (!this.prisma) { + this.memoryFlows.set(state, { flow: data, returnTo, expiresAt }); + return; + } + await this.prisma.connectorOAuthAttempt + .deleteMany({ where: { expiresAt: { lt: new Date() } } }) + .catch(() => undefined); + await this.prisma.connectorOAuthAttempt.create({ + data: { + stateHash: this.stateHash(state), + userId: data.userId, + connectorId: data.connectorId, + payload: encrypt(JSON.stringify(data), this.encryptionKey(), 'connector-oauth'), + returnTo: returnTo ?? null, + expiresAt: new Date(expiresAt), + }, + }); } - getPendingFlow(state: string): PendingOAuthFlow | undefined { - const flow = this.pendingFlows.get(state); - if (!flow) return undefined; + /** The pending flow for `state`, without consuming it. */ + async getPendingFlow(state: string): Promise { + if (!state) return undefined; + if (!this.prisma) { + const hit = this.memoryFlows.get(state); + if (!hit || hit.expiresAt < Date.now()) return undefined; + return { flow: hit.flow, returnTo: hit.returnTo }; + } + const row = await this.prisma.connectorOAuthAttempt.findUnique({ + where: { stateHash: this.stateHash(state) }, + }); + if (!row || row.expiresAt.getTime() < Date.now()) return undefined; + return this.toRecord(row); + } - // Check expiry - if (Date.now() - flow.createdAt > 10 * 60 * 1000) { - this.pendingFlows.delete(state); - return undefined; + /** The pending flow for `state`, deleted in the same step: usable once. */ + async takePendingFlow(state: string): Promise { + if (!state) return undefined; + if (!this.prisma) { + const hit = this.memoryFlows.get(state); + this.memoryFlows.delete(state); + if (!hit || hit.expiresAt < Date.now()) return undefined; + return { flow: hit.flow, returnTo: hit.returnTo }; } + const row = await this.prisma.connectorOAuthAttempt + .delete({ where: { stateHash: this.stateHash(state) } }) + .catch(() => null); + if (!row || row.expiresAt.getTime() < Date.now()) return undefined; + return this.toRecord(row); + } - return flow; + async deletePendingFlow(state: string): Promise { + await this.takePendingFlow(state); } - deletePendingFlow(state: string): void { - this.pendingFlows.delete(state); + private toRecord(row: { payload: string; returnTo: string | null }): PendingFlowRecord | undefined { + try { + const flow = JSON.parse( + decrypt(row.payload, this.encryptionKey(), 'connector-oauth'), + ) as PendingOAuthFlow; + return { flow, returnTo: safeReturnTo(row.returnTo ?? undefined) }; + } catch (err: any) { + this.logger.warn(`Unreadable pending OAuth flow: ${err?.message || err}`); + return undefined; + } } } + +/** How long a started authorization waits for the user to come back. */ +export const PENDING_FLOW_TTL_MS = 15 * 60 * 1000; + +export interface PendingFlowRecord { + flow: PendingOAuthFlow; + /** Where the dashboard should land afterwards (internal path), if set. */ + returnTo?: string; +} + +/** + * An internal dashboard path, or undefined. Anything else (an absolute URL, + * `//host`, a backslash trick) would be an open redirect. + */ +export function safeReturnTo(value: string | undefined | null): string | undefined { + if (!value || typeof value !== 'string') return undefined; + if (value.length > 500) return undefined; + if (!value.startsWith('/') || value.startsWith('//') || value.includes('\\')) return undefined; + if ([...value].some((ch) => ch.charCodeAt(0) < 0x20)) return undefined; + return value; +} diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index 9ee07187..8a6d7ac2 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth-context'; @@ -45,6 +45,15 @@ const TYPE_ICONS: Record = { export default function NewConnectorPage() { const { token } = useAuth(); + // What to register in the provider's app, as the server will send it. + const [oauthRedirectUri, setOauthRedirectUri] = useState(null); + useEffect(() => { + if (!token) return; + connectors + .oauthRedirectUri(token) + .then((r) => setOauthRedirectUri(r.redirectUri)) + .catch(() => setOauthRedirectUri(null)); + }, [token]); const router = useRouter(); const [selectedType, setSelectedType] = useState(null); // OData: SAP Gateway mode (catalog, sap-client) and its settings. @@ -569,7 +578,7 @@ export default function NewConnectorPage() {

After creating the connector, you will be redirected to authorize via OAuth2. Tokens will be stored securely.

- Set the Redirect / Callback URI in your OAuth provider to: {typeof window !== 'undefined' ? (window.location.hostname === 'localhost' ? window.location.origin.replace(':3000', ':4000') : window.location.origin) : 'http://localhost:4000'}/api/mcp-oauth/callback + Set the Redirect / Callback URI in your OAuth provider to: {oauthRedirectUri ?? '…'}

diff --git a/packages/frontend/src/app/connectors/oauth/complete/layout.tsx b/packages/frontend/src/app/connectors/oauth/complete/layout.tsx new file mode 100644 index 00000000..fad7c68f --- /dev/null +++ b/packages/frontend/src/app/connectors/oauth/complete/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; + +// The URL carries an authorization code until the page strips it: never send +// it on as a Referer. +export const metadata: Metadata = { referrer: 'no-referrer' }; + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/packages/frontend/src/app/connectors/oauth/complete/page.tsx b/packages/frontend/src/app/connectors/oauth/complete/page.tsx new file mode 100644 index 00000000..d0e55405 --- /dev/null +++ b/packages/frontend/src/app/connectors/oauth/complete/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { Suspense, useEffect, useRef, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { useAuth } from '@/lib/auth-context'; +import { connectors } from '@/lib/api'; +import { Card } from '@/components/ui/card'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +/** + * Where "Authorize with Provider" lands. The provider sends the browser to the + * backend callback, which only checks the state and forwards the code here; + * this page exchanges it in an authenticated request, so the server can check + * that the person completing the authorization is the one who started it. + */ +function CompleteContent() { + const params = useSearchParams(); + const router = useRouter(); + const { token, isLoading } = useAuth(); + const [error, setError] = useState(params.get('error')); + const started = useRef(false); + + const state = params.get('state'); + const code = params.get('code'); + const connectorId = params.get('connectorId'); + + useEffect(() => { + if (error || !state || !code || started.current || isLoading) return; + if (!token) { + // Sign in first, then come back here with the same code. + const here = `${window.location.pathname}${window.location.search}`; + router.replace(`/login?redirect=${encodeURIComponent(here)}`); + return; + } + started.current = true; + // The code is single-use and bound to a verifier the server keeps, but it + // still has no business staying in the address bar or the history. + window.history.replaceState({}, '', '/connectors/oauth/complete'); + connectors + .oauthComplete(state, code, token) + .then((out) => { + const fallback = `/connectors/${out.connectorId}?oauth=success&tools=${out.toolsImported}`; + router.replace(out.returnTo || fallback); + }) + .catch((err: Error) => setError(err.message || 'The authorization could not be completed.')); + }, [error, state, code, token, isLoading, router]); + + if (!error && !state) { + return

Nothing to complete here.

; + } + + if (error) { + return ( +
+

Authorization not completed

+

{error}

+ + {connectorId ? 'Back to the connector' : 'Back to connectors'} + +
+ ); + } + + return

Completing the authorization…

; +} + +export default function OAuthCompletePage() { + return ( +
+ + Loading…

}> + +
+
+
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index af692d0e..46d498d8 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -425,11 +425,19 @@ export const connectors = { request<{ message: string; created: number; skipped: number; tools: number; errors?: string[] }>('/api/connectors/import-all', { method: 'POST', body: data, token }), healthCheck: (token: string) => request<{ total: number; healthy: number; unhealthy: number; connectors: any[] }>('/api/connectors/health-check', { token }), - oauthAuthorize: (id: string, token: string) => + oauthAuthorize: (id: string, token: string, returnTo?: string) => request<{ authorizationUrl?: string; error?: string }>( `/api/connectors/${id}/oauth/authorize`, - { method: 'POST', token }, + { method: 'POST', token, body: returnTo ? { returnTo } : undefined }, + ), + /** Second half of "Authorize with Provider": exchange the code the provider sent back. */ + oauthComplete: (state: string, code: string, token: string) => + request<{ connectorId: string; toolsImported: number; returnTo?: string }>( + '/api/mcp-oauth/complete', + { method: 'POST', token, body: { state, code } }, ), + oauthRedirectUri: (token: string) => + request<{ redirectUri: string }>('/api/connectors/oauth/redirect-uri', { token }), discoverTools: (id: string, token: string) => request<{ message: string; tools: any[]; skipped?: string[]; error?: string }>( `/api/connectors/${id}/discover-tools`, diff --git a/packages/frontend/tests/e2e/connector-oauth-complete.spec.ts b/packages/frontend/tests/e2e/connector-oauth-complete.spec.ts new file mode 100644 index 00000000..a59b3405 --- /dev/null +++ b/packages/frontend/tests/e2e/connector-oauth-complete.spec.ts @@ -0,0 +1,89 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * "Authorize with Provider" completes in the dashboard: the backend callback + * only forwards code + state here, and this page exchanges them in an + * authenticated request, so the server can refuse a user other than the one + * who started the authorization. + */ + +const USER = { + id: 'u1', + email: 'test@example.com', + name: 'Test User', + role: 'ADMIN', + organizationId: 'o1', + emailVerified: true, +}; + +async function mockApi(page: Page, complete: { status: number; body: unknown }) { + const posts: unknown[] = []; + await page.route(/\/api\//, async (route) => { + const req = route.request(); + const url = req.url(); + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.includes('/api/mcp-oauth/complete') && req.method() === 'POST') { + posts.push({ body: req.postDataJSON(), auth: req.headers()['authorization'] }); + return json(complete.body, complete.status); + } + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (url.includes('/api/organizations/mine')) return json([{ id: 'o1', name: 'Acme', role: 'ADMIN', joinedAt: '2026-01-01' }]); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + if (url.includes('/api/connectors/c1')) return json({ id: 'c1', name: 'Etsy', type: 'REST', authType: 'OAUTH2', tools: [], isActive: true }); + if (url.includes('/api/connectors')) return json([]); + return json({}); + }); + return posts; +} + +async function signIn(page: Page) { + await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); +} + +test('exchanges the code as the signed-in user and lands where the flow asked', async ({ page }) => { + await signIn(page); + const posts = await mockApi(page, { + status: 200, + body: { connectorId: 'c1', toolsImported: 0, returnTo: '/connectors/c1?oauth=success&tools=0' }, + }); + await page.goto('/connectors/oauth/complete?state=st-1&code=co-1'); + await expect(page).toHaveURL(/\/connectors\/c1/); + expect(posts).toEqual([{ body: { state: 'st-1', code: 'co-1' }, auth: 'Bearer test-token' }]); +}); + +test('shows the server refusal instead of a success', async ({ page }) => { + await signIn(page); + await mockApi(page, { + status: 403, + body: { statusCode: 403, message: 'This authorization was started by another account.' }, + }); + await page.goto('/connectors/oauth/complete?state=st-1&code=co-1'); + await expect(page.locator('p[role="alert"]')).toContainText('started by another account'); + // The code does not stay in the address bar. + await expect(page).toHaveURL(/\/connectors\/oauth\/complete$/); +}); + +test('explains an error the provider returned, with a way back', async ({ page }) => { + await signIn(page); + const posts = await mockApi(page, { status: 200, body: {} }); + await page.goto('/connectors/oauth/complete?error=The+authorization+was+cancelled+at+the+provider.&connectorId=c1'); + await expect(page.locator('p[role="alert"]')).toContainText('cancelled at the provider'); + await expect(page.getByRole('link', { name: 'Back to the connector' })).toHaveAttribute('href', '/connectors/c1'); + expect(posts).toHaveLength(0); +}); + +test('sends a signed-out visitor to sign in first, keeping the code', async ({ page }) => { + const posts = await mockApi(page, { status: 200, body: {} }); + await page.goto('/connectors/oauth/complete?state=st-1&code=co-1'); + await expect(page).toHaveURL(/\/login\?redirect=/); + const redirect = new URL(page.url()).searchParams.get('redirect'); + expect(redirect).toBe('/connectors/oauth/complete?state=st-1&code=co-1'); + expect(posts).toHaveLength(0); +}); From dfa84f2dee083c30db55abc6b2cd86d9b740c7d8 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 10:43:59 +0200 Subject: [PATCH 3/6] Connector setup status and guided setup - Setup status per connector (ready / needs_input / needs_authorization), computed from its variables and OAuth tokens. Connectors that are not ready are not listed on MCP (tools/list, per-server endpoints, shared /mcp); the shared endpoint names them under needsSetup with the link to finish them, and a call by name gets that link instead of a placeholder error. Checked against production: 213 of 1037 connectors are not ready, none of them had a successful call in 14 days. - Catalog: optional envVarMeta (label, kind, secret, help, example, pattern, link, advanced) with derived defaults for every adapter, curated for the 14 most installed; setupKind (none / credentials / oauth_browser); validator rule. - POST /api/adapters/:slug/verify tries the credentials against the adapter's probe on an in-memory connector before anything is saved. - /connectors/setup/: grouped fields with where to find them, check then install, 'Save and sign in' chaining the OAuth authorization with a return to the setup page, finish an existing connector, drafts instead of 'Skip for now'. The store installs through it; /welcome asks what to connect first. Setup funnel product events. --- .../src/adapters/adapters.controller.ts | 57 ++- .../src/adapters/adapters.service.spec.ts | 74 +++ .../backend/src/adapters/adapters.service.ts | 235 ++++++++- packages/backend/src/adapters/catalog.ts | 8 + packages/backend/src/adapters/de/datev.json | 12 + .../src/adapters/de/getmyinvoices.json | 6 + .../src/adapters/de/lexware-office.json | 6 + .../backend/src/adapters/de/sendcloud.json | 13 + packages/backend/src/adapters/de/weclapp.json | 12 + .../backend/src/adapters/env-var-meta.spec.ts | 51 ++ packages/backend/src/adapters/env-var-meta.ts | 117 +++++ .../backend/src/adapters/intl/ebay-sell.json | 22 + packages/backend/src/adapters/intl/etsy.json | 18 + .../backend/src/adapters/intl/google-ads.json | 19 + .../adapters/intl/google-search-console.json | 14 + packages/backend/src/adapters/intl/jev.json | 7 + packages/backend/src/adapters/intl/odoo.json | 17 + .../backend/src/adapters/intl/pinterest.json | 17 + .../src/adapters/intl/telegram-bot.json | 11 + .../src/adapters/intl/whatsapp-business.json | 12 + .../src/audit/product-event.service.ts | 12 + .../connector-setup-status.util.spec.ts | 113 +++++ .../connectors/connector-setup-status.util.ts | 110 +++++ .../src/connectors/connectors.controller.ts | 38 +- .../src/mcp-server/dynamic-mcp-tools.spec.ts | 27 ++ .../src/mcp-server/dynamic-mcp-tools.ts | 11 + .../src/mcp-server/mcp-endpoint.controller.ts | 6 +- .../src/mcp-server/mcp-server.service.ts | 15 +- .../src/mcp-server/shared-toolset.spec.ts | 42 ++ .../backend/src/mcp-server/shared-toolset.ts | 45 +- .../backend/src/mcp-server/tool-registry.ts | 20 + .../frontend/src/app/connectors/[id]/page.tsx | 4 + packages/frontend/src/app/connectors/page.tsx | 2 + .../src/app/connectors/setup/[slug]/page.tsx | 449 ++++++++++++++++++ .../src/app/connectors/store/page.tsx | 344 +------------- packages/frontend/src/app/welcome/page.tsx | 115 +++-- .../frontend/src/components/setup-status.tsx | 61 +++ packages/frontend/src/lib/api.ts | 44 ++ .../tests/e2e/connector-guided-setup.spec.ts | 153 ++++++ scripts/validate-adapters.mjs | 20 + scripts/validate-adapters.test.mjs | 10 + 41 files changed, 1962 insertions(+), 407 deletions(-) create mode 100644 packages/backend/src/adapters/env-var-meta.spec.ts create mode 100644 packages/backend/src/adapters/env-var-meta.ts create mode 100644 packages/backend/src/connectors/connector-setup-status.util.spec.ts create mode 100644 packages/backend/src/connectors/connector-setup-status.util.ts create mode 100644 packages/frontend/src/app/connectors/setup/[slug]/page.tsx create mode 100644 packages/frontend/src/components/setup-status.tsx create mode 100644 packages/frontend/tests/e2e/connector-guided-setup.spec.ts diff --git a/packages/backend/src/adapters/adapters.controller.ts b/packages/backend/src/adapters/adapters.controller.ts index 6bcfb248..1cd5c067 100644 --- a/packages/backend/src/adapters/adapters.controller.ts +++ b/packages/backend/src/adapters/adapters.controller.ts @@ -8,6 +8,8 @@ import { UseGuards, ForbiddenException, BadRequestException, + HttpCode, + HttpException, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; @@ -156,9 +158,39 @@ export class AdaptersController { 'Returns the full adapter definition with connector config and all tool mappings.', }) getBySlug(@Param('slug') slug: string) { - return this.adaptersService.getBySlug(slug); + return this.adaptersService.describe(slug); } + @Post(':slug/verify') + @HttpCode(200) + @ApiOperation({ + summary: 'Try an adapter with credentials before saving it', + description: + 'Runs the adapter\'s safe read call against an in-memory connector. Nothing is stored. ' + + 'ok=true: it worked; ok=false: a value is missing or the API refused; ok=null: nothing to try yet (needs a sign-in at the provider, or no safe call).', + }) + async verify( + @Req() req: any, + @Param('slug') slug: string, + @Body() body: { credentials?: Record; connectorId?: string }, + ) { + if (req.user.role === 'VIEWER') { + throw new ForbiddenException('Viewers cannot modify connectors'); + } + // Each try is a real call to the provider; a form does not need more. + if (!this.verifyLimiter.take(req.user.sub)) { + throw new HttpException('Too many attempts. Wait a minute and try again.', 429); + } + return this.adaptersService.verifyCredentials( + slug, + req.user.organizationId, + body?.credentials, + body?.connectorId, + ); + } + + private readonly verifyLimiter = new PerKeyWindowLimiter(20, 60_000); + @Post(':slug/import') @ApiOperation({ summary: 'Import a built-in adapter as a new connector', @@ -210,3 +242,26 @@ export interface StarterPackInstallResult { probeOk?: boolean | null; error?: string; } + +/** At most `max` events per key within a sliding window of `windowMs`. In memory, per instance. */ +export class PerKeyWindowLimiter { + private readonly hits = new Map(); + constructor( + private readonly max: number, + private readonly windowMs: number, + ) {} + + take(key: string, now = Date.now()): boolean { + const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs); + if (recent.length >= this.max) { + this.hits.set(key, recent); + return false; + } + recent.push(now); + this.hits.set(key, recent); + if (this.hits.size > 10_000) { + for (const [k, v] of this.hits) if (v.every((t) => now - t >= this.windowMs)) this.hits.delete(k); + } + return true; + } +} diff --git a/packages/backend/src/adapters/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index 97798344..177ff5ca 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -347,3 +347,77 @@ describe('AdaptersService starter pack', () => { ); }); }); + +describe('AdaptersService.verifyCredentials', () => { + function build(execute: jest.Mock) { + const prisma = { connector: { create: jest.fn() }, mcpTool: { create: jest.fn() } }; + const service = new AdaptersService( + prisma as any, + { reloadConnectorTools: jest.fn() } as any, + { get: (k: string) => (k === 'ENCRYPTION_KEY' ? 'a'.repeat(48) : undefined) } as any, + { executeConnectorCall: execute } as any, + ); + return { service, prisma }; + } + + it('runs the probe with the given key in memory and writes nothing', async () => { + const execute = jest.fn().mockResolvedValue({ companyName: 'Acme GmbH' }); + const { service, prisma } = build(execute); + const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: ' key-1 ' }); + expect(out).toMatchObject({ ok: true, sample: expect.stringContaining('Acme GmbH') }); + const [connector] = execute.mock.calls[0]; + // No id: OAuth and login-token caches stay in memory. + expect(connector.id).toBe(''); + expect(connector.envVars).toEqual({ LEXWARE_API_KEY: 'key-1' }); + expect(prisma.connector.create).not.toHaveBeenCalled(); + }); + + it('reports a refused key as auth_failed, with the provider message', async () => { + const err: any = new Error('401 Unauthorized: invalid token'); + err.status = 401; + const { service } = build(jest.fn().mockRejectedValue(err)); + const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: 'bad' }); + expect(out).toMatchObject({ ok: false, kind: 'auth_failed', status: 401 }); + }); + + it('names what is still empty without calling the API', async () => { + const execute = jest.fn(); + const { service } = build(execute); + const out = await service.verifyCredentials('weclapp', 'org1', { WECLAPP_API_TOKEN: 't' }); + expect(out).toMatchObject({ ok: false, kind: 'invalid_input', missing: ['WECLAPP_TENANT'] }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('refuses an address variable that is not one', async () => { + const execute = jest.fn(); + const { service } = build(execute); + const out = await service.verifyCredentials('substack', 'org1', { SUBSTACK_PUBLICATION_URL: 'not a url at all' }); + expect(out).toMatchObject({ ok: false, kind: 'invalid_input' }); + expect(execute).not.toHaveBeenCalled(); + }); + + it('has nothing to try for Etsy before the sign-in at Etsy', async () => { + const execute = jest.fn(); + const { service } = build(execute); + const out = await service.verifyCredentials('etsy', 'org1', { ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' }); + expect(out).toEqual({ ok: null, skipped: 'authorization' }); + expect(execute).not.toHaveBeenCalled(); + }); +}); + +describe('AdaptersService.verifyCredentials on an existing connector', () => { + it('fills a field left empty from what the connector stores, only within the organization', async () => { + const execute = jest.fn().mockResolvedValue({ ok: 1 }); + const findFirst = jest.fn().mockResolvedValue({ envVars: { LEXWARE_API_KEY: 'stored-key' } }); + const service = new AdaptersService( + { connector: { findFirst } } as any, + { reloadConnectorTools: jest.fn() } as any, + { get: (k: string) => (k === 'ENCRYPTION_KEY' ? 'a'.repeat(48) : undefined) } as any, + { executeConnectorCall: execute } as any, + ); + const out = await service.verifyCredentials('lexware-office', 'org1', { LEXWARE_API_KEY: '' }, 'c1'); + expect(out.ok).toBe(true); + expect(findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'c1', organizationId: 'org1' } })); + expect(execute.mock.calls[0][0].envVars).toEqual({ LEXWARE_API_KEY: 'stored-key' }); + }); +}); diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 139b95df..120848c4 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -21,6 +21,14 @@ import { STARTER_PACK } from './starter-pack'; import { ConnectorsService } from '../connectors/connectors.service'; import { classifyToolExecutionError } from '../connectors/connector-error.util'; import { applyResponseTransform } from '../connectors/response-transform.util'; +import { + describeAdapterEnvVars, + EnvVarDescriptor, + needsBrowserAuthorization, + setupKind, + SetupKind, +} from './env-var-meta'; +import { computeSetupState } from '../connectors/connector-setup-status.util'; @Injectable() export class AdaptersService { @@ -42,10 +50,32 @@ export class AdaptersService { listAll(): AdapterMeta[] { return listAdapters() .filter((a) => this.isInstallableHere(a)) - .map((a) => ({ - ...a, - requiredEnvVars: withoutOperatorProvided(a.requiredEnvVars) ?? [], - })); + .map((a) => { + const requiredEnvVars = withoutOperatorProvided(a.requiredEnvVars) ?? []; + const full = getAdapter(a.slug); + return { + ...a, + requiredEnvVars, + setupKind: full ? setupKind({ ...full, requiredEnvVars }) : undefined, + }; + }); + } + + /** + * The adapter as the setup form needs it: the definition, each variable + * described (label, address / credential / setting, secret, help), and what + * setting it up involves. + */ + describe(slug: string): AdapterDefinition & { + envVars: EnvVarDescriptor[]; + setupKind: SetupKind; + } { + const adapter = this.getBySlug(slug); + return { + ...adapter, + envVars: describeAdapterEnvVars(adapter), + setupKind: setupKind(adapter), + }; } getBySlug(slug: string): AdapterDefinition { @@ -112,18 +142,18 @@ export class AdaptersService { return this.configService.get('DEPLOYMENT_MODE') !== 'cloud'; } - async importAdapter( - slug: string, - userId: string, - organizationId: string, - credentials?: Record, - ): Promise<{ - connectorId: string; - toolsCreated: number; - probe: ImportProbeResult | null; - }> { - const adapter = this.getBySlug(slug); - + /** + * The connector an adapter becomes with these credentials, before anything + * is written: credentials trimmed and operator values applied, base-URL + * variables normalised, {{VAR}} resolved in auth, address and headers. + * Shared by the import and by the pre-save verification, so both judge + * exactly the same configuration. + */ + private prepareConnector( + adapter: AdapterDefinition, + input?: Record, + ) { + let credentials = input; // Values the operator provides for everyone (e.g. the cloud's own MOTIS // URL) go in here, and override anything the request carried. credentials = withOperatorProvided(credentials); @@ -168,7 +198,7 @@ export class AdaptersService { // Resolve {{VAR}} placeholders in baseUrl (e.g. weclapp tenant) const resolvedBaseUrl = this.resolveString(adapter.connector.baseUrl, credentials); this.assertBaseUrlFullyResolved( - slug, + adapter.slug, adapter.connector.baseUrl, resolvedBaseUrl, ); @@ -188,6 +218,165 @@ export class AdaptersService { ? (credentials as Record) : null; + return { + credentials, + resolvedAuthConfig, + encryptedAuth, + resolvedBaseUrl, + resolvedHeaders, + envVarsToPersist, + }; + } + + /** + * Try the adapter with these credentials before anything is saved: the same + * preparation as the import, then its probe call against a connector that + * exists only in memory (no row, no token cache written). + * + * - `ok: true`: the API answered; `sample` shows what came back. + * - `ok: false`: a value is missing or malformed (`kind: 'invalid_input'`), + * or the API refused the call (`kind` from the shared classifier, e.g. + * `auth_failed`). + * - `ok: null`: nothing to try yet: the connector needs a sign-in at the + * provider first, or the adapter has no safe read call. + */ + async verifyCredentials( + slug: string, + organizationId: string, + credentials?: Record, + /** Finishing an existing connector: a field left empty uses what it stores (masked secrets come back empty). */ + existingConnectorId?: string, + ): Promise { + const adapter = this.getBySlug(slug); + if (existingConnectorId) { + const stored = await this.prisma.connector.findFirst({ + where: { id: existingConnectorId, organizationId }, + select: { envVars: true }, + }); + const env = (stored?.envVars ?? {}) as Record; + const merged: Record = {}; + for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && v) merged[k] = v; + for (const [k, v] of Object.entries(credentials ?? {})) if (typeof v === 'string' && v.trim()) merged[k] = v; + credentials = merged; + } + // Required fields left empty: say which, before anything else complains + // about the address they would have formed. + const empty = adapter.requiredEnvVars.filter((name) => !credentials?.[name]?.trim()); + const browserTokens = needsBrowserAuthorization(adapter) + ? new Set(describeAdapterEnvVars(adapter).filter((d) => d.advanced).map((d) => d.name)) + : new Set(); + const missingRequired = empty.filter((name) => !browserTokens.has(name)); + if (missingRequired.length > 0) { + return { + ok: false, + kind: 'invalid_input', + missing: missingRequired, + message: `Still empty: ${missingRequired.join(', ')}.`, + }; + } + let prepared: ReturnType; + try { + prepared = this.prepareConnector(adapter, credentials); + } catch (err: any) { + return { ok: false, kind: 'invalid_input', message: String(err?.message ?? err) }; + } + const state = computeSetupState({ + authType: adapter.connector.authType, + authConfig: prepared.resolvedAuthConfig, + baseUrl: prepared.resolvedBaseUrl, + headers: prepared.resolvedHeaders, + envVars: prepared.envVarsToPersist, + config: { adapterSlug: slug }, + }); + if (state.status === 'needs_input') { + return { + ok: false, + kind: 'invalid_input', + missing: state.missing, + message: `Still empty: ${state.missing.join(', ')}.`, + }; + } + if (state.status === 'needs_authorization') return { ok: null, skipped: 'authorization' }; + + const call = pickProbe(adapter); + const tool = call ? adapter.tools.find((t) => t.name === call.toolName) : undefined; + if (!call || !tool) return { ok: null, skipped: 'no_probe' }; + + const transient = { + // No id: token services then keep what they fetch in memory only. + id: '', + name: adapter.connector.name, + type: adapter.connector.type, + baseUrl: prepared.resolvedBaseUrl, + authType: adapter.connector.authType || 'NONE', + authConfig: prepared.encryptedAuth, + headers: prepared.resolvedHeaders, + envVars: prepared.envVarsToPersist, + config: { ...(adapter.connector.config ?? {}), adapterSlug: slug }, + organizationId, + specUrl: null, + } as unknown as Parameters[0]; + + const started = Date.now(); + try { + const raw = await this.connectors.executeConnectorCall( + transient, + tool.endpointMapping as any, + call.params, + call.toolName, + tool.parameters, + ); + const shaped = applyResponseTransform(raw, tool.responseMapping as any).value; + return { + ok: true, + toolName: call.toolName, + durationMs: Date.now() - started, + sample: truncateSample(shaped), + }; + } catch (err: any) { + const status: number | undefined = + typeof err?.status === 'number' + ? err.status + : typeof err?.response?.status === 'number' + ? err.response.status + : undefined; + const upstream = String(err?.message ?? err ?? 'unknown error').slice(0, 400); + const { kind, hint } = classifyToolExecutionError({ + status, + authType: adapter.connector.authType, + message: upstream, + }); + return { + ok: false, + kind, + toolName: call.toolName, + status: status ?? null, + message: `${upstream} ${hint}`.trim(), + }; + } + } + + async importAdapter( + slug: string, + userId: string, + organizationId: string, + credentials?: Record, + ): Promise<{ + connectorId: string; + toolsCreated: number; + probe: ImportProbeResult | null; + }> { + const adapter = this.getBySlug(slug); + const { + credentials: cleaned, + resolvedAuthConfig, + encryptedAuth, + resolvedBaseUrl, + resolvedHeaders, + envVarsToPersist, + } = this.prepareConnector(adapter, credentials); + credentials = cleaned; + const connector = await this.prisma.connector.create({ data: { userId, @@ -455,6 +644,18 @@ export interface StarterPackItem { installed: boolean; } +export type VerifyResult = + | { ok: true; toolName: string; durationMs: number; sample: string } + | { + ok: false; + kind: string; + message: string; + missing?: string[]; + toolName?: string; + status?: number | null; + } + | { ok: null; skipped: 'authorization' | 'no_probe' }; + export type ImportProbeResult = | { ok: true; toolName: string; durationMs: number; sample: string } | { diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index da015732..35786c3d 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -272,6 +272,7 @@ import { wantsODataBuiltins, } from '../connectors/odata/odata-builtins'; import { computeAdapterVersion } from './catalog-fingerprint'; +import type { EnvVarMeta, SetupKind } from './env-var-meta'; export interface AdapterMeta { slug: string; @@ -317,6 +318,12 @@ export interface AdapterMeta { * may use the literal `__TOMORROW__` for a date. Without one, the first * GET tool with no required parameters is used. */ probe?: { tool: string; params?: Record }; + /** How each variable is presented when someone sets the connector up + * (label, secret or not, where to find it). Optional and partial: what is + * left out is derived from the name, see env-var-meta.ts. */ + envVarMeta?: Record; + /** What setting it up involves; filled in by the adapters API. */ + setupKind?: SetupKind; } export interface AdapterDefinition extends AdapterMeta { @@ -718,6 +725,7 @@ export function listAdapters(): AdapterMeta[] { priority: adapter.priority, selfHostOnly: adapter.selfHostOnly, probe: adapter.probe, + envVarMeta: adapter.envVarMeta, })); } diff --git a/packages/backend/src/adapters/de/datev.json b/packages/backend/src/adapters/de/datev.json index deb842ce..5d06df96 100644 --- a/packages/backend/src/adapters/de/datev.json +++ b/packages/backend/src/adapters/de/datev.json @@ -11,6 +11,18 @@ "DATEV_CLIENT_ID", "DATEV_CLIENT_SECRET" ], + "envVarMeta": { + "DATEV_CLIENT_ID": { + "label": "Client ID", + "help": "From your app in the DATEV developer portal.", + "link": "https://developer.datev.de" + }, + "DATEV_CLIENT_SECRET": { + "label": "Client secret", + "help": "The client secret of the same app.", + "link": "https://developer.datev.de" + } + }, "connector": { "name": "DATEV Online APIs", "type": "REST", diff --git a/packages/backend/src/adapters/de/getmyinvoices.json b/packages/backend/src/adapters/de/getmyinvoices.json index 6eaa33d0..efc9c23e 100644 --- a/packages/backend/src/adapters/de/getmyinvoices.json +++ b/packages/backend/src/adapters/de/getmyinvoices.json @@ -9,6 +9,12 @@ "requiredEnvVars": [ "GETMYINVOICES_API_KEY" ], + "envVarMeta": { + "GETMYINVOICES_API_KEY": { + "label": "API key", + "help": "GetMyInvoices web app → top-right menu → API access." + } + }, "connector": { "name": "GetMyInvoices Accounts API v3", "type": "REST", diff --git a/packages/backend/src/adapters/de/lexware-office.json b/packages/backend/src/adapters/de/lexware-office.json index 73f5f6cd..740d42f2 100644 --- a/packages/backend/src/adapters/de/lexware-office.json +++ b/packages/backend/src/adapters/de/lexware-office.json @@ -10,6 +10,12 @@ "requiredEnvVars": [ "LEXWARE_API_KEY" ], + "envVarMeta": { + "LEXWARE_API_KEY": { + "label": "API key", + "help": "In Lexware Office (paid plan): Einstellungen → Erweiterungen → Öffentliche API → create a new access token. It is shown once." + } + }, "connector": { "name": "Lexware Office API v1", "type": "REST", diff --git a/packages/backend/src/adapters/de/sendcloud.json b/packages/backend/src/adapters/de/sendcloud.json index 602740b1..cc8e30de 100644 --- a/packages/backend/src/adapters/de/sendcloud.json +++ b/packages/backend/src/adapters/de/sendcloud.json @@ -8,6 +8,19 @@ "icon": "sendcloud", "docsUrl": "https://api.sendcloud.dev/docs/sendcloud-public-api/", "requiredEnvVars": ["SENDCLOUD_PUBLIC_KEY", "SENDCLOUD_SECRET_KEY"], + "envVarMeta": { + "SENDCLOUD_PUBLIC_KEY": { + "label": "Public key", + "secret": false, + "help": "Sendcloud panel → Settings → Integrations → Sendcloud API: create a key pair and copy the public key.", + "link": "https://panel.sendcloud.sc/#/settings/integrations" + }, + "SENDCLOUD_SECRET_KEY": { + "label": "Secret key", + "help": "The secret key of the same key pair.", + "link": "https://panel.sendcloud.sc/#/settings/integrations" + } + }, "connector": { "name": "Sendcloud", "type": "REST", diff --git a/packages/backend/src/adapters/de/weclapp.json b/packages/backend/src/adapters/de/weclapp.json index 689423c6..3af9cb37 100644 --- a/packages/backend/src/adapters/de/weclapp.json +++ b/packages/backend/src/adapters/de/weclapp.json @@ -10,6 +10,18 @@ "WECLAPP_TENANT", "WECLAPP_API_TOKEN" ], + "envVarMeta": { + "WECLAPP_TENANT": { + "label": "Tenant", + "help": "The first part of your weclapp address: acme for acme.weclapp.com. Not the whole address.", + "example": "acme", + "pattern": "^[A-Za-z0-9-]+$" + }, + "WECLAPP_API_TOKEN": { + "label": "API token", + "help": "In weclapp: your user menu → My settings → API → API token." + } + }, "connector": { "name": "weclapp ERP", "type": "REST", diff --git a/packages/backend/src/adapters/env-var-meta.spec.ts b/packages/backend/src/adapters/env-var-meta.spec.ts new file mode 100644 index 00000000..a6005e1c --- /dev/null +++ b/packages/backend/src/adapters/env-var-meta.spec.ts @@ -0,0 +1,51 @@ +import { getAdapter, listAdapters } from './catalog'; +import { describeAdapterEnvVars, labelFromName, setupKind } from './env-var-meta'; + +const byName = (slug: string) => + Object.fromEntries(describeAdapterEnvVars(getAdapter(slug)!).map((d) => [d.name, d])); + +describe('describeAdapterEnvVars', () => { + it('derives a label without the adapter prefix', () => { + expect(labelFromName('ETSY_CLIENT_ID', 'etsy')).toBe('Client ID'); + expect(labelFromName('WECLAPP_API_TOKEN', 'weclapp')).toBe('API Token'); + expect(labelFromName('SAP_HANA_HOST', 'sap-s4hana-hana')).toBe('HANA Host'); + }); + + it('marks a variable used in the address as an address, and keys as secret', () => { + const lexware = byName('lexware-office'); + expect(lexware.LEXWARE_API_KEY).toMatchObject({ kind: 'credential', secret: true, required: true }); + const weclapp = byName('weclapp'); + expect(weclapp.WECLAPP_TENANT).toMatchObject({ kind: 'address', secret: false, pattern: '^[A-Za-z0-9-]+$' }); + }); + + it('lets the adapter correct what the name suggests', () => { + // The bot token sits in the URL path, but it is a credential. + expect(byName('telegram-bot').TELEGRAM_BOT_TOKEN).toMatchObject({ kind: 'credential', secret: true, label: 'Bot token' }); + expect(byName('sendcloud').SENDCLOUD_PUBLIC_KEY.secret).toBe(false); + }); + + it('folds away the token an authorization fills in', () => { + const etsy = byName('etsy'); + expect(etsy.ETSY_REFRESH_TOKEN).toMatchObject({ advanced: true, required: false }); + expect(etsy.ETSY_CLIENT_ID.advanced).toBeUndefined(); + }); + + it('describes every variable of every adapter without throwing', () => { + for (const meta of listAdapters()) { + const adapter = getAdapter(meta.slug)!; + const described = describeAdapterEnvVars(adapter); + expect(described.map((d) => d.name).sort()).toEqual( + [...new Set([...adapter.requiredEnvVars, ...(adapter.optionalEnvVars ?? [])])].sort(), + ); + for (const d of described) expect(d.label.length).toBeGreaterThan(0); + } + }); +}); + +describe('setupKind', () => { + it('tells keyless, credential and browser-authorization adapters apart', () => { + expect(setupKind(getAdapter('openplz')!)).toBe('none'); + expect(setupKind(getAdapter('lexware-office')!)).toBe('credentials'); + expect(setupKind(getAdapter('etsy')!)).toBe('oauth_browser'); + }); +}); diff --git a/packages/backend/src/adapters/env-var-meta.ts b/packages/backend/src/adapters/env-var-meta.ts new file mode 100644 index 00000000..cde302d1 --- /dev/null +++ b/packages/backend/src/adapters/env-var-meta.ts @@ -0,0 +1,117 @@ +import { isSecretName } from '../connectors/connector-secrets.util'; +import type { AdapterDefinition } from './catalog'; + +/** + * How a connector's variables are presented when someone sets it up: in the + * guided install, and to a model setting it up through MCP. + * + * The catalog only lists variable names (requiredEnvVars / optionalEnvVars). + * An adapter may add `envVarMeta` to describe them; whatever it leaves out is + * derived here, so all 265 adapters get a usable form without hand edits. + */ +export type EnvVarKind = 'address' | 'credential' | 'setting'; + +export interface EnvVarMeta { + label?: string; + kind?: EnvVarKind; + /** Never shown back, never accepted through a chat. */ + secret?: boolean; + /** Where to find the value, in a sentence. */ + help?: string; + example?: string; + /** Regular expression the value must match (validated in the form). */ + pattern?: string; + /** Page of the provider where the value is created or shown. */ + link?: string; + /** Rarely needed: shown collapsed (e.g. a refresh token the authorization fills in). */ + advanced?: boolean; +} + +export interface EnvVarDescriptor extends Required> { + name: string; + required: boolean; + help?: string; + example?: string; + pattern?: string; + link?: string; + advanced?: boolean; +} + +/** + * What setting the connector up involves: + * - `none`: nothing to enter; + * - `credentials`: values to type or paste; + * - `oauth_browser`: an app's client settings, then a sign-in at the provider. + */ +export type SetupKind = 'none' | 'credentials' | 'oauth_browser'; + +const UPPER_WORDS = new Set(['ID', 'URL', 'API', 'DB', 'IP', 'SSL', 'TLS', 'WABA', 'SAP', 'HANA', 'JWT', 'OAUTH']); + +/** `ETSY_CLIENT_ID` → "Client ID" (the adapter's own prefix is dropped). */ +export function labelFromName(name: string, slug?: string): string { + let words = name.split(/[_\s]+/).filter(Boolean); + const prefix = (slug ?? '').toUpperCase().split(/[-_]/)[0]; + if (words.length > 1 && prefix && words[0] === prefix) words = words.slice(1); + return words + .map((w) => (UPPER_WORDS.has(w) ? w : w.charAt(0) + w.slice(1).toLowerCase())) + .join(' '); +} + +/** True when the adapter needs a sign-in at the provider (authorization code flow). */ +export function needsBrowserAuthorization(adapter: Pick): boolean { + const auth = (adapter.connector.authConfig ?? {}) as Record; + return ( + adapter.connector.authType === 'OAUTH2' && + !!auth.authorizationUrl && + String(auth.grant ?? '') !== 'client_credentials' + ); +} + +export function setupKind(adapter: Pick): SetupKind { + if (needsBrowserAuthorization(adapter)) return 'oauth_browser'; + return adapter.requiredEnvVars.length === 0 ? 'none' : 'credentials'; +} + +/** Names an authorization fills in: `{{VAR}}` used as the OAuth refresh/access token. */ +function tokenVariables(adapter: Pick): Set { + const auth = (adapter.connector.authConfig ?? {}) as Record; + const out = new Set(); + for (const key of ['refreshToken', 'accessToken']) { + const m = /^\{\{\s*([^{}\s]+)\s*\}\}$/.exec(String(auth[key] ?? '')); + if (m) out.add(m[1]); + } + return out; +} + +export function describeAdapterEnvVars( + adapter: Pick< + AdapterDefinition, + 'slug' | 'connector' | 'requiredEnvVars' | 'optionalEnvVars' + > & { envVarMeta?: Record }, +): EnvVarDescriptor[] { + const browser = needsBrowserAuthorization(adapter); + const tokens = tokenVariables(adapter); + const baseUrl = adapter.connector.baseUrl ?? ''; + const names: Array<[string, boolean]> = [ + ...adapter.requiredEnvVars.map((n): [string, boolean] => [n, true]), + ...(adapter.optionalEnvVars ?? []) + .filter((n) => !adapter.requiredEnvVars.includes(n)) + .map((n): [string, boolean] => [n, false]), + ]; + return names.map(([name, required]) => { + const inAddress = new RegExp(`\\{\\{\\s*${name}\\s*\\}\\}`).test(baseUrl); + const secret = isSecretName(name); + const derived: EnvVarDescriptor = { + name, + required, + label: labelFromName(name, adapter.slug), + kind: inAddress ? 'address' : secret ? 'credential' : 'setting', + secret, + // With a browser sign-in, the token variables are filled by the + // authorization; asking for them up front only confuses. + ...(browser && tokens.has(name) ? { advanced: true } : {}), + }; + const own = adapter.envVarMeta?.[name] ?? {}; + return { ...derived, ...own, name, required } as EnvVarDescriptor; + }); +} diff --git a/packages/backend/src/adapters/intl/ebay-sell.json b/packages/backend/src/adapters/intl/ebay-sell.json index 03c44dfb..67a0b299 100644 --- a/packages/backend/src/adapters/intl/ebay-sell.json +++ b/packages/backend/src/adapters/intl/ebay-sell.json @@ -13,6 +13,28 @@ "EBAY_REFRESH_TOKEN", "EBAY_MARKETPLACE_ID" ], + "envVarMeta": { + "EBAY_CLIENT_ID": { + "label": "App ID (client ID)", + "help": "developer.ebay.com → your production keyset: the App ID.", + "link": "https://developer.ebay.com/my/keys" + }, + "EBAY_CLIENT_SECRET": { + "label": "Cert ID (client secret)", + "help": "The Cert ID of the same keyset.", + "link": "https://developer.ebay.com/my/keys" + }, + "EBAY_REFRESH_TOKEN": { + "label": "User refresh token", + "help": "Run eBay's authorization once for the seller account (eBay's user token tool) and paste the refresh token. It lasts about 18 months." + }, + "EBAY_MARKETPLACE_ID": { + "label": "Marketplace", + "help": "The eBay site you sell on.", + "example": "EBAY_DE", + "pattern": "^EBAY_[A-Z_]+$" + } + }, "connector": { "name": "eBay Sell APIs", "type": "REST", diff --git a/packages/backend/src/adapters/intl/etsy.json b/packages/backend/src/adapters/intl/etsy.json index 9178ecf8..ba74e9a8 100644 --- a/packages/backend/src/adapters/intl/etsy.json +++ b/packages/backend/src/adapters/intl/etsy.json @@ -14,6 +14,24 @@ "optionalEnvVars": [ "ETSY_REFRESH_TOKEN" ], + "envVarMeta": { + "ETSY_CLIENT_ID": { + "label": "Keystring", + "kind": "credential", + "help": "Etsy developers → Your apps → your app: the Keystring.", + "link": "https://www.etsy.com/developers/your-apps" + }, + "ETSY_CLIENT_SECRET": { + "label": "Shared secret", + "help": "Same page, the Shared secret next to the Keystring.", + "link": "https://www.etsy.com/developers/your-apps" + }, + "ETSY_REFRESH_TOKEN": { + "label": "Refresh token", + "help": "Leave empty: the authorization with Etsy fills it in.", + "advanced": true + } + }, "connector": { "name": "Etsy Open API v3", "type": "REST", diff --git a/packages/backend/src/adapters/intl/google-ads.json b/packages/backend/src/adapters/intl/google-ads.json index a1167b43..abf25991 100644 --- a/packages/backend/src/adapters/intl/google-ads.json +++ b/packages/backend/src/adapters/intl/google-ads.json @@ -14,6 +14,25 @@ "optionalEnvVars": [ "GOOGLE_ADS_LOGIN_CUSTOMER_ID" ], + "envVarMeta": { + "GOOGLE_CLIENT_ID": { + "label": "OAuth client ID", + "help": "Google Cloud console → APIs & Services → Credentials → your OAuth client of type Web application.", + "example": "1234567890-abc.apps.googleusercontent.com", + "pattern": "\\.apps\\.googleusercontent\\.com$", + "link": "https://console.cloud.google.com/apis/credentials" + }, + "GOOGLE_CLIENT_SECRET": { + "label": "OAuth client secret", + "help": "The client secret of the same OAuth client.", + "link": "https://console.cloud.google.com/apis/credentials" + }, + "GOOGLE_ADS_LOGIN_CUSTOMER_ID": { + "label": "Manager account ID", + "help": "Only if you reach client accounts through a manager account: its 10-digit id, without dashes. Leave empty otherwise.", + "pattern": "^[0-9]{10}$" + } + }, "connector": { "name": "Google Ads", "type": "REST", diff --git a/packages/backend/src/adapters/intl/google-search-console.json b/packages/backend/src/adapters/intl/google-search-console.json index b15ceaec..0c5695af 100644 --- a/packages/backend/src/adapters/intl/google-search-console.json +++ b/packages/backend/src/adapters/intl/google-search-console.json @@ -11,6 +11,20 @@ "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET" ], + "envVarMeta": { + "GOOGLE_CLIENT_ID": { + "label": "OAuth client ID", + "help": "Google Cloud console → APIs & Services → Credentials → your OAuth client of type Web application.", + "example": "1234567890-abc.apps.googleusercontent.com", + "pattern": "\\.apps\\.googleusercontent\\.com$", + "link": "https://console.cloud.google.com/apis/credentials" + }, + "GOOGLE_CLIENT_SECRET": { + "label": "OAuth client secret", + "help": "The client secret of the same OAuth client.", + "link": "https://console.cloud.google.com/apis/credentials" + } + }, "connector": { "name": "Google Search Console", "type": "REST", diff --git a/packages/backend/src/adapters/intl/jev.json b/packages/backend/src/adapters/intl/jev.json index 45bb1c0c..df6f0188 100644 --- a/packages/backend/src/adapters/intl/jev.json +++ b/packages/backend/src/adapters/intl/jev.json @@ -8,6 +8,13 @@ "icon": "jev", "docsUrl": "https://docs.typesafe.ai/api", "requiredEnvVars": ["TYPESAFE_API_KEY"], + "envVarMeta": { + "TYPESAFE_API_KEY": { + "label": "TypeSafe API key", + "help": "Create an API key in the TypeSafe console.", + "link": "https://console.typesafe.ai" + } + }, "probe": { "tool": "jev_list_models", "params": {} diff --git a/packages/backend/src/adapters/intl/odoo.json b/packages/backend/src/adapters/intl/odoo.json index 0cb3a570..c3ad1367 100644 --- a/packages/backend/src/adapters/intl/odoo.json +++ b/packages/backend/src/adapters/intl/odoo.json @@ -12,6 +12,23 @@ "ODOO_DB", "ODOO_API_KEY" ], + "envVarMeta": { + "ODOO_URL": { + "label": "Odoo address", + "help": "The address you open Odoo at, without a trailing slash.", + "example": "https://mycompany.odoo.com", + "pattern": "^https?://[^\\s/]+(/[^\\s]*)?[^/\\s]$" + }, + "ODOO_DB": { + "label": "Database name", + "help": "On Odoo Online this is usually the subdomain: mycompany for mycompany.odoo.com.", + "example": "mycompany" + }, + "ODOO_API_KEY": { + "label": "API key", + "help": "In Odoo: your avatar → My Profile → Account Security → New API key. Odoo shows it once." + } + }, "connector": { "name": "Odoo JSON-2 API", "type": "REST", diff --git a/packages/backend/src/adapters/intl/pinterest.json b/packages/backend/src/adapters/intl/pinterest.json index bbdeb163..6ec76633 100644 --- a/packages/backend/src/adapters/intl/pinterest.json +++ b/packages/backend/src/adapters/intl/pinterest.json @@ -9,6 +9,23 @@ "docsUrl": "https://developers.pinterest.com/docs/api/v5/", "requiredEnvVars": ["PINTEREST_CLIENT_ID", "PINTEREST_CLIENT_SECRET"], "optionalEnvVars": ["PINTEREST_REFRESH_TOKEN"], + "envVarMeta": { + "PINTEREST_CLIENT_ID": { + "label": "App ID", + "help": "Pinterest developers → My apps → your app: the App ID.", + "link": "https://developers.pinterest.com/apps/" + }, + "PINTEREST_CLIENT_SECRET": { + "label": "App secret key", + "help": "Same page, the App secret key.", + "link": "https://developers.pinterest.com/apps/" + }, + "PINTEREST_REFRESH_TOKEN": { + "label": "Refresh token", + "help": "Leave empty: the authorization with Pinterest fills it in.", + "advanced": true + } + }, "connector": { "name": "Pinterest Business API v5", "type": "REST", diff --git a/packages/backend/src/adapters/intl/telegram-bot.json b/packages/backend/src/adapters/intl/telegram-bot.json index 2e5d2fcd..7c9afd68 100644 --- a/packages/backend/src/adapters/intl/telegram-bot.json +++ b/packages/backend/src/adapters/intl/telegram-bot.json @@ -8,6 +8,17 @@ "icon": "telegram", "docsUrl": "https://core.telegram.org/bots/api", "requiredEnvVars": ["TELEGRAM_BOT_TOKEN"], + "envVarMeta": { + "TELEGRAM_BOT_TOKEN": { + "label": "Bot token", + "kind": "credential", + "secret": true, + "help": "In Telegram, open @BotFather, send /newbot, and copy the token it replies with.", + "example": "123456789:AAH...", + "pattern": "^[0-9]+:[A-Za-z0-9_-]{30,}$", + "link": "https://t.me/BotFather" + } + }, "connector": { "name": "Telegram Bot API", "type": "REST", diff --git a/packages/backend/src/adapters/intl/whatsapp-business.json b/packages/backend/src/adapters/intl/whatsapp-business.json index fc8ab549..326ec7dd 100644 --- a/packages/backend/src/adapters/intl/whatsapp-business.json +++ b/packages/backend/src/adapters/intl/whatsapp-business.json @@ -8,6 +8,18 @@ "icon": "whatsapp", "docsUrl": "https://developers.facebook.com/docs/whatsapp/cloud-api", "requiredEnvVars": ["WHATSAPP_ACCESS_TOKEN", "WHATSAPP_BUSINESS_ACCOUNT_ID"], + "envVarMeta": { + "WHATSAPP_ACCESS_TOKEN": { + "label": "Access token", + "help": "Meta Business settings → System users: generate a permanent token with whatsapp_business_messaging and whatsapp_business_management. The 24-hour token from API Setup works for a first try.", + "link": "https://business.facebook.com/settings/system-users" + }, + "WHATSAPP_BUSINESS_ACCOUNT_ID": { + "label": "WhatsApp Business Account ID", + "help": "developers.facebook.com → your app → WhatsApp → API Setup: the WhatsApp Business Account ID (WABA ID).", + "pattern": "^[0-9]+$" + } + }, "connector": { "name": "WhatsApp Business Cloud API", "type": "REST", diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index b35b34bb..b42d3b27 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -34,6 +34,18 @@ export const ProductEvents = { STARTER_PACK_VIEWED: 'starter_pack_viewed', /** Installed connectors from the starter pack. metadata.adapterSlug = comma list. */ STARTER_PACK_INSTALLED: 'starter_pack_installed', + /** + * The guided connector setup (/connectors/setup/). metadata.adapterSlug + * on all of them; read in order they answer where a setup is abandoned: + * opened, credentials refused (metadata.kind = auth_failed, invalid_input…), + * sent to the provider's sign-in, finished, or kept as a draft / unverified. + */ + SETUP_STARTED: 'setup_started', + SETUP_VERIFY_FAILED: 'setup_verify_failed', + OAUTH_STARTED: 'oauth_started', + SETUP_COMPLETED: 'setup_completed', + SETUP_SAVED_DRAFT: 'setup_saved_draft', + SETUP_SAVED_UNVERIFIED: 'setup_saved_unverified', /** * A new cloud account was created; metadata = the first and last touch the * visitor arrived through (see signup-attribution.ts). Written by the diff --git a/packages/backend/src/connectors/connector-setup-status.util.spec.ts b/packages/backend/src/connectors/connector-setup-status.util.spec.ts new file mode 100644 index 00000000..cf0455f5 --- /dev/null +++ b/packages/backend/src/connectors/connector-setup-status.util.spec.ts @@ -0,0 +1,113 @@ +import { computeSetupState } from './connector-setup-status.util'; + +const etsyAuth = { + grant: 'refresh_token', + authorizationUrl: 'https://www.etsy.com/oauth/connect', + tokenUrl: 'https://api.etsy.com/v3/public/oauth/token', + clientId: '{{ETSY_CLIENT_ID}}', + clientSecret: '{{ETSY_CLIENT_SECRET}}', + refreshToken: '{{ETSY_REFRESH_TOKEN}}', + extraHeaders: { 'x-api-key': '{{ETSY_CLIENT_ID}}:{{ETSY_CLIENT_SECRET}}' }, +}; + +describe('computeSetupState', () => { + it('is ready for a keyless connector', () => { + expect(computeSetupState({ authType: 'NONE', baseUrl: 'https://openplzapi.org/de' })).toEqual({ + status: 'ready', + missing: [], + }); + }); + + it('needs input when a credential was never filled in', () => { + expect( + computeSetupState({ authType: 'BEARER_TOKEN', authConfig: { token: '{{LEXWARE_API_KEY}}' } }), + ).toEqual({ status: 'needs_input', missing: ['LEXWARE_API_KEY'] }); + }); + + it('counts a variable set later in the env vars', () => { + expect( + computeSetupState({ + authType: 'BEARER_TOKEN', + authConfig: JSON.stringify({ token: '{{LEXWARE_API_KEY}}' }), + envVars: { LEXWARE_API_KEY: 'k' }, + }).status, + ).toBe('ready'); + }); + + it('needs input for an address variable (weclapp tenant)', () => { + expect( + computeSetupState({ + authType: 'API_KEY', + baseUrl: 'https://{{WECLAPP_TENANT}}.weclapp.com/webapp/api/v2', + authConfig: { apiKey: 'k', headerName: 'AuthenticationToken' }, + }), + ).toEqual({ status: 'needs_input', missing: ['WECLAPP_TENANT'] }); + }); + + it('asks for the Etsy app keys first, not for the refresh token', () => { + expect(computeSetupState({ authType: 'OAUTH2', authConfig: etsyAuth })).toEqual({ + status: 'needs_input', + missing: ['ETSY_CLIENT_ID', 'ETSY_CLIENT_SECRET'], + }); + }); + + it('needs authorization once the Etsy keys are in but nobody authorized', () => { + expect( + computeSetupState({ + authType: 'OAUTH2', + authConfig: etsyAuth, + envVars: { ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' }, + }), + ).toEqual({ status: 'needs_authorization', missing: [] }); + }); + + it('is ready after the authorization stored a refresh token', () => { + expect( + computeSetupState({ + authType: 'OAUTH2', + authConfig: { ...etsyAuth, refreshToken: 'rt-123', accessToken: 'at' }, + envVars: { ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' }, + }).status, + ).toBe('ready'); + }); + + it('finds the authorization URL in the catalog for an older install', () => { + const { authorizationUrl: _omit, ...rowWithoutUrl } = etsyAuth; + expect( + computeSetupState({ + authType: 'OAUTH2', + authConfig: rowWithoutUrl, + envVars: { ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' }, + config: { adapterSlug: 'etsy' }, + }).status, + ).toBe('needs_authorization'); + }); + + it('treats client_credentials as ready once the keys are there', () => { + expect( + computeSetupState({ + authType: 'OAUTH2', + authConfig: { grant: 'client_credentials', tokenUrl: 't', clientId: 'id', clientSecret: 's' }, + }).status, + ).toBe('ready'); + }); + + it('asks for a pasted refresh token when there is no browser flow', () => { + expect( + computeSetupState({ + authType: 'OAUTH2', + authConfig: { tokenUrl: 't', clientId: 'id', clientSecret: 's', refreshToken: '{{DROPBOX_REFRESH_TOKEN}}' }, + }), + ).toEqual({ status: 'needs_input', missing: ['DROPBOX_REFRESH_TOKEN'] }); + }); + + it('ignores caller-context placeholders, which resolve at call time', () => { + expect( + computeSetupState({ + authType: 'API_KEY', + authConfig: { apiKey: 'k' }, + headers: { 'X-User': '{{amcp.user.email}}' }, + }).status, + ).toBe('ready'); + }); +}); diff --git a/packages/backend/src/connectors/connector-setup-status.util.ts b/packages/backend/src/connectors/connector-setup-status.util.ts new file mode 100644 index 00000000..f127ca72 --- /dev/null +++ b/packages/backend/src/connectors/connector-setup-status.util.ts @@ -0,0 +1,110 @@ +import { getAdapter } from '../adapters/catalog'; +import { interpolateDeep, interpolateString } from '../common/env-interpolation.util'; +import { CALLER_CONTEXT_PREFIX } from '../common/caller-context.util'; +import { findUnresolvedPlaceholders } from '../common/unresolved-placeholders.util'; + +/** + * Whether a connector can serve calls yet. + * + * - `ready`: every variable it references has a value, and an OAuth connector + * that needs a browser authorization has one. + * - `needs_input`: a variable is still empty (a credential, a tenant, an + * address). Every call would fail before reaching the API. + * - `needs_authorization`: the client settings are there but nobody has + * completed "Authorize with Provider" yet. + * + * Connectors that are not ready are not listed on MCP: a model that sees their + * tools calls them and gets an error the user cannot fix from the chat. + */ +export type SetupStatus = 'ready' | 'needs_input' | 'needs_authorization'; + +export interface SetupState { + status: SetupStatus; + /** Variables without a value, when status is needs_input. */ + missing: string[]; +} + +export interface SetupStatusInput { + authType: string; + /** Decrypted auth config: object, or the JSON string the registry keeps. */ + authConfig?: unknown; + baseUrl?: string | null; + headers?: unknown; + envVars?: unknown; + config?: unknown; +} + +/** Auth config fields that an authorization fills in, not the user. */ +const TOKEN_FIELDS = new Set(['accessToken', 'refreshToken', 'expiresAt', 'expiresIn']); + +function parseAuthConfig(value: unknown): Record { + if (!value) return {}; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } + return typeof value === 'object' ? (value as Record) : {}; +} + +function asStringMap(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (v !== undefined && v !== null && String(v) !== '') out[k] = String(v); + } + return out; +} + +function hasRealValue(value: unknown): boolean { + if (value === undefined || value === null) return false; + const text = String(value).trim(); + return text !== '' && findUnresolvedPlaceholders(text).length === 0; +} + +/** The authorization URL a browser OAuth flow would use, row first, then the catalog. */ +function browserAuthorizationUrl( + authConfig: Record, + config: unknown, +): string | undefined { + if (authConfig.authorizationUrl) return String(authConfig.authorizationUrl); + const slug = (config as { adapterSlug?: unknown } | null)?.adapterSlug; + if (typeof slug !== 'string') return undefined; + const catalog = getAdapter(slug)?.connector.authConfig as + | Record + | undefined; + return catalog?.authorizationUrl ? String(catalog.authorizationUrl) : undefined; +} + +export function computeSetupState(input: SetupStatusInput): SetupState { + const envVars = asStringMap(input.envVars); + const options = { reservedPrefix: CALLER_CONTEXT_PREFIX }; + const authConfig = interpolateDeep(parseAuthConfig(input.authConfig), envVars, options); + const isBrowserOAuth = + input.authType === 'OAUTH2' && + String(authConfig.grant ?? '') !== 'client_credentials' && + !!browserAuthorizationUrl(authConfig, input.config); + + // Tokens of a browser OAuth connector are written by the authorization; a + // placeholder there means "not authorized yet", not "fill in this field". + const checkedAuth = isBrowserOAuth + ? Object.fromEntries(Object.entries(authConfig).filter(([k]) => !TOKEN_FIELDS.has(k))) + : authConfig; + + const missing = findUnresolvedPlaceholders({ + baseUrl: input.baseUrl ? interpolateString(input.baseUrl, envVars, options) : undefined, + headers: input.headers ? interpolateDeep(input.headers, envVars, options) : undefined, + authConfig: checkedAuth, + }) + .filter((name) => !name.startsWith(CALLER_CONTEXT_PREFIX)) + .sort(); + if (missing.length > 0) return { status: 'needs_input', missing }; + + if (isBrowserOAuth && !hasRealValue(authConfig.refreshToken) && !hasRealValue(authConfig.accessToken)) { + return { status: 'needs_authorization', missing: [] }; + } + return { status: 'ready', missing: [] }; +} diff --git a/packages/backend/src/connectors/connectors.controller.ts b/packages/backend/src/connectors/connectors.controller.ts index 771af71e..70cc0a22 100644 --- a/packages/backend/src/connectors/connectors.controller.ts +++ b/packages/backend/src/connectors/connectors.controller.ts @@ -71,6 +71,7 @@ import { } from './odata/odata-builtins'; import { parseODataTools } from './parsers/odata.parser'; import { normalizeSettings } from './engines/odata.engine'; +import { computeSetupState } from './connector-setup-status.util'; class CreateConnectorDto { @ApiProperty({ @@ -598,7 +599,40 @@ export class ConnectorsController { limit: pagination.limit, offset: pagination.offset, }); - return rows.map((c) => toPublicConnector(c)); + return rows.map((c) => this.withSetupState(c)); + } + + /** + * The public view of a connector plus whether it can serve calls yet + * (`setupStatus`, and the variables still empty). The dashboard shows it as + * a badge; MCP does not list connectors that are not ready. + */ + private withSetupState[0] & { + authType: string; + baseUrl: string; + config?: unknown; + }>(connector: C) { + let authConfig: unknown = undefined; + if (typeof connector.authConfig === 'string' && connector.authConfig) { + try { + authConfig = JSON.parse(decrypt(connector.authConfig, this.encryptionKey)); + } catch { + authConfig = undefined; + } + } + const setup = computeSetupState({ + authType: connector.authType, + authConfig, + baseUrl: connector.baseUrl, + headers: connector.headers, + envVars: connector.envVars, + config: connector.config, + }); + return { + ...toPublicConnector(connector), + setupStatus: setup.status, + missingVariables: setup.missing, + }; } @Post() @@ -847,7 +881,7 @@ export class ConnectorsController { async findOne(@Req() req: any, @Param('id') id: string) { const connector = await this.connectorsService.findById(id); this.assertOrgMatch(connector, req); - return toPublicConnector(connector); + return this.withSetupState(connector); } @Put(':id') diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts index b9f6c5c0..c53c4551 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.spec.ts @@ -532,3 +532,30 @@ describe('DynamicMcpTools — a base URL variable without https://', () => { expect(text).not.toMatch(/SSRF/); }); }); + +describe('DynamicMcpTools — connector not authorized yet', () => { + it('says to authorize, with the link, instead of naming the refresh-token variable', async () => { + const saved = process.env.FRONTEND_URL; + process.env.FRONTEND_URL = 'https://cloud.example.com'; + try { + const tool = makeTool(); + tool.setupStatus = 'needs_authorization'; + tool.connectorConfig = { + baseUrl: 'https://openapi.etsy.com/v3/application', + authType: 'OAUTH2', + authConfig: JSON.stringify({ refreshToken: '{{ETSY_REFRESH_TOKEN}}' }), + }; + const { executor, restEngine } = build(tool); + const res = await executor.executeTool('list_devices', {}); + expect(res.isError).toBe(true); + const text = res.content[0].text; + expect(text).toContain('has not been authorized yet'); + expect(text).toContain('https://cloud.example.com/connectors/conn-1'); + expect(text).not.toContain('ETSY_REFRESH_TOKEN'); + expect(restEngine.execute).not.toHaveBeenCalled(); + } finally { + if (saved === undefined) delete process.env.FRONTEND_URL; + else process.env.FRONTEND_URL = saved; + } + }); +}); diff --git a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts index 6cffc921..800499cb 100644 --- a/packages/backend/src/mcp-server/dynamic-mcp-tools.ts +++ b/packages/backend/src/mcp-server/dynamic-mcp-tools.ts @@ -341,6 +341,17 @@ export class DynamicMcpTools { ...(proxyUrl ? { proxyUrl } : {}), }; + // An OAuth connector nobody has authorized carries a placeholder where + // the refresh token goes. Say what to do about it, rather than naming + // a variable the user is not supposed to fill in by hand. + if (tool.setupStatus === 'needs_authorization') { + const page = connectorPageUrl(tool.connectorId); + throw new Error( + 'OAuth2: this connector has not been authorized yet. No request was sent to the API. ' + + `Open the connector in AnythingMCP${page ? ` (${page})` : ''} and click Authorize with Provider.`, + ); + } + // Nothing left to substitute it with: fail here, with the variable names, // rather than let the vendor answer something that reads like our bug. assertNoUnresolvedPlaceholders( diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index 3e1bacf8..fc4603b4 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -26,7 +26,7 @@ import { toolVisibilityRole } from './mcp-server.service'; import { McpServersService } from '../mcp-servers/mcp-servers.service'; import { McpSessionManager } from '../mcp-servers/mcp-session.manager'; import { processGauges } from '../common/process-vitals'; -import { ToolRegistry, RegisteredTool } from './tool-registry'; +import { ToolRegistry, RegisteredTool, isListable } from './tool-registry'; import { McpConnectionGrantService, ResolvedGrant, @@ -305,6 +305,7 @@ export class McpEndpointController { return result; }, connectors: (ids) => this.mcpServersService.getConnectorSummaries(ids), + connectorUrl: (id) => `${dashboardBase}/connectors/${encodeURIComponent(id)}`, guide: (ids, wholeScope) => this.mcpServersService.getSharedGuide({ connectorIds: ids, @@ -559,6 +560,7 @@ export class McpEndpointController { const seen = new Set(); const listed: Array> = []; for (const tool of tools) { + if (!isListable(tool)) continue; // Two reachable connectors may expose the same name (a grant spanning // two configs of one provider). One entry per name, like the per-server // endpoint; the call path resolves within the same connector scope. @@ -1296,6 +1298,8 @@ export class McpEndpointController { const registeredNames = new Set(); for (const tool of serverTools) { + // A connector that is not set up yet would only produce errors. + if (!isListable(tool)) continue; // Skip tools not allowed by role if (allowedToolIds !== null && !allowedToolIds.includes(tool.id)) continue; // Dedupe by tool name. Two connectors can expose the same name (same diff --git a/packages/backend/src/mcp-server/mcp-server.service.ts b/packages/backend/src/mcp-server/mcp-server.service.ts index 3bdad78b..af54f10d 100644 --- a/packages/backend/src/mcp-server/mcp-server.service.ts +++ b/packages/backend/src/mcp-server/mcp-server.service.ts @@ -17,6 +17,7 @@ import { ToolAnnotations, deriveToolAnnotations, } from './tool-annotations'; +import { computeSetupState } from '../connectors/connector-setup-status.util'; /** * The synthetic role that makes one tool visible in the GLOBAL `/mcp` @@ -131,6 +132,17 @@ export class McpServerService implements OnModuleInit { private registerConnectorTools( connector: Connector & { tools: McpTool[] }, ): void { + const authConfig = this.decryptAuthConfig(connector.authConfig); + // Computed once per connector: a connector that cannot serve calls yet is + // registered (calls by name still get a precise error) but not listed. + const setupStatus = computeSetupState({ + authType: connector.authType, + authConfig, + baseUrl: connector.baseUrl, + headers: connector.headers, + envVars: connector.envVars, + config: connector.config, + }).status; for (const tool of connector.tools) { const toolDef = { id: tool.id, @@ -144,12 +156,13 @@ export class McpServerService implements OnModuleInit { connectorConfig: { baseUrl: connector.baseUrl, authType: connector.authType, - authConfig: this.decryptAuthConfig(connector.authConfig), + authConfig, headers: connector.headers as Record | undefined, envVars: connector.envVars as Record | undefined, specUrl: connector.specUrl ?? undefined, config: connector.config as Record | undefined, }, + setupStatus, endpointMapping: tool.endpointMapping as any, responseMapping: tool.responseMapping as | Record diff --git a/packages/backend/src/mcp-server/shared-toolset.spec.ts b/packages/backend/src/mcp-server/shared-toolset.spec.ts index c10c0ae1..333242ae 100644 --- a/packages/backend/src/mcp-server/shared-toolset.spec.ts +++ b/packages/backend/src/mcp-server/shared-toolset.spec.ts @@ -65,6 +65,7 @@ function makeDeps(overrides: Partial = {}) { ), guide: jest.fn(async () => '## Acme CRM\nUse emails in lower case.'), kgLookup: jest.fn(async () => ({ entities: [] })), + connectorUrl: (id: string) => `https://cloud.example.com/connectors/${id}`, configuration: jest.fn(async () => ({ dashboardUrl: 'https://cloud.example.com/connectors', servers: [{ name: 'Default', url: 'https://cloud.example.com/mcp/srv-1' }], @@ -308,3 +309,44 @@ describe('shared /mcp tool set', () => { } }); }); + +describe('connectors that are not set up yet', () => { + const ETSY_PENDING = tool({ + name: 'etsy_get_shop', + connectorId: 'c-etsy', + setupStatus: 'needs_authorization', + }); + + it('are not offered to the model, but named with where to finish them', async () => { + const { client } = await connect([CRM_READ, ETSY_PENDING]); + const list = await call(client, 'anythingmcp_list_connectors'); + expect(list.body.connectors.map((c: any) => c.id)).toEqual(['c-crm']); + expect(list.body.needsSetup).toEqual([ + { + name: 'c-etsy', + status: 'needs_authorization', + whatIsMissing: 'it has to be authorized with the provider', + finishSetupUrl: 'https://cloud.example.com/connectors/c-etsy', + }, + ]); + const search = await call(client, 'anythingmcp_search_tools', { query: 'etsy shop' }); + expect(JSON.stringify(search.body)).not.toContain('etsy_get_shop'); + }); + + it('answer a call by name with the link instead of running it', async () => { + const { client, deps } = await connect([CRM_READ, ETSY_PENDING]); + const out = await call(client, 'anythingmcp_run_read_tool', { tool: 'etsy_get_shop', arguments: {} }); + expect(out.isError).toBe(true); + expect(out.body.error).toContain('not set up yet'); + expect(out.body.error).toContain('https://cloud.example.com/connectors/c-etsy'); + expect(deps.execute).not.toHaveBeenCalled(); + }); + + it('drop the empty-workspace hint when the only connector is waiting for setup', async () => { + const { client } = await connect([ETSY_PENDING]); + const list = await call(client, 'anythingmcp_list_connectors'); + expect(list.body.connectors).toEqual([]); + expect(list.body.hint).toBeUndefined(); + expect(list.body.needsSetup).toHaveLength(1); + }); +}); diff --git a/packages/backend/src/mcp-server/shared-toolset.ts b/packages/backend/src/mcp-server/shared-toolset.ts index fa7cf10e..8bcc31a4 100644 --- a/packages/backend/src/mcp-server/shared-toolset.ts +++ b/packages/backend/src/mcp-server/shared-toolset.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { McpServer } from '@modelcontextprotocol/server'; import { listAdapters } from '../adapters/catalog'; -import { RegisteredTool } from './tool-registry'; +import { RegisteredTool, isListable } from './tool-registry'; import { deriveToolAnnotations } from './tool-annotations'; import { jsonSchemaToZodShape, stripEnvVarParams } from './tool-schema.util'; @@ -80,6 +80,12 @@ export function excludedOnSharedEndpoint(tool: RegisteredTool): boolean { return typeof slug === 'string' && excludedAdapterSlugs().has(slug); } +function describeSetupStatus(status: RegisteredTool['setupStatus']): string { + return status === 'needs_authorization' + ? 'it has to be authorized with the provider' + : 'a credential or setting is still empty'; +} + type TextResult = { content: { type: 'text'; text: string }[]; isError?: boolean; @@ -100,6 +106,8 @@ export interface SharedToolsetDeps { guide(connectorIds: string[], wholeScope: boolean): Promise; /** Knowledge-graph answer, or null when the graph is off for the workspace. */ kgLookup(query: string, connectorIds: string[]): Promise; + /** Dashboard page of one connector, where the user finishes its setup. */ + connectorUrl(connectorId: string): string; /** Where the user configures connectors, plus their servers' direct URLs. */ configuration(): Promise<{ dashboardUrl: string; @@ -176,8 +184,12 @@ export function registerSharedToolset( scopeTools: RegisteredTool[], deps: SharedToolsetDeps, ): void { - const tools = scopeTools.filter((t) => !excludedOnSharedEndpoint(t)); + const served = scopeTools.filter((t) => !excludedOnSharedEndpoint(t)); const withheld = scopeTools.filter((t) => excludedOnSharedEndpoint(t)); + // Connectors still missing a credential or an authorization: not offered to + // the model, but named in list_connectors with where to finish them. + const tools = served.filter(isListable); + const pending = served.filter((t) => !isListable(t)); const connectorIds = [...new Set(tools.map((t) => t.connectorId))]; let summaries: Promise> | null = null; @@ -234,6 +246,18 @@ export function registerSharedToolset( ), }; } + const unfinished = pending.find((t) => t.name === name); + if (unfinished) { + const byId = await connectorsById(); + return { + error: json( + { + error: `'${name}' belongs to the connector '${connectorName(byId, unfinished.connectorId)}', which is not set up yet (${describeSetupStatus(unfinished.setupStatus)}). Give the user this link to finish it: ${deps.connectorUrl(unfinished.connectorId)}`, + }, + true, + ), + }; + } if (withheld.some((t) => t.name === name)) { return { error: json( @@ -325,8 +349,23 @@ export function registerSharedToolset( const notServedHere = [...new Set(withheld.map((t) => t.connectorId))].map( (id) => connectorName(byId, id), ); + const needsSetup = [...new Map(pending.map((t) => [t.connectorId, t])).values()] + .map((t) => ({ + name: connectorName(byId, t.connectorId), + status: t.setupStatus, + whatIsMissing: describeSetupStatus(t.setupStatus), + finishSetupUrl: deps.connectorUrl(t.connectorId), + })) + .sort((a, b) => a.name.localeCompare(b.name)); return json({ connectors, + ...(needsSetup.length + ? { + needsSetup, + needsSetupHint: + 'These connectors are installed but not usable yet. Give the user the finishSetupUrl; their tools appear here as soon as the setup is done.', + } + : {}), ...(notServedHere.length ? { notServedHere, @@ -334,7 +373,7 @@ export function registerSharedToolset( 'Payment, banking and trading connectors are only served on their server\'s own URL.', } : {}), - ...(connectors.length === 0 + ...(connectors.length === 0 && needsSetup.length === 0 ? { hint: 'No connectors yet. The user adds them in the dashboard: call anythingmcp_get_configuration_url.', } diff --git a/packages/backend/src/mcp-server/tool-registry.ts b/packages/backend/src/mcp-server/tool-registry.ts index 70df7542..a3e23fc9 100644 --- a/packages/backend/src/mcp-server/tool-registry.ts +++ b/packages/backend/src/mcp-server/tool-registry.ts @@ -1,3 +1,4 @@ +import type { SetupStatus } from '../connectors/connector-setup-status.util'; import { processGauges } from '../common/process-vitals'; import { Injectable, Logger } from '@nestjs/common'; @@ -50,6 +51,15 @@ export interface RegisteredTool { // Explicit MCP tool annotations: an admin override, or the annotations // reported by an upstream MCP server. Layered over the derived ones. annotations?: unknown; + // Whether the owning connector can serve calls yet (see + // connector-setup-status.util). Undefined counts as ready: tools registered + // outside McpServerService (tests, previews) keep being listed. + setupStatus?: SetupStatus; +} + +/** True when a tool's connector is fully set up, so the tool may be listed. */ +export function isListable(tool: RegisteredTool): boolean { + return tool.setupStatus === undefined || tool.setupStatus === 'ready'; } @Injectable() @@ -131,6 +141,16 @@ export class ToolRegistry { return Array.from(this.toolsById.values()); } + /** + * Tools whose connector is fully set up: what tools/list and the shared + * endpoint may show. A connector still missing a credential or an OAuth + * authorization stays registered (a call by name gets the precise error) + * but is not offered to the model. + */ + getListableTools(): RegisteredTool[] { + return this.getAllTools().filter(isListable); + } + /** * Count how many tools share a given name across all orgs/connectors. * Used by McpServerService to decide whether a tool name is already diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index 8f133bb6..97ea844a 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -14,6 +14,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill } from '@/components/ui/badge'; +import { SetupStatusBanner, SetupStatusPill } from '@/components/setup-status'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { authTypeLabel, cn } from '@/lib/utils'; @@ -814,6 +815,7 @@ export default function ConnectorDetailPage() { > {connector.isActive ? 'Active' : 'Inactive'} +
{connector.baseUrl} @@ -821,6 +823,8 @@ export default function ConnectorDetailPage() {
+ + {msg && (
{msg} diff --git a/packages/frontend/src/app/connectors/page.tsx b/packages/frontend/src/app/connectors/page.tsx index d3b42141..2b95e844 100644 --- a/packages/frontend/src/app/connectors/page.tsx +++ b/packages/frontend/src/app/connectors/page.tsx @@ -9,6 +9,7 @@ import { AppSelect } from '@/components/ui/select'; import { AppShell } from '@/components/app-shell'; import { Card } from '@/components/ui/card'; import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; +import { SetupStatusPill } from '@/components/setup-status'; import { Button, buttonVariants } from '@/components/ui/button'; import { ActionMenu } from '@/components/ui/action-menu'; import { authTypeLabel, cn } from '@/lib/utils'; @@ -488,6 +489,7 @@ export default function ConnectorsPage() { > {c.isActive ? 'Active' : 'Inactive'} +
{/* Hover actions (above the full-card link) */}
diff --git a/packages/frontend/src/app/connectors/setup/[slug]/page.tsx b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx new file mode 100644 index 00000000..04cd4c74 --- /dev/null +++ b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx @@ -0,0 +1,449 @@ +'use client'; + +import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { useAuth } from '@/lib/auth-context'; +import { + adapters, + connectors, + productEvents, + type AdapterSetupInfo, + type EnvVarDescriptor, + type VerifyResult, +} from '@/lib/api'; +import { AppShell } from '@/components/app-shell'; +import { Card } from '@/components/ui/card'; +import { Button, buttonVariants } from '@/components/ui/button'; +import { ConnectorLogo } from '@/components/connector-logo'; +import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit'; +import { cn } from '@/lib/utils'; + +/** + * Guided setup of a catalog connector, in one place: what to enter (grouped, + * with where to find each value), a check against the API before anything is + * saved, the sign-in at the provider for OAuth connectors, and a real result + * at the end. Replaces the install dialog whose "Skip for now" produced + * connectors that failed every call. + * + * Query: + * connector= finish a connector that already exists (a draft, or one + * installed from a chat), instead of installing a new one + * step=done back from the provider's sign-in + * from=claude show "Back to Claude" at the end + */ + +type Phase = 'form' | 'working' | 'done'; + +const GROUPS: Array<{ kind: EnvVarDescriptor['kind']; title: string }> = [ + { kind: 'address', title: 'Where it is' }, + { kind: 'credential', title: 'Credentials' }, + { kind: 'setting', title: 'Settings' }, +]; + +function SetupContent() { + const { slug } = useParams<{ slug: string }>(); + const params = useSearchParams(); + const router = useRouter(); + const { token } = useAuth(); + const existingId = params.get('connector'); + const fromClaude = params.get('from') === 'claude'; + const backFromProvider = params.get('step') === 'done'; + + const [info, setInfo] = useState(null); + const [loadError, setLoadError] = useState(''); + const [values, setValues] = useState>({}); + const [revealed, setRevealed] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); + const [phase, setPhase] = useState(backFromProvider ? 'working' : 'form'); + const [error, setError] = useState(''); + const [verifyFailed, setVerifyFailed] = useState(null); + const [result, setResult] = useState<{ connectorId: string; sample?: string; status?: string } | null>(null); + const [redirectUri, setRedirectUri] = useState(null); + /** Secrets the existing connector already holds: shown as set, may stay empty. */ + const [storedSecrets, setStoredSecrets] = useState([]); + const started = useRef(false); + + useEffect(() => { + if (!token || !slug) return; + adapters + .describe(slug, token) + .then((d) => { + setInfo(d); + if (!started.current) { + started.current = true; + productEvents.track('setup_started', token, { adapterSlug: slug, kind: d.setupKind, existing: !!existingId }); + } + }) + .catch((e: Error) => setLoadError(e.message || 'This connector is not available.')); + if (existingId) { + // Pre-fill what is stored and not secret (secrets come back empty). + connectors + .get(existingId, token) + .then((c) => { + const env = (c?.envVars ?? {}) as Record; + setValues((v) => ({ ...Object.fromEntries(Object.entries(env).filter(([, x]) => x)), ...v })); + setStoredSecrets(Array.isArray(c?.maskedEnvVars) ? c.maskedEnvVars : []); + }) + .catch(() => {}); + } + }, [token, slug, existingId]); + + useEffect(() => { + if (!token || info?.setupKind !== 'oauth_browser') return; + connectors.oauthRedirectUri(token).then((r) => setRedirectUri(r.redirectUri)).catch(() => {}); + }, [token, info?.setupKind]); + + // Back from the provider: the authorization is stored; show the outcome. + useEffect(() => { + if (!backFromProvider || !existingId || !token) return; + connectors + .get(existingId, token) + .then(async (c) => { + if (c?.setupStatus === 'ready') { + const test = await connectors.test(existingId, token).catch(() => null); + setResult({ connectorId: existingId, status: (test as any)?.message }); + setPhase('done'); + productEvents.track('setup_completed', token, { adapterSlug: slug, kind: 'oauth_browser' }); + } else { + setError('The authorization did not complete. Try again.'); + setPhase('form'); + } + }) + .catch((e: Error) => { + setError(e.message); + setPhase('form'); + }); + }, [backFromProvider, existingId, token, slug]); + + const fields = useMemo(() => info?.envVars ?? [], [info]); + const visibleFields = fields.filter((f) => !f.advanced); + const advancedFields = fields.filter((f) => f.advanced); + + /** + * What to send. A new install leaves out required fields that are empty (an + * optional one goes as '' so its placeholder resolves). An existing + * connector gets every field: one left empty keeps its stored value, and a + * name left out would delete it. + */ + const credentials = () => + Object.fromEntries( + fields + .map((f) => [f.name, (values[f.name] ?? '').trim()] as const) + .filter(([name, v]) => existingId || v !== '' || !fields.find((f) => f.name === name)?.required), + ) as Record; + + /** Client-side checks: required fields and patterns. */ + const validate = (): boolean => { + const errs: Record = {}; + for (const f of fields) { + const v = (values[f.name] ?? '').trim(); + if (f.required && !f.advanced && !v && !storedSecrets.includes(f.name)) errs[f.name] = 'Required'; + else if (v && f.pattern) { + try { + if (!new RegExp(f.pattern).test(v)) errs[f.name] = f.example ? `Looks wrong. Example: ${f.example}` : 'Looks wrong'; + } catch { + /* a broken pattern never blocks the form */ + } + } + } + setFieldErrors(errs); + return Object.keys(errs).length === 0; + }; + + /** Create the connector, or store the values on the existing one. Returns its id. */ + const save = async (): Promise => { + const creds = credentials(); + if (existingId) { + await connectors.updateEnvVars(existingId, creds, token!); + return existingId; + } + const out = await adapters.import(slug, token!, creds); + return out.connectorId; + }; + + const fail = (e: any) => { + setPhase('form'); + setError(e?.message || 'Something went wrong.'); + }; + + const verifyAndSave = async () => { + if (!token || !info || !validate()) return; + setError(''); + setVerifyFailed(null); + setPhase('working'); + try { + if (info.setupKind === 'oauth_browser') { + const id = await save(); + const returnTo = `/connectors/setup/${slug}?connector=${id}&step=done${fromClaude ? '&from=claude' : ''}`; + const auth = await connectors.oauthAuthorize(id, token, returnTo); + if (!auth.authorizationUrl) throw new Error(auth.error || 'Could not start the authorization.'); + productEvents.track('oauth_started', token, { adapterSlug: slug }); + window.location.href = auth.authorizationUrl; + return; + } + const check = + info.setupKind === 'none' ? null : await adapters.verify(slug, token, credentials(), existingId ?? undefined); + if (check && check.ok === false) { + productEvents.track('setup_verify_failed', token, { adapterSlug: slug, kind: check.kind }); + if (check.missing?.length) { + setFieldErrors(Object.fromEntries(check.missing.map((m) => [m, 'Required']))); + } + setVerifyFailed(check); + setPhase('form'); + return; + } + const id = await save(); + setResult({ connectorId: id, sample: check && check.ok ? check.sample : undefined }); + setPhase('done'); + productEvents.track('setup_completed', token, { adapterSlug: slug, kind: info.setupKind }); + } catch (e) { + fail(e); + } + }; + + /** Keep what was entered without checking it; the connector stays hidden from MCP until complete. */ + const saveAnyway = async (draft: boolean) => { + if (!token) return; + setError(''); + setPhase('working'); + try { + const id = await save(); + productEvents.track(draft ? 'setup_saved_draft' : 'setup_saved_unverified', token, { adapterSlug: slug }); + router.push(`/connectors/${id}`); + } catch (e) { + fail(e); + } + }; + + if (loadError) { + return ( + +

{loadError}

+ + Back to the marketplace + +
+ ); + } + if (!info) return

Loading…

; + + if (phase === 'done' && result) { + return ( + +
+ +
+

{info.name} is ready

+

Your AI client can use it now. Ask it something about {info.name}.

+
+
+ {result.sample && ( +
+

What the API answered

+
{result.sample}
+
+ )} +
+ {fromClaude && ( + + Back to Claude + + )} + + Open the connector + + + Add another + +
+
+ ); + } + + const working = phase === 'working'; + const isOAuth = info.setupKind === 'oauth_browser'; + const primaryLabel = + info.setupKind === 'none' + ? `Install ${info.name}` + : isOAuth + ? `Save and sign in to ${info.name.split(' ')[0]}` + : existingId + ? 'Check and save' + : 'Check and install'; + + const renderField = (f: EnvVarDescriptor) => { + const id = `field-${f.name}`; + const show = revealed[f.name] === true; + return ( +
+ +
+ setValues((v) => ({ ...v, [f.name]: e.target.value }))} + aria-invalid={!!fieldErrors[f.name]} + className={cn( + 'w-full rounded-[9px] border bg-[var(--surface)] px-3 py-2 text-[16px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:outline-none sm:text-sm', + fieldErrors[f.name] ? 'border-[var(--danger)]' : 'border-[var(--border)] focus:border-[var(--border-strong)]', + f.secret && 'pr-16', + )} + /> + {f.secret && ( + + )} +
+ {fieldErrors[f.name] &&

{fieldErrors[f.name]}

} + {(f.help || f.link) && ( +

+ {f.help} + {f.link && ( + <> + {' '} + + Open + + + )} +

+ )} +
+ ); + }; + + return ( +
+ +
+ +
+

+ {existingId ? `Finish setting up ${info.name}` : `Set up ${info.name}`} +

+

{info.description}

+
+
+ + {isOAuth && ( +
+

+ You need an app of your own at {info.name.split(' ')[0]}, then you sign in once. In the app's settings, register this + redirect URI exactly as shown: +

+
+ {redirectUri ?? '…'} + {redirectUri && ( + + )} +
+
+ )} + + {info.setupKind === 'none' && ( +

Nothing to enter: it works without an account.

+ )} + + {GROUPS.map(({ kind, title }) => { + const group = visibleFields.filter((f) => f.kind === kind); + if (group.length === 0) return null; + return ( +
+ {title} + {group.map(renderField)} +
+ ); + })} + + {advancedFields.length > 0 && ( +
+ Advanced +
{advancedFields.map(renderField)}
+
+ )} + + {verifyFailed && verifyFailed.ok === false && ( +
+

+ {verifyFailed.kind === 'auth_failed' + ? `${info.name} did not accept these credentials.` + : verifyFailed.kind === 'invalid_input' + ? 'Some values are missing or not valid.' + : `${info.name} answered with an error.`} +

+

{verifyFailed.message}

+ {verifyFailed.kind !== 'invalid_input' && ( + + )} +
+ )} + {error && ( + isTrialLimitMessage(error) ? ( + + ) : ( +

{error}

+ ) + )} + +
+ + {info.setupKind !== 'none' && !existingId && ( + + )} +
+ {info.setupKind !== 'none' && !existingId && ( +

+ A draft is not offered to your AI client until it is complete. +

+ )} +
+ + {info.instructions && ( + +
+ Step-by-step guide +
+ {info.instructions} +
+
+
+ )} +
+ ); +} + +export default function ConnectorSetupPage() { + return ( + +
+ Loading…

}> + +
+
+
+ ); +} diff --git a/packages/frontend/src/app/connectors/store/page.tsx b/packages/frontend/src/app/connectors/store/page.tsx index 8bae4e7d..e72fc1f0 100644 --- a/packages/frontend/src/app/connectors/store/page.tsx +++ b/packages/frontend/src/app/connectors/store/page.tsx @@ -3,8 +3,6 @@ import Link from 'next/link'; import { Suspense, useEffect, useMemo, useState, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; import { useAuth } from '@/lib/auth-context'; import { adapters } from '@/lib/api'; import { AppShell } from '@/components/app-shell'; @@ -12,7 +10,6 @@ import { Card } from '@/components/ui/card'; import { Button, buttonVariants } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { authTypeLabel, cn } from '@/lib/utils'; -import { McpAssignModal } from '@/components/mcp-assign-modal'; import { matchesSearch } from '@/lib/marketplace-search'; import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit'; @@ -231,44 +228,6 @@ interface AdapterItem { authType?: string; } -/** - * Pre-fill every optional env var with an empty string. An optional var the - * user never touches must still reach the backend as '' — otherwise the - * {{VAR}} placeholder survives resolution and is sent to the target API as a - * literal string (e.g. a Destatis password header of "{{DESTATIS_PASSWORD}}"). - */ -function seedOptionalCredentials(adapter: { - optionalEnvVars?: string[]; -}): Record { - return Object.fromEntries( - (adapter.optionalEnvVars || []).map((v) => [v, '']), - ); -} - -/** - * Env vars the connector's base URL is built from (e.g. SAP_HANA_HOST in - * hana://{{SAP_HANA_HOST}}:{{SAP_HANA_PORT}}/). The connector cannot be - * created without them, so they cannot be skipped like an API key. - */ -function addressVars(adapter: { connector?: { baseUrl?: string } }): string[] { - const url = adapter.connector?.baseUrl ?? ''; - return [...new Set([...url.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)].map((m) => m[1]))]; -} - -interface AdapterDetail extends AdapterItem { - // Long-form, Markdown-formatted help authored on the adapter JSON. - // Rendered inside the install modal so users see "where to find your - // refresh_token", auth-flow gotchas, etc. without leaving the page. - instructions?: string; - connector: { - name: string; - type: string; - baseUrl: string; - authType: string; - authConfig?: Record; - }; -} - export default function AdapterStorePage() { return ( @@ -286,24 +245,8 @@ function AdapterStoreContent() { const [search, setSearch] = useState(''); const [activeCategory, setActiveCategory] = useState(null); const [showAllCategories, setShowAllCategories] = useState(false); - const [importing, setImporting] = useState(null); const [msg, setMsg] = useState(''); - // Credential modal state - const [configAdapter, setConfigAdapter] = useState(null); - const [credentialValues, setCredentialValues] = useState>({}); - // Per-field reveal toggle for password-masked credential inputs in the - // install modal. Keyed by env var name so each "Show/Hide" button - // toggles only its own field. Reset together with credentialValues. - const [revealedCredentials, setRevealedCredentials] = useState>({}); - const [configLoading, setConfigLoading] = useState(false); - // An import that fails from the modal keeps the modal open and shows why - // there, instead of closing it and leaving a banner at the top of the page. - const [configError, setConfigError] = useState(''); - - // MCP assignment modal state - const [importedConnector, setImportedConnector] = useState<{ id: string; name: string } | null>(null); - // Track whether auto-install from ?install= param has been triggered const autoInstallTriggered = useRef(false); @@ -316,63 +259,12 @@ function AdapterStoreContent() { .finally(() => setLoading(false)); }, [token]); - const doImport = async (slug: string, credentials?: Record) => { - if (!token) return; - setImporting(slug); - setMsg(''); - setConfigError(''); - try { - const adapter = list.find((a) => a.slug === slug); - const result = await adapters.import(slug, token, credentials); - setConfigAdapter(null); - setMsg(describeImport(result.message, result.probe)); - setImporting(null); - // Show MCP assignment modal - setImportedConnector({ id: result.connectorId, name: adapter?.name || slug }); - } catch (err: any) { - setImporting(null); - if (configAdapter) setConfigError(err.message); - else setMsg(`Import failed: ${err.message}`); - } - }; - - const handleImportClick = async (adapter: AdapterItem) => { - if (!token) return; - - // If nothing at all is prompted for, import directly. Optional vars count: - // skipping the modal would leave them unset, and an unset {{VAR}} survives - // resolution and is sent to the API as a literal string. - const promptedVars = [ - ...(adapter.requiredEnvVars || []), - ...(adapter.optionalEnvVars || []), - ]; - if (promptedVars.length === 0) { - await doImport(adapter.slug); - return; - } - - // Fetch full adapter detail to show in modal - setConfigError(''); - setConfigLoading(true); - try { - const detail = await adapters.get(adapter.slug, token); - setConfigAdapter(detail); - // Seed optional vars to '' so leaving one blank still submits an empty - // value. Without the key the backend keeps the literal {{VAR}} - // placeholder and sends it to the API verbatim. - setCredentialValues(seedOptionalCredentials(detail)); - setRevealedCredentials({}); - } catch { - // Fallback: use list data - setConfigAdapter({ - ...adapter, - connector: { name: adapter.name, type: 'REST', baseUrl: '', authType: 'API_KEY' }, - } as AdapterDetail); - setCredentialValues(seedOptionalCredentials(adapter)); - setRevealedCredentials({}); - } finally { - setConfigLoading(false); - } + // Every install goes through the guided setup: it asks for what the + // connector needs, checks it against the API before saving, and runs the + // provider sign-in for OAuth connectors. The old dialog's "Skip for now" + // created connectors that failed every call. + const handleImportClick = (adapter: AdapterItem) => { + router.push(`/connectors/setup/${encodeURIComponent(adapter.slug)}`); }; // Auto-import when ?install= is present (e.g. from website marketplace) @@ -383,14 +275,8 @@ function AdapterStoreContent() { const adapter = list.find((a) => a.slug === installSlug); if (!adapter) return; autoInstallTriggered.current = true; - handleImportClick(adapter); - }, [loading, list, token, searchParams]); - - const handleConfigSubmit = () => { - if (!configAdapter) return; - const creds = Object.keys(credentialValues).length > 0 ? credentialValues : undefined; - doImport(configAdapter.slug, creds); - }; + router.replace(`/connectors/setup/${encodeURIComponent(adapter.slug)}`); + }, [loading, list, token, searchParams, router]); /** * Categories ranked by how much of the catalog each one holds, so the first @@ -607,7 +493,6 @@ function AdapterStoreContent() {
{filtered.map((adapter) => { const isPublic = adapter.authType === 'NONE'; - const isImporting = importing === adapter.slug; /* log-ish 1..10 segment scale, same as the marketing-site card */ const fillCount = Math.max( 1, @@ -684,15 +569,10 @@ function AdapterStoreContent() {
@@ -703,205 +583,10 @@ function AdapterStoreContent() { )} - {/* Credential Configuration Modal */} - {configAdapter && ( -
-
setConfigAdapter(null)} - /> -
- - - {/* Scrollable content region. Keeps the footer actions pinned and - reachable even when an adapter has many env vars (the modal is - capped at 90vh instead of growing past the viewport). */} -
-

- Configure {configAdapter.name} -

-

- {addressVars(configAdapter).length > 0 - ? `${addressVars(configAdapter).map(formatEnvVarLabel).join(', ')} ${addressVars(configAdapter).length === 1 ? 'is' : 'are'} part of this connector's address, so enter ${addressVars(configAdapter).length === 1 ? 'it' : 'them'} now. The rest can wait.` - : 'This adapter requires credentials to work. Enter them now or skip and configure later.'} -

- -
- - Auth type: {authTypeLabel(configAdapter.connector?.authType)} -
- - {/* Setup instructions — collapsible details block, default open - so first-time users see how to obtain each credential. The - content is the same Markdown stored on the adapter JSON's - `instructions` field. */} - {configAdapter.instructions && ( -
- - 📖 How to get these credentials - -
- - {configAdapter.instructions} - -
-
- )} - -
- {[ - ...configAdapter.requiredEnvVars, - ...(configAdapter.optionalEnvVars || []), - ].map((envVar) => { - const isOptional = ( - configAdapter.optionalEnvVars || [] - ).includes(envVar); - const isSecret = - envVar.toLowerCase().includes('secret') || - envVar.toLowerCase().includes('password') || - envVar.toLowerCase().includes('token') || - envVar.toLowerCase().includes('key'); - const visible = revealedCredentials[envVar] === true; - return ( -
- -
- - setCredentialValues((prev) => ({ - ...prev, - [envVar]: e.target.value, - })) - } - placeholder={envVar} - className={cn( - 'w-full rounded-[9px] border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm text-[var(--text)] placeholder:text-[var(--text-3)] focus:border-[var(--border-strong)] focus:outline-none', - isSecret && 'pr-16' - )} - /> - {isSecret && ( - - )} -
-
- ); - })} -
-
- - {configError && ( -

- {configError} -

- )} - - {/* Pinned footer — always visible, never scrolls out of reach. */} -
- {/* Skipping only works when the address has no variables: the - backend cannot create a connector whose URL is still - {{SAP_HANA_HOST}}, and used to answer 400 after the modal - had already closed. */} - {addressVars(configAdapter).length === 0 && ( - - )} - -
-
-
- )} - - {/* MCP Server Assignment Modal */} - {importedConnector && token && ( - { - setImportedConnector(null); - if (mcpServerId) { - router.push(`/mcp-server/${mcpServerId}`); - } else { - router.push(`/connectors/${importedConnector.id}`); - } - }} - onClose={() => { - setImportedConnector(null); - router.push(`/connectors/${importedConnector.id}`); - }} - /> - )} ); } -/** - * One line for the banner: the import message, plus what the backend's - * test call found. A wrong token used to surface days later, from the agent; - * now it is on screen while the value is still in the form. - */ -function describeImport( - message: string, - probe: { ok: boolean; toolName: string; status?: number | null; message?: string } | null | undefined, -): string { - if (!probe) return message; - if (probe.ok) return `${message} Test call ${probe.toolName} succeeded — the connector works.`; - const status = probe.status ? ` (HTTP ${probe.status})` : ''; - return `${message} But the test call ${probe.toolName} failed${status}: ${probe.message ?? 'no details'}. Check the credentials in the connector editor.`; -} - -/** Convert ENV_VAR_NAME to a human-readable label */ -function formatEnvVarLabel(envVar: string): string { - return envVar - .replace(/^(PAYONE_|DHL_|IS24_|WECLAPP_|DESTATIS_|TEAMVIEWER_|MFR_|FASTBILL_|BILLOMAT_|DATEV_|SCOPEVISIO_|KENJO_)/, '') - .replace(/_/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - function PlusIcon() { return ( @@ -930,15 +615,6 @@ function DownloadIcon() { ); } -function CloseIcon() { - return ( - - - - - ); -} - function LockIcon() { return ( diff --git a/packages/frontend/src/app/welcome/page.tsx b/packages/frontend/src/app/welcome/page.tsx index e8bff630..773f4ab9 100644 --- a/packages/frontend/src/app/welcome/page.tsx +++ b/packages/frontend/src/app/welcome/page.tsx @@ -8,22 +8,21 @@ import { adapters, users } from '@/lib/api'; import { LogoIcon } from '@/components/logo-icon'; import { ConnectorLogo } from '@/components/connector-logo'; import { StarterPack } from '@/components/starter-pack'; +import { matchesSearch } from '@/lib/marketplace-search'; -// A small, curated subset of slugs known to actually work end-to-end -// today, ordered by popularity from the production analytics -// (Sendcloud + Playtomic lead, then GitHub/Twitter/Slack as broadly -// useful starters). We don't fetch and re-rank: a stable list keeps -// the wizard predictable, and the user can switch to the full -// /connectors/store from the CTA below. +// What people connect most and get working, from production (installs that +// went on to a successful call, September-October 2026). Keyless demos have +// their own section below: they are installed often and used almost never, +// so they no longer lead. const STARTER_SLUGS = [ + 'etsy', + 'telegram-bot', + 'odoo', + 'weclapp', + 'lexware-office', 'sendcloud', - 'playtomic-public', - 'github', - 'twitter', - 'slack', - 'notion', - 'stripe', - 'help-scout', + 'getmyinvoices', + 'google-search-console', ]; export default function WelcomePage() { @@ -31,6 +30,8 @@ export default function WelcomePage() { const router = useRouter(); const [starters, setStarters] = useState([]); const [skipping, setSkipping] = useState(false); + const [catalog, setCatalog] = useState([]); + const [query, setQuery] = useState(''); useEffect(() => { // Bounce to /login if the user landed here unauthenticated. @@ -45,6 +46,7 @@ export default function WelcomePage() { adapters .list(token) .then((all: any[]) => { + setCatalog(all); const bySlug = new Map(all.map((a) => [a.slug, a])); setStarters( STARTER_SLUGS.map((s) => bySlug.get(s)).filter(Boolean) as any[], @@ -110,13 +112,56 @@ export default function WelcomePage() {

- {/* Starter pack: keyless connectors, ticked by default, added in one - click and put on the user's MCP server. It replaces the old - single-connector demo, and offers the same live "Try it" call - for each connector once it is added. */} - {/* Viewers can't add connectors (the install endpoint rejects them), - so don't offer the pack only to fail. */} - {token && user.role !== 'VIEWER' && } + {/* What do you want to connect? Search first: people arrive with an + app in mind, and every result opens the guided setup. */} + {token && user.role !== 'VIEWER' && ( +
+ + setQuery(e.target.value)} + placeholder="Etsy, Odoo, weclapp, Lexware, Shopify…" + className="w-full rounded-[10px] border border-[var(--border)] bg-[var(--surface)] px-4 py-3 text-[16px] text-[var(--text)] placeholder:text-[var(--text-3)] focus:border-[var(--border-strong)] focus:outline-none" + /> +
+ {(query.trim() + ? catalog.filter((a) => matchesSearch([a.name, a.slug, a.description ?? '', a.category ?? ''], query)).slice(0, 8) + : starters + ).map((a) => ( + + +
+
{a.name}
+
+ {a.setupKind === 'none' ? 'No account needed' : a.setupKind === 'oauth_browser' ? 'Sign in with your account' : 'API key'} +
+
+ + ))} +
+ {query.trim() && catalog.length > 0 && + !catalog.some((a) => matchesSearch([a.name, a.slug, a.description ?? '', a.category ?? ''], query)) && ( +

+ Nothing in the catalog matches. You can still{' '} + add your own API. +

+ )} +
+ )} + + {/* Keyless demos, added in one click, for trying AnythingMCP before + picking a real app. Viewers can't add connectors. */} + {token && user.role !== 'VIEWER' && ( + + )} {/* Two big paths — marketplace vs custom */}
@@ -131,8 +176,8 @@ export default function WelcomePage() { Browse the marketplace

- 180+ pre-built connectors. OAuth, API keys, refresh-token - rotation — all wired up. Click → install → done. + 265 pre-built connectors. OAuth, API keys, refresh-token + rotation, all wired up. Pick one and follow the setup.

Open marketplace → @@ -157,32 +202,6 @@ export default function WelcomePage() {
- {/* Starter shortcuts — real logos + 1-click install */} - {starters.length > 0 && ( -
-

- Popular connectors (sign in with your account) -

-
- {starters.map((a) => ( - - -
-
{a.name}
-
- {a.toolCount} tools -
-
- - ))} -
-
- )} - {/* Differentiator teaser — what makes AnythingMCP "smart" beyond a proxy. It's empty for a brand-new account, so this is a concept hook (no data, no AI), nudging toward the graph once tools exist. */} diff --git a/packages/frontend/src/components/setup-status.tsx b/packages/frontend/src/components/setup-status.tsx new file mode 100644 index 00000000..5ae47ed5 --- /dev/null +++ b/packages/frontend/src/components/setup-status.tsx @@ -0,0 +1,61 @@ +'use client'; + +import Link from 'next/link'; +import { StatusPill } from '@/components/ui/badge'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { ConnectorSetupStatus } from '@/lib/api'; + +/** Where a connector that is not set up gets finished. */ +export function finishSetupHref(connector: { id: string; config?: { adapterSlug?: string } | null }): string { + const slug = connector.config?.adapterSlug; + return slug + ? `/connectors/setup/${encodeURIComponent(slug)}?connector=${encodeURIComponent(connector.id)}` + : `/connectors/${encodeURIComponent(connector.id)}`; +} + +const COPY: Record, { pill: string; banner: string }> = { + needs_input: { + pill: 'Needs setup', + banner: 'A credential or setting is still empty, so your AI client does not see this connector yet.', + }, + needs_authorization: { + pill: 'Needs sign-in', + banner: 'It has to be authorized with the provider before your AI client can use it.', + }, +}; + +/** Shown next to "Active" when the connector cannot serve calls yet. */ +export function SetupStatusPill({ status }: { status?: ConnectorSetupStatus | null }) { + if (!status || status === 'ready') return null; + return ( + + {COPY[status].pill} + + ); +} + +/** On the connector page: what is missing and the way to finish it. */ +export function SetupStatusBanner({ + connector, +}: { + connector: { id: string; setupStatus?: ConnectorSetupStatus | null; missingVariables?: string[]; config?: { adapterSlug?: string } | null }; +}) { + const status = connector.setupStatus; + if (!status || status === 'ready') return null; + const missing = connector.missingVariables ?? []; + const catalog = !!connector.config?.adapterSlug; + return ( +
+
+

{COPY[status].banner}

+ {missing.length > 0 &&

Still empty: {missing.join(', ')}

} +
+ {catalog && ( + + Finish setup + + )} +
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index 46d498d8..def0f715 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -446,6 +446,40 @@ export const connectors = { }; // Adapters (built-in connector recipes) +export type SetupKind = 'none' | 'credentials' | 'oauth_browser'; + +export interface EnvVarDescriptor { + name: string; + required: boolean; + label: string; + kind: 'address' | 'credential' | 'setting'; + secret: boolean; + help?: string; + example?: string; + pattern?: string; + link?: string; + advanced?: boolean; +} + +export interface AdapterSetupInfo { + slug: string; + name: string; + description: string; + instructions?: string; + icon: string; + docsUrl?: string; + connector: { name: string; type: string; baseUrl: string; authType: string }; + envVars: EnvVarDescriptor[]; + setupKind: SetupKind; +} + +export type VerifyResult = + | { ok: true; toolName: string; durationMs: number; sample: string } + | { ok: false; kind: string; message: string; missing?: string[]; status?: number | null } + | { ok: null; skipped: 'authorization' | 'no_probe' }; + +export type ConnectorSetupStatus = 'ready' | 'needs_input' | 'needs_authorization'; + export const adapters = { list: (token: string) => request('/api/adapters', { token }), @@ -462,6 +496,16 @@ export const adapters = { `/api/adapters/${slug}/import`, { method: 'POST', token, body: credentials ? { credentials } : undefined }, ), + /** Full adapter with each variable described, for the guided setup. */ + describe: (slug: string, token: string) => + request(`/api/adapters/${slug}`, { token }), + /** Try credentials before saving: nothing is stored. */ + verify: (slug: string, token: string, credentials: Record, connectorId?: string) => + request(`/api/adapters/${slug}/verify`, { + method: 'POST', + token, + body: { credentials, ...(connectorId ? { connectorId } : {}) }, + }), starterPack: (token: string) => request('/api/adapters/starter-pack', { token }), installStarterPack: (slugs: string[], token: string) => diff --git a/packages/frontend/tests/e2e/connector-guided-setup.spec.ts b/packages/frontend/tests/e2e/connector-guided-setup.spec.ts new file mode 100644 index 00000000..9c2b8071 --- /dev/null +++ b/packages/frontend/tests/e2e/connector-guided-setup.spec.ts @@ -0,0 +1,153 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * The guided connector setup replaces the store's install dialog: grouped + * fields with where to find them, a check against the API before anything is + * saved, "Save and sign in" for OAuth connectors, and a real result at the end. + */ + +const USER = { id: 'u1', email: 'test@example.com', name: 'Test User', role: 'ADMIN', organizationId: 'o1', emailVerified: true }; + +const LEXWARE = { + slug: 'lexware-office', + name: 'Lexware Office', + description: 'Invoices and vouchers', + icon: 'lexware-office', + instructions: '**Getting a token** in Einstellungen.', + connector: { name: 'Lexware Office', type: 'REST', baseUrl: 'https://api.lexware.io/v1', authType: 'BEARER_TOKEN' }, + setupKind: 'credentials', + envVars: [ + { name: 'LEXWARE_API_KEY', required: true, label: 'API key', kind: 'credential', secret: true, help: 'Einstellungen → Öffentliche API.' }, + ], +}; + +const ETSY = { + slug: 'etsy', + name: 'Etsy Open API v3', + description: 'Your Etsy shop', + icon: 'etsy', + connector: { name: 'Etsy', type: 'REST', baseUrl: 'https://openapi.etsy.com/v3/application', authType: 'OAUTH2' }, + setupKind: 'oauth_browser', + envVars: [ + { name: 'ETSY_CLIENT_ID', required: true, label: 'Keystring', kind: 'credential', secret: false }, + { name: 'ETSY_CLIENT_SECRET', required: true, label: 'Shared secret', kind: 'credential', secret: true }, + { name: 'ETSY_REFRESH_TOKEN', required: false, label: 'Refresh token', kind: 'credential', secret: true, advanced: true }, + ], +}; + +interface Calls { + verify: any[]; + imports: any[]; + envVars: any[]; + authorize: any[]; +} + +async function setup(page: Page, opts: { verify?: any; connector?: any } = {}): Promise { + const calls: Calls = { verify: [], imports: [], envVars: [], authorize: [] }; + await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); + await page.route(/\/api\//, async (route) => { + const req = route.request(); + const url = req.url(); + const json = (body: unknown, status = 200) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + if (url.endsWith('/api/adapters/lexware-office/verify')) { + calls.verify.push(req.postDataJSON()); + return json(opts.verify ?? { ok: true, toolName: 'lexware_office_get_profile', durationMs: 120, sample: '{"companyName":"Acme GmbH"}' }); + } + if (url.includes('/api/adapters/lexware-office/import') || url.includes('/api/adapters/etsy/import')) { + calls.imports.push(req.postDataJSON()); + return json({ message: 'ok', connectorId: 'c9', toolsCreated: 12, attachedToServer: { id: 's1', name: 'Default' } }); + } + if (url.endsWith('/api/adapters/lexware-office')) return json(LEXWARE); + if (url.endsWith('/api/adapters/etsy')) return json(ETSY); + if (url.includes('/api/connectors/oauth/redirect-uri')) return json({ redirectUri: 'https://cloud.example.com/api/mcp-oauth/callback' }); + if (url.includes('/api/connectors/c9/oauth/authorize')) { + calls.authorize.push(req.postDataJSON()); + return json({ authorizationUrl: 'https://www.etsy.com/oauth/connect?state=s1' }); + } + if (url.includes('/api/connectors/c9/env-vars')) { + calls.envVars.push(req.postDataJSON()); + return json({}); + } + if (url.includes('/api/connectors/c9/test')) return json({ ok: true, message: 'Connected' }); + if (url.includes('/api/connectors/c9')) return json(opts.connector ?? { id: 'c9', setupStatus: 'ready', envVars: {}, maskedEnvVars: [] }); + if (url.includes('/api/product-events')) return json({ ok: true }); + if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); + if (url.includes('/api/users/me')) return json(USER); + if (url.includes('/api/organizations/current')) return json({ id: 'o1', name: 'Acme', createdAt: '2026-01-01' }); + if (url.includes('/api/organizations/mine')) return json([{ id: 'o1', name: 'Acme', role: 'ADMIN', joinedAt: '2026-01-01' }]); + if (url.includes('/api/license/status')) return json({ plan: 'community', status: 'active' }); + return json({}); + }); + return calls; +} + +test('checks the key before installing, and shows what the API answered', async ({ page }) => { + const calls = await setup(page); + await page.goto('/connectors/setup/lexware-office'); + await expect(page.getByRole('heading', { name: 'Set up Lexware Office' })).toBeVisible(); + await expect(page.getByText('Einstellungen → Öffentliche API.')).toBeVisible(); + + await page.getByRole('button', { name: 'Check and install' }).click(); + await expect(page.getByText('Required')).toBeVisible(); + expect(calls.verify).toHaveLength(0); + + await page.getByLabel('API key').fill(' key-123 '); + await page.getByRole('button', { name: 'Check and install' }).click(); + await expect(page.getByRole('heading', { name: 'Lexware Office is ready' })).toBeVisible(); + await expect(page.getByText('Acme GmbH')).toBeVisible(); + expect(calls.verify).toEqual([{ credentials: { LEXWARE_API_KEY: 'key-123' } }]); + expect(calls.imports).toEqual([{ credentials: { LEXWARE_API_KEY: 'key-123' } }]); +}); + +test('does not install when the API refuses the key', async ({ page }) => { + const calls = await setup(page, { + verify: { ok: false, kind: 'auth_failed', status: 401, message: '401 Unauthorized: invalid token. Check the API key.' }, + }); + await page.goto('/connectors/setup/lexware-office'); + await page.getByLabel('API key').fill('wrong'); + await page.getByRole('button', { name: 'Check and install' }).click(); + await expect(page.getByText('Lexware Office did not accept these credentials.')).toBeVisible(); + expect(calls.imports).toHaveLength(0); + await expect(page.getByRole('button', { name: 'Save anyway' })).toBeVisible(); +}); + +test('OAuth: saves the app keys and goes straight to the sign-in, with the way back', async ({ page }) => { + const calls = await setup(page); + await page.route('https://www.etsy.com/**', (route) => route.fulfill({ status: 200, body: 'Etsy consent page' })); + await page.goto('/connectors/setup/etsy'); + await expect(page.getByText('https://cloud.example.com/api/mcp-oauth/callback')).toBeVisible(); + // The refresh token is folded away: the sign-in fills it in. + await expect(page.getByLabel('Refresh token')).toBeHidden(); + await page.getByLabel('Keystring').fill('ks'); + await page.getByLabel('Shared secret').fill('ss'); + await page.getByRole('button', { name: 'Save and sign in to Etsy' }).click(); + await page.waitForURL('https://www.etsy.com/oauth/connect?state=s1'); + expect(calls.imports[0].credentials).toMatchObject({ ETSY_CLIENT_ID: 'ks', ETSY_CLIENT_SECRET: 'ss' }); + expect(calls.authorize).toEqual([{ returnTo: '/connectors/setup/etsy?connector=c9&step=done' }]); +}); + +test('OAuth: back from the provider, shows the connector ready', async ({ page }) => { + await setup(page); + await page.goto('/connectors/setup/etsy?connector=c9&step=done&from=claude'); + await expect(page.getByRole('heading', { name: 'Etsy Open API v3 is ready' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Back to Claude' })).toBeVisible(); +}); + +test('finishing an existing connector keeps a stored secret left empty', async ({ page }) => { + const calls = await setup(page, { + connector: { id: 'c9', setupStatus: 'needs_input', envVars: { LEXWARE_API_KEY: '' }, maskedEnvVars: ['LEXWARE_API_KEY'] }, + }); + await page.goto('/connectors/setup/lexware-office?connector=c9'); + await expect(page.getByRole('heading', { name: 'Finish setting up Lexware Office' })).toBeVisible(); + await expect(page.getByLabel('API key')).toHaveAttribute('placeholder', 'Stored. Leave empty to keep it'); + await page.getByRole('button', { name: 'Check and save' }).click(); + await expect(page.getByRole('heading', { name: 'Lexware Office is ready' })).toBeVisible(); + expect(calls.verify).toEqual([{ credentials: { LEXWARE_API_KEY: '' }, connectorId: 'c9' }]); + expect(calls.envVars).toEqual([{ envVars: { LEXWARE_API_KEY: '' } }]); + expect(calls.imports).toHaveLength(0); +}); diff --git a/scripts/validate-adapters.mjs b/scripts/validate-adapters.mjs index 2df0a879..c4862549 100644 --- a/scripts/validate-adapters.mjs +++ b/scripts/validate-adapters.mjs @@ -245,6 +245,26 @@ export function validateAdapter(adapter, file, region) { } } + if (adapter.envVarMeta !== undefined) { + const meta = adapter.envVarMeta; + const declared = [...(adapter.requiredEnvVars || []), ...(Array.isArray(adapter.optionalEnvVars) ? adapter.optionalEnvVars : [])]; + const KINDS = new Set(['address', 'credential', 'setting']); + const FIELDS = new Set(['label', 'kind', 'secret', 'help', 'example', 'pattern', 'link', 'advanced']); + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + errors.push(error('env-meta-shape', 'envVarMeta', 'envVarMeta must map a variable name to its description', 'Use { "MY_VAR": { "label": "…", "help": "…" } }.', 'adapter-fields')); + } else { + for (const [envVar, m] of Object.entries(meta)) { + const path = `envVarMeta.${envVar}`; + if (!declared.includes(envVar)) errors.push(error('env-meta-unknown', path, `envVarMeta describes "${envVar}", which is not in requiredEnvVars or optionalEnvVars`, 'Describe only variables the adapter declares.', 'adapter-fields')); + if (!m || typeof m !== 'object' || Array.isArray(m)) { errors.push(error('env-meta-shape', path, 'Each envVarMeta entry must be an object', 'Use { "label": "…" }.', 'adapter-fields')); continue; } + for (const k of Object.keys(m)) if (!FIELDS.has(k)) errors.push(error('env-meta-field', `${path}.${k}`, `Unknown envVarMeta field "${k}"`, `Use one of: ${[...FIELDS].join(', ')}.`, 'adapter-fields')); + if (m.kind !== undefined && !KINDS.has(m.kind)) errors.push(error('env-meta-kind', `${path}.kind`, `kind must be address, credential or setting, not "${m.kind}"`, 'Pick the kind that matches what the value is.', 'adapter-fields')); + if (m.pattern !== undefined) { try { new RegExp(m.pattern); } catch { errors.push(error('env-meta-pattern', `${path}.pattern`, 'pattern is not a valid regular expression', 'Fix or remove the pattern.', 'adapter-fields')); } } + if (m.link !== undefined && !/^https:\/\//.test(String(m.link))) errors.push(error('env-meta-link', `${path}.link`, 'link must be an https URL', 'Use the provider page where the value is created.', 'adapter-fields')); + } + } + } + if (adapter.envVarAliases !== undefined) { const aliases = adapter.envVarAliases; const declared = [...(adapter.requiredEnvVars || []), ...(Array.isArray(adapter.optionalEnvVars) ? adapter.optionalEnvVars : [])]; diff --git a/scripts/validate-adapters.test.mjs b/scripts/validate-adapters.test.mjs index ab799e01..7d810163 100644 --- a/scripts/validate-adapters.test.mjs +++ b/scripts/validate-adapters.test.mjs @@ -287,3 +287,13 @@ test('graphqlVariableDefinitions reads required-ness, defaults and anonymous doc assert.deepEqual(validatorModule.graphqlVariableDefinitions('{ me { id } }'), []); assert.deepEqual(validatorModule.graphqlVariableDefinitions('mutation($in: X!) { a }'), [{ name: 'in', required: true }]); }); + +test('envVarMeta describes declared variables with known fields only', () => { + const ok = validateAdapter(adapter({ envVarMeta: { GOOD_KEY: { label: 'API key', kind: 'credential', secret: true, help: 'Settings > API', link: 'https://example.test/keys', pattern: '^[a-z0-9]+$' } } }), 'good.json', 'de'); + assert.deepEqual(ok.errors, []); + const unknown = validateAdapter(adapter({ envVarMeta: { OTHER: { label: 'x' } } }), 'good.json', 'de'); + assert.ok(unknown.errors.some((e) => e.rule === 'env-meta-unknown')); + const bad = validateAdapter(adapter({ envVarMeta: { GOOD_KEY: { kind: 'password', pattern: '(', link: 'http://x', colour: 'red' } } }), 'good.json', 'de'); + const rules = bad.errors.map((e) => e.rule).sort(); + assert.deepEqual(rules, ['env-meta-field', 'env-meta-kind', 'env-meta-link', 'env-meta-pattern']); +}); From df4d764c4e4e9d3a6f778ab965cc067e019a58a9 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 10:56:48 +0200 Subject: [PATCH 4/6] Set connectors up from the AI client through the shared endpoint The shared /mcp keeps its eight tools. A virtual "AnythingMCP Setup" connector appears in anythingmcp_list_connectors for ADMIN and EDITOR members, with three tools reached through search/describe/run: setup_find_connectors, setup_install_connector and setup_get_status. - Install is catalog-only, accepts only non-secret settings, is limited to 10 installs per user per hour and respects the plan's connector limit. New connectors are attached to the servers the client was granted. - Secrets and OAuth sign-ins never pass through the chat: the tools return a one-time link (/s/, 30 minutes, single use, only for the user it was made for) that opens the guided setup. - Cloud: after approving an AI client into a workspace with no connectors, a page explains how to add an app (in the chat or in a new tab) before handing back to the client. --- .../migration.sql | 19 + packages/backend/prisma/schema.prisma | 19 + .../backend/src/adapters/adapters.module.ts | 6 +- .../adapters/connector-setup.service.spec.ts | 172 +++++++++ .../src/adapters/connector-setup.service.ts | 365 ++++++++++++++++++ .../src/adapters/setup-links.controller.ts | 28 ++ .../src/audit/product-event.service.ts | 7 + .../src/audit/security-event.service.ts | 2 + .../backend/src/auth/login.controller.spec.ts | 143 +++++++ packages/backend/src/auth/login.controller.ts | 114 ++++++ .../src/mcp-server/mcp-endpoint.controller.ts | 34 ++ .../src/mcp-server/mcp-server.module.ts | 5 +- .../backend/src/mcp-server/shared-setup.ts | 53 +++ .../src/mcp-server/shared-toolset.spec.ts | 61 +++ .../backend/src/mcp-server/shared-toolset.ts | 128 +++++- .../frontend/src/app/s/[token]/layout.tsx | 9 + packages/frontend/src/app/s/[token]/page.tsx | 54 +++ packages/frontend/src/lib/api.ts | 10 + .../frontend/tests/e2e/setup-link.spec.ts | 70 ++++ 19 files changed, 1290 insertions(+), 9 deletions(-) create mode 100644 packages/backend/prisma/migrations/20261003110000_connector_setup_links/migration.sql create mode 100644 packages/backend/src/adapters/connector-setup.service.spec.ts create mode 100644 packages/backend/src/adapters/connector-setup.service.ts create mode 100644 packages/backend/src/adapters/setup-links.controller.ts create mode 100644 packages/backend/src/mcp-server/shared-setup.ts create mode 100644 packages/frontend/src/app/s/[token]/layout.tsx create mode 100644 packages/frontend/src/app/s/[token]/page.tsx create mode 100644 packages/frontend/tests/e2e/setup-link.spec.ts diff --git a/packages/backend/prisma/migrations/20261003110000_connector_setup_links/migration.sql b/packages/backend/prisma/migrations/20261003110000_connector_setup_links/migration.sql new file mode 100644 index 00000000..ad9d8ca1 --- /dev/null +++ b/packages/backend/prisma/migrations/20261003110000_connector_setup_links/migration.sql @@ -0,0 +1,19 @@ +-- One-time links that finish a connector's setup in the dashboard, handed to +-- the user by an AI client that installed the connector through MCP. Only the +-- token's SHA-256 is stored; see the model comment. +CREATE TABLE "connector_setup_links" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "connector_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + + CONSTRAINT "connector_setup_links_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "connector_setup_links_token_hash_key" ON "connector_setup_links"("token_hash"); +CREATE INDEX "connector_setup_links_connector_id_user_id_idx" ON "connector_setup_links"("connector_id", "user_id"); +CREATE INDEX "connector_setup_links_expires_at_idx" ON "connector_setup_links"("expires_at"); diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index bba2b325..81d21eca 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -1017,6 +1017,25 @@ model ConnectorOAuthAttempt { @@map("connector_oauth_attempts") } +/// One-time link that finishes a connector's setup in the dashboard: the +/// secrets and the provider sign-in a chat must not handle. Created when a +/// connector is installed through MCP (or its status asked for); opened by the +/// same user, signed in, once. Only the SHA-256 of the token is stored. +model ConnectorSetupLink { + id String @id @default(cuid()) + tokenHash String @unique @map("token_hash") + connectorId String @map("connector_id") + userId String @map("user_id") + organizationId String @map("organization_id") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + + @@index([connectorId, userId]) + @@index([expiresAt]) + @@map("connector_setup_links") +} + model SsoLoginAttempt { id String @id @default(cuid()) diff --git a/packages/backend/src/adapters/adapters.module.ts b/packages/backend/src/adapters/adapters.module.ts index ba06d085..b2370cf4 100644 --- a/packages/backend/src/adapters/adapters.module.ts +++ b/packages/backend/src/adapters/adapters.module.ts @@ -4,6 +4,8 @@ import { AdaptersPublicController, } from './adapters.controller'; import { AdaptersService } from './adapters.service'; +import { ConnectorSetupService } from './connector-setup.service'; +import { SetupLinksController } from './setup-links.controller'; import { McpServerModule } from '../mcp-server/mcp-server.module'; import { LicenseModule } from '../license/license.module'; import { McpServersModule } from '../mcp-servers/mcp-servers.module'; @@ -11,7 +13,7 @@ import { ConnectorsModule } from '../connectors/connectors.module'; @Module({ imports: [McpServerModule, LicenseModule, McpServersModule, ConnectorsModule], - controllers: [AdaptersPublicController, AdaptersController], - providers: [AdaptersService], + controllers: [AdaptersPublicController, AdaptersController, SetupLinksController], + providers: [AdaptersService, ConnectorSetupService], }) export class AdaptersModule {} diff --git a/packages/backend/src/adapters/connector-setup.service.spec.ts b/packages/backend/src/adapters/connector-setup.service.spec.ts new file mode 100644 index 00000000..0377df77 --- /dev/null +++ b/packages/backend/src/adapters/connector-setup.service.spec.ts @@ -0,0 +1,172 @@ +import { ConnectorSetupService } from './connector-setup.service'; +import { AdaptersService } from './adapters.service'; +import { encrypt } from '../common/crypto/encryption.util'; + +const KEY = 'k'.repeat(32); + +function build(opts: { role?: string; connectors?: any[]; importResult?: any } = {}) { + process.env.ENCRYPTION_KEY = KEY; + const links: any[] = []; + const serverConnectors: any[] = []; + const stored: Record = {}; + const prisma: any = { + organizationMember: { findFirst: jest.fn().mockResolvedValue(opts.role ? { role: opts.role } : null) }, + connector: { + findMany: jest.fn().mockResolvedValue(opts.connectors ?? []), + findUnique: jest.fn(async ({ where }: any) => stored[where.id] ?? null), + findFirst: jest.fn(async ({ where }: any) => (stored[where.id]?.organizationId === where.organizationId ? stored[where.id] : null)), + }, + connectorSetupLink: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + create: jest.fn(async ({ data }: any) => (links.push({ id: `l${links.length}`, ...data }), data)), + findUnique: jest.fn(async ({ where }: any) => links.find((l) => l.tokenHash === where.tokenHash) ?? null), + update: jest.fn(async ({ where, data }: any) => Object.assign(links.find((l) => l.id === where.id), data)), + }, + mcpServerConfig: { findMany: jest.fn(async ({ where }: any) => where.id.in.map((id: string) => ({ id }))) }, + mcpServerConnector: { create: jest.fn(async ({ data }: any) => serverConnectors.push(data)) }, + }; + const realAdapters = new AdaptersService( + { connector: {}, mcpTool: {} } as any, + { reloadConnectorTools: jest.fn() } as any, + { get: (k: string) => (k === 'ENCRYPTION_KEY' ? KEY : undefined) } as any, + {} as any, + ); + const adapters: any = { + listAll: () => realAdapters.listAll(), + describe: (slug: string) => realAdapters.describe(slug), + importAdapter: jest.fn(async (slug: string, _u: string, orgId: string, settings: Record) => { + const def = realAdapters.getBySlug(slug); + const id = `c-${slug}`; + stored[id] = { + id, + organizationId: orgId, + authType: def.connector.authType, + authConfig: def.connector.authConfig ? encrypt(JSON.stringify(def.connector.authConfig), KEY) : null, + baseUrl: def.connector.baseUrl, + headers: null, + envVars: settings, + config: { adapterSlug: slug }, + }; + return { connectorId: id, toolsCreated: def.tools.length, probe: opts.importResult ?? null }; + }), + }; + const licenseGuard: any = { + checkCanCreateConnector: jest.fn().mockResolvedValue(undefined), + getUsage: jest.fn().mockResolvedValue({ connectors: { current: 1, max: 5 } }), + }; + const securityEvents: any = { log: jest.fn() }; + const productEvents: any = { log: jest.fn() }; + const service = new ConnectorSetupService(prisma, adapters as unknown as AdaptersService, licenseGuard, { register: jest.fn() } as any, securityEvents, productEvents); + const ctx = { userId: 'u1', organizationId: 'org-1', serverIds: ['srv-granted'], dashboardBase: 'https://cloud.example.com' }; + return { service, prisma, adapters, licenseGuard, securityEvents, links, serverConnectors, ctx }; +} + +describe('ConnectorSetupService — who may', () => { + it.each([['ADMIN', true], ['EDITOR', true], ['VIEWER', false], [undefined, false]])('%s → %s', async (role, ok) => { + const { service, ctx } = build({ role: role as any }); + await expect(service.canSetUp(ctx)).resolves.toBe(ok); + }); +}); + +describe('ConnectorSetupService — find', () => { + it('finds Etsy, says a sign-in is needed, and lists no secret as passable', async () => { + const { service, ctx } = build(); + const out: any = (await service.find(ctx, { query: 'etsy' })).body; + const etsy = out.results.find((r: any) => r.adapter === 'etsy'); + expect(etsy.setup).toMatch(/sign-in at the provider/); + expect(etsy.settingsYouMayPass.map((s: any) => s.name)).toEqual(['ETSY_CLIENT_ID']); + expect(etsy.enteredByTheUserOnTheLinkedPage).toContain('Shared secret'); + expect(out.connectorsLeftOnThisPlan).toBe(4); + }); + + it('never offers payment, banking or trading connectors', async () => { + const { service, ctx } = build(); + const out: any = (await service.find(ctx, { query: 'payone sorare', limit: 10 })).body; + expect(out.results.map((r: any) => r.adapter)).not.toEqual(expect.arrayContaining(['payone', 'sorare'])); + }); +}); + +describe('ConnectorSetupService — install', () => { + it('refuses a secret from the chat, before installing anything', async () => { + const { service, adapters, ctx } = build(); + const out = await service.install(ctx, { adapter: 'lexware-office', settings: { LEXWARE_API_KEY: 'k' } }); + expect(out.isError).toBe(true); + expect(JSON.stringify(out.body)).toContain('never taken from the chat'); + expect(adapters.importAdapter).not.toHaveBeenCalled(); + }); + + it('refuses a setting the adapter does not have, and an unknown or excluded adapter', async () => { + const { service, ctx } = build(); + expect((await service.install(ctx, { adapter: 'weclapp', settings: { BASE_URL: 'https://evil.example' } })).isError).toBe(true); + expect((await service.install(ctx, { adapter: 'no-such-thing' })).isError).toBe(true); + expect((await service.install(ctx, { adapter: 'payone' })).isError).toBe(true); + }); + + it('installs a keyless connector ready to use, on the granted server, and records it', async () => { + const { service, ctx, serverConnectors, securityEvents } = build(); + const out: any = await service.install(ctx, { adapter: 'openplz' }); + expect(out.isError).toBeFalsy(); + expect(out.body.status).toBe('ready'); + expect(serverConnectors).toEqual([{ mcpServerId: 'srv-granted', connectorId: 'c-openplz' }]); + expect(securityEvents.log).toHaveBeenCalledWith(expect.objectContaining({ event: 'CONNECTOR_INSTALLED_VIA_MCP' })); + }); + + it('returns a one-time link when the user has to enter something', async () => { + const { service, ctx, links } = build(); + const out: any = await service.install(ctx, { adapter: 'weclapp', settings: { WECLAPP_TENANT: 'acme' } }); + expect(out.body.status).toBe('needs_input'); + expect(out.body.whatTheUserDoes).toContain('API token'); + expect(out.body.finishSetupUrl).toMatch(/^https:\/\/cloud\.example\.com\/s\/[A-Za-z0-9_-]{20,}$/); + // Only the hash is stored. + const token = out.body.finishSetupUrl.split('/s/')[1]; + expect(JSON.stringify(links)).not.toContain(token); + }); + + it('asks for the sign-in when an OAuth connector has its app keys', async () => { + const { service, ctx } = build(); + const out: any = await service.install(ctx, { adapter: 'etsy', settings: { ETSY_CLIENT_ID: 'ks' } }); + expect(out.body.status).toBe('needs_input'); // the shared secret still has to be entered on the page + expect(out.body.finishSetupUrl).toBeDefined(); + }); + + it('stops after ten installs an hour', async () => { + const { service, ctx } = build(); + for (let i = 0; i < 10; i++) await service.install(ctx, { adapter: 'openplz' }); + const out = await service.install(ctx, { adapter: 'openplz' }); + expect(out.isError).toBe(true); + expect(JSON.stringify(out.body)).toContain('an hour'); + }); + + it('respects the trial limit', async () => { + const { service, ctx, licenseGuard, adapters } = build(); + licenseGuard.checkCanCreateConnector.mockRejectedValueOnce(new Error('Trial limit reached (2 connectors).')); + const out = await service.install(ctx, { adapter: 'openplz' }); + expect(out.isError).toBe(true); + expect(adapters.importAdapter).not.toHaveBeenCalled(); + }); +}); + +describe('ConnectorSetupService — links', () => { + async function linkFor(build_: ReturnType) { + const out: any = await build_.service.install(build_.ctx, { adapter: 'weclapp', settings: { WECLAPP_TENANT: 'acme' } }); + return out.body.finishSetupUrl.split('/s/')[1] as string; + } + + it('opens once, for the user it was made for, on the guided setup of that connector', async () => { + const b = build(); + const token = await linkFor(b); + await expect(b.service.resolveLink(token, 'someone-else')).resolves.toEqual({ error: expect.stringContaining('another account') }); + await expect(b.service.resolveLink(token, 'u1')).resolves.toEqual({ + redirect: '/connectors/setup/weclapp?connector=c-weclapp&from=claude', + }); + await expect(b.service.resolveLink(token, 'u1')).resolves.toEqual({ error: expect.stringContaining('already used') }); + }); + + it('does not open after it expires, or with a made-up token', async () => { + const b = build(); + const token = await linkFor(b); + b.links[0].expiresAt = new Date(Date.now() - 1000); + await expect(b.service.resolveLink(token, 'u1')).resolves.toEqual({ error: expect.stringContaining('expired') }); + await expect(b.service.resolveLink('made-up', 'u1')).resolves.toEqual({ error: expect.stringContaining('expired') }); + }); +}); diff --git a/packages/backend/src/adapters/connector-setup.service.ts b/packages/backend/src/adapters/connector-setup.service.ts new file mode 100644 index 00000000..48aa7a80 --- /dev/null +++ b/packages/backend/src/adapters/connector-setup.service.ts @@ -0,0 +1,365 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { createHash, randomBytes } from 'crypto'; +import { PrismaService } from '../common/prisma.service'; +import { AdaptersService } from './adapters.service'; +import { describeAdapterEnvVars } from './env-var-meta'; +import { LicenseGuardService } from '../license/license-guard.service'; +import { SecurityEvents, SecurityEventService } from '../audit/security-event.service'; +import { ProductEvents, ProductEventService } from '../audit/product-event.service'; +import { + SetupCallResult, + SetupContext, + SharedSetupProvider, + SharedSetupRegistry, +} from '../mcp-server/shared-setup'; +import { isExcludedAdapterSlug } from '../mcp-server/shared-toolset'; +import { computeSetupState } from '../connectors/connector-setup-status.util'; +import { decrypt } from '../common/crypto/encryption.util'; + +/** How long a setup link handed to the user stays valid. */ +export const SETUP_LINK_TTL_MS = 30 * 60 * 1000; +/** Installs through a chat, per user and hour. A model does not need more. */ +const INSTALLS_PER_HOUR = 10; + +/** + * Setting up connectors from an AI client, through the shared `/mcp` + * endpoint (see shared-setup.ts), and the one-time links that finish what a + * chat must not handle: secrets and the provider's sign-in. + * + * Rules, all enforced here rather than trusted to the model: + * - catalog adapters only: no URL a prompt injection could point at a server + * of its own; + * - only values that are not secret are accepted from the chat; a secret is + * refused with the link to enter it in the dashboard instead; + * - ADMIN or EDITOR of the workspace, the same rule as the dashboard; + * - the trial's connector limit, and INSTALLS_PER_HOUR per user; + * - every install is recorded as a security event. + */ +@Injectable() +export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit { + private readonly logger = new Logger(ConnectorSetupService.name); + private readonly installs = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly adapters: AdaptersService, + private readonly licenseGuard: LicenseGuardService, + private readonly registry: SharedSetupRegistry, + private readonly securityEvents: SecurityEventService, + private readonly productEvents: ProductEventService, + ) {} + + onModuleInit(): void { + this.registry.register(this); + } + + // ── Who may ─────────────────────────────────────────────────────────── + + async canSetUp(ctx: SetupContext): Promise { + if (!ctx.organizationId) return false; + const member = await this.prisma.organizationMember.findFirst({ + where: { userId: ctx.userId, organizationId: ctx.organizationId, deactivatedAt: null }, + select: { role: true }, + }); + return member?.role === 'ADMIN' || member?.role === 'EDITOR'; + } + + // ── Find ────────────────────────────────────────────────────────────── + + async find(ctx: SetupContext, args: { query?: string; limit?: number }): Promise { + const words = String(args.query ?? '') + .toLowerCase() + .split(/\s+/) + .filter((w) => w.length > 1); + const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 10); + const installed = new Set( + ( + await this.prisma.connector.findMany({ + where: { organizationId: ctx.organizationId }, + select: { config: true }, + }) + ) + .map((c) => (c.config as { adapterSlug?: string } | null)?.adapterSlug) + .filter((s): s is string => !!s), + ); + + const scored = this.adapters + .listAll() + .filter((a) => !isExcludedAdapterSlug(a.slug)) + .map((a) => { + const name = a.name.toLowerCase(); + const text = `${a.slug} ${name} ${a.category} ${a.description}`.toLowerCase(); + let score = 0; + for (const w of words) { + if (a.slug === w || name === w) score += 10; + else if (a.slug.startsWith(w) || name.split(/\s+/).some((n) => n.startsWith(w))) score += 5; + else if (text.includes(w)) score += 1; + } + return { a, score: words.length ? score : (a.priority ?? 0) }; + }) + .filter((x) => !words.length || x.score > 0) + .sort((x, y) => y.score - x.score || (y.a.priority ?? 0) - (x.a.priority ?? 0)) + .slice(0, limit); + + const usage = await this.licenseGuard.getUsage(ctx.userId, ctx.organizationId).catch(() => null); + return { + body: { + results: scored.map(({ a }) => { + const full = this.adapters.describe(a.slug); + const vars = full.envVars.filter((v) => !v.advanced); + return { + adapter: a.slug, + name: a.name, + description: a.description, + alreadyInstalled: installed.has(a.slug), + setup: + full.setupKind === 'none' + ? 'Nothing to enter: installs and works right away.' + : full.setupKind === 'oauth_browser' + ? 'Needs the user\'s own app keys and a sign-in at the provider, done on a page AnythingMCP links to.' + : 'Needs credentials the user enters on a page AnythingMCP links to.', + settingsYouMayPass: vars + .filter((v) => !v.secret) + .map((v) => ({ name: v.name, label: v.label, required: v.required, help: v.help, example: v.example })), + enteredByTheUserOnTheLinkedPage: vars.filter((v) => v.secret).map((v) => v.label), + }; + }), + ...(usage?.connectors?.max != null + ? { connectorsLeftOnThisPlan: Math.max(0, usage.connectors.max - usage.connectors.current) } + : {}), + next: 'Confirm the choice with the user, then call setup_install_connector with the adapter id (and any settings listed above). Never ask for passwords, API keys or tokens in the chat.', + }, + }; + } + + // ── Install ─────────────────────────────────────────────────────────── + + async install( + ctx: SetupContext, + args: { adapter?: string; settings?: Record }, + ): Promise { + const slug = String(args.adapter ?? '').trim(); + let definition: ReturnType; + try { + if (!slug || isExcludedAdapterSlug(slug)) throw new Error('unknown'); + definition = this.adapters.describe(slug); + } catch { + return { isError: true, body: { error: `No catalog connector '${slug}'. Use setup_find_connectors to get its id.` } }; + } + + const descriptors = new Map(describeAdapterEnvVars(definition).map((d) => [d.name, d])); + const settings: Record = {}; + for (const [name, value] of Object.entries(args.settings ?? {})) { + const d = descriptors.get(name); + if (!d) { + return { isError: true, body: { error: `'${name}' is not a setting of ${definition.name}. Settings: ${[...descriptors.keys()].join(', ') || 'none'}.` } }; + } + if (d.secret) { + return { + isError: true, + body: { + error: `'${d.label}' is a secret and is never taken from the chat. Install without it; the user enters it on the page linked in the answer.`, + }, + }; + } + if (value !== undefined && value !== null && String(value).trim() !== '') settings[name] = String(value).trim(); + } + + if (!this.takeInstallSlot(ctx.userId)) { + return { isError: true, body: { error: `At most ${INSTALLS_PER_HOUR} connectors an hour can be installed from a chat. Try again later or use the dashboard.` } }; + } + try { + await this.licenseGuard.checkCanCreateConnector(ctx.userId, ctx.organizationId); + } catch (err: any) { + return { isError: true, body: { error: String(err?.message ?? err), dashboard: `${ctx.dashboardBase}/settings/license` } }; + } + + let imported: Awaited>; + try { + imported = await this.adapters.importAdapter(slug, ctx.userId, ctx.organizationId, settings); + } catch (err: any) { + return { isError: true, body: { error: String(err?.message ?? err) } }; + } + await this.attachToGrantedServers(ctx, imported.connectorId); + await this.securityEvents.log({ + event: SecurityEvents.CONNECTOR_INSTALLED_VIA_MCP, + actorType: 'USER', + actorUserId: ctx.userId, + organizationId: ctx.organizationId, + metadata: { adapter: slug, connectorId: imported.connectorId, settings: Object.keys(settings) }, + }); + await this.productEvents.log({ + event: ProductEvents.SETUP_COMPLETED, + userId: ctx.userId, + organizationId: ctx.organizationId, + metadata: { adapterSlug: slug, via: 'mcp' }, + }); + + const state = await this.connectorState(imported.connectorId); + if (state.status === 'ready') { + return { + body: { + installed: definition.name, + status: 'ready', + tools: definition.tools.length, + ...(imported.probe ? { firstCall: imported.probe.ok ? 'worked' : imported.probe.message } : {}), + next: 'Its tools are available now: find them with anythingmcp_search_tools.', + }, + }; + } + const link = await this.createLink(ctx, imported.connectorId); + return { + body: { + installed: definition.name, + status: state.status, + whatTheUserDoes: + state.status === 'needs_authorization' + ? `Open the link, then sign in to ${definition.name} and approve. It takes a minute.` + : `Open the link and enter ${state.missing.map((m) => descriptors.get(m)?.label ?? m).join(', ')}.`, + finishSetupUrl: link, + linkValidFor: '30 minutes, for this user only', + next: 'Give the user the link. When they say they are done, call setup_get_status.', + }, + }; + } + + // ── Status ──────────────────────────────────────────────────────────── + + async status(ctx: SetupContext): Promise { + const rows = await this.prisma.connector.findMany({ + where: { organizationId: ctx.organizationId }, + orderBy: { createdAt: 'desc' }, + take: 50, + select: { id: true, name: true, authType: true, authConfig: true, baseUrl: true, headers: true, envVars: true, config: true, createdAt: true }, + }); + const out = []; + for (const r of rows) { + const state = this.stateOf(r); + out.push({ + name: r.name, + status: state.status, + ...(state.status !== 'ready' ? { finishSetupUrl: await this.createLink(ctx, r.id) } : {}), + }); + } + return { + body: { + connectors: out, + ...(out.length === 0 ? { hint: 'No connectors yet: use setup_find_connectors.' } : {}), + }, + }; + } + + // ── Links ───────────────────────────────────────────────────────────── + + private hash(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + /** A fresh one-time link to finish this connector, for this user. */ + async createLink(ctx: Pick, connectorId: string): Promise { + const token = randomBytes(24).toString('base64url'); + await this.prisma.connectorSetupLink.deleteMany({ where: { expiresAt: { lt: new Date() } } }).catch(() => undefined); + await this.prisma.connectorSetupLink.create({ + data: { + tokenHash: this.hash(token), + connectorId, + userId: ctx.userId, + organizationId: ctx.organizationId, + expiresAt: new Date(Date.now() + SETUP_LINK_TTL_MS), + }, + }); + return `${ctx.dashboardBase}/s/${token}`; + } + + /** + * Open a link: valid, unused, not expired, and opened by the user it was + * made for. Marks it used and returns where the dashboard should go. + */ + async resolveLink(token: string, userId: string): Promise<{ redirect: string } | { error: string }> { + const row = await this.prisma.connectorSetupLink.findUnique({ where: { tokenHash: this.hash(String(token || '')) } }); + if (!row || row.expiresAt.getTime() < Date.now()) { + return { error: 'This link has expired. Ask your AI client for a new one, or open the connector in the dashboard.' }; + } + if (row.userId !== userId) { + return { error: 'This link was made for another account. Sign in as that account.' }; + } + if (row.usedAt) { + return { error: 'This link was already used. Ask your AI client for a new one, or open the connector in the dashboard.' }; + } + await this.prisma.connectorSetupLink.update({ where: { id: row.id }, data: { usedAt: new Date() } }); + const connector = await this.prisma.connector.findFirst({ + where: { id: row.connectorId, organizationId: row.organizationId }, + select: { id: true, config: true }, + }); + if (!connector) return { error: 'This connector no longer exists.' }; + const slug = (connector.config as { adapterSlug?: string } | null)?.adapterSlug; + return { + redirect: slug + ? `/connectors/setup/${encodeURIComponent(slug)}?connector=${encodeURIComponent(connector.id)}&from=claude` + : `/connectors/${encodeURIComponent(connector.id)}`, + }; + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private takeInstallSlot(userId: string, now = Date.now()): boolean { + const recent = (this.installs.get(userId) ?? []).filter((t) => now - t < 60 * 60 * 1000); + if (recent.length >= INSTALLS_PER_HOUR) { + this.installs.set(userId, recent); + return false; + } + recent.push(now); + this.installs.set(userId, recent); + return true; + } + + /** + * importAdapter put the connector on the user's own server. The connection + * may be granted a different one (a teammate's, a picked one): put it there + * too, or the chat that installed it would not see it. + */ + private async attachToGrantedServers(ctx: SetupContext, connectorId: string): Promise { + if (ctx.serverIds.length === 0) return; + const servers = await this.prisma.mcpServerConfig.findMany({ + where: { id: { in: ctx.serverIds }, organizationId: ctx.organizationId, isActive: true }, + select: { id: true }, + }); + for (const s of servers) { + await this.prisma.mcpServerConnector + .create({ data: { mcpServerId: s.id, connectorId } }) + .catch(() => undefined); // already there + } + } + + private async connectorState(connectorId: string) { + const row = await this.prisma.connector.findUnique({ + where: { id: connectorId }, + select: { authType: true, authConfig: true, baseUrl: true, headers: true, envVars: true, config: true }, + }); + return row ? this.stateOf(row) : { status: 'needs_input' as const, missing: [] as string[] }; + } + + private stateOf(row: { + authType: string; + authConfig: string | null; + baseUrl: string; + headers: unknown; + envVars: unknown; + config: unknown; + }) { + let authConfig: unknown; + try { + authConfig = row.authConfig ? JSON.parse(decrypt(row.authConfig, process.env.ENCRYPTION_KEY || '')) : undefined; + } catch { + authConfig = undefined; + } + return computeSetupState({ + authType: row.authType, + authConfig, + baseUrl: row.baseUrl, + headers: row.headers, + envVars: row.envVars, + config: row.config, + }); + } +} diff --git a/packages/backend/src/adapters/setup-links.controller.ts b/packages/backend/src/adapters/setup-links.controller.ts new file mode 100644 index 00000000..c4111a3f --- /dev/null +++ b/packages/backend/src/adapters/setup-links.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, GoneException, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { ConnectorSetupService } from './connector-setup.service'; + +/** + * Opening a one-time setup link (/s/ in the dashboard). Authenticated: + * the link only works for the user it was made for, so one forwarded or + * leaked from a chat opens nothing for anyone else. + */ +@ApiTags('Connector setup') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/setup-links') +export class SetupLinksController { + constructor(private readonly setup: ConnectorSetupService) {} + + @Post('resolve') + @HttpCode(200) + @Throttle({ default: { limit: 20, ttl: 60_000 } }) + @ApiOperation({ summary: 'Open a one-time connector setup link' }) + async resolve(@Req() req: any, @Body() body: { token?: string }): Promise<{ redirect: string }> { + const out = await this.setup.resolveLink(String(body?.token ?? ''), req.user.sub); + if ('error' in out) throw new GoneException(out.error); + return out; + } +} diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index b42d3b27..ae92f222 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -60,6 +60,12 @@ export const ProductEvents = { * add a connector (read against the connectors table). */ AI_CLIENT_CONNECTED: 'ai_client_connected', + /** + * Cloud: after approving an AI client, the user was told their workspace + * is empty and shown how to add an app (chat or dashboard). Server-only. + * Read against setup_completed: does the prompt lead to a first connector. + */ + EMPTY_WORKSPACE_PROMPT: 'empty_workspace_prompt', } as const; export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents]; @@ -71,6 +77,7 @@ export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents const SERVER_ONLY = new Set([ ProductEvents.SIGNUP_ATTRIBUTED, ProductEvents.AI_CLIENT_CONNECTED, + ProductEvents.EMPTY_WORKSPACE_PROMPT, ]); const CLIENT_REPORTABLE = new Set( Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)), diff --git a/packages/backend/src/audit/security-event.service.ts b/packages/backend/src/audit/security-event.service.ts index afd8e038..4f2833fb 100644 --- a/packages/backend/src/audit/security-event.service.ts +++ b/packages/backend/src/audit/security-event.service.ts @@ -79,6 +79,8 @@ export const SecurityEvents = { CONSENT_GRANTED: 'CONSENT_GRANTED', CONSENT_DENIED: 'CONSENT_DENIED', DCR_CLIENT_REGISTERED: 'DCR_CLIENT_REGISTERED', + /** An AI client installed a catalog connector through the shared /mcp setup tools. */ + CONNECTOR_INSTALLED_VIA_MCP: 'CONNECTOR_INSTALLED_VIA_MCP', } as const; export type SecurityEventName = diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 2b9e2b1d..03e5d17a 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -494,4 +494,147 @@ describe('LoginController', () => { expect(res._cookies['login_user']).toBeUndefined(); }); }); + + describe('cloud: approving into an empty workspace', () => { + let cloud: LoginController; + let productEvents: { log: jest.Mock }; + let cloudPrisma: { + user: { findUnique: jest.Mock }; + organizationMember: { findFirst: jest.Mock }; + connector: { count: jest.Mock }; + }; + + const arrange = (opts: { connectors?: number; role?: string; session?: boolean } = {}) => { + if (opts.session !== false) { + store.getOAuthSession.mockResolvedValue({ + sessionId: 's1', + state: 'x', + clientId: 'client-abc', + redirectUri: 'https://claude.ai/api/mcp/auth_callback', + expiresAt: Date.now() + 60_000, + } as any); + store.getClient.mockResolvedValue({ client_id: 'client-abc', client_name: 'Claude' } as any); + } + cloudPrisma.user.findUnique.mockImplementation(({ select }: any) => + select?.organizationId + ? { organizationId: 'org-1' } + : { id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }, + ); + cloudPrisma.organizationMember.findFirst.mockResolvedValue({ role: opts.role ?? 'ADMIN' }); + cloudPrisma.connector.count.mockResolvedValue(opts.connectors ?? 0); + authService.comparePassword.mockResolvedValue(true); + }; + + const login = async (signedCookies: Record = {}) => { + const res = makeRes(); + await cloud.handleLogin( + makeReq({ + cookies: { oauth_session: 's1' }, + signedCookies: { login_csrf: 'tok', ...signedCookies }, + headers: { host: 'mcp.test' }, + } as any), + { email: 'a@b.com', password: 'pw', csrf: 'tok', action: 'approve' }, + res, + ); + return res; + }; + + beforeEach(() => { + cloudPrisma = { + user: { findUnique: jest.fn() }, + organizationMember: { findFirst: jest.fn() }, + connector: { count: jest.fn() }, + }; + productEvents = { log: jest.fn().mockResolvedValue(undefined) }; + config.get.mockImplementation((key: string) => + key === 'FRONTEND_URL' ? 'https://cloud.example/' : undefined, + ); + cloud = new LoginController( + authService as unknown as AuthService, + cloudPrisma as unknown as PrismaService, + config as unknown as ConfigService, + store as unknown as PrismaOAuthStore, + sso as unknown as SsoService, + { mode: 'cloud', isCloud: () => true, isSelfHosted: () => false } as any, + grants as any, + productEvents as any, + ); + }); + + it('explains how to add an app before handing back to the client', async () => { + arrange(); + const res = await login(); + + expect(res._redirect).toBeUndefined(); + expect(res._sent).toContain('Claude is connected'); + expect(res._sent).toContain('href="http://mcp.test/callback"'); + expect(res._sent).toContain('href="https://cloud.example/welcome"'); + expect(res._sent).toContain('target="_blank"'); + // The detour must not outlive the 60-second login cookie. + expect(res._cookies['login_user'].options.maxAge).toBe(15 * 60 * 1000); + expect(res._cookies['login_user'].options.signed).toBe(true); + expect(productEvents.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'empty_workspace_prompt', + userId: 'u1', + organizationId: 'org-1', + }), + ); + }); + + it('escapes the client name the client registered itself', async () => { + arrange(); + store.getClient.mockResolvedValue({ client_id: 'c', client_name: '' } as any); + const res = await login(); + + expect(res._sent).toContain('<img src=x> is connected'); + expect(res._sent).not.toContain(''); + }); + + it.each([ + ['the workspace has connectors', { connectors: 2 }], + ['the user cannot add connectors', { role: 'VIEWER' }], + ])('goes straight to /callback when %s', async (_label, opts) => { + arrange(opts); + const res = await login(); + + expect(res._redirect).toBe('http://mcp.test/callback'); + expect(res._cookies['login_user'].options.maxAge).toBe(60 * 1000); + expect(productEvents.log).not.toHaveBeenCalled(); + }); + + it('goes straight to /callback when the client connects one server URL', async () => { + arrange(); + const res = await login({ mcp_resource: 'srv-1' }); + + expect(res._redirect).toBe('http://mcp.test/callback'); + }); + + it('never shows on self-hosted', async () => { + arrange(); + const selfHosted = new LoginController( + authService as unknown as AuthService, + cloudPrisma as unknown as PrismaService, + config as unknown as ConfigService, + store as unknown as PrismaOAuthStore, + sso as unknown as SsoService, + { mode: 'self-hosted', isCloud: () => false, isSelfHosted: () => true } as any, + grants as any, + productEvents as any, + ); + const res = makeRes(); + await selfHosted.handleLogin( + makeReq({ + cookies: { oauth_session: 's1' }, + signedCookies: { login_csrf: 'tok' }, + headers: { host: 'mcp.test' }, + } as any), + { email: 'a@b.com', password: 'pw', csrf: 'tok', action: 'approve' }, + res, + ); + + expect(res._redirect).toBe('http://mcp.test/callback'); + expect(cloudPrisma.connector.count).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index 3e54838c..24889aa3 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -7,6 +7,7 @@ import { Req, Res, Logger, + Optional, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Throttle } from '@nestjs/throttler'; @@ -25,6 +26,7 @@ import { SsoService } from '../identity-providers/sso.service'; import { MCP_RESOURCE_COOKIE } from './resource-indicator.middleware'; import { providerMarkSvg } from './provider-marks'; import { McpConnectionGrantService } from '../mcp-servers/mcp-connection-grant.service'; +import { ProductEvents, ProductEventService } from '../audit/product-event.service'; /** * Carries the VERIFIED identity across the second step of the authorize flow. @@ -74,6 +76,7 @@ export class LoginController { private readonly sso: SsoService, private readonly deployment: DeploymentService, private readonly grants: McpConnectionGrantService, + @Optional() private readonly productEvents?: ProductEventService, ) {} @Get('login') @@ -299,9 +302,120 @@ export class LoginController { // Derive callback URL from the request origin (works behind proxy/tunnel) const baseUrl = this.getBaseUrl(req); + if (await this.maybeOfferFirstConnector(req, res, user.id, encoded, `${baseUrl}/callback`)) return; res.redirect(`${baseUrl}/callback`); } + /** + * Cloud only: a user who connects an AI client to a workspace with no + * connectors lands back in the chat with nothing to use. 146 of the first + * 428 sign-ups from the Claude directory did exactly that. Before handing + * back, say so once, and show both ways forward: ask the client to set an + * app up (the shared endpoint's setup tools), or add one in a new tab. + * + * The OAuth flow is only paused: "Continue" goes to the same callback, and + * the short-lived login cookie is renewed so a detour does not expire it. + * Returns true when it has rendered the page. + */ + private async maybeOfferFirstConnector( + req: Request, + res: Response, + userId: string, + encodedProfile: string, + callbackUrl: string, + ): Promise { + if (!this.deployment.isCloud()) return false; + const consent = await this.loadConsentContext(req); + if (!consent) return false; + // A client connecting one server's own URL does not get the setup tools + // (they live on the shared endpoint), so the advice would not hold. + const requestedServerId = (req as Request & { signedCookies?: Record }) + .signedCookies?.[MCP_RESOURCE_COOKIE]; + if (typeof requestedServerId === 'string' && requestedServerId) return false; + const account = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { organizationId: true }, + }); + const organizationId = account?.organizationId; + if (!organizationId) return false; + const [member, connectors] = await Promise.all([ + this.prisma.organizationMember.findFirst({ + where: { userId, organizationId, deactivatedAt: null }, + select: { role: true }, + }), + this.prisma.connector.count({ where: { organizationId } }), + ]); + if (connectors > 0 || (member?.role !== 'ADMIN' && member?.role !== 'EDITOR')) return false; + + const isSecure = this.isSecureRequest(req); + res.cookie('login_user', encodedProfile, { + httpOnly: true, + secure: isSecure, + maxAge: 15 * 60 * 1000, + sameSite: isSecure ? 'none' : 'lax', + signed: true, + }); + await this.productEvents + ?.log({ + event: ProductEvents.EMPTY_WORKSPACE_PROMPT, + userId, + organizationId, + metadata: { client: consent.clientName }, + }) + .catch(() => undefined); + + const dashboard = (this.configService.get('FRONTEND_URL') || '').replace(/\/+$/, ''); + res.setHeader('Content-Type', 'text/html'); + res.send( + this.renderFirstConnectorOffer({ + clientName: consent.clientName, + callbackUrl, + welcomeUrl: `${dashboard}/welcome`, + }), + ); + return true; + } + + private renderFirstConnectorOffer(params: { + clientName: string; + callbackUrl: string; + welcomeUrl: string; + }): string { + const client = this.escapeHtml(params.clientName); + return ` + + + + + + ${client} is connected + + + +
+

${client} is connected

+

Your workspace has no apps yet, so ${client} has nothing to work with. You can add them right in the chat. For example, ask:

+

“Connect my Etsy shop to AnythingMCP.”

+

${client} finds the connector and sets it up with you. Passwords and keys are never typed into the chat: you get a link to enter them here.

+ Continue to ${client} + Or add an app here first (new tab) +
+ +`; + } + /** * Decide what this client may reach, asking the user only when there is * genuinely something to ask. diff --git a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts index fc4603b4..39a9d00b 100644 --- a/packages/backend/src/mcp-server/mcp-endpoint.controller.ts +++ b/packages/backend/src/mcp-server/mcp-endpoint.controller.ts @@ -9,6 +9,7 @@ import { Body, UseGuards, Logger, + Optional, } from '@nestjs/common'; import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { Request, Response } from 'express'; @@ -27,6 +28,7 @@ import { McpServersService } from '../mcp-servers/mcp-servers.service'; import { McpSessionManager } from '../mcp-servers/mcp-session.manager'; import { processGauges } from '../common/process-vitals'; import { ToolRegistry, RegisteredTool, isListable } from './tool-registry'; +import { SharedSetupRegistry } from './shared-setup'; import { McpConnectionGrantService, ResolvedGrant, @@ -189,6 +191,7 @@ export class McpEndpointController { private readonly sessionManager: McpSessionManager, private readonly grants: McpConnectionGrantService, private readonly kgSkills: KgSkillService, + @Optional() private readonly sharedSetup?: SharedSetupRegistry, ) {} // Streamable-HTTP response framing. Default: SSE-framed responses @@ -287,6 +290,24 @@ export class McpEndpointController { const dashboardBase = trimSlash(process.env.FRONTEND_URL) || requestBase; const mcpBase = trimSlash(process.env.SERVER_URL) || requestBase; + // Connector setup from the chat, for those who may install connectors in + // the workspace this connection reaches. Not offered to a credential + // pinned to one server (an API key), which is not a person setting up. + const setupOrg = + grant?.mode === 'organization' + ? grant.organizationId + : grant?.mode === 'servers' + ? grant.servers[0]?.organizationId + : user.organizationId; + const setupProvider = this.sharedSetup?.get() ?? null; + const setupCtx = setupOrg + ? { userId: user.sub, organizationId: setupOrg, serverIds, dashboardBase } + : null; + const canSetUp = + !!setupProvider && !!setupCtx && !user.mcpServerId && grant?.mode !== 'none' + ? await setupProvider.canSetUp(setupCtx) + : false; + const deps: SharedToolsetDeps = { execute: async (tool, args) => { const { structured: _structured, ...result } = @@ -306,6 +327,19 @@ export class McpEndpointController { }, connectors: (ids) => this.mcpServersService.getConnectorSummaries(ids), connectorUrl: (id) => `${dashboardBase}/connectors/${encodeURIComponent(id)}`, + ...(canSetUp && setupProvider && setupCtx + ? { + setup: { + organizationId: setupCtx.organizationId, + run: (name: string, args: Record) => + name === 'setup_find_connectors' + ? setupProvider.find(setupCtx, args as { query?: string; limit?: number }) + : name === 'setup_install_connector' + ? setupProvider.install(setupCtx, args as { adapter?: string; settings?: Record }) + : setupProvider.status(setupCtx), + }, + } + : {}), guide: (ids, wholeScope) => this.mcpServersService.getSharedGuide({ connectorIds: ids, diff --git a/packages/backend/src/mcp-server/mcp-server.module.ts b/packages/backend/src/mcp-server/mcp-server.module.ts index 1f3043c0..ec12cc7d 100644 --- a/packages/backend/src/mcp-server/mcp-server.module.ts +++ b/packages/backend/src/mcp-server/mcp-server.module.ts @@ -18,6 +18,7 @@ import { LoginTokenService } from '../connectors/engines/login-token.service'; import { GraphqlSchemaService } from '../connectors/engines/graphql-schema.service'; import { McpServersModule } from '../mcp-servers/mcp-servers.module'; import { LicenseModule } from '../license/license.module'; +import { SharedSetupRegistry } from './shared-setup'; const ENGINES = [ RestEngine, @@ -31,7 +32,7 @@ const ENGINES = [ @Module({ imports: [McpServersModule, LicenseModule], controllers: [McpEndpointController, WellKnownOAuthController, RegistryCatchUpController], - providers: [McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, GraphqlSchemaService, ...ENGINES], - exports: [McpServerService, ToolRegistry], + providers: [SharedSetupRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, GraphqlSchemaService, ...ENGINES], + exports: [McpServerService, ToolRegistry, SharedSetupRegistry], }) export class McpServerModule {} diff --git a/packages/backend/src/mcp-server/shared-setup.ts b/packages/backend/src/mcp-server/shared-setup.ts new file mode 100644 index 00000000..feeed88d --- /dev/null +++ b/packages/backend/src/mcp-server/shared-setup.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Connector setup through the shared `/mcp` endpoint. + * + * The shared endpoint serves a fixed list of eight tools (it is what the + * Claude directory reviewed). Setting up connectors from a chat is offered + * BEHIND those tools, as a virtual "AnythingMCP Setup" connector whose tools + * are found with anythingmcp_search_tools and run with run_read_tool / + * run_write_tool, exactly like a workspace's own tools. + * + * The implementation lives with the adapters (catalog, import, licence), which + * already depend on this module; it registers itself here at start-up so this + * module does not have to import them back. + */ +export interface SetupContext { + userId: string; + /** Workspace the connection reaches (the grant's, else the active one). */ + organizationId: string; + /** Servers the connection is granted, so a new connector lands where it is visible. */ + serverIds: string[]; + /** Dashboard base URL for links handed to the user. */ + dashboardBase: string; +} + +export interface SetupCallResult { + body: unknown; + isError?: boolean; +} + +export interface SharedSetupProvider { + /** Whether this caller may install connectors in that workspace (ADMIN or EDITOR). */ + canSetUp(ctx: SetupContext): Promise; + find(ctx: SetupContext, args: { query?: string; limit?: number }): Promise; + install( + ctx: SetupContext, + args: { adapter?: string; settings?: Record }, + ): Promise; + status(ctx: SetupContext): Promise; +} + +@Injectable() +export class SharedSetupRegistry { + private provider: SharedSetupProvider | null = null; + + register(provider: SharedSetupProvider): void { + this.provider = provider; + } + + get(): SharedSetupProvider | null { + return this.provider; + } +} diff --git a/packages/backend/src/mcp-server/shared-toolset.spec.ts b/packages/backend/src/mcp-server/shared-toolset.spec.ts index 333242ae..78d32c4f 100644 --- a/packages/backend/src/mcp-server/shared-toolset.spec.ts +++ b/packages/backend/src/mcp-server/shared-toolset.spec.ts @@ -350,3 +350,64 @@ describe('connectors that are not set up yet', () => { expect(list.body.needsSetup).toHaveLength(1); }); }); + +describe('connector setup from the chat (AnythingMCP Setup)', () => { + function withSetup(run: jest.Mock = jest.fn(async () => ({ body: { results: [] } }))) { + return makeDeps({ setup: { organizationId: 'org-A', run } } as any); + } + + it('is offered on an empty workspace, and the hint points the model at it', async () => { + const { client } = await connect([], withSetup()); + const list = await call(client, 'anythingmcp_list_connectors'); + expect(list.body.connectors).toEqual([ + expect.objectContaining({ id: 'anythingmcp-setup', name: 'AnythingMCP Setup', readTools: 2, writeTools: 1 }), + ]); + expect(list.body.hint).toContain('setup_find_connectors'); + }); + + it('is found by search and run through the generic run tools', async () => { + const run = jest.fn(async () => ({ body: { results: [{ adapter: 'etsy' }] } })); + const { client } = await connect([CRM_READ], withSetup(run)); + const search = await call(client, 'anythingmcp_search_tools', { query: 'connect etsy app' }); + expect(JSON.stringify(search.body)).toContain('setup_find_connectors'); + const out = await call(client, 'anythingmcp_run_read_tool', { tool: 'setup_find_connectors', arguments: { query: 'etsy' } }); + expect(out.isError).toBe(false); + expect(run).toHaveBeenCalledWith('setup_find_connectors', { query: 'etsy' }); + }); + + it('installs only through the write tool, so the client asks the user first', async () => { + const run = jest.fn(async () => ({ body: { installed: 'Etsy' } })); + const { client } = await connect([], withSetup(run)); + const viaRead = await call(client, 'anythingmcp_run_read_tool', { tool: 'setup_install_connector', arguments: { adapter: 'etsy' } }); + expect(viaRead.isError).toBe(true); + expect(run).not.toHaveBeenCalled(); + const viaWrite = await call(client, 'anythingmcp_run_write_tool', { tool: 'setup_install_connector', arguments: { adapter: 'etsy' } }); + expect(viaWrite.body).toEqual({ installed: 'Etsy' }); + }); + + it('passes an error from the setup service through as an error result', async () => { + const run = jest.fn(async () => ({ isError: true, body: { error: 'secret' } })); + const { client } = await connect([], withSetup(run)); + const out = await call(client, 'anythingmcp_run_write_tool', { tool: 'setup_install_connector', arguments: { adapter: 'x' } }); + expect(out).toEqual({ isError: true, body: { error: 'secret' } }); + }); + + it('documents itself in the workspace guide', async () => { + const { client } = await connect([], withSetup()); + const guide = await call(client, 'anythingmcp_get_workspace_guide', { connector: 'AnythingMCP Setup' }); + expect(String(guide.body)).toContain('Never ask the user for passwords, API keys or tokens'); + }); + + it('is absent for a caller who may not install connectors', async () => { + const { client } = await connect([], makeDeps()); + const list = await call(client, 'anythingmcp_list_connectors'); + expect(list.body.connectors).toEqual([]); + expect(list.body.hint).toContain('anythingmcp_get_configuration_url'); + }); + + it('keeps the eight tools the directory reviewed', async () => { + const { client } = await connect([], withSetup()); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual([...SHARED_TOOL_NAMES].sort()); + }); +}); diff --git a/packages/backend/src/mcp-server/shared-toolset.ts b/packages/backend/src/mcp-server/shared-toolset.ts index 8bcc31a4..51b431d7 100644 --- a/packages/backend/src/mcp-server/shared-toolset.ts +++ b/packages/backend/src/mcp-server/shared-toolset.ts @@ -74,6 +74,11 @@ function excludedAdapterSlugs(): Set { return excludedSlugs; } +/** True for a catalog adapter the shared endpoint does not serve (payments, banking, trading). */ +export function isExcludedAdapterSlug(slug: string): boolean { + return excludedAdapterSlugs().has(slug); +} + /** True for a tool of a catalog connector the shared endpoint does not serve. */ export function excludedOnSharedEndpoint(tool: RegisteredTool): boolean { const slug = tool.connectorConfig?.config?.adapterSlug; @@ -86,6 +91,91 @@ function describeSetupStatus(status: RegisteredTool['setupStatus']): string { : 'a credential or setting is still empty'; } +/** + * The virtual "AnythingMCP Setup" connector: installing catalog connectors + * from the chat, reached through the same search / describe / run tools as a + * workspace's own (see shared-setup.ts). Offered to ADMINs and EDITORs. + */ +export const SETUP_CONNECTOR_ID = 'anythingmcp-setup'; +export const SETUP_CONNECTOR_NAME = 'AnythingMCP Setup'; + +const SETUP_GUIDE = [ + `## ${SETUP_CONNECTOR_NAME}`, + 'Adds connectors to this workspace from the chat. Use it when the user wants to work with an app that is not connected yet, or when the workspace has no connectors.', + '1. setup_find_connectors with the app or topic. Show the user what you found and confirm which one to install.', + '2. setup_install_connector with the connector id, plus only the non-secret settings the search listed (a tenant name, a shop or instance URL). Never ask the user for passwords, API keys or tokens in the chat.', + '3. If the answer has finishSetupUrl, give the user that link: they enter the secrets or sign in to the provider there. It works once, for them, for 30 minutes.', + '4. When they say they are done, setup_get_status confirms it; the new tools then appear in anythingmcp_search_tools.', +].join('\n'); + +const SETUP_TOOL_SPECS: Array<{ + name: 'setup_find_connectors' | 'setup_install_connector' | 'setup_get_status'; + title: string; + description: string; + parameters: Record; + readOnly: boolean; +}> = [ + { + name: 'setup_find_connectors', + title: 'Find a connector to add', + description: + 'Search the AnythingMCP catalog (265 ready connectors: ERPs, online shops, accounting, CRM, messaging, data APIs) for an app the user wants to connect. Returns each connector id, what setting it up involves, and which non-secret settings may be passed when installing.', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'App name or topic, e.g. "etsy", "odoo", "invoices".' }, + limit: { type: 'number', description: 'Maximum results, 1 to 10. Default 5.' }, + }, + required: ['query'], + }, + readOnly: true, + }, + { + name: 'setup_install_connector', + title: 'Add a connector', + description: + "Install a catalog connector in the user's workspace. Ask the user first. Pass only settings that setup_find_connectors listed as settingsYouMayPass (such as a tenant name or a shop URL), never passwords, API keys or tokens: when those are needed, the answer contains a one-time link where the user enters them or signs in to the provider.", + parameters: { + type: 'object', + properties: { + adapter: { type: 'string', description: 'Connector id from setup_find_connectors, e.g. "etsy".' }, + settings: { type: 'object', description: 'Non-secret settings by name, e.g. {"WECLAPP_TENANT": "acme"}.' }, + }, + required: ['adapter'], + }, + readOnly: false, + }, + { + name: 'setup_get_status', + title: 'Setup status', + description: + "Which connectors of the workspace are ready and which still need the user, each with a fresh link to finish it. Call it after the user says they completed a setup link.", + parameters: { type: 'object', properties: {} }, + readOnly: true, + }, +]; + +function setupTools(organizationId: string): RegisteredTool[] { + return SETUP_TOOL_SPECS.map((spec) => ({ + id: `${SETUP_CONNECTOR_ID}:${spec.name}`, + connectorId: SETUP_CONNECTOR_ID, + organizationId, + name: spec.name, + description: spec.description, + parameters: spec.parameters, + connectorType: 'SETUP', + connectorConfig: { baseUrl: '', authType: 'NONE' }, + endpointMapping: { method: spec.readOnly ? 'GET' : 'POST', path: '/' }, + annotations: { + title: spec.title, + readOnlyHint: spec.readOnly, + destructiveHint: false, + idempotentHint: spec.readOnly, + openWorldHint: false, + }, + })); +} + type TextResult = { content: { type: 'text'; text: string }[]; isError?: boolean; @@ -108,6 +198,14 @@ export interface SharedToolsetDeps { kgLookup(query: string, connectorIds: string[]): Promise; /** Dashboard page of one connector, where the user finishes its setup. */ connectorUrl(connectorId: string): string; + /** + * Connector setup from the chat, already bound to this caller; absent when + * the caller may not install connectors (or the instance does not offer it). + */ + setup?: { + organizationId: string; + run(name: string, args: Record): Promise<{ body: unknown; isError?: boolean }>; + }; /** Where the user configures connectors, plus their servers' direct URLs. */ configuration(): Promise<{ dashboardUrl: string; @@ -188,7 +286,10 @@ export function registerSharedToolset( const withheld = scopeTools.filter((t) => excludedOnSharedEndpoint(t)); // Connectors still missing a credential or an authorization: not offered to // the model, but named in list_connectors with where to finish them. - const tools = served.filter(isListable); + const tools = [ + ...served.filter(isListable), + ...(deps.setup ? setupTools(deps.setup.organizationId) : []), + ]; const pending = served.filter((t) => !isListable(t)); const connectorIds = [...new Set(tools.map((t) => t.connectorId))]; @@ -196,7 +297,13 @@ export function registerSharedToolset( const connectorsById = () => { summaries ??= deps .connectors([...new Set(scopeTools.map((t) => t.connectorId))]) - .then((list) => new Map(list.map((c) => [c.id, c]))); + .then((list) => { + const byId = new Map(list.map((c) => [c.id, c])); + if (deps.setup) { + byId.set(SETUP_CONNECTOR_ID, { id: SETUP_CONNECTOR_ID, name: SETUP_CONNECTOR_NAME, hasGuide: true }); + } + return byId; + }); return summaries; }; const connectorName = (byId: Map, id: string) => @@ -315,6 +422,10 @@ export function registerSharedToolset( true, ); } + if (tool.connectorId === SETUP_CONNECTOR_ID && deps.setup) { + const out = await deps.setup.run(tool.name, parsed.data as Record); + return json(out.body, !!out.isError); + } return deps.execute(tool, parsed.data as Record); }; @@ -373,9 +484,11 @@ export function registerSharedToolset( 'Payment, banking and trading connectors are only served on their server\'s own URL.', } : {}), - ...(connectors.length === 0 && needsSetup.length === 0 + ...(connectors.every((c) => c.id === SETUP_CONNECTOR_ID) && needsSetup.length === 0 ? { - hint: 'No connectors yet. The user adds them in the dashboard: call anythingmcp_get_configuration_url.', + hint: deps.setup + ? `No apps are connected yet. Ask the user which app they want to work with, then add it with the ${SETUP_CONNECTOR_NAME} tools (setup_find_connectors, then setup_install_connector).` + : 'No connectors yet. The user adds them in the dashboard: call anythingmcp_get_configuration_url.', } : {}), }); @@ -551,7 +664,12 @@ export function registerSharedToolset( if (args.connector && ids.length === 0) { return json({ error: `No connector '${args.connector}' on this connection.` }, true); } - const guide = ids.length ? await deps.guide(ids, !args.connector) : undefined; + const own = ids.filter((id) => id !== SETUP_CONNECTOR_ID); + const workspaceGuide = own.length ? await deps.guide(own, !args.connector) : undefined; + const guide = + [workspaceGuide, ids.includes(SETUP_CONNECTOR_ID) ? SETUP_GUIDE : undefined] + .filter(Boolean) + .join('\n\n') || undefined; if (!guide) return json({ guide: null, note: 'The workspace has no notes for these connectors.' }); const text = guide.length > GUIDE_MAX_CHARS diff --git a/packages/frontend/src/app/s/[token]/layout.tsx b/packages/frontend/src/app/s/[token]/layout.tsx new file mode 100644 index 00000000..fc51297f --- /dev/null +++ b/packages/frontend/src/app/s/[token]/layout.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; + +// The URL is a one-time credential until it is used: keep it out of Referer +// headers and search engines. +export const metadata: Metadata = { referrer: 'no-referrer', robots: { index: false, follow: false } }; + +export default function Layout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/packages/frontend/src/app/s/[token]/page.tsx b/packages/frontend/src/app/s/[token]/page.tsx new file mode 100644 index 00000000..8b7d8112 --- /dev/null +++ b/packages/frontend/src/app/s/[token]/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/auth-context'; +import { setupLinks } from '@/lib/api'; +import { Card } from '@/components/ui/card'; +import { buttonVariants } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +/** + * A one-time link from an AI client ("open this to finish connecting Etsy"). + * It only opens for the user it was made for, signed in, once; then it leads + * to the guided setup of that connector. + */ +export default function SetupLinkPage() { + const { token } = useParams<{ token: string }>(); + const { token: authToken, isLoading } = useAuth(); + const router = useRouter(); + const [error, setError] = useState(null); + const started = useRef(false); + + useEffect(() => { + if (isLoading || started.current) return; + if (!authToken) { + router.replace(`/login?redirect=${encodeURIComponent(`/s/${token}`)}`); + return; + } + started.current = true; + setupLinks + .resolve(token, authToken) + .then((out) => router.replace(out.redirect)) + .catch((e: Error) => setError(e.message || 'This link cannot be opened.')); + }, [isLoading, authToken, token, router]); + + return ( +
+ + {error ? ( + <> +

This link does not work any more

+

{error}

+ + Open your connectors + + + ) : ( +

Opening the setup…

+ )} +
+
+ ); +} diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index def0f715..a1a67e0e 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -445,6 +445,16 @@ export const connectors = { ), }; +/** One-time links an AI client hands out to finish a connector's setup. */ +export const setupLinks = { + resolve: (token: string, authToken: string) => + request<{ redirect: string }>('/api/setup-links/resolve', { + method: 'POST', + token: authToken, + body: { token }, + }), +}; + // Adapters (built-in connector recipes) export type SetupKind = 'none' | 'credentials' | 'oauth_browser'; diff --git a/packages/frontend/tests/e2e/setup-link.spec.ts b/packages/frontend/tests/e2e/setup-link.spec.ts new file mode 100644 index 00000000..f7f37142 --- /dev/null +++ b/packages/frontend/tests/e2e/setup-link.spec.ts @@ -0,0 +1,70 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * One-time links an AI client hands out ("open this to finish connecting + * Etsy"): /s/ resolves the token for the signed-in user and moves on to + * the guided setup; a used, expired or foreign link says so. + */ + +const USER = { id: 'u1', email: 'test@example.com', name: 'Test User', role: 'ADMIN', organizationId: 'o1', emailVerified: true }; + +async function signIn(page: Page) { + await page.context().addCookies([{ name: 'amcp_token', value: 'test-token', url: 'http://localhost:3100' }]); + await page.addInitScript((user) => { + localStorage.setItem('amcp_token', 'test-token'); + localStorage.setItem('amcp_user', JSON.stringify(user)); + }, USER); +} + +async function mockApi(page: Page, resolve: { status: number; body: unknown }) { + const resolved: any[] = []; + await page.route(/\/(api|health)\//, (route) => { + const req = route.request(); + const path = new URL(req.url()).pathname; + if (path === '/api/setup-links/resolve') { + resolved.push(req.postDataJSON()); + return route.fulfill({ status: resolve.status, contentType: 'application/json', body: JSON.stringify(resolve.body) }); + } + if (path === '/api/auth/me') { + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(USER) }); + } + return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }); + }); + return resolved; +} + +test('a valid link leads to the guided setup of its connector', async ({ page }) => { + await signIn(page); + const resolved = await mockApi(page, { + status: 200, + body: { redirect: '/connectors/setup/etsy?connector=c1&from=claude' }, + }); + + await page.goto('/s/tok_abc123'); + + await page.waitForURL(/\/connectors\/setup\/etsy\?connector=c1&from=claude$/, { timeout: 15_000 }); + expect(resolved).toEqual([{ token: 'tok_abc123' }]); +}); + +test('a used or expired link says so instead of opening anything', async ({ page }) => { + await signIn(page); + await mockApi(page, { + status: 410, + body: { statusCode: 410, message: 'This link was already used. Ask for a new one in the chat.' }, + }); + + await page.goto('/s/tok_used'); + + await expect(page.getByRole('heading', { name: 'This link does not work any more' })).toBeVisible(); + await expect(page.getByText('This link was already used.')).toBeVisible(); + expect(new URL(page.url()).pathname).toBe('/s/tok_used'); +}); + +test('signed out, it asks to sign in and comes back to the link', async ({ page }) => { + await mockApi(page, { status: 200, body: { redirect: '/connectors' } }); + + await page.goto('/s/tok_abc123'); + + await page.waitForURL(/\/login\?redirect=/, { timeout: 15_000 }); + expect(new URL(page.url()).searchParams.get('redirect')).toBe('/s/tok_abc123'); +}); From 3080a07db7ac0768c028b83602502d48a5d572ac Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 11:08:55 +0200 Subject: [PATCH 5/6] Setup fixes from the UI test: actionable verify errors, OAuth sign-in in the chat instructions, setup event kinds --- packages/backend/src/adapters/adapters.service.ts | 6 ++++-- .../backend/src/adapters/connector-setup.service.spec.ts | 1 + packages/backend/src/adapters/connector-setup.service.ts | 8 ++++++-- packages/backend/src/audit/product-event.service.ts | 4 +++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 120848c4..39f7c4ab 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -351,7 +351,8 @@ export class AdaptersService { kind, toolName: call.toolName, status: status ?? null, - message: `${upstream} ${hint}`.trim(), + // The hint is what the user acts on; the provider's own words follow. + message: hint ? `${hint} (${upstream.replace(/[.\s]+$/, '')})` : upstream, }; } } @@ -534,7 +535,8 @@ export class AdaptersService { toolName: call.toolName, durationMs: Date.now() - started, status: status ?? null, - message: `${upstream} ${hint}`.trim(), + // The hint is what the user acts on; the provider's own words follow. + message: hint ? `${hint} (${upstream.replace(/[.\s]+$/, '')})` : upstream, }; } } diff --git a/packages/backend/src/adapters/connector-setup.service.spec.ts b/packages/backend/src/adapters/connector-setup.service.spec.ts index 0377df77..757e688e 100644 --- a/packages/backend/src/adapters/connector-setup.service.spec.ts +++ b/packages/backend/src/adapters/connector-setup.service.spec.ts @@ -126,6 +126,7 @@ describe('ConnectorSetupService — install', () => { const { service, ctx } = build(); const out: any = await service.install(ctx, { adapter: 'etsy', settings: { ETSY_CLIENT_ID: 'ks' } }); expect(out.body.status).toBe('needs_input'); // the shared secret still has to be entered on the page + expect(out.body.whatTheUserDoes).toMatch(/enter Shared secret, then sign in to .+ and approve\./); expect(out.body.finishSetupUrl).toBeDefined(); }); diff --git a/packages/backend/src/adapters/connector-setup.service.ts b/packages/backend/src/adapters/connector-setup.service.ts index 48aa7a80..b4a70f8b 100644 --- a/packages/backend/src/adapters/connector-setup.service.ts +++ b/packages/backend/src/adapters/connector-setup.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { createHash, randomBytes } from 'crypto'; import { PrismaService } from '../common/prisma.service'; import { AdaptersService } from './adapters.service'; -import { describeAdapterEnvVars } from './env-var-meta'; +import { describeAdapterEnvVars, needsBrowserAuthorization } from './env-var-meta'; import { LicenseGuardService } from '../license/license-guard.service'; import { SecurityEvents, SecurityEventService } from '../audit/security-event.service'; import { ProductEvents, ProductEventService } from '../audit/product-event.service'; @@ -215,7 +215,11 @@ export class ConnectorSetupService implements SharedSetupProvider, OnModuleInit whatTheUserDoes: state.status === 'needs_authorization' ? `Open the link, then sign in to ${definition.name} and approve. It takes a minute.` - : `Open the link and enter ${state.missing.map((m) => descriptors.get(m)?.label ?? m).join(', ')}.`, + : `Open the link and enter ${state.missing.map((m) => descriptors.get(m)?.label ?? m).join(', ')}${ + needsBrowserAuthorization(definition) + ? `, then sign in to ${definition.name} and approve` + : '' + }.`, finishSetupUrl: link, linkValidFor: '30 minutes, for this user only', next: 'Give the user the link. When they say they are done, call setup_get_status.', diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index ae92f222..8be713a1 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -148,7 +148,9 @@ export class ProductEventService { * a client name or a server id, nothing that should ever be a secret, and a * fixed key set is what keeps an untrusted body from choosing property names. */ -const METADATA_KEYS = ['client', 'serverId', 'connectorId', 'adapterSlug'] as const; +// `kind`: what a setup involved or why its check failed ('credentials', +// 'auth'); `via`: where a connector was set up ('mcp' when from the chat). +const METADATA_KEYS = ['client', 'serverId', 'connectorId', 'adapterSlug', 'kind', 'via'] as const; function boundMetadata( metadata: Record | null | undefined, From 48dc1564924b620c0e64ef1d6a6eb4e7579d0b22 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sat, 3 Oct 2026 14:04:54 +0200 Subject: [PATCH 6/6] Verify: take only declared variable names from the request; drop an unused assignment --- packages/backend/src/adapters/adapters.service.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index a7e68fe3..f948ae42 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -257,10 +257,14 @@ export class AdaptersService { select: { envVars: true }, }); const env = (stored?.envVars ?? {}) as Record; - const merged: Record = {}; - for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && v) merged[k] = v; - for (const [k, v] of Object.entries(credentials ?? {})) if (typeof v === 'string' && v.trim()) merged[k] = v; - credentials = merged; + // Only names the adapter declares come from the request. + const declared = new Set([...adapter.requiredEnvVars, ...(adapter.optionalEnvVars ?? [])]); + const merged = new Map(); + for (const [k, v] of Object.entries(env)) if (typeof v === 'string' && v) merged.set(k, v); + for (const [k, v] of Object.entries(credentials ?? {})) { + if (declared.has(k) && typeof v === 'string' && v.trim()) merged.set(k, v); + } + credentials = Object.fromEntries(merged); } // Required fields left empty: say which, before anything else complains // about the address they would have formed. @@ -372,14 +376,12 @@ export class AdaptersService { }> { const adapter = this.getBySlug(slug); const { - credentials: cleaned, resolvedAuthConfig, encryptedAuth, resolvedBaseUrl, resolvedHeaders, envVarsToPersist, } = this.prepareConnector(adapter, credentials); - credentials = cleaned; const connector = await this.prisma.connector.create({ data: {