@forestadmin/mcp-server
v1.24.3
Published
Model Context Protocol server for Forest Admin with OAuth authentication
Readme
@forestadmin/mcp-server
Model Context Protocol (MCP) server for Forest Admin with OAuth authentication support.
Overview
This MCP server provides HTTP REST API access to Forest Admin operations, enabling AI assistants and other MCP clients to interact with your Forest Admin data through a standardized protocol.
Available Tools
| Tool | Description |
|------|-------------|
| describeCollection | Get the schema of a collection (fields, types, relations) |
| list | Retrieve records from a collection |
| listRelated | Retrieve related records |
| create | Create a new record |
| update | Update an existing record |
| delete | Delete records |
| associate | Associate records in a relation |
| dissociate | Dissociate records from a relation |
| getActionForm | Get the form fields for a custom action |
| executeAction | Execute a custom action |
| requestActionFileUpload | Get a destination to upload a file to, for an action File field (only with fileUploads) |
| listWorkflows | List the MCP-enabled workflows in the caller's rendering, optionally filtered by collection |
| triggerWorkflow | Start a workflow run on a record, and return its runId |
| getWorkflowRun | Read a run started through MCP: its state and step-by-step history |
triggerWorkflow starts a process with side effects, so it is annotated destructiveHint: true and
MCP clients are expected to ask the user before each call. Like every other tool it is enabled by
default, but it stays inert until someone turns the MCP trigger on for a given workflow in Forest
— the server refuses a trigger on any workflow that has not opted in.
Usage
With Forest Admin Agent
The MCP server is included with the Forest Admin agent. Simply call mountAiMcpServer():
import { createAgent } from '@forestadmin/agent';
const agent = createAgent(options)
.addDataSource(myDataSource)
.mountAiMcpServer();
agent.mountOnExpress(app);
agent.start();The MCP server will be automatically initialized and mounted on your application.
Standalone Server
You can run the MCP server standalone using the CLI:
npx forest-mcp-serverOr from the package directory:
yarn start # Production
yarn start:dev # Development (loads .env file automatically)Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| FOREST_ENV_SECRET | Yes | - | Your Forest Admin environment secret |
| FOREST_AUTH_SECRET | Yes | - | Your Forest Admin authentication secret (must match your agent) |
| MCP_SERVER_PORT | No | 3931 | Port for the HTTP server |
| FOREST_MCP_SERVER_URL | No | http://localhost:<port> | Public URL this server is reachable at — an http(s) origin only, no path. Required for any deployed server: without it the OAuth metadata advertise localhost, and the in-memory upload store mints localhost upload URLs no remote client can reach |
| FOREST_MCP_ENABLED_TOOLS | No | - | Comma-separated list of tools to enable (allowlist) |
| FOREST_MCP_ALLOWED_OAUTH_CLIENTS | No | - | Comma-separated domains of the OAuth client applications allowed to connect (allowedOAuthClients). Unset, any registered client is accepted |
| 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 |
| FOREST_MCP_FILE_UPLOADS | No | - | false turns action file uploads off (they are on by default, in memory). Any other value than true/false fails at startup |
| FOREST_MCP_UPLOAD_STORAGE_MODULE | No | - | Path to a module providing the fileUploads options, for a real storage backend. FOREST_MCP_FILE_UPLOADS=false wins over it |
Example Configuration
Create a .env file in the package directory:
FOREST_ENV_SECRET="your-env-secret"
FOREST_AUTH_SECRET="your-auth-secret"Then run:
yarn start:devOr set the variables inline:
FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-serverDeployed behind a public URL, add it — clients are sent wherever this says:
FOREST_MCP_SERVER_URL="https://mcp.example.com" \
FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-serverRestrict Tools
You can restrict which tools the MCP server exposes using enabledTools. Only the listed tools will be available. New tools added in future releases will NOT be automatically enabled — you must explicitly add them.
For example, to set up a read-only mode where the AI assistant can only browse data (no create, update, delete or action execution):
// With Forest Admin Agent — read-only example
agent.mountAiMcpServer({
enabledTools: ['describeCollection', 'list', 'listRelated'],
});# Standalone
export FOREST_MCP_ENABLED_TOOLS="describeCollection,list,listRelated"
npx forest-mcp-serverWhen enabledTools is not set, all tools are enabled by default.
See Available Tools for the full list. describeCollection is always enabled as it is required for the MCP server to function properly.
Restrict OAuth Clients
Any OAuth client can register against the MCP server through Dynamic Client Registration and, once one of your users signs in, obtain tokens. Use allowedOAuthClients to accept only approved client applications:
// With Forest Admin Agent
agent.mountAiMcpServer({
allowedOAuthClients: ['dust.tt'],
});# Standalone
export FOREST_MCP_ALLOWED_OAUTH_CLIENTS="dust.tt"
npx forest-mcp-serverA client is allowed only when every redirect URI it registered is an http(s) URI on a listed domain or one of its subdomains (dust.tt matches eu.dust.tt); custom schemes are rejected because they deliver the callback to whatever local application registered them, regardless of hostname. Matching uses redirect URIs because they are the one piece of registration metadata an impostor cannot benefit from — the authorization code is only ever delivered there. Self-declared fields such as the client name are ignored.
List bare domains only — unicode domains are matched through their punycode form. An entry with a scheme, port, path, or spaces fails at startup, as does a configured value containing no domains at all: the allowlist never silently falls back to accepting or rejecting everyone on a malformed configuration.
Every other client is rejected with a standard invalid_client error telling the user to contact their administrator; the response does not reveal the allowed domains. Registration itself still succeeds — it happens on the Forest Admin server — the client just cannot use it against this server. Access tokens issued before you enabled the option stay valid until they expire (1 hour at most); refreshes are blocked immediately.
Native desktop clients (Claude Desktop, MCP Inspector, ...) register loopback (localhost) redirect URIs, which a domain allowlist never matches — they are rejected unless you explicitly list localhost, which would admit every local application and defeats the restriction. Omit the option in environments that need native clients (e.g. development).
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.
// With Forest Agent
agent.mountAiMcpServer({
tokenTtl: { accessTokenSeconds: 900, refreshTokenSeconds: 86400 },
});# Standalone
export FOREST_MCP_ACCESS_TOKEN_TTL_SECONDS=900
export FOREST_MCP_REFRESH_TOKEN_TTL_SECONDS=86400
npx forest-mcp-serverThe two settings differ in what the user notices:
accessTokenSecondsshortens 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.refreshTokenSecondsbounds 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.
Action File Uploads
Experimental. The MCP specification is still designing its own file transfer story (SEP-2631). The
UploadStoragecontract is expected to survive, but therequestActionFileUploadtool and the handle format may change to follow the specification once it lands.
Actions with File fields cannot normally run over MCP. The agent expects file values as data
uris, which would transit the model's context window and exceed most MCP clients' payload limits.
The fileUploads option enables them through an upload side-channel that keeps the bytes out of
the conversation:
- The client calls the
requestActionFileUploadtool with{ "filename", "mimeType", "sha256"? }and receives a pre-authorized upload URL plus a signedfileHandlestring. - The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model.
- The client passes the handle (
"$uploadedFile:<...>") as the field value inexecuteAction. The server downloads the object and hands it to the agent. The model only ever exchanges the small handle.
requestActionFileUpload follows enabledTools like every other tool, so a server that leaves it out never advertises it and never serves the upload endpoint.
The upload URL is unauthenticated — the model holds no agent credential, and must not — so the URL
itself is the authorization, as with an S3 presigned PUT. It carries a random uuid, is refused
before a byte is read unless this server issued it, expires with uploadUrlTtlSeconds, and serves
nothing but the PUT. Against the in-memory store it also accepts a single upload: once the
bytes land, a leaked URL can no longer replace them. Writing is not consuming either — redemption
needs the signed handle, which is bound to the user who requested it.
A presigned backend URL is a different animal: it is typically replayable until it expires — S3
accepts as many PUTs as fit in expiresInSeconds — so there, a URL leaked to an access log can
overwrite the bytes after the legitimate upload and before the action runs. The sha256 pin is
the defense that covers every backend at once: S3 signs it into the URL, so a different payload is
rejected at upload time, and redemption re-verifies the digest regardless of what the backend
checked. The tool instructs the model to pin by default; treat an unpinned upload as accepting that
window.
Nothing to provision, and nothing to switch on
It is on by default. With no storage, the server holds the objects in memory and serves its own
upload endpoint under <origin>/mcp/uploads. The fileUploads option only configures that — a
backend, size limits, ttls:
agent.mountAiMcpServer(); // in memory, single instance
agent.mountAiMcpServer({ fileUploads: { storage } }); // a real backendTo turn the feature off, pass fileUploads: false — on the standalone server,
FOREST_MCP_FILE_UPLOADS=false. The tool is not registered, the upload endpoint is never mounted,
and executeAction stops mentioning either. Going through enabledTools would work too, but it is
an allowlist — declining this one feature that way means naming every other tool and opting out of
everything shipped after.
Single instance only. The upload and the redemption are two separate requests. With several replicas, in cluster mode, or on a serverless runtime, one of them lands on an instance that never saw the other and the action fails — intermittently, which reads as a flaky feature rather than a misconfiguration. Objects are also lost on restart. The server warns the first time an upload destination is asked for, and the failure names this cause. Those deployments need a
storage.
ephemeralMaxTotalBytes bounds what the in-memory store holds across all pending uploads, 64 MiB by
default. Redeeming a file does not free it — the object lives until handleTtlSeconds so a retry
after a failed action still finds it — so on the defaults the store holds about three max-size
files per 45-minute window rather than a rolling 64 MiB. Size it against that, or shorten
handleTtlSeconds. It is deliberately absolute rather than a multiple of maxBytes: derived, raising the
per-file limit would multiply what the process can hold.
With a storage backend
Provide storage for anything beyond a single instance. Any backend that can pre-authorize an
upload and read the object back works — S3 presigned URLs (below), GCS signed URLs, Azure SAS. The
package has no storage dependency of its own.
On the standalone server
Uploads are on with the in-memory store, with the same single-instance caveat.
For a real backend, a storage is an object with methods, so unlike every other standalone option it
cannot travel through an environment variable. Point FOREST_MCP_UPLOAD_STORAGE_MODULE at a module
that default-exports the options instead — a bad path, or a module that exports nothing, fails at
startup rather than running with uploads silently disabled:
// forest-upload-storage.js
module.exports = {
storage: {
/* createUploadUrl / download / getSize, as below */
},
maxBytes: 50 * 1024 * 1024,
downloadTimeoutSeconds: 10,
};FOREST_MCP_UPLOAD_STORAGE_MODULE=./forest-upload-storage.js npx forest-mcp-serverThe module may also export a function, sync or async, returning the same options — useful when the backend needs credentials fetched at boot.
The client must be able to upload
Step 2 is an ordinary HTTPS request, made by the client, outside the MCP protocol. The client has to be able to make it:
Claude Code and custom agents: works. The shell runs on the same machine as the developer, so it reaches a
localhostagent too — this is the one place the whole flow can be tried end to end against a local agent. Verified.Claude Desktop, Claude.ai and Cowork: the attached file lands in the code execution sandbox and the model can
curl -X PUT -T <path> <uploadUrl>— applying every header the tool returned, since a pinnedsha256is signed intox-amz-checksum-sha256on S3 and the PUT is rejected without it. Two conditions, and both are needed:uploadUrlmust be publicly reachable. That sandbox is hosted and runs on its own network, so alocalhostor private address is never reachable from it, whatever else is configured. An agent running on a developer's machine cannot be tested this way.- Its host must be allowed for outbound traffic in that sandbox. Where this is configured differs between clients and versions — and on a managed workspace the setting may not be exposed to the end user at all, in which case only an administrator can unblock the upload. Do not promise your users a menu path; give them the host to get allowed.
Both are client-side and outside this server's control, so document your upload host for your users.
Verified end to end from a Claude Desktop chat and from a Cowork cloud session: an agent behind a public HTTPS URL, that host added to the sandbox's allowed domains, and the model's
PUTgoes through. Before the host was allowed, the same sandbox answeredHost not in allowlisteven for an ordinary public domain — so the allowlist is the whole of condition 2, and satisfying it is enough. The Cowork run started from a single natural sentence, with no tool named: the model found the form, requested a destination and pinned thesha256unprompted, and no tool argument carried base64 — checked against the request bodies at the tunnel, not the transcript.One wrinkle observed there: the filename the action stores is whatever the client reports, and a sandbox may normalize it (
rapport-1815.pdfarrived asrapport1815.pdfwhile the bytes and mime type were exact). Treat it as a label, not an identifier.
The tool states this prerequisite in its description and repeats it in its response, so a model whose upload was blocked has the diagnosis in context.
Trying it locally
packages/_example needs no configuration for this — no cloud account, no storage code.
Its review collection carries an Attach a document action with a File and a FileList field.
Start the example agent, connect an MCP client to it, and ask for that action with a file — the
action reports the name, mime type and byte count it received.
sequenceDiagram
participant Client as MCP client
participant Server as MCP server
participant Storage as Storage backend
participant Agent as Forest Admin agent
Client->>Server: requestActionFileUpload {filename, mimeType, sha256?}
Server-->>Client: uploadUrl + fileHandle (user-bound JWT)
Client->>Storage: PUT raw bytes to uploadUrl
Note over Client,Storage: bytes bypass the server and the model
Client->>Server: executeAction {values: {field: "$uploadedFile:..."}}
Server->>Storage: download object
Note over Server: verify user, TTL, maxBytes, sha256 pin
Server->>Agent: executeAction with the file
Agent-->>Server: action result
Server-->>Client: result (the model only saw the handle)The storage backend is pluggable, and this package has no storage dependency. Provide an
implementation of UploadStorage; any backend that can pre-authorize an upload and read the object
back works, such as S3 presigned URLs (below), GCS signed URLs, Azure SAS, or an endpoint you serve
yourself. The only hard requirement is that the URL be reachable from the MCP client, since that is
what uploads the bytes.
import {
S3Client,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { UploadStorage } from '@forestadmin/mcp-server';
const s3 = new S3Client({});
const bucket = 'my-uploads-bucket';
const storage: UploadStorage = {
async createUploadUrl({ key, mimeType, sha256, expiresInSeconds }) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: mimeType,
...(sha256 && { ChecksumSHA256: sha256 }),
});
const url = await getSignedUrl(s3, command, {
expiresIn: expiresInSeconds,
// Load-bearing: without it the checksum is hoisted to the query string, which S3 does not
// enforce — the pin would silently stop protecting the upload.
...(sha256 && { unhoistableHeaders: new Set(['x-amz-checksum-sha256']) }),
});
return {
url,
headers: { 'Content-Type': mimeType, ...(sha256 && { 'x-amz-checksum-sha256': sha256 }) },
};
},
async getSize(key) {
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
return head.ContentLength;
},
async download(key) {
const object = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
return Buffer.from(await object.Body.transformToByteArray());
},
};
const server = new ForestMCPServer({
// ...
fileUploads: { storage },
});The other options are keyPrefix (default mcp-uploads/), uploadUrlTtlSeconds (default 15 min),
handleTtlSeconds (default 45 min, longer than the upload URL so a slow upload still leaves time to
run the action), maxBytes (default 20 MiB), maxConcurrentDownloads (default 5), and
downloadTimeoutSeconds (default 15 s), and ephemeralMaxTotalBytes (default 64 MiB, in-memory store only).
Lower downloadTimeoutSeconds if the clients calling your agent cut requests sooner than that. The
whole executeAction has to fit inside their timeout: reading the object, encoding it, and running
your action. A read that outlives the caller is wasted work — and left unbounded it would hold its
concurrency slot after the caller gave up.
A few properties matter in production.
- The server stays stateless. The handle is a JWT signed with
authSecret, so there is no database and no session affinity, and any replica can redeem a handle issued by another. - A handle is bound to the user it was issued to. Only that user's Bearer token can redeem it, and it expires with
handleTtlSeconds. It stays redeemable until then, so keep the TTL short. - When the client sends
sha256(hex or base64), the upload URL is pinned to that digest and the digest is checked again on the downloaded bytes at redemption. Content substituted after an upload URL leak cannot be redeemed. maxBytesdoes not bound memory on its own. A pre-authorized upload URL cannot always cap the object size, so the limit is enforced at redemption: before downloading whengetSizereports a size, and only after the bytes are in memory when it returnsundefined. ImplementgetSizewhenever the backend can answer it cheaply.maxConcurrentDownloadsbounds concurrent downloads, not peak memory. All the files oneexecuteActioncall references are held together until the call completes, so a form with N file fields holds up to N ×maxByteswhatever the concurrency limit is. SizemaxBytesagainst the number of file fields your actions declare.- A timed-out read is abandoned, not cancelled.
UploadStorage.downloadtakes noAbortSignal, so whendownloadTimeoutSecondsfires the concurrency slot is freed while the underlying read keeps running. Against a backend slower than that timeout the number of reads in flight can therefore exceedmaxConcurrentDownloads. KeepdownloadTimeoutSecondslow so a slow backend fails fast instead of accumulating. - The server never deletes objects, and does not delete them once redeemed either:
executeActiondownloads every reference before it sets fields or runs, so consuming on read would leave a model retrying after any later failure with handles whose objects are gone, told the upload failed when it had not. Configure a lifecycle rule on the storage backend, for example deleting objects underkeyPrefixafter one day; the in-memory store reclaims onhandleTtlSecondsand on its own total.
Only executeAction resolves handles. getActionForm echoes field values back to the model, so a
handle stays a handle there: resolving it would put the file content back into the model's context.
Change hooks never see a handle. On the getActionForm path, file references are withheld from
tryToSetFields, so a hook fired by another field reads the file field as unset rather than as a
string it would call .buffer on. On the executeAction path the handles are already resolved, so
a hook receives the file as the data uri it expects. Either way a hook never has to know this
side-channel exists.
API Endpoints
Once running, the MCP server exposes the following endpoints:
| Method | Path | Description |
|--------|------|-------------|
| POST | /mcp | Main MCP protocol endpoint (requires Bearer token) |
| POST | /oauth/authorize | OAuth 2.0 authorization |
| POST | /oauth/token | OAuth 2.0 token exchange |
| GET | /.well-known/oauth-protected-resource/mcp | OAuth metadata discovery |
The /mcp endpoint expects MCP protocol messages (JSON-RPC 2.0) and requires a valid OAuth Bearer token with at least the mcp:read scope.
Features
- HTTP Transport: Uses streamable HTTP transport for MCP communication
- OAuth Authentication: Built-in OAuth 2.0 with scopes (
mcp:read,mcp:write,mcp:action,mcp:admin) - CORS Enabled: Allows cross-origin requests
- Express-based: Built on top of Express.js for reliability and extensibility
Development
Building
yarn buildWatch Mode
yarn build:watchLinting
yarn lintTesting
yarn testCleaning
yarn cleanInternal Environment Variables
These are only needed by Forest Admin developers (e.g. to point to a local or staging server):
| Variable | Default | Description |
|----------|---------|-------------|
| FOREST_SERVER_URL | https://api.forestadmin.com | Forest Admin API URL |
| FOREST_APP_URL | https://app.forestadmin.com | Forest Admin application URL |
Architecture
The server consists of:
- ForestMCPServer: Main server class managing the MCP server lifecycle
- McpServer: Core MCP protocol implementation
- StreamableHTTPServerTransport: HTTP transport layer for MCP
- Express App: HTTP server handling incoming requests
License
GPL-3.0
Repository
https://github.com/ForestAdmin/agent-nodejs
Support
For issues and feature requests, please visit the GitHub repository.
