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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/backend/src/adapters/de/lexware-office.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,11 @@
"properties": {
"voucherType": {
"type": "string",
"description": "Comma-separated types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote, invoice, creditnote, orderconfirmation, quotation, deliverynote, downpaymentinvoice."
"description": "Required by Lexware. Comma-separated types: salesinvoice, salescreditnote, purchaseinvoice, purchasecreditnote, invoice, creditnote, orderconfirmation, quotation, deliverynote, downpaymentinvoice, or any."
},
"voucherStatus": {
"type": "string",
"description": "Comma-separated statuses: draft, open, paid, paidoff, voided, transferred, sepadebit, overdue, accepted, rejected."
"description": "Required by Lexware. Comma-separated statuses: draft, open, paid, paidoff, voided, transferred, sepadebit, overdue, accepted, rejected, or any. overdue cannot be combined with other statuses: ask for it on its own (Lexware answers 400 otherwise)."
},
"archived": {
"type": "boolean",
Expand All @@ -139,7 +139,11 @@
"type": "number",
"description": "Page size, 1–250 (default 25)."
}
}
},
"required": [
"voucherType",
"voucherStatus"
]
},
"endpointMapping": {
"method": "GET",
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/adapters/intl/api-football.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"name": "API-Football v3",
"type": "REST",
"baseUrl": "https://v3.football.api-sports.io",
"healthPath": "/status",
"healthcheckPath": "/status",
"authType": "API_KEY",
"authConfig": {
"headerName": "x-apisports-key",
Expand Down
12 changes: 11 additions & 1 deletion packages/backend/src/audit/product-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ export const ProductEvents = {
* brings sign-ups, verified sign-ups and paying customers.
*/
SIGNUP_ATTRIBUTED: 'signup_attributed',
/**
* A user connected an AI client (Claude, ChatGPT…) through the OAuth flow
* for the first time; metadata.client = the client's name. Server-only.
* Answers: how many sign-ups reach the client, and how many of those then
* add a connector (read against the connectors table).
*/
AI_CLIENT_CONNECTED: 'ai_client_connected',
} as const;

export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents];
Expand All @@ -49,7 +56,10 @@ export type ProductEventName = (typeof ProductEvents)[keyof typeof ProductEvents
* Events only the server writes. A signed-in user could otherwise post a
* `signup_attributed` of their own and skew the channel report.
*/
const SERVER_ONLY = new Set<string>([ProductEvents.SIGNUP_ATTRIBUTED]);
const SERVER_ONLY = new Set<string>([
ProductEvents.SIGNUP_ATTRIBUTED,
ProductEvents.AI_CLIENT_CONNECTED,
]);
const CLIENT_REPORTABLE = new Set<string>(
Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)),
);
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/common/ssrf.util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ describe('extractSsrfBlockedHostname', () => {
"SSRF guard: cannot resolve 'other-mcp-server': getaddrinfo ENOTFOUND other-mcp-server",
'other-mcp-server',
],
[
"Host not found: 'nina.api.proxy.bund.dev' could not be resolved (ENOTFOUND). Check the address in the connector settings.",
'nina.api.proxy.bund.dev',
],
])('extracts the host from %s', (message, expected) => {
expect(extractSsrfBlockedHostname(message)).toBe(expected);
});
Expand All @@ -28,3 +32,12 @@ describe('extractSsrfBlockedHostname', () => {
expect(extractSsrfBlockedHostname(message)).toBeUndefined();
});
});

describe('assertSafeOutboundHost on a name that does not resolve', () => {
it('says the host was not found instead of reporting a policy block', async () => {
const { assertSafeOutboundHost } = await import('./ssrf.util');
await expect(
assertSafeOutboundHost('no-such-host.invalid', { SSRF_GUARD: 'enabled' } as NodeJS.ProcessEnv),
).rejects.toThrow(/^Host not found: 'no-such-host\.invalid' could not be resolved \(ENOTFOUND\)/);
});
});
15 changes: 11 additions & 4 deletions packages/backend/src/common/ssrf.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,15 @@ async function vetHost(
try {
resolved = await dns.lookup(hostname, { all: true });
} catch (e: any) {
// Not a policy decision: the name simply has no address (a typo, a
// retired API, a DNS hiccup). Worded as such, because "SSRF guard" made
// users and the model read a security block into a wrong host name.
const temporary = e?.code === 'EAI_AGAIN';
throw new SsrfBlockedError(
`SSRF guard: cannot resolve '${hostname}': ${e?.message || e}`,
`Host not found: '${hostname}' could not be resolved (${e?.code || e?.message || e}). ` +
(temporary
? 'This is usually a temporary DNS failure; try again.'
: 'Check the address in the connector settings.'),
);
}

Expand Down Expand Up @@ -386,9 +393,9 @@ export function createSsrfGuardedAgents(env: NodeJS.ProcessEnv = process.env): {
export function extractSsrfBlockedHostname(
message: string,
): string | undefined {
const match = /SSRF guard:\s*(?:address|hostname|cannot resolve)\s*'([^']+)'/.exec(
message || '',
);
const match =
/SSRF guard:\s*(?:address|hostname|cannot resolve)\s*'([^']+)'/.exec(message || '') ??
/Host not found:\s*'([^']+)'/.exec(message || '');
return match?.[1];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@ describe('assertNoUnresolvedPlaceholders', () => {
).toThrow(/The "Acme" connector is missing a value for X/);
});

it('links to the connector page when given one', () => {
expect(() =>
assertNoUnresolvedPlaceholders(
{ authConfig: { token: '{{X}}' } },
'the "Acme" connector',
'https://cloud.example.com/connectors/c1',
),
).toThrow(/Open the connector \(https:\/\/cloud\.example\.com\/connectors\/c1\) and set that variable/);
});

it('falls back to a generic subject', () => {
expect(() =>
assertNoUnresolvedPlaceholders({ authConfig: { token: '{{X}}' } }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export function assertNoUnresolvedPlaceholders(
request: RequestShape,
/** How to name the thing in the error, e.g. `the connector behind etsy_get_shop`. */
subject?: string,
/** The connector's page in the dashboard, so the reader can go straight there. */
fixUrl?: string,
): void {
const missing = findUnresolvedPlaceholders({
baseUrl: request.baseUrl,
Expand All @@ -78,7 +80,7 @@ export function assertNoUnresolvedPlaceholders(
`${which} is missing ${missing.length === 1 ? 'a value' : 'values'} for ${names}. ` +
'The request was not sent, because it would have carried the placeholder text ' +
'instead of the credential and the upstream API would have rejected it with a ' +
'misleading error. Open the connector and set ' +
`misleading error. Open the connector${fixUrl ? ` (${fixUrl})` : ''} and set ` +
`${missing.length === 1 ? 'that variable' : 'those variables'}, then try again.`,
);
}
22 changes: 21 additions & 1 deletion packages/backend/src/common/url.util.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeConnectorBaseUrl, resolveMcpEndpointUrl } from './url.util';
import { connectorPageUrl, normalizeConnectorBaseUrl, resolveMcpEndpointUrl } from './url.util';

describe('normalizeConnectorBaseUrl', () => {
it('keeps a well-formed https URL untouched', () => {
Expand Down Expand Up @@ -155,3 +155,23 @@ describe('resolveMcpEndpointUrl credential handling', () => {
).toBe('http://mcp.example.com:8931/tenant/a');
});
});

describe('connectorPageUrl', () => {
const saved = process.env.FRONTEND_URL;
afterEach(() => {
if (saved === undefined) delete process.env.FRONTEND_URL;
else process.env.FRONTEND_URL = saved;
});

it('builds the dashboard link from FRONTEND_URL', () => {
process.env.FRONTEND_URL = 'https://cloud.example.com/';
expect(connectorPageUrl('c1')).toBe('https://cloud.example.com/connectors/c1');
});

it('gives nothing without a usable FRONTEND_URL or id', () => {
delete process.env.FRONTEND_URL;
expect(connectorPageUrl('c1')).toBeUndefined();
process.env.FRONTEND_URL = 'https://cloud.example.com';
expect(connectorPageUrl(undefined)).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions packages/backend/src/common/url.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,15 @@ function withPath(base: URL, pathname: string, search: string): URL {
url.hash = '';
return url;
}

/**
* Public dashboard address of a connector's page, for messages a person reads
* in a chat client ("open the connector and set X"). FRONTEND_URL is where the
* dashboard is served; undefined when it is not configured, so callers can
* fall back to wording without a link.
*/
export function connectorPageUrl(connectorId: string | undefined | null): string | undefined {
const base = (process.env.FRONTEND_URL || '').trim().replace(/\/+$/, '');
if (!connectorId || !/^https?:\/\//i.test(base)) return undefined;
return `${base}/connectors/${encodeURIComponent(connectorId)}`;
}
4 changes: 3 additions & 1 deletion packages/backend/src/connectors/connectors.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { CALLER_CONTEXT_PREFIX } from '../common/caller-context.util';
import { assertNoUnresolvedPlaceholders } from '../common/unresolved-placeholders.util';
import { assertAbsoluteBaseUrl } from '../common/base-url-variable.util';
import { extractSsrfBlockedHostname } from '../common/ssrf.util';
import { normalizeConnectorBaseUrl } from '../common/url.util';
import { connectorPageUrl, normalizeConnectorBaseUrl } from '../common/url.util';
import { resolveAdapterIcon } from './connector-icon.util';
import { applySchemaDefaults } from '../common/schema-defaults.util';
import { renderStaticResponse } from './static-response.util';
Expand Down Expand Up @@ -251,6 +251,7 @@ export class ConnectorsService {
assertNoUnresolvedPlaceholders(
{ baseUrl, headers, authConfig },
`the "${connector.name}" connector`,
connectorPageUrl(connector.id),
);
assertAbsoluteBaseUrl(
{
Expand Down Expand Up @@ -547,6 +548,7 @@ export class ConnectorsService {
authConfig,
},
toolName ? `the connector behind ${toolName}` : `the "${connector.name}" connector`,
connectorPageUrl(connector.id),
);
assertAbsoluteBaseUrl(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isPrivateKeyJwt,
} from './client-assertion.util';
import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util';
import { connectorPageUrl } from '../../common/url.util';

/** Refresh tokens that expire within this window (5 minutes). */
const PROACTIVE_REFRESH_BUFFER_MS = 5 * 60 * 1000;
Expand Down Expand Up @@ -147,9 +148,10 @@ export class OAuth2TokenService {
);
}
if (!authConfig.refreshToken && authConfig.authorizationUrl) {
const page = connectorPageUrl(connectorId);
throw unauthorized(
'OAuth2: this connector has not been authorized yet. No request was sent to the API. ' +
'Open the connector in AnythingMCP and click Authorize with Provider.',
`Open the connector in AnythingMCP${page ? ` (${page})` : ''} and click Authorize with Provider.`,
);
}
}
Expand Down
62 changes: 61 additions & 1 deletion packages/backend/src/connectors/engines/rest.engine.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RestEngine, serializeRepeatedParams } from './rest.engine';
import { RestEngine, parseRetryAfterMs, serializeRepeatedParams } from './rest.engine';
import { OAuth2TokenService } from './oauth2-token.service';
import { LoginTokenService } from './login-token.service';
import axios, { AxiosError } from 'axios';
Expand Down Expand Up @@ -743,6 +743,49 @@ describe('RestEngine', () => {
expect(mockedAxios).toHaveBeenCalledTimes(1);
});

// A rate limit is not an outage: at most one more attempt, and only when
// the API's Retry-After fits inside a tool call.
describe('rate limits', () => {
const limited = (status: number, retryAfter?: string) =>
new AxiosError('limited', undefined, undefined, {}, {
status,
data: {},
headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter },
} as any);
const call = () =>
engine.execute(
{ baseUrl: 'https://api.example.com', authType: 'NONE' },
{ method: 'GET', path: '/' },
{},
);

it('tries a 429 without Retry-After only once more', async () => {
mockedAxios.mockRejectedValue(limited(429));
await expect(call()).rejects.toBeInstanceOf(AxiosError);
expect(mockedAxios).toHaveBeenCalledTimes(2);
});

it('honours a short Retry-After and returns the success', async () => {
mockedAxios
.mockRejectedValueOnce(limited(429, '0'))
.mockResolvedValueOnce({ data: { ok: true } });
await expect(call()).resolves.toEqual({ ok: true });
expect(mockedAxios).toHaveBeenCalledTimes(2);
});

it('does not retry when Retry-After is longer than a tool call can wait', async () => {
mockedAxios.mockRejectedValue(limited(429, '60'));
await expect(call()).rejects.toBeInstanceOf(AxiosError);
expect(mockedAxios).toHaveBeenCalledTimes(1);
});

it('treats a 503 with a long Retry-After the same way', async () => {
mockedAxios.mockRejectedValue(limited(503, '120'));
await expect(call()).rejects.toBeInstanceOf(AxiosError);
expect(mockedAxios).toHaveBeenCalledTimes(1);
});
});

it('gives up after exhausting retries on persistent 503', async () => {
mockedAxios.mockRejectedValue(err(503));

Expand Down Expand Up @@ -1460,3 +1503,20 @@ describe('RestEngine — bodyTemplate that will not parse', () => {
expect(err.message).not.toMatch(/super-secret/);
});
});

describe('parseRetryAfterMs', () => {
it('reads delay-seconds', () => {
expect(parseRetryAfterMs('2')).toBe(2000);
expect(parseRetryAfterMs(['5'])).toBe(5000);
});
it('reads an HTTP date relative to now', () => {
const now = Date.parse('Sat, 03 Oct 2026 10:00:00 GMT');
expect(parseRetryAfterMs('Sat, 03 Oct 2026 10:00:02 GMT', now)).toBe(2000);
expect(parseRetryAfterMs('Sat, 03 Oct 2026 09:00:00 GMT', now)).toBe(0);
});
it('returns null for missing or unreadable values', () => {
expect(parseRetryAfterMs(undefined)).toBeNull();
expect(parseRetryAfterMs('')).toBeNull();
expect(parseRetryAfterMs('soon')).toBeNull();
});
});
Loading
Loading