strapi-plugin-ai-sdk 0.8.0 → 0.10.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.
package/README.md CHANGED
@@ -302,18 +302,54 @@ Additionally, the AI SDK automatically discovers tools from other installed plug
302
302
 
303
303
  ## MCP Server
304
304
 
305
- The plugin exposes an [MCP](https://modelcontextprotocol.io/) server at `/api/ai-sdk/mcp` that lets external AI clients call the public tools directly.
305
+ The plugin exposes an [MCP](https://modelcontextprotocol.io/) server at `/api/ai-sdk/mcp` that lets external AI clients (Claude Desktop, Claude Code, Cursor, Windsurf, etc.) call the public tools directly.
306
306
 
307
307
  ### How It Works
308
308
 
309
+ - Uses the low-level MCP `Server` class for full control over JSON Schema output, ensuring compatibility with `mcp-remote` and all MCP clients regardless of Zod version
310
+ - Uses the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) (MCP 2025-03-26 spec)
309
311
  - Sessions are created on first request and identified by the `mcp-session-id` header
310
312
  - Tool names are converted from camelCase to snake_case (`listContentTypes` -> `list_content_types`)
313
+ - Each tool includes a `title` (e.g. "Strapi: Search Content") and `annotations` (`readOnlyHint`, `destructiveHint`) for better client integration
314
+ - All tool schemas include `additionalProperties: false` to ensure compatibility with `mcp-remote` and Claude Desktop
315
+ - Custom Zod-to-JSON Schema converter that supports both Zod 3 and Zod 4, producing complete type information (types, descriptions, defaults, enums, constraints) for every parameter
316
+ - MCP arguments are coerced through the Zod schema before execution — stringified JSON values (e.g. `fields: '["title"]'`) are automatically parsed to their expected types, and defaults are applied for omitted optional parameters
317
+ - The server returns dynamic `instructions` during initialization so clients know when to load tools — plugins that provide `getMeta()` get keyword-driven entries (e.g. `/youtube`, `/octalens`), others get auto-generated summaries
311
318
  - Sessions expire after the configured timeout (default: 4 hours)
312
319
  - Maximum concurrent sessions can be configured (default: 100)
313
320
 
321
+ ### Setup
322
+
323
+ #### 1. Enable permissions
324
+
325
+ In the Strapi admin panel:
326
+
327
+ 1. Go to **Settings > API Tokens**
328
+ 2. Create a new API token (or use an existing one)
329
+ 3. Under **Permissions**, enable the **Ai-sdk** actions: `handle` (covers POST, GET, DELETE for MCP)
330
+ 4. Copy the token
331
+
332
+ Alternatively, for public access without a token:
333
+
334
+ 1. Go to **Settings > Users & Permissions > Roles > Public**
335
+ 2. Under **Ai-sdk**, enable `handle`
336
+ 3. Save
337
+
338
+ #### 2. Connect your AI client
339
+
340
+ The MCP endpoint URL is:
341
+
342
+ ```
343
+ http://localhost:1337/api/ai-sdk/mcp
344
+ ```
345
+
346
+ For remote deployments, replace `localhost:1337` with your Strapi URL.
347
+
314
348
  ### Connecting from Claude Desktop
315
349
 
316
- Add to your Claude Desktop MCP config:
350
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
351
+
352
+ **Without authentication (public permissions enabled):**
317
353
 
318
354
  ```json
319
355
  {
@@ -325,9 +361,53 @@ Add to your Claude Desktop MCP config:
325
361
  }
326
362
  ```
327
363
 
364
+ **With an API token:**
365
+
366
+ ```json
367
+ {
368
+ "mcpServers": {
369
+ "strapi": {
370
+ "command": "npx",
371
+ "args": [
372
+ "mcp-remote",
373
+ "http://localhost:1337/api/ai-sdk/mcp",
374
+ "--header",
375
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
376
+ ]
377
+ }
378
+ }
379
+ }
380
+ ```
381
+
382
+ Restart Claude Desktop after saving the config.
383
+
384
+ ### Connecting from Claude Code
385
+
386
+ Add to `~/.claude/settings.json`:
387
+
388
+ ```json
389
+ {
390
+ "mcpServers": {
391
+ "strapi": {
392
+ "command": "npx",
393
+ "args": [
394
+ "mcp-remote",
395
+ "http://localhost:1337/api/ai-sdk/mcp",
396
+ "--header",
397
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
398
+ ]
399
+ }
400
+ }
401
+ }
402
+ ```
403
+
404
+ Or run: `claude mcp add strapi -- npx mcp-remote http://localhost:1337/api/ai-sdk/mcp --header "Authorization: Bearer YOUR_STRAPI_API_TOKEN"`
405
+
328
406
  ### Connecting from Cursor
329
407
 
330
- Add to your Cursor MCP settings:
408
+ Add to your Cursor MCP settings (`.cursor/mcp.json`):
409
+
410
+ **Without authentication:**
331
411
 
332
412
  ```json
333
413
  {
@@ -339,6 +419,71 @@ Add to your Cursor MCP settings:
339
419
  }
340
420
  ```
341
421
 
422
+ **With an API token:**
423
+
424
+ ```json
425
+ {
426
+ "mcpServers": {
427
+ "strapi": {
428
+ "command": "npx",
429
+ "args": [
430
+ "mcp-remote",
431
+ "http://localhost:1337/api/ai-sdk/mcp",
432
+ "--header",
433
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
434
+ ]
435
+ }
436
+ }
437
+ }
438
+ ```
439
+
440
+ ### Testing with cURL
441
+
442
+ ```bash
443
+ # 1. Initialize a session
444
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
445
+ -H "Content-Type: application/json" \
446
+ -H "Accept: application/json, text/event-stream" \
447
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
448
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}'
449
+
450
+ # 2. Send initialized notification (use the mcp-session-id from step 1)
451
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
452
+ -H "Content-Type: application/json" \
453
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
454
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
455
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
456
+
457
+ # 3. List available tools
458
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
459
+ -H "Content-Type: application/json" \
460
+ -H "Accept: application/json, text/event-stream" \
461
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
462
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
463
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
464
+
465
+ # 4. Call a tool
466
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
467
+ -H "Content-Type: application/json" \
468
+ -H "Accept: application/json, text/event-stream" \
469
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
470
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
471
+ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_content_types","arguments":{}}}'
472
+ ```
473
+
474
+ ### MCP Configuration
475
+
476
+ ```typescript
477
+ // config/plugins.ts
478
+ config: {
479
+ mcp: {
480
+ sessionTimeoutMs: 4 * 60 * 60 * 1000, // 4 hours (default)
481
+ maxSessions: 100, // default
482
+ cleanupInterval: 100, // cleanup expired sessions every N requests
483
+ },
484
+ }
485
+ ```
486
+
342
487
  ## Guardrails
343
488
 
344
489
  The plugin includes a guardrail middleware that checks all user input before it reaches the AI. It runs on every AI endpoint (`/ask`, `/ask-stream`, `/chat`, `/mcp`).
@@ -533,7 +678,7 @@ export const mySearchTool = {
533
678
  };
534
679
  ```
535
680
 
536
- **2. Create the `ai-tools` service:**
681
+ **2. Create the `ai-tools` service with optional `getMeta()`:**
537
682
 
538
683
  ```typescript
539
684
  // server/src/services/ai-tools.ts
@@ -544,6 +689,19 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
544
689
  getTools() {
545
690
  return tools;
546
691
  },
692
+
693
+ /**
694
+ * Optional: provide metadata so the MCP server instructions
695
+ * include your plugin's capabilities and trigger keywords.
696
+ * Without this, a summary is auto-generated from tool descriptions.
697
+ */
698
+ getMeta() {
699
+ return {
700
+ label: 'My Plugin',
701
+ description: 'Search and manage my plugin data with relevance ranking',
702
+ keywords: ['/my-plugin', 'my data', 'search my stuff'],
703
+ };
704
+ },
547
705
  });
548
706
  ```
549
707
 
@@ -575,6 +733,22 @@ interface ToolDefinition {
575
733
  }
576
734
  ```
577
735
 
736
+ #### ToolSourceMeta Interface (optional `getMeta()`)
737
+
738
+ When your plugin provides `getMeta()` on its `ai-tools` service, the MCP server instructions include your plugin's capabilities with trigger keywords. This helps Claude Desktop's "Load tools when needed" mode activate the right server for your plugin's queries.
739
+
740
+ Without `getMeta()`, the AI SDK auto-generates a summary from your tool descriptions — so this is optional but recommended for better discoverability.
741
+
742
+ ```typescript
743
+ interface ToolSourceMeta {
744
+ label: string; // Human-readable label, e.g. "YouTube Transcripts"
745
+ description: string; // One-line capability summary for MCP instructions
746
+ keywords?: string[]; // Trigger keywords/prefixes, e.g. ["/youtube", "/yt", "transcript"]
747
+ }
748
+ ```
749
+
750
+ Keywords that start with `/` are rendered as slash-command hints in the instructions (e.g. `/youtube or /yt — Fetch and search YouTube transcripts`). Other keywords are included as natural-language triggers.
751
+
578
752
  #### Canonical Architecture Pattern
579
753
 
580
754
  The recommended pattern is to define tools once in `server/src/tools/` and consume them from both the AI SDK service and MCP handlers:
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  const anthropic = require("@ai-sdk/anthropic");
3
- const mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
3
+ const index_js = require("@modelcontextprotocol/sdk/server/index.js");
4
+ const types_js = require("@modelcontextprotocol/sdk/types.js");
4
5
  const ai = require("ai");
5
6
  const zod = require("zod");
6
7
  const fs = require("fs");
@@ -34,7 +35,7 @@ function toSnakeCase$1(str) {
34
35
  return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
35
36
  }
36
37
  function extractType(field) {
37
- const def = field?._zod?.def;
38
+ const def = field?._zod?.def ?? field?.def;
38
39
  if (!def) return "unknown";
39
40
  switch (def.type) {
40
41
  case "string":
@@ -44,11 +45,11 @@ function extractType(field) {
44
45
  case "boolean":
45
46
  return "boolean";
46
47
  case "enum":
47
- return Object.keys(def.entries).join(" | ");
48
+ return def.entries ? Object.keys(def.entries).join(" | ") : "enum";
48
49
  case "optional":
49
- return extractType({ _zod: { def: def.innerType } });
50
+ return extractType(def.innerType);
50
51
  case "default":
51
- return extractType({ _zod: { def: def.innerType } });
52
+ return extractType(def.innerType);
52
53
  case "record":
53
54
  return "object";
54
55
  case "array":
@@ -117,67 +118,319 @@ function generateToolGuide(registry) {
117
118
  function toSnakeCase(str) {
118
119
  return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
119
120
  }
121
+ function toTitle(mcpName, source) {
122
+ const prefix = source === "built-in" ? "Strapi" : source.replace(/_/g, "-");
123
+ const shortName = mcpName.replace(/^[a-z_]+__/, "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
124
+ return `${prefix}: ${shortName}`;
125
+ }
126
+ function getToolSource(name) {
127
+ const sep = name.indexOf("__");
128
+ return sep === -1 ? "built-in" : name.substring(0, sep);
129
+ }
130
+ function summarizeTools(tools, registry) {
131
+ const summaries = [];
132
+ for (const name of tools) {
133
+ const def = registry.get(name);
134
+ if (!def) continue;
135
+ const first = def.description.split(/\.\s/)[0].replace(/\.$/, "");
136
+ summaries.push(first);
137
+ }
138
+ return summaries.join(". ") + ".";
139
+ }
140
+ function buildInstructions(registry) {
141
+ const sources = registry.getToolSources();
142
+ const lines = [];
143
+ lines.push(
144
+ "Strapi CMS MCP server. Use this server for ANY of the following:"
145
+ );
146
+ lines.push(
147
+ "- /strapi — Query, create, update, delete CMS content (articles, pages, authors, categories, media, any custom content types)"
148
+ );
149
+ for (const source of sources) {
150
+ if (source.id === "built-in") continue;
151
+ const meta = registry.getSourceMeta(source.id);
152
+ if (meta) {
153
+ const prefix = meta.keywords?.length ? meta.keywords.map((k) => k.startsWith("/") ? k : `/${k}`).join(" or ") : `/${source.id.replace(/_/g, "-")}`;
154
+ lines.push(`- ${prefix} — ${meta.description}`);
155
+ } else {
156
+ const label = source.id.replace(/_/g, "-");
157
+ const summary = summarizeTools(source.tools, registry);
158
+ lines.push(`- /${label} — ${summary}`);
159
+ }
160
+ }
161
+ lines.push(
162
+ "- /memory — Save and recall user facts and preferences",
163
+ "- /notes — Save and recall research notes, code snippets, ideas",
164
+ "- /tasks — Create, update, complete, and list tasks",
165
+ "- /email — Send emails",
166
+ "- /media — Upload media files to the CMS"
167
+ );
168
+ return lines.join("\n");
169
+ }
170
+ function getZodType(field) {
171
+ const def = field?._def;
172
+ if (!def) return void 0;
173
+ return def.typeName ?? def.type;
174
+ }
175
+ function getDescription(field) {
176
+ return field?.description ?? field?._def?.description;
177
+ }
178
+ function zodToInputSchema(schema2) {
179
+ const shape = schema2.shape;
180
+ const properties = {};
181
+ const required = [];
182
+ for (const [key, fieldDef] of Object.entries(shape)) {
183
+ const prop = zodFieldToJsonSchema(fieldDef);
184
+ properties[key] = prop;
185
+ if (!isOptionalOrDefaulted(fieldDef)) {
186
+ required.push(key);
187
+ }
188
+ }
189
+ const result = {
190
+ type: "object",
191
+ properties,
192
+ additionalProperties: false
193
+ };
194
+ if (required.length > 0) {
195
+ result.required = required;
196
+ }
197
+ return result;
198
+ }
199
+ function isOptionalOrDefaulted(field) {
200
+ if (!field) return true;
201
+ const t = getZodType(field);
202
+ if (!t) return false;
203
+ const normalized = normalizeType(t);
204
+ if (normalized === "optional" || normalized === "default") return true;
205
+ if (field._def?.innerType) return isOptionalOrDefaulted(field._def.innerType);
206
+ return false;
207
+ }
208
+ function normalizeType(t) {
209
+ if (t.startsWith("Zod")) return t.slice(3).toLowerCase();
210
+ return t.toLowerCase();
211
+ }
212
+ function zodFieldToJsonSchema(field) {
213
+ const rawType = getZodType(field);
214
+ if (!rawType) return {};
215
+ const t = normalizeType(rawType);
216
+ const def = field._def;
217
+ const prop = {};
218
+ const desc = getDescription(field);
219
+ if (desc) prop.description = desc;
220
+ switch (t) {
221
+ case "string":
222
+ prop.type = "string";
223
+ break;
224
+ case "number": {
225
+ prop.type = "number";
226
+ if (field.isInt) prop.type = "integer";
227
+ if (typeof field.minValue === "number" && field.minValue > -Number.MAX_SAFE_INTEGER) prop.minimum = field.minValue;
228
+ if (typeof field.maxValue === "number" && field.maxValue < Number.MAX_SAFE_INTEGER) prop.maximum = field.maxValue;
229
+ if (def.checks && Array.isArray(def.checks)) {
230
+ for (const check of def.checks) {
231
+ if (check.kind === "min") prop.minimum = check.value;
232
+ if (check.kind === "max") prop.maximum = check.value;
233
+ if (check.kind === "int") prop.type = "integer";
234
+ }
235
+ }
236
+ break;
237
+ }
238
+ case "boolean":
239
+ prop.type = "boolean";
240
+ break;
241
+ case "enum": {
242
+ prop.type = "string";
243
+ if (Array.isArray(def.values)) {
244
+ prop.enum = def.values;
245
+ } else if (def.entries) {
246
+ prop.enum = Object.keys(def.entries);
247
+ } else if (Array.isArray(field.options)) {
248
+ prop.enum = field.options;
249
+ }
250
+ break;
251
+ }
252
+ case "array": {
253
+ prop.type = "array";
254
+ const itemType = def.element ?? def.type;
255
+ if (itemType) {
256
+ const itemSchema = zodFieldToJsonSchema(itemType);
257
+ prop.items = Object.keys(itemSchema).length > 0 ? itemSchema : { type: "string" };
258
+ }
259
+ break;
260
+ }
261
+ case "object": {
262
+ prop.type = "object";
263
+ if (field.shape) {
264
+ const nested = {};
265
+ for (const [k, v] of Object.entries(field.shape)) {
266
+ nested[k] = zodFieldToJsonSchema(v);
267
+ }
268
+ prop.properties = nested;
269
+ prop.additionalProperties = false;
270
+ }
271
+ break;
272
+ }
273
+ case "optional": {
274
+ const inner = zodFieldToJsonSchema(def.innerType);
275
+ if (desc) inner.description = desc;
276
+ return inner;
277
+ }
278
+ case "default": {
279
+ const inner = zodFieldToJsonSchema(def.innerType);
280
+ const dv = typeof def.defaultValue === "function" ? def.defaultValue() : def.defaultValue;
281
+ if (dv !== void 0) inner.default = dv;
282
+ if (desc) inner.description = desc;
283
+ return inner;
284
+ }
285
+ case "effects":
286
+ return zodFieldToJsonSchema(def.schema ?? def.innerType);
287
+ case "nullable":
288
+ return zodFieldToJsonSchema(def.innerType);
289
+ case "union": {
290
+ const options2 = def.options;
291
+ if (Array.isArray(options2) && options2.length > 0) {
292
+ return zodFieldToJsonSchema(options2[0]);
293
+ }
294
+ break;
295
+ }
296
+ case "record":
297
+ prop.type = "object";
298
+ break;
299
+ case "literal":
300
+ if (def.value !== void 0) {
301
+ prop.type = typeof def.value;
302
+ prop.enum = [def.value];
303
+ }
304
+ break;
305
+ }
306
+ return prop;
307
+ }
308
+ function coerceArgs(args, schema2) {
309
+ const shape = schema2.shape;
310
+ const result = { ...args };
311
+ for (const [key, value] of Object.entries(result)) {
312
+ if (typeof value !== "string") continue;
313
+ const fieldDef = shape[key];
314
+ if (!fieldDef) continue;
315
+ const expectedType = resolveBaseType(fieldDef);
316
+ if (expectedType === "object" || expectedType === "array") {
317
+ try {
318
+ const parsed = JSON.parse(value);
319
+ if (typeof parsed === "object" && parsed !== null) {
320
+ result[key] = parsed;
321
+ }
322
+ } catch {
323
+ }
324
+ }
325
+ }
326
+ return result;
327
+ }
328
+ function resolveBaseType(field) {
329
+ const rawType = getZodType(field);
330
+ if (!rawType) return void 0;
331
+ const t = normalizeType(rawType);
332
+ if ((t === "optional" || t === "default" || t === "nullable") && field._def?.innerType) {
333
+ return resolveBaseType(field._def.innerType);
334
+ }
335
+ if (t === "record") return "object";
336
+ return t;
337
+ }
120
338
  function createMcpServer(strapi) {
121
339
  const plugin = strapi.plugin("ai-sdk");
122
340
  const registry = plugin.toolRegistry;
123
341
  if (!registry) {
124
342
  throw new Error("Tool registry not initialized");
125
343
  }
126
- const server = new mcp_js.McpServer(
344
+ const instructions = buildInstructions(registry);
345
+ const server = new index_js.Server(
127
346
  {
128
- name: "ai-sdk-mcp",
347
+ name: "strapi-ai-sdk",
129
348
  version: "1.0.0"
130
349
  },
131
350
  {
132
351
  capabilities: {
133
352
  tools: {},
134
353
  resources: {}
135
- }
354
+ },
355
+ instructions
136
356
  }
137
357
  );
138
- const toolNames = [];
358
+ const toolList = [];
359
+ const toolHandlers = /* @__PURE__ */ new Map();
139
360
  for (const [name, def] of registry.getPublic()) {
140
361
  const mcpName = toSnakeCase(name);
141
- toolNames.push(mcpName);
142
- server.registerTool(
143
- mcpName,
144
- {
145
- description: def.description,
146
- inputSchema: def.schema.shape
147
- },
148
- async (args) => {
149
- strapi.log.debug(`[ai-sdk:mcp] Tool call: ${mcpName}`);
150
- const result = await def.execute(args, strapi);
151
- return {
152
- content: [
153
- {
154
- type: "text",
155
- text: JSON.stringify(result, null, 2)
156
- }
157
- ]
158
- };
362
+ const source = getToolSource(name);
363
+ toolList.push({
364
+ name: mcpName,
365
+ title: toTitle(mcpName, source),
366
+ description: def.description,
367
+ inputSchema: zodToInputSchema(def.schema),
368
+ annotations: {
369
+ readOnlyHint: def.publicSafe ?? false,
370
+ destructiveHint: false
159
371
  }
160
- );
372
+ });
373
+ toolHandlers.set(mcpName, def);
161
374
  }
375
+ server.setRequestHandler(types_js.ListToolsRequestSchema, async () => {
376
+ strapi.log.debug("[ai-sdk:mcp] Listing tools");
377
+ return { tools: toolList };
378
+ });
379
+ server.setRequestHandler(types_js.CallToolRequestSchema, async (request) => {
380
+ const { name, arguments: args } = request.params;
381
+ strapi.log.debug(`[ai-sdk:mcp] Tool call: ${name}`);
382
+ const def = toolHandlers.get(name);
383
+ if (!def) {
384
+ return {
385
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
386
+ isError: true
387
+ };
388
+ }
389
+ try {
390
+ const coerced = coerceArgs(args ?? {}, def.schema);
391
+ const validated = def.schema.parse(coerced);
392
+ const result = await def.execute(validated, strapi);
393
+ return {
394
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
395
+ };
396
+ } catch (error) {
397
+ strapi.log.error(`[ai-sdk:mcp] Tool ${name} failed:`, {
398
+ error: error instanceof Error ? error.message : String(error)
399
+ });
400
+ return {
401
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: String(error) }) }],
402
+ isError: true
403
+ };
404
+ }
405
+ });
162
406
  const guideMarkdown = generateToolGuide(registry);
163
- server.registerResource(
164
- "Tool Guide",
165
- "strapi://tools/guide",
166
- {
167
- description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
168
- mimeType: "text/markdown"
169
- },
170
- async () => ({
171
- contents: [
172
- {
173
- uri: "strapi://tools/guide",
174
- mimeType: "text/markdown",
175
- text: guideMarkdown
176
- }
177
- ]
178
- })
179
- );
407
+ server.setRequestHandler(types_js.ListResourcesRequestSchema, async () => ({
408
+ resources: [
409
+ {
410
+ uri: "strapi://tools/guide",
411
+ name: "Tool Guide",
412
+ description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
413
+ mimeType: "text/markdown"
414
+ }
415
+ ]
416
+ }));
417
+ server.setRequestHandler(types_js.ReadResourceRequestSchema, async (request) => {
418
+ if (request.params.uri === "strapi://tools/guide") {
419
+ return {
420
+ contents: [
421
+ {
422
+ uri: "strapi://tools/guide",
423
+ mimeType: "text/markdown",
424
+ text: guideMarkdown
425
+ }
426
+ ]
427
+ };
428
+ }
429
+ throw new Error(`Resource not found: ${request.params.uri}`);
430
+ });
431
+ const toolNames = toolList.map((t) => t.name);
180
432
  strapi.log.info("[ai-sdk:mcp] MCP server created with tools:", { tools: toolNames });
433
+ strapi.log.debug("[ai-sdk:mcp] Server instructions:", instructions);
181
434
  return server;
182
435
  }
183
436
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
@@ -280,6 +533,15 @@ class AIProvider {
280
533
  class ToolRegistry {
281
534
  constructor() {
282
535
  this.tools = /* @__PURE__ */ new Map();
536
+ this.sourceMeta = /* @__PURE__ */ new Map();
537
+ }
538
+ /** Register metadata for a tool source (plugin namespace) */
539
+ setSourceMeta(sourceId, meta) {
540
+ this.sourceMeta.set(sourceId, meta);
541
+ }
542
+ /** Get metadata for a tool source, if provided */
543
+ getSourceMeta(sourceId) {
544
+ return this.sourceMeta.get(sourceId);
283
545
  }
284
546
  register(def) {
285
547
  this.tools.set(def.name, def);
@@ -1492,6 +1754,13 @@ function discoverPluginTools(strapi, registry) {
1492
1754
  const count = registerContributedTools(strapi, registry, pluginName, contributed);
1493
1755
  if (count > 0) {
1494
1756
  strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1757
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1758
+ if (typeof aiToolsService.getMeta === "function") {
1759
+ const meta = aiToolsService.getMeta();
1760
+ if (meta?.label && meta?.description) {
1761
+ registry.setSourceMeta(safeName, meta);
1762
+ }
1763
+ }
1495
1764
  }
1496
1765
  } catch (err) {
1497
1766
  strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
@@ -1,5 +1,6 @@
1
1
  import { createAnthropic } from "@ai-sdk/anthropic";
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
3
4
  import { generateText, streamText, tool, zodSchema, convertToModelMessages, stepCountIs } from "ai";
4
5
  import { z } from "zod";
5
6
  import { mkdtempSync, writeFileSync, unlinkSync, rmdirSync } from "fs";
@@ -14,7 +15,7 @@ function toSnakeCase$1(str) {
14
15
  return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
15
16
  }
16
17
  function extractType(field) {
17
- const def = field?._zod?.def;
18
+ const def = field?._zod?.def ?? field?.def;
18
19
  if (!def) return "unknown";
19
20
  switch (def.type) {
20
21
  case "string":
@@ -24,11 +25,11 @@ function extractType(field) {
24
25
  case "boolean":
25
26
  return "boolean";
26
27
  case "enum":
27
- return Object.keys(def.entries).join(" | ");
28
+ return def.entries ? Object.keys(def.entries).join(" | ") : "enum";
28
29
  case "optional":
29
- return extractType({ _zod: { def: def.innerType } });
30
+ return extractType(def.innerType);
30
31
  case "default":
31
- return extractType({ _zod: { def: def.innerType } });
32
+ return extractType(def.innerType);
32
33
  case "record":
33
34
  return "object";
34
35
  case "array":
@@ -97,67 +98,319 @@ function generateToolGuide(registry) {
97
98
  function toSnakeCase(str) {
98
99
  return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
99
100
  }
101
+ function toTitle(mcpName, source) {
102
+ const prefix = source === "built-in" ? "Strapi" : source.replace(/_/g, "-");
103
+ const shortName = mcpName.replace(/^[a-z_]+__/, "").replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
104
+ return `${prefix}: ${shortName}`;
105
+ }
106
+ function getToolSource(name) {
107
+ const sep = name.indexOf("__");
108
+ return sep === -1 ? "built-in" : name.substring(0, sep);
109
+ }
110
+ function summarizeTools(tools, registry) {
111
+ const summaries = [];
112
+ for (const name of tools) {
113
+ const def = registry.get(name);
114
+ if (!def) continue;
115
+ const first = def.description.split(/\.\s/)[0].replace(/\.$/, "");
116
+ summaries.push(first);
117
+ }
118
+ return summaries.join(". ") + ".";
119
+ }
120
+ function buildInstructions(registry) {
121
+ const sources = registry.getToolSources();
122
+ const lines = [];
123
+ lines.push(
124
+ "Strapi CMS MCP server. Use this server for ANY of the following:"
125
+ );
126
+ lines.push(
127
+ "- /strapi — Query, create, update, delete CMS content (articles, pages, authors, categories, media, any custom content types)"
128
+ );
129
+ for (const source of sources) {
130
+ if (source.id === "built-in") continue;
131
+ const meta = registry.getSourceMeta(source.id);
132
+ if (meta) {
133
+ const prefix = meta.keywords?.length ? meta.keywords.map((k) => k.startsWith("/") ? k : `/${k}`).join(" or ") : `/${source.id.replace(/_/g, "-")}`;
134
+ lines.push(`- ${prefix} — ${meta.description}`);
135
+ } else {
136
+ const label = source.id.replace(/_/g, "-");
137
+ const summary = summarizeTools(source.tools, registry);
138
+ lines.push(`- /${label} — ${summary}`);
139
+ }
140
+ }
141
+ lines.push(
142
+ "- /memory — Save and recall user facts and preferences",
143
+ "- /notes — Save and recall research notes, code snippets, ideas",
144
+ "- /tasks — Create, update, complete, and list tasks",
145
+ "- /email — Send emails",
146
+ "- /media — Upload media files to the CMS"
147
+ );
148
+ return lines.join("\n");
149
+ }
150
+ function getZodType(field) {
151
+ const def = field?._def;
152
+ if (!def) return void 0;
153
+ return def.typeName ?? def.type;
154
+ }
155
+ function getDescription(field) {
156
+ return field?.description ?? field?._def?.description;
157
+ }
158
+ function zodToInputSchema(schema2) {
159
+ const shape = schema2.shape;
160
+ const properties = {};
161
+ const required = [];
162
+ for (const [key, fieldDef] of Object.entries(shape)) {
163
+ const prop = zodFieldToJsonSchema(fieldDef);
164
+ properties[key] = prop;
165
+ if (!isOptionalOrDefaulted(fieldDef)) {
166
+ required.push(key);
167
+ }
168
+ }
169
+ const result = {
170
+ type: "object",
171
+ properties,
172
+ additionalProperties: false
173
+ };
174
+ if (required.length > 0) {
175
+ result.required = required;
176
+ }
177
+ return result;
178
+ }
179
+ function isOptionalOrDefaulted(field) {
180
+ if (!field) return true;
181
+ const t = getZodType(field);
182
+ if (!t) return false;
183
+ const normalized = normalizeType(t);
184
+ if (normalized === "optional" || normalized === "default") return true;
185
+ if (field._def?.innerType) return isOptionalOrDefaulted(field._def.innerType);
186
+ return false;
187
+ }
188
+ function normalizeType(t) {
189
+ if (t.startsWith("Zod")) return t.slice(3).toLowerCase();
190
+ return t.toLowerCase();
191
+ }
192
+ function zodFieldToJsonSchema(field) {
193
+ const rawType = getZodType(field);
194
+ if (!rawType) return {};
195
+ const t = normalizeType(rawType);
196
+ const def = field._def;
197
+ const prop = {};
198
+ const desc = getDescription(field);
199
+ if (desc) prop.description = desc;
200
+ switch (t) {
201
+ case "string":
202
+ prop.type = "string";
203
+ break;
204
+ case "number": {
205
+ prop.type = "number";
206
+ if (field.isInt) prop.type = "integer";
207
+ if (typeof field.minValue === "number" && field.minValue > -Number.MAX_SAFE_INTEGER) prop.minimum = field.minValue;
208
+ if (typeof field.maxValue === "number" && field.maxValue < Number.MAX_SAFE_INTEGER) prop.maximum = field.maxValue;
209
+ if (def.checks && Array.isArray(def.checks)) {
210
+ for (const check of def.checks) {
211
+ if (check.kind === "min") prop.minimum = check.value;
212
+ if (check.kind === "max") prop.maximum = check.value;
213
+ if (check.kind === "int") prop.type = "integer";
214
+ }
215
+ }
216
+ break;
217
+ }
218
+ case "boolean":
219
+ prop.type = "boolean";
220
+ break;
221
+ case "enum": {
222
+ prop.type = "string";
223
+ if (Array.isArray(def.values)) {
224
+ prop.enum = def.values;
225
+ } else if (def.entries) {
226
+ prop.enum = Object.keys(def.entries);
227
+ } else if (Array.isArray(field.options)) {
228
+ prop.enum = field.options;
229
+ }
230
+ break;
231
+ }
232
+ case "array": {
233
+ prop.type = "array";
234
+ const itemType = def.element ?? def.type;
235
+ if (itemType) {
236
+ const itemSchema = zodFieldToJsonSchema(itemType);
237
+ prop.items = Object.keys(itemSchema).length > 0 ? itemSchema : { type: "string" };
238
+ }
239
+ break;
240
+ }
241
+ case "object": {
242
+ prop.type = "object";
243
+ if (field.shape) {
244
+ const nested = {};
245
+ for (const [k, v] of Object.entries(field.shape)) {
246
+ nested[k] = zodFieldToJsonSchema(v);
247
+ }
248
+ prop.properties = nested;
249
+ prop.additionalProperties = false;
250
+ }
251
+ break;
252
+ }
253
+ case "optional": {
254
+ const inner = zodFieldToJsonSchema(def.innerType);
255
+ if (desc) inner.description = desc;
256
+ return inner;
257
+ }
258
+ case "default": {
259
+ const inner = zodFieldToJsonSchema(def.innerType);
260
+ const dv = typeof def.defaultValue === "function" ? def.defaultValue() : def.defaultValue;
261
+ if (dv !== void 0) inner.default = dv;
262
+ if (desc) inner.description = desc;
263
+ return inner;
264
+ }
265
+ case "effects":
266
+ return zodFieldToJsonSchema(def.schema ?? def.innerType);
267
+ case "nullable":
268
+ return zodFieldToJsonSchema(def.innerType);
269
+ case "union": {
270
+ const options2 = def.options;
271
+ if (Array.isArray(options2) && options2.length > 0) {
272
+ return zodFieldToJsonSchema(options2[0]);
273
+ }
274
+ break;
275
+ }
276
+ case "record":
277
+ prop.type = "object";
278
+ break;
279
+ case "literal":
280
+ if (def.value !== void 0) {
281
+ prop.type = typeof def.value;
282
+ prop.enum = [def.value];
283
+ }
284
+ break;
285
+ }
286
+ return prop;
287
+ }
288
+ function coerceArgs(args, schema2) {
289
+ const shape = schema2.shape;
290
+ const result = { ...args };
291
+ for (const [key, value] of Object.entries(result)) {
292
+ if (typeof value !== "string") continue;
293
+ const fieldDef = shape[key];
294
+ if (!fieldDef) continue;
295
+ const expectedType = resolveBaseType(fieldDef);
296
+ if (expectedType === "object" || expectedType === "array") {
297
+ try {
298
+ const parsed = JSON.parse(value);
299
+ if (typeof parsed === "object" && parsed !== null) {
300
+ result[key] = parsed;
301
+ }
302
+ } catch {
303
+ }
304
+ }
305
+ }
306
+ return result;
307
+ }
308
+ function resolveBaseType(field) {
309
+ const rawType = getZodType(field);
310
+ if (!rawType) return void 0;
311
+ const t = normalizeType(rawType);
312
+ if ((t === "optional" || t === "default" || t === "nullable") && field._def?.innerType) {
313
+ return resolveBaseType(field._def.innerType);
314
+ }
315
+ if (t === "record") return "object";
316
+ return t;
317
+ }
100
318
  function createMcpServer(strapi) {
101
319
  const plugin = strapi.plugin("ai-sdk");
102
320
  const registry = plugin.toolRegistry;
103
321
  if (!registry) {
104
322
  throw new Error("Tool registry not initialized");
105
323
  }
106
- const server = new McpServer(
324
+ const instructions = buildInstructions(registry);
325
+ const server = new Server(
107
326
  {
108
- name: "ai-sdk-mcp",
327
+ name: "strapi-ai-sdk",
109
328
  version: "1.0.0"
110
329
  },
111
330
  {
112
331
  capabilities: {
113
332
  tools: {},
114
333
  resources: {}
115
- }
334
+ },
335
+ instructions
116
336
  }
117
337
  );
118
- const toolNames = [];
338
+ const toolList = [];
339
+ const toolHandlers = /* @__PURE__ */ new Map();
119
340
  for (const [name, def] of registry.getPublic()) {
120
341
  const mcpName = toSnakeCase(name);
121
- toolNames.push(mcpName);
122
- server.registerTool(
123
- mcpName,
124
- {
125
- description: def.description,
126
- inputSchema: def.schema.shape
127
- },
128
- async (args) => {
129
- strapi.log.debug(`[ai-sdk:mcp] Tool call: ${mcpName}`);
130
- const result = await def.execute(args, strapi);
131
- return {
132
- content: [
133
- {
134
- type: "text",
135
- text: JSON.stringify(result, null, 2)
136
- }
137
- ]
138
- };
342
+ const source = getToolSource(name);
343
+ toolList.push({
344
+ name: mcpName,
345
+ title: toTitle(mcpName, source),
346
+ description: def.description,
347
+ inputSchema: zodToInputSchema(def.schema),
348
+ annotations: {
349
+ readOnlyHint: def.publicSafe ?? false,
350
+ destructiveHint: false
139
351
  }
140
- );
352
+ });
353
+ toolHandlers.set(mcpName, def);
141
354
  }
355
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
356
+ strapi.log.debug("[ai-sdk:mcp] Listing tools");
357
+ return { tools: toolList };
358
+ });
359
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
360
+ const { name, arguments: args } = request.params;
361
+ strapi.log.debug(`[ai-sdk:mcp] Tool call: ${name}`);
362
+ const def = toolHandlers.get(name);
363
+ if (!def) {
364
+ return {
365
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
366
+ isError: true
367
+ };
368
+ }
369
+ try {
370
+ const coerced = coerceArgs(args ?? {}, def.schema);
371
+ const validated = def.schema.parse(coerced);
372
+ const result = await def.execute(validated, strapi);
373
+ return {
374
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
375
+ };
376
+ } catch (error) {
377
+ strapi.log.error(`[ai-sdk:mcp] Tool ${name} failed:`, {
378
+ error: error instanceof Error ? error.message : String(error)
379
+ });
380
+ return {
381
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: String(error) }) }],
382
+ isError: true
383
+ };
384
+ }
385
+ });
142
386
  const guideMarkdown = generateToolGuide(registry);
143
- server.registerResource(
144
- "Tool Guide",
145
- "strapi://tools/guide",
146
- {
147
- description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
148
- mimeType: "text/markdown"
149
- },
150
- async () => ({
151
- contents: [
152
- {
153
- uri: "strapi://tools/guide",
154
- mimeType: "text/markdown",
155
- text: guideMarkdown
156
- }
157
- ]
158
- })
159
- );
387
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
388
+ resources: [
389
+ {
390
+ uri: "strapi://tools/guide",
391
+ name: "Tool Guide",
392
+ description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
393
+ mimeType: "text/markdown"
394
+ }
395
+ ]
396
+ }));
397
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
398
+ if (request.params.uri === "strapi://tools/guide") {
399
+ return {
400
+ contents: [
401
+ {
402
+ uri: "strapi://tools/guide",
403
+ mimeType: "text/markdown",
404
+ text: guideMarkdown
405
+ }
406
+ ]
407
+ };
408
+ }
409
+ throw new Error(`Resource not found: ${request.params.uri}`);
410
+ });
411
+ const toolNames = toolList.map((t) => t.name);
160
412
  strapi.log.info("[ai-sdk:mcp] MCP server created with tools:", { tools: toolNames });
413
+ strapi.log.debug("[ai-sdk:mcp] Server instructions:", instructions);
161
414
  return server;
162
415
  }
163
416
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
@@ -260,6 +513,15 @@ class AIProvider {
260
513
  class ToolRegistry {
261
514
  constructor() {
262
515
  this.tools = /* @__PURE__ */ new Map();
516
+ this.sourceMeta = /* @__PURE__ */ new Map();
517
+ }
518
+ /** Register metadata for a tool source (plugin namespace) */
519
+ setSourceMeta(sourceId, meta) {
520
+ this.sourceMeta.set(sourceId, meta);
521
+ }
522
+ /** Get metadata for a tool source, if provided */
523
+ getSourceMeta(sourceId) {
524
+ return this.sourceMeta.get(sourceId);
263
525
  }
264
526
  register(def) {
265
527
  this.tools.set(def.name, def);
@@ -1472,6 +1734,13 @@ function discoverPluginTools(strapi, registry) {
1472
1734
  const count = registerContributedTools(strapi, registry, pluginName, contributed);
1473
1735
  if (count > 0) {
1474
1736
  strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1737
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1738
+ if (typeof aiToolsService.getMeta === "function") {
1739
+ const meta = aiToolsService.getMeta();
1740
+ if (meta?.label && meta?.description) {
1741
+ registry.setSourceMeta(safeName, meta);
1742
+ }
1743
+ }
1475
1744
  }
1476
1745
  } catch (err) {
1477
1746
  strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
@@ -16,8 +16,22 @@ export interface ToolDefinition {
16
16
  }
17
17
  /** Type alias for external plugin authors to import when contributing tools */
18
18
  export type AiToolContribution = ToolDefinition;
19
+ /** Metadata a plugin can optionally provide to describe its tool source */
20
+ export interface ToolSourceMeta {
21
+ /** Short human-readable label, e.g. "YouTube Transcripts" */
22
+ label: string;
23
+ /** One-line capability summary for MCP instructions */
24
+ description: string;
25
+ /** Trigger keywords/prefixes users might type, e.g. ["/youtube", "/yt", "transcript"] */
26
+ keywords?: string[];
27
+ }
19
28
  export declare class ToolRegistry {
20
29
  private readonly tools;
30
+ private readonly sourceMeta;
31
+ /** Register metadata for a tool source (plugin namespace) */
32
+ setSourceMeta(sourceId: string, meta: ToolSourceMeta): void;
33
+ /** Get metadata for a tool source, if provided */
34
+ getSourceMeta(sourceId: string): ToolSourceMeta | undefined;
21
35
  register(def: ToolDefinition): void;
22
36
  unregister(name: string): boolean;
23
37
  get(name: string): ToolDefinition | undefined;
@@ -1,5 +1,5 @@
1
1
  import type { ModelMessage, ToolSet, StopCondition } from 'ai';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import type { AIProvider } from './ai-provider';
5
5
  import type { ToolRegistry } from './tool-registry';
@@ -72,14 +72,14 @@ export interface StreamTextResult {
72
72
  }
73
73
  export declare function isPromptInput(input: GenerateInput): input is PromptInput;
74
74
  export interface MCPSession {
75
- server: McpServer;
75
+ server: Server;
76
76
  transport: StreamableHTTPServerTransport;
77
77
  createdAt: number;
78
78
  }
79
79
  export interface PluginInstance {
80
80
  aiProvider?: AIProvider;
81
81
  toolRegistry?: ToolRegistry;
82
- createMcpServer?: (() => McpServer) | null;
82
+ createMcpServer?: (() => Server) | null;
83
83
  mcpSessions?: Map<string, MCPSession> | null;
84
84
  }
85
85
  export {};
@@ -1,7 +1,11 @@
1
1
  import type { Core } from '@strapi/strapi';
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
3
  /**
4
4
  * Create an MCP server instance configured with public tools from the registry.
5
5
  * Internal tools are excluded.
6
+ *
7
+ * Uses the low-level Server class (not McpServer) for full control over
8
+ * the JSON Schema output, ensuring compatibility with mcp-remote and
9
+ * Claude Desktop regardless of Zod version.
6
10
  */
7
- export declare function createMcpServer(strapi: Core.Strapi): McpServer;
11
+ export declare function createMcpServer(strapi: Core.Strapi): Server;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.8.0",
2
+ "version": "0.10.0",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",