Model Context Protocol (MCP) server for Forest Admin with OAuth authentication support.
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.
| 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) |
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.
You can run the MCP server standalone using the CLI:
npx forest-mcp-server
Or from the package directory:
yarn start # Production
yarn start:dev # Development (loads .env file automatically)
| 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 |
Create a .env file in the package directory:
FOREST_ENV_SECRET="your-env-secret"
FOREST_AUTH_SECRET="your-auth-secret"
Then run:
yarn start:dev
Or set the variables inline:
FOREST_ENV_SECRET="your-env-secret" FOREST_AUTH_SECRET="your-auth-secret" npx forest-mcp-server
Deployed 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-server
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-server
When 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.
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-server
A 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).
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-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.
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:
requestActionFileUpload tool with { "filename", "mimeType", "sha256"? } and receives a pre-authorized upload URL plus a signed fileHandle string."$uploadedFile:<...>") as the field value in executeAction. 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.
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 backend
To 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.
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.
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-server
The module may also export a function, sync or async, returning the same options — useful when the backend needs credentials fetched at boot.
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 localhost agent 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 pinned sha256 is signed into x-amz-checksum-sha256 on S3 and the PUT is rejected
without it. Two conditions, and both are needed:
uploadUrl must be publicly reachable. That sandbox is hosted and runs on its own
network, so a localhost or private address is never reachable from it, whatever else is
configured. An agent running on a developer's machine cannot be tested this way.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
PUT goes through. Before the host was allowed, the same sandbox answered Host not in allowlist even 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 the sha256 unprompted, 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.pdf arrived as rapport1815.pdf while 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.
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.
authSecret, so there is no database and no session affinity, and any replica can redeem a handle issued by another.handleTtlSeconds. It stays redeemable until then, so keep the TTL short.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.maxBytes does 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 when getSize reports a size, and only after the bytes are in memory when it returns undefined. Implement getSize whenever the backend can answer it cheaply.maxConcurrentDownloads bounds concurrent downloads, not peak memory. All the files one executeAction call references are held together until the call completes, so a form with N file fields holds up to N × maxBytes whatever the concurrency limit is. Size maxBytes against the number of file fields your actions declare.UploadStorage.download takes no AbortSignal, so when downloadTimeoutSeconds fires 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 exceed maxConcurrentDownloads. Keep downloadTimeoutSeconds low so a slow backend fails fast instead of accumulating.executeAction downloads 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 under keyPrefix after one day; the in-memory store reclaims on handleTtlSeconds and 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.
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.
mcp:read, mcp:write, mcp:action, mcp:admin)yarn build
yarn build:watch
yarn lint
yarn test
yarn clean
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 |
The server consists of:
GPL-3.0
https://github.com/ForestAdmin/agent-nodejs
For issues and feature requests, please visit the GitHub repository.