payload-mcp-toolkit 0.7.5 → 0.8.0

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.
@@ -16,12 +16,12 @@ export const PRESET_ACTIONS = {
16
16
  ],
17
17
  admin: ALL_ACTIONS
18
18
  };
19
- /**
20
- * Asymmetric per-preset action map for globals. `editor` is intentionally
21
- * read-only on globals — a single bad write on a singleton broadcasts
22
- * site-wide with no per-document containment. Operators who want global
23
- * writes promote the key to `admin` or use a Custom key with explicit
24
- * `globalScopes`. README and CHANGELOG call out the asymmetry.
19
+ /**
20
+ * Asymmetric per-preset action map for globals. `editor` is intentionally
21
+ * read-only on globals — a single bad write on a singleton broadcasts
22
+ * site-wide with no per-document containment. Operators who want global
23
+ * writes promote the key to `admin` or use a Custom key with explicit
24
+ * `globalScopes`. README and CHANGELOG call out the asymmetry.
25
25
  */ export const PRESET_GLOBAL_ACTIONS = {
26
26
  'read-only': [
27
27
  'read'
@@ -60,28 +60,28 @@ export function buildRoutingTables(tools) {
60
60
  toolKind
61
61
  };
62
62
  }
63
- /**
64
- * Build a scope checker bound to a concrete tool list. The checker is a pure
65
- * function over (scopes, toolName, resource) — the routing tables are closed
66
- * over once at construction time.
67
- *
68
- * Fail-closed semantics:
69
- * - Null/undefined scopes grant full access (back-compat).
70
- * - When `scopes.collections` / `scopes.globals` is set, it is a *whitelist*
71
- * for that resource kind — unlisted resources are denied.
72
- * - When a tool resolves to a collection or global kind but the corresponding
73
- * scope map is undefined and `scopes.preset` is undefined, the call is
74
- * denied (closes the `tools.allow`-only latent fail-open).
75
- * - Account-level tools are gated by the preset's action list, if a preset
76
- * is set. Without a preset, a key scoped to specific collections/globals
77
- * cannot use account-level tools — they'd broaden the surface.
63
+ /**
64
+ * Build a scope checker bound to a concrete tool list. The checker is a pure
65
+ * function over (scopes, toolName, resource) — the routing tables are closed
66
+ * over once at construction time.
67
+ *
68
+ * Fail-closed semantics:
69
+ * - Null/undefined scopes grant full access (back-compat).
70
+ * - When `scopes.collections` / `scopes.globals` is set, it is a *whitelist*
71
+ * for that resource kind — unlisted resources are denied.
72
+ * - When a tool resolves to a collection or global kind but the corresponding
73
+ * scope map is undefined and `scopes.preset` is undefined, the call is
74
+ * denied (closes the `tools.allow`-only latent fail-open).
75
+ * - Account-level tools are gated by the preset's action list, if a preset
76
+ * is set. Without a preset, a key scoped to specific collections/globals
77
+ * cannot use account-level tools — they'd broaden the surface.
78
78
  */ export function buildScopeChecker(tools) {
79
79
  const tables = buildRoutingTables(tools);
80
80
  return (scopes, toolName, resource)=>assertScopeAllows(scopes, toolName, resource, tables);
81
81
  }
82
- /**
83
- * Internal pure checker. Exposed for the per-request wrapper in the registry
84
- * so it can re-use the same `RoutingTables` it built once at startup.
82
+ /**
83
+ * Internal pure checker. Exposed for the per-request wrapper in the registry
84
+ * so it can re-use the same `RoutingTables` it built once at startup.
85
85
  */ export function assertScopeAllows(scopes, toolName, resource, tables) {
86
86
  const resourceKind = tables.toolKind.get(toolName) ?? null;
87
87
  // Unregistered tool — fail-closed at request time. Adding a tool without a
@@ -140,9 +140,15 @@ function checkResource(scopes, toolName, resource, toolAction, policy) {
140
140
  const presetActions = scopes.preset ? policy.presetActions[scopes.preset] : undefined;
141
141
  const resourceScope = scopes[policy.scopeAxis];
142
142
  if (!resource) {
143
- // Resource-keyed tool called without a slug; defer to schema validation.
143
+ // Fail-closed. Every built-in collection/global tool takes a required
144
+ // `collection` / `slug` argument, so this only fires for a malformed call
145
+ // or for a host tool that declared resource routing without a resource
146
+ // argument. Allowing it would let such a tool read a hard-coded collection
147
+ // straight past the key's whitelist. A tool that genuinely spans the whole
148
+ // install belongs on `routing.kind: 'account'`.
144
149
  return {
145
- allowed: true
150
+ allowed: false,
151
+ reason: `Tool "${toolName}" is routed to a ${policy.label} but the call carries no ` + `${policy.label} argument, so its scope cannot be checked. A tool with a fixed ` + `or install-wide target must use routing.kind: 'account'.`
146
152
  };
147
153
  }
148
154
  if (!action) return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/scope/policy.ts"],"sourcesContent":["import type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from '../types'\r\n\r\n// ─── Routing primitives ──────────────────────────────────────────────\r\n\r\nexport type ResourceKind = 'collection' | 'global' | 'account'\r\n\r\n/**\r\n * Discriminated routing tag attached to every tool factory output.\r\n *\r\n * Collocates the scope-routing decision with the tool definition itself —\r\n * the registry derives the collection/global/account lookups from `tools`\r\n * at boot. Adding a new tool can no longer drift the routing maps out of\r\n * sync because TS requires `routing` on every factory return.\r\n */\r\nexport type ToolRouting =\r\n | { kind: 'collection'; action: CollectionAction }\r\n | { kind: 'global'; action: GlobalAction }\r\n | { kind: 'account'; action: CollectionAction }\r\n\r\n/**\r\n * Minimal \"routable tool\" interface used by the policy module. The full\r\n * `ToolFactoryOutput` shape (handler, parameters, description) is irrelevant\r\n * here; we only need `name` + `routing` to build the lookup tables.\r\n */\r\nexport interface RoutableTool {\r\n name: string\r\n routing: ToolRouting\r\n}\r\n\r\n// ─── Per-preset action tables ────────────────────────────────────────\r\n\r\nconst ALL_ACTIONS: CollectionAction[] = ['read', 'create', 'update', 'delete']\r\n\r\nexport const PRESET_ACTIONS: Record<ScopePreset, CollectionAction[]> = {\r\n 'read-only': ['read'],\r\n editor: ['read', 'create', 'update'],\r\n admin: ALL_ACTIONS,\r\n}\r\n\r\n/**\r\n * Asymmetric per-preset action map for globals. `editor` is intentionally\r\n * read-only on globals — a single bad write on a singleton broadcasts\r\n * site-wide with no per-document containment. Operators who want global\r\n * writes promote the key to `admin` or use a Custom key with explicit\r\n * `globalScopes`. README and CHANGELOG call out the asymmetry.\r\n */\r\nexport const PRESET_GLOBAL_ACTIONS: Record<ScopePreset, GlobalAction[]> = {\r\n 'read-only': ['read'],\r\n editor: ['read'],\r\n admin: ['read', 'update'],\r\n}\r\n\r\nexport const PRESET_TOOL_DENY: Record<ScopePreset, string[]> = {\r\n 'read-only': [],\r\n editor: ['safeDelete', 'deleteDocument'],\r\n admin: [],\r\n}\r\n\r\n// ─── Routing tables built from the tool list ─────────────────────────\r\n\r\nexport interface ScopeDecision {\r\n allowed: boolean\r\n reason?: string\r\n}\r\n\r\nexport interface RoutingTables {\r\n collectionToolAction: ReadonlyMap<string, CollectionAction>\r\n globalToolAction: ReadonlyMap<string, GlobalAction>\r\n accountToolAction: ReadonlyMap<string, CollectionAction>\r\n toolKind: ReadonlyMap<string, ResourceKind>\r\n}\r\n\r\nexport function buildRoutingTables(tools: RoutableTool[]): RoutingTables {\r\n const collectionToolAction = new Map<string, CollectionAction>()\r\n const globalToolAction = new Map<string, GlobalAction>()\r\n const accountToolAction = new Map<string, CollectionAction>()\r\n const toolKind = new Map<string, ResourceKind>()\r\n for (const t of tools) {\r\n toolKind.set(t.name, t.routing.kind)\r\n if (t.routing.kind === 'collection') collectionToolAction.set(t.name, t.routing.action)\r\n else if (t.routing.kind === 'global') globalToolAction.set(t.name, t.routing.action)\r\n else accountToolAction.set(t.name, t.routing.action)\r\n }\r\n return { collectionToolAction, globalToolAction, accountToolAction, toolKind }\r\n}\r\n\r\n// ─── Scope evaluation ────────────────────────────────────────────────\r\n\r\nexport type ScopeChecker = (\r\n scopes: KeyScopes | null | undefined,\r\n toolName: string,\r\n resource: string | undefined,\r\n) => ScopeDecision\r\n\r\n/**\r\n * Build a scope checker bound to a concrete tool list. The checker is a pure\r\n * function over (scopes, toolName, resource) — the routing tables are closed\r\n * over once at construction time.\r\n *\r\n * Fail-closed semantics:\r\n * - Null/undefined scopes grant full access (back-compat).\r\n * - When `scopes.collections` / `scopes.globals` is set, it is a *whitelist*\r\n * for that resource kind — unlisted resources are denied.\r\n * - When a tool resolves to a collection or global kind but the corresponding\r\n * scope map is undefined and `scopes.preset` is undefined, the call is\r\n * denied (closes the `tools.allow`-only latent fail-open).\r\n * - Account-level tools are gated by the preset's action list, if a preset\r\n * is set. Without a preset, a key scoped to specific collections/globals\r\n * cannot use account-level tools — they'd broaden the surface.\r\n */\r\nexport function buildScopeChecker(tools: RoutableTool[]): ScopeChecker {\r\n const tables = buildRoutingTables(tools)\r\n return (scopes, toolName, resource) => assertScopeAllows(scopes, toolName, resource, tables)\r\n}\r\n\r\n/**\r\n * Internal pure checker. Exposed for the per-request wrapper in the registry\r\n * so it can re-use the same `RoutingTables` it built once at startup.\r\n */\r\nexport function assertScopeAllows(\r\n scopes: KeyScopes | null | undefined,\r\n toolName: string,\r\n resource: string | undefined,\r\n tables: RoutingTables,\r\n): ScopeDecision {\r\n const resourceKind = tables.toolKind.get(toolName) ?? null\r\n // Unregistered tool — fail-closed at request time. Adding a tool without a\r\n // routing field is a TS error at the factory return site, so this branch\r\n // only fires for typo'd tool names sent by the client.\r\n if (resourceKind === null) {\r\n return {\r\n allowed: false,\r\n reason: `Tool \"${toolName}\" has no registered scope mapping.`,\r\n }\r\n }\r\n\r\n if (!scopes || (scopes.preset === undefined && !scopes.collections && !scopes.globals && !scopes.tools)) {\r\n return { allowed: true }\r\n }\r\n\r\n if (scopes.tools?.deny?.includes(toolName)) {\r\n return { allowed: false, reason: `Tool \"${toolName}\" is denied for this API key.` }\r\n }\r\n if (scopes.tools?.allow && !scopes.tools.allow.includes(toolName)) {\r\n return {\r\n allowed: false,\r\n reason: `Tool \"${toolName}\" is not in the allow-list for this API key.`,\r\n }\r\n }\r\n\r\n if (scopes.preset && PRESET_TOOL_DENY[scopes.preset]?.includes(toolName)) {\r\n return {\r\n allowed: false,\r\n reason: `Tool \"${toolName}\" is not allowed by the \"${scopes.preset}\" preset.`,\r\n }\r\n }\r\n\r\n if (resourceKind === 'account') {\r\n return checkAccount(scopes, toolName, tables.accountToolAction)\r\n }\r\n const policy = resourceKind === 'collection' ? COLLECTION_POLICY : GLOBAL_POLICY\r\n const toolAction =\r\n resourceKind === 'collection' ? tables.collectionToolAction : tables.globalToolAction\r\n return checkResource(scopes, toolName, resource, toolAction, policy)\r\n}\r\n\r\n/**\r\n * Per-resource-kind policy. Collapses what used to be two near-identical\r\n * `checkCollection` / `checkGlobal` helpers — the only differences are\r\n * the preset-actions table, the label, and which axis of `KeyScopes` to\r\n * read for explicit overrides.\r\n */\r\ninterface ResourcePolicy {\r\n presetActions: Record<ScopePreset, readonly string[]>\r\n scopeAxis: 'collections' | 'globals'\r\n label: 'collection' | 'global'\r\n Label: 'Collection' | 'Global'\r\n}\r\n\r\nconst COLLECTION_POLICY: ResourcePolicy = {\r\n presetActions: PRESET_ACTIONS,\r\n scopeAxis: 'collections',\r\n label: 'collection',\r\n Label: 'Collection',\r\n}\r\n\r\nconst GLOBAL_POLICY: ResourcePolicy = {\r\n presetActions: PRESET_GLOBAL_ACTIONS,\r\n scopeAxis: 'globals',\r\n label: 'global',\r\n Label: 'Global',\r\n}\r\n\r\nfunction checkResource(\r\n scopes: KeyScopes,\r\n toolName: string,\r\n resource: string | undefined,\r\n toolAction: ReadonlyMap<string, string>,\r\n policy: ResourcePolicy,\r\n): ScopeDecision {\r\n const action = toolAction.get(toolName)\r\n const presetActions = scopes.preset ? policy.presetActions[scopes.preset] : undefined\r\n const resourceScope = scopes[policy.scopeAxis]\r\n\r\n if (!resource) {\r\n // Resource-keyed tool called without a slug; defer to schema validation.\r\n return { allowed: true }\r\n }\r\n if (!action) return { allowed: true }\r\n\r\n if (resourceScope) {\r\n const override = resourceScope[resource]\r\n if (!override) {\r\n return {\r\n allowed: false,\r\n reason: `${policy.Label} \"${resource}\" is not in this API key's allowed ${policy.scopeAxis}.`,\r\n }\r\n }\r\n if (!override.includes(action as never)) {\r\n return {\r\n allowed: false,\r\n reason: `Action \"${action}\" on ${policy.label} \"${resource}\" is not permitted by this API key's scope.`,\r\n }\r\n }\r\n return { allowed: true }\r\n }\r\n\r\n if (!presetActions) {\r\n // Fail-closed: `tools.allow` without a resource map or preset would\r\n // otherwise broadcast the tool across every resource. Require explicit\r\n // intent.\r\n return {\r\n allowed: false,\r\n reason: `Tool \"${toolName}\" requires an explicit ${policy.label} scope or preset on this API key.`,\r\n }\r\n }\r\n\r\n if (!presetActions.includes(action)) {\r\n return {\r\n allowed: false,\r\n reason: `Action \"${action}\" on ${policy.label} \"${resource}\" is not permitted by this API key's preset.`,\r\n }\r\n }\r\n return { allowed: true }\r\n}\r\n\r\nfunction checkAccount(\r\n scopes: KeyScopes,\r\n toolName: string,\r\n toolAction: ReadonlyMap<string, CollectionAction>,\r\n): ScopeDecision {\r\n const action = toolAction.get(toolName)\r\n const presetActions = scopes.preset ? PRESET_ACTIONS[scopes.preset] : undefined\r\n\r\n // Explicit resource override is the tightest signal: an account-level tool\r\n // operates across the whole site (searchContent across every collection,\r\n // uploadMedia into any media coll, etc.) and would broaden the key beyond\r\n // the resource whitelist regardless of which preset is set. Deny account\r\n // tools whenever the key carries explicit collection/global scopes.\r\n if (scopes.collections || scopes.globals) {\r\n return {\r\n allowed: false,\r\n reason: `Tool \"${toolName}\" is denied for keys with explicit collection or global scopes — account-level tools would broaden access beyond the whitelist.`,\r\n }\r\n }\r\n\r\n if (presetActions) {\r\n if (action && !presetActions.includes(action)) {\r\n return {\r\n allowed: false,\r\n reason: `Action \"${action}\" is not permitted by this API key's preset.`,\r\n }\r\n }\r\n return { allowed: true }\r\n }\r\n\r\n return { allowed: true }\r\n}\r\n"],"names":["ALL_ACTIONS","PRESET_ACTIONS","editor","admin","PRESET_GLOBAL_ACTIONS","PRESET_TOOL_DENY","buildRoutingTables","tools","collectionToolAction","Map","globalToolAction","accountToolAction","toolKind","t","set","name","routing","kind","action","buildScopeChecker","tables","scopes","toolName","resource","assertScopeAllows","resourceKind","get","allowed","reason","preset","undefined","collections","globals","deny","includes","allow","checkAccount","policy","COLLECTION_POLICY","GLOBAL_POLICY","toolAction","checkResource","presetActions","scopeAxis","label","Label","resourceScope","override"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/scope/policy.ts"],"sourcesContent":["import type { CollectionAction, GlobalAction, KeyScopes, ScopePreset } from '../types'\n\n// ─── Routing primitives ──────────────────────────────────────────────\n\nexport type ResourceKind = 'collection' | 'global' | 'account'\n\n/**\n * Discriminated routing tag attached to every tool factory output.\n *\n * Collocates the scope-routing decision with the tool definition itself —\n * the registry derives the collection/global/account lookups from `tools`\n * at boot. Adding a new tool can no longer drift the routing maps out of\n * sync because TS requires `routing` on every factory return.\n */\nexport type ToolRouting =\n | { kind: 'collection'; action: CollectionAction }\n | { kind: 'global'; action: GlobalAction }\n | { kind: 'account'; action: CollectionAction }\n\n/**\n * Minimal \"routable tool\" interface used by the policy module. The full\n * `ToolFactoryOutput` shape (handler, parameters, description) is irrelevant\n * here; we only need `name` + `routing` to build the lookup tables.\n */\nexport interface RoutableTool {\n name: string\n routing: ToolRouting\n}\n\n// ─── Per-preset action tables ────────────────────────────────────────\n\nconst ALL_ACTIONS: CollectionAction[] = ['read', 'create', 'update', 'delete']\n\nexport const PRESET_ACTIONS: Record<ScopePreset, CollectionAction[]> = {\n 'read-only': ['read'],\n editor: ['read', 'create', 'update'],\n admin: ALL_ACTIONS,\n}\n\n/**\n * Asymmetric per-preset action map for globals. `editor` is intentionally\n * read-only on globals — a single bad write on a singleton broadcasts\n * site-wide with no per-document containment. Operators who want global\n * writes promote the key to `admin` or use a Custom key with explicit\n * `globalScopes`. README and CHANGELOG call out the asymmetry.\n */\nexport const PRESET_GLOBAL_ACTIONS: Record<ScopePreset, GlobalAction[]> = {\n 'read-only': ['read'],\n editor: ['read'],\n admin: ['read', 'update'],\n}\n\nexport const PRESET_TOOL_DENY: Record<ScopePreset, string[]> = {\n 'read-only': [],\n editor: ['safeDelete', 'deleteDocument'],\n admin: [],\n}\n\n// ─── Routing tables built from the tool list ─────────────────────────\n\nexport interface ScopeDecision {\n allowed: boolean\n reason?: string\n}\n\nexport interface RoutingTables {\n collectionToolAction: ReadonlyMap<string, CollectionAction>\n globalToolAction: ReadonlyMap<string, GlobalAction>\n accountToolAction: ReadonlyMap<string, CollectionAction>\n toolKind: ReadonlyMap<string, ResourceKind>\n}\n\nexport function buildRoutingTables(tools: RoutableTool[]): RoutingTables {\n const collectionToolAction = new Map<string, CollectionAction>()\n const globalToolAction = new Map<string, GlobalAction>()\n const accountToolAction = new Map<string, CollectionAction>()\n const toolKind = new Map<string, ResourceKind>()\n for (const t of tools) {\n toolKind.set(t.name, t.routing.kind)\n if (t.routing.kind === 'collection') collectionToolAction.set(t.name, t.routing.action)\n else if (t.routing.kind === 'global') globalToolAction.set(t.name, t.routing.action)\n else accountToolAction.set(t.name, t.routing.action)\n }\n return { collectionToolAction, globalToolAction, accountToolAction, toolKind }\n}\n\n// ─── Scope evaluation ────────────────────────────────────────────────\n\nexport type ScopeChecker = (\n scopes: KeyScopes | null | undefined,\n toolName: string,\n resource: string | undefined,\n) => ScopeDecision\n\n/**\n * Build a scope checker bound to a concrete tool list. The checker is a pure\n * function over (scopes, toolName, resource) — the routing tables are closed\n * over once at construction time.\n *\n * Fail-closed semantics:\n * - Null/undefined scopes grant full access (back-compat).\n * - When `scopes.collections` / `scopes.globals` is set, it is a *whitelist*\n * for that resource kind — unlisted resources are denied.\n * - When a tool resolves to a collection or global kind but the corresponding\n * scope map is undefined and `scopes.preset` is undefined, the call is\n * denied (closes the `tools.allow`-only latent fail-open).\n * - Account-level tools are gated by the preset's action list, if a preset\n * is set. Without a preset, a key scoped to specific collections/globals\n * cannot use account-level tools — they'd broaden the surface.\n */\nexport function buildScopeChecker(tools: RoutableTool[]): ScopeChecker {\n const tables = buildRoutingTables(tools)\n return (scopes, toolName, resource) => assertScopeAllows(scopes, toolName, resource, tables)\n}\n\n/**\n * Internal pure checker. Exposed for the per-request wrapper in the registry\n * so it can re-use the same `RoutingTables` it built once at startup.\n */\nexport function assertScopeAllows(\n scopes: KeyScopes | null | undefined,\n toolName: string,\n resource: string | undefined,\n tables: RoutingTables,\n): ScopeDecision {\n const resourceKind = tables.toolKind.get(toolName) ?? null\n // Unregistered tool — fail-closed at request time. Adding a tool without a\n // routing field is a TS error at the factory return site, so this branch\n // only fires for typo'd tool names sent by the client.\n if (resourceKind === null) {\n return {\n allowed: false,\n reason: `Tool \"${toolName}\" has no registered scope mapping.`,\n }\n }\n\n if (!scopes || (scopes.preset === undefined && !scopes.collections && !scopes.globals && !scopes.tools)) {\n return { allowed: true }\n }\n\n if (scopes.tools?.deny?.includes(toolName)) {\n return { allowed: false, reason: `Tool \"${toolName}\" is denied for this API key.` }\n }\n if (scopes.tools?.allow && !scopes.tools.allow.includes(toolName)) {\n return {\n allowed: false,\n reason: `Tool \"${toolName}\" is not in the allow-list for this API key.`,\n }\n }\n\n if (scopes.preset && PRESET_TOOL_DENY[scopes.preset]?.includes(toolName)) {\n return {\n allowed: false,\n reason: `Tool \"${toolName}\" is not allowed by the \"${scopes.preset}\" preset.`,\n }\n }\n\n if (resourceKind === 'account') {\n return checkAccount(scopes, toolName, tables.accountToolAction)\n }\n const policy = resourceKind === 'collection' ? COLLECTION_POLICY : GLOBAL_POLICY\n const toolAction =\n resourceKind === 'collection' ? tables.collectionToolAction : tables.globalToolAction\n return checkResource(scopes, toolName, resource, toolAction, policy)\n}\n\n/**\n * Per-resource-kind policy. Collapses what used to be two near-identical\n * `checkCollection` / `checkGlobal` helpers — the only differences are\n * the preset-actions table, the label, and which axis of `KeyScopes` to\n * read for explicit overrides.\n */\ninterface ResourcePolicy {\n presetActions: Record<ScopePreset, readonly string[]>\n scopeAxis: 'collections' | 'globals'\n label: 'collection' | 'global'\n Label: 'Collection' | 'Global'\n}\n\nconst COLLECTION_POLICY: ResourcePolicy = {\n presetActions: PRESET_ACTIONS,\n scopeAxis: 'collections',\n label: 'collection',\n Label: 'Collection',\n}\n\nconst GLOBAL_POLICY: ResourcePolicy = {\n presetActions: PRESET_GLOBAL_ACTIONS,\n scopeAxis: 'globals',\n label: 'global',\n Label: 'Global',\n}\n\nfunction checkResource(\n scopes: KeyScopes,\n toolName: string,\n resource: string | undefined,\n toolAction: ReadonlyMap<string, string>,\n policy: ResourcePolicy,\n): ScopeDecision {\n const action = toolAction.get(toolName)\n const presetActions = scopes.preset ? policy.presetActions[scopes.preset] : undefined\n const resourceScope = scopes[policy.scopeAxis]\n\n if (!resource) {\n // Fail-closed. Every built-in collection/global tool takes a required\n // `collection` / `slug` argument, so this only fires for a malformed call\n // or for a host tool that declared resource routing without a resource\n // argument. Allowing it would let such a tool read a hard-coded collection\n // straight past the key's whitelist. A tool that genuinely spans the whole\n // install belongs on `routing.kind: 'account'`.\n return {\n allowed: false,\n reason:\n `Tool \"${toolName}\" is routed to a ${policy.label} but the call carries no ` +\n `${policy.label} argument, so its scope cannot be checked. A tool with a fixed ` +\n `or install-wide target must use routing.kind: 'account'.`,\n }\n }\n if (!action) return { allowed: true }\n\n if (resourceScope) {\n const override = resourceScope[resource]\n if (!override) {\n return {\n allowed: false,\n reason: `${policy.Label} \"${resource}\" is not in this API key's allowed ${policy.scopeAxis}.`,\n }\n }\n if (!override.includes(action as never)) {\n return {\n allowed: false,\n reason: `Action \"${action}\" on ${policy.label} \"${resource}\" is not permitted by this API key's scope.`,\n }\n }\n return { allowed: true }\n }\n\n if (!presetActions) {\n // Fail-closed: `tools.allow` without a resource map or preset would\n // otherwise broadcast the tool across every resource. Require explicit\n // intent.\n return {\n allowed: false,\n reason: `Tool \"${toolName}\" requires an explicit ${policy.label} scope or preset on this API key.`,\n }\n }\n\n if (!presetActions.includes(action)) {\n return {\n allowed: false,\n reason: `Action \"${action}\" on ${policy.label} \"${resource}\" is not permitted by this API key's preset.`,\n }\n }\n return { allowed: true }\n}\n\nfunction checkAccount(\n scopes: KeyScopes,\n toolName: string,\n toolAction: ReadonlyMap<string, CollectionAction>,\n): ScopeDecision {\n const action = toolAction.get(toolName)\n const presetActions = scopes.preset ? PRESET_ACTIONS[scopes.preset] : undefined\n\n // Explicit resource override is the tightest signal: an account-level tool\n // operates across the whole site (searchContent across every collection,\n // uploadMedia into any media coll, etc.) and would broaden the key beyond\n // the resource whitelist regardless of which preset is set. Deny account\n // tools whenever the key carries explicit collection/global scopes.\n if (scopes.collections || scopes.globals) {\n return {\n allowed: false,\n reason: `Tool \"${toolName}\" is denied for keys with explicit collection or global scopes — account-level tools would broaden access beyond the whitelist.`,\n }\n }\n\n if (presetActions) {\n if (action && !presetActions.includes(action)) {\n return {\n allowed: false,\n reason: `Action \"${action}\" is not permitted by this API key's preset.`,\n }\n }\n return { allowed: true }\n }\n\n return { allowed: true }\n}\n"],"names":["ALL_ACTIONS","PRESET_ACTIONS","editor","admin","PRESET_GLOBAL_ACTIONS","PRESET_TOOL_DENY","buildRoutingTables","tools","collectionToolAction","Map","globalToolAction","accountToolAction","toolKind","t","set","name","routing","kind","action","buildScopeChecker","tables","scopes","toolName","resource","assertScopeAllows","resourceKind","get","allowed","reason","preset","undefined","collections","globals","deny","includes","allow","checkAccount","policy","COLLECTION_POLICY","GLOBAL_POLICY","toolAction","checkResource","presetActions","scopeAxis","label","Label","resourceScope","override"],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ToolFactoryOutput } from './registry';
1
2
  /**
2
3
  * payload-mcp-toolkit configuration.
3
4
  *
@@ -62,6 +63,43 @@ export interface ContentToolkitOptions {
62
63
  /** Media collection slug (default: 'media') */
63
64
  collectionSlug?: string;
64
65
  };
66
+ /**
67
+ * Extra tools to register alongside the built-in ones.
68
+ *
69
+ * Each entry is a plain `ToolFactoryOutput`: a name, a description, a Zod
70
+ * shape (or `z.object({...})`), a handler, and a `routing` tag. Custom tools
71
+ * go through the same wrapper as the built-ins — scope checks, `req.context
72
+ * .source = 'mcp'` stamping, and the audit log all apply — and their names
73
+ * appear in the API-key scope dropdowns.
74
+ *
75
+ * The handler receives the live `PayloadRequest`, so a tool that needs the
76
+ * authenticated user or the Payload instance reads them off `req` per call
77
+ * rather than closing over them at boot.
78
+ *
79
+ * `routing` decides which scope axis gates the tool. Use
80
+ * `{kind: 'collection', action: 'read'}` for a tool whose args carry a
81
+ * `collection` (or `slug`) key — the registry reads that key to find the
82
+ * target for the scope check.
83
+ *
84
+ * A custom tool may not reuse a built-in tool's name; the plugin throws at
85
+ * boot if one does.
86
+ *
87
+ * ```ts
88
+ * mcpToolkitPlugin({
89
+ * customTools: [{
90
+ * name: 'countActiveMembers',
91
+ * description: 'Number of members with an active membership.',
92
+ * parameters: { since: z.string().optional() },
93
+ * routing: { kind: 'collection', action: 'read' },
94
+ * handler: async (args, req) => {
95
+ * const { totalDocs } = await req.payload.count({ collection: 'memberships' })
96
+ * return { content: [{ type: 'text', text: String(totalDocs) }] }
97
+ * },
98
+ * }],
99
+ * })
100
+ * ```
101
+ */
102
+ customTools?: ToolFactoryOutput[];
65
103
  /**
66
104
  * MCP transport / auth configuration. Mostly safe to leave unset;
67
105
  * defaults to no-CORS server-to-server use only.
package/dist/types.js CHANGED
@@ -1,15 +1,10 @@
1
- /**
2
- * payload-mcp-toolkit configuration.
3
- *
4
- * The plugin works with zero options every field below is an escape hatch
5
- * for the cases where Payload's own config doesn't carry enough signal.
6
- */ /**
7
- * Runtime scope shape consumed by `registry.assertScopeAllows`.
8
- *
9
- * - `collections` / `globals` are whitelists when present: a resource not
10
- * listed there is denied for this key.
11
- * - `tools.allow` / `tools.deny` are per-tool overrides that take precedence
12
- * over the preset / resource maps.
1
+ /**
2
+ * Runtime scope shape consumed by `registry.assertScopeAllows`.
3
+ *
4
+ * - `collections` / `globals` are whitelists when present: a resource not
5
+ * listed there is denied for this key.
6
+ * - `tools.allow` / `tools.deny` are per-tool overrides that take precedence
7
+ * over the preset / resource maps.
13
8
  */ export { };
14
9
 
15
10
  //# sourceMappingURL=types.js.map
package/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/types.ts"],"sourcesContent":["/**\r\n * payload-mcp-toolkit configuration.\r\n *\r\n * The plugin works with zero options — every field below is an escape hatch\r\n * for the cases where Payload's own config doesn't carry enough signal.\r\n */\r\nexport interface ContentToolkitOptions {\r\n /**\r\n * Preview URL behavior. The toolkit reads `collection.admin.livePreview.url`\r\n * (or `collection.admin.preview` as a fallback) when generating preview links\r\n * for draft documents. Provide this object only to override what Payload\r\n * already knows.\r\n */\r\n preview?: {\r\n /**\r\n * Absolute base URL prepended to relative preview paths. Defaults to\r\n * `incomingConfig.serverURL`, then `process.env.NEXT_PUBLIC_SERVER_URL`,\r\n * then `process.env.SITE_URL`. If none of those resolve and your preview\r\n * URL function returns a relative path, no preview URL is appended.\r\n */\r\n siteUrl?: string\r\n\r\n /**\r\n * Disable preview URL injection entirely.\r\n */\r\n disabled?: boolean\r\n }\r\n\r\n /**\r\n * Per-collection draft behavior overrides. The default behavior is inferred\r\n * from each collection's `versions.drafts` setting:\r\n * - drafts enabled → `'always-draft'` (raw `update` is locked; clients go\r\n * through `publishDraft` / `patchLayout` / `updateDocument` which preserve\r\n * draft semantics)\r\n * - drafts disabled → `'always-publish'`\r\n *\r\n * Override per slug only if you need to allow raw publish on a draftable\r\n * collection.\r\n */\r\n draftBehavior?: Record<string, 'always-draft' | 'always-publish'>\r\n\r\n /**\r\n * Override the auth collection used for API key linkage. By default the\r\n * toolkit scans `incomingConfig.collections` for the first collection with\r\n * `auth: true`, preferring one named `'users'`.\r\n */\r\n userCollection?: string\r\n\r\n /**\r\n * Hide collections or globals from the MCP surface. Useful for internal\r\n * bookkeeping collections that should not be exposed to AI clients.\r\n */\r\n exclude?: {\r\n collections?: string[]\r\n globals?: string[]\r\n }\r\n\r\n /**\r\n * Site-specific domain prompts that teach the AI business vocabulary.\r\n * Merged with the auto-generated prompts.\r\n */\r\n domainPrompts?: DomainPrompt[]\r\n\r\n /** Media upload configuration */\r\n mediaUpload?: {\r\n /** Maximum file size in bytes (default: 10MB) */\r\n maxFileSize?: number\r\n /** Media collection slug (default: 'media') */\r\n collectionSlug?: string\r\n }\r\n\r\n /**\r\n * MCP transport / auth configuration. Mostly safe to leave unset;\r\n * defaults to no-CORS server-to-server use only.\r\n */\r\n auth?: {\r\n /**\r\n * Origins permitted on the `Origin` header. Empty / unset means\r\n * server-to-server callers only (no browser-based MCP clients).\r\n * `*` is intentionally not honoured.\r\n */\r\n allowedOrigins?: string[]\r\n }\r\n\r\n /**\r\n * Override API-key collection settings. Slug defaults to\r\n * `payload-mcp-api-keys` for zero-touch upgrade compatibility with\r\n * `@payloadcms/plugin-mcp` v0.3.x rows.\r\n */\r\n apiKeyCollection?: {\r\n slug?: string\r\n /**\r\n * Override the user collection that API keys link to. By default\r\n * the toolkit reuses the same `userCollection` resolution as elsewhere\r\n * (`options.userCollection`, then `incomingConfig.admin.user`).\r\n */\r\n userCollection?: string\r\n }\r\n}\r\n\r\n/** A domain prompt that teaches the AI site-specific vocabulary */\r\nexport interface DomainPrompt {\r\n /** Unique name for the prompt */\r\n name: string\r\n /** Display title */\r\n title: string\r\n /** Description of what this prompt teaches */\r\n description: string\r\n /** The prompt content */\r\n content: string\r\n}\r\n\r\n/** Introspected field metadata */\r\nexport interface FieldSchema {\r\n name: string\r\n type: string\r\n required?: boolean\r\n hasMany?: boolean\r\n relationTo?: string | string[]\r\n options?: Array<{ label: string; value: string }>\r\n fields?: FieldSchema[]\r\n maxRows?: number\r\n}\r\n\r\n/** Introspected collection metadata */\r\nexport interface CollectionSchema {\r\n slug: string\r\n fields: FieldSchema[]\r\n hasDrafts: boolean\r\n hasLivePreview: boolean\r\n relationships: Array<{ fieldName: string; relationTo: string | string[]; hasMany: boolean }>\r\n searchableFields: string[]\r\n}\r\n\r\n/** Introspected global metadata. Globals are singletons — no relationships or searchable-fields graph. */\r\nexport interface GlobalSchema {\r\n slug: string\r\n fields: FieldSchema[]\r\n hasDrafts: boolean\r\n hasLivePreview: boolean\r\n}\r\n\r\n/**\r\n * One block in the catalog. Flat — no section/leaf distinction. Whether a\r\n * block can nest other blocks is encoded in the `BlockNestingMap` keyed by\r\n * the path to its `blocks` field.\r\n */\r\nexport interface BlockSchema {\r\n slug: string\r\n fields: FieldSchema[]\r\n}\r\n\r\n/**\r\n * Flat catalog of every block referenced by the schema.\r\n */\r\nexport interface BlockCatalog {\r\n blocks: BlockSchema[]\r\n}\r\n\r\n/**\r\n * One entry per `blocks`-typed field anywhere in the schema.\r\n *\r\n * `path` is `<owner>.<dottedFieldPath>` where owner is the collection or\r\n * block slug that contains the field. Values list the slugs that field\r\n * accepts. The AI uses this to compose blocks at any nesting depth without\r\n * us pre-classifying anything as a \"section\" or \"leaf\".\r\n */\r\nexport interface BlockNestingEdge {\r\n /** Owner of the blocks field — a collection slug, a block slug, or a global slug. */\r\n owner: string\r\n /** Whether the owner is a collection, a block, or a global */\r\n ownerType: 'collection' | 'block' | 'global'\r\n /** Dotted path to the blocks field within the owner (e.g. `layout`, `hero.content`) */\r\n fieldPath: string\r\n /** Block slugs that this field accepts */\r\n acceptedBlockSlugs: string[]\r\n /** Optional row cap from the field config */\r\n maxRows?: number\r\n}\r\n\r\n/** Map of every blocks-field in the schema to the slugs it accepts */\r\nexport type BlockNestingMap = BlockNestingEdge[]\r\n\r\n/** Relationship edge in the collection graph */\r\nexport interface RelationshipEdge {\r\n fromCollection: string\r\n fieldName: string\r\n toCollection: string | string[]\r\n hasMany: boolean\r\n}\r\n\r\n// ─── Scope shapes ─────────────────────────────────────────────────────\r\n//\r\n// Canonical scope types live here so the auth strategy, registry, and admin\r\n// API-keys collection all import from the same surface. Globals support\r\n// only `read` / `update` — they don't have `create` / `delete` semantics.\r\n\r\nexport type CollectionAction = 'read' | 'create' | 'update' | 'delete'\r\nexport type GlobalAction = 'read' | 'update'\r\nexport type ScopePreset = 'read-only' | 'editor' | 'admin'\r\n\r\n/**\r\n * Runtime scope shape consumed by `registry.assertScopeAllows`.\r\n *\r\n * - `collections` / `globals` are whitelists when present: a resource not\r\n * listed there is denied for this key.\r\n * - `tools.allow` / `tools.deny` are per-tool overrides that take precedence\r\n * over the preset / resource maps.\r\n */\r\nexport interface KeyScopes {\r\n preset?: ScopePreset\r\n collections?: Record<string, CollectionAction[]>\r\n globals?: Record<string, GlobalAction[]>\r\n tools?: { allow?: string[]; deny?: string[] }\r\n}\r\n"],"names":[],"mappings":"AAAA;;;;;CAKC,GAoMD;;;;;;;CAOC,GACD,WAKC"}
1
+ {"version":3,"sources":["../src/types.ts"],"sourcesContent":["import type { ToolFactoryOutput } from './registry'\n\n/**\n * payload-mcp-toolkit configuration.\n *\n * The plugin works with zero options — every field below is an escape hatch\n * for the cases where Payload's own config doesn't carry enough signal.\n */\nexport interface ContentToolkitOptions {\n /**\n * Preview URL behavior. The toolkit reads `collection.admin.livePreview.url`\n * (or `collection.admin.preview` as a fallback) when generating preview links\n * for draft documents. Provide this object only to override what Payload\n * already knows.\n */\n preview?: {\n /**\n * Absolute base URL prepended to relative preview paths. Defaults to\n * `incomingConfig.serverURL`, then `process.env.NEXT_PUBLIC_SERVER_URL`,\n * then `process.env.SITE_URL`. If none of those resolve and your preview\n * URL function returns a relative path, no preview URL is appended.\n */\n siteUrl?: string\n\n /**\n * Disable preview URL injection entirely.\n */\n disabled?: boolean\n }\n\n /**\n * Per-collection draft behavior overrides. The default behavior is inferred\n * from each collection's `versions.drafts` setting:\n * - drafts enabled → `'always-draft'` (raw `update` is locked; clients go\n * through `publishDraft` / `patchLayout` / `updateDocument` which preserve\n * draft semantics)\n * - drafts disabled → `'always-publish'`\n *\n * Override per slug only if you need to allow raw publish on a draftable\n * collection.\n */\n draftBehavior?: Record<string, 'always-draft' | 'always-publish'>\n\n /**\n * Override the auth collection used for API key linkage. By default the\n * toolkit scans `incomingConfig.collections` for the first collection with\n * `auth: true`, preferring one named `'users'`.\n */\n userCollection?: string\n\n /**\n * Hide collections or globals from the MCP surface. Useful for internal\n * bookkeeping collections that should not be exposed to AI clients.\n */\n exclude?: {\n collections?: string[]\n globals?: string[]\n }\n\n /**\n * Site-specific domain prompts that teach the AI business vocabulary.\n * Merged with the auto-generated prompts.\n */\n domainPrompts?: DomainPrompt[]\n\n /** Media upload configuration */\n mediaUpload?: {\n /** Maximum file size in bytes (default: 10MB) */\n maxFileSize?: number\n /** Media collection slug (default: 'media') */\n collectionSlug?: string\n }\n\n /**\n * Extra tools to register alongside the built-in ones.\n *\n * Each entry is a plain `ToolFactoryOutput`: a name, a description, a Zod\n * shape (or `z.object({...})`), a handler, and a `routing` tag. Custom tools\n * go through the same wrapper as the built-ins — scope checks, `req.context\n * .source = 'mcp'` stamping, and the audit log all apply — and their names\n * appear in the API-key scope dropdowns.\n *\n * The handler receives the live `PayloadRequest`, so a tool that needs the\n * authenticated user or the Payload instance reads them off `req` per call\n * rather than closing over them at boot.\n *\n * `routing` decides which scope axis gates the tool. Use\n * `{kind: 'collection', action: 'read'}` for a tool whose args carry a\n * `collection` (or `slug`) key — the registry reads that key to find the\n * target for the scope check.\n *\n * A custom tool may not reuse a built-in tool's name; the plugin throws at\n * boot if one does.\n *\n * ```ts\n * mcpToolkitPlugin({\n * customTools: [{\n * name: 'countActiveMembers',\n * description: 'Number of members with an active membership.',\n * parameters: { since: z.string().optional() },\n * routing: { kind: 'collection', action: 'read' },\n * handler: async (args, req) => {\n * const { totalDocs } = await req.payload.count({ collection: 'memberships' })\n * return { content: [{ type: 'text', text: String(totalDocs) }] }\n * },\n * }],\n * })\n * ```\n */\n customTools?: ToolFactoryOutput[]\n\n /**\n * MCP transport / auth configuration. Mostly safe to leave unset;\n * defaults to no-CORS server-to-server use only.\n */\n auth?: {\n /**\n * Origins permitted on the `Origin` header. Empty / unset means\n * server-to-server callers only (no browser-based MCP clients).\n * `*` is intentionally not honoured.\n */\n allowedOrigins?: string[]\n }\n\n /**\n * Override API-key collection settings. Slug defaults to\n * `payload-mcp-api-keys` for zero-touch upgrade compatibility with\n * `@payloadcms/plugin-mcp` v0.3.x rows.\n */\n apiKeyCollection?: {\n slug?: string\n /**\n * Override the user collection that API keys link to. By default\n * the toolkit reuses the same `userCollection` resolution as elsewhere\n * (`options.userCollection`, then `incomingConfig.admin.user`).\n */\n userCollection?: string\n }\n}\n\n/** A domain prompt that teaches the AI site-specific vocabulary */\nexport interface DomainPrompt {\n /** Unique name for the prompt */\n name: string\n /** Display title */\n title: string\n /** Description of what this prompt teaches */\n description: string\n /** The prompt content */\n content: string\n}\n\n/** Introspected field metadata */\nexport interface FieldSchema {\n name: string\n type: string\n required?: boolean\n hasMany?: boolean\n relationTo?: string | string[]\n options?: Array<{ label: string; value: string }>\n fields?: FieldSchema[]\n maxRows?: number\n}\n\n/** Introspected collection metadata */\nexport interface CollectionSchema {\n slug: string\n fields: FieldSchema[]\n hasDrafts: boolean\n hasLivePreview: boolean\n relationships: Array<{ fieldName: string; relationTo: string | string[]; hasMany: boolean }>\n searchableFields: string[]\n}\n\n/** Introspected global metadata. Globals are singletons — no relationships or searchable-fields graph. */\nexport interface GlobalSchema {\n slug: string\n fields: FieldSchema[]\n hasDrafts: boolean\n hasLivePreview: boolean\n}\n\n/**\n * One block in the catalog. Flat — no section/leaf distinction. Whether a\n * block can nest other blocks is encoded in the `BlockNestingMap` keyed by\n * the path to its `blocks` field.\n */\nexport interface BlockSchema {\n slug: string\n fields: FieldSchema[]\n}\n\n/**\n * Flat catalog of every block referenced by the schema.\n */\nexport interface BlockCatalog {\n blocks: BlockSchema[]\n}\n\n/**\n * One entry per `blocks`-typed field anywhere in the schema.\n *\n * `path` is `<owner>.<dottedFieldPath>` where owner is the collection or\n * block slug that contains the field. Values list the slugs that field\n * accepts. The AI uses this to compose blocks at any nesting depth without\n * us pre-classifying anything as a \"section\" or \"leaf\".\n */\nexport interface BlockNestingEdge {\n /** Owner of the blocks field — a collection slug, a block slug, or a global slug. */\n owner: string\n /** Whether the owner is a collection, a block, or a global */\n ownerType: 'collection' | 'block' | 'global'\n /** Dotted path to the blocks field within the owner (e.g. `layout`, `hero.content`) */\n fieldPath: string\n /** Block slugs that this field accepts */\n acceptedBlockSlugs: string[]\n /** Optional row cap from the field config */\n maxRows?: number\n}\n\n/** Map of every blocks-field in the schema to the slugs it accepts */\nexport type BlockNestingMap = BlockNestingEdge[]\n\n/** Relationship edge in the collection graph */\nexport interface RelationshipEdge {\n fromCollection: string\n fieldName: string\n toCollection: string | string[]\n hasMany: boolean\n}\n\n// ─── Scope shapes ─────────────────────────────────────────────────────\n//\n// Canonical scope types live here so the auth strategy, registry, and admin\n// API-keys collection all import from the same surface. Globals support\n// only `read` / `update` — they don't have `create` / `delete` semantics.\n\nexport type CollectionAction = 'read' | 'create' | 'update' | 'delete'\nexport type GlobalAction = 'read' | 'update'\nexport type ScopePreset = 'read-only' | 'editor' | 'admin'\n\n/**\n * Runtime scope shape consumed by `registry.assertScopeAllows`.\n *\n * - `collections` / `globals` are whitelists when present: a resource not\n * listed there is denied for this key.\n * - `tools.allow` / `tools.deny` are per-tool overrides that take precedence\n * over the preset / resource maps.\n */\nexport interface KeyScopes {\n preset?: ScopePreset\n collections?: Record<string, CollectionAction[]>\n globals?: Record<string, GlobalAction[]>\n tools?: { allow?: string[]; deny?: string[] }\n}\n"],"names":[],"mappings":"AAiPA;;;;;;;CAOC,GACD,WAKC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-mcp-toolkit",
3
- "version": "0.7.5",
3
+ "version": "0.8.0",
4
4
  "description": "Standalone schema-aware MCP plugin for Payload CMS v3 — owns the /api/mcp endpoint, scoped API keys, draft workflow, and AI-friendly tools so non-technical editors can manage content via AI chat.",
5
5
  "license": "MIT",
6
6
  "author": "jon8800",
@@ -62,12 +62,12 @@
62
62
  "test:watch": "vitest"
63
63
  },
64
64
  "dependencies": {
65
- "@modelcontextprotocol/sdk": "^1.18.0",
65
+ "@modelcontextprotocol/sdk": "^1.23.0",
66
66
  "mcp-handler": "^1.1.0"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "payload": "^3.0.0",
70
- "zod": "^3.0.0"
70
+ "zod": "^3.25 || ^4"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@payloadcms/db-sqlite": "3.82.1",
@@ -94,7 +94,7 @@
94
94
  },
95
95
  "engines": {
96
96
  "node": "^18.20.2 || >=20.9.0",
97
- "pnpm": "^9 || ^10"
97
+ "pnpm": ">=9"
98
98
  },
99
99
  "pnpm": {
100
100
  "onlyBuiltDependencies": [