From b0e4029f13c92421b3b2d3796b5ab599fe94d2bf Mon Sep 17 00:00:00 2001 From: Matteo Morelli Date: Mon, 5 Oct 2026 13:59:12 +0200 Subject: [PATCH] Fixes from the 5 Oct error review: copy that reports failure, header names checked, Odoo grouping, Sentry noise - Copy buttons go through one copyText() helper with the textarea fallback and only say "Copied" when it worked. The new API key button said "Copied!" while the browser refused the write, for a key shown only once (ANYTHINGMCP-CLOUD-FRONTEND-C). - Header names are checked as HTTP tokens when a connector is saved and before a request is sent, with a message that explains Header Name. Two Odoo connectors had the key's label ("Valentino API Key", "API Odoo") there and every call failed with Node's "Header name must be a valid HTTP token". The API-key forms show the rule as you type. - Odoo (JSON-2): read_group does not exist on Odoo 19; the instructions and odoo_call_method point to formatted_read_group (13 failed calls in 2 orgs). - Sentry: ignore raw-body's "request aborted" on the backend (ANYTHINGMCP-CLOUD-BACKEND-6, 1,388 events from one looping client) and blocked Google Tag Manager beacons in the cloud app (ANYTHINGMCP-CLOUD-FRONTEND-D). --- packages/backend/src/adapters/intl/odoo.json | 6 ++-- .../src/common/http-header-name.util.spec.ts | 35 ++++++++++++++++++ .../src/common/http-header-name.util.ts | 34 ++++++++++++++++++ .../src/connectors/connectors.service.ts | 13 ++++++- .../connectors/engines/rest.engine.spec.ts | 17 +++++++++ .../src/connectors/engines/rest.engine.ts | 3 ++ packages/backend/src/instrument.ts | 6 ++++ .../frontend/src/app/connectors/[id]/page.tsx | 5 +++ .../frontend/src/app/connectors/new/page.tsx | 5 +++ .../src/app/connectors/setup/[slug]/page.tsx | 3 +- .../frontend/src/app/mcp-server/[id]/page.tsx | 27 ++------------ packages/frontend/src/app/mcp-server/page.tsx | 6 ++-- .../identity-providers/recovery-codes.tsx | 3 +- .../frontend/src/components/chat-message.tsx | 3 +- packages/frontend/src/lib/clipboard.ts | 36 +++++++++++++++++++ packages/frontend/src/lib/sentry-scrub.ts | 3 ++ .../tests/e2e/sentry-injected-scripts.spec.ts | 17 ++++++++- 17 files changed, 188 insertions(+), 34 deletions(-) create mode 100644 packages/backend/src/common/http-header-name.util.spec.ts create mode 100644 packages/backend/src/common/http-header-name.util.ts create mode 100644 packages/frontend/src/lib/clipboard.ts diff --git a/packages/backend/src/adapters/intl/odoo.json b/packages/backend/src/adapters/intl/odoo.json index c3ad1367..8ce757bf 100644 --- a/packages/backend/src/adapters/intl/odoo.json +++ b/packages/backend/src/adapters/intl/odoo.json @@ -6,7 +6,7 @@ "category": "erp", "icon": "odoo", "docsUrl": "https://www.odoo.com/documentation/master/developer/reference/external_api.html", - "instructions": "**Getting an API key**\n1. In Odoo open your user menu → **My Profile → Account Security** and create a new **API key**. Odoo shows it once.\n2. Put it in `ODOO_API_KEY`, your instance URL in `ODOO_URL` (e.g. `https://mycompany.odoo.com`, no trailing slash) and the database name in `ODOO_DB`. On Odoo Online the database name is usually the subdomain.\n\n**This adapter uses the JSON-2 API**, which Odoo introduced in 19 and which is the only Odoo HTTP surface a generic REST engine can speak: `POST {url}/json/2/{model}/{method}` with the key as a bearer token and the database in an `X-Odoo-Database` header. On **Odoo 18 and older** that route does not exist — those versions only offer XML-RPC and the older `/jsonrpc` endpoint, neither of which this connector can call. For an older instance, build a custom connector against `/jsonrpc` (POST, `{\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":{\"service\":\"object\",\"method\":\"execute_kw\",\"args\":[db, uid, key, model, method, args]}}`) or install the OCA REST API module.\n\n**Domains are Odoo's query language.** A domain is a list of triples: `[[\"state\",\"=\",\"sale\"],[\"amount_total\",\">\",1000]]`, implicitly AND-ed. `|` and `!` prefix operators express OR and NOT. `odoo_search_read` takes one verbatim, which is what makes this connector able to answer questions the purpose-built tools do not cover.\n\n**Always pass `fields`.** Odoo models have hundreds of columns and omitting `fields` returns all of them, which will exhaust the agent's context on a handful of rows. Start with `odoo_fields_get` to see what exists.\n\n**Permissions follow the user.** The API key inherits its owner's access rights and record rules. A restricted user sees fewer rows, not an error — so an agent reporting 'no open orders' may simply be looking through the wrong account.\n\n**Self-hosted, which is the thing to plan for.**\n- On **AnythingMCP Cloud** the instance must be reachable from the public internet on a real hostname with a valid TLS certificate. A self-signed certificate will fail: the connector offers no trust-anything switch.\n- On an internal host (`odoo.intern`, `10.0.0.x`), self-host AnythingMCP on the same network and add that host to `SSRF_ALLOWED_HOSTS`, or the outbound guard refuses the call before it is made.\n\n**Writes**: `odoo_create` and `odoo_write` change the live database, and Odoo's automations (mail, stock moves, accounting entries) fire as if a person had done it.", + "instructions": "**Getting an API key**\n1. In Odoo open your user menu → **My Profile → Account Security** and create a new **API key**. Odoo shows it once.\n2. Put it in `ODOO_API_KEY`, your instance URL in `ODOO_URL` (e.g. `https://mycompany.odoo.com`, no trailing slash) and the database name in `ODOO_DB`. On Odoo Online the database name is usually the subdomain.\n\n**This adapter uses the JSON-2 API**, which Odoo introduced in 19 and which is the only Odoo HTTP surface a generic REST engine can speak: `POST {url}/json/2/{model}/{method}` with the key as a bearer token and the database in an `X-Odoo-Database` header. On **Odoo 18 and older** that route does not exist — those versions only offer XML-RPC and the older `/jsonrpc` endpoint, neither of which this connector can call. For an older instance, build a custom connector against `/jsonrpc` (POST, `{\"jsonrpc\":\"2.0\",\"method\":\"call\",\"params\":{\"service\":\"object\",\"method\":\"execute_kw\",\"args\":[db, uid, key, model, method, args]}}`) or install the OCA REST API module.\n\n**Domains are Odoo's query language.** A domain is a list of triples: `[[\"state\",\"=\",\"sale\"],[\"amount_total\",\">\",1000]]`, implicitly AND-ed. `|` and `!` prefix operators express OR and NOT. `odoo_search_read` takes one verbatim, which is what makes this connector able to answer questions the purpose-built tools do not cover.\n\n**Always pass `fields`.** Odoo models have hundreds of columns and omitting `fields` returns all of them, which will exhaust the agent's context on a handful of rows. Start with `odoo_fields_get` to see what exists.\n\n**Totals and grouping**: Odoo 19 has no public `read_group` (the JSON-2 API answers 404). Call `formatted_read_group` through `odoo_call_method` instead, with kwargs such as `{\"domain\": [[\"state\",\"=\",\"posted\"]], \"groupby\": [\"company_id\"], \"aggregates\": [\"amount_total:sum\", \"__count\"]}`. Aggregates are `field:function` (sum, avg, min, max, count_distinct) plus `__count`.\n\n**Permissions follow the user.** The API key inherits its owner's access rights and record rules. A restricted user sees fewer rows, not an error — so an agent reporting 'no open orders' may simply be looking through the wrong account.\n\n**Self-hosted, which is the thing to plan for.**\n- On **AnythingMCP Cloud** the instance must be reachable from the public internet on a real hostname with a valid TLS certificate. A self-signed certificate will fail: the connector offers no trust-anything switch.\n- On an internal host (`odoo.intern`, `10.0.0.x`), self-host AnythingMCP on the same network and add that host to `SSRF_ALLOWED_HOSTS`, or the outbound guard refuses the call before it is made.\n\n**Writes**: `odoo_create` and `odoo_write` change the live database, and Odoo's automations (mail, stock moves, accounting entries) fire as if a person had done it.", "requiredEnvVars": [ "ODOO_URL", "ODOO_DB", @@ -448,7 +448,7 @@ }, { "name": "odoo_call_method", - "description": "Call an arbitrary public method on an Odoo model — the escape hatch for workflow actions such as action_confirm on a sale order or action_post on an invoice.", + "description": "Call an arbitrary public method on an Odoo model — the escape hatch for workflow actions such as action_confirm on a sale order or action_post on an invoice. For totals per group use formatted_read_group (read_group does not exist on Odoo 19).", "parameters": { "type": "object", "properties": { @@ -458,7 +458,7 @@ }, "method": { "type": "string", - "description": "Public method name, e.g. action_confirm, action_post, button_cancel." + "description": "Public method name, e.g. action_confirm, action_post, button_cancel, formatted_read_group." }, "ids": { "type": "array", diff --git a/packages/backend/src/common/http-header-name.util.spec.ts b/packages/backend/src/common/http-header-name.util.spec.ts new file mode 100644 index 00000000..a9c11ba2 --- /dev/null +++ b/packages/backend/src/common/http-header-name.util.spec.ts @@ -0,0 +1,35 @@ +import { + describeInvalidHeaderNames, + invalidConnectorHeaderNames, + isValidHeaderName, +} from './http-header-name.util'; + +describe('http-header-name.util', () => { + it('accepts header names the catalog uses', () => { + for (const name of ['X-API-Key', 'Authorization', 'X-Odoo-Database', 'x-api-key', 'Api-Token', 'X_Custom']) { + expect(isValidHeaderName(name)).toBe(true); + } + }); + + it('refuses names Node would refuse at send time', () => { + for (const name of ['API Odoo', 'Valentino API Key', 'X-Key:', '', 'Clé']) { + expect(isValidHeaderName(name)).toBe(false); + } + }); + + it('checks plain headers, the API-key header and the signature header', () => { + expect( + invalidConnectorHeaderNames( + { 'Content-Type': 'application/json', 'My Header': 'x' }, + { headerName: 'API Odoo', signature: { headerName: 'Signature' } }, + ), + ).toEqual(['My Header', 'API Odoo']); + expect(invalidConnectorHeaderNames(undefined, { token: 'abc' })).toEqual([]); + }); + + it('explains what Header Name means', () => { + expect(describeInvalidHeaderNames(['API Odoo'])).toMatch( + /"API Odoo" is not a valid HTTP header name.*not the name you gave the key/, + ); + }); +}); diff --git a/packages/backend/src/common/http-header-name.util.ts b/packages/backend/src/common/http-header-name.util.ts new file mode 100644 index 00000000..9fc6512b --- /dev/null +++ b/packages/backend/src/common/http-header-name.util.ts @@ -0,0 +1,34 @@ +/** + * HTTP header names are RFC 9110 "tokens": letters, digits and a few symbols, + * no spaces. Node refuses anything else at send time with "Header name must be + * a valid HTTP token", which tells the user nothing. In production that came + * from the API-key form, where people typed the label of their key + * ("Valentino API Key", "API Odoo") into Header Name. + */ +const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +export function isValidHeaderName(name: string): boolean { + return HTTP_TOKEN.test(name); +} + +/** Header names a connector would send that Node will refuse. */ +export function invalidConnectorHeaderNames( + headers?: Record | null, + authConfig?: Record | null, +): string[] { + const names = [ + ...Object.keys(headers ?? {}), + ...[authConfig?.headerName, (authConfig?.signature as Record | undefined)?.headerName] + .filter((v): v is string => typeof v === 'string' && v !== ''), + ]; + return names.filter((n) => !isValidHeaderName(n)); +} + +export function describeInvalidHeaderNames(names: string[]): string { + const list = names.map((n) => `"${n}"`).join(', '); + return ( + `${list} ${names.length === 1 ? 'is not a valid HTTP header name' : 'are not valid HTTP header names'}: ` + + `use letters, digits and "-" only, no spaces. In the authentication settings, Header Name is the header ` + + `the key travels in (for example X-API-Key), not the name you gave the key.` + ); +} diff --git a/packages/backend/src/connectors/connectors.service.ts b/packages/backend/src/connectors/connectors.service.ts index 6c96fed6..6b7d2b5f 100644 --- a/packages/backend/src/connectors/connectors.service.ts +++ b/packages/backend/src/connectors/connectors.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../common/prisma.service'; import { Connector, ConnectorType, AuthType } from '../generated/prisma/client'; @@ -10,6 +10,7 @@ import { DatabaseEngine } from './engines/database.engine'; import { McpClientEngine } from './engines/mcp-client.engine'; import { encrypt, decrypt } from '../common/crypto/encryption.util'; import { getRequiredSecret } from '../common/secrets.util'; +import { describeInvalidHeaderNames, invalidConnectorHeaderNames } from '../common/http-header-name.util'; import { interpolateConnectorConfig, interpolateDeep, @@ -112,6 +113,7 @@ export class ConnectorsService { instructions?: string; }, ): Promise { + assertValidHeaderNames(data.headers, data.authConfig); const encryptedAuth = data.authConfig ? encrypt(JSON.stringify(data.authConfig), this.encryptionKey) : null; @@ -151,6 +153,7 @@ export class ConnectorsService { }>, ): Promise { const existing = await this.findById(id); + assertValidHeaderNames(data.headers, data.authConfig); const updateData: any = { ...data }; if (data.authConfig) { @@ -1110,3 +1113,11 @@ export interface DiscoveredMcpTool { outputSchema: Record | null; annotations: Record | null; } + +function assertValidHeaderNames( + headers?: Record | null, + authConfig?: Record | null, +): void { + const invalid = invalidConnectorHeaderNames(headers, authConfig); + if (invalid.length > 0) throw new BadRequestException(describeInvalidHeaderNames(invalid)); +} diff --git a/packages/backend/src/connectors/engines/rest.engine.spec.ts b/packages/backend/src/connectors/engines/rest.engine.spec.ts index 89b94fcc..ad73437c 100644 --- a/packages/backend/src/connectors/engines/rest.engine.spec.ts +++ b/packages/backend/src/connectors/engines/rest.engine.spec.ts @@ -335,6 +335,23 @@ describe('RestEngine', () => { ); }); + it('refuses a header name with spaces before sending, and says which field is wrong', async () => { + mockedAxios.mockResolvedValue({ data: {} }); + + await expect( + engine.execute( + { + baseUrl: 'https://api.example.com', + authType: 'API_KEY', + authConfig: { headerName: 'Valentino API Key', apiKey: 'sk-test' }, + }, + { method: 'GET', path: '/' }, + {}, + ), + ).rejects.toThrow(/"Valentino API Key" is not a valid HTTP header name.*X-API-Key/); + expect(mockedAxios).not.toHaveBeenCalled(); + }); + it('should inject bearer token auth', async () => { mockedAxios.mockResolvedValue({ data: {} }); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index cd8d7ea0..b863e751 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -17,6 +17,7 @@ import { import { assertSafeOutboundUrl } from '../../common/ssrf.util'; import { fetchOutbound } from '../../common/outbound-fetch.util'; import { assertNoUnresolvedPlaceholders } from '../../common/unresolved-placeholders.util'; +import { describeInvalidHeaderNames, isValidHeaderName } from '../../common/http-header-name.util'; import { XMLParser } from 'fast-xml-parser'; import { pickExposedHeaders } from './response-headers.util'; import { ssrfGuardedAxiosOptions } from '../../common/guarded-http.util'; @@ -541,6 +542,8 @@ export class RestEngine { // so for a few seconds, and the old 1.2 s total often gave up just short of // recovery. 3.7 s worst case still sits well inside any MCP client timeout. const delaysMs = [300, 900, 2500]; + const badHeaders = Object.keys(axiosConfig.headers ?? {}).filter((n) => !isValidHeaderName(n)); + if (badHeaders.length > 0) throw new Error(describeInvalidHeaderNames(badHeaders)); for (let attempt = 0; ; attempt++) { try { return await axios(axiosConfig); diff --git a/packages/backend/src/instrument.ts b/packages/backend/src/instrument.ts index 0e00296f..3c478c57 100644 --- a/packages/backend/src/instrument.ts +++ b/packages/backend/src/instrument.ts @@ -59,6 +59,12 @@ if (dsn) { }), ], + // raw-body's BadRequestError when the client hangs up before its request + // body has been read. Nothing failed on our side; a client looping on + // /mcp/:id and dropping its calls sent 1,388 of these in one burst on + // 4 Oct 2026 (ANYTHINGMCP-CLOUD-BACKEND-6). + ignoreErrors: [/^request aborted$/], + beforeSend: scrubEvent, beforeSendTransaction: scrubEvent, beforeBreadcrumb: scrubBreadcrumb, diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index 35645f1f..eb62ce70 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -1152,6 +1152,11 @@ export default function ConnectorDetailPage() {
setEditAuthKey(e.target.value)} placeholder="X-API-Key" className="w-full border border-[var(--border)] rounded-[9px] px-3 py-2 text-sm bg-[var(--surface)] focus:outline-none focus:border-[var(--border-strong)]" /> + {editAuthKey && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(editAuthKey) && ( +

+ The HTTP header the key is sent in, e.g. X-API-Key: letters, digits and - only, no spaces. +

+ )}
diff --git a/packages/frontend/src/app/connectors/new/page.tsx b/packages/frontend/src/app/connectors/new/page.tsx index 8a6d7ac2..f66be8fb 100644 --- a/packages/frontend/src/app/connectors/new/page.tsx +++ b/packages/frontend/src/app/connectors/new/page.tsx @@ -589,6 +589,11 @@ export default function NewConnectorPage() {
setAuthKey(e.target.value)} placeholder="X-API-Key" className={cn(inputClass, 'font-mono text-[13px]')} /> + {authKey && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(authKey) && ( +

+ The HTTP header the key is sent in, e.g. X-API-Key: letters, digits and - only, no spaces. +

+ )}
diff --git a/packages/frontend/src/app/connectors/setup/[slug]/page.tsx b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx index 3e3f1ec5..4ee60bab 100644 --- a/packages/frontend/src/app/connectors/setup/[slug]/page.tsx +++ b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx @@ -20,6 +20,7 @@ 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'; +import { copyText } from '@/lib/clipboard'; /** * Guided setup of a catalog connector, in one place: what to enter (grouped, @@ -367,7 +368,7 @@ function SetupContent() {
{redirectUri ?? '…'} {redirectUri && ( - )} diff --git a/packages/frontend/src/app/mcp-server/[id]/page.tsx b/packages/frontend/src/app/mcp-server/[id]/page.tsx index afb5a874..9e5632cf 100644 --- a/packages/frontend/src/app/mcp-server/[id]/page.tsx +++ b/packages/frontend/src/app/mcp-server/[id]/page.tsx @@ -10,6 +10,7 @@ import { Card } from '@/components/ui/card'; import { Badge, StatusPill, type Tone } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { ConnectionCheck } from '@/components/connection-check'; +import { copyText } from '@/lib/clipboard'; // Opens claude.ai straight on its "Add custom connector" dialog. Connectors // moved from Settings to Customize → Connectors; the old settings URL now only @@ -208,29 +209,7 @@ export default function McpServerDetailPage() { }; const handleCopy = async (text: string, label: string) => { - let ok = false; - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - ok = true; - } - } catch {} - if (!ok) { - // Fallback for non-secure contexts (e.g. plain-HTTP LAN deployments) - const ta = document.createElement('textarea'); - ta.value = text; - ta.setAttribute('readonly', ''); - ta.style.position = 'fixed'; - ta.style.top = '0'; - ta.style.left = '0'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { - ok = document.execCommand('copy'); - } catch {} - document.body.removeChild(ta); - } + const ok = await copyText(text); if (ok) { setCopied(label); setTimeout(() => setCopied(''), 2000); @@ -881,7 +860,7 @@ export default function McpServerDetailPage() { {generatedKey}