@frontmcp/skills 1.7.0 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog/frontmcp-config/examples/configure-http/cors-restricted-origins.md +2 -2
- package/catalog/frontmcp-config/references/configure-auth-modes.md +1 -1
- package/catalog/frontmcp-config/references/configure-http.md +116 -18
- package/catalog/frontmcp-config/references/configure-transport.md +8 -2
- package/catalog/frontmcp-deployment/examples/build-for-sdk/create-flat-config.md +4 -0
- package/catalog/frontmcp-deployment/examples/deploy-to-node/docker-compose-with-redis.md +2 -0
- package/catalog/frontmcp-deployment/examples/deploy-to-node/pm2-with-nginx.md +2 -1
- package/catalog/frontmcp-deployment/examples/deploy-to-node-dockerfile/basic-multistage-dockerfile.md +2 -0
- package/catalog/frontmcp-deployment/examples/deploy-to-node-dockerfile/secure-nonroot-dockerfile.md +2 -0
- package/catalog/frontmcp-deployment/references/build-for-sdk.md +29 -3
- package/catalog/frontmcp-deployment/references/deploy-to-node-dockerfile.md +2 -0
- package/catalog/frontmcp-deployment/references/deploy-to-node.md +11 -8
- package/catalog/frontmcp-development/examples/create-job/job-with-permissions.md +2 -0
- package/catalog/frontmcp-development/references/create-job.md +36 -22
- package/catalog/frontmcp-development/references/create-workflow.md +10 -10
- package/catalog/frontmcp-development/references/official-plugins.md +15 -1
- package/catalog/frontmcp-production-readiness/examples/production-node-server/docker-multi-stage.md +2 -0
- package/catalog/frontmcp-production-readiness/references/common-checklist.md +9 -0
- package/catalog/skills-manifest.json +1 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ level: basic
|
|
|
5
5
|
description: 'Configure CORS to allow only specific frontend origins with credentials.'
|
|
6
6
|
tags: [config, browser, http, cors, restricted, origins]
|
|
7
7
|
features:
|
|
8
|
-
- '
|
|
8
|
+
- 'Naming the origins a browser may read from — omitting `cors` sends no headers at all'
|
|
9
9
|
- 'Enabling `credentials: true` with specific origins (required -- browsers reject `*` with credentials)'
|
|
10
10
|
- 'Setting `maxAge` to reduce preflight request overhead'
|
|
11
11
|
- 'Reading port from an environment variable with a fallback'
|
|
@@ -41,7 +41,7 @@ class Server {}
|
|
|
41
41
|
|
|
42
42
|
## What This Demonstrates
|
|
43
43
|
|
|
44
|
-
-
|
|
44
|
+
- Naming the origins a browser may read from — omitting `cors` sends no headers at all
|
|
45
45
|
- Enabling `credentials: true` with specific origins (required -- browsers reject `*` with credentials)
|
|
46
46
|
- Setting `maxAge` to reduce preflight request overhead
|
|
47
47
|
- Reading port from an environment variable with a fallback
|
|
@@ -67,7 +67,7 @@ Local mode also accepts `allowDefaultPublic` (default `false` — set `true` to
|
|
|
67
67
|
|
|
68
68
|
> **Public origin (security):** pin `FRONTMCP_PUBLIC_URL` in production. The issuer / resource / OAuth-discovery URLs and the transparent expected audience derive from it rather than from request headers; `X-Forwarded-Host`/`X-Forwarded-Proto` are ignored unless `FRONTMCP_TRUST_PROXY=1` (a trusted proxy that strips client-supplied forwarded headers).
|
|
69
69
|
|
|
70
|
-
**Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later
|
|
70
|
+
**Progressive / incremental authorization** (opt-in via `incrementalAuth`): when enabled, the minted token carries an `authorized_apps` claim and a `tools/call` for an app NOT in that claim resolves to a `CallToolResult` with `isError: true` and `_meta.code === 'AUTHORIZATION_REQUIRED'` (fields: `authorization_required: true`, `app`, `tool`, `auth_url`, `required_scopes`, `session_mode`, `supports_incremental`). The client declares the initial grant on `/oauth/authorize?…&apps=crm` (omit `apps` to grant all apps) and expands it later by **following the `auth_url` from the failed call** — that URL carries a framework-signed, single-use `ticket` naming the target app and the prior grant, and the new token's claim is the **union** of the prior apps plus the target (the user identity and already-granted apps are preserved; upstream tokens stay server-side). Do NOT hand-assemble `…&mode=incremental&app=slack`: since 1.7.2 (GHSA-2c4g-9c8x-6m8g) an authorize without a valid ticket is an ordinary login and runs the full credential gate, and `/oauth/callback` ignores an `incremental=true` parameter entirely. Without an `incrementalAuth` block, no claim is minted and there is **no** app-level gating (allow-all preserved). `consent` (tool-level) and `incrementalAuth` (app-level) are independent.
|
|
71
71
|
|
|
72
72
|
To collect and verify your own credentials, add a declarative `login` (custom page fields / title / subject strategy) and an `authenticate(input, ctx)` verifier that returns `{ ok: true, sub?, claims? }` (custom claims are embedded in the token; reserved claims are stripped) or `{ ok: false, message }` (re-renders the login page; no code issued). Both are optional and default to the built-in email login. See `configure-auth.md` for a full example.
|
|
73
73
|
|
|
@@ -33,7 +33,9 @@ Configure the HTTP server — port, CORS policy, unix sockets, entry path prefix
|
|
|
33
33
|
- Only need rate limiting or IP filtering without changing HTTP binding -- use `configure-throttle`
|
|
34
34
|
- Need to configure TLS/HTTPS termination -- handle at the reverse proxy or load balancer level, not in FrontMCP
|
|
35
35
|
|
|
36
|
-
> **Decision:** Use this skill when you need to customize how the HTTP listener binds (port, socket, prefix) or how it handles CORS; skip if the
|
|
36
|
+
> **Decision:** Use this skill when you need to customize how the HTTP listener binds (port, socket, prefix, network interface) or how it handles CORS; skip if the defaults are sufficient — port 3000, bound to loopback, sending no CORS headers.
|
|
37
|
+
|
|
38
|
+
> **Changed in v1.7.0.** The server now binds `127.0.0.1` (was `0.0.0.0`) and omitting `cors` now sends no CORS headers (was `{ origin: true }`). Reaching the server from another host or another origin is an explicit opt-in — see [Network Binding](#network-binding) and [CORS Configuration](#cors-configuration).
|
|
37
39
|
|
|
38
40
|
## HttpOptionsInput
|
|
39
41
|
|
|
@@ -46,7 +48,7 @@ Configure the HTTP server — port, CORS policy, unix sockets, entry path prefix
|
|
|
46
48
|
entryPath: '', // default: '' (root)
|
|
47
49
|
socketPath: undefined, // unix socket path (overrides port)
|
|
48
50
|
cors: {
|
|
49
|
-
// default:
|
|
51
|
+
// default: undefined — no CORS headers at all
|
|
50
52
|
origin: ['https://myapp.com'],
|
|
51
53
|
credentials: true,
|
|
52
54
|
maxAge: 86400,
|
|
@@ -87,18 +89,108 @@ http: {
|
|
|
87
89
|
}
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
## Network Binding
|
|
93
|
+
|
|
94
|
+
The server binds `127.0.0.1` unless told otherwise — a server that says nothing about security is
|
|
95
|
+
local-only. The effective address is resolved in this order, first match wins:
|
|
96
|
+
|
|
97
|
+
1. `http.security.bindAddress` — `'loopback'`, `'all'`, or a literal address
|
|
98
|
+
2. The `FRONTMCP_BIND_ADDRESS` environment variable — same three forms
|
|
99
|
+
3. Strict mode (`http.security.strict`) — `0.0.0.0` when distributed, `127.0.0.1` otherwise
|
|
100
|
+
4. A distributed build (`FRONTMCP_DEPLOYMENT_MODE=distributed`) — `0.0.0.0`
|
|
101
|
+
5. The default — `127.0.0.1`
|
|
102
|
+
|
|
103
|
+
```typescript
|
|
104
|
+
http: {
|
|
105
|
+
security: {
|
|
106
|
+
bindAddress: 'all', // 0.0.0.0 — reachable from other hosts
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
In a container, prefer the environment variable: a Dockerfile cannot reach into the server's
|
|
112
|
+
TypeScript config, and the env var needs no rebuild.
|
|
113
|
+
|
|
114
|
+
```dockerfile
|
|
115
|
+
ENV FRONTMCP_BIND_ADDRESS=all
|
|
116
|
+
EXPOSE 3000
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## DNS Rebinding Protection
|
|
120
|
+
|
|
121
|
+
**On by default since v1.7.2** (BC-035, GHSA-mc9g-v2cp-vfff). The server validates `Host`,
|
|
122
|
+
`X-Forwarded-Host` and `Origin` before routing and before reading the body; a request naming a host
|
|
123
|
+
this server does not answer to gets `403`. This covers the MCP endpoint, the OAuth routes, the SSE
|
|
124
|
+
transport and any custom route.
|
|
125
|
+
|
|
126
|
+
Binding loopback does **not** protect against this: a DNS rebinding attack points an
|
|
127
|
+
attacker-controlled domain at `127.0.0.1`, so loopback is the destination. CORS does not either —
|
|
128
|
+
after the rebind the browser genuinely considers the request same-origin. Validating `Host` is the
|
|
129
|
+
server-side defence.
|
|
130
|
+
|
|
131
|
+
With no `allowedHosts` configured, the list is derived from what the process listens on: `localhost`,
|
|
132
|
+
`127.0.0.1` and `[::1]`, each with and without the bound port, plus a specific bound NIC address.
|
|
133
|
+
Matching is case-insensitive and treats `host` and `host:80`/`host:443` as equal.
|
|
134
|
+
|
|
135
|
+
**Deployments behind a proxy need one line of config.** A routable bind (`0.0.0.0`, `::`, a specific
|
|
136
|
+
NIC) is reached under a hostname the process cannot know, so a derived list is not enforced there —
|
|
137
|
+
FrontMCP logs a warning and leaves host checking off rather than 403-ing a proxied deployment on a
|
|
138
|
+
patch upgrade. Name the public host to turn it on:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
http: {
|
|
142
|
+
security: {
|
|
143
|
+
dnsRebindingProtection: {
|
|
144
|
+
allowedHosts: ['api.example.com', 'api.example.com:8443'],
|
|
145
|
+
allowedOrigins: ['https://app.example.com'],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Or via the environment, which pairs with `FRONTMCP_BIND_ADDRESS` in a container:
|
|
152
|
+
|
|
153
|
+
```dockerfile
|
|
154
|
+
ENV FRONTMCP_BIND_ADDRESS=all
|
|
155
|
+
ENV FRONTMCP_ALLOWED_HOSTS=api.example.com,api.example.com:8443
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
To turn it off entirely: `dnsRebindingProtection: { enabled: false }`.
|
|
159
|
+
|
|
160
|
+
A request with **no** `Origin` header is allowed through — non-browser clients never send one, and a
|
|
161
|
+
rebound page always does. Rejecting the absent case breaks every CLI client and adds nothing.
|
|
162
|
+
|
|
90
163
|
## CORS Configuration
|
|
91
164
|
|
|
92
|
-
###
|
|
165
|
+
### No CORS Headers (Default)
|
|
93
166
|
|
|
94
|
-
When `cors` is not specified, the server
|
|
167
|
+
When `cors` is not specified, the server sends **no CORS headers**. A cross-origin request still
|
|
168
|
+
reaches the server and is served normally — the browser simply refuses to let the calling page read
|
|
169
|
+
the response. Non-browser clients are unaffected: CORS is a browser rule, **not server-side access
|
|
170
|
+
control**. If you need to keep callers out, use authentication.
|
|
171
|
+
|
|
172
|
+
`cors: {}` and `cors: { origin: false }` behave identically to omitting the option — the middleware
|
|
173
|
+
is installed only when `origin` is set to something other than `false`.
|
|
95
174
|
|
|
96
175
|
```typescript
|
|
97
|
-
//
|
|
176
|
+
// No CORS headers (default behavior)
|
|
98
177
|
http: {
|
|
99
178
|
}
|
|
100
179
|
```
|
|
101
180
|
|
|
181
|
+
### Allow Any Origin
|
|
182
|
+
|
|
183
|
+
The pre-v1.7.0 default. Reflects whatever `Origin` the request carries, so any page a user visits
|
|
184
|
+
can read this server's responses — never use it in production.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
http: {
|
|
188
|
+
cors: {
|
|
189
|
+
origin: true,
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
102
194
|
### Restrict to Specific Origins
|
|
103
195
|
|
|
104
196
|
```typescript
|
|
@@ -139,11 +231,11 @@ http: {
|
|
|
139
231
|
|
|
140
232
|
### CORS Fields
|
|
141
233
|
|
|
142
|
-
| Field | Type | Default
|
|
143
|
-
| ------------- | ------------------------------------------- |
|
|
144
|
-
| `origin` | `boolean \| string \| string[] \| function` | `
|
|
145
|
-
| `credentials` | `boolean` | `false`
|
|
146
|
-
| `maxAge` | `number` | —
|
|
234
|
+
| Field | Type | Default | Description |
|
|
235
|
+
| ------------- | ------------------------------------------- | ------- | ---------------------------------------------------------------------------------- |
|
|
236
|
+
| `origin` | `boolean \| string \| string[] \| function` | none | Allowed origins. No default — omitting it (or `false`) installs no CORS middleware |
|
|
237
|
+
| `credentials` | `boolean` | `false` | Allow cookies/auth headers |
|
|
238
|
+
| `maxAge` | `number` | — | Preflight cache duration (seconds) |
|
|
147
239
|
|
|
148
240
|
## Request Body Limits
|
|
149
241
|
|
|
@@ -345,13 +437,13 @@ curl --unix-socket /tmp/my-mcp-server.sock http://localhost/
|
|
|
345
437
|
|
|
346
438
|
## Common Patterns
|
|
347
439
|
|
|
348
|
-
| Pattern | Correct | Incorrect | Why
|
|
349
|
-
| --------------------- | ------------------------------------------------------------ | -------------------------------------------- |
|
|
350
|
-
| Port from environment | `port: Number(process.env.PORT) \|\| 3000` | `port: process.env.PORT` | The `port` field expects a number; passing a string causes a silent bind failure
|
|
351
|
-
| CORS with credentials | `cors: { origin: ['https://myapp.com'], credentials: true }` | `cors: { origin: true, credentials: true }` | Browsers reject `Access-Control-Allow-Origin: *` when credentials are enabled; you must list explicit origins
|
|
352
|
-
| Unix socket mode | `socketPath: '/tmp/my-mcp.sock'` with no `port` field | Setting both `socketPath` and `port` | When `socketPath` is set, `port` is silently ignored which can cause confusion during debugging
|
|
353
|
-
| Entry path prefix | `entryPath: '/api/mcp'` (no trailing slash) | `entryPath: '/api/mcp/'` with trailing slash | Trailing slashes cause double-slash issues in route matching (e.g., `/api/mcp//sse`)
|
|
354
|
-
|
|
|
440
|
+
| Pattern | Correct | Incorrect | Why |
|
|
441
|
+
| --------------------- | ------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
442
|
+
| Port from environment | `port: Number(process.env.PORT) \|\| 3000` | `port: process.env.PORT` | The `port` field expects a number; passing a string causes a silent bind failure |
|
|
443
|
+
| CORS with credentials | `cors: { origin: ['https://myapp.com'], credentials: true }` | `cors: { origin: true, credentials: true }` | Browsers reject `Access-Control-Allow-Origin: *` when credentials are enabled; you must list explicit origins |
|
|
444
|
+
| Unix socket mode | `socketPath: '/tmp/my-mcp.sock'` with no `port` field | Setting both `socketPath` and `port` | When `socketPath` is set, `port` is silently ignored which can cause confusion during debugging |
|
|
445
|
+
| Entry path prefix | `entryPath: '/api/mcp'` (no trailing slash) | `entryPath: '/api/mcp/'` with trailing slash | Trailing slashes cause double-slash issues in route matching (e.g., `/api/mcp//sse`) |
|
|
446
|
+
| Allowing cross-origin | `cors: { origin: ['https://myapp.com'] }` | Omitting the `cors` field entirely | Omitting `cors` (like `cors: false`) sends no CORS headers at all; a browser on another origin needs an explicit `origin` list |
|
|
355
447
|
|
|
356
448
|
## Verification Checklist
|
|
357
449
|
|
|
@@ -362,8 +454,14 @@ curl --unix-socket /tmp/my-mcp-server.sock http://localhost/
|
|
|
362
454
|
- [ ] If `socketPath` is set, `port` is removed or commented out to avoid confusion
|
|
363
455
|
- [ ] `entryPath` does not have a trailing slash
|
|
364
456
|
|
|
457
|
+
### Network Binding
|
|
458
|
+
|
|
459
|
+
- [ ] If the server must be reachable from another host, `security.bindAddress` or `FRONTMCP_BIND_ADDRESS` is set — the default is loopback-only
|
|
460
|
+
- [ ] Containers set `FRONTMCP_BIND_ADDRESS=all` so the published port reaches the process
|
|
461
|
+
|
|
365
462
|
### CORS
|
|
366
463
|
|
|
464
|
+
- [ ] `cors` is set only if a browser on another origin needs access — omitting it sends no headers
|
|
367
465
|
- [ ] If `credentials: true`, `origin` lists explicit allowed origins (not `true` or `*`)
|
|
368
466
|
- [ ] `maxAge` is set to a reasonable value for production (e.g., `86400` for 24 hours)
|
|
369
467
|
- [ ] Dynamic origin function handles `undefined` origin (non-browser requests)
|
|
@@ -390,7 +488,7 @@ curl --unix-socket /tmp/my-mcp-server.sock http://localhost/
|
|
|
390
488
|
| CORS errors in the browser console | Origin not included in the `cors.origin` list or `credentials: true` with wildcard origin | Add the frontend origin to the `origin` array and ensure credentials and origin settings are compatible |
|
|
391
489
|
| Unix socket file not created | Missing write permissions on the target directory or stale socket file from a previous run | Check directory permissions and remove the stale `.sock` file before restarting |
|
|
392
490
|
| Routes return 404 after setting `entryPath` | Client is still requesting the root path without the prefix | Update client base URL to include the entry path (e.g., `http://localhost:3000/api/mcp`) |
|
|
393
|
-
| Server binds but external clients cannot connect |
|
|
491
|
+
| Server binds but external clients cannot connect | The default bind address is `127.0.0.1`, which a published container port cannot reach | Set `FRONTMCP_BIND_ADDRESS=all` in the container (or `http.security.bindAddress: 'all'` in the config) |
|
|
394
492
|
| `413 Payload Too Large` with JSON-RPC envelope | Request body exceeded `bodyLimit` (default `'4mb'`) | Raise `http.bodyLimit` to fit the payload, or move large blobs to a separate upload endpoint |
|
|
395
493
|
| Server throws at startup mentioning a "reserved" path | A custom `http.routes` path collides with the MCP entry path, `/oauth/*`, `/.well-known/*`, `/health`, or `/metrics` | Rename the custom route to a non-reserved path |
|
|
396
494
|
| Custom route returns JSON when HTML/bytes expected | The Express adapter defaults responses to `application/json` | Set `res.setHeader('Content-Type', ...)` (or `res.type(...)`) in the handler before sending the body |
|
|
@@ -45,7 +45,7 @@ Configure how clients connect to your FrontMCP server — SSE, Streamable HTTP,
|
|
|
45
45
|
distributedMode: 'auto', // boolean | 'auto'
|
|
46
46
|
eventStore: {
|
|
47
47
|
enabled: true,
|
|
48
|
-
provider: 'redis', // 'memory' | 'redis'
|
|
48
|
+
provider: 'redis', // 'memory' | 'redis' | 'sqlite'
|
|
49
49
|
maxEvents: 10000,
|
|
50
50
|
ttlMs: 300000,
|
|
51
51
|
},
|
|
@@ -115,7 +115,7 @@ Enable event store so clients can resume SSE connections after disconnects:
|
|
|
115
115
|
transport: {
|
|
116
116
|
eventStore: {
|
|
117
117
|
enabled: true,
|
|
118
|
-
provider: 'redis', // 'memory'
|
|
118
|
+
provider: 'redis', // 'memory' | 'redis' | 'sqlite'
|
|
119
119
|
maxEvents: 10000, // max events to store
|
|
120
120
|
ttlMs: 300000, // 5 minute TTL
|
|
121
121
|
redis: { provider: 'redis', host: 'localhost' },
|
|
@@ -123,6 +123,12 @@ transport: {
|
|
|
123
123
|
}
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
**Auto-enabled in distributed mode.** A distributed deployment with Redis configured turns the event store on without an explicit `eventStore` block, so the notes below apply there too.
|
|
127
|
+
|
|
128
|
+
**Requires 1.7.2 or later.** Before 1.7.2 (GHSA-84j6-jc92-77jm) one store instance was shared by every session with no ownership check on replay, and the upstream transport writes every session's standalone SSE stream under the constant id `_GET_stream` with sequential event numbers — so a client sending `Last-Event-ID: _GET_stream:1` was replayed other sessions' server-to-client messages (tool results, resource contents, notifications). On 1.7.1 or earlier, do not enable the event store on a multi-tenant deployment.
|
|
129
|
+
|
|
130
|
+
From 1.7.2 each session gets a view over the shared store scoped to its own session id: an event id belonging to another session replays nothing.
|
|
131
|
+
|
|
126
132
|
## Target-Specific Recommendations
|
|
127
133
|
|
|
128
134
|
| Target | Recommended Preset | Persistence | Event Store |
|
|
@@ -42,6 +42,10 @@ async function main() {
|
|
|
42
42
|
case 'multiply':
|
|
43
43
|
return { result: input.a * input.b };
|
|
44
44
|
case 'divide':
|
|
45
|
+
// `z.number()` accepts 0, so guard the divisor: 1/0 is Infinity, which JSON
|
|
46
|
+
// serialises as null, and 0/0 is NaN, which the output schema rejects with an
|
|
47
|
+
// opaque error. Fail with a message the caller can act on instead.
|
|
48
|
+
if (input.b === 0) throw new Error('Cannot divide by zero');
|
|
45
49
|
return { result: input.a / input.b };
|
|
46
50
|
}
|
|
47
51
|
}),
|
|
@@ -70,6 +70,8 @@ RUN npx frontmcp build --target node
|
|
|
70
70
|
FROM node:24-alpine AS production
|
|
71
71
|
WORKDIR /app
|
|
72
72
|
ENV NODE_ENV=production
|
|
73
|
+
# The server binds 127.0.0.1 by default, which a published container port cannot reach.
|
|
74
|
+
ENV FRONTMCP_BIND_ADDRESS=all
|
|
73
75
|
COPY --from=builder /app/dist ./dist
|
|
74
76
|
COPY --from=builder /app/package.json ./
|
|
75
77
|
COPY --from=builder /app/yarn.lock ./
|
|
@@ -54,7 +54,8 @@ server {
|
|
|
54
54
|
# .env
|
|
55
55
|
PORT=3000
|
|
56
56
|
NODE_ENV=production
|
|
57
|
-
|
|
57
|
+
# No FRONTMCP_BIND_ADDRESS here on purpose: the server binds 127.0.0.1 by default,
|
|
58
|
+
# which is exactly right when NGINX on the same host is the only thing talking to it.
|
|
58
59
|
REDIS_URL=redis://localhost:6379
|
|
59
60
|
LOG_LEVEL=info
|
|
60
61
|
```
|
|
@@ -31,6 +31,8 @@ RUN yarn frontmcp build --target node
|
|
|
31
31
|
FROM node:24-alpine AS production
|
|
32
32
|
WORKDIR /app
|
|
33
33
|
ENV NODE_ENV=production
|
|
34
|
+
# The server binds 127.0.0.1 by default, which a published container port cannot reach.
|
|
35
|
+
ENV FRONTMCP_BIND_ADDRESS=all
|
|
34
36
|
ENV PORT=3000
|
|
35
37
|
COPY --from=builder /app/dist ./dist
|
|
36
38
|
COPY --from=builder /app/package.json ./
|
|
@@ -69,15 +69,34 @@ const server = await create({
|
|
|
69
69
|
tool({
|
|
70
70
|
name: 'calculate',
|
|
71
71
|
description: 'Perform calculation',
|
|
72
|
-
inputSchema: {
|
|
72
|
+
inputSchema: {
|
|
73
|
+
a: z.number(),
|
|
74
|
+
b: z.number(),
|
|
75
|
+
operation: z.enum(['add', 'subtract', 'multiply', 'divide']),
|
|
76
|
+
},
|
|
73
77
|
outputSchema: { result: z.number() },
|
|
74
|
-
})((input) =>
|
|
78
|
+
})((input) => {
|
|
79
|
+
switch (input.operation) {
|
|
80
|
+
case 'add':
|
|
81
|
+
return { result: input.a + input.b };
|
|
82
|
+
case 'subtract':
|
|
83
|
+
return { result: input.a - input.b };
|
|
84
|
+
case 'multiply':
|
|
85
|
+
return { result: input.a * input.b };
|
|
86
|
+
case 'divide':
|
|
87
|
+
// `z.number()` accepts 0, so guard the divisor: 1/0 is Infinity, which JSON
|
|
88
|
+
// serialises as null, and 0/0 is NaN, which the output schema rejects with an
|
|
89
|
+
// opaque error. Fail with a message the caller can act on instead.
|
|
90
|
+
if (input.b === 0) throw new Error('Cannot divide by zero');
|
|
91
|
+
return { result: input.a / input.b };
|
|
92
|
+
}
|
|
93
|
+
}),
|
|
75
94
|
],
|
|
76
95
|
cacheKey: 'my-service', // Reuse same instance on repeated calls
|
|
77
96
|
});
|
|
78
97
|
|
|
79
98
|
// Call tools directly
|
|
80
|
-
const result = await server.callTool('calculate', {
|
|
99
|
+
const result = await server.callTool('calculate', { a: 2, b: 2, operation: 'add' });
|
|
81
100
|
|
|
82
101
|
// List available tools
|
|
83
102
|
const { tools } = await server.listTools();
|
|
@@ -86,6 +105,13 @@ const { tools } = await server.listTools();
|
|
|
86
105
|
await server.dispose();
|
|
87
106
|
```
|
|
88
107
|
|
|
108
|
+
> **Never `eval()` tool input.** A tool's arguments come from the MCP caller, or from an LLM acting
|
|
109
|
+
> on caller-controlled prompts — they are untrusted by definition. `eval` on that value runs with the
|
|
110
|
+
> embedding process's full authority: environment credentials, the filesystem, the network, and
|
|
111
|
+
> `process.getBuiltinModule('child_process')`. Output-schema validation cannot help, because the side
|
|
112
|
+
> effects happen before the result is validated. Model the operation in the schema, as above, so the
|
|
113
|
+
> set of things the tool can do is fixed at design time.
|
|
114
|
+
|
|
89
115
|
### CreateConfig Fields
|
|
90
116
|
|
|
91
117
|
```typescript
|
|
@@ -130,7 +130,10 @@ Create a `.env` file or set variables in your deployment environment:
|
|
|
130
130
|
# Server
|
|
131
131
|
PORT=3000
|
|
132
132
|
NODE_ENV=production
|
|
133
|
-
|
|
133
|
+
# The server binds 127.0.0.1 by default. Set this only when it must be reachable
|
|
134
|
+
# from another host — a container, a VM, direct access. Behind a reverse proxy on
|
|
135
|
+
# the same machine, leave it unset.
|
|
136
|
+
FRONTMCP_BIND_ADDRESS=all
|
|
134
137
|
|
|
135
138
|
# Redis (required for session storage in production)
|
|
136
139
|
REDIS_URL=redis://localhost:6379
|
|
@@ -139,13 +142,13 @@ REDIS_URL=redis://localhost:6379
|
|
|
139
142
|
LOG_LEVEL=info
|
|
140
143
|
```
|
|
141
144
|
|
|
142
|
-
| Variable
|
|
143
|
-
|
|
|
144
|
-
| `PORT`
|
|
145
|
-
| `NODE_ENV`
|
|
146
|
-
| `REDIS_URL`
|
|
147
|
-
| `
|
|
148
|
-
| `LOG_LEVEL`
|
|
145
|
+
| Variable | Description | Default |
|
|
146
|
+
| ----------------------- | ------------------------------------------------------------------ | ------------------------ |
|
|
147
|
+
| `PORT` | HTTP port for the server | `3000` |
|
|
148
|
+
| `NODE_ENV` | Runtime environment | `development` |
|
|
149
|
+
| `REDIS_URL` | Redis connection string for storage | (none) |
|
|
150
|
+
| `FRONTMCP_BIND_ADDRESS` | Network interface to bind: `all`, `loopback`, or a literal address | `loopback` (`127.0.0.1`) |
|
|
151
|
+
| `LOG_LEVEL` | Logging verbosity | `info` |
|
|
149
152
|
|
|
150
153
|
## Step 5: Health Checks
|
|
151
154
|
|
|
@@ -112,6 +112,8 @@ class DataServer {}
|
|
|
112
112
|
## What This Demonstrates
|
|
113
113
|
|
|
114
114
|
- Declarative `permissions` as an array of `{ action, roles, scopes, custom }` rules
|
|
115
|
+
(**enforced from 1.7.2 onward** — see GHSA-58v2-gpcc-jmqv; earlier versions
|
|
116
|
+
stored the rules without evaluating them)
|
|
115
117
|
- Using `tags` and `labels` for categorization and filtering
|
|
116
118
|
- The `job()` function builder for simple jobs that need no class
|
|
117
119
|
- Full server registration with `jobs.enabled: true` and a Redis store
|
|
@@ -35,17 +35,17 @@ Create a class extending `JobContext<In, Out>` and implement the `execute(input:
|
|
|
35
35
|
|
|
36
36
|
### JobMetadata Fields
|
|
37
37
|
|
|
38
|
-
| Field | Type | Required | Default | Description
|
|
39
|
-
| -------------- | ------------------------ | -------- | ---------------- |
|
|
40
|
-
| `name` | `string` | Yes | -- | Unique job name
|
|
41
|
-
| `inputSchema` | `ZodRawShape` | Yes | -- | Zod raw shape for input validation
|
|
42
|
-
| `outputSchema` | `ZodRawShape \| ZodType` | Yes | -- | Zod schema for output validation
|
|
43
|
-
| `description` | `string` | No | -- | Human-readable description
|
|
44
|
-
| `timeout` | `number` | No | `300000` (5 min) | Maximum execution time in milliseconds
|
|
45
|
-
| `retry` | `RetryPolicy` | No | -- | Retry configuration (see below)
|
|
46
|
-
| `tags` | `string[]` | No | -- | Categorization tags
|
|
47
|
-
| `labels` | `Record<string, string>` | No | -- | Key-value labels for filtering
|
|
48
|
-
| `permissions` | `JobPermission[]` | No | -- | Array of permission rules
|
|
38
|
+
| Field | Type | Required | Default | Description |
|
|
39
|
+
| -------------- | ------------------------ | -------- | ---------------- | -------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `name` | `string` | Yes | -- | Unique job name |
|
|
41
|
+
| `inputSchema` | `ZodRawShape` | Yes | -- | Zod raw shape for input validation |
|
|
42
|
+
| `outputSchema` | `ZodRawShape \| ZodType` | Yes | -- | Zod schema for output validation |
|
|
43
|
+
| `description` | `string` | No | -- | Human-readable description |
|
|
44
|
+
| `timeout` | `number` | No | `300000` (5 min) | Maximum execution time in milliseconds |
|
|
45
|
+
| `retry` | `RetryPolicy` | No | -- | Retry configuration (see below) |
|
|
46
|
+
| `tags` | `string[]` | No | -- | Categorization tags |
|
|
47
|
+
| `labels` | `Record<string, string>` | No | -- | Key-value labels for filtering |
|
|
48
|
+
| `permissions` | `JobPermission[]` | No | -- | Array of permission rules. Multiple rules may target the same action; all matching rules must pass |
|
|
49
49
|
|
|
50
50
|
### Basic Example
|
|
51
51
|
|
|
@@ -266,13 +266,17 @@ class ImportCsvJob extends JobContext {
|
|
|
266
266
|
|
|
267
267
|
Control who can interact with jobs using the `permissions` field. **`permissions` is an array** of rules; each rule grants access to a single `action` and lists the roles, scopes, and/or custom predicate required for that action.
|
|
268
268
|
|
|
269
|
+
**Requires 1.7.2 or later.** Before 1.7.2 (GHSA-58v2-gpcc-jmqv) the `permissions` array was validated and stored but never evaluated — every job was reachable by every caller who could reach `execute_job`. On older versions do not rely on this field for access control.
|
|
270
|
+
|
|
271
|
+
Semantics: no rules for an action means allow (the documented default); once any rule targets an action, **all** rules for that action must pass, and `roles`/`scopes` within a single rule are **any-of**. Enforcement happens in `JobExecutionManager`, so background runs and workflow steps are covered, and `list_jobs` hides entries the caller could not run. A denial is indistinguishable from "not found" so restricted job names cannot be enumerated.
|
|
272
|
+
|
|
269
273
|
### Permission Rule Shape
|
|
270
274
|
|
|
271
275
|
```typescript
|
|
272
276
|
interface JobPermission {
|
|
273
277
|
action: 'create' | 'read' | 'update' | 'delete' | 'execute' | 'list'; // singular!
|
|
274
|
-
roles?: string[]; //
|
|
275
|
-
scopes?: string[]; // token must include
|
|
278
|
+
roles?: string[]; // caller must have one of these roles
|
|
279
|
+
scopes?: string[]; // token must include one of these scopes
|
|
276
280
|
custom?: (authInfo: Partial<Record<string, unknown>>) => boolean | Promise<boolean>;
|
|
277
281
|
}
|
|
278
282
|
```
|
|
@@ -418,7 +422,7 @@ class DataApp {}
|
|
|
418
422
|
|
|
419
423
|
### Enabling the Jobs System
|
|
420
424
|
|
|
421
|
-
**Auto-enable (issue #408):** declaring any `@App({ jobs: [...] })` (or `workflows: [...]`) is enough — the jobs subsystem comes up with in-memory stores by default and the management tools (`execute_job`, `list_jobs`, `get_job_status`, `
|
|
425
|
+
**Auto-enable (issue #408):** declaring any `@App({ jobs: [...] })` (or `workflows: [...]`) is enough — the jobs subsystem comes up with in-memory stores by default and the management tools (`execute_job`, `list_jobs`, `get_job_status`, `remove_job`) are registered automatically so agents can invoke them. `register_job` is added only when `jobs.allowDynamicRegistration` is `true`. No `@FrontMcp({ jobs: { enabled: true } })` is required for the happy path.
|
|
422
426
|
|
|
423
427
|
**When to configure `@FrontMcp({ jobs })` explicitly:** override the in-memory default with persistent storage (Redis recommended for multi-replica HA) so job state, progress, logs, and outputs survive retries and server restarts.
|
|
424
428
|
|
|
@@ -450,17 +454,27 @@ Setting `jobs: { enabled: false }` is an explicit opt-out — declared jobs will
|
|
|
450
454
|
|
|
451
455
|
Once jobs are registered, the SDK exposes five MCP tools (snake_case per ecosystem convention; hyphen aliases like `execute-job` keep working with a deprecation log line for one release):
|
|
452
456
|
|
|
453
|
-
| Tool | Purpose
|
|
454
|
-
| ---------------- |
|
|
455
|
-
| `list_jobs` | List registered jobs with optional `tags` / `labels` / `query` filters
|
|
456
|
-
| `execute_job` | Execute a registered job by name (`{ name, input?, background? }`)
|
|
457
|
-
| `get_job_status` | Get the run state for a `runId` returned by `execute_job`
|
|
458
|
-
| `register_job` | Register a dynamic job at runtime
|
|
459
|
-
| `remove_job` | Remove a dynamic job by name (`hideFromDiscovery: true`)
|
|
457
|
+
| Tool | Purpose |
|
|
458
|
+
| ---------------- | ---------------------------------------------------------------------- |
|
|
459
|
+
| `list_jobs` | List registered jobs with optional `tags` / `labels` / `query` filters |
|
|
460
|
+
| `execute_job` | Execute a registered job by name (`{ name, input?, background? }`) |
|
|
461
|
+
| `get_job_status` | Get the run state for a `runId` returned by `execute_job` |
|
|
462
|
+
| `register_job` | Register a dynamic job at runtime — **opt-in**, see below |
|
|
463
|
+
| `remove_job` | Remove a dynamic job by name (`hideFromDiscovery: true`) |
|
|
460
464
|
|
|
461
465
|
Workflows expose a parallel set: `list_workflows`, `execute_workflow`, `get_workflow_status`, `register_workflow`, `remove_workflow`.
|
|
462
466
|
|
|
463
|
-
|
|
467
|
+
`register_job` / `register_workflow` take a **raw script string** and register it as an executable job. Since 1.7.2 they are not registered unless you opt in:
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
@FrontMcp({ jobs: { enabled: true, allowDynamicRegistration: true } })
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Leave it off unless an agent is genuinely meant to author jobs — with it on, any caller who reaches the tool list can author and run code on the server.
|
|
474
|
+
|
|
475
|
+
`get_job_status` / `get_workflow_status` return only runs started by the calling subject; a run record carries the job's inputs and results, so a foreign `runId` reads as "not found".
|
|
476
|
+
|
|
477
|
+
For finer-grained control, opt out of auto-registration by importing the tool classes manually:
|
|
464
478
|
|
|
465
479
|
```typescript
|
|
466
480
|
import { App, ExecuteJobTool, GetJobStatusTool, ListJobsTool } from '@frontmcp/sdk';
|
|
@@ -35,16 +35,16 @@ Create a class decorated with `@Workflow`. The decorator requires `name` and `st
|
|
|
35
35
|
|
|
36
36
|
### WorkflowMetadata Fields
|
|
37
37
|
|
|
38
|
-
| Field | Type | Required | Default | Description
|
|
39
|
-
| ---------------- | ---------------------------------- | ----------- | ----------------- |
|
|
40
|
-
| `name` | `string` | Yes | -- | Unique workflow name
|
|
41
|
-
| `steps` | `WorkflowStep[]` | Yes (min 1) | -- | Array of step definitions
|
|
42
|
-
| `description` | `string` | No | -- | Human-readable description
|
|
43
|
-
| `trigger` | `'manual' \| 'webhook' \| 'event'` | No | `'manual'` | How the workflow is initiated
|
|
44
|
-
| `webhook` | `WebhookConfig` | No | -- | Webhook configuration (when trigger is `'webhook'`)
|
|
45
|
-
| `timeout` | `number` | No | `600000` (10 min) | Maximum total workflow execution time in milliseconds
|
|
46
|
-
| `maxConcurrency` | `number` | No | `5` | Maximum number of steps running in parallel
|
|
47
|
-
| `permissions` | `
|
|
38
|
+
| Field | Type | Required | Default | Description |
|
|
39
|
+
| ---------------- | ---------------------------------- | ----------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40
|
+
| `name` | `string` | Yes | -- | Unique workflow name |
|
|
41
|
+
| `steps` | `WorkflowStep[]` | Yes (min 1) | -- | Array of step definitions |
|
|
42
|
+
| `description` | `string` | No | -- | Human-readable description |
|
|
43
|
+
| `trigger` | `'manual' \| 'webhook' \| 'event'` | No | `'manual'` | How the workflow is initiated |
|
|
44
|
+
| `webhook` | `WebhookConfig` | No | -- | Webhook configuration (when trigger is `'webhook'`) |
|
|
45
|
+
| `timeout` | `number` | No | `600000` (10 min) | Maximum total workflow execution time in milliseconds |
|
|
46
|
+
| `maxConcurrency` | `number` | No | `5` | Maximum number of steps running in parallel |
|
|
47
|
+
| `permissions` | `JobPermission[]` | No | -- | Array of permission rules. Multiple rules may target the same action; all matching rules must pass. Same shape and semantics as job permissions; **enforced from 1.7.2** (GHSA-58v2-gpcc-jmqv) |
|
|
48
48
|
|
|
49
49
|
### WorkflowStep Fields
|
|
50
50
|
|
|
@@ -662,9 +662,23 @@ interface DashboardPluginOptionsInput {
|
|
|
662
662
|
|
|
663
663
|
- `enabled` -- When omitted, the dashboard is automatically enabled in development (`NODE_ENV !== 'production'`) and disabled in production.
|
|
664
664
|
- `basePath` -- URL path where the dashboard is served. Default: `'/dashboard'`.
|
|
665
|
-
- `auth.token` --
|
|
665
|
+
- `auth.enabled` / `auth.token` -- Gate the dashboard page on a shared secret. Present it as `Authorization: Bearer <token>` (preferred) or `?token=<value>`. `enabled: true` without a `token` is a **startup error** — the server refuses to boot rather than serve an "authenticated" dashboard with nothing to check. The token is compared in constant time and is never embedded in the served page.
|
|
666
666
|
- `cdn` -- Override default CDN URLs for the dashboard UI bundle and its dependencies. Useful for air-gapped environments.
|
|
667
667
|
|
|
668
|
+
### Security
|
|
669
|
+
|
|
670
|
+
**Requires 1.7.2 or later.** Before 1.7.2 (GHSA-rgxj-434m-vxh3) `auth.token` was documented and validated but never checked, the operator's options never reached the middleware at all, and the dashboard's MCP scope declared `auth: { mode: 'public' }` unconditionally — so a dashboard configured with a secret still served its page, and `dashboard:graph` still returned the entire server's inventory, to anyone. On 1.7.1 or earlier, treat any server running `DashboardApp` as publicly introspectable.
|
|
671
|
+
|
|
672
|
+
The dashboard's MCP scope **inherits the server's authentication**. Its introspection tools (`dashboard:graph`, `dashboard:list-tools`, `dashboard:list-resources`) reach the root scope and enumerate every app, tool, resource and prompt on the server — including names, descriptions and (on request) schemas. Two consequences:
|
|
673
|
+
|
|
674
|
+
- On an authenticated server (`local`, `remote`, `transparent`, `orchestrated`), the dashboard requires the same credential as everything else.
|
|
675
|
+
- On a **public** server the dashboard is public too, because the server is. `auth.token` gates the dashboard _page_, not the MCP scope or the SSE stream. If the inventory is sensitive, authenticate the server — do not rely on the dashboard token alone.
|
|
676
|
+
|
|
677
|
+
Two further limitations worth knowing:
|
|
678
|
+
|
|
679
|
+
- The token is accepted as `Authorization: Bearer <token>` or `?token=`. Prefer the header: a URL token lands in browser history, `Referer` headers and access logs. There is no cookie/session option yet.
|
|
680
|
+
- Dashboard options are **process-wide**. Two `@FrontMcp` servers built in one process that configure the dashboard differently share the last configuration registered, including its token; the plugin logs a warning when that happens. Run one dashboard per process.
|
|
681
|
+
|
|
668
682
|
---
|
|
669
683
|
|
|
670
684
|
## Registration Pattern
|
package/catalog/frontmcp-production-readiness/examples/production-node-server/docker-multi-stage.md
CHANGED
|
@@ -33,6 +33,8 @@ RUN npx frontmcp build
|
|
|
33
33
|
FROM node:24-slim AS runtime
|
|
34
34
|
WORKDIR /app
|
|
35
35
|
ENV NODE_ENV=production
|
|
36
|
+
# The server binds 127.0.0.1 by default, which a published container port cannot reach.
|
|
37
|
+
ENV FRONTMCP_BIND_ADDRESS=all
|
|
36
38
|
COPY package.json yarn.lock ./
|
|
37
39
|
RUN yarn install --frozen-lockfile --production && yarn cache clean
|
|
38
40
|
COPY --from=builder /app/dist ./dist
|
|
@@ -29,6 +29,15 @@ These checks apply to ALL deployment targets. Run them first, then proceed to yo
|
|
|
29
29
|
- [ ] Credentials mode is only enabled if cookies/sessions are needed
|
|
30
30
|
- [ ] Preflight cache (`maxAge`) is set to reduce OPTIONS requests
|
|
31
31
|
|
|
32
|
+
### DNS Rebinding Protection
|
|
33
|
+
|
|
34
|
+
- [ ] `security.dnsRebindingProtection.allowedHosts` (or `FRONTMCP_ALLOWED_HOSTS`) names the public
|
|
35
|
+
hostname(s) — on a routable bind the derived default is **not** enforced, and FrontMCP logs a
|
|
36
|
+
warning saying so
|
|
37
|
+
- [ ] Startup logs show no `DNS-rebinding protection is not enforcing a Host allow-list` warning
|
|
38
|
+
- [ ] Include the port when the public URL uses a non-default one (`api.example.com:8443`)
|
|
39
|
+
- [ ] `allowedOrigins` is set when a browser client connects, so a foreign `Origin` is refused
|
|
40
|
+
|
|
32
41
|
### Input Validation
|
|
33
42
|
|
|
34
43
|
- [ ] All tool inputs use Zod schemas (never trust raw input)
|
|
@@ -781,7 +781,7 @@
|
|
|
781
781
|
"level": "basic",
|
|
782
782
|
"tags": ["config", "browser", "http", "cors", "restricted", "origins"],
|
|
783
783
|
"features": [
|
|
784
|
-
"
|
|
784
|
+
"Naming the origins a browser may read from — omitting `cors` sends no headers at all",
|
|
785
785
|
"Enabling `credentials: true` with specific origins (required -- browsers reject `*` with credentials)",
|
|
786
786
|
"Setting `maxAge` to reduce preflight request overhead",
|
|
787
787
|
"Reading port from an environment variable with a fallback"
|