Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
} from '@forestadmin/datasource-customizer';
import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit';
import type { ForestSchema } from '@forestadmin/forestadmin-client';
import type { ToolName } from '@forestadmin/mcp-server';
import type { TokenTtlOptions, ToolName } from '@forestadmin/mcp-server';

import { DataSourceCustomizer } from '@forestadmin/datasource-customizer';
import bodyParser from '@koa/bodyparser';
Expand Down Expand Up @@ -57,6 +57,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
private mcpEnabled = false;
private mcpEnabledTools?: ToolName[];
private mcpBasePath?: string;
private mcpTokenTtl?: TokenTtlOptions;

/** In-process workflow executor, created only when addWorkflowExecutor() is called. */
private embeddedExecutor: EmbeddedWorkflowExecutor | null = null;
Expand Down Expand Up @@ -257,11 +258,20 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
* // OAuth discovery metadata stays at the origin root (prefix-suffixed), so root `.well-known`
* // traffic must still reach the agent.
* agent.mountAiMcpServer({ basePath: '/ai' });
* // Example: shorten the OAuth token lifetimes. `accessTokenSeconds` cannot exceed the 1h Forest
* // grants; `refreshTokenSeconds` bounds the time between two interactive logins, which is
* // otherwise unbounded since Forest re-grants its refresh lifetime on every refresh.
* agent.mountAiMcpServer({ tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 } });
*/
mountAiMcpServer(options?: { enabledTools?: ToolName[]; basePath?: string }): this {
mountAiMcpServer(options?: {
enabledTools?: ToolName[];
basePath?: string;
tokenTtl?: TokenTtlOptions;
}): this {
this.mcpEnabled = true;
this.mcpEnabledTools = options?.enabledTools;
this.mcpBasePath = options?.basePath;
this.mcpTokenTtl = options?.tokenTtl;

return this;
}
Expand Down Expand Up @@ -429,6 +439,7 @@ export default class Agent<S extends TSchema = TSchema> extends FrameworkMounter
forestServerClient,
enabledTools: this.mcpEnabledTools,
basePath: this.mcpBasePath,
tokenTtl: this.mcpTokenTtl,
agentDispatcher: this.getInProcessDispatcher(),
});

Expand Down
14 changes: 14 additions & 0 deletions packages/agent/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,20 @@ describe('Agent', () => {
expect(mcpServerSpy).toHaveBeenCalledWith(expect.objectContaining({ basePath: '/mcp' }));
});

test('should pass tokenTtl to ForestMCPServer', async () => {
const options = factories.forestAdminHttpDriverOptions.build();
const agent = new Agent(options);

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

expect(mcpServerSpy).toHaveBeenCalledWith(
expect.objectContaining({
tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 },
}),
);
});

test('passes an in-process agentDispatcher to ForestMCPServer', async () => {
const options = factories.forestAdminHttpDriverOptions.build();
const agent = new Agent(options);
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Key flows that only make sense across files:

- **Per-request MCP server.** `handleMcpRequest` builds a *fresh* `McpServer` + `StreamableHTTPServerTransport` per request (`sessionIdGenerator: undefined`, stateless) and closes the transport on response end. Tool registration in `createMcpServer()` therefore re-runs on every call.
- **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery).
- **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login.
- **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data.
- **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort").
- **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`.
Expand Down
29 changes: 29 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ yarn start:dev # Development (loads .env file automatically)
| `MCP_SERVER_PORT` | No | `3931` | Port for the HTTP server |
| `FOREST_MCP_ENABLED_TOOLS` | No | - | Comma-separated list of tools to enable (allowlist) |
| `FOREST_AGENT_URL` | No | your environment's back-end URL | URL the MCP server uses to reach the back-end's data layer. Set it when the server runs next to a self-hosted back-end at an internal address (e.g. `http://localhost:3310`), instead of the public URL registered in Forest |
| `FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS` | No | `3600` (1 hour) | Maximum lifetime of the OAuth access tokens the server issues (`tokenTtl.accessTokenSeconds`). Minimum `60` |
| `FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS` | No | unbounded | Maximum time between two interactive logins (`tokenTtl.refreshTokenSeconds`). Unset, a client that keeps refreshing never signs in again. Minimum `60` |

#### Example Configuration

Expand Down Expand Up @@ -109,6 +111,33 @@ When `enabledTools` is not set, all tools are enabled by default.

See [Available Tools](#available-tools) for the full list. `describeCollection` is always enabled as it is required for the MCP server to function properly.

## Shorten Token Lifetimes

Forest grants **1 hour** (3600s) for an access token and **8 days** (691200s) for a refresh token — but it re-grants those 8 days on *every* refresh, so without `refreshTokenSeconds` a client that keeps working is never asked to sign in again.

**Both values are upper bounds: they can only shorten that, never extend it.** For `accessTokenSeconds` a value above 3600 therefore has no effect. `refreshTokenSeconds` bounds the whole session, which Forest otherwise re-extends on every refresh, so any value shortens it however large it is.

```typescript
// With Forest Agent
agent.mountAiMcpServer({
tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 },
});
```

```bash
# Standalone
export FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS=900
export FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS=86400
npx forest-mcp-server
```

The two settings differ in what the user notices:

- `accessTokenSeconds` shortens how long a leaked access token can drive **this server**: the MCP path closes, its scopes stop applying and its calls stop being audited. It does **not** shorten the Forest token carried inside that JWT — the JWT is signed, not encrypted, so treat a leak as a Forest token leak and revoke at the source. It is transparent to users — the assistant silently obtains a new one.
- `refreshTokenSeconds` bounds the time between two **interactive logins**: once it elapses, the assistant can no longer refresh and the user signs in through the browser again. It is measured from the login itself, so an active assistant cannot keep extending its session. Refresh tokens issued before you enabled the option carry no login timestamp, so their window is measured from their last refresh instead — one longer session each, then bounded.

The minimum for either value is 60 seconds; anything lower is raised to it. An invalid value (zero, negative, fractional) fails at startup rather than silently leaving the tokens uncapped.

## API Endpoints

Once running, the MCP server exposes the following endpoints:
Expand Down
7 changes: 7 additions & 0 deletions packages/mcp-server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import ForestMCPServer from './server';
import parseToolList from './utils/parse-tool-list';

const toSeconds = (value?: string) => (value === undefined ? undefined : Number(value));

// Start the server when run directly as CLI
const server = new ForestMCPServer({
forestServerUrl: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com',
Expand All @@ -11,6 +13,11 @@ const server = new ForestMCPServer({
authSecret: process.env.FOREST_AUTH_SECRET,
enabledTools: parseToolList(process.env.FOREST_MCP_ENABLED_TOOLS),
agentUrl: process.env.FOREST_AGENT_URL,
// normalizeTokenTtl rejects NaN and non-positive values, so a bad variable fails at startup.
tokenTtl: {
accessTokenSeconds: toSeconds(process.env.FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS),
refreshTokenSeconds: toSeconds(process.env.FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS),
},
});

server.run().catch(error => {
Expand Down
Loading
Loading