Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/mcp-server/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Key flows that only make sense across files:
- **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()`.
- **Action file uploads are a side-channel** (`src/file-uploads/`), enabled by the `fileUploads` option with a host-provided `UploadStorage` backend. `POST /files` (bearer-protected, only mounted when enabled) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `executeAction` swaps `"$uploadedFile:<handle>"` values for the base64 data URI the agent expects (`resolve.ts`) and enforces `maxBytes`, the optional sha256 pin, and a per-process download concurrency bound at redemption. `getActionForm` leaves handles unresolved on purpose because it echoes values back into the model's context. When enabled, `makeIsMcpRoute(prefix, { fileUploads: true })` also claims `/files`.

## Commands

Expand Down
88 changes: 88 additions & 0 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,101 @@ The two settings differ in what the user notices:

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

Actions with **File fields** cannot normally run over MCP. The agent expects file values as base64 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 `POST`s `/files` (same Bearer token as `/mcp`) 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 redeems it by downloading the object, re-encoding it as the data URI the agent expects, and forwarding it. The model only ever exchanges the small handle.

```mermaid
sequenceDiagram
participant Client as MCP client
participant Server as MCP server
participant Storage as Storage backend
participant Agent as Forest Admin agent

Client->>Server: POST /files {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 as a data URI
Agent-->>Server: action result
Server-->>Client: result (the model only saw the handle)
```

The storage backend is pluggable. The server itself has no storage dependency. You provide an implementation of the `UploadStorage` interface, and any backend that can pre-authorize an upload and read the object back works, such as S3 presigned URLs (shown below), GCS signed URLs, Azure SAS, or a local endpoint you serve yourself.

```typescript
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,
...(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), and `maxConcurrentDownloads` (default 5).

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`.
- 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.
- A pre-authorized upload URL cannot always cap the object size, so `maxBytes` is enforced at redemption (before download when the backend implements `getSize`). Each redemption holds up to the file plus its base64 copy in memory, and `maxConcurrentDownloads` bounds the process's worst case to roughly `maxBytes × 2.3 × maxConcurrentDownloads`.
- The server never deletes objects. Configure a lifecycle rule on the storage backend, for example deleting objects under `keyPrefix` after one day. Handles cannot be revoked before they expire, so keep `handleTtlSeconds` short.

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.

## API Endpoints

Once running, the MCP server exposes the following endpoints:

| Method | Path | Description |
|--------|------|-------------|
| POST | `/mcp` | Main MCP protocol endpoint (requires Bearer token) |
| POST | `/files` | Upload side-channel for action file fields (only with `fileUploads`; 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 |
Expand Down
64 changes: 64 additions & 0 deletions packages/mcp-server/src/file-uploads/handles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import jsonwebtoken from 'jsonwebtoken';

/**
* Sentinel prefix used inside action form values, e.g.
* { "document": "$uploadedFile:<jwt>" }
* A string (not an object) so it passes the agent-client's field validation and stays
* cheap when echoed back by getActionForm.
*/
export const UPLOADED_FILE_PREFIX = '$uploadedFile:';

const HANDLE_TYPE = 'mcp-upload';

export interface UploadHandleClaims {
key: string;
name: string;
mimeType: string;
/** Base64 sha256 the upload was pinned to, when the client provided one. */
sha256?: string;
}

export function signUploadHandle(
claims: UploadHandleClaims & { userId: number | string },
authSecret: string,
ttlSeconds: number,
): string {
return jsonwebtoken.sign(
{
type: HANDLE_TYPE,
key: claims.key,
name: claims.name,
mime: claims.mimeType,
uploader: String(claims.userId),
...(claims.sha256 && { sha256: claims.sha256 }),
},
authSecret,
{ expiresIn: ttlSeconds },
);
}

/** Throws on tampered, expired, or cross-user handles. */
export function verifyUploadHandle(
handle: string,
userId: number | string,
authSecret: string,
): UploadHandleClaims {
const decoded = jsonwebtoken.verify(handle, authSecret) as {
type?: string;
key: string;
name: string;
mime: string;
uploader?: string;
sha256?: string;
};

if (decoded?.type !== HANDLE_TYPE) throw new Error('Not an upload handle');
if (decoded.uploader !== String(userId)) throw new Error('Handle was issued to another user');

return {
key: decoded.key,
name: decoded.name,
mimeType: decoded.mime,
sha256: decoded.sha256,
};
}
122 changes: 122 additions & 0 deletions packages/mcp-server/src/file-uploads/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { ResolvedFileUploads } from './types';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';

import * as crypto from 'crypto';

import { UPLOADED_FILE_PREFIX, verifyUploadHandle } from './handles';

function isHandle(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(UPLOADED_FILE_PREFIX);
}

function collectHandles(values: Record<string, unknown>): Set<string> {
const handles = new Set<string>();

for (const value of Object.values(values)) {
if (isHandle(value)) handles.add(value);
else if (Array.isArray(value)) value.filter(isHandle).forEach(v => handles.add(v as string));
}

return handles;
}

async function loadAsDataUri(
handle: string,
userId: number | string,
uploads: ResolvedFileUploads,
): Promise<string> {
const claims = verifyUploadHandle(
handle.slice(UPLOADED_FILE_PREFIX.length),
userId,
uploads.authSecret,
);

// A pre-authorized upload URL cannot always cap the object size, so the limit is
// enforced here, before the bytes are read when the backend can report a size.
const size = await uploads.storage.getSize?.(claims.key);

if (size !== undefined && size > uploads.maxBytes) {
throw new Error(`Uploaded file is ${size} bytes, above the ${uploads.maxBytes} byte limit`);
}

const buffer = await uploads.storage.download(claims.key);

if (buffer.length === 0) {
throw new Error('Uploaded file is empty. Did the upload to uploadUrl succeed?');
}

if (buffer.length > uploads.maxBytes) {
throw new Error(
`Uploaded file is ${buffer.length} bytes, above the ${uploads.maxBytes} byte limit`,
);
}

// When the handle carries a sha256, re-verify the digest on the downloaded bytes. Even
// if the upload URL leaked and someone overwrote the object, substituted content cannot
// be redeemed.
if (claims.sha256) {
const digest = crypto.createHash('sha256').update(new Uint8Array(buffer)).digest('base64');

if (digest !== claims.sha256) {
throw new Error('Uploaded file does not match the sha256 it was pinned to');
}
}

return `data:${claims.mimeType};name=${claims.name};base64,${buffer.toString('base64')}`;
}

/**
* Replaces "$uploadedFile:<handle>" values in action form values with the uploaded
* object re-encoded as the data URI the agent expects for File fields. The model only
* ever exchanges the small handle. The base64 payload exists in memory here and in the
* outbound call to the agent.
*
* Handles are resolved concurrently and deduplicated, so a handle referenced by several
* fields is downloaded once.
*
* Only executeAction resolves handles. getActionForm echoes field values back to
* the model, and a resolved data URI there would put the file content back into
* the model's context.
*/
export default async function resolveUploadedFileValues(
values: Record<string, unknown>,
authInfo: AuthInfo | undefined,
uploads: ResolvedFileUploads | undefined,
): Promise<Record<string, unknown>> {
const handles = collectHandles(values);

if (handles.size === 0) return values;

if (!uploads) {
throw new Error(
'File uploads are not configured on this server. ' +
'Ask the administrator to set the fileUploads option to enable action file fields.',
);
}

const userId = authInfo?.extra?.userId as number | string | undefined;

if (userId === undefined || userId === null) {
throw new Error('Cannot resolve uploaded files without an authenticated user');
}

const dataUris = new Map(
await Promise.all(
[...handles].map(
async (handle): Promise<[string, string]> => [
handle,
await uploads.limitDownload(() => loadAsDataUri(handle, userId, uploads)),
],
),
),
);

const substitute = (value: unknown) => (isHandle(value) ? dataUris.get(value) : value);

return Object.fromEntries(
Object.entries(values).map(([field, value]) => [
field,
Array.isArray(value) ? value.map(substitute) : substitute(value),
]),
);
}
Loading
Loading