@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.
Files changed (20) hide show
  1. package/catalog/frontmcp-config/examples/configure-http/cors-restricted-origins.md +2 -2
  2. package/catalog/frontmcp-config/references/configure-auth-modes.md +1 -1
  3. package/catalog/frontmcp-config/references/configure-http.md +116 -18
  4. package/catalog/frontmcp-config/references/configure-transport.md +8 -2
  5. package/catalog/frontmcp-deployment/examples/build-for-sdk/create-flat-config.md +4 -0
  6. package/catalog/frontmcp-deployment/examples/deploy-to-node/docker-compose-with-redis.md +2 -0
  7. package/catalog/frontmcp-deployment/examples/deploy-to-node/pm2-with-nginx.md +2 -1
  8. package/catalog/frontmcp-deployment/examples/deploy-to-node-dockerfile/basic-multistage-dockerfile.md +2 -0
  9. package/catalog/frontmcp-deployment/examples/deploy-to-node-dockerfile/secure-nonroot-dockerfile.md +2 -0
  10. package/catalog/frontmcp-deployment/references/build-for-sdk.md +29 -3
  11. package/catalog/frontmcp-deployment/references/deploy-to-node-dockerfile.md +2 -0
  12. package/catalog/frontmcp-deployment/references/deploy-to-node.md +11 -8
  13. package/catalog/frontmcp-development/examples/create-job/job-with-permissions.md +2 -0
  14. package/catalog/frontmcp-development/references/create-job.md +36 -22
  15. package/catalog/frontmcp-development/references/create-workflow.md +10 -10
  16. package/catalog/frontmcp-development/references/official-plugins.md +15 -1
  17. package/catalog/frontmcp-production-readiness/examples/production-node-server/docker-multi-stage.md +2 -0
  18. package/catalog/frontmcp-production-readiness/references/common-checklist.md +9 -0
  19. package/catalog/skills-manifest.json +1 -1
  20. 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
- - 'Restricting CORS to explicit origins instead of the permissive default'
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
- - Restricting CORS to explicit origins instead of the permissive default
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 via an incremental authorize `…&mode=incremental&app=slack&apps=crm` — 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). 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.
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 default port 3000 with permissive CORS is sufficient.
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: permissive (all origins)
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
- ### Permissive (Default)
165
+ ### No CORS Headers (Default)
93
166
 
94
- When `cors` is not specified, the server allows all origins without credentials:
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
- // All origins allowed (default behavior)
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 | Description |
143
- | ------------- | ------------------------------------------- | ------------ | ---------------------------------- |
144
- | `origin` | `boolean \| string \| string[] \| function` | `true` (all) | Allowed origins |
145
- | `credentials` | `boolean` | `false` | Allow cookies/auth headers |
146
- | `maxAge` | `number` | — | Preflight cache duration (seconds) |
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
- | Disabling CORS | `cors: false` | Omitting the `cors` field entirely | Omitting `cors` applies permissive defaults (all origins allowed); set `false` explicitly to send no CORS headers |
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 | Server bound to `localhost` or `127.0.0.1` inside a container | Set `host: '0.0.0.0'` or use Docker port mapping to expose the container port |
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' for single instance, 'redis' for distributed
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
- HOST=0.0.0.0
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 ./
@@ -50,6 +50,8 @@ USER frontmcp
50
50
 
51
51
  # Environment defaults
52
52
  ENV NODE_ENV=production
53
+ # The server binds 127.0.0.1 by default, which a published container port cannot reach.
54
+ ENV FRONTMCP_BIND_ADDRESS=all
53
55
  ENV PORT=3000
54
56
 
55
57
  EXPOSE 3000
@@ -69,15 +69,34 @@ const server = await create({
69
69
  tool({
70
70
  name: 'calculate',
71
71
  description: 'Perform calculation',
72
- inputSchema: { expression: z.string() },
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) => ({ result: eval(input.expression) })),
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', { expression: '2 + 2' });
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
@@ -65,6 +65,8 @@ USER frontmcp
65
65
 
66
66
  # Environment defaults
67
67
  ENV NODE_ENV=production
68
+ # The server binds 127.0.0.1 by default, which a published container port cannot reach.
69
+ ENV FRONTMCP_BIND_ADDRESS=all
68
70
  ENV PORT=3000
69
71
 
70
72
  EXPOSE 3000
@@ -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
- HOST=0.0.0.0
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 | Description | Default |
143
- | ----------- | ----------------------------------- | ------------- |
144
- | `PORT` | HTTP port for the server | `3000` |
145
- | `NODE_ENV` | Runtime environment | `development` |
146
- | `REDIS_URL` | Redis connection string for storage | (none) |
147
- | `HOST` | Network interface to bind | `0.0.0.0` |
148
- | `LOG_LEVEL` | Logging verbosity | `info` |
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 (one per action) |
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[]; // user must have one of these roles
275
- scopes?: string[]; // token must include all of these scopes
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`, `register_job`, `remove_job`) are registered automatically so agents can invoke them. No `@FrontMcp({ jobs: { enabled: true } })` is required for the happy path.
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 (sandboxed; `hideFromDiscovery: true`) |
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
- For finer-grained control (e.g. omit `register_job` / `remove_job` in production), opt out of auto-registration by importing the tool classes manually:
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` | `WorkflowPermission[]` | No | -- | Array of permission rules (one per action) |
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` -- When set, the dashboard requires `?token=<value>` as a query parameter.
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
@@ -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
- "Restricting CORS to explicit origins instead of the permissive default",
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"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/skills",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "Curated skills catalog for FrontMCP projects",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "homepage": "https://docs.agentfront.dev",