diff --git a/CITATION.cff b/CITATION.cff
index 951026a1..9c579a99 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -13,7 +13,7 @@ abstract: >
server that turns REST/OpenAPI, SOAP/WSDL and GraphQL APIs, SQL/NoSQL
databases and other MCP servers into tools for AI clients such as Claude,
ChatGPT, Google Gemini, GitHub Copilot and Cursor, without writing code.
- It ships 475 pre-built adapters, with a focus on ERP and e-commerce
+ It ships 477 pre-built adapters, with a focus on ERP and e-commerce
systems (SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware,
WooCommerce, Amazon Seller, Kaufland, OTTO, etc.), a per-workspace
knowledge graph served over MCP, per-tool response mapping,
diff --git a/README.de.md b/README.de.md
index 646c064b..fda8cfe0 100644
--- a/README.de.md
+++ b/README.de.md
@@ -1,5 +1,5 @@
-
+
AnythingMCP: selbst gehostetes MCP-Gateway
@@ -19,7 +19,7 @@
AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 475 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 20 davon kommen ohne API-Schlüssel aus.
+ Es bringt 477 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 20 davon kommen ohne API-Schlüssel aus.
@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec
## Connector-Katalog
-475 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen.
+477 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen.
| Kategorie | Beispiele |
|---|---|
diff --git a/README.ja.md b/README.ja.md
index d40051e1..0cf15449 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -1,5 +1,5 @@
AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 475 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 20 of them need no API key.
+ It ships 477 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 20 of them need no API key.
@@ -103,7 +103,7 @@ Tools register at runtime, without a restart. Per-connector `{{VAR}}` values are
## Connector catalog
-475 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages.
+477 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages.
| Category | Examples |
|---|---|
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 6a48a322..eb80f71d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,5 +1,5 @@
@@ -111,7 +111,7 @@ docker compose up -d
## 连接器目录
-共 475 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
+共 477 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。
| 类别 | 示例 |
|---|---|
diff --git a/docs/connectors/proxy.md b/docs/connectors/proxy.md
index 32e323ad..7efd6f9a 100644
--- a/docs/connectors/proxy.md
+++ b/docs/connectors/proxy.md
@@ -79,7 +79,14 @@ rate-limited APIs. Default is `false`.
Adapters shipped with `useProxy: true` today: Deutsche Bahn, Sorare,
OpenTable, Resy, Untappd, idealista, Trenitalia, ImmobilienScout24, Etsy,
-Mercado Libre.
+Mercado Libre, Playtomic.
+
+A `LOGIN_TOKEN` connector logs in through the proxy when the tool call that
+needs the token is proxied, so the login leaves from the same address as the
+call. Playtomic is why: it answers a login from a datacenter IP with a 403
+page, so a proxied tool whose login went out directly never got a token.
+Like every proxied request, the login's TLS is terminated by the proxy, so
+the credentials in its body pass through it.
For GraphQL adapters, if any tool opts in, the auto-injected
`_graphql_query` / `_mutation` / `_subscription` helpers inherit the
diff --git a/glama.json b/glama.json
index ea66d19f..8405894e 100644
--- a/glama.json
+++ b/glama.json
@@ -3,5 +3,5 @@
"maintainers": [
"helpcodeai"
],
- "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 475 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0."
+ "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 477 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0."
}
diff --git a/package.json b/package.json
index 08054bb8..def08cc9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "anythingmcp",
"version": "0.37.0",
- "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 475 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).",
+ "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 477 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).",
"private": true,
"license": "AGPL-3.0-only",
"engines": {
diff --git a/packages/backend/src/adapters/adapters.controller.ts b/packages/backend/src/adapters/adapters.controller.ts
index c330261c..4661b015 100644
--- a/packages/backend/src/adapters/adapters.controller.ts
+++ b/packages/backend/src/adapters/adapters.controller.ts
@@ -118,6 +118,19 @@ export class AdaptersController {
return { items: await this.adaptersService.popularConnectors(req.user.organizationId) };
}
+ @Get('match-url')
+ @ApiOperation({
+ summary: 'Catalog connectors for an API base URL',
+ description:
+ 'The listed adapters whose API lives at the given URL (matched by host, then base path), best first, ' +
+ 'so the custom connector form can offer the ready-made connector. An unparseable URL returns none.',
+ })
+ @ApiQuery({ name: 'url', required: true })
+ matchUrl(@Query('url') url: string | undefined) {
+ if (typeof url !== 'string') throw new BadRequestException('url is required');
+ return { matches: this.adaptersService.matchByUrl(url) };
+ }
+
@Get('starter-pack')
@ApiOperation({
summary: 'Connectors offered to a new workspace',
diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts
index 41b7dcaa..601dfd56 100644
--- a/packages/backend/src/adapters/adapters.service.ts
+++ b/packages/backend/src/adapters/adapters.service.ts
@@ -44,12 +44,14 @@ import { storableBaseUrl } from '../connectors/catalog-env-rebuild.util';
import { describeDiscoveredTools, mergeDiscoveredMcpTools } from './mcp-adapter.util';
import { deriveToolAnnotations } from '../mcp-server/tool-annotations';
import { mcpToolPrefixOf } from '../connectors/mcp-connector-config.util';
+import { matchCatalogUrl, UrlMatch, UrlMatchCandidate } from './catalog-url-match';
@Injectable()
export class AdaptersService {
private readonly logger = new Logger(AdaptersService.name);
private readonly encryptionKey: string;
private readonly publicCatalogCache = new Map();
+ private urlMatchCandidates: UrlMatchCandidate[] | null = null;
constructor(
private readonly prisma: PrismaService,
@@ -95,6 +97,21 @@ export class AdaptersService {
return entry;
}
+ /**
+ * The listed adapters that serve the API at `url`, for the custom connector
+ * form: someone typing a base URL the catalog knows is offered the
+ * ready-made connector (see catalog-url-match.ts).
+ */
+ matchByUrl(url: string): UrlMatch[] {
+ this.urlMatchCandidates ??= this.listAll().flatMap((a) => {
+ const baseUrl = getAdapter(a.slug)?.connector?.baseUrl;
+ return typeof baseUrl === 'string'
+ ? [{ slug: a.slug, name: a.name, icon: a.icon, description: a.description, toolCount: a.toolCount, baseUrl }]
+ : [];
+ });
+ return matchCatalogUrl(url, this.urlMatchCandidates);
+ }
+
/** Setup instructions of one listed adapter, for its marketplace page. */
publicInstructions(slug: string): { slug: string; instructions: string | null } {
const adapter = this.getBySlug(slug);
diff --git a/packages/backend/src/adapters/catalog-url-match.spec.ts b/packages/backend/src/adapters/catalog-url-match.spec.ts
new file mode 100644
index 00000000..cca22ea0
--- /dev/null
+++ b/packages/backend/src/adapters/catalog-url-match.spec.ts
@@ -0,0 +1,108 @@
+import { hostPattern, matchCatalogUrl, UrlMatchCandidate, URL_MATCH_MAX } from './catalog-url-match';
+import { getAdapter, listAdapters } from './catalog';
+
+const candidate = (slug: string, baseUrl: string, toolCount = 5): UrlMatchCandidate => ({
+ slug,
+ name: slug,
+ icon: slug,
+ description: '',
+ toolCount,
+ baseUrl,
+});
+
+describe('hostPattern', () => {
+ it('keeps a fixed host as it is, without www', () => {
+ expect(hostPattern('https://www.example.com/api/v1')).toMatchObject({
+ exact: 'example.com',
+ path: '/api/v1',
+ });
+ });
+
+ it('turns a variable label into a wildcard while a domain is left', () => {
+ const p = hostPattern('https://{{SHOP}}.myshopify.com/admin/api/2025-01');
+ expect(p?.exact).toBeNull();
+ expect(p?.regex?.test('acme.myshopify.com')).toBe(true);
+ expect(p?.regex?.test('myshopify.com.evil.io')).toBe(false);
+ });
+
+ it('matches nothing when the host is only a variable', () => {
+ expect(hostPattern('{{ODOO_URL}}')).toBeNull();
+ expect(hostPattern('https://{{HOST}}/api')).toBeNull();
+ });
+
+ it('cuts the base path at its first variable', () => {
+ expect(hostPattern('https://api.example.com/v1/{{ACCOUNT}}/items')?.path).toBe('/v1');
+ });
+});
+
+describe('matchCatalogUrl', () => {
+ const list = [
+ candidate('graph-a', 'https://graph.example.com/v1/ads', 18),
+ candidate('graph-b', 'https://graph.example.com/v1', 20),
+ candidate('shop', 'https://{{SHOP}}.shop.example.com', 12),
+ candidate('other', 'https://api.other.com', 3),
+ ];
+
+ it('accepts a URL typed without a scheme', () => {
+ expect(matchCatalogUrl('api.other.com/v2', list).map((m) => m.slug)).toEqual(['other']);
+ });
+
+ it('keeps only the adapters whose base path the URL starts with, when any', () => {
+ expect(matchCatalogUrl('https://graph.example.com/v1/ads/123', list).map((m) => m.slug)).toEqual([
+ 'graph-a',
+ 'graph-b',
+ ]);
+ });
+
+ it('lists every adapter on the host when no base path fits, most tools first', () => {
+ expect(matchCatalogUrl('https://graph.example.com/v9', list).map((m) => m.slug)).toEqual([
+ 'graph-b',
+ 'graph-a',
+ ]);
+ });
+
+ it('matches a tenant host against a variable label', () => {
+ expect(matchCatalogUrl('https://acme.shop.example.com/x', list).map((m) => m.slug)).toEqual(['shop']);
+ });
+
+ it('returns nothing for junk, local hosts and unknown APIs', () => {
+ expect(matchCatalogUrl('', list)).toEqual([]);
+ expect(matchCatalogUrl('not a url', list)).toEqual([]);
+ expect(matchCatalogUrl('http://localhost:8080', list)).toEqual([]);
+ expect(matchCatalogUrl('ftp://api.other.com', list)).toEqual([]);
+ expect(matchCatalogUrl('https://api.unknown.dev', list)).toEqual([]);
+ });
+
+ it('never returns more than the cap', () => {
+ const many = Array.from({ length: 12 }, (_, i) => candidate(`a${i}`, 'https://same.example.com'));
+ expect(matchCatalogUrl('https://same.example.com', many)).toHaveLength(URL_MATCH_MAX);
+ });
+});
+
+describe('matchCatalogUrl on the real catalog', () => {
+ const catalog: UrlMatchCandidate[] = listAdapters()
+ .filter((a) => !getAdapter(a.slug)?.unlisted)
+ .map((a) => ({
+ slug: a.slug,
+ name: a.name,
+ icon: a.icon,
+ description: a.description,
+ toolCount: a.toolCount,
+ baseUrl: getAdapter(a.slug)!.connector.baseUrl,
+ }));
+ const slugsFor = (url: string) => matchCatalogUrl(url, catalog).map((m) => m.slug);
+
+ // Base URLs of custom connectors users built by hand on the Cloud in
+ // October 2026, for APIs the catalog already covered.
+ it.each([
+ ['https://api-v2.fattureincloud.it', 'fatture-in-cloud'],
+ ['https://api.printify.com/v1', 'printify'],
+ ['https://api.printful.com', 'printful'],
+ ['https://api.pinterest.com/v5', 'pinterest'],
+ ['https://cloud-api.yandex.net/v1/disk', 'yandex-disk'],
+ ['https://openapi.etsy.com/v3', 'etsy'],
+ ['https://api.telegram.org/file/bot{{TELEGRAM_BOT_TOKEN}}', 'telegram-bot'],
+ ])('offers the catalog connector for %s', (url, slug) => {
+ expect(slugsFor(url)).toContain(slug);
+ });
+});
diff --git a/packages/backend/src/adapters/catalog-url-match.ts b/packages/backend/src/adapters/catalog-url-match.ts
new file mode 100644
index 00000000..e6779cd9
--- /dev/null
+++ b/packages/backend/src/adapters/catalog-url-match.ts
@@ -0,0 +1,130 @@
+/**
+ * Which catalog adapters talk to the API at a given URL.
+ *
+ * People keep building by hand connectors the catalog already has: in four
+ * days of October 2026, custom connectors were made for Fatture in Cloud,
+ * Printify, Printful, Pinterest, ERPNext, The Odds API, Yandex Disk and Odoo,
+ * most with fewer tools than the catalog's. The new-connector form sends the
+ * base URL the user types here and offers the ready-made connector instead.
+ *
+ * Matching is by host. An adapter whose host contains a variable
+ * (`https://{{SHOP}}.myshopify.com`) matches any value of it, as long as a
+ * fixed domain is left (`{{ODOO_URL}}` alone matches nothing). When several
+ * adapters share a host (graph.facebook.com, www.googleapis.com), the ones
+ * whose base path the URL starts with come first, and only they are kept.
+ */
+
+export interface UrlMatchCandidate {
+ slug: string;
+ name: string;
+ icon: string;
+ description: string;
+ toolCount: number;
+ baseUrl: string;
+}
+
+export interface UrlMatch {
+ slug: string;
+ name: string;
+ icon: string;
+ description: string;
+ toolCount: number;
+}
+
+/** At most this many suggestions; a shared host lists the best few. */
+export const URL_MATCH_MAX = 5;
+
+const VAR = '__amcp_var__';
+
+interface HostPattern {
+ exact: string | null;
+ regex: RegExp | null;
+ path: string;
+}
+
+/** The host part of a URL the user typed, with or without a scheme. */
+function parseUserUrl(input: string): { host: string; path: string } | null {
+ const raw = input.trim();
+ if (!raw || raw.length > 2048) return null;
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
+ try {
+ const url = new URL(withScheme);
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
+ const host = url.hostname.toLowerCase().replace(/^www\./, '').replace(/\.$/, '');
+ if (!host.includes('.')) return null;
+ return { host, path: normalizePath(url.pathname) };
+ } catch {
+ return null;
+ }
+}
+
+function normalizePath(path: string): string {
+ return path.toLowerCase().replace(/\/+$/, '');
+}
+
+const patternCache = new Map();
+
+/** How an adapter's base URL matches a host, or null if it cannot. */
+export function hostPattern(baseUrl: string): HostPattern | null {
+ if (patternCache.has(baseUrl)) return patternCache.get(baseUrl)!;
+ let pattern: HostPattern | null = null;
+ try {
+ const url = new URL(baseUrl.replace(/\{\{[^}]+\}\}/g, VAR));
+ const host = url.hostname.toLowerCase().replace(/^www\./, '');
+ // The fixed part must still name a domain: `x.myshopify.com` keeps
+ // `myshopify.com`, a host that is only a variable keeps nothing.
+ const fixed = host.split('.').filter((label) => !label.includes(VAR));
+ const path = url.pathname.includes(VAR)
+ ? normalizePath(url.pathname.slice(0, url.pathname.indexOf(VAR)))
+ : normalizePath(url.pathname);
+ if (fixed.length >= 2 && !host.startsWith('localhost')) {
+ pattern = host.includes(VAR)
+ ? {
+ exact: null,
+ regex: new RegExp(
+ '^' +
+ host
+ .split(VAR)
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ .join('[a-z0-9-]+(?:\\.[a-z0-9-]+)*') +
+ '$',
+ ),
+ path,
+ }
+ : { exact: host, regex: null, path };
+ }
+ } catch {
+ pattern = null;
+ }
+ patternCache.set(baseUrl, pattern);
+ return pattern;
+}
+
+/** The adapters among `candidates` that serve the API at `url`, best first. */
+export function matchCatalogUrl(url: string, candidates: UrlMatchCandidate[]): UrlMatch[] {
+ const target = parseUserUrl(url);
+ if (!target) return [];
+ const scored: Array<{ c: UrlMatchCandidate; score: number; pathHit: boolean }> = [];
+ for (const c of candidates) {
+ const p = hostPattern(c.baseUrl);
+ if (!p) continue;
+ const hostHit = p.exact ? p.exact === target.host : p.regex!.test(target.host);
+ if (!hostHit) continue;
+ const pathHit =
+ p.path !== '' && (target.path === p.path || target.path.startsWith(`${p.path}/`));
+ // A longer base path that fits is the more specific adapter.
+ scored.push({ c, score: (p.exact ? 2 : 1) + (pathHit ? 4 + p.path.length / 1000 : 0), pathHit });
+ }
+ const anyPathHit = scored.some((s) => s.pathHit);
+ return scored
+ .filter((s) => !anyPathHit || s.pathHit)
+ .sort((a, b) => b.score - a.score || b.c.toolCount - a.c.toolCount || a.c.name.localeCompare(b.c.name))
+ .slice(0, URL_MATCH_MAX)
+ .map(({ c }) => ({
+ slug: c.slug,
+ name: c.name,
+ icon: c.icon,
+ description: c.description,
+ toolCount: c.toolCount,
+ }));
+}
diff --git a/packages/backend/src/adapters/intl/playtomic-public.json b/packages/backend/src/adapters/intl/playtomic-public.json
index c354ecac..01eee99e 100644
--- a/packages/backend/src/adapters/intl/playtomic-public.json
+++ b/packages/backend/src/adapters/intl/playtomic-public.json
@@ -2,8 +2,7 @@
"slug": "playtomic-public",
"name": "Playtomic (Lite)",
"description": "Find padel, tennis, and futsal courts worldwide — read-only. Search clubs near you, check court availability and pricing, browse Playtomic's tenant catalog. 4 read tools; needs a free Playtomic account (email + password), no API key.",
- "instructions": "This is the **lite, read-only** Playtomic connector — the fastest way to give an AI agent the ability to find padel/tennis courts and check availability, without the 13-tool full connector's personal-data scope.\n\n**Authentication (handled automatically)**: Playtomic closed anonymous access to its API in mid-2025, so even read-only club search now needs an authenticated session. Provide a **free** Playtomic account (`PLAYTOMIC_EMAIL` + `PLAYTOMIC_PASSWORD`); the AnythingMCP `LOGIN_TOKEN` engine logs in, attaches `Authorization: Bearer ` to every call, caches the token encrypted, and re-logs in automatically ~60 s before expiry and on any `401`. Your password is stored encrypted and only sent over TLS to Playtomic's login endpoint. No club membership is required — any registered Playtomic user works.\n\n**What you can do**:\n- Search clubs (tenants) by geo coordinate + radius, optionally filtered by sport.\n- Fetch a single club's full details (resources/courts, opening info, address, timezone, images).\n- Read free time slots for a given day across one or more clubs, with prices.\n- Read Playtomic's sport configuration (allowed durations, resource types, sport list).\n\n**What requires the full Playtomic connector instead**:\n- Your own profile, level, match history, social graph.\n- Open matches you can join.\n- Member/club-specific discounted pricing (this connector returns generic pricing).\n- Booking a court (write operations are not exposed — Playtomic has no official public booking API).\n\n**Pricing notes**:\n- `price` is a string like `\"48 EUR\"` — parse amount + currency separately.\n- Same `start_time` may appear multiple times with different `duration` values (60/90/120 min). These are alternative bookings of the same slot, not separate free slots.\n- `duration` is in **minutes**.\n\n**Time handling**:\n- `local_start_min` / `local_start_max` are naive ISO-8601 in the tenant's local timezone — find the timezone in `tenant.address.timezone` (e.g. `Europe/Berlin`).\n- For a full-day window use `2026-05-23T00:00:00` → `2026-05-23T23:59:59`.\n\n**Joining availability back to courts**: every availability row has a `resource_id`; map it back to `tenant.resources[].name` from `get_tenant` to know which court (indoor/outdoor, single/double, etc.).\n\n**Rate limits**: Playtomic has no published public-API SLA. Be gentle — under 1 req/s is safe. The connector identifies itself honestly as AnythingMCP (no UA spoofing).\n\n**Stability**: this is an undocumented/internal API reverse-engineered from the iOS app. Endpoints can change without notice. If a tool breaks, check the docs URL for updates.\n\n**Suggested starter prompts**:\n- \"Find padel clubs within 30 km of Milan that are open today.\"\n- \"What time slots are free on Saturday afternoon at FREIBURG PADEL?\"\n- \"Compare prices across all padel clubs in Madrid for Sunday morning, 90-minute sessions.\"\n- \"Which courts in Barcelona offer indoor crystal padel?\"",
- "unlisted": true,
+ "instructions": "This is the **lite, read-only** Playtomic connector — the fastest way to give an AI agent the ability to find padel/tennis courts and check availability, without the 13-tool full connector's personal-data scope.\n\n**Authentication (handled automatically)**: Playtomic closed anonymous access to its API in mid-2025, so even read-only club search now needs an authenticated session. Provide a **free** Playtomic account (`PLAYTOMIC_EMAIL` + `PLAYTOMIC_PASSWORD`); the AnythingMCP `LOGIN_TOKEN` engine logs in, attaches `Authorization: Bearer ` to every call, caches the token encrypted, and re-logs in automatically ~60 s before expiry and on any `401`. Your password is stored encrypted and only sent over TLS to Playtomic's login endpoint. No club membership is required — any registered Playtomic user works.\n\n**What you can do**:\n- Search clubs (tenants) by geo coordinate + radius, optionally filtered by sport.\n- Fetch a single club's full details (resources/courts, opening info, address, timezone, images).\n- Read free time slots for a given day across one or more clubs, with prices.\n- Read Playtomic's sport configuration (allowed durations, resource types, sport list).\n\n**What requires the full Playtomic connector instead**:\n- Your own profile, level, match history, social graph.\n- Open matches you can join.\n- Member/club-specific discounted pricing (this connector returns generic pricing).\n- Booking a court (write operations are not exposed — Playtomic has no official public booking API).\n\n**Pricing notes**:\n- `price` is a string like `\"48 EUR\"` — parse amount + currency separately.\n- Same `start_time` may appear multiple times with different `duration` values (60/90/120 min). These are alternative bookings of the same slot, not separate free slots.\n- `duration` is in **minutes**.\n\n**Time handling**:\n- `local_start_min` / `local_start_max` are naive ISO-8601 in the tenant's local timezone — find the timezone in `tenant.address.timezone` (e.g. `Europe/Berlin`).\n- For a full-day window use `2026-05-23T00:00:00` → `2026-05-23T23:59:59`.\n\n**Joining availability back to courts**: every availability row has a `resource_id`; map it back to `tenant.resources[].name` from `get_tenant` to know which court (indoor/outdoor, single/double, etc.).\n\n**Rate limits**: Playtomic has no published public-API SLA. Be gentle — under 1 req/s is safe. The connector identifies itself honestly as AnythingMCP (no UA spoofing).\n\n**Stability**: this is an undocumented/internal API reverse-engineered from the iOS app. Endpoints can change without notice. If a tool breaks, check the docs URL for updates.\n\n**Suggested starter prompts**:\n- \"Find padel clubs within 30 km of Milan that are open today.\"\n- \"What time slots are free on Saturday afternoon at FREIBURG PADEL?\"\n- \"Compare prices across all padel clubs in Madrid for Sunday morning, 90-minute sessions.\"\n- \"Which courts in Barcelona offer indoor crystal padel?\"\n\n## Network: routed through the connector proxy\n\nPlaytomic refuses requests from datacenter IP addresses: from a cloud server its login answers `403` with an HTML page. Every tool of this connector is therefore marked to go through the instance's connector proxy (`CONNECTOR_PROXY_URL`), and the login that mints the token goes through the same proxy. On AnythingMCP Cloud this is set up for you and the calls count towards the workspace's hourly proxy quota. Self-hosted from a home or office connection, it works without a proxy; from a datacenter, set `CONNECTOR_PROXY_URL` (see the proxy documentation). The proxy terminates TLS, so your Playtomic email and password pass through it on the way to Playtomic.",
"region": "intl",
"category": "sports",
"icon": "playtomic",
diff --git a/packages/backend/src/adapters/intl/playtomic-public.live.spec.ts b/packages/backend/src/adapters/intl/playtomic-public.live.spec.ts
index d1c90ca8..8c1c6f26 100644
--- a/packages/backend/src/adapters/intl/playtomic-public.live.spec.ts
+++ b/packages/backend/src/adapters/intl/playtomic-public.live.spec.ts
@@ -8,15 +8,17 @@ const a = adapter as unknown as {
connector: { baseUrl: string; authType: string; authConfig: Record };
tools: Array<{
name: string;
+ useProxy?: boolean;
endpointMapping: { method: string; path: string; queryParams?: Record };
}>;
};
describe('playtomic-public adapter — static spec conformance', () => {
- // Playtomic blocks the cloud's datacenter IP (403 HTML on login since
- // Aug 2026), so the connector is not offered. Installed ones keep resolving.
- it('is unlisted', () => {
- expect(a.unlisted).toBe(true);
+ // Playtomic refuses datacenter IPs (403 HTML on login from the cloud), so
+ // every tool goes through the connector proxy; the login follows the tool.
+ it('is listed, and every tool is routed through the connector proxy', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.tools.every((t) => t.useProxy === true)).toBe(true);
});
it('is the read-only LOGIN_TOKEN lite variant in the sports category', () => {
diff --git a/packages/backend/src/adapters/intl/playtomic.json b/packages/backend/src/adapters/intl/playtomic.json
index 23fd604a..47508e63 100644
--- a/packages/backend/src/adapters/intl/playtomic.json
+++ b/packages/backend/src/adapters/intl/playtomic.json
@@ -2,8 +2,7 @@
"slug": "playtomic",
"name": "Playtomic",
"description": "Full Playtomic access for padel, tennis, and futsal players. Search clubs and availability, find open matches to join, read your own profile, level, stats, history, and recommendations. 13 tools, email + password auth with automatic JWT refresh.",
- "instructions": "## Authentication (handled automatically)\n\nPlaytomic uses email + password to mint a ~1-hour JWT. The AnythingMCP `LOGIN_TOKEN` engine handles login, caching, and refresh:\n\n1. `POST https://api.app.playtomic.io/v3/auth/login` with `{ email, password, requested_user_roles: [\"ROLE_CUSTOMER\"] }`.\n2. The response contains `access_token` (JWT, ~1h TTL) and `refresh_token` (~60d).\n3. Every tool call attaches `Authorization: Bearer `.\n4. The engine proactively re-logs in 60 s before expiry and on any `401`. Tokens are cached encrypted in the `connector_auth_cache` DB table.\n\n## Credentials\n\n- `PLAYTOMIC_EMAIL` — your Playtomic account email.\n- `PLAYTOMIC_PASSWORD` — your plain account password. Stored encrypted; only sent over TLS to Playtomic's login endpoint.\n\n## When dedicated tools are not enough\n\nThe 13 tools below cover the common Playtomic workflows. Anything not exposed (e.g. accepting a join request, creating a match, paying for a booking) requires Playtomic's mobile app — there is no documented public booking write API and we deliberately don't expose write endpoints.\n\n## Public vs full connector\n\nIf you only want to **search clubs and check availability** (no personal data), use the `playtomic-public` (Playtomic Lite) connector instead — the same 4 read tools with a lighter scope. It still needs a free Playtomic account, since Playtomic closed anonymous API access.\n\n## Pricing notes\n\n- `price` is a string like `\"48 EUR\"` — parse amount + currency.\n- The same `start_time` may appear with several `duration` values (60/90/120 min). These are alternative bookings of the same slot, not separate free slots.\n- `duration` is in **minutes**.\n- If your account has linked club memberships, pricing returned by `playtomic_get_availability` reflects your member discount.\n\n## Time handling\n\n- `local_start_min` / `local_start_max` are naive ISO-8601 in the **tenant's** local timezone (see `tenant.address.timezone`, e.g. `Europe/Berlin`). For a full day use `T00:00:00` → `T23:59:59` on the same date.\n- Match/course/tournament date filters use the same naive ISO format unless otherwise noted.\n\n## Joining availability to courts\n\nEvery availability row has a `resource_id`. Map it back to `tenant.resources[]` (from `playtomic_get_tenant`) to know court name, indoor/outdoor, single/double, surface.\n\n## Levels & match recommendations\n\n- `level_value` is your Playtomic skill rating (typically 0–7). `level_confidence` reflects how many matches the rating is based on.\n- `playtomic_match_recommendations` returns open matches at your level near a coordinate. Empty array = no recommendations right now (it's normal).\n\n## Rate limits\n\nPlaytomic has no published SLA. Keep request volume reasonable (≤1 req/s steady state). The engine auto-handles `401` with a retry-after-relogin and gives up after one retry — if you see `401` twice, check that your credentials are correct.\n\n## Stability\n\nThis is an undocumented/internal API reverse-engineered from the Playtomic iOS app. Endpoints can change without notice. If a tool starts failing, check the AnythingMCP docs for a connector update.\n\n## Useful starter prompts\n\n- \"Find open padel matches at my level near Milan this weekend.\" → `playtomic_match_recommendations`\n- \"What's my current padel level on Playtomic?\" → `playtomic_get_my_level`\n- \"How many padel matches have I won this year?\" → `playtomic_get_my_stats`\n- \"At which clubs do I play the most?\" → `playtomic_get_top_clubs`\n- \"Show me free crystal-court slots tomorrow afternoon within 20 km of my home.\" → `playtomic_search_tenants` + `playtomic_get_availability`\n- \"Are there any padel tournaments coming up near Barcelona?\" → `playtomic_list_tournaments`",
- "unlisted": true,
+ "instructions": "## Authentication (handled automatically)\n\nPlaytomic uses email + password to mint a ~1-hour JWT. The AnythingMCP `LOGIN_TOKEN` engine handles login, caching, and refresh:\n\n1. `POST https://api.app.playtomic.io/v3/auth/login` with `{ email, password, requested_user_roles: [\"ROLE_CUSTOMER\"] }`.\n2. The response contains `access_token` (JWT, ~1h TTL) and `refresh_token` (~60d).\n3. Every tool call attaches `Authorization: Bearer `.\n4. The engine proactively re-logs in 60 s before expiry and on any `401`. Tokens are cached encrypted in the `connector_auth_cache` DB table.\n\n## Credentials\n\n- `PLAYTOMIC_EMAIL` — your Playtomic account email.\n- `PLAYTOMIC_PASSWORD` — your plain account password. Stored encrypted; only sent over TLS to Playtomic's login endpoint.\n\n## When dedicated tools are not enough\n\nThe 13 tools below cover the common Playtomic workflows. Anything not exposed (e.g. accepting a join request, creating a match, paying for a booking) requires Playtomic's mobile app — there is no documented public booking write API and we deliberately don't expose write endpoints.\n\n## Public vs full connector\n\nIf you only want to **search clubs and check availability** (no personal data), use the `playtomic-public` (Playtomic Lite) connector instead — the same 4 read tools with a lighter scope. It still needs a free Playtomic account, since Playtomic closed anonymous API access.\n\n## Pricing notes\n\n- `price` is a string like `\"48 EUR\"` — parse amount + currency.\n- The same `start_time` may appear with several `duration` values (60/90/120 min). These are alternative bookings of the same slot, not separate free slots.\n- `duration` is in **minutes**.\n- If your account has linked club memberships, pricing returned by `playtomic_get_availability` reflects your member discount.\n\n## Time handling\n\n- `local_start_min` / `local_start_max` are naive ISO-8601 in the **tenant's** local timezone (see `tenant.address.timezone`, e.g. `Europe/Berlin`). For a full day use `T00:00:00` → `T23:59:59` on the same date.\n- Match/course/tournament date filters use the same naive ISO format unless otherwise noted.\n\n## Joining availability to courts\n\nEvery availability row has a `resource_id`. Map it back to `tenant.resources[]` (from `playtomic_get_tenant`) to know court name, indoor/outdoor, single/double, surface.\n\n## Levels & match recommendations\n\n- `level_value` is your Playtomic skill rating (typically 0–7). `level_confidence` reflects how many matches the rating is based on.\n- `playtomic_match_recommendations` returns open matches at your level near a coordinate. Empty array = no recommendations right now (it's normal).\n\n## Rate limits\n\nPlaytomic has no published SLA. Keep request volume reasonable (≤1 req/s steady state). The engine auto-handles `401` with a retry-after-relogin and gives up after one retry — if you see `401` twice, check that your credentials are correct.\n\n## Stability\n\nThis is an undocumented/internal API reverse-engineered from the Playtomic iOS app. Endpoints can change without notice. If a tool starts failing, check the AnythingMCP docs for a connector update.\n\n## Useful starter prompts\n\n- \"Find open padel matches at my level near Milan this weekend.\" → `playtomic_match_recommendations`\n- \"What's my current padel level on Playtomic?\" → `playtomic_get_my_level`\n- \"How many padel matches have I won this year?\" → `playtomic_get_my_stats`\n- \"At which clubs do I play the most?\" → `playtomic_get_top_clubs`\n- \"Show me free crystal-court slots tomorrow afternoon within 20 km of my home.\" → `playtomic_search_tenants` + `playtomic_get_availability`\n- \"Are there any padel tournaments coming up near Barcelona?\" → `playtomic_list_tournaments`\n\n## Network: routed through the connector proxy\n\nPlaytomic refuses requests from datacenter IP addresses: from a cloud server its login answers `403` with an HTML page. Every tool of this connector is therefore marked to go through the instance's connector proxy (`CONNECTOR_PROXY_URL`), and the login that mints the token goes through the same proxy. On AnythingMCP Cloud this is set up for you and the calls count towards the workspace's hourly proxy quota. Self-hosted from a home or office connection, it works without a proxy; from a datacenter, set `CONNECTOR_PROXY_URL` (see the proxy documentation). The proxy terminates TLS, so your Playtomic email and password pass through it on the way to Playtomic.",
"region": "intl",
"category": "sports",
"icon": "playtomic",
diff --git a/packages/backend/src/adapters/intl/playtomic.live.spec.ts b/packages/backend/src/adapters/intl/playtomic.live.spec.ts
index 69b34dc4..1c1ffc19 100644
--- a/packages/backend/src/adapters/intl/playtomic.live.spec.ts
+++ b/packages/backend/src/adapters/intl/playtomic.live.spec.ts
@@ -12,15 +12,17 @@ const a = adapter as unknown as {
};
tools: Array<{
name: string;
+ useProxy?: boolean;
endpointMapping: { method: string; path: string; queryParams?: Record };
}>;
};
describe('playtomic adapter — static spec conformance', () => {
- // Playtomic blocks the cloud's datacenter IP (403 HTML on login since
- // Aug 2026), so the connector is not offered. Installed ones keep resolving.
- it('is unlisted', () => {
- expect(a.unlisted).toBe(true);
+ // Playtomic refuses datacenter IPs (403 HTML on login from the cloud), so
+ // every tool goes through the connector proxy; the login follows the tool.
+ it('is listed, and every tool is routed through the connector proxy', () => {
+ expect(a.unlisted).toBeUndefined();
+ expect(a.tools.every((t) => t.useProxy === true)).toBe(true);
});
it('is the full LOGIN_TOKEN variant in the sports category', () => {
diff --git a/packages/backend/src/connectors/engines/graphql.engine.ts b/packages/backend/src/connectors/engines/graphql.engine.ts
index 41633a3d..3e9aae99 100644
--- a/packages/backend/src/connectors/engines/graphql.engine.ts
+++ b/packages/backend/src/connectors/engines/graphql.engine.ts
@@ -122,6 +122,7 @@ export class GraphqlEngine {
const bundle = await this.loginTokenService.getToken(
auth,
config.connectorId,
+ { proxyUrl: config.proxyUrl },
);
applyLoginTokenHeaders(headers, auth, bundle.token, bundle.aud);
break;
@@ -254,6 +255,7 @@ export class GraphqlEngine {
const bundle = await this.loginTokenService.forceRelogin(
auth,
config.connectorId,
+ { proxyUrl: config.proxyUrl },
);
applyLoginTokenHeaders(headers, auth, bundle.token, bundle.aud);
const retry = await axios.post(config.baseUrl, requestConfig, {
diff --git a/packages/backend/src/connectors/engines/login-token.service.spec.ts b/packages/backend/src/connectors/engines/login-token.service.spec.ts
index c0c38128..31dae09d 100644
--- a/packages/backend/src/connectors/engines/login-token.service.spec.ts
+++ b/packages/backend/src/connectors/engines/login-token.service.spec.ts
@@ -1,6 +1,7 @@
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as bcrypt from 'bcrypt';
+import { HttpsProxyAgent } from 'https-proxy-agent';
import {
LoginTokenService,
jsonPath,
@@ -264,6 +265,45 @@ describe('LoginTokenService', () => {
expect(bundle.token).toBe('jwt-plain');
});
+ it('sends the login, salt fetch included, through the proxy the tool call uses', async () => {
+ // Playtomic answers logins from our server's IP with a 403 page; through
+ // the connector proxy it answers normally. A proxied tool whose login went
+ // out directly could never get a token.
+ const seen: any[] = [];
+ (mockedAxios as unknown as jest.Mock).mockImplementation(async (config: any) => {
+ seen.push(config);
+ if (config.url.includes('/users/')) {
+ return { data: { salt: bcrypt.genSaltSync(4) } };
+ }
+ return { data: { data: { signIn: { token: 'jwt-proxied', aud: 'x' } } } };
+ });
+
+ const bundle = await service.getToken(baseAuth, 'conn-proxy', {
+ proxyUrl: 'http://key:@proxy.example.com:8011',
+ });
+
+ expect(bundle.token).toBe('jwt-proxied');
+ expect(seen).toHaveLength(2);
+ for (const config of seen) {
+ expect(config.proxy).toBe(false);
+ expect(config.httpsAgent).toBeInstanceOf(HttpsProxyAgent);
+ expect(config.httpsAgent.proxy.host).toBe('proxy.example.com:8011');
+ }
+ });
+
+ it('logs in directly when the call is not proxied', async () => {
+ (mockedAxios as unknown as jest.Mock).mockImplementation(async (config: any) => {
+ expect(config.httpsAgent).not.toBeInstanceOf(HttpsProxyAgent);
+ expect(config.proxy).toBeUndefined();
+ return { data: { data: { signIn: { token: 'jwt-direct', aud: 'x' } } } };
+ });
+
+ const auth: LoginTokenAuthConfig = { ...baseAuth, passwordHashing: { scheme: 'none' } };
+ await expect(service.getToken(auth, 'conn-direct')).resolves.toMatchObject({
+ token: 'jwt-direct',
+ });
+ });
+
it("surfaces the service's own refusal rather than our json path", async () => {
// This body is what a rejected sign-in actually looks like: HTTP 200, no
// token, and the reason in `errors`. Reporting the missing json path here
diff --git a/packages/backend/src/connectors/engines/login-token.service.ts b/packages/backend/src/connectors/engines/login-token.service.ts
index dab42819..865e6b37 100644
--- a/packages/backend/src/connectors/engines/login-token.service.ts
+++ b/packages/backend/src/connectors/engines/login-token.service.ts
@@ -7,6 +7,7 @@ import { encrypt, decrypt } from '../../common/crypto/encryption.util';
import { getRequiredSecret } from '../../common/secrets.util';
import { assertSafeOutboundUrl } from '../../common/ssrf.util';
import { outboundAxiosOptions } from '../../common/outbound-http';
+import { createUnblockerProxyAgent } from './unblocker-proxy-agent';
const DEFAULT_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
const DEFAULT_PROACTIVE_REFRESH_SECONDS = 24 * 60 * 60; // 1 day
@@ -20,6 +21,16 @@ const MIN_PROACTIVE_REFRESH_MS = 30 * 1000;
// long before trying again, instead of hitting the vendor's login on every call.
const PROACTIVE_RETRY_BACKOFF_MS = 30 * 1000;
+/**
+ * How the login request travels. A tool routed through the connector proxy
+ * (`use_proxy`) passes its proxy here, so the login leaves from the same
+ * address as the call it authorises: a vendor that blocks the server's IP
+ * (Playtomic answers its login with a 403 page) blocks the login as well.
+ */
+export interface LoginRequestOptions {
+ proxyUrl?: string;
+}
+
/**
* How long before `expiresAt` a token counts as due for renewal.
*
@@ -160,6 +171,7 @@ export class LoginTokenService {
async getToken(
authConfig: LoginTokenAuthConfig,
connectorId?: string,
+ request: LoginRequestOptions = {},
): Promise {
const key = this.cacheKey(authConfig, connectorId);
const cached = this.cache.get(key);
@@ -175,7 +187,7 @@ export class LoginTokenService {
const pending = this.resolving.get(key);
if (pending) return pending;
- const p = this.resolveToken(authConfig, connectorId, cached).finally(() => {
+ const p = this.resolveToken(authConfig, connectorId, cached, request).finally(() => {
this.resolving.delete(key);
});
this.resolving.set(key, p);
@@ -186,6 +198,7 @@ export class LoginTokenService {
authConfig: LoginTokenAuthConfig,
connectorId: string | undefined,
cached: LoginTokenBundle | undefined,
+ request: LoginRequestOptions,
): Promise {
const key = this.cacheKey(authConfig, connectorId);
let current = cached;
@@ -199,7 +212,7 @@ export class LoginTokenService {
}
try {
- return await this.loginWithMutex(authConfig, connectorId);
+ return await this.loginWithMutex(authConfig, connectorId, request);
} catch (err) {
// A proactive refresh that fails (the vendor's login is rate-limited or
// briefly down) must not fail a call the current token can still serve.
@@ -232,21 +245,23 @@ export class LoginTokenService {
async forceRelogin(
authConfig: LoginTokenAuthConfig,
connectorId?: string,
+ request: LoginRequestOptions = {},
): Promise {
const key = this.cacheKey(authConfig, connectorId);
this.cache.delete(key);
- return this.loginWithMutex(authConfig, connectorId);
+ return this.loginWithMutex(authConfig, connectorId, request);
}
private async loginWithMutex(
authConfig: LoginTokenAuthConfig,
- connectorId?: string,
+ connectorId: string | undefined,
+ request: LoginRequestOptions,
): Promise {
const key = this.cacheKey(authConfig, connectorId);
const existing = this.inFlight.get(key);
if (existing) return existing;
- const p = this.performLogin(authConfig, connectorId).finally(() => {
+ const p = this.performLogin(authConfig, connectorId, request).finally(() => {
this.inFlight.delete(key);
});
this.inFlight.set(key, p);
@@ -255,7 +270,8 @@ export class LoginTokenService {
private async performLogin(
authConfig: LoginTokenAuthConfig,
- connectorId?: string,
+ connectorId: string | undefined,
+ request: LoginRequestOptions,
): Promise {
this.requireString(authConfig.loginUrl, 'loginUrl');
this.requireString(authConfig.username, 'username');
@@ -264,7 +280,7 @@ export class LoginTokenService {
this.requireString(authConfig.tokenJsonPath, 'tokenJsonPath');
}
- const passwordToSend = await this.preparePassword(authConfig);
+ const passwordToSend = await this.preparePassword(authConfig, request);
const templateParams: Record = {
username: authConfig.username,
@@ -303,7 +319,9 @@ export class LoginTokenService {
data = templateParams;
}
- this.logger.debug(`LOGIN_TOKEN: logging in via ${method} ${url}`);
+ this.logger.debug(
+ `LOGIN_TOKEN: logging in via ${method} ${url}${request.proxyUrl ? ' (via proxy)' : ''}`,
+ );
const response = await axios({
method,
url,
@@ -315,6 +333,7 @@ export class LoginTokenService {
credentialHeaders: Object.keys(authConfig.loginHeaders || {}),
credentialsInBody: true,
}),
+ ...proxyAgents(request),
});
let token: unknown;
@@ -387,6 +406,7 @@ export class LoginTokenService {
private async preparePassword(
authConfig: LoginTokenAuthConfig,
+ request: LoginRequestOptions,
): Promise {
const hashing = authConfig.passwordHashing;
if (!hashing || hashing.scheme === 'none') return authConfig.password;
@@ -396,7 +416,7 @@ export class LoginTokenService {
);
}
- const salt = await this.resolveSalt(authConfig);
+ const salt = await this.resolveSalt(authConfig, request);
if (!salt) {
throw new Error(
'LOGIN_TOKEN: passwordHashing.scheme=bcrypt requires a non-empty salt',
@@ -407,6 +427,7 @@ export class LoginTokenService {
private async resolveSalt(
authConfig: LoginTokenAuthConfig,
+ request: LoginRequestOptions,
): Promise {
const src = authConfig.passwordHashing?.saltSource;
if (!src) return null;
@@ -430,6 +451,7 @@ export class LoginTokenService {
headers: src.headers,
timeout: 10000,
...outboundAxiosOptions({ credentialHeaders: Object.keys(src.headers || {}) }),
+ ...proxyAgents(request),
});
const path = src.responsePath || 'salt';
@@ -746,3 +768,13 @@ export function extractLoginRefusal(body: unknown): string | null {
};
return walk(body, 0);
}
+
+/**
+ * Agents for a login that goes through the connector proxy, set after the
+ * outbound spread as the tool calls do (see rest.engine). Empty without one.
+ */
+function proxyAgents(request: LoginRequestOptions) {
+ if (!request.proxyUrl) return {};
+ const agent = createUnblockerProxyAgent(request.proxyUrl);
+ return { httpAgent: agent, httpsAgent: agent, proxy: false as const };
+}
diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts
index 31d178fe..52710bcb 100644
--- a/packages/backend/src/connectors/engines/rest.engine.ts
+++ b/packages/backend/src/connectors/engines/rest.engine.ts
@@ -528,6 +528,7 @@ export class RestEngine {
const bundle = await this.loginTokenService.forceRelogin(
authConfig,
config.connectorId,
+ { proxyUrl: config.proxyUrl },
);
injectLoginTokenHeaders(axiosConfig, authConfig, bundle.token, bundle.aud);
rebuildBody(axiosConfig, buildBody);
@@ -698,6 +699,7 @@ export class RestEngine {
authType: string;
authConfig?: Record;
connectorId?: string;
+ proxyUrl?: string;
},
): Promise {
if (!config.authConfig) return;
@@ -781,6 +783,7 @@ export class RestEngine {
const bundle = await this.loginTokenService.getToken(
config.authConfig as unknown as LoginTokenAuthConfig,
config.connectorId,
+ { proxyUrl: config.proxyUrl },
);
injectLoginTokenHeaders(
axiosConfig,
diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx
index 92731c0a..29550984 100644
--- a/packages/frontend/src/app/connectors/new/page.tsx
+++ b/packages/frontend/src/app/connectors/new/page.tsx
@@ -4,7 +4,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
-import { connectors, isMachineGrant, type OAuthGrant } from '@/lib/api';
+import { adapters, connectors, isMachineGrant, type CatalogUrlMatch, type OAuthGrant } from '@/lib/api';
+import { ConnectorLogo } from '@/components/connector-logo';
import { AppShell } from '@/components/app-shell';
import { Card } from '@/components/ui/card';
import { McpAssignModal } from '@/components/mcp-assign-modal';
@@ -102,6 +103,32 @@ export default function NewConnectorPage() {
suggestedFix?: { action: string; hostname?: string; url?: string };
} | null>(null);
const [createdConnector, setCreatedConnector] = useState<{ id: string; name: string } | null>(null);
+ // Catalog connectors for the API at the typed base URL. People kept
+ // building by hand connectors the catalog already had (Printify, Fatture in
+ // Cloud, Pinterest…), most with fewer tools than the ready-made one.
+ const [catalogMatches, setCatalogMatches] = useState([]);
+ useEffect(() => {
+ const url = baseUrl.trim();
+ if (!token || !url || selectedType === 'DATABASE' || !url.includes('.')) {
+ setCatalogMatches([]);
+ return;
+ }
+ let cancelled = false;
+ const timer = setTimeout(() => {
+ adapters
+ .matchUrl(url, token)
+ .then((res) => {
+ if (!cancelled) setCatalogMatches(res.matches);
+ })
+ .catch(() => {
+ if (!cancelled) setCatalogMatches([]);
+ });
+ }, 400);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ };
+ }, [baseUrl, selectedType, token]);
const buildAuthConfig = () => {
switch (authType) {
@@ -425,6 +452,43 @@ export default function NewConnectorPage() {
className={cn(inputClass, 'font-mono text-[13px]')}
required
/>
+ {catalogMatches.length > 0 && (
+
+
+ {catalogMatches.length === 1
+ ? 'There is a ready-made connector for this API'
+ : 'There are ready-made connectors for this API'}
+
+
+ Its tools, descriptions and sign-in are already set up and tested, and it gets catalog updates.
+