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.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.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/adapters.service.spec.ts b/packages/backend/src/adapters/adapters.service.spec.ts index fd2ab4e4..3bede6e9 100644 --- a/packages/backend/src/adapters/adapters.service.spec.ts +++ b/packages/backend/src/adapters/adapters.service.spec.ts @@ -348,6 +348,80 @@ 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' }); + }); +}); + describe('AdaptersService unlisted adapters', () => { const { getAdapter } = jest.requireActual('./catalog'); diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 15937e9d..f948ae42 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'; import { describeDiscoveredTools, mergeDiscoveredMcpTools } from './mcp-adapter.util'; @Injectable() @@ -43,10 +51,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 { @@ -115,18 +145,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); @@ -171,7 +201,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, ); @@ -191,6 +221,168 @@ 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; + // 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. + 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, + // The hint is what the user acts on; the provider's own words follow. + message: hint ? `${hint} (${upstream.replace(/[.\s]+$/, '')})` : upstream, + }; + } + } + + async importAdapter( + slug: string, + userId: string, + organizationId: string, + credentials?: Record, + ): Promise<{ + connectorId: string; + toolsCreated: number; + probe: ImportProbeResult | null; + }> { + const adapter = this.getBySlug(slug); + const { + resolvedAuthConfig, + encryptedAuth, + resolvedBaseUrl, + resolvedHeaders, + envVarsToPersist, + } = this.prepareConnector(adapter, credentials); + const connector = await this.prisma.connector.create({ data: { userId, @@ -394,7 +586,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, }; } } @@ -504,6 +697,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 333d8c1d..85c4fe5f 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -274,6 +274,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; @@ -325,6 +326,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 { @@ -732,6 +739,7 @@ export function listAdapters(): AdapterMeta[] { selfHostOnly: adapter.selfHostOnly, unlisted: adapter.unlisted, probe: adapter.probe, + envVarMeta: adapter.envVarMeta, })); } 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..757e688e --- /dev/null +++ b/packages/backend/src/adapters/connector-setup.service.spec.ts @@ -0,0 +1,173 @@ +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.whatTheUserDoes).toMatch(/enter Shared secret, then sign in to .+ and approve\./); + 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..b4a70f8b --- /dev/null +++ b/packages/backend/src/adapters/connector-setup.service.ts @@ -0,0 +1,369 @@ +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, 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'; +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(', ')}${ + 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.', + }, + }; + } + + // ── 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/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 1b517bc9..1b59420f 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 6397f277..233ab0cc 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/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 b35b34bb..8be713a1 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 @@ -48,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]; @@ -59,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)), @@ -129,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, 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/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..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'; @@ -26,7 +27,8 @@ 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 { 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 } = @@ -305,6 +326,20 @@ export class McpEndpointController { return result; }, 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, @@ -559,6 +594,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 +1332,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.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/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-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 c10c0ae1..78d32c4f 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,105 @@ 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); + }); +}); + +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 fa7cf10e..51b431d7 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'; @@ -74,12 +74,108 @@ 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; 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'; +} + +/** + * 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; @@ -100,6 +196,16 @@ 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; + /** + * 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; @@ -176,15 +282,28 @@ 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), + ...(deps.setup ? setupTools(deps.setup.organizationId) : []), + ]; + const pending = served.filter((t) => !isListable(t)); const connectorIds = [...new Set(tools.map((t) => t.connectorId))]; let summaries: Promise> | null = null; 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) => @@ -234,6 +353,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( @@ -291,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); }; @@ -325,8 +460,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,9 +484,11 @@ export function registerSharedToolset( 'Payment, banking and trading connectors are only served on their server\'s own URL.', } : {}), - ...(connectors.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.', } : {}), }); @@ -512,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/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 e294e59d..35645f1f 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 83881f55..a9c12d04 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..5ee6a307 --- /dev/null +++ b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx @@ -0,0 +1,456 @@ +'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 c641df8a..1e6b3e8f 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 { adapterAuthLabel, adapterNeedsCredentials, 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 = !adapterNeedsCredentials(adapter); - 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,215 +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: {adapterAuthLabel({ authType: configAdapter.connector?.authType, requiredEnvVars: configAdapter.requiredEnvVars })} -
- - {/* 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 ( @@ -940,15 +615,6 @@ function DownloadIcon() { ); } -function CloseIcon() { - return ( - - - - - ); -} - function LockIcon() { return ( 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/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..a1a67e0e 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -445,7 +445,51 @@ 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'; + +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 +506,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..d4a5f57e --- /dev/null +++ b/packages/frontend/tests/e2e/connector-guided-setup.spec.ts @@ -0,0 +1,158 @@ +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(); + // A text field then a password field reads as a login form: the browser + // must not fill the user's own e-mail and password into the app's keys. + await expect(page.getByLabel('Keystring')).toHaveAttribute('autocomplete', 'off'); + await expect(page.getByLabel('Shared secret')).toHaveAttribute('autocomplete', 'new-password'); + await expect(page.getByLabel('Shared secret')).toHaveAttribute('data-1p-ignore', 'true'); + 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/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts b/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts index 9f85649b..ef75f5f7 100644 --- a/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts +++ b/packages/frontend/tests/e2e/install-form-no-autofill.spec.ts @@ -28,7 +28,19 @@ test('credential fields opt out of autofill, and a body-key adapter is not label const url = route.request().url(); const json = (b: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) }); if (/\/api\/adapters\/odoo-jsonrpc$/.test(url)) { - return json({ ...ODOO, instructions: 'Four values.', connector: { authType: 'NONE', baseUrl: '{{ODOO_URL}}' }, tools: [] }); + return json({ + ...ODOO, + instructions: 'Four values.', + connector: { name: 'Odoo', type: 'REST', authType: 'NONE', baseUrl: '{{ODOO_URL}}' }, + tools: [], + setupKind: 'credentials', + envVars: [ + { name: 'ODOO_URL', required: true, label: 'URL', kind: 'address', secret: false }, + { name: 'ODOO_DB', required: true, label: 'Database', kind: 'setting', secret: false }, + { name: 'ODOO_UID', required: true, label: 'User ID', kind: 'setting', secret: false }, + { name: 'ODOO_API_KEY', required: true, label: 'API key', kind: 'credential', secret: true }, + ], + }); } if (/\/api\/adapters(\?|$)/.test(url)) return json([ODOO, HN]); if (url.includes('/api/users/me/onboarding-state')) return json({ onboardingCompletedAt: '2026-01-01T00:00:00Z' }); @@ -43,9 +55,11 @@ test('credential fields opt out of autofill, and a body-key adapter is not label await expect(page.getByText('API Key', { exact: true }).first()).toBeVisible(); await expect(page.getByText('Public API', { exact: true })).toHaveCount(1); // Hacker News only + // Install opens the guided setup, whose fields carry the same opt-outs. await page.getByRole('button', { name: 'Install' }).first().click(); - const uid = page.locator('#cred-ODOO_UID'); - const key = page.locator('#cred-ODOO_API_KEY'); + await page.waitForURL(/\/connectors\/setup\/odoo-jsonrpc/); + const uid = page.getByLabel('User ID'); + const key = page.getByLabel('API key'); await expect(uid).toBeVisible({ timeout: 10_000 }); await expect(uid).toHaveAttribute('autocomplete', 'off'); await expect(key).toHaveAttribute('autocomplete', 'new-password'); 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'); +}); 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']); +});