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}