@frontmcp/skills 1.7.1 → 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/references/configure-auth-modes.md +1 -1
- package/catalog/frontmcp-config/references/configure-http.md +44 -0
- package/catalog/frontmcp-config/references/configure-transport.md +8 -2
- 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/references/common-checklist.md +9 -0
- package/package.json +1 -1
|
@@ -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
|
|
|
@@ -116,6 +116,50 @@ ENV FRONTMCP_BIND_ADDRESS=all
|
|
|
116
116
|
EXPOSE 3000
|
|
117
117
|
```
|
|
118
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
|
+
|
|
119
163
|
## CORS Configuration
|
|
120
164
|
|
|
121
165
|
### No CORS Headers (Default)
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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)
|