@abinnovision/payloadcms-mcpx 1.0.0-beta.7 → 1.0.0-beta.9

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/README.md CHANGED
@@ -27,6 +27,9 @@ yarn add @abinnovision/payloadcms-mcpx
27
27
  ```
28
28
 
29
29
  - Peer dependency: `payload >=3.88.0 <4`.
30
+ - `@payloadcms/ui` and `react` are optional peers, needed only by the admin
31
+ setup guide. A headless install can leave them out and set
32
+ `apiKeys.setupGuide: false`.
30
33
  - The package is published as ESM only, matching Payload itself.
31
34
 
32
35
  ## Usage
@@ -85,6 +88,22 @@ all keys) or add fields.
85
88
 
86
89
  ## Connecting a client
87
90
 
91
+ Saved keys carry a **Connect a client** tab in the admin holding these same
92
+ instructions with their own URL and key filled in, each block behind a copy
93
+ button. The tab only exists once the key does, so the create form stays free of
94
+ it. Turn it off with `apiKeys.setupGuide: false`, which also drops the tabs and
95
+ restores the flat form.
96
+
97
+ The tab renders an admin component, so it has to be in the import map:
98
+
99
+ ```bash
100
+ payload generate:importmap
101
+ ```
102
+
103
+ Without that entry Payload logs a missing-component error and renders nothing
104
+ else; the rest of the plugin is unaffected. The URL comes from `serverURL` when
105
+ the config sets one and from the browser's origin otherwise.
106
+
88
107
  The endpoint speaks streamable HTTP with `Authorization: Bearer <key>`:
89
108
 
90
109
  ```bash
@@ -163,8 +182,9 @@ Rules the tools enforce and explain in their own descriptions:
163
182
  the only place the restriction is checked.
164
183
  - Field and collection `admin.description` values are included in
165
184
  `describeSchema` and `listCapabilities`, so intent written for the admin
166
- panel reaches the client. Strings and locale-keyed records pass through;
167
- functions and components are dropped.
185
+ panel reaches the client. A locale-keyed record is resolved to one string for
186
+ the request's language, falling back to the deployment's fallback language and
187
+ then to the record's first entry; functions and components are dropped.
168
188
  - Builtin tools reject unknown arguments by name instead of silently ignoring
169
189
  them.
170
190
  - Every path this plugin accepts or reports is a JSON Pointer. A schema path
@@ -295,6 +315,7 @@ Builtin tools reject them instead.
295
315
  | `globals.<slug>.allowLiveWrites` | `false` | Permit writes to a global without drafts (they land live). |
296
316
  | `userCollection` | `config.admin.user` or `users` | Auth collection the keys act as. |
297
317
  | `apiKeys.slug` | `mcpx-api-keys` | Slug of the generated key collection. |
318
+ | `apiKeys.setupGuide` | `true` | Add a "Connect a client" tab to saved keys. Needs the import map. |
298
319
  | `apiKeys.overrideCollection` | none | Final override applied to the generated collection. |
299
320
  | `endpoint.path` | `/mcpx` | Endpoint path below the API route. |
300
321
  | `limits.maxLimit` | `25` | Upper bound for `findDocuments.limit`. |
@@ -1,4 +1,4 @@
1
- import { createCapabilityFields, createKeyFields } from "./fields.mjs";
1
+ import { createCapabilityFields, createKeyFields, withSetupGuideTab } from "./fields.mjs";
2
2
  import { generateApiKey, hashApiKey } from "./key.mjs";
3
3
  //#region src/api-keys/collection.ts
4
4
  /**
@@ -36,7 +36,7 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
36
36
  delete: ownKeysOnly
37
37
  },
38
38
  hooks: { beforeChange: [keyBeforeChange] },
39
- fields: [
39
+ fields: withSetupGuideTab([
40
40
  {
41
41
  name: "user",
42
42
  type: "relationship",
@@ -51,7 +51,7 @@ import { generateApiKey, hashApiKey } from "./key.mjs";
51
51
  },
52
52
  ...createKeyFields(),
53
53
  ...createCapabilityFields(options)
54
- ]
54
+ ], options)
55
55
  };
56
56
  };
57
57
  //#endregion
@@ -15,6 +15,7 @@ const checkbox = (name, description) => ({
15
15
  defaultValue: false,
16
16
  admin: { description }
17
17
  });
18
+ const SETUP_GUIDE_FIELD = "setupGuide";
18
19
  /**
19
20
  * Fields every key carries. Key generation and the HMAC index live in the
20
21
  * collection-level `beforeChange` hook (see `collection.ts`), because sibling
@@ -56,6 +57,39 @@ const checkbox = (name, description) => ({
56
57
  }
57
58
  ];
58
59
  /**
60
+ * Wraps the key fields and the setup guide in unnamed tabs, so the wide
61
+ * snippets get the full form width without pushing the key itself out of view.
62
+ * Unnamed on purpose: named tabs would nest the data and move `capabilities`
63
+ * off the document root, which capability resolution reads.
64
+ *
65
+ * The guide tab is conditioned on the update operation. On create there is no
66
+ * key to hand out, and a tab leading to an empty panel is worse than no tab.
67
+ */ const withSetupGuideTab = (keyFields, options) => {
68
+ if (!options.setupGuide) return keyFields;
69
+ return [{
70
+ type: "tabs",
71
+ tabs: [{
72
+ label: "Key",
73
+ fields: keyFields
74
+ }, {
75
+ label: "Connect a client",
76
+ admin: { condition: (_data, _siblingData, { operation }) => operation === "update" },
77
+ fields: [{
78
+ name: SETUP_GUIDE_FIELD,
79
+ type: "ui",
80
+ admin: {
81
+ disableListColumn: true,
82
+ components: { Field: {
83
+ path: "@abinnovision/payloadcms-mcpx/client",
84
+ exportName: "McpxSetupGuide",
85
+ clientProps: { endpointPath: options.endpointPath }
86
+ } }
87
+ }
88
+ }]
89
+ }]
90
+ }];
91
+ };
92
+ /**
59
93
  * One checkbox per exposed operation, grouped per collection, per global and
60
94
  * per custom tool. Only operations the plugin config exposes get a checkbox, so
61
95
  * a key can never enable more than the config allows. Everything defaults to
@@ -100,4 +134,4 @@ const checkbox = (name, description) => ({
100
134
  }];
101
135
  };
102
136
  //#endregion
103
- export { createCapabilityFields, createKeyFields };
137
+ export { SETUP_GUIDE_FIELD, createCapabilityFields, createKeyFields, withSetupGuideTab };
@@ -0,0 +1,54 @@
1
+ //#region src/api-keys/setup-guide.ts
2
+ const KEY_PLACEHOLDER = "<your-key>";
3
+ /**
4
+ * Server name for the client config. MCP clients key their config by this, so
5
+ * it has to survive labels with spaces or punctuation.
6
+ */ const toServerName = (label) => {
7
+ const slug = (label ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8
+ return slug === "" ? "payload" : slug;
9
+ };
10
+ /**
11
+ * The connection instructions for one key, split into independently copyable
12
+ * blocks. Kept a pure builder so the admin component holds only rendering and
13
+ * the snippets stay unit-testable.
14
+ */ const buildSetupGuide = (input) => {
15
+ const key = typeof input.apiKey === "string" ? input.apiKey : KEY_PLACEHOLDER;
16
+ const name = toServerName(input.label);
17
+ const url = input.endpointUrl;
18
+ return [
19
+ {
20
+ id: "endpoint",
21
+ title: "Endpoint",
22
+ description: "Streamable HTTP. Point any MCP client at this URL.",
23
+ snippet: url
24
+ },
25
+ {
26
+ id: "header",
27
+ title: "Authorization header",
28
+ description: "The only accepted credential; cookies and JWTs are ignored.",
29
+ snippet: `Authorization: Bearer ${key}`
30
+ },
31
+ {
32
+ id: "claude-code",
33
+ title: "Claude Code",
34
+ snippet: [`claude mcp add --transport http ${name} ${url} \\`, ` --header "Authorization: Bearer ${key}"`].join("\n")
35
+ },
36
+ {
37
+ id: "claude-desktop",
38
+ title: "Claude Desktop",
39
+ description: "Has no direct header support, so it goes through mcp-remote.",
40
+ snippet: JSON.stringify({ mcpServers: { [name]: {
41
+ command: "npx",
42
+ args: [
43
+ "-y",
44
+ "mcp-remote",
45
+ url,
46
+ "--header",
47
+ `Authorization: Bearer ${key}`
48
+ ]
49
+ } } }, null, 2)
50
+ }
51
+ ];
52
+ };
53
+ //#endregion
54
+ export { KEY_PLACEHOLDER, buildSetupGuide, toServerName };
@@ -0,0 +1,2 @@
1
+ import { McpxSetupGuide, McpxSetupGuideProps } from "./setup-guide.mjs";
2
+ export { McpxSetupGuide, type McpxSetupGuideProps };
@@ -0,0 +1,2 @@
1
+ import { McpxSetupGuide } from "./setup-guide.mjs";
2
+ export { McpxSetupGuide };
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ //#region src/client/setup-guide.d.ts
3
+ interface McpxSetupGuideProps {
4
+ /** Endpoint path below the API route, from the plugin options. */
5
+ endpointPath: string;
6
+ }
7
+ /**
8
+ * Per-key connection instructions on the API key edit view. Renders nothing
9
+ * until the document is saved, because before that there is no key to hand to
10
+ * a client.
11
+ */
12
+ declare const McpxSetupGuide: React.FC<McpxSetupGuideProps>;
13
+ //#endregion
14
+ export { McpxSetupGuide, type McpxSetupGuideProps };
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { buildSetupGuide } from "../api-keys/setup-guide.mjs";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { CopyToClipboard, useConfig, useDocumentInfo, useFormFields } from "@payloadcms/ui";
5
+ import { useEffect, useState } from "react";
6
+ //#region src/client/setup-guide.tsx
7
+ const asString = (value) => typeof value === "string" ? value : void 0;
8
+ /**
9
+ * Reads `serverURL` when the config sets one and falls back to the browser's
10
+ * origin. The fallback has to wait for mount: this component is server-rendered
11
+ * first, where `window` does not exist.
12
+ */ const useOrigin = (serverUrl) => {
13
+ const [origin, setOrigin] = useState(serverUrl);
14
+ useEffect(() => {
15
+ if (serverUrl === "") setOrigin(window.location.origin);
16
+ }, [serverUrl]);
17
+ return origin;
18
+ };
19
+ /**
20
+ * Payload's own theme variables, so the panel follows the admin's light and
21
+ * dark themes without shipping a stylesheet consumers would have to transpile.
22
+ */ const styles = {
23
+ lead: { marginBottom: "calc(var(--base) * 0.75)" },
24
+ section: { marginBottom: "calc(var(--base) * 0.75)" },
25
+ sectionHeader: {
26
+ display: "flex",
27
+ alignItems: "center",
28
+ gap: "calc(var(--base) * 0.25)"
29
+ },
30
+ description: {
31
+ margin: "calc(var(--base) * 0.15) 0",
32
+ color: "var(--theme-elevation-500)"
33
+ },
34
+ snippet: {
35
+ margin: 0,
36
+ padding: "calc(var(--base) * 0.4)",
37
+ background: "var(--theme-elevation-50)",
38
+ border: "1px solid var(--theme-elevation-150)",
39
+ borderRadius: "3px",
40
+ fontFamily: "var(--font-mono)",
41
+ overflowX: "auto",
42
+ whiteSpace: "pre-wrap",
43
+ wordBreak: "break-all"
44
+ }
45
+ };
46
+ /**
47
+ * Per-key connection instructions on the API key edit view. Renders nothing
48
+ * until the document is saved, because before that there is no key to hand to
49
+ * a client.
50
+ */ const McpxSetupGuide = ({ endpointPath }) => {
51
+ const { id } = useDocumentInfo();
52
+ const { config } = useConfig();
53
+ const apiKey = useFormFields(([fields]) => fields["apiKey"]?.value);
54
+ const label = useFormFields(([fields]) => fields["label"]?.value);
55
+ const origin = useOrigin(config.serverURL);
56
+ if (id === void 0) return null;
57
+ const sections = buildSetupGuide({
58
+ endpointUrl: `${origin.replace(/\/+$/, "")}${config.routes.api}${endpointPath}`,
59
+ apiKey: asString(apiKey),
60
+ label: asString(label)
61
+ });
62
+ return /*#__PURE__*/ jsxs("div", {
63
+ className: "field-type",
64
+ children: [/*#__PURE__*/ jsx("p", {
65
+ style: styles.lead,
66
+ children: "Every snippet below contains this key in full. Treat it like a password."
67
+ }), sections.map((section) => /*#__PURE__*/ jsxs("section", {
68
+ style: styles.section,
69
+ children: [
70
+ /*#__PURE__*/ jsxs("div", {
71
+ style: styles.sectionHeader,
72
+ children: [/*#__PURE__*/ jsx("strong", { children: section.title }), /*#__PURE__*/ jsx(CopyToClipboard, { value: section.snippet })]
73
+ }),
74
+ section.description ? /*#__PURE__*/ jsx("p", {
75
+ style: styles.description,
76
+ children: section.description
77
+ }) : null,
78
+ /*#__PURE__*/ jsx("pre", {
79
+ style: styles.snippet,
80
+ children: section.snippet
81
+ })
82
+ ]
83
+ }, section.id))]
84
+ });
85
+ };
86
+ //#endregion
87
+ export { McpxSetupGuide };
@@ -0,0 +1 @@
1
+ import { PayloadRequest } from "payload";
package/dist/i18n.mjs ADDED
@@ -0,0 +1,40 @@
1
+ //#region src/i18n.ts
2
+ /**
3
+ * A locale-keyed record, once it is known to hold nothing but strings.
4
+ */ const stringRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((entry) => typeof entry === "string") ? value : void 0;
5
+ /**
6
+ * Picks the entry a language addresses, treating an empty value as absent so
7
+ * the chain continues rather than yielding a useless string.
8
+ */ const pick = (record, language) => {
9
+ for (const code of Array.isArray(language) ? language : [language]) {
10
+ const entry = record[code];
11
+ if (entry !== void 0 && entry.trim() !== "") return entry;
12
+ }
13
+ };
14
+ /**
15
+ * Resolves a static label or `admin.description` to the one string a client
16
+ * can use: the request's language, then the fallback language configured for
17
+ * the deployment, then whichever entry the record declares first.
18
+ *
19
+ * Anything that is not a string or a string-valued record is dropped. A
20
+ * description written as a function or a React component is an admin-UI
21
+ * construct that may reach client-only i18n, so it is never invoked here.
22
+ */ const translateStatic = (value, language) => {
23
+ if (typeof value === "string") return value.trim() === "" ? void 0 : value;
24
+ const record = stringRecord(value);
25
+ if (!record) return;
26
+ return pick(record, language.language) ?? pick(record, language.fallbackLanguage) ?? Object.values(record).find((entry) => entry.trim() !== "");
27
+ };
28
+ /**
29
+ * Binds {@link translateStatic} to a request's language, so a walk that
30
+ * resolves many descriptions carries no request of its own.
31
+ */ const translatorFor = (i18n) => (value) => translateStatic(value, i18n);
32
+ /**
33
+ * Translator for callers with no request in hand. Both language keys miss, so
34
+ * the chain degrades to the record's first entry.
35
+ */ const translateAny = translatorFor({
36
+ fallbackLanguage: "",
37
+ language: ""
38
+ });
39
+ //#endregion
40
+ export { translateAny, translateStatic, translatorFor };
package/dist/options.mjs CHANGED
@@ -132,6 +132,7 @@ const normalizeLimits = (limits) => {
132
132
  userCollection,
133
133
  apiKeysSlug,
134
134
  endpointPath: options.endpoint?.path ?? DEFAULT_ENDPOINT_PATH,
135
+ setupGuide: options.apiKeys?.setupGuide ?? true,
135
136
  limits: normalizeLimits(options.limits),
136
137
  tools,
137
138
  auth: options.auth,
@@ -1,4 +1,5 @@
1
1
  import { lexicalSubSchema, subSchemaNodeTypes } from "./lexical.mjs";
2
+ import { translateAny } from "../i18n.mjs";
2
3
  import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextField, joinPath, splitPath, targetOf } from "./walk.mjs";
3
4
  //#region src/schema/describe.ts
4
5
  /**
@@ -115,9 +116,12 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
115
116
  /**
116
117
  * Describes a collection or global root, one block reached through a schema
117
118
  * path, or the fields a Lexical node carries.
118
- */ const describeNode = (config, ref, schemaPath = "") => {
119
+ *
120
+ * Curried on the translator that resolves each `admin.description`, so a
121
+ * request binds its language once and the walk itself stays request-free.
122
+ */ const nodeDescriber = (translate = translateAny) => (config, ref, schemaPath = "") => {
119
123
  const { blockType, fields } = fieldsAtSchemaPath(config, targetOf(config, ref), schemaPath);
120
- const descriptors = describeFields(fields);
124
+ const descriptors = describeFields(fields, translate);
121
125
  const next = descriptors.flatMap((descriptor) => branchesOf(fields, descriptor, schemaPath).map((branch) => branch.path));
122
126
  return {
123
127
  ...blockType === void 0 ? {} : { blockType },
@@ -153,4 +157,4 @@ import { blockOf, blockSlugsOf, describeFields, findBlocksField, findRichTextFie
153
157
  };
154
158
  };
155
159
  //#endregion
156
- export { describeNode, reachableSchemaPaths };
160
+ export { nodeDescriber, reachableSchemaPaths };
@@ -1,2 +1,3 @@
1
1
  import "./lexical.mjs";
2
+ import "../i18n.mjs";
2
3
  import "payload";
@@ -1,4 +1,5 @@
1
1
  import { allowedNodeTypes, nodeOptions } from "./lexical.mjs";
2
+ import { translateAny } from "../i18n.mjs";
2
3
  import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
3
4
  //#region src/schema/walk.ts
4
5
  /**
@@ -46,16 +47,8 @@ import { fieldIsHiddenOrDisabled, fieldIsVirtual } from "payload/shared";
46
47
  };
47
48
  const isSkipped = (field) => !("name" in field) || field.type === "join" || RESERVED_FIELD_NAMES.has(field.name) || fieldIsVirtual(field) || fieldIsHiddenOrDisabled(field);
48
49
  const isReadOnly = (field) => "admin" in field && field.admin.readOnly === true;
49
- /**
50
- * The `admin.description` of a field or collection, when it is serializable:
51
- * a string or a locale-keyed record. Functions and components are admin-UI
52
- * constructs and are dropped.
53
- */ const staticDescription = (description) => {
54
- if (typeof description === "string") return description;
55
- return typeof description === "object" && description !== null && Object.values(description).every((entry) => typeof entry === "string") ? description : void 0;
56
- };
57
- const describeBase = (field, path, readOnly) => {
58
- const description = staticDescription("admin" in field ? field.admin.description : void 0);
50
+ const describeBase = (field, { path, readOnly, translate }) => {
51
+ const description = translate("admin" in field ? field.admin.description : void 0);
59
52
  return {
60
53
  path,
61
54
  type: field.type,
@@ -65,8 +58,8 @@ const describeBase = (field, path, readOnly) => {
65
58
  ...readOnly ? { readOnly: true } : {}
66
59
  };
67
60
  };
68
- const describeLeaf = (field, path, readOnly) => {
69
- const descriptor = describeBase(field, path, readOnly);
61
+ const describeLeaf = (field, at) => {
62
+ const descriptor = describeBase(field, at);
70
63
  if (field.type === "select" || field.type === "radio") descriptor.options = field.options.map((option) => typeof option === "string" ? option : option.value);
71
64
  if (field.type === "relationship" || field.type === "upload") descriptor.relationTo = field.relationTo;
72
65
  if ((field.type === "select" || field.type === "relationship" || field.type === "upload") && field.hasMany === true) descriptor.hasMany = true;
@@ -109,21 +102,34 @@ const withRows = (descriptor, field) => ({
109
102
  * constraint. The walk stops at every blocks field and names the slugs instead
110
103
  * of descending, which keeps a node proportional to the number of blocks it
111
104
  * allows rather than to the size of their definitions.
112
- */ const describeFields = (fields, prefix = [], parentReadOnly = false) => fields.flatMap((field) => {
113
- if (isSkipped(field)) return [];
114
- const readOnly = parentReadOnly || isReadOnly(field);
115
- const path = [...prefix, field.name];
116
- if (field.type === "tab" || field.type === "group") {
117
- const own = describeBase(field, joinPath(path), readOnly);
118
- return [...isInformative(own) ? [own] : [], ...describeFields(field.flattenedFields, path, readOnly)];
119
- }
120
- if (field.type === "array") return [withRows(describeBase(field, joinPath(path), readOnly), field), ...describeFields(field.flattenedFields, [...path, "*"], readOnly)];
121
- if (field.type === "blocks") return [withRows({
122
- ...describeBase(field, joinPath(path), readOnly),
123
- blocks: blockSlugsOf(field)
124
- }, field)];
125
- return [describeLeaf(field, joinPath(path), readOnly)];
126
- });
105
+ *
106
+ * `translate` resolves each `admin.description` to the request's language.
107
+ * Callers that walk for paths alone leave it out and get the language-agnostic
108
+ * default, so a missing argument costs language selection, never the
109
+ * description itself.
110
+ */ const describeFields = (fields, translate = translateAny) => {
111
+ const walk = (current, prefix, parentReadOnly) => current.flatMap((field) => {
112
+ if (isSkipped(field)) return [];
113
+ const readOnly = parentReadOnly || isReadOnly(field);
114
+ const path = [...prefix, field.name];
115
+ const at = {
116
+ path: joinPath(path),
117
+ readOnly,
118
+ translate
119
+ };
120
+ if (field.type === "tab" || field.type === "group") {
121
+ const own = describeBase(field, at);
122
+ return [...isInformative(own) ? [own] : [], ...walk(field.flattenedFields, path, readOnly)];
123
+ }
124
+ if (field.type === "array") return [withRows(describeBase(field, at), field), ...walk(field.flattenedFields, [...path, "*"], readOnly)];
125
+ if (field.type === "blocks") return [withRows({
126
+ ...describeBase(field, at),
127
+ blocks: blockSlugsOf(field)
128
+ }, field)];
129
+ return [describeLeaf(field, at)];
130
+ });
131
+ return walk(fields, [], false);
132
+ };
127
133
  /**
128
134
  * The descriptors that address a value, which is what every walk resolving a
129
135
  * path against a document needs. A container describes a position rather than
@@ -156,4 +162,4 @@ const targetOf = (config, ref) => {
156
162
  return found;
157
163
  };
158
164
  //#endregion
159
- export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, staticDescription, targetOf };
165
+ export { JSON_POINTER_PATTERN, RESERVED_FIELD_NAMES, blockOf, blockSlugsOf, describeAddressableFields, describeFields, findBlocksField, findRichTextField, joinPath, pointerFromPayloadPath, splitPath, targetOf };
@@ -1,7 +1,8 @@
1
+ import { translatorFor } from "../i18n.mjs";
1
2
  import { jsonResult } from "../endpoint/result.mjs";
2
3
  import { targetShape } from "./shared.mjs";
3
4
  import { refOf, resolveTarget } from "./target.mjs";
4
- import { describeNode, reachableSchemaPaths } from "../schema/describe.mjs";
5
+ import { nodeDescriber, reachableSchemaPaths } from "../schema/describe.mjs";
5
6
  import { z } from "zod";
6
7
  //#region src/tools/describe-schema.ts
7
8
  const describeSchema = {
@@ -33,6 +34,7 @@ Fields Payload maintains (id, _status, createdAt, updatedAt) are never listed an
33
34
  handler: (args, scope) => {
34
35
  const ref = refOf(resolveTarget(scope, args, "read"));
35
36
  const { config } = scope.req.payload;
37
+ const describeNode = nodeDescriber(translatorFor(scope.req.i18n));
36
38
  const expanded = args.expand === true ? reachableSchemaPaths(config, ref) : void 0;
37
39
  const nodes = (expanded?.paths ?? (args.paths && args.paths.length > 0 ? args.paths : [""])).map((schemaPath) => {
38
40
  try {
@@ -1,4 +1,4 @@
1
- import { staticDescription } from "../schema/walk.mjs";
1
+ import { translatorFor } from "../i18n.mjs";
2
2
  import { jsonResult } from "../endpoint/result.mjs";
3
3
  import { translateLabel } from "./shared.mjs";
4
4
  import { hasDraftValidationEnabled } from "payload/shared";
@@ -16,12 +16,13 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
16
16
  inputSchema: () => ({}),
17
17
  handler: (_args, scope) => {
18
18
  const { payload } = scope.req;
19
+ const translate = translatorFor(scope.req.i18n);
19
20
  const collections = scope.options.collections.flatMap((entry) => {
20
21
  const capability = scope.capabilities.collections[entry.slug];
21
22
  const collection = payload.collections[entry.slug];
22
23
  if (!capability || !collection || !(capability.read || capability.write)) return [];
23
24
  const { config } = collection;
24
- const description = staticDescription(config.admin.description);
25
+ const description = translate(config.admin.description);
25
26
  return [{
26
27
  slug: entry.slug,
27
28
  labels: {
@@ -40,7 +41,7 @@ A global is a singleton: it has no id, is not listed by findDocuments and cannot
40
41
  const capability = scope.capabilities.globals[entry.slug];
41
42
  const config = payload.globals.config.find((candidate) => candidate.slug === entry.slug);
42
43
  if (!capability || !config || !(capability.read || capability.write)) return [];
43
- const description = staticDescription(config.admin.description);
44
+ const description = translate(config.admin.description);
44
45
  return [{
45
46
  slug: entry.slug,
46
47
  label: translateLabel(scope, config.label, entry.slug),
@@ -1,3 +1,4 @@
1
+ import { translateStatic } from "../i18n.mjs";
1
2
  import { NotFound } from "payload";
2
3
  import { z } from "zod";
3
4
  //#region src/tools/shared.ts
@@ -90,9 +91,7 @@ const depthShape = (scope) => ({ depth: z.number().int().min(0).max(scope.option
90
91
  i18n,
91
92
  t
92
93
  }) : label;
93
- if (typeof resolved === "string") return resolved;
94
- if (resolved && typeof resolved === "object") return resolved[i18n.language] ?? Object.values(resolved)[0] ?? fallback;
95
- return fallback;
94
+ return translateStatic(resolved, i18n) ?? fallback;
96
95
  };
97
96
  //#endregion
98
97
  export { depthShape, idSchema, idShape, localeOf, localeShape, readTarget, slugEnum, targetShape, translateLabel };
package/dist/types.d.mts CHANGED
@@ -90,6 +90,11 @@ type McpxPluginOptions = {
90
90
  apiKeys?: {
91
91
  /** Slug of the generated API key collection. Default `mcpx-api-keys`. */
92
92
  slug?: string;
93
+ /**
94
+ * Add a "Connect a client" tab to saved keys, holding ready-to-paste MCP
95
+ * client config. Default `true`. The snippets contain the key in full.
96
+ */
97
+ setupGuide?: boolean;
93
98
  /** Final override applied to the generated collection. */
94
99
  overrideCollection?: (collection: CollectionConfig) => CollectionConfig;
95
100
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-mcpx",
4
- "version": "1.0.0-beta.7",
4
+ "version": "1.0.0-beta.9",
5
5
  "description": "Payload CMS plugin exposing a fixed, schema-aware MCP tool surface with draft-only writes and per-API-key capabilities.",
6
6
  "keywords": [
7
7
  "payload",
@@ -26,6 +26,10 @@
26
26
  ".": {
27
27
  "types": "./dist/index.d.mts",
28
28
  "default": "./dist/index.mjs"
29
+ },
30
+ "./client": {
31
+ "types": "./dist/client/index.d.mts",
32
+ "default": "./dist/client/index.mjs"
29
33
  }
30
34
  },
31
35
  "files": [
@@ -35,17 +39,17 @@
35
39
  ],
36
40
  "scripts": {
37
41
  "build": "tsdown",
38
- "format:check": "prettier --check 'src/**/*.ts' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
39
- "format:fix": "prettier --write 'src/**/*.ts' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
40
- "lint:check": "eslint 'src/**/*.ts' 'test/**/*.ts'",
41
- "lint:fix": "eslint 'src/**/*.ts' 'test/**/*.ts' --fix",
42
+ "format:check": "prettier --check 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
43
+ "format:fix": "prettier --write 'src/**/*.{ts,tsx}' 'test/**/*.ts' '*.{json{,5},md,y{,a}ml}'",
44
+ "lint:check": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts'",
45
+ "lint:fix": "eslint 'src/**/*.{ts,tsx}' 'test/**/*.ts' --fix",
42
46
  "test-integration": "vitest --run --config test/integration/vitest.config.mts",
43
47
  "test-unit": "vitest --run --coverage --config vitest.config.mts",
44
48
  "test-unit:watch": "vitest --config vitest.config.mts",
45
49
  "typecheck": "tsc --noEmit"
46
50
  },
47
51
  "lint-staged": {
48
- "{src,test}/**/*.ts": [
52
+ "{src,test}/**/*.{ts,tsx}": [
49
53
  "eslint --fix",
50
54
  "prettier --write"
51
55
  ],
@@ -63,6 +67,7 @@
63
67
  "@arethetypeswrong/core": "^0.18.5",
64
68
  "@payloadcms/db-sqlite": "3.88.0",
65
69
  "@payloadcms/richtext-lexical": "3.88.0",
70
+ "@payloadcms/ui": "3.88.0",
66
71
  "@swc/core": "^1.16.1",
67
72
  "@types/node": "^26.2.0",
68
73
  "@types/react": "^19.2.18",
@@ -79,7 +84,17 @@
79
84
  "vitest": "^4.1.10"
80
85
  },
81
86
  "peerDependencies": {
82
- "payload": ">=3.88.0 <4"
87
+ "@payloadcms/ui": ">=3.88.0 <4",
88
+ "payload": ">=3.88.0 <4",
89
+ "react": "^19"
90
+ },
91
+ "peerDependenciesMeta": {
92
+ "@payloadcms/ui": {
93
+ "optional": true
94
+ },
95
+ "react": {
96
+ "optional": true
97
+ }
83
98
  },
84
99
  "publishConfig": {
85
100
  "ghpr": true,