Skip to content

feat(mcp-server): let clients shorten the OAuth token lifetimes - #1788

Merged
Scra3 merged 15 commits into
mainfrom
feat/mcp-token-ttl
Jul 29, 2026
Merged

feat(mcp-server): let clients shorten the OAuth token lifetimes#1788
Scra3 merged 15 commits into
mainfrom
feat/mcp-token-ttl

Conversation

@Scra3

@Scra3 Scra3 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Spendesk asked to configure short-lived OAuth2 tokens on the Forest MCP server, to force periodic re-authentication and limit the blast radius of a leaked token. Nothing was configurable today: the access token mirrors Forest Admin's 1h and the refresh token its 8 days.

API as agreed in the thread (validated by @gautierd, @pierre.merlet and Guilhem):

agent.mountAiMcpServer({
  tokenTtl: {
    accessTokenSeconds: 900,
    refreshTokenSeconds: 86400,
  },
});

Standalone equivalent:

export FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS=900
export FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS=86400

Shorten only, never extend

Both values are applied as min(forestLifetime, configured), so a client can only shorten. The cap is applied where the TTL is computed, not in the sign() options — that is what keeps the advertised expires_in in step with the lifetime actually signed into the JWT.

Why the refresh token needed more than a min()

⚠️ This is the part worth reviewing, because a plain min() would have shipped a feature that does not do what the ticket asks — exactly what @julien.jolles flagged in the thread.

refreshAccessToken on the SaaS side (forestadmin-server, oauth-authorization-service.ts:212) deletes the presented refresh token and then calls generateTokens, which mints a brand-new 8-day refresh token (:342/:349). No session anchor is carried anywhere. The refresh chain is an unbounded sliding window: a client that refreshes at least once every 8 days is never asked to re-authenticate.

So a per-refresh cap would only be an idle timeout ("if the client goes quiet for N seconds, log in again"), not periodic re-auth.

Fix: anchor the chain on the interactive login with a sessionStartedAt claim that is carried across refreshes and never re-stamped. Effective refresh TTL becomes min(forestRemaining, sessionStartedAt + cap - now), which is still <= forestRemaining, so the shorten-only rule holds. Refresh tokens minted before the claim existed fall back to their iat (one last window, then bounded). Once the window elapses the refresh is rejected before any round trip to Forest Admin.

Precedent for this shape already in the repo: packages/agent-bff/src/oauth/session-store.ts:148,167-173 keeps an absolute expiresAt while swapping the SaaS tokens underneath.

The 60s floor is measured, not guessed

The MCP SDK rate-limits /oauth/token to windowMs: 15min, max: 50 (handlers/token.js:31-39) and server.ts:510 does not override it → one token request every 18s. Below that a client exhausts its own quota and takes 429s. 60s leaves ~30% of the budget and stays clear of inter-instance clock drift, since both verifyAccessToken and the SDK's bearer middleware compare timestamps with a zero clock tolerance. Values under 60s are raised to it with a Warn.

Invalid values (zero, negative, fractional, NaN, non-number) throw at startup: a security control that silently degrades to a no-op is worse than one that refuses to boot, since the customer would believe their tokens are short-lived when they are not.

Deliberately left alone

expires_in: expiresIn > 0 ? expiresIn : 3600 — that branch is only reachable when the Forest token is already expired (needs >1h of clock skew), and a cap can shorten a positive lifetime but never drive it to zero, so this change cannot widen it. A test pins it so the carry-over is visible rather than accidental. Fixing it properly means throwing instead of forging a dead token, which is a new failure mode on the login path and deserves its own PR.

Tests

  • test/utils/token-ttl.test.ts — validation table per field, the clamp and its boundary, and the security rule (capAccessTokenTtl(900, 3600) === 900)
  • test/forest-oauth-provider.test.ts — frozen clock so lifetimes are asserted as exact values; the crux test asserts the refresh window shrinks as the session ages (6400) even though Forest returns a fresh 8-day token
  • test/server.test.ts — one end-to-end POST /oauth/token covering options → constructor → provider → JWT: expires_in === 120 and exp - iat === 120 on the issued token
  • test/cli.test.ts, packages/agent/test/agent.test.ts — env parsing and pass-through

681 passing in mcp-server, 1006 in agent, lint clean on both. Each new test verified red before / green after.

Both packages are touched in one PR so multi-semantic-release bumps the pin in the same run.

Docs for the public site follow in ForestAdmin/docs and developer-guide (separate repos).

🤖 Generated with Claude Code

Note

Allow clients to cap OAuth token lifetimes in the MCP server

  • Adds a tokenTtl option (accessTokenSeconds, refreshTokenSeconds) to ForestMCPServer and Agent.mountAiMcpServer, capping issued OAuth token lifetimes below Forest's upstream expiry.
  • Validates and normalizes TTL values at construction time; values below 60s are raised to 60s with a warning, and invalid values (non-positive, NaN) throw an error.
  • Introduces session anchoring via sessionStartedAt in refresh token claims so the refresh cap applies to the total session window, not per-rotation; once the session window expires, refresh attempts fail with invalid_grant.
  • The standalone CLI reads FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS and FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS environment variables via a new parseTokenTtl helper in parse-token-ttl.ts.
  • Behavioral Change: expires_in in token responses now reflects the signed JWT TTL rather than the upstream Forest value; OAuth error codes from Forest (e.g. invalid_grant) are now preserved instead of being re-wrapped as invalid_request.

Changes since #1788 opened

  • Added zod schemas for runtime validation of JWT token payloads in ForestOAuthProvider module and updated exchangeRefreshToken and exchangeAuthorizationCode methods to validate decoded tokens [9bb8e63]
  • Added test cases verifying token payload validation in exchangeAuthorizationCode and exchangeRefreshToken methods [9bb8e63]
  • Removed parseTokenTtl function from parse-token-ttl utility module and capTtl function from token-ttl utility module [5f21e99]
  • Replaced parseTokenTtl utility usage in CLI module with local toSeconds helper function for converting environment variables to token TTL configuration [5f21e99]
  • Replaced capTtl function call with inline Math.min logic in ForestOAuthProvider class token expiration computation [5f21e99]
  • Removed test suites for deleted parseTokenTtl and capTtl functions [5f21e99]
  • Modified ForestAccessTokenSchema and ForestRefreshTokenSchema validation to coerce string values to numbers and enforce positive integer requirements for exp fields, and additionally for meta.renderingId in access tokens [371315a]
  • Added test coverage for ForestOAuthProvider.exchangeAuthorizationCode to verify handling of string-typed meta.renderingId values in access tokens [371315a]
  • Removed Zod-based validation of Forest OAuth token payloads in ForestOAuthProvider.exchangeAuthorizationCode [8326551]
  • Removed test cases validating Forest OAuth token payload schema requirements [8326551]
  • Updated comments throughout mcp-server package [06b5a40]
  • Refactored access token cap warning system in ForestOAuthProvider class [53a442d]
  • Changed RefreshTokenClaims from interface to type alias derived from Zod schema [53a442d]
  • Added parameterized test to verify ForestOAuthProvider.exchangeAuthorizationCode throws errors when tokens are not decodable JWTs [db9e8f0]

Macroscope summarized f685a7c.

Spendesk asked for short-lived OAuth tokens on the MCP server to force periodic
re-authentication and limit the blast radius of a leaked token. Nothing was
configurable: the access token mirrored Forest Admin's 1h and the refresh token
its 8 days.

    agent.mountAiMcpServer({
      tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 },
    });

Both values are upper bounds, applied as a min() against what Forest Admin
granted, so a client can only ever shorten a lifetime. The cap is applied where
the TTL is computed rather than in the sign() options, which keeps the advertised
expires_in in step with the lifetime signed into the JWT.

The refresh cap is anchored on the interactive login through a sessionStartedAt
claim that is carried across refreshes and never re-stamped. Forest Admin mints a
brand-new full-lifetime refresh token on every refresh grant, so a plain
per-refresh cap would slide forever: an active client would never re-authenticate
and the option would only be an idle timeout. Refresh tokens predating the claim
fall back to their issue date. Once the window elapses the refresh is rejected
before any call to Forest Admin.

Values below 60s are raised to it: the MCP SDK rate-limits /oauth/token to 50
requests per 15 minutes, so a shorter lifetime makes a client exhaust its own
quota and take 429s. Invalid values (zero, negative, fractional) fail at startup
instead of silently leaving the tokens uncapped.

Available embedded and standalone (FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS,
FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS), since standalone is the only mode
available to non-Node back-ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/mcp-server/src/utils/token-ttl.ts Outdated
Comment thread packages/mcp-server/src/forest-oauth-provider.ts
@qltysh

qltysh Bot commented Jul 28, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (4)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent/src/agent.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/forest-oauth-provider.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/server.ts100.0%
New Coverage rating: A
packages/mcp-server/src/utils/token-ttl.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.

Addresses both review findings, which share one cause: the session deadline was
enforced at a single point in time and against a single token.

An access token could outlive the session. generateAccessToken bounded it only by
accessTokenSeconds and the Forest lifetime, so refreshing near the deadline handed
out a token valid well past sessionStartedAt + refreshTokenSeconds — with a 24h
session and a 1h access token, up to an hour of access without an interactive
login.

The deadline could also lapse mid-flight. exchangeRefreshToken checked it before
the Forest round trips, then the TTL was recomputed after them, so a refresh
started just before expiry signed a refresh token with a non-positive lifetime:
an already-dead token returned as a success instead of a clean InvalidGrantError.

The remaining session lifetime is now measured once, after those round trips, and
bounds both tokens; a non-positive value rejects the grant. capRefreshTokenTtl
collapses into sessionRemainingSeconds so the anchor arithmetic lives in one
place, and capAccessTokenTtl becomes the generic capTtl.

Also warns once per process when a configured value exceeds the lifetime Forest
Admin granted. The min() silently neutralizes it, which reads as "my setting is
ignored" — the same confusion raised while the option was being designed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up, three defects.

An expired session raised the wrong OAuth error code depending on timing. The
in-flight check throws InvalidGrantError from inside generateAccessToken, but
exchangeRefreshToken's catch rewrapped everything as InvalidRequestError, so the
same decision surfaced as invalid_grant when caught pre-flight and invalid_request
when the Forest round trips outlasted the session. Only invalid_grant makes the
client drop its refresh token and re-run the interactive login, so in the window
this check exists to close the user got a dead end instead of the re-login the
option promises. The passthrough is narrowed to InvalidGrantError rather than
OAuthError, leaving Forest's own error surface untouched. The test asserted the
message by substring and passed on the wrong class; it now asserts the class.

`refreshTokenSeconds` was reported as having no effect when it exceeded one Forest
refresh lifetime. It has no inert case: it bounds the whole session, which Forest
re-extends on every refresh, so any value binds however large. Warning about it
told operators to delete a working control. Only the access cap can be inert.

The clamp log said "ignoring" while applying the 60s minimum — the opposite
reading, since the result is a 60s lifetime rather than an uncapped one.

Also pins the anchor's precedence: flipping `sessionStartedAt ?? iat` passed the
whole suite because each test supplied only one claim, while every real refresh
token carries both. That mutation is the silent no-op the anchor exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthv

matthv commented Jul 28, 2026

Copy link
Copy Markdown
Member

Reviewed in depth. The design is right and I want to say so before the criticism: I verified your central argument against the SaaS source, and it holds — packages/private-api/src/config/session.js sets OAUTH_REFRESH_TOKEN_LIFETIME_IN_SECONDS: 8 * 24 * 3600, and oauth-authorization-service.ts:244-251 deletes the presented refresh token then calls generateTokens({...}) with no exp inheritance. Fresh 8 days on every refresh, no absolute session anchor anywhere in the SaaS. A plain min() really would have shipped an idle timeout, and tokenTtl.refreshTokenSeconds really is the only mechanism in the whole system that can force a re-login. The sessionStartedAt anchor is the correct answer.

Two blockers, both narrow to fix. Everything below was run against 5d85f21 in a worktree, and the two blockers are backed by throwaway tests I wrote and executed rather than by reading.


🔴 1. The InvalidGrantError is downgraded to invalid_request, so the client never re-authenticates

forest-oauth-provider.ts:425 throws InvalidGrantError, but the call sits inside the try of exchangeRefreshToken, whose catch at :311-319 rewrites every error into InvalidRequestError. Measured, both paths:

Path Error that reaches the wire Client outcome
Pre-flight :287-296 (window already elapsed) invalid_grant ✅ re-login — works as designed
sessionRemaining <= 0 :424-425 (session expires during the Forest round trips) invalid_request ❌ hard fail, no re-login

Why the code and not the message decides this, in the SDK you're on:

  • server/auth/handlers/token.js:124-127 serialises error.toResponseObject() verbatim, so the provider's error class is the client-visible error field.
  • client/auth.js:177-187auth() invalidates credentials and restarts the interactive flow only for InvalidGrantError / InvalidClientError / UnauthorizedClientError. Any other OAuthError hits the bare throw error. invalid_request is precisely the code the client treats as unrecoverable.

The catch has no instanceof filter, so this is wider than the new guard — and the widest case is routine, not a race:

Inner error Native code → client After the catch
InvalidGrantError :425 (new) invalid_grant → re-login invalid_request → hard fail
CustomOAuthError('invalid_grant') :356user removed from the project, session revoked invalid_grant → re-login invalid_request → hard fail
CustomOAuthError('server_error') :356 — Forest 5xx server_error → re-login invalid_request → hard fail
plain Error from :380 / :395 / getUserInfo :399 server_error → re-login invalid_request → hard fail

For the "user removed from the project" row our JWT is still valid, so the pre-flight and jwt.verify both pass, Forest replies invalid_grant, and the assistant hard-fails forever without ever offering a login. That one is pre-existing — but this PR stakes a re-authentication feature on the same chassis, so the one-line fix now buys a lot more than the race path:

} catch (error) {
  // Preserve the OAuth code: `invalid_grant` is what tells the client to re-authenticate,
  // and `invalid_request` is the one code it treats as unrecoverable.
  if (error instanceof OAuthError) throw error;
  ...
  throw new ServerError(`Failed to refresh token: ${message}`);
}

ServerError rather than InvalidRequestError for the genuinely unexpected residue — correct per RFC 6749, and it's also the only code the SDK client still recovers from. Both OAuthError and ServerError are exported from @modelcontextprotocol/sdk/server/auth/errors.js. The same change applies at :229-231 on the authorization-code path.

🔴 2. warnIfCapIsInert tells the operator the refresh cap does nothing, in exactly its flagship configuration

:410-414 compares refreshTokenSeconds against the lifetime of one Forest refresh token. That denominator is right for the access token and wrong for the refresh one: with the session anchor, the uncapped baseline isn't 8 days, it's unbounded — which is your own argument at token-ttl.ts:58-59. So any finite cap has an effect, and the warning is unconditionally false for that field.

Measured with refreshTokenSeconds: 2592000 (30 days) and Forest granting 7 days:

  • at login: tokenTtl.refreshTokenSeconds=2592000 exceeds the 604800s lifetime granted by Forest Admin and has no effect
  • at day 29: refresh token signed with expiresIn: 86400 — the day left in the window, not Forest's 604800
  • at day 30: InvalidGrantError → browser re-login, i.e. exactly what the ticket asked for

"Force re-auth monthly" with 8-day upstream tokens is the flagship use case, and we log that it does nothing. The rational operator response is to delete the setting, removing the only absolute session bound that exists.

Same claim in three more places, and contradicted in place:

  • server.ts:145-147 — "so a value above Forest Admin's own TTL has no effect", then two lines later "refreshTokenSeconds bounds the time between two interactive logins". Both can't be true.
  • README.md:116 vs README.md:135 — identical contradiction.
  • the warns for each cap that exceeds the lifetime Forest Admin granted test (test/forest-oauth-provider.test.ts:1065) regression-protects it.

Suggest scoping the helper to accessTokenSeconds (where it's correct), dropping the :410-414 call, and fixing the two doc surfaces. packages/mcp-server/CLAUDE.md:16 already describes the design correctly.


🟠 Important

3. The tests can't see blocker 1. Both rejection tests assert .rejects.toThrow('Refresh token has expired') — a substring match that passes for InvalidGrantError('Refresh token has expired') and for InvalidRequestError('Failed to refresh token: Refresh token has expired'). forest-oauth-provider.test.ts:1185 and :1203 read identically yet emit different codes, and nothing in the suite can tell them apart. .rejects.toMatchObject({ errorCode: 'invalid_grant' }) on both. Beyond that, refreshTokenSeconds has zero coverage above the mocked provider: the new server.test.ts block only exercises accessTokenSeconds on the authorization-code grant, and the existing refresh e2e tests stop at expect(response.body.error).toBeDefined() (:678, :711). One e2e POST /oauth/token with an elapsed session asserting body.error === 'invalid_grant' would pin the actual contract — and a mid-flight variant would have failed today. The harness already exists at server.test.ts:607-666.

4. ignoring vs clamping. token-ttl.ts:25-29 logs ignoring tokenTtl.${field}: minimum value is 60 seconds and then return MIN_TOKEN_TTL_SECONDS. It clamps and enforces; nothing is ignored. An operator who sets 30 reads "ignoring" and concludes their access tokens keep Forest's 3600s, when they actually live 60s — and if they then debug unexplained 429s on /oauth/token, this log line is what makes them rule out the TTL setting. README.md:137 states the real behaviour, and the test name says clamps, while the test asserts the misleading string verbatim (test/utils/token-ttl.test.ts:52). Suggest tokenTtl.${field}=${value} is below the ${MIN}s minimum and was raised to ${MIN}s.

5. An empty env var silently disables the control. parse-token-ttl.ts:8if (!accessTokenSeconds && !refreshTokenSeconds) return undefined. FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS="" is indistinguishable from unset: no cap, no log. The partial case is the dangerous one — access set, refresh empty: the operator watches short access tokens work correctly and reasonably concludes both settings took effect, while the only bound on time-between-logins is off. That's the most common deployment misconfiguration there is (=$UNSET_VAR, an unrendered Helm value, a valueFrom on a missing ConfigMap key), and README.md:137 explicitly promises the opposite: "fails at startup rather than silently leaving the tokens uncapped". Suggest distinguishing undefined from '' and throwing on ''. (Minor, same file: " " currently throws Invalid tokenTtl.accessTokenSeconds "0", reporting a value nobody typed — .trim() fixes the diagnostic.)

6. The refresh-token claim schema can drift silently, and it fails open. The sign() payload (:448-459) and the decoded type (:241-249) describe the same wire format but are fully decoupled by the as typeof decoded cast at :252. Rename sessionStartedAt on the producer side only and ?? decoded.iat takes over — and iat is re-stamped on every rotation, so the window slides forever and the cap never forces a re-login again. That's the exact failure mode token-ttl.ts:58 exists to prevent, with no error and no log. Both sign and verify are mocked in the suite, so nothing catches it. A named RefreshTokenClaims used at both ends turns it into a compile error. Related: the ?? nowInSeconds tail resets the anchor to now — unreachable today since jsonwebtoken always stamps iat, but a fail-open default on a security bound is worth at least a comment.

7. Two docs promise more than the code delivers.

  • README.md:135 — "It is measured from the login itself" is unconditional, but at rollout ?? decoded.iat anchors on the last rotation, not the login. An assistant refreshing hourly always presents an iat minutes old, so enabling refreshTokenSeconds: 86400 gives a three-week-old session another 24h. Your PR body is honest about this ("one last window, then bounded"); the README isn't. A caveat is the minimum; rejecting tokens without sessionStartedAt (one re-login at deploy) is the strict option.
  • README.md:68 — the default for FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS is listed as "Forest Admin's own lifetime". Per the SaaS trace at the top of this comment there is no ceiling at all when it's unset: refresh at least once per 8 days and you never sign in again. Combined with blocker 2 telling operators that values above 8 days do nothing, the docs steer them away from the entire useful range. Suggest "unbounded — Forest Admin re-grants its refresh lifetime on every refresh".

8. Log levels are inverted on the new paths. :291-296 logs at Error what is the feature's success path — a session reaching its configured limit is the control working. With the documented 86400 that's one ERROR per user per day forever, which trains operators and alerting to ignore Error from [ForestOAuthProvider] — exactly where blocker 1 needs to be visible. Meanwhile :424-425, the only genuinely anomalous branch in the new code, logs nothing at all. Suggest Info at :291 reworded as an outcome, and a Warn at :424 including roundTripSeconds — that's the field that distinguishes a real expiry from Forest latency, and it's unobtainable today. (Separately: exchangeAuthorizationCode's catch at :229-231 has no this.logger call at all, so a failed interactive login leaves zero server-side trace. Pre-existing, but this PR touches that function at :215.)


Smaller things

  • cli.ts:15-18parseTokenTtl(access, refresh) takes two adjacent positional string | undefined. Swapping them compiles, type-checks, passes every test, and turns access=900, refresh=86400 into access=86400: 24× longer, in the exact direction the feature exists to prevent. An object parameter makes it impossible.
  • The 60s floor protects less than its comment claims. The rationale at token-ttl.ts:3-4 is accurate and well sourced — I confirmed windowMs: 15*60*1000, max: 50 in handlers/token.js:62-63 and that server.ts:521 doesn't override it. But tokenHandler passes no keyGenerator, so express-rate-limit buckets per IP, and server.ts:427 sets trust proxy 1. "a client exhausts its own quota" understates it: at accessTokenSeconds: 60, four clients behind one egress IP blow the window, and 429TooManyRequestsError is neither ServerError nor InvalidGrantError, so it hard-fails through the same mechanism as blocker 1. Also worth noting the comment derives 18s but the constant guards 60s — the clock-drift reasoning that justifies the gap is in your PR description but not in the code.
  • :428 "Capped here, not in the sign() options" — expiresIn is passed as a sign() option at :444. The intent (compute once, before signing, so the response and the JWT can't disagree) is right and worth keeping; the wording contradicts itself.
  • No cross-field sanity check: { accessTokenSeconds: 3600, refreshTokenSeconds: 60 } is accepted and forces a browser login every minute. Almost certainly swapped values — a warn would catch the same mistake as the positional-args issue above.
  • field: keyof TokenTtlOptions in normalizeSeconds, warnIfCapIsInert and fieldsWarnedAsInert: those strings surface in user-facing errors and in the README table, so a rename desynchronises them silently today. Free, and uses a type that already exists.

What I actually verified

  • Both blockers reproduced with throwaway tests executed against 5d85f21, not inferred. Blocker 2's numbers (86400 signed at day 29, rejection at day 30) are assertions that passed.
  • Your expires_in: expiresIn > 0 ? expiresIn : 3600 analysis checks out: the line is pre-existing (origin/main, same file), and the cap genuinely cannot newly reach it, since sessionRemaining <= 0 throws at :424 and every normalised cap is >= 60. Leaving it out was the right call. One caveat — CLAUDE.md:16 now asserts expires_in "stays in step with the signed JWT", which is true everywhere except that branch, so it's worth scoping the sentence.
  • The direction of every rounding is safe: sessionStartedAt captured before the round trip and nowInSeconds re-measured after, so slow upstreams shorten sessions. Pre-check and post-check reduce to the same condition, so no off-by-one at elapsed == cap. Tightening the cap applies immediately to live sessions; loosening it doesn't extend already-issued tokens.
  • 549 tests passed and lint clean on the touched files locally. Caveat: 3 suites (server.test.ts, index.test.ts, tools/get-action-form.test.ts) failed to compile in my sandbox on a stale @forestadmin/datasource-toolkit (ActionField.getPlainField missing) — unrelated to this PR, and I re-ran your new tokenTtl option block separately, 2/2 green. So I did not reproduce the 681 figure.

test/utils/token-ttl.test.ts is the strongest file here, by the way — property-based rather than implementation-based ("never extends", "shrinks instead of sliding"), the invalid-input matrix covers both fields, and the 59/60 boundary is distinguished. And bounding the access token by sessionRemaining closes the hole that verifyAccessToken never checking the anchor would otherwise leave wide open; the comment at :416-417 should definitely stay.

alban bertolini and others added 3 commits July 28, 2026 16:18
Without the numbers a reader cannot tell whether their value will bind. Added to
the JSDoc on both the option and the type — that is the copy people read in their
IDE — plus the env table and the README section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to @matthv's review.

Widened the error passthrough from InvalidGrantError to OAuthError. The catch was
flattening every Forest error into invalid_request, the one code the SDK client
treats as unrecoverable (client/auth.js:150-162 retries only on invalid_grant,
invalid_client and unauthorized_client, and maps the wire code back to a class).
So a session Forest itself revoked — user removed from the project, token revoked —
hard-failed forever without ever offering a login. Six tests asserted that
flattening; they now assert the propagated code. Kept InvalidRequestError for the
non-OAuth residue: ServerError reads better per RFC but the client recovers from
neither, so it buys nothing here.

An env var set to an empty string was indistinguishable from unset, silently
leaving that token uncapped — worst when only one of the two is empty, since the
operator sees short access tokens working and assumes both took effect. It now
coerces to NaN and is rejected, matching what the README already promised.
parseTokenTtl takes an object: swapping two adjacent positional strings compiled,
type-checked and applied the refresh cap to access tokens.

The refresh claims are now a named type used by both the sign() payload and the
verify() cast, so renaming one side is a compile error instead of a silent
fallback to iat — which is re-stamped on every rotation and would slide the window
forever.

Log levels were inverted: a session reaching its limit is this option working, not
an error, and with the documented 86400 it fired once per user per day. It drops to
Info, while the genuinely anomalous branch (round trips outlasting the session) now
warns with the elapsed time, the only way to tell it from a plain expiry.

Docs: the refresh default is unbounded, not 8 days — Forest re-grants its lifetime
on every refresh, so unset means never signing in again. Also documents that tokens
predating the option anchor on their last refresh for one longer session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The company renamed. Scoped to the lines this branch adds; the rest of the repo
still carries the old name and needs its own sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Scra3

Scra3 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Thanks — genuinely useful review. All of it is addressed. Two of your findings were already fixed in cb4afc4f5 (you reviewed 5d85f21), and on one point I have to correct you.

🔴 1 — fixed, and you pushed me further than I had gone

I had already narrowed this to InvalidGrantError in cb4afc4f5, which closed the race path but left your bigger case standing. Your CustomOAuthError table changed my mind: widened to OAuthError.

The tests were pinning the bug. forest-oauth-provider.test.ts:807 mocked Forest returning invalid_grant and asserted we emit Failed to refresh token; six server.test.ts cases asserted expect(response.body.error).toBe('invalid_request') for Forest sending invalid_grant, invalid_client, invalid_scope, unauthorized_client and server_error — every recoverable code flattened to the one that is not. All seven now assert the propagated code.

One correction: ServerError is not recoverable either. client/auth.js:150-162 retries only on InvalidClientError, UnauthorizedClientError and InvalidGrantError; everything else hits the bare throw. Combined with :131 (OAUTH_ERRORS[error] || ServerError) the wire code decides, and server_error is not in that set. So swapping InvalidRequestErrorServerError for the non-OAuth residue is defensible on RFC grounds but buys nothing behaviourally — I kept InvalidRequestError rather than churn more tests on a rationale that does not hold.

🔴 2 — was already fixed

cb4afc4f5: the helper is scoped to accessTokenSeconds, the refreshTokenSeconds call is gone, and the test now asserts silence for a 30-day cap. Both doc surfaces corrected in the same commit. You are right that my own comment at token-ttl.ts:58-59 stated the premise that made the warning false.

🟠 3 — both assertions now check the wire code

Switched to .rejects.toMatchObject({ errorCode: 'invalid_grant' }). Verified by mutation: removing the passthrough now fails rejects when the session elapses while talking to Forest, which it did not before. The e2e refresh-grant gap is real and still open — see the end.

🟠 4 — was already fixed

cb4afc4f5: raising tokenTtl.X from N to the 60s minimum.

🟠 5 — fixed

"" now coerces to NaN and is rejected. Your partial case is what sold it: access set, refresh empty, and the operator watches short access tokens work and concludes both landed. Also .trim(), so " " no longer reports a value nobody typed. And parseTokenTtl takes an object now — your positional-swap point was right, swapping them compiled and type-checked.

🟠 6 — fixed

Named RefreshTokenClaims, used by both the sign() payload and the verify() cast. Renaming one side is a compile error instead of a silent slide to iat.

🟠 7 — fixed, and one of these was my own regression

The default was wrong in a way I had made worse: I changed it to 691200 (8 days) about an hour before your review. It is unbounded — Forest re-grants its lifetime on every refresh, so unset means never signing in again. Fixed in the README, the env tables and the public docs, plus the rollout caveat for tokens predating the claim.

🟠 8 — fixed

Info on the limit-reached path (it is the option working, once per user per day at the documented 86400), and a Warn carrying roundTripSeconds on the round-trips-outlasted-the-session branch, which logged nothing at all.

Smaller

Done: object parameter for parseTokenTtl; keyof TokenTtlOptions for the field params; the cap comment reworded (it did contradict itself — expiresIn is a sign() option); the 60s rationale now says per-IP and explains the margin instead of implying 60 is the rate-limit threshold.

Deliberately not done: the cross-field warn for { access: 3600, refresh: 60 } — the tighter bound legitimately wins and normalizeSeconds sees one field at a time; and exchangeAuthorizationCode's missing logger, which is pre-existing and on the login error path, where widening scope is how the last two blockers got in.

Still open

The e2e refresh-grant coverage from your point 3. Everything about refreshTokenSeconds is tested above a mocked sign/verify, so "the anchor we sign is the anchor we read back" is never closed by a real round trip — and that is exactly the property a claim rename would break. The server.test.ts:607-666 harness exists; a two-hop test would catch it for free. I would rather do that in its own PR than grow this one further.

On your sandbox caveat: 692 passed here and lint clean on both packages. The three suites that failed to compile for you were the stale datasource-toolkit pin — unrelated to this branch, fixed on main by #1772.

The JWT the server signs is not encrypted and carries the Forest token under a
serverToken claim, so a leaked MCP access token yields a Forest credential usable
against the agent for its own remaining lifetime — bypassing the scopes and the
audit log the MCP path enforces. Claiming the cap shortens how long a leaked token
stays usable promised more than the code delivers.

Also drops the JSDoc over-generalisation: only accessTokenSeconds is bounded by a
Forest lifetime, refreshTokenSeconds bounds an otherwise unbounded session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@matthv

matthv commented Jul 28, 2026

Copy link
Copy Markdown
Member

Re-reviewed at 637517a7b. Both blockers are genuinely fixed — I re-ran the throwaway probes rather than trusting the diff:

before now
refresh rejected by the pre-flight check invalid_grant invalid_grant
refresh rejected mid-flight (sessionRemaining <= 0) invalid_request invalid_grant
30d refresh cap, Forest granting 8d warns "has no effect" silent
…and the same cap 29 days in refresh token signed at 86400, i.e. still binding

The OAuthError passthrough turned out to be worth more than the race path it was aimed at: it repaired the pre-existing flattening across the whole authorization-code path, and the six e2e assertions in server.test.ts that used to expect a blanket invalid_request now pin invalid_grant / invalid_client / invalid_scope / unauthorized_client / server_error. Nice side effect.

Also worth calling out README.md:133 from the last commit — that the JWT is signed and not encrypted, so accessTokenSeconds bounds what a leaked token can drive through this server but does nothing to the Forest token carried inside it. I verified that's exactly right (serverToken: forestServerAccessToken is signed into the payload at forest-oauth-provider.ts:459), and being explicit that a leak must be revoked at the source is the honest way to scope a security control. Good addition.

Three things still open, one of which I'd fix before merging.

1. roundTripSeconds reports the session age, not a round tripforest-oauth-provider.ts:437-443

`sessionStartedAt=${sessionStartedAt}, roundTripSeconds=${nowInSeconds - sessionStartedAt}`

sessionStartedAt is the login anchor, not the start of this request. Reaching this branch means sessionRemaining <= 0, i.e. nowInSeconds - sessionStartedAt >= refreshTokenSeconds — so the field always prints at least the configured cap. Measured: it printed 86404 on a request whose two Forest round trips took 5 s.

The comment two lines above says "the elapsed time is the only way to tell that from a plain expiry", and that's the right instinct — but as computed, the value is ~constant at the cap and can't make that distinction. Either diff against the nowInSeconds captured in exchangeRefreshToken before the round trips, or rename it to sessionAgeSeconds and drop the claim. A diagnostic field that is always misleading is worse than no field.

2. RefreshTokenClaims catches a rename but not an omission:34-41

sessionStartedAt?: number is optional on the shared interface, so it's optional on the producer too. Renaming the claim on one side is a compile error, as the comment promises — but deleting it from the refreshClaims object at :467 still typechecks, and ?? decoded.iat then silently takes over with an iat that's re-stamped on every rotation. That's the exact slide the interface was introduced to prevent, so it'd be worth making the field required on the write side and optional only on the read side (e.g. Omit<RefreshTokenClaims, 'sessionStartedAt'> & { sessionStartedAt?: number; iat?: number } for the verify() cast).

3. A stale note now contradicts the tests below ittest/server.test.ts:745-746

// Note: The implementation wraps all OAuth errors in InvalidRequestError,
// so the error code is always 'invalid_request' with the original error in the description

That was true before this PR; the six tests directly underneath it now assert the real codes. Just delete it.

Smaller, take or leave:

  • CLAUDE.md:16 still says the cap placement keeps expires_in "in step with the signed JWT". True everywhere except the already-expired branch at :482, so worth scoping.
  • The // Only reachable when the Forest token is already expired; a cap cannot reach zero. comment got dropped, and expires_in: expiresIn > 0 ? expiresIn : 3600 now has no explanation at all. That comment was accurate — my earlier note was about the CLAUDE.md sentence, not about it. Worth restoring.
  • exchangeAuthorizationCode's catch still has no this.logger call, so a failed interactive login leaves no server-side trace. Less pressing now that the OAuth code propagates, since the client at least receives something meaningful.
  • The residue in both catches is still InvalidRequestError. Fine for genuinely unexpected errors, except that a SyntaxError from response.json() on a non-JSON 502, or a throw out of getUserInfo, still hard-fails the client where ServerError would let it recover.
  • [ForestOAuthProvider] on the clamp warning in token-ttl.ts — that one is emitted from ForestMCPServer's constructor, so the prefix misattributes it.
  • Still no cross-field check: { accessTokenSeconds: 3600, refreshTokenSeconds: 60 } is accepted and forces a browser login every minute, which is almost certainly swapped values.

Locally: 554 tests pass (549 before the fixes), server.test.ts 103/103, lint clean — 0 errors, and the 6 no-explicit-any warnings are all pre-existing and untouched by this PR. Same caveat as before: three suites don't compile in my sandbox on a stale @forestadmin/datasource-toolkit (ActionField.getPlainField), unrelated to this branch, so I ran server.test.ts separately.

From my side this is good to merge once #1 is sorted — the design was right from the start, and the fixes landed where they needed to.

Second review round from @matthv.

roundTripSeconds was `nowInSeconds - sessionStartedAt`, i.e. the session age. Since
that branch is only reached when the session has lapsed, the field always printed at
least the configured cap — 86404 on a request whose round trips took 5s — so it could
never do the one job the comment claimed. It now diffs against a timestamp captured
at the top of generateAccessToken, before the Forest calls.

RefreshTokenClaims caught a rename but not an omission: `sessionStartedAt?` was
optional on the shared interface, so deleting it from the signed payload still
typechecked and `?? iat` took over silently. It is now required on the write side,
with a separate read type accepting it missing for tokens minted before the claim.
Verified: omitting it is TS2741.

Adds the cross-field warn two reviewers asked for — a session shorter than one access
token is almost always the two values swapped.

Drops a stale note in server.test.ts that contradicted the six assertions under it,
restores the comment explaining the 3600 fallback (accurate, and the line had no
explanation left), scopes the CLAUDE.md expires_in claim to exclude the
already-expired branch, and drops the [ForestOAuthProvider] prefix from the clamp
warning — that one is emitted from the ForestMCPServer constructor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Scra3

Scra3 commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

All three fixed. Thanks for re-running the probes rather than reading the diff — and for catching that roundTripSeconds was cosmetically plausible and functionally useless.

1 — roundTripSeconds now measures a round trip

You were right, and the diagnosis was exact: reaching that branch requires nowInSeconds - sessionStartedAt >= refreshTokenSeconds, so the field could only ever print at least the cap.

Fixed by capturing a timestamp at the top of generateAccessToken, before the Forest calls, and diffing against that — no parameter threading needed, since the round trips happen inside that function:

const requestStartedAt = Math.floor(Date.now() / 1000);
const response = await fetch(`${this.forestServerUrl}/oauth/token`, { ... });
...
roundTripSeconds=${nowInSeconds - requestStartedAt}

I kept the field name and the claim rather than renaming to sessionAgeSeconds, because the distinction it was supposed to draw is the useful one — it just needed the right operand.

2 — the interface now catches the omission too

Correct, and the hole was exactly where you pointed: optional on the shared interface meant optional on the producer. sessionStartedAt is now required on RefreshTokenClaims, with a separate read type for the verify() cast:

type DecodedRefreshToken = Omit<RefreshTokenClaims, 'sessionStartedAt'> & {
  sessionStartedAt?: number;
  iat?: number;
};

Verified by deleting the claim from the signed payload: error TS2741: Property 'sessionStartedAt' is missing in type ... but required in type 'RefreshTokenClaims'.

3 — stale note deleted

Gone. It described the behaviour the six assertions beneath it now contradict.

Smaller ones — all taken

  • CLAUDE.md:16 scoped: expires_in matches the signed JWT except on the already-expired-upstream branch, which advertises 3600.
  • The // Only reachable when the Forest token is already expired comment is restored. You are right that I dropped it on the wrong signal — a comment-analysis pass flagged the neighbouring "cannot drift" claim as false (it was, and it is gone), and I cut this one alongside it. It was accurate, and the line had no explanation left.
  • The [ForestOAuthProvider] prefix is off the clamp warning — you are right that it is emitted from the ForestMCPServer constructor, so it misattributed.
  • Cross-field check added. You and one other reviewer both raised it, and the failure mode is concrete enough to deserve the four lines: a session shorter than one access token warns Did you swap the two values?. The tighter bound still legitimately wins, so it is a warn, not a throw.

Still holding on two

ServerError for the residue. I have to push back a second time, with the line reference this time: client/auth.js:150-162 recovers on InvalidClientError, UnauthorizedClientError and InvalidGrantError only — everything else reaches the bare throw error. ServerError is not in that set, so a SyntaxError out of response.json() on a non-JSON 502 hard-fails the client identically whether we wrap it in InvalidRequestError or ServerError. It is more correct per RFC 6749 §5.2 and I would take it as a standalone cleanup, but it changes six e2e assertions for zero behavioural gain, so not in this PR.

exchangeAuthorizationCode's missing logger. Agreed it is a real gap, and less pressing now that the code propagates. Leaving it: it is pre-existing, on the login error path, and touching that path in a TTL PR is precisely how the first two blockers got in.

Still open, unchanged

The e2e refresh-grant test. Everything about refreshTokenSeconds sits above a mocked sign/verify, so "the anchor we sign is the anchor we read back" is never closed by a real round trip — and after this round that property is compiler-enforced against renames and omissions, but still not against a verify() that returns a differently-shaped object at runtime. Its own PR.

694 passed here (692 before, +2 for the cross-field warn), lint clean on both packages.

alban bertolini and others added 6 commits July 29, 2026 09:44
zod is already a runtime dependency of this package (^4.3.5, used by every tool
schema), so this adds no dependency — and it follows the boundary-validation pattern
workflow-executor documents for anything crossing a trust boundary.

Three casts were unchecked. The two Forest tokens are only `decode`d, never verified
— we hold no key — so their shape was entirely unvalidated: a missing or non-numeric
`exp` put NaN into the TTL arithmetic and then into sign(). Our own refresh token is
signature-verified but its payload shape was not, which was the one hole the required
`sessionStartedAt` claim could not close: the compiler now rejects a rename or an
omission, but not a verify() returning a different shape at runtime.

The refresh schema makes `type: z.literal('refresh')` part of the shape, so the parse
subsumes the old type check and a signature-valid token we cannot read is refused
before any call to Forest.

Drops the hand-written DecodedRefreshToken, now redundant with the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither earned its keep.

parse-token-ttl existed to coerce env strings, but Number() already trims (' 900 '
→ 900) and turns '' and '   ' into 0, which the positive-integer check already
rejects. So the trim was dead and the empty-string-to-NaN trick only changed the
rejected value printed in the message from "0" to "NaN" — both values nobody typed.
Its "both undefined → undefined" guard also duplicated normalizeTokenTtl's own.
Replaced by one expression at the only call site.

capTtl was a one-line Math.min wrapper with a single production call site, and it
introduced a second convention for "no bound" (undefined) alongside the Infinity
that sessionRemainingSeconds already returns. Inlining it unifies the sentinel and
keeps the property that matters — expiresIn computed once, before signing.

Its unit tests go with it; the same property is asserted with exact values at the
provider level ("shortens both token lifetimes", "never extends beyond the
lifetimes Forest granted").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The zod schemas were stricter than the tokens they parse, which on the login path
means nobody signs in.

private-api's auth-token.ts types the payload as `meta?: { renderingId?: number }` —
optional both ways — and the cast this replaced tolerated a stringified id, since it
only ever fed String(renderingId) and getUserInfo. A strict z.number() would have
rejected a token the previous code accepted.

Coerced with int().positive() rather than plain coercion: Number(null) is 0, and an
exp of 0 means expired in 1970, so bare coercion would have let the NaN class of bug
back in through a different door.

Pins that a valid token still passes, including one carrying renderingId as a string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I justified parsing the SaaS tokens with "a NaN reaches sign()". That is true and
harmless: jsonwebtoken.sign rejects a NaN expiresIn, a missing meta already throws on
destructuring, and a stringified exp already worked through JS coercion. Every
malformed shape failed loudly, so a schema there bought a nicer message — against the
risk of rejecting a token the SaaS legitimately sends, which breaks login. That risk
materialised twice while writing it. Reverted to the cast.

Our own refresh token is the opposite case and keeps its schema: if verify() returns a
shape without `sessionStartedAt`, the `?? iat` fallback takes over with a timestamp
re-stamped on every rotation, so the session window slides forever with no error and
no log. Silent, and a security bound. That is what the parse is for.

The Forest-token rejection tests go too: jsonwebtoken is mocked in that suite, so the
loud failure they claimed to pin is invisible there. A test that cannot observe its
property is worse than none. The tolerance test stays — it guards against someone
re-adding a strict schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removed the three that repeated a fact stated elsewhere in the same file (the
sessionStartedAt rollout note, duplicated on the type; the anchor note, same) or that
paraphrased visible code (the parse subsuming the type check, when z.literal('refresh')
sits two lines above). Shortened five others. Deleted all seven in the test file — the
test names already carry the intent.

What survives is the set where the reason is not deducible from the code and where a
future edit would silently break something: the measured rate limit behind the 60s
floor, the SaaS re-granting its refresh lifetime (the whole reason for the anchor), why
the SaaS tokens are cast rather than parsed, why only the access cap can be inert, why
the OAuth code must pass through, why the TTL is computed before signing, and why the
3600 branch is unreachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema and the interface declared the same five claims independently, so
renaming `sessionStartedAt` in the schema alone compiled fine while the parse
silently stopped finding the claim and `?? iat` slid the window — only a runtime
test stood in the way. The write type is now derived from the schema
(Required<Pick<...>>), so both a schema rename and a payload omission are compile
errors; verified as TS errors by mutation.

Also collapses warnIfCapIsInert's per-field Set to a boolean: since the refresh
warning was removed there is one call site and one field, and the keyof parameter
was generality for a case that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Scra3

Scra3 commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Heads-up before anyone approves: six commits landed after your re-review of 637517a7b, and one of them reverts something you reviewed. Flagging rather than letting the approval carry over silently.

The revert

I had added zod schemas to all three token payloads. Asked what the parse actually bought on the SaaS tokens, I measured it instead of assuming — and my own justification did not hold:

case, with the plain cast what actually happened
exp absent NaN → jsonwebtoken.sign throws ("expiresIn should be a number of seconds")
exp as a string "1785003600" - now = 3600 → already worked
meta absent throws on destructuring

So nothing failed silently; the schema bought a nicer message. Against that, a schema stricter than the real token breaks login for everyone — and it did, twice, while I wrote it: first z.number() rejecting a stringified renderingId (private-api types meta?: { renderingId?: number }, both optional), then z.coerce.number() accepting null as 0, i.e. an exp in 1970.

Reverted to the cast on both SaaS tokens (8326551c8). Kept on our own refresh token, which is the opposite case: a shape without sessionStartedAt makes ?? iat take over with a timestamp re-stamped on every rotation, so the window slides forever with no error and no log. Silent, and a security bound.

I also dropped the tests that claimed to pin the loud failure — jsonwebtoken is mocked in that suite, so they could not observe it. A test that cannot see its property is worse than none.

Your points from the second round

  • roundTripSeconds (f685a7c5f) — fixed as you described; it now diffs against a timestamp taken at the top of generateAccessToken, before the Forest calls.
  • RefreshTokenClaims (53a442dec) — went further than required-on-write. The interface and the schema were two independent declarations, so renaming the claim in the schema alone still compiled. The write type is now derived from the schema, and I verified both mutations are compile errors: schema rename → TS2344, payload omission → TS2741.
  • Stale note in server.test.ts, CLAUDE.md scoping, the restored 3600 comment, the misattributed [ForestOAuthProvider] prefix, the cross-field warn — all in 3c1a4ee3c.

Also worth your eyes

637517a7b scopes what accessTokenSeconds protects, which you called out as a good addition. The trigger was a reviewer noticing the signed JWT is not encrypted and carries the SaaS token under serverToken, so a leaked MCP token still yields a Forest credential usable against the agent — bypassing the scopes and the audit log. The docs now say to revoke at the source rather than implying the cap bounds the leak.

Net since your review: -70 lines, 681 passed, lint clean. The direction of travel is more conservative than what you approved, not less — but you should see the revert before signing off.

qlty flagged the diff at 96.7% against a 98% threshold, on lines 400-414 — the two
'not a decodable JWT' guards. They predate this branch but count as modified because
the rename touched their messages, and they were never covered. The zod rejection
tests I removed had been covering them incidentally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Scra3
Scra3 merged commit 6174cc2 into main Jul 29, 2026
32 checks passed
@Scra3
Scra3 deleted the feat/mcp-token-ttl branch July 29, 2026 08:59
forest-bot added a commit that referenced this pull request Jul 29, 2026
# @forestadmin/mcp-server [1.20.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/mcp-server@1.19.1...@forestadmin/mcp-server@1.20.0) (2026-07-29)

### Features

* **mcp-server:** let clients shorten the OAuth token lifetimes ([#1788](#1788)) ([6174cc2](6174cc2))
forest-bot added a commit that referenced this pull request Jul 29, 2026
# @forestadmin/agent [1.91.0](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.90.4...@forestadmin/agent@1.91.0) (2026-07-29)

### Features

* **mcp-server:** let clients shorten the OAuth token lifetimes ([#1788](#1788)) ([6174cc2](6174cc2))

### Dependencies

* **@forestadmin/mcp-server:** upgraded to 1.20.0
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