Skip to content

feat(gateway): serve the MCP and the API on one port - #1978

Open
nbouliol wants to merge 10 commits into
feat/gateway-r1from
feature/prd-1337-create-the-forestadmingateway-package-that-serves-both
Open

nbouliol wants to merge 10 commits into
feat/gateway-r1from
feature/prd-1337-create-the-forestadmingateway-package-that-serves-both

Conversation

@nbouliol

@nbouliol nbouliol commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Summary

New @forestadmin/gateway package ("private": true, bin forest-gateway): one process, one port, serving the MCP, the API or both, for agents that can't embed them. forest-mcp-server and forest-bff are untouched.

forest-gateway                      FOREST_GATEWAY_SERVICES = mcp | api | mcp,api
  BFFHttpServer (name "Forest Gateway", API activity-log drain)
    standalone handler              sets X-Forest-Gateway-Version
      GET /health                   { healthy, version, services: { mcp, api } }, 503 if an enabled one is degraded
      createGatewaySwitch(P)        reused as is from mcp-server
        P/mcp, P/oauth/*, discovery → ForestMCPServer, or a 503 handler when a secret is missing
        P/oauth/*?service=api       → buildBff
        P/api/{agent,docs,health}   → buildBff (basePath P/api, gatewayVersion)
      next                          → 404
  installShutdownHandlers(server)   no composite stop
packages/gateway/src/
├── gateway-env.ts          # FOREST_GATEWAY_*, PORT, legacy vars warned and ignored
├── standalone-handler.ts   # root /health, version header, 404, unavailable MCP
├── run-gateway.ts          # builds the services, the switch, the listener, the boot log
├── cli-dispatch.ts         # start, openapi, --help, --version
└── cli.ts

Decisions (also on the ticket):

  • PORT (default 3931 with mcp, 3450 with api only) and FOREST_GATEWAY_URL only; MCP_SERVER_PORT, HTTP_PORT, FOREST_MCP_SERVER_URL, BFF_PUBLIC_URL are ignored with a warning. MCP variables keep their legacy names (parseMcpEnv).
  • services.mcp / services.api report 'disabled' when not enabled.
  • Stays private: multi-semantic-release skips private packages (ignorePrivate defaults to true), so no tag, changelog or publish until the Docker-image ticket flips it. npm name reservation and trusted publishing happen before that flip, outside this PR.

Evidence

  • Before: no single entry point; MCP and API on two processes, both claiming /oauth/* at the root.
    After: yarn workspace @forestadmin/gateway test — 4 suites, 106 tests pass:
    • run-gateway.test.ts: a real Gateway (real MCP + BFF, fake Forest server over HTTP) on P='' and P='/ai': MCP token endpoint without service served by the MCP's real callback, ?service=api by the BFF, API CORS preflight, discovery at /.well-known/oauth-authorization-server/ai, 404 on /api/oauth/token, root /agent/v1/*, /mcp and /api/* outside /ai; 503 service_unavailable without FOREST_AUTH_SECRET / FOREST_ENV_SECRET; /health and /api/health agree on a degraded API; Forest unreachable → /health 200; version header on every kind of response; listener logs say "Forest Gateway".
    • shutdown.test.ts (spawned process): SIGTERM / SIGINT exit 0 before the deadline, SIGTERM+SIGTERM and SIGTERM+SIGINT stop once, an in-flight API request finishes, one still open past the 10 s deadline is closed.
    • cli-dispatch.test.ts: forest-gateway openapi announces /api, /ai/api, or FOREST_GATEWAY_URL + P/api.
    • gateway-env.test.ts: services parsing (empty / mcp,foo → error listing mcp, api), port defaults, PORT=0 without URL fails naming both.
  • npm resolution: npm pack + npm install in an empty external project resolves agent-bff@1.39.0 and mcp-server@1.27.1 from the registry with no peer conflict; forest-gateway --version and openapi run.

Not done here:

  • e2e against the benches through /api (scripts live in the non-Node matrix replay folder, not in this repo).
  • Shutdown with the audit backend down (the drain itself is covered in agent-bff).
  • Full OAuth authorize / exchange / refresh per service: routing to each token endpoint is tested, the full flow belongs to the bench e2e.

Merge Danger

Door: two-way

New private package; reverting the PR removes it.

Blast Radius: none

Nothing imports it, it isn't published, and no existing package or Docker image changes.

Fixes PRD-1337

🤖 Generated with Claude Code

Note

Add @forestadmin/gateway package serving MCP and API on one port

  • New forest-gateway CLI starts a single BFFHttpServer that routes MCP and API handlers behind one shared listener, with a root /health endpoint and JSON 404 fallback (run-gateway.ts, standalone-handler.ts).
  • Configuration comes from FOREST_GATEWAY_SERVICES, FOREST_GATEWAY_BASE_PATH, FOREST_GATEWAY_URL, and PORT; defaults are port 3931 with MCP enabled and 3450 for API-only (gateway-env.ts).
  • The openapi CLI command renders the API document under the gateway base path plus /api, with optional --output file writing (cli-dispatch.ts).
  • Embedded services no longer use their own listener variables: the BFF runs under the gateway's shared listener and /api public path, and MCP listener/URL variables are removed before parsing (gateway-env.ts).
  • Missing MCP secrets (FOREST_ENV_SECRET, FOREST_AUTH_SECRET) no longer fail startup; MCP routes return HTTP 503 service_unavailable and gateway health reports degraded.
  • Behavioral Change: legacy BFF_PUBLIC_URL, HTTP_PORT, and MCP_SERVER_PORT/FOREST_MCP_SERVER_URL variables are unused by the gateway and trigger startup warnings instead of configuring it.

Changes since #1978 opened

  • Centralized environment variable configuration for MCP and BFF listeners by introducing MCP_LISTENER_ENV constant in @forestadmin/mcp-server and BFF_LISTENER_ENV mapping in gateway, then updated LEGACY_VARS, toBffEnv, and toMcpEnv to reference these constants instead of hardcoded environment variable names [b3b7e52]
  • Extracted CLI output handling utilities from @forestadmin/gateway to @forestadmin/agent-bff package, including constants, parsing logic, and file writing functionality [b3b7e52]
  • Added gateway package to the CI build workflow matrix and bumped @forestadmin/agent-bff dependency from 1.39.0 to ^1.40.0 and @forestadmin/mcp-server from 1.27.1 to ^1.28.0 in gateway package [b3b7e52]
  • Introduced EnvAliases class to resolve canonical environment variable names against legacy aliases, with support for precedence rules, conflict detection, deprecation warnings, and optional blank-value handling [605c738]
  • Refactored parseGatewayEnv to use structured per-service configuration with alias resolution, validation, and aggregated warnings [605c738]
  • Added validation requiring API public URL to end with the API mount path suffix derived from base path [605c738]
  • Created parseAllowedOAuthClients parser and integrated OAuth client allowlist support into API service configuration [605c738]
  • Updated buildApiService and buildMcpService to accept structured settings with pre-resolved environment variables and labels [605c738]
  • Refactored runGateway to use parseGatewayEnv output, log aggregated warnings, and construct services from structured settings [605c738]
  • Implemented probeHealth utility and CLI for programmatic health checks with alias-aware port resolution [605c738]
  • Updated renderGatewayOpenApi and renderOpenApi to use aliased configuration and emit warnings with correct key labels [605c738]
  • Added parseOpenApiEnv exported function to resolve API environment configuration with base path and warnings for OpenAPI generation [605c738]
  • Introduced parsePort, parseGatewayUrl, and assertAgentUrl validators that reference specific resolved keys in error messages [605c738]
  • Created Docker image build infrastructure with multi-stage Dockerfile, dependency manifest generation, closure validation, and smoke test suite [605c738]
  • Implemented GitHub Actions workflow to build, scan, and publish multi-architecture Gateway Docker images to GHCR with version tagging [605c738]
  • Extended release workflow to detect newly created @forestadmin/gateway tags and trigger Docker image publishing [605c738]
  • Added scan-gate.js script to parse Trivy results and gate builds on fixable vulnerabilities in OpenTelemetry packages [605c738]
  • Overhauled test suite to validate environment aliasing, structured configuration, URL validation, OAuth allowlist parsing, and health probing [605c738]
  • Removed private: true flag from @forestadmin/gateway package and added @forestadmin/forestadmin-client runtime dependency [605c738]
  • Exported CONFIG_KEYS and parsePublicUrl from @forestadmin/agent-bff package entrypoint [605c738]
  • Pinned zod dependency in @forestadmin/mcp-server to exact version 4.6.5 [605c738]
  • Updated test helper environment variables to use new FOREST_* canonical names and deterministic encryption key [605c738]
  • Added deprecation warnings to @forestadmin/agent-bff and @forestadmin/mcp-server CLI entrypoints directing users to @forestadmin/gateway [f65b49f]
  • Added test coverage verifying deprecation warnings are logged correctly [f65b49f]
  • Updated README files for @forestadmin/agent-bff and @forestadmin/mcp-server to document deprecation [f65b49f]
📊 Macroscope summarized f65b49f. 20 files reviewed, 4 issues evaluated, 1 issue filtered, 3 comments posted

🗂️ Filtered Issues

packages/gateway/docker/smoke-test.sh — 1 comment posted, 2 evaluated, 1 filtered
  • line 34: Install the cleanup trap immediately after mktemp -d, before the initial checks. With set -e, a failed docker run, help grep, bundle check, or OpenAPI check at lines 10–23 exits before this trap is registered, leaving $TMP_DIR and its output files behind. The failures this smoke test is meant to detect therefore bypass temporary-file cleanup. [ Out of scope (post-validation triage) ]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 6, 2026

Copy link
Copy Markdown

PRD-1337

@qltysh

qltysh Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

8 new issues

Tool Category Rule Count
qlty Duplication Found 47 lines of similar code in 2 locations (mass = 226) 3
qlty Structure Function with many returns (count = 7): dispatchCli 2
qlty Structure Function with many parameters (count = 4): resolveApi 2
qlty Structure Function with high complexity (count = 12): generate 1

@qltysh

qltysh Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

Unable to calculate total coverage change because base branch coverage was not found.

Modified Files with Diff Coverage (15)

RatingFile% DiffUncovered Line #s
New file Coverage rating: A
packages/mcp-server/src/index.ts100.0%
New file Coverage rating: A
packages/gateway/src/env-aliases.ts100.0%
New file Coverage rating: A
packages/gateway/src/version.ts100.0%
New file Coverage rating: A
packages/mcp-server/src/mcp-env.ts100.0%
New file Coverage rating: A
packages/gateway/src/cli-dispatch.ts100.0%
New file Coverage rating: A
packages/gateway/src/index.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/cli-dispatch.ts100.0%
New file Coverage rating: A
packages/gateway/src/standalone-handler.ts100.0%
New file Coverage rating: A
packages/gateway/src/healthcheck.ts100.0%
New file Coverage rating: A
packages/gateway/src/gateway-env.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/cli-output.ts100.0%
New file Coverage rating: A
...min-client/src/oauth-client-allowlist/normalize-domain-list.ts50.0%33
New file Coverage rating: A
packages/gateway/src/run-gateway.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/index.ts100.0%
New file Coverage rating: A
packages/agent-bff/src/openapi/render-openapi.ts100.0%
Total99.7%
🤖 Increase coverage with AI coding...
In the `feature/prd-1337-create-the-forestadmingateway-package-that-serves-both` branch, add test coverage for this new code:

- `packages/forestadmin-client/src/oauth-client-allowlist/normalize-domain-list.ts` -- Line 33

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@nbouliol

nbouliol commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Manual test against a real agent

forest-gateway built from this branch, FOREST_GATEWAY_SERVICES=mcp,api, port 3931, Forest dev server, a v2 Node agent (mabank) on localhost:3310. Two runs: no base path, then FOREST_GATEWAY_BASE_PATH=/ai. P below is the base path.

MCP (Claude Code, claude mcp add --transport http … P/mcp)

P='' P=/ai
Browser OAuth on P/oauth/* ✅ ✅
describeCollection + list on customers ✅ 10 records ✅

API with a service-account key (X-Forest-Bff-Key: fgw_…, X-Forest-Timezone)

Call P='' P=/ai
GET P/api/agent/v1/context 200 200
POST P/api/agent/v1/companies/count 200 200
POST P/api/agent/v1/companies/list 200 200
GET P/api/agent/openapi.json → servers[0].url /api /ai/api
P/api/docs → document and bundle paths /api/… /ai/api/…
GET P/api/health 200 200

API OAuth (P=/ai, DCR client on the Forest server, PKCE)

Step Result
GET /ai/oauth/authorize?service=api → Forest login → code ✅
POST /ai/oauth/token?service=api (authorization_code) 200, served by the BFF, access + refresh
POST /ai/api/agent/v1/companies/count with the bearer 200
Refresh on /ai/oauth/token?service=api 200
API call with the refreshed bearer 200
API bearer on /ai/mcp 401 invalid_token: each service keeps its own tokens

Routing

Call P='' P=/ai
POST P/oauth/token without service MCP (Express, 400) MCP
POST P/oauth/token?service=api BFF (400) BFF
OPTIONS P/oauth/token?service=api from an allowed origin 204 + BFF CORS 204 + BFF CORS
MCP discovery /.well-known/oauth-authorization-server /.well-known/oauth-authorization-server/ai, issuer http://localhost:3931/ai
POST P/api/oauth/token 404 404
/agent/v1/* at the root 404 404
/api/*, /oauth/token?service=api without /ai n/a 404
Root /health 200, mcp: ok, api: ok 200
X-Forest-Gateway-Version every response every response

Still not covered

  • e2e against the non-Node benches.

@nbouliol
nbouliol changed the base branch from main to feat/gateway-r1 October 7, 2026 09:58
…d-1337-create-the-forestadmingateway-package-that-serves-both

@Tonours Tonours left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spec (PRD-1337): conforms to the acceptance criteria, but the gateway's tests never run in CI.

Applies to: .github/workflows/build.yml:62-79 (not in this diff)

Should fix: no CI job runs the gateway's lint or its 106 tests, so a regression merges green.

Code

The test matrix in .github/workflows/build.yml:62-79 lists each package by name, and gateway is not in it.
A way out: add gateway to that matrix.

Reproduction
  1. Break a gateway test on this branch.
  2. Push.
  3. The Linting & Testing jobs all pass.

Comment thread packages/gateway/package.json Outdated
Comment thread packages/gateway/package.json Outdated
Comment thread packages/gateway/src/gateway-env.ts Outdated
Comment thread packages/gateway/src/cli-dispatch.ts Outdated
nbouliol and others added 6 commits October 7, 2026 14:36
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exact pins fall behind the workspace on each release, since a private
package is skipped by multi-semantic-release, and Yarn then pulls the
published versions instead of linking the local ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rver own

A rename on their side now fails the gateway's typecheck, or carries
over through the exported name, instead of silently moving the API's
public url and port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nbouliol

nbouliol commented Oct 7, 2026

Copy link
Copy Markdown
Member Author

@Tonours on the CI finding from your review: fixed in 2fc7205, gateway is now in the test matrix of build.yml, so its lint and its 106 tests run on every push.

@Tonours

Tonours commented Oct 7, 2026

Copy link
Copy Markdown
Member

Verified closed: the gateway package now runs lint and tests in CI on every push.

How it was checked

A regression in the gateway package merged green because no CI job ran its lint or tests.
gateway sits in the build.yml test matrix at b3b7e52 and its job passed at this head.
No check pins the matrix entry, so this close is unpinned.

@Tonours Tonours left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after /verify-fixes run 1978-20261007133450-Tonours-verify: 5 findings of 1978-20261007121227-Tonours closed or accepted, none reopened.

@nbouliol
nbouliol added this pull request to stack #1989 October 8, 2026 12:34
@nbouliol
nbouliol removed this pull request from stack #1989 October 8, 2026 12:35
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`);

// Block only on Docker-only OTel packages — everything else is report-only.
const blocking = findings.filter(f => f.pkg.startsWith('@opentelemetry/'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium docker/scan-gate.js:48

A fixable vulnerability in a Docker-only transitive dependency such as import-in-the-middle or require-in-the-middle is excluded from blocking, so this script exits successfully and permits publishing the vulnerable image. The @opentelemetry/ name-prefix check misses those packages; classify findings against the Docker-only dependency closure instead.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/gateway/docker/scan-gate.js around line 48:

A fixable vulnerability in a Docker-only transitive dependency such as `import-in-the-middle` or `require-in-the-middle` is excluded from `blocking`, so this script exits successfully and permits publishing the vulnerable image. The `@opentelemetry/` name-prefix check misses those packages; classify findings against the Docker-only dependency closure instead.

echo "is_latest=$IS_LATEST" >> "$GITHUB_OUTPUT"

TAGS="-t $IMAGE:$FULL"
if [ "$IS_LATEST" = "true" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High workflows/docker-publish-gateway.yml:423

An older release can overwrite :latest, the major/minor tags, and the server deployment with its version after a newer release has already published. IS_LATEST is decided before imagetools create, while different versions run concurrently, so the newer tag can appear and publish between the check and write; serialize the mutable-tag decision and write, and ensure notifications use the same protected decision.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @.github/workflows/docker-publish-gateway.yml around line 423:

An older release can overwrite `:latest`, the major/minor tags, and the server deployment with its version after a newer release has already published. `IS_LATEST` is decided before `imagetools create`, while different versions run concurrently, so the newer tag can appear and publish between the check and write; serialize the mutable-tag decision and write, and ensure notifications use the same protected decision.

code=""
attempt=0
while [ "$attempt" -lt 90 ]; do
code=$(curl -s -o "$out" -w '%{http_code}' "$url" || true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium docker/smoke-test.sh:77

The smoke test can hang indefinitely if an HTTP endpoint accepts a connection but never completes its response, so it never reaches the retry limit, failure diagnostics, or cleanup. curl has no transfer timeout by default, and the direct requests at lines 137 and 144 have the same issue; add --max-time to each request.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/gateway/docker/smoke-test.sh around line 77:

The smoke test can hang indefinitely if an HTTP endpoint accepts a connection but never completes its response, so it never reaches the retry limit, failure diagnostics, or cleanup. `curl` has no transfer timeout by default, and the direct requests at lines 137 and 144 have the same issue; add `--max-time` to each request.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants