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
6 changes: 3 additions & 3 deletions packages/backend/src/adapters/intl/odoo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": {
Expand All @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions packages/backend/src/common/http-header-name.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
34 changes: 34 additions & 0 deletions packages/backend/src/common/http-header-name.util.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null,
authConfig?: Record<string, unknown> | null,
): string[] {
const names = [
...Object.keys(headers ?? {}),
...[authConfig?.headerName, (authConfig?.signature as Record<string, unknown> | 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.`
);
}
13 changes: 12 additions & 1 deletion packages/backend/src/connectors/connectors.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -112,6 +113,7 @@ export class ConnectorsService {
instructions?: string;
},
): Promise<Connector> {
assertValidHeaderNames(data.headers, data.authConfig);
const encryptedAuth = data.authConfig
? encrypt(JSON.stringify(data.authConfig), this.encryptionKey)
: null;
Expand Down Expand Up @@ -151,6 +153,7 @@ export class ConnectorsService {
}>,
): Promise<Connector> {
const existing = await this.findById(id);
assertValidHeaderNames(data.headers, data.authConfig);

const updateData: any = { ...data };
if (data.authConfig) {
Expand Down Expand Up @@ -1110,3 +1113,11 @@ export interface DiscoveredMcpTool {
outputSchema: Record<string, unknown> | null;
annotations: Record<string, unknown> | null;
}

function assertValidHeaderNames(
headers?: Record<string, unknown> | null,
authConfig?: Record<string, unknown> | null,
): void {
const invalid = invalidConnectorHeaderNames(headers, authConfig);
if (invalid.length > 0) throw new BadRequestException(describeInvalidHeaderNames(invalid));
}
17 changes: 17 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });

Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/connectors/engines/rest.engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/frontend/src/app/connectors/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
}

fetchConnector();
}, [token, id]);

Check warning on line 252 in packages/frontend/src/app/connectors/[id]/page.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, typecheck, build)

React Hook useEffect has missing dependencies: 'fetchConnector' and 'searchParams'. Either include them or remove the dependency array

const resetOauth1Fields = () => {
setEditOauth1Key('');
Expand Down Expand Up @@ -1152,6 +1152,11 @@
<div>
<label className="block text-sm font-medium mb-1">Header Name</label>
<input type="text" autoComplete="off" data-1p-ignore data-lpignore="true" data-bwignore="true" data-form-type="other" value={editAuthKey} onChange={(e) => 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) && (
<p className="mt-1 text-[12px]" style={{ color: 'var(--danger)' }}>
The HTTP header the key is sent in, e.g. X-API-Key: letters, digits and - only, no spaces.
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">API Key</label>
Expand Down
5 changes: 5 additions & 0 deletions packages/frontend/src/app/connectors/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,11 @@ export default function NewConnectorPage() {
<div>
<label className={labelClass}>Header name</label>
<input type="text" value={authKey} onChange={(e) => setAuthKey(e.target.value)} placeholder="X-API-Key" className={cn(inputClass, 'font-mono text-[13px]')} />
{authKey && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(authKey) && (
<p className="mt-1 text-[12px]" style={{ color: 'var(--danger)' }}>
The HTTP header the key is sent in, e.g. X-API-Key: letters, digits and - only, no spaces.
</p>
)}
</div>
<div>
<label className={labelClass}>API key</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -367,7 +368,7 @@ function SetupContent() {
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-[var(--surface)] px-2 py-1 font-mono text-xs text-[var(--text)]">{redirectUri ?? '…'}</code>
{redirectUri && (
<Button size="sm" variant="secondary" onClick={() => navigator.clipboard?.writeText(redirectUri)}>
<Button size="sm" variant="secondary" onClick={() => void copyText(redirectUri)}>
Copy
</Button>
)}
Expand Down
27 changes: 3 additions & 24 deletions packages/frontend/src/app/mcp-server/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -881,7 +860,7 @@ export default function McpServerDetailPage() {
{generatedKey}
</code>
<button
onClick={() => { navigator.clipboard.writeText(generatedKey); setKeyMsg('Copied!'); }}
onClick={async () => setKeyMsg((await copyText(generatedKey)) ? 'Copied!' : 'Error: copy blocked by the browser. Select the key and copy it manually.')}
className={cn(buttonVariants({ variant: 'secondary', size: 'md' }), 'flex-shrink-0')}
>
Copy
Expand Down
6 changes: 4 additions & 2 deletions packages/frontend/src/app/mcp-server/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { StatusPill } from '@/components/ui/badge';
import { AppSelect } from '@/components/ui/select';
import { isTrialLimitMessage, TrialLimitNotice } from '@/lib/trial-limit';
import { copyText } from '@/lib/clipboard';

export default function McpServerListPage() {
const { token } = useAuth();
Expand Down Expand Up @@ -54,10 +55,11 @@ export default function McpServerListPage() {

const handleCopy = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
navigator.clipboard?.writeText(`/mcp/${id}`).then(() => {
void copyText(`/mcp/${id}`).then((ok) => {
if (!ok) return;
setCopiedId(id);
setTimeout(() => setCopiedId((prev) => (prev === id ? null : prev)), 1500);
}).catch(() => {});
});
};

const filtered = servers.filter((s) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { recoveryCodes as api, type RecoveryCodeStatus } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/toast';
import { copyText } from '@/lib/clipboard';

/**
* Break-glass credentials for the signed-in admin.
Expand Down Expand Up @@ -57,7 +58,7 @@ export function RecoveryCodesCard({

const handleCopy = async () => {
if (!issued) return;
await navigator.clipboard.writeText(issued.join('\n'));
if (!(await copyText(issued.join('\n')))) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
Expand Down
3 changes: 2 additions & 1 deletion packages/frontend/src/components/chat-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import { useState, useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { copyText } from '@/lib/clipboard';

function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
if (!(await copyText(text))) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
Expand Down
Loading
Loading