Skip to content

Review 14 Oct: Cloudinary refresh race, weclapp properties hint, Odoo expired key hint, SMTP in compose - #1084

Merged
helpcodeai merged 7 commits into
mainfrom
helpcode/review-1014-fixes
Oct 14, 2026
Merged

helpcodeai merged 7 commits into
mainfrom
helpcode/review-1014-fixes

Conversation

@helpcodeai

@helpcodeai helpcodeai commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes from the 14 Oct review, plus three Odoo/SAP items from two stuck Odoo 19 users and the Postman form-body bug that #1057 fixed for HAR.

1. Cloudinary: OAuth refresh failed minutes after each authorization (our bug)

Root cause. Tool calls through /mcp built the engine config without connectorId, for every engine type, and parsed a fresh authConfig from the registry's JSON string on every call. As a result, OAuth2TokenService:

  • keyed its cache and mutex on a new "unsaved" key each time, so nothing was cached or locked;
  • did not re-read the stored refresh token from the database;
  • never stored the renewed one.

Cloudinary's MCP server issues 5-minute access tokens (expires_in: 300) and rotates the refresh token. The first renewal after five minutes succeeded and its new refresh token was dropped. The next renewal replayed the spent one and got invalid_grant: Invalid or expired refresh token.

The cloud logs show it:

  • The refresh failures on /mcp read token refresh failed: with no for connector ….
  • Connector cmv6t4iry… was re-authorized at 06:11:43. Its 06:32 call renewed successfully, yet the row was never updated: no lastRefreshedAt, and updated_at is still 06:11:43.

The "two refreshes 1 s apart" were two Test connection clicks after the token was already dead. #916 had fixed Test connection and discovery, not the tool-call path.

Fix.

  • The tool-call path passes connectorId to the engines. Credentials built from {{amcp.*}} caller variables stay unkeyed, because they differ per caller.
  • A renewal for a saved connector runs under a Postgres transaction-scoped advisory lock (pg_advisory_xact_lock(hashtext('amcp:oauth2-refresh:<id>'))).
    • It reads the stored refresh token and writes the rotated one on that transaction's connection, before the lock is released. Using one connection means a full pool cannot deadlock against itself.
    • This covers the two backends of a blue/green deploy. A renewal that waited on the lock takes the token the other process stored instead of spending the refresh token again. After a 401, it does so only if that token was obtained after the refused request.
    • If the write does not survive the transaction, the token is written again outside it.
    • If no connection is free for the lock within 3 s, the renewal runs unlocked, as before.
  • The MCP bridge now recognises the SDK's 401. SdkHttpError carries the status in data.status, and its message is only the body (Error POSTing to endpoint: {"error":"invalid_token"…}), so the old message.includes('401') check never fired and the renewal after a rejection never ran.

Tests. oauth2-token.service.refresh-race.spec.ts uses a rotating fake provider and a shared fake table with advisory-lock semantics:

  • five-minute tokens across several renewals with a fresh authConfig per call;
  • two concurrent calls in one process;
  • two processes renewing at once;
  • the same two processes without the lock, which lose the connector;
  • the write failing under the lock;
  • the transaction failing after the token endpoint answered;
  • no connection free for the lock;
  • adopting a token after a 401.

The SQL was checked against a real Postgres 16 with two Prisma clients.

Existing connectors. cmv6t4iry043u01k3iuqwrbpo and cmv6r9xu100hr01k3zn3ui7vt were re-authorized this morning but will break again within minutes of their next renewal until this is deployed. Rotating OAuth providers on REST or GraphQL tool calls (JTL, Sage, DATEV) were exposed to the same path.

2. weclapp: 400 "unknown property"

About 50 failures over 10 workspaces since 4 Oct. All of them were guessed names in properties, such as customerNumber on orders and invoices, openAmount, salesOrderItems, shippingAddress, unitName, stockQuantity and validUntil.

  • Each list tool's properties now starts with "Leave empty (the default)". It then lists real top-level field names from weclapp's API v2 OpenAPI (https://www.weclapp.com/api/openapi_v2.yaml), with the names guessed wrong in production and what to use instead.
  • The instructions stop recommending properties on large lists.
  • The weclapp error hint now first says to retry without properties.
  • No tool renamed. The adapter version changes, so existing installs are offered the update.

3. Odoo: "Invalid apikey" after a day = expired key

Since Odoo 18, API keys expire, and the New API Key dialog preselects 1 Day (the first entry of _selection_duration). Persistent Key is offered to administrators only; other users get at most their group's api_key_duration, which is 90 days for internal users.

In the cloud: 63 JSON-2 Invalid apikey 401s across 5 workspaces, and about 40 JSON-RPC Access Denied across 9 workspaces, in two weeks. Changes, for odoo and odoo-jsonrpc:

  • setup steps;
  • API key field help;
  • verify hints;
  • new runtime hints for Invalid apikey and Odoo Server Error: Access Denied.

They say the key has likely expired and to create a new one with Duration "Persistent Key" or the longest duration offered.

Odoo JSON-2 404 (added on request):

  • The database-name lookup asked /json/2/web/database/list, which 404s, so it never suggested anything for the JSON-2 adapter. It now asks the origin.
  • Before offering the JSON-RPC connector, the check calls the unauthenticated common.version:
    • below 19: offers JSON-RPC;
    • 19 or later: says the database name is wrong and names the right one;
    • Odoo Online's "typo" page: a 404 whose script redirects to odoo.com/typo?domain=… (checked live). In that case the check says the address is not an Odoo database.
  • saas~19 versions are parsed. If the version cannot be read, the old hint and suggestion stay.

4. SAP S/4HANA Cloud token URL (added on request)

The instructions gave a BTP XSUAA token URL (…authentication.eu10.hana.ondemand.com/oauth/token), while the field help gave the tenant -api host. Both now point to the Token Service URL shown under the arrangement's Inbound Communication → OAuth 2.0 Details, on https://<tenant>-api.s4hana.cloud.sap/sap/bc/sec/oauth2/token. The base URL example now includes -api too.

A 401 at verify or at runtime lists the four causes:

  • token URL;
  • a client that is not the arrangement's communication user;
  • a missing arrangement;
  • a scope.

A 403 names the missing arrangement.

5. SMTP in the self-hosted compose files (#1083)

docker-compose.yml and docker-compose.quickstart.yml list the app environment one variable at a time and left out SMTP_*, so what setup.sh writes into .env never reached the app. Both now pass SMTP_HOST/PORT/USER/PASS/FROM/FROM_NAME/REPLY_TO/SECURE.

The cloud compose already passed them (Mailgun, unchanged).

SMTP_FROM_NAME, which setup.sh and railway.json ask for, was never read. It now names the sender when SMTP_FROM is a bare address. A SMTP_FROM that already has a name wins, so the cloud is unaffected.

A test checks that every SMTP_* the email service reads is passed by both compose files. docker compose config was run on both.

6. Postman import: form bodies sent as JSON

The same bug as the HAR importer (#1057):

  • urlencoded bodies now get bodyEncoding: form-urlencoded.
  • formdata bodies get form-data (multipart). A file field becomes a { "__file": "$field" } marker that the engine fills from the public URL the caller passes, like Etsy's image upload.
  • Body field names __proto__, constructor and prototype are skipped, and form fields are merged in one step instead of keyed writes. This came from CodeQL's remote-property-injection rule on the changed lines.

Checks

  • Backend jest: 571 suites passed, 15,090 tests passed (689 skipped are live specs).
  • tsc clean for backend and frontend; ESLint clean on all changed files.
  • node scripts/validate-adapters.mjs: 405/405.
  • node scripts/adapter-count.mjs --check: OK.
  • No dependency changes.

…nnector id, lock renewals

Cloudinary's MCP server issues 5-minute access tokens and rotates the
refresh token on every renewal. Tool calls through /mcp reached the
OAuth2 token service without the connector id, and parsed a fresh
authConfig on every call. So nothing was cached, the renewed token was
never stored, and the next renewal replayed the spent refresh token:
invalid_grant a few minutes after each authorization. The same path
served REST and GraphQL tool calls.

- The tool-call path passes the connector id to the engines (except for
  credentials built from caller-context variables).
- A renewal for a saved connector runs under a Postgres advisory lock
  held by a transaction, with the reads and the write of the rotated
  token on that transaction, so the two backends of a blue/green deploy
  never spend the same refresh token. A renewal that waited takes the
  token the other process stored. If the write does not survive the
  transaction, it is written again outside it.
- The MCP bridge recognises the SDK's 401 (status in error.data, the
  message holds only the body), so the renewal after a rejection runs.
…ud token help

weclapp: about 50 list calls in ten days failed with 400 "unknown
property", all guessed names in `properties`. Each list tool now says to
leave it empty and lists real field names from weclapp's API v2 OpenAPI,
with the names guessed wrong in production and what to use instead. The
error hint first says to retry without `properties`.

Odoo: since Odoo 18 API keys expire and the key dialog preselects
"1 Day", so "Invalid apikey" (JSON-2) or Access Denied (JSON-RPC) the
next day means an expired key. Setup steps, field help, the verify hint
and a runtime hint say to create a key with Duration "Persistent Key"
(administrators) or the longest duration offered.

Odoo JSON-2 404: the database-name lookup asked /json/2/web/database/list
and never found anything; it now asks the origin. Before offering the
JSON-RPC connector, the check asks Odoo's common.version: below 19 it
offers JSON-RPC, on 19 it says the database name is wrong and names the
right one, and on Odoo Online's "typo" page it says the address is not an
Odoo database.

SAP S/4HANA Cloud: the instructions gave a BTP token URL while the field
help gave the tenant API host. Both now point to the Token Service URL
shown under the arrangement's OAuth 2.0 Details, on the -api host. A 401
lists the four usual causes (token URL, client of another communication
user, missing arrangement, scope); a 403 names the missing arrangement.
docker-compose.yml and the quickstart listed the app's environment one
variable at a time and left out SMTP_*, so the SMTP settings setup.sh
writes into .env never reached the app. Both now pass SMTP_HOST, PORT,
USER, PASS, FROM, FROM_NAME, REPLY_TO and SECURE. The cloud compose
already did.

SMTP_FROM_NAME, which setup.sh and railway.json ask for, was never read.
It now names the sender when SMTP_FROM is a bare address.

A test checks that every SMTP_* variable the email service reads is
passed by both compose files.
A urlencoded or formdata body was mapped field by field without a
bodyEncoding, and Content-Type is not a replayed header, so the engine
sent the fields as JSON (the HAR importer had the same bug, fixed in
#1057). urlencoded bodies now get bodyEncoding form-urlencoded. formdata
bodies get form-data (multipart), and a file field becomes a __file
marker the engine fills from the public URL the caller passes, as the
Etsy image upload does.
@helpcodeai
helpcodeai requested a review from D3nisty as a code owner October 14, 2026 08:26
@helpcodeai
helpcodeai merged commit 221cdc6 into main Oct 14, 2026
14 checks passed
@helpcodeai
helpcodeai deleted the helpcode/review-1014-fixes branch October 14, 2026 08:48
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 14, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants