payload-mcp-toolkit 0.8.0 → 0.8.1

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/dist/api-keys.js CHANGED
@@ -152,6 +152,22 @@ const isCustomPreset = (data)=>!!data && typeof data === 'object' && data.preset
152
152
  disableLocalStrategy: true,
153
153
  useAPIKey: true
154
154
  },
155
+ // Every MCP request authenticates by looking up this column. Payload's
156
+ // `useAPIKey` adds `apiKeyIndex` but does not index it, so each call cost a
157
+ // sequential scan over the whole key table — and the table only grows.
158
+ // Declared here rather than added by hand in a host migration, so Payload's
159
+ // schema builder knows about it and never offers to drop it.
160
+ //
161
+ // Not unique: rows exist with a null `apiKeyIndex` (a key row saved before
162
+ // `enableAPIKey` is ticked), and a unique index would let only one of them
163
+ // exist at a time.
164
+ indexes: [
165
+ {
166
+ fields: [
167
+ 'apiKeyIndex'
168
+ ]
169
+ }
170
+ ],
155
171
  hooks: {
156
172
  beforeValidate: [
157
173
  ({ data, originalDoc })=>{
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/api-keys.ts"],"sourcesContent":["import type { CollectionBeforeValidateHook, CollectionConfig, Field } from 'payload'\n\nexport const API_KEYS_DEFAULT_SLUG = 'payload-mcp-api-keys'\n\nexport interface CreateApiKeysCollectionOptions {\n /**\n * Collection slug. Defaults to `payload-mcp-api-keys` for zero-touch\n * compatibility with rows created by `@payloadcms/plugin-mcp` v0.3.x.\n */\n slug?: string\n /**\n * Slug of the user collection that API keys link to. Required.\n */\n userCollection: string\n /**\n * Collection slugs offered to the collection-scopes matrix component.\n * Snapshotted at plugin-init time from the host Payload config; adding a\n * collection requires a dev-server restart for it to surface in the\n * admin UI.\n */\n availableCollections: string[]\n /**\n * Tool names offered as options for the `toolAllow` / `toolDeny` selects.\n * Sourced from the toolkit's registered tools at plugin init.\n */\n availableTools: string[]\n /**\n * Global slugs offered to the global-scopes matrix component. Optional —\n * direct callers of the factory that pre-date globals support continue\n * to work; sites with no globals get an empty array and the second\n * matrix table is not rendered.\n */\n availableGlobals?: string[]\n}\n\nconst PRESET_OPTIONS = [\n { label: 'Read-only', value: 'read-only' },\n { label: 'Editor (read + create + update)', value: 'editor' },\n { label: 'Admin (all actions)', value: 'admin' },\n { label: 'Custom (use overrides below)', value: 'custom' },\n] as const\n\nconst isCustomPreset = (data: unknown): boolean =>\n !!data && typeof data === 'object' && (data as { preset?: unknown }).preset === 'custom'\n\n/**\n * Builds the `payload-mcp-api-keys` collection used by the v0.4 standalone\n * plugin. Reuses Payload's built-in `useAPIKey: true` so the underlying\n * `apiKey` / `apiKeyIndex` columns match what `@payloadcms/plugin-mcp`\n * v0.3.x wrote — existing rows authenticate without re-issue.\n *\n * Layout:\n * - Main column: name, description, preset, scopes matrix (custom only),\n * tools collapsible (custom only).\n * - Sidebar: user relationship, key prefix, expiresAt, revokedAt,\n * lastUsedAt — identity + lifecycle metadata kept out of the\n * scope-editing flow.\n */\nexport function createApiKeysCollection(\n options: CreateApiKeysCollectionOptions,\n): CollectionConfig {\n if (!options || !options.userCollection) {\n throw new Error(\n 'createApiKeysCollection: `userCollection` is required (slug of the user collection that owns API keys).',\n )\n }\n if (!Array.isArray(options.availableCollections)) {\n throw new Error(\n 'createApiKeysCollection: `availableCollections` is required (slugs of collections that scope overrides may target).',\n )\n }\n if (!Array.isArray(options.availableTools)) {\n throw new Error(\n 'createApiKeysCollection: `availableTools` is required (names of registered MCP tools).',\n )\n }\n\n const slug = options.slug ?? API_KEYS_DEFAULT_SLUG\n const toolOptions = options.availableTools.map((t) => ({ label: t, value: t }))\n const availableGlobals = Array.isArray(options.availableGlobals)\n ? options.availableGlobals\n : []\n\n const presetField: Field = {\n name: 'preset',\n type: 'select',\n required: true,\n defaultValue: 'custom',\n options: PRESET_OPTIONS as unknown as { label: string; value: string }[],\n admin: {\n description:\n 'Role preset. \"Custom\" unlocks the per-collection matrix and the tool overrides below. ' +\n 'Switching away from Custom CLEARS every override on save (collectionScopes, globalScopes, ' +\n 'toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline, so ' +\n 'reconfigure the matrices and tool lists before saving.',\n },\n }\n\n // Stored shape: Array<{ slug: string; actions: ('read'|'create'|'update'|'delete')[] }>\n // The default Payload UI for an `array` would force users to add rows\n // one at a time; the custom matrix component renders all available\n // collections at once with a checkbox grid (rows × actions).\n //\n // `availableCollections` is forwarded via `clientProps` — Payload v3's\n // sanctioned escape hatch for serializable static data that the client\n // component needs at render time.\n const collectionScopesField: Field = {\n name: 'collectionScopes',\n type: 'json',\n admin: {\n condition: isCustomPreset,\n components: {\n Field: {\n path: 'payload-mcp-toolkit/client',\n exportName: 'CollectionScopesMatrix',\n clientProps: {\n availableCollections: options.availableCollections,\n },\n },\n },\n },\n }\n\n // Mirrors `collectionScopes` exactly — one additive JSONB column with a\n // default of `'[]'`, default-rendered by `GlobalScopesMatrix`. Hidden\n // under non-custom presets. Stored shape:\n // Array<{ slug: string; actions: ('read'|'update')[] }>\n // No `availableGlobals.length > 0` gate: `ScopesTable` renders its own\n // empty-state message when zero items are passed, so the field surfaces\n // under Custom regardless of host config, matching the collection variant.\n const globalScopesField: Field = {\n name: 'globalScopes',\n type: 'json',\n admin: {\n condition: isCustomPreset,\n components: {\n Field: {\n path: 'payload-mcp-toolkit/client',\n exportName: 'GlobalScopesMatrix',\n clientProps: {\n availableGlobals,\n },\n },\n },\n },\n }\n\n const toolsCollapsible: Field = {\n type: 'collapsible',\n label: 'Tool overrides',\n admin: {\n condition: isCustomPreset,\n description:\n 'Per-tool whitelist / blacklist. Layered on top of preset and collection scopes.',\n initCollapsed: true,\n },\n fields: [\n {\n name: 'toolAllow',\n type: 'select',\n hasMany: true,\n options: toolOptions,\n admin: {\n description:\n 'If set, only these tools are callable with this key. Leave empty to allow any tool ' +\n 'the collection or global scopes permit. Under the Custom preset, an empty list is ' +\n 'treated as deny-all ONLY when no collection or global scopes are set (the fresh- ' +\n 'Custom-key sentinel); when collection or global scopes are populated, an empty list ' +\n 'collapses to \"no tool restriction\" so the resource scopes alone determine what is ' +\n 'callable — to deny every tool while keeping resource scopes, enumerate them in ' +\n 'toolDeny instead. Preset-mode keys created via the REST API with an empty list are ' +\n 'coerced to \"no restriction\".',\n },\n },\n {\n name: 'toolDeny',\n type: 'select',\n hasMany: true,\n options: toolOptions,\n admin: {\n description: 'These tools are blocked regardless of any other scope.',\n },\n },\n ],\n }\n\n return {\n slug,\n admin: {\n group: 'MCP',\n useAsTitle: 'name',\n description:\n 'API keys for MCP clients. Scopes control which collections and tools each key can access.',\n defaultColumns: ['name', 'user', 'keyPrefix', 'preset', 'lastUsedAt', 'expiresAt', 'revokedAt'],\n },\n auth: {\n disableLocalStrategy: true,\n useAPIKey: true,\n },\n hooks: {\n beforeValidate: [\n (({ data, originalDoc }) => {\n // The override fields (collectionScopes, globalScopes, toolAllow,\n // toolDeny) are conditionally rendered only under the Custom preset\n // (`condition: isCustomPreset`). Under any other preset they are\n // hidden in the admin UI, which means two things:\n //\n // 1. The admin form omits hidden fields from its payload on save,\n // so `data` only carries the visible fields — we can't\n // \"collapse the empty array we see in `data`\" because we never\n // see it at all. The stale value lives on `originalDoc`.\n // 2. A Custom→Admin switch silently keeps the prior\n // `toolAllow:[...]` / `collectionScopes:[...]`, and\n // `composeScopes` then emits a scope gate that rejects calls\n // the user clearly intended to allow.\n //\n // Fix: when the preset is non-Custom, explicitly write `null` into\n // `data` for every override axis (regardless of what `data` carries\n // or what originalDoc holds). Payload persists nulls, so the stale\n // values are erased on every save. The Custom-preset branch below\n // keeps the explicit-empty-means-deny semantic intact.\n if (!data) return data\n const d = data as Record<string, unknown>\n const orig = (originalDoc ?? {}) as Record<string, unknown>\n const preset = d.preset ?? orig.preset\n\n // `readField` falls through to originalDoc when `data` omits the\n // key entirely (admin form skipping hidden fields), but honours\n // an explicit null/empty in `data` over originalDoc.\n const readField = (key: string): unknown =>\n key in d ? d[key] : orig[key]\n const isNonEmptyArray = (v: unknown): boolean =>\n Array.isArray(v) && v.length > 0\n // Null, undefined, or `[]` — the three shapes that mean \"the user\n // expressed no tool restriction\". Anything else (a bare string, a\n // number) is malformed and must reach Payload's validator rather than\n // being silently read as \"no restriction\".\n const isUnset = (v: unknown): boolean =>\n v === null || v === undefined || (Array.isArray(v) && v.length === 0)\n\n const OVERRIDE_AXES = [\n 'collectionScopes',\n 'globalScopes',\n 'toolAllow',\n 'toolDeny',\n ] as const\n\n if (preset !== 'custom') {\n for (const axis of OVERRIDE_AXES) d[axis] = null\n return data\n }\n\n // Custom preset: the Tools collapsible is labelled as an *override*\n // layered on top of collection / global scopes, and its description\n // says \"Leave empty to allow any tool the collection scopes permit.\"\n // Payload's hasMany-select default of `[]` would otherwise turn the\n // Tools section into a mandatory whitelist — a user who configures\n // collection scopes and never opens the collapsible would silently\n // store `toolAllow:[]`, which `composeScopes` honours as deny-all on\n // the tools axis and rejects every call.\n //\n // Resolve the mismatch by coercing a non-populated `toolAllow` to\n // null whenever the key carries any concrete resource scope\n // (collection or global entries). \"Non-populated\" covers both `[]`\n // (what the admin form sends) and a missing key (what a key created\n // through the Local API sends) — Payload reads an unset hasMany\n // select back as `[]` either way, so without this a scripted key\n // with collection scopes and no tool list would deny every tool.\n // The fresh-Custom-key sentinel in `composeScopes` still covers the\n // \"no scopes at all\" case (everything null → deny-all), so users who\n // genuinely want deny-all do not regress.\n const hasResourceScope =\n isNonEmptyArray(readField('collectionScopes')) ||\n isNonEmptyArray(readField('globalScopes'))\n if (hasResourceScope && isUnset(readField('toolAllow'))) {\n d.toolAllow = null\n }\n return data\n }) as CollectionBeforeValidateHook,\n ],\n },\n labels: {\n plural: 'API Keys',\n singular: 'API Key',\n },\n fields: [\n // Main column.\n {\n name: 'name',\n type: 'text',\n required: true,\n admin: { description: 'Human label for this key (e.g. \"Editorial team — Claude Desktop\").' },\n },\n {\n name: 'description',\n type: 'textarea',\n admin: { description: 'Optional notes about the purpose of this key.' },\n },\n presetField,\n collectionScopesField,\n globalScopesField,\n toolsCollapsible,\n\n // Sidebar — identity + lifecycle.\n {\n name: 'user',\n type: 'relationship',\n relationTo: options.userCollection,\n required: true,\n admin: {\n position: 'sidebar',\n description:\n 'The user this key authenticates as. Tool calls use this user for access checks on target collections.',\n },\n },\n {\n name: 'keyPrefix',\n type: 'text',\n index: true,\n admin: {\n position: 'sidebar',\n readOnly: true,\n description:\n 'First 8 characters of the API key — used in audit logs to identify the key without exposing the full secret.',\n },\n hooks: {\n beforeChange: [\n ({ data, originalDoc, value }) => {\n if (typeof value === 'string' && value.length > 0) return value\n const incomingKey = (data as { apiKey?: unknown } | undefined)?.apiKey\n if (typeof incomingKey === 'string' && incomingKey.length >= 8) {\n return incomingKey.slice(0, 8)\n }\n const existing = (originalDoc as { keyPrefix?: unknown } | undefined)?.keyPrefix\n return typeof existing === 'string' ? existing : undefined\n },\n ],\n },\n },\n {\n name: 'expiresAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n description: 'Optional expiry. Requests authenticated with an expired key are rejected.',\n },\n },\n {\n name: 'revokedAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n description: 'Set to revoke a key. Revoked keys are rejected at auth time.',\n },\n },\n {\n name: 'lastUsedAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n readOnly: true,\n description:\n 'Updated on each successful authentication. Fire-and-forget; not on the request hot path.',\n },\n },\n ],\n }\n}\n"],"names":["API_KEYS_DEFAULT_SLUG","PRESET_OPTIONS","label","value","isCustomPreset","data","preset","createApiKeysCollection","options","userCollection","Error","Array","isArray","availableCollections","availableTools","slug","toolOptions","map","t","availableGlobals","presetField","name","type","required","defaultValue","admin","description","collectionScopesField","condition","components","Field","path","exportName","clientProps","globalScopesField","toolsCollapsible","initCollapsed","fields","hasMany","group","useAsTitle","defaultColumns","auth","disableLocalStrategy","useAPIKey","hooks","beforeValidate","originalDoc","d","orig","readField","key","isNonEmptyArray","v","length","isUnset","undefined","OVERRIDE_AXES","axis","hasResourceScope","toolAllow","labels","plural","singular","relationTo","position","index","readOnly","beforeChange","incomingKey","apiKey","slice","existing","keyPrefix"],"mappings":"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"}
1
+ {"version":3,"sources":["../src/api-keys.ts"],"sourcesContent":["import type { CollectionBeforeValidateHook, CollectionConfig, Field } from 'payload'\n\nexport const API_KEYS_DEFAULT_SLUG = 'payload-mcp-api-keys'\n\nexport interface CreateApiKeysCollectionOptions {\n /**\n * Collection slug. Defaults to `payload-mcp-api-keys` for zero-touch\n * compatibility with rows created by `@payloadcms/plugin-mcp` v0.3.x.\n */\n slug?: string\n /**\n * Slug of the user collection that API keys link to. Required.\n */\n userCollection: string\n /**\n * Collection slugs offered to the collection-scopes matrix component.\n * Snapshotted at plugin-init time from the host Payload config; adding a\n * collection requires a dev-server restart for it to surface in the\n * admin UI.\n */\n availableCollections: string[]\n /**\n * Tool names offered as options for the `toolAllow` / `toolDeny` selects.\n * Sourced from the toolkit's registered tools at plugin init.\n */\n availableTools: string[]\n /**\n * Global slugs offered to the global-scopes matrix component. Optional —\n * direct callers of the factory that pre-date globals support continue\n * to work; sites with no globals get an empty array and the second\n * matrix table is not rendered.\n */\n availableGlobals?: string[]\n}\n\nconst PRESET_OPTIONS = [\n { label: 'Read-only', value: 'read-only' },\n { label: 'Editor (read + create + update)', value: 'editor' },\n { label: 'Admin (all actions)', value: 'admin' },\n { label: 'Custom (use overrides below)', value: 'custom' },\n] as const\n\nconst isCustomPreset = (data: unknown): boolean =>\n !!data && typeof data === 'object' && (data as { preset?: unknown }).preset === 'custom'\n\n/**\n * Builds the `payload-mcp-api-keys` collection used by the v0.4 standalone\n * plugin. Reuses Payload's built-in `useAPIKey: true` so the underlying\n * `apiKey` / `apiKeyIndex` columns match what `@payloadcms/plugin-mcp`\n * v0.3.x wrote — existing rows authenticate without re-issue.\n *\n * Layout:\n * - Main column: name, description, preset, scopes matrix (custom only),\n * tools collapsible (custom only).\n * - Sidebar: user relationship, key prefix, expiresAt, revokedAt,\n * lastUsedAt — identity + lifecycle metadata kept out of the\n * scope-editing flow.\n */\nexport function createApiKeysCollection(\n options: CreateApiKeysCollectionOptions,\n): CollectionConfig {\n if (!options || !options.userCollection) {\n throw new Error(\n 'createApiKeysCollection: `userCollection` is required (slug of the user collection that owns API keys).',\n )\n }\n if (!Array.isArray(options.availableCollections)) {\n throw new Error(\n 'createApiKeysCollection: `availableCollections` is required (slugs of collections that scope overrides may target).',\n )\n }\n if (!Array.isArray(options.availableTools)) {\n throw new Error(\n 'createApiKeysCollection: `availableTools` is required (names of registered MCP tools).',\n )\n }\n\n const slug = options.slug ?? API_KEYS_DEFAULT_SLUG\n const toolOptions = options.availableTools.map((t) => ({ label: t, value: t }))\n const availableGlobals = Array.isArray(options.availableGlobals)\n ? options.availableGlobals\n : []\n\n const presetField: Field = {\n name: 'preset',\n type: 'select',\n required: true,\n defaultValue: 'custom',\n options: PRESET_OPTIONS as unknown as { label: string; value: string }[],\n admin: {\n description:\n 'Role preset. \"Custom\" unlocks the per-collection matrix and the tool overrides below. ' +\n 'Switching away from Custom CLEARS every override on save (collectionScopes, globalScopes, ' +\n 'toolAllow, toolDeny); switching back to Custom starts from a fresh deny-all baseline, so ' +\n 'reconfigure the matrices and tool lists before saving.',\n },\n }\n\n // Stored shape: Array<{ slug: string; actions: ('read'|'create'|'update'|'delete')[] }>\n // The default Payload UI for an `array` would force users to add rows\n // one at a time; the custom matrix component renders all available\n // collections at once with a checkbox grid (rows × actions).\n //\n // `availableCollections` is forwarded via `clientProps` — Payload v3's\n // sanctioned escape hatch for serializable static data that the client\n // component needs at render time.\n const collectionScopesField: Field = {\n name: 'collectionScopes',\n type: 'json',\n admin: {\n condition: isCustomPreset,\n components: {\n Field: {\n path: 'payload-mcp-toolkit/client',\n exportName: 'CollectionScopesMatrix',\n clientProps: {\n availableCollections: options.availableCollections,\n },\n },\n },\n },\n }\n\n // Mirrors `collectionScopes` exactly — one additive JSONB column with a\n // default of `'[]'`, default-rendered by `GlobalScopesMatrix`. Hidden\n // under non-custom presets. Stored shape:\n // Array<{ slug: string; actions: ('read'|'update')[] }>\n // No `availableGlobals.length > 0` gate: `ScopesTable` renders its own\n // empty-state message when zero items are passed, so the field surfaces\n // under Custom regardless of host config, matching the collection variant.\n const globalScopesField: Field = {\n name: 'globalScopes',\n type: 'json',\n admin: {\n condition: isCustomPreset,\n components: {\n Field: {\n path: 'payload-mcp-toolkit/client',\n exportName: 'GlobalScopesMatrix',\n clientProps: {\n availableGlobals,\n },\n },\n },\n },\n }\n\n const toolsCollapsible: Field = {\n type: 'collapsible',\n label: 'Tool overrides',\n admin: {\n condition: isCustomPreset,\n description:\n 'Per-tool whitelist / blacklist. Layered on top of preset and collection scopes.',\n initCollapsed: true,\n },\n fields: [\n {\n name: 'toolAllow',\n type: 'select',\n hasMany: true,\n options: toolOptions,\n admin: {\n description:\n 'If set, only these tools are callable with this key. Leave empty to allow any tool ' +\n 'the collection or global scopes permit. Under the Custom preset, an empty list is ' +\n 'treated as deny-all ONLY when no collection or global scopes are set (the fresh- ' +\n 'Custom-key sentinel); when collection or global scopes are populated, an empty list ' +\n 'collapses to \"no tool restriction\" so the resource scopes alone determine what is ' +\n 'callable — to deny every tool while keeping resource scopes, enumerate them in ' +\n 'toolDeny instead. Preset-mode keys created via the REST API with an empty list are ' +\n 'coerced to \"no restriction\".',\n },\n },\n {\n name: 'toolDeny',\n type: 'select',\n hasMany: true,\n options: toolOptions,\n admin: {\n description: 'These tools are blocked regardless of any other scope.',\n },\n },\n ],\n }\n\n return {\n slug,\n admin: {\n group: 'MCP',\n useAsTitle: 'name',\n description:\n 'API keys for MCP clients. Scopes control which collections and tools each key can access.',\n defaultColumns: ['name', 'user', 'keyPrefix', 'preset', 'lastUsedAt', 'expiresAt', 'revokedAt'],\n },\n auth: {\n disableLocalStrategy: true,\n useAPIKey: true,\n },\n // Every MCP request authenticates by looking up this column. Payload's\n // `useAPIKey` adds `apiKeyIndex` but does not index it, so each call cost a\n // sequential scan over the whole key table — and the table only grows.\n // Declared here rather than added by hand in a host migration, so Payload's\n // schema builder knows about it and never offers to drop it.\n //\n // Not unique: rows exist with a null `apiKeyIndex` (a key row saved before\n // `enableAPIKey` is ticked), and a unique index would let only one of them\n // exist at a time.\n indexes: [{ fields: ['apiKeyIndex'] }],\n hooks: {\n beforeValidate: [\n (({ data, originalDoc }) => {\n // The override fields (collectionScopes, globalScopes, toolAllow,\n // toolDeny) are conditionally rendered only under the Custom preset\n // (`condition: isCustomPreset`). Under any other preset they are\n // hidden in the admin UI, which means two things:\n //\n // 1. The admin form omits hidden fields from its payload on save,\n // so `data` only carries the visible fields — we can't\n // \"collapse the empty array we see in `data`\" because we never\n // see it at all. The stale value lives on `originalDoc`.\n // 2. A Custom→Admin switch silently keeps the prior\n // `toolAllow:[...]` / `collectionScopes:[...]`, and\n // `composeScopes` then emits a scope gate that rejects calls\n // the user clearly intended to allow.\n //\n // Fix: when the preset is non-Custom, explicitly write `null` into\n // `data` for every override axis (regardless of what `data` carries\n // or what originalDoc holds). Payload persists nulls, so the stale\n // values are erased on every save. The Custom-preset branch below\n // keeps the explicit-empty-means-deny semantic intact.\n if (!data) return data\n const d = data as Record<string, unknown>\n const orig = (originalDoc ?? {}) as Record<string, unknown>\n const preset = d.preset ?? orig.preset\n\n // `readField` falls through to originalDoc when `data` omits the\n // key entirely (admin form skipping hidden fields), but honours\n // an explicit null/empty in `data` over originalDoc.\n const readField = (key: string): unknown =>\n key in d ? d[key] : orig[key]\n const isNonEmptyArray = (v: unknown): boolean =>\n Array.isArray(v) && v.length > 0\n // Null, undefined, or `[]` — the three shapes that mean \"the user\n // expressed no tool restriction\". Anything else (a bare string, a\n // number) is malformed and must reach Payload's validator rather than\n // being silently read as \"no restriction\".\n const isUnset = (v: unknown): boolean =>\n v === null || v === undefined || (Array.isArray(v) && v.length === 0)\n\n const OVERRIDE_AXES = [\n 'collectionScopes',\n 'globalScopes',\n 'toolAllow',\n 'toolDeny',\n ] as const\n\n if (preset !== 'custom') {\n for (const axis of OVERRIDE_AXES) d[axis] = null\n return data\n }\n\n // Custom preset: the Tools collapsible is labelled as an *override*\n // layered on top of collection / global scopes, and its description\n // says \"Leave empty to allow any tool the collection scopes permit.\"\n // Payload's hasMany-select default of `[]` would otherwise turn the\n // Tools section into a mandatory whitelist — a user who configures\n // collection scopes and never opens the collapsible would silently\n // store `toolAllow:[]`, which `composeScopes` honours as deny-all on\n // the tools axis and rejects every call.\n //\n // Resolve the mismatch by coercing a non-populated `toolAllow` to\n // null whenever the key carries any concrete resource scope\n // (collection or global entries). \"Non-populated\" covers both `[]`\n // (what the admin form sends) and a missing key (what a key created\n // through the Local API sends) — Payload reads an unset hasMany\n // select back as `[]` either way, so without this a scripted key\n // with collection scopes and no tool list would deny every tool.\n // The fresh-Custom-key sentinel in `composeScopes` still covers the\n // \"no scopes at all\" case (everything null → deny-all), so users who\n // genuinely want deny-all do not regress.\n const hasResourceScope =\n isNonEmptyArray(readField('collectionScopes')) ||\n isNonEmptyArray(readField('globalScopes'))\n if (hasResourceScope && isUnset(readField('toolAllow'))) {\n d.toolAllow = null\n }\n return data\n }) as CollectionBeforeValidateHook,\n ],\n },\n labels: {\n plural: 'API Keys',\n singular: 'API Key',\n },\n fields: [\n // Main column.\n {\n name: 'name',\n type: 'text',\n required: true,\n admin: { description: 'Human label for this key (e.g. \"Editorial team — Claude Desktop\").' },\n },\n {\n name: 'description',\n type: 'textarea',\n admin: { description: 'Optional notes about the purpose of this key.' },\n },\n presetField,\n collectionScopesField,\n globalScopesField,\n toolsCollapsible,\n\n // Sidebar — identity + lifecycle.\n {\n name: 'user',\n type: 'relationship',\n relationTo: options.userCollection,\n required: true,\n admin: {\n position: 'sidebar',\n description:\n 'The user this key authenticates as. Tool calls use this user for access checks on target collections.',\n },\n },\n {\n name: 'keyPrefix',\n type: 'text',\n index: true,\n admin: {\n position: 'sidebar',\n readOnly: true,\n description:\n 'First 8 characters of the API key — used in audit logs to identify the key without exposing the full secret.',\n },\n hooks: {\n beforeChange: [\n ({ data, originalDoc, value }) => {\n if (typeof value === 'string' && value.length > 0) return value\n const incomingKey = (data as { apiKey?: unknown } | undefined)?.apiKey\n if (typeof incomingKey === 'string' && incomingKey.length >= 8) {\n return incomingKey.slice(0, 8)\n }\n const existing = (originalDoc as { keyPrefix?: unknown } | undefined)?.keyPrefix\n return typeof existing === 'string' ? existing : undefined\n },\n ],\n },\n },\n {\n name: 'expiresAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n description: 'Optional expiry. Requests authenticated with an expired key are rejected.',\n },\n },\n {\n name: 'revokedAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n description: 'Set to revoke a key. Revoked keys are rejected at auth time.',\n },\n },\n {\n name: 'lastUsedAt',\n type: 'date',\n admin: {\n position: 'sidebar',\n readOnly: true,\n description:\n 'Updated on each successful authentication. Fire-and-forget; not on the request hot path.',\n },\n },\n ],\n }\n}\n"],"names":["API_KEYS_DEFAULT_SLUG","PRESET_OPTIONS","label","value","isCustomPreset","data","preset","createApiKeysCollection","options","userCollection","Error","Array","isArray","availableCollections","availableTools","slug","toolOptions","map","t","availableGlobals","presetField","name","type","required","defaultValue","admin","description","collectionScopesField","condition","components","Field","path","exportName","clientProps","globalScopesField","toolsCollapsible","initCollapsed","fields","hasMany","group","useAsTitle","defaultColumns","auth","disableLocalStrategy","useAPIKey","indexes","hooks","beforeValidate","originalDoc","d","orig","readField","key","isNonEmptyArray","v","length","isUnset","undefined","OVERRIDE_AXES","axis","hasResourceScope","toolAllow","labels","plural","singular","relationTo","position","index","readOnly","beforeChange","incomingKey","apiKey","slice","existing","keyPrefix"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payload-mcp-toolkit",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",