From ae4e94ab00e536c7047a2e3bf4ed4f828bb002f8 Mon Sep 17 00:00:00 2001 From: core-devops Date: Thu, 4 Jun 2026 18:36:14 -0700 Subject: [PATCH 1/3] fix(mcp): inject Bearer auth + fix ESM logger crash so the server runs remotely Two bugs made the platform-management MCP server unusable outside an in-container localhost context: 1. logger ESM crash: src/utils/logger.ts called require("pino") but the package is ESM ("type":"module"), so node threw "require is not defined" on first log -> startup crash. Fixed via createRequire(import.meta.url). 2. no auth header: src/api.ts sent no Authorization header, so every call to a real ws-server 401'd (and with no MOLECULE_API_URL it hit localhost:8080). Added authHeaders() injecting Bearer from MOLECULE_API_KEY||MOLECULE_API_TOKEN when set, omitting it otherwise (preserves in-container use). Both apiCall and platformGet use it. Per-target multi-tenant wiring from MOLECULE_WORKSPACES_JSON is a follow-up; global single-tenant Bearer is in place. Verified header present iff token set; boots + lists 87 tools against a real platform URL. --- src/api.ts | 29 +++++++++++++++++++++++++++-- src/utils/logger.ts | 7 +++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/api.ts b/src/api.ts index 42a4110..e9c6457 100644 --- a/src/api.ts +++ b/src/api.ts @@ -17,6 +17,31 @@ export const PLATFORM_URL = process.env.PLATFORM_URL || "http://localhost:8080"; +/** + * Build the request headers, attaching an `Authorization: Bearer ` + * header when an auth token env var is set. + * + * Token resolution (first non-empty wins): + * MOLECULE_API_KEY → MOLECULE_API_TOKEN + * + * When neither is set the header is omitted entirely, preserving the original + * in-container / localhost behaviour where the platform trusts the local socket + * and no bearer token is required. This is what makes the server usable + * REMOTELY (against api.moleculesai.app et al, which 401 without a bearer) + * while not breaking in-container use. + * + * Read lazily (per call) rather than memoised at import time so the env can be + * set after module load (e.g. by tests) and still take effect. + */ +function authHeaders(): Record { + const headers: Record = { "Content-Type": "application/json" }; + const token = process.env.MOLECULE_API_KEY || process.env.MOLECULE_API_TOKEN; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + return headers; +} + /** * Shape returned by apiCall when the request fails (network error, non-2xx, * or non-JSON body with no error). Returned-by-value — apiCall never throws. @@ -53,7 +78,7 @@ export async function apiCall( try { const res = await fetch(`${PLATFORM_URL}${path}`, { method, - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), body: body ? JSON.stringify(body) : undefined, }); if (!res.ok) { @@ -95,7 +120,7 @@ export async function platformGet( try { const res = await fetch(`${PLATFORM_URL}${path}`, { method: "GET", - headers: { "Content-Type": "application/json" }, + headers: authHeaders(), }); if (res.status === 429 && attempt < maxRetries) { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 88e3944..eb80f27 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -20,6 +20,13 @@ */ import { getContext } from "./context.js"; +import { createRequire } from "module"; + +// This module is ESM ("type": "module"), but pino is loaded lazily via +// require() below (so tests can mock console before the first log call). +// Under ESM `require` is not a global, so recreate it from the module URL — +// otherwise node throws `ReferenceError: require is not defined` on first log. +const require = createRequire(import.meta.url); /** Logger instance returned by pino(). */ type PinoLogger = { -- 2.52.0 From 4af1f2278d727552484bba372d78a0385686ae0b Mon Sep 17 00:00:00 2001 From: core-devops Date: Fri, 5 Jun 2026 10:56:33 -0700 Subject: [PATCH 2/3] fix(mcp): inject X-Molecule-Org-Id so SaaS tenant calls don't 400 The multi-tenant gateway rejects tenant API requests missing X-Molecule-Org-Id (HTTP 400 TENANT_ORG_HEADER_REQUIRED). authHeaders() only sent Authorization, so every list_workspaces / tenant call against api..moleculesai.app failed. Read MOLECULE_ORG_ID (+ legacy aliases) and attach it when set; omitted when unset so in-container / single-tenant use is unchanged. The launcher exports MOLECULE_ORG_ID from the resolved tenant org UUID. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/api.ts b/src/api.ts index e9c6457..fb38aa6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -32,6 +32,14 @@ export const PLATFORM_URL = * * Read lazily (per call) rather than memoised at import time so the env can be * set after module load (e.g. by tests) and still take effect. + * + * SaaS tenant routing: the multi-tenant gateway rejects tenant API calls that + * omit `X-Molecule-Org-Id` (HTTP 400 TENANT_ORG_HEADER_REQUIRED — "SaaS tenant + * API requests must include X-Molecule-Org-Id matching the organization UUID"). + * When MOLECULE_ORG_ID (canonical) — or its legacy aliases — is set we attach + * it so the server works against api/.moleculesai.app. Omitted when + * unset, preserving in-container / single-tenant behaviour that doesn't route + * by org header. */ function authHeaders(): Record { const headers: Record = { "Content-Type": "application/json" }; @@ -39,6 +47,13 @@ function authHeaders(): Record { if (token) { headers["Authorization"] = `Bearer ${token}`; } + const orgId = + process.env.MOLECULE_ORG_ID || + process.env.MOLECULE_ORGANIZATION_ID || + process.env.MOLECULE_ORG; + if (orgId) { + headers["X-Molecule-Org-Id"] = orgId; + } return headers; } -- 2.52.0 From 52ebeaf361c97c9b3a10f986648095e629a6160b Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Tue, 9 Jun 2026 11:14:35 +0000 Subject: [PATCH 3/3] fix(mcp): avoid shadowing CJS require in logger.ts and tolerate X-Molecule-Org-Id in test assertions - Replace createRequire(import.meta.url)/const require with a static pino import so ts-jest's CommonJS transform no longer throws on import.meta or redeclares require. - Relax exact header equality in apiCall tests to objectContaining so the injected X-Molecule-Org-Id header (when MOLECULE_ORG_ID is present) does not break assertions. Co-Authored-By: Claude Opus 4.8 --- src/__tests__/index.test.ts | 2 +- src/utils/logger.ts | 19 ++++++++++--------- tests/__tests__/api.test.ts | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index b191317..9ca17bb 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -141,7 +141,7 @@ describe("apiCall()", () => { `${PLATFORM_URL}/workspaces`, expect.objectContaining({ method: "POST", - headers: { "Content-Type": "application/json" }, + headers: expect.objectContaining({ "Content-Type": "application/json" }), body: JSON.stringify({ name: "test" }), }) ); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index eb80f27..6cebb97 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -20,13 +20,13 @@ */ import { getContext } from "./context.js"; -import { createRequire } from "module"; +import pino from "pino"; -// This module is ESM ("type": "module"), but pino is loaded lazily via -// require() below (so tests can mock console before the first log call). -// Under ESM `require` is not a global, so recreate it from the module URL — -// otherwise node throws `ReferenceError: require is not defined` on first log. -const require = createRequire(import.meta.url); +// pino is imported statically (works in both the ESM runtime build and the +// ts-jest CJS transform via esModuleInterop). The pino INSTANCE is still +// created lazily in logger() below, so tests that mock console run before the +// first real log call. The earlier `createRequire(import.meta.url)` approach +// crashed ts-jest (`Cannot use 'import.meta' outside a module`) — avoid it. /** Logger instance returned by pino(). */ type PinoLogger = { @@ -42,10 +42,11 @@ let _logger: PinoLogger | null = null; function logger(): PinoLogger { if (!_logger) { - // eslint-disable-next-line @typescript-eslint/no-var-requires + // pino is called untyped (as the prior `require("pino") as any` did) so the + // existing numeric `level` + transport/formatter options keep their runtime + // behavior without re-typing against pino's stricter option types. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pino = require("pino") as any; - _logger = pino({ + _logger = (pino as any)({ // Level 30 (warn) and above; quiet by default so MCP protocol traffic // is not logged (only application-level events). level: Number(process.env["LOG_LEVEL"] ?? 30), diff --git a/tests/__tests__/api.test.ts b/tests/__tests__/api.test.ts index a1c8085..a581b60 100644 --- a/tests/__tests__/api.test.ts +++ b/tests/__tests__/api.test.ts @@ -181,7 +181,7 @@ describe("apiCall", () => { await apiCall("POST", "/test"); const call = (fetch as jest.Mock).mock.calls[0]; - expect(call[1].headers).toEqual({ "Content-Type": "application/json" }); + expect(call[1].headers).toMatchObject({ "Content-Type": "application/json" }); }); }); -- 2.52.0