npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-server

Or 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: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

Restrict 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-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.

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-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).

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-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.

Action File Uploads

Experimental. The MCP specification is still designing its own file transfer story (SEP-2631). The UploadStorage contract is expected to survive, but the requestActionFileUpload tool 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:

  1. The client calls the requestActionFileUpload tool with { "filename", "mimeType", "sha256"? } and receives a pre-authorized upload URL plus a signed fileHandle string.
  2. The client uploads the raw bytes directly to the storage backend, so they never pass through the MCP server or the model.
  3. The client passes the handle ("$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.

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 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.

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-server

The 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 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:

    1. 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.
    2. 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 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.

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.
  • 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.
  • A timed-out read is abandoned, not cancelled. 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.
  • The server never deletes objects, and does not delete them once redeemed either: 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.

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 build

Watch Mode

yarn build:watch

Linting

yarn lint

Testing

yarn test

Cleaning

yarn clean

Internal 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.