Skip to content

feat(mcp-server): accept a service account credential - #1944

Open
nbouliol wants to merge 4 commits into
mainfrom
feature/prd-1372-accept-a-service-account-credential-on-the-gateway-mcp
Open

nbouliol wants to merge 4 commits into
mainfrom
feature/prd-1372-accept-a-service-account-credential-on-the-gateway-mcp

Conversation

@nbouliol

@nbouliol nbouliol commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

An autonomous system can call the Gateway MCP with a service account credential (Authorization: Bearer fgw_… or fbff_…). It needs no OAuth flow and no human.

Fixes PRD-1372

What

File Change
forestadmin-client/src/gateway-api-key/ parseGatewayApiKey, GatewayApiKeyClient (resolve with a service), GatewayApiKeyResolveError
mcp-server/src/gateway-api-key-authenticator.ts Resolve with service: 'mcp', cache, error mapping, mint a 5-minute agent JWT
mcp-server/src/forest-oauth-provider.ts verifyAccessToken tries the credential first. JWTs never match the key pattern, so the OAuth path is unchanged
mcp-server README, CLAUDE.md Document the credential path

Behaviour

  • AuthInfo:

    • token is the minted agent JWT. agent-caller forwards it unchanged, and nothing else reads authInfo.token.
    • clientId: service-account-key:<keyId>.
    • expiresAt is the expiry of the agent JWT.
    • Scopes are mcp:read mcp:write mcp:action, the OAuth default. Real rights come from the service account's Role and Teams.
    • forestServerToken is the resolve's saasAccessToken. A resolve without one is refused.
  • Refusals:

    Resolve MCP answer
    401 InvalidTokenError, 401
    403 plan_feature_missing InsufficientScopeError, 403, "The project's plan does not include the Gateway MCP"
    other 403 InsufficientScopeError, 403
    400, 429, 5xx, unreachable ServerError, 500

    A plan or identity refusal is a 403, never a 401: a 401 would send an MCP client into the OAuth flow.

  • Cache: success for 60 s, 401/403 for 10 s, 10,000 entries. This is the same grace window as agent-bff.

Why this shape

  • The resolve client lives in forestadmin-client, not agent-bff: the Gateway switch requires mcp-server to import nothing from agent-bff. Both packages already depend on forestadmin-client. Moving agent-bff onto it is PRD-1409.
  • GatewayApiKeyClient calls fetch, not ServerUtils: ServerUtils drops meta.code and Retry-After, and plan_feature_missing must be told apart.
  • allowedOrigins is neither read nor required: it is on its way out (PRD-1386).

Rollout

The server maps mcp to no plan feature until PRD-1371 ships gatewayMcp. Until then every credential on the MCP gets 403 plan_feature_missing. Merging is safe; the dev end-to-end check waits for PRD-1371.

Notes for reviewers

  • The server mints the saasAccessToken with sessionType: bff. Billing still counts MCP reads: the activity log source comes from Forest-Application-Source: MCP.
  • The agent JWT is built with toAgentTokenClaims (fix: sign agent tokens in the shape Ruby agents read, and read both in the Node agent #1947), like the OAuth path and the workflow executor: tags is a {key, value} array and rendering_id a string, the shape Ruby agents read. The Node agent turns the array back into a record. agent-bff's issueAgentToken still signs a record.

How to test

yarn workspace @forestadmin/forestadmin-client test
yarn workspace @forestadmin/mcp-server test

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

🤖 Generated with Claude Code

Note

Add service-account (fgw_/fbff_) gateway credential support to MCP server auth

  • Adds a GatewayApiKeyClient in forestadmin-client that parses fgw_/fbff_ credentials and resolves them against the Forest server for the mcp service
  • ForestOAuthProvider.verifyAccessToken tries gateway-key parsing before JWT verification; matching credentials resolve into AuthInfo with a five-minute HS256 agent token and mcp:read/mcp:write/mcp:action scopes
  • Resolve refusals map to OAuth errors: 401 → InvalidTokenError, 403 → InsufficientScopeError (plan-feature or environment-access), others → ServerError
  • Successful identities are cached 60 seconds; 401/403 refusals are negatively cached 10 seconds, with a 10,000-entry LRU cap (see gateway-api-key-authenticator.ts)
  • Behavioral Change: bearer tokens matching the gateway-key format are no longer sent through local JWT verification in forest-oauth-provider.ts

Macroscope summarized 5cd9fb1.

@linear-code

linear-code Bot commented Sep 30, 2026

Copy link
Copy Markdown

PRD-1372

@qltysh

qltysh Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

3 new issues

Tool Category Rule Count
qlty Structure Complex binary expression 1
qlty Structure High total complexity (count = 58) 1
qlty Structure Function with many returns (count = 4): toOAuthError 1

@nbouliol nbouliol assigned Tonours and unassigned Tonours Sep 30, 2026
@nbouliol
nbouliol requested a review from Tonours September 30, 2026 14:17
Comment thread packages/forestadmin-client/src/gateway-api-key/index.ts Outdated
@qltysh

qltysh Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/forestadmin-client/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/forest-oauth-provider.ts100.0%
New file Coverage rating: A
packages/mcp-server/src/gateway-api-key-authenticator.ts100.0%
New file Coverage rating: A
packages/forestadmin-client/src/gateway-api-key/index.ts100.0%
New file Coverage rating: A
packages/forestadmin-client/src/gateway-api-key/resolve-error.ts100.0%
Total100.0%
🚦 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.

@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-1372): conforms to the five acceptance criteria. The ticket asked to reuse agent-bff's parseApiKey, and the PR copies the pattern instead, with the move tracked as PRD-1409.

Comment thread packages/mcp-server/src/forest-oauth-provider.ts
Comment thread packages/mcp-server/src/forest-oauth-provider.ts Outdated
}

if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as SaasErrorBody;

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 gateway-api-key/index.ts:85

A non-OK response with a valid JSON body of null throws a raw TypeError at body.errors, so it bypasses GatewayApiKeyResolveError and prevents the caller's refusal mapping. Normalize null to an empty object before reading errors.

Suggested change
const body = (await response.json().catch(() => ({}))) as SaasErrorBody;
const body = ((await response.json().catch(() => ({}))) ?? {}) as SaasErrorBody;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/forestadmin-client/src/gateway-api-key/index.ts around line 85:

A non-OK response with a valid JSON body of `null` throws a raw `TypeError` at `body.errors`, so it bypasses `GatewayApiKeyResolveError` and prevents the caller's refusal mapping. Normalize `null` to an empty object before reading `errors`.

@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 1944-20260930161056-Tonours-verify: 1 finding of 1944-20260930143229-Tonours closed, none reopened.

nbouliol and others added 4 commits October 2, 2026 14:01
An fgw_ or fbff_ bearer is resolved on the Forest server with service mcp,
cached, and turned into a short-lived agent token. The resolve client lives
in forestadmin-client so mcp-server imports nothing from agent-bff.

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>
…laims shape

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nbouliol
nbouliol force-pushed the feature/prd-1372-accept-a-service-account-credential-on-the-gateway-mcp branch from ef0f579 to 5cd9fb1 Compare October 2, 2026 12:02

@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-1372): conforms. The scope line asking to reuse agent-bff's parseApiKey is superseded by the 2026-09-30 decision recorded in PRD-1409.

export { default as GatewayApiKeyResolveError } from './resolve-error';
export type { GatewayApiKeyResolveErrorParams } from './resolve-error';

const API_KEY_PATTERN = /^(?:fgw|fbff)_([0-9a-f]{16})_([0-9a-f]{64})$/;

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.

This diff meets the criteria for a security review, so please run /security-review locally before merging.

Triggers
  • packages/forestadmin-client/src/gateway-api-key/index.ts:6 parses a bearer credential received at the MCP trust boundary.
  • packages/forestadmin-client/src/gateway-api-key/index.ts:72 sends the environment secret to the resolve endpoint.
  • packages/mcp-server/src/forest-oauth-provider.ts:569 routes matching bearer tokens past JWT verification to a new auth path.
  • packages/mcp-server/src/gateway-api-key-authenticator.ts:80 caches authentication decisions keyed on a credential hash.
  • packages/mcp-server/src/gateway-api-key-authenticator.ts:122 signs an agent JWT with the agent auth secret.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I ran /security-review again on HEAD 5cd9fb1: no finding at or above 0.8 confidence. It was a code read, with no exploit attempted.

Paths checked:

Path Why it is safe
Auth path selection (forest-oauth-provider.ts:568-570) The key regex is anchored, has no m flag and accepts only lowercase hex (gateway-api-key/index.ts:6). A token cannot be both a valid JWT and a key. A key always goes through the server resolve.
Cache (gateway-api-key-authenticator.ts:76-83,161-170) The cache key is sha256(keyId:secret), so a known keyId with a wrong secret never hits a cached identity. Only 401/403 are cached negatively; a 5xx cannot poison a valid key. Revocation can lag by up to 60 s, as in agent-bff (resolve-cache.ts:53-54).
Agent JWT claims (gateway-api-key-authenticator.ts:119-141) Every claim comes from the resolve response, which is authenticated by the env secret and checked for shape (index.ts:118-155). Signing is the same as agent-bff since #1948: toAgentTokenClaims, HS256, 5 min. The agent still applies its own permissions (query-string.ts:194-200). Scopes match the OAuth default.
Agent JWT replayed on /mcp The token is only sent to the environment's agent (agent-caller.ts:30-56), never to the client. If replayed anyway, it has no serverToken and no scopes, so DecodedAccessTokenSchema refuses it.
Agent JWT used as an upload handle Refused: handles.ts:47-48 requires type: 'mcp-upload', and a handle is bound to its uploader.
Secrets in logs (resolve-error.ts:10-24, forest-oauth-provider.ts:640-651) The key secret and env secret appear only in the request body and headers. The cause messages logged are: "fetch failed" plus the syscall and host:port, the timeout message, an invalid URL showing only forestServerUrl, a JSON parse error on the response, or a fixed string. None of them contain the request. saasAccessToken is never logged.
Errors returned to the client OAuth errors carry fixed messages, and the cause never goes into the response body. Any other error becomes a generic 500.
saasAccessToken Used like serverToken on the OAuth path (activity logs, Forest calls), and never sent to the client.

🤖 Generated with Claude Code

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