strapi-plugin-ai-sdk 0.8.0 → 0.9.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,52 @@ 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
+ - 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
316
  - Sessions expire after the configured timeout (default: 4 hours)
312
317
  - Maximum concurrent sessions can be configured (default: 100)
313
318
 
319
+ ### Setup
320
+
321
+ #### 1. Enable permissions
322
+
323
+ In the Strapi admin panel:
324
+
325
+ 1. Go to **Settings > API Tokens**
326
+ 2. Create a new API token (or use an existing one)
327
+ 3. Under **Permissions**, enable the **Ai-sdk** actions: `handle` (covers POST, GET, DELETE for MCP)
328
+ 4. Copy the token
329
+
330
+ Alternatively, for public access without a token:
331
+
332
+ 1. Go to **Settings > Users & Permissions > Roles > Public**
333
+ 2. Under **Ai-sdk**, enable `handle`
334
+ 3. Save
335
+
336
+ #### 2. Connect your AI client
337
+
338
+ The MCP endpoint URL is:
339
+
340
+ ```
341
+ http://localhost:1337/api/ai-sdk/mcp
342
+ ```
343
+
344
+ For remote deployments, replace `localhost:1337` with your Strapi URL.
345
+
314
346
  ### Connecting from Claude Desktop
315
347
 
316
- Add to your Claude Desktop MCP config:
348
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
349
+
350
+ **Without authentication (public permissions enabled):**
317
351
 
318
352
  ```json
319
353
  {
@@ -325,9 +359,53 @@ Add to your Claude Desktop MCP config:
325
359
  }
326
360
  ```
327
361
 
362
+ **With an API token:**
363
+
364
+ ```json
365
+ {
366
+ "mcpServers": {
367
+ "strapi": {
368
+ "command": "npx",
369
+ "args": [
370
+ "mcp-remote",
371
+ "http://localhost:1337/api/ai-sdk/mcp",
372
+ "--header",
373
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
374
+ ]
375
+ }
376
+ }
377
+ }
378
+ ```
379
+
380
+ Restart Claude Desktop after saving the config.
381
+
382
+ ### Connecting from Claude Code
383
+
384
+ Add to `~/.claude/settings.json`:
385
+
386
+ ```json
387
+ {
388
+ "mcpServers": {
389
+ "strapi": {
390
+ "command": "npx",
391
+ "args": [
392
+ "mcp-remote",
393
+ "http://localhost:1337/api/ai-sdk/mcp",
394
+ "--header",
395
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
396
+ ]
397
+ }
398
+ }
399
+ }
400
+ ```
401
+
402
+ Or run: `claude mcp add strapi -- npx mcp-remote http://localhost:1337/api/ai-sdk/mcp --header "Authorization: Bearer YOUR_STRAPI_API_TOKEN"`
403
+
328
404
  ### Connecting from Cursor
329
405
 
330
- Add to your Cursor MCP settings:
406
+ Add to your Cursor MCP settings (`.cursor/mcp.json`):
407
+
408
+ **Without authentication:**
331
409
 
332
410
  ```json
333
411
  {
@@ -339,6 +417,71 @@ Add to your Cursor MCP settings:
339
417
  }
340
418
  ```
341
419
 
420
+ **With an API token:**
421
+
422
+ ```json
423
+ {
424
+ "mcpServers": {
425
+ "strapi": {
426
+ "command": "npx",
427
+ "args": [
428
+ "mcp-remote",
429
+ "http://localhost:1337/api/ai-sdk/mcp",
430
+ "--header",
431
+ "Authorization: Bearer YOUR_STRAPI_API_TOKEN"
432
+ ]
433
+ }
434
+ }
435
+ }
436
+ ```
437
+
438
+ ### Testing with cURL
439
+
440
+ ```bash
441
+ # 1. Initialize a session
442
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
443
+ -H "Content-Type: application/json" \
444
+ -H "Accept: application/json, text/event-stream" \
445
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
446
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}'
447
+
448
+ # 2. Send initialized notification (use the mcp-session-id from step 1)
449
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
450
+ -H "Content-Type: application/json" \
451
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
452
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
453
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
454
+
455
+ # 3. List available tools
456
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
457
+ -H "Content-Type: application/json" \
458
+ -H "Accept: application/json, text/event-stream" \
459
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
460
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
461
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
462
+
463
+ # 4. Call a tool
464
+ curl -s -X POST http://localhost:1337/api/ai-sdk/mcp \
465
+ -H "Content-Type: application/json" \
466
+ -H "Accept: application/json, text/event-stream" \
467
+ -H "Authorization: Bearer YOUR_STRAPI_API_TOKEN" \
468
+ -H "mcp-session-id: SESSION_ID_FROM_STEP_1" \
469
+ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_content_types","arguments":{}}}'
470
+ ```
471
+
472
+ ### MCP Configuration
473
+
474
+ ```typescript
475
+ // config/plugins.ts
476
+ config: {
477
+ mcp: {
478
+ sessionTimeoutMs: 4 * 60 * 60 * 1000, // 4 hours (default)
479
+ maxSessions: 100, // default
480
+ cleanupInterval: 100, // cleanup expired sessions every N requests
481
+ },
482
+ }
483
+ ```
484
+
342
485
  ## Guardrails
343
486
 
344
487
  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 +676,7 @@ export const mySearchTool = {
533
676
  };
534
677
  ```
535
678
 
536
- **2. Create the `ai-tools` service:**
679
+ **2. Create the `ai-tools` service with optional `getMeta()`:**
537
680
 
538
681
  ```typescript
539
682
  // server/src/services/ai-tools.ts
@@ -544,6 +687,19 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
544
687
  getTools() {
545
688
  return tools;
546
689
  },
690
+
691
+ /**
692
+ * Optional: provide metadata so the MCP server instructions
693
+ * include your plugin's capabilities and trigger keywords.
694
+ * Without this, a summary is auto-generated from tool descriptions.
695
+ */
696
+ getMeta() {
697
+ return {
698
+ label: 'My Plugin',
699
+ description: 'Search and manage my plugin data with relevance ranking',
700
+ keywords: ['/my-plugin', 'my data', 'search my stuff'],
701
+ };
702
+ },
547
703
  });
548
704
  ```
549
705
 
@@ -575,6 +731,22 @@ interface ToolDefinition {
575
731
  }
576
732
  ```
577
733
 
734
+ #### ToolSourceMeta Interface (optional `getMeta()`)
735
+
736
+ 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.
737
+
738
+ Without `getMeta()`, the AI SDK auto-generates a summary from your tool descriptions — so this is optional but recommended for better discoverability.
739
+
740
+ ```typescript
741
+ interface ToolSourceMeta {
742
+ label: string; // Human-readable label, e.g. "YouTube Transcripts"
743
+ description: string; // One-line capability summary for MCP instructions
744
+ keywords?: string[]; // Trigger keywords/prefixes, e.g. ["/youtube", "/yt", "transcript"]
745
+ }
746
+ ```
747
+
748
+ 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.
749
+
578
750
  #### Canonical Architecture Pattern
579
751
 
580
752
  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,246 @@ 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 zodToInputSchema(schema2) {
171
+ const shape = schema2.shape;
172
+ const properties = {};
173
+ const required = [];
174
+ for (const [key, fieldDef] of Object.entries(shape)) {
175
+ const prop = zodFieldToJsonSchema(fieldDef);
176
+ properties[key] = prop;
177
+ if (!isOptionalOrDefaulted(fieldDef)) {
178
+ required.push(key);
179
+ }
180
+ }
181
+ const result = {
182
+ type: "object",
183
+ properties,
184
+ additionalProperties: false
185
+ };
186
+ if (required.length > 0) {
187
+ result.required = required;
188
+ }
189
+ return result;
190
+ }
191
+ function isOptionalOrDefaulted(field) {
192
+ if (!field) return true;
193
+ const def = field._def;
194
+ if (!def) return false;
195
+ const typeName = def.typeName;
196
+ if (typeName === "ZodOptional" || typeName === "ZodDefault") return true;
197
+ if (def.innerType) return isOptionalOrDefaulted(def.innerType);
198
+ return false;
199
+ }
200
+ function zodFieldToJsonSchema(field) {
201
+ if (!field?._def) return {};
202
+ const def = field._def;
203
+ const typeName = def.typeName;
204
+ const prop = {};
205
+ if (def.description) prop.description = def.description;
206
+ switch (typeName) {
207
+ case "ZodString":
208
+ prop.type = "string";
209
+ break;
210
+ case "ZodNumber":
211
+ prop.type = "number";
212
+ if (def.checks) {
213
+ for (const check of def.checks) {
214
+ if (check.kind === "min") prop.minimum = check.value;
215
+ if (check.kind === "max") prop.maximum = check.value;
216
+ if (check.kind === "int") prop.type = "integer";
217
+ }
218
+ }
219
+ break;
220
+ case "ZodBoolean":
221
+ prop.type = "boolean";
222
+ break;
223
+ case "ZodEnum":
224
+ prop.type = "string";
225
+ prop.enum = def.values;
226
+ break;
227
+ case "ZodArray":
228
+ prop.type = "array";
229
+ if (def.type) {
230
+ prop.items = zodFieldToJsonSchema(def.type);
231
+ }
232
+ break;
233
+ case "ZodObject":
234
+ prop.type = "object";
235
+ if (field.shape) {
236
+ const nested = {};
237
+ for (const [k, v] of Object.entries(field.shape)) {
238
+ nested[k] = zodFieldToJsonSchema(v);
239
+ }
240
+ prop.properties = nested;
241
+ prop.additionalProperties = false;
242
+ }
243
+ break;
244
+ case "ZodOptional":
245
+ return { ...zodFieldToJsonSchema(def.innerType), ...def.description ? { description: def.description } : {} };
246
+ case "ZodDefault":
247
+ return {
248
+ ...zodFieldToJsonSchema(def.innerType),
249
+ default: def.defaultValue(),
250
+ ...def.description ? { description: def.description } : {}
251
+ };
252
+ case "ZodEffects":
253
+ return zodFieldToJsonSchema(def.schema);
254
+ case "ZodNullable":
255
+ return zodFieldToJsonSchema(def.innerType);
256
+ case "ZodUnion":
257
+ if (def.options?.length) {
258
+ return zodFieldToJsonSchema(def.options[0]);
259
+ }
260
+ break;
261
+ case "ZodRecord":
262
+ prop.type = "object";
263
+ break;
264
+ }
265
+ return prop;
266
+ }
120
267
  function createMcpServer(strapi) {
121
268
  const plugin = strapi.plugin("ai-sdk");
122
269
  const registry = plugin.toolRegistry;
123
270
  if (!registry) {
124
271
  throw new Error("Tool registry not initialized");
125
272
  }
126
- const server = new mcp_js.McpServer(
273
+ const instructions = buildInstructions(registry);
274
+ const server = new index_js.Server(
127
275
  {
128
- name: "ai-sdk-mcp",
276
+ name: "strapi-ai-sdk",
129
277
  version: "1.0.0"
130
278
  },
131
279
  {
132
280
  capabilities: {
133
281
  tools: {},
134
282
  resources: {}
135
- }
283
+ },
284
+ instructions
136
285
  }
137
286
  );
138
- const toolNames = [];
287
+ const toolList = [];
288
+ const toolHandlers = /* @__PURE__ */ new Map();
139
289
  for (const [name, def] of registry.getPublic()) {
140
290
  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
- };
291
+ const source = getToolSource(name);
292
+ toolList.push({
293
+ name: mcpName,
294
+ title: toTitle(mcpName, source),
295
+ description: def.description,
296
+ inputSchema: zodToInputSchema(def.schema),
297
+ annotations: {
298
+ readOnlyHint: def.publicSafe ?? false,
299
+ destructiveHint: false
159
300
  }
160
- );
301
+ });
302
+ toolHandlers.set(mcpName, def);
161
303
  }
304
+ server.setRequestHandler(types_js.ListToolsRequestSchema, async () => {
305
+ strapi.log.debug("[ai-sdk:mcp] Listing tools");
306
+ return { tools: toolList };
307
+ });
308
+ server.setRequestHandler(types_js.CallToolRequestSchema, async (request) => {
309
+ const { name, arguments: args } = request.params;
310
+ strapi.log.debug(`[ai-sdk:mcp] Tool call: ${name}`);
311
+ const def = toolHandlers.get(name);
312
+ if (!def) {
313
+ return {
314
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
315
+ isError: true
316
+ };
317
+ }
318
+ try {
319
+ const result = await def.execute(args ?? {}, strapi);
320
+ return {
321
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
322
+ };
323
+ } catch (error) {
324
+ strapi.log.error(`[ai-sdk:mcp] Tool ${name} failed:`, {
325
+ error: error instanceof Error ? error.message : String(error)
326
+ });
327
+ return {
328
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: String(error) }) }],
329
+ isError: true
330
+ };
331
+ }
332
+ });
162
333
  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
- );
334
+ server.setRequestHandler(types_js.ListResourcesRequestSchema, async () => ({
335
+ resources: [
336
+ {
337
+ uri: "strapi://tools/guide",
338
+ name: "Tool Guide",
339
+ description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
340
+ mimeType: "text/markdown"
341
+ }
342
+ ]
343
+ }));
344
+ server.setRequestHandler(types_js.ReadResourceRequestSchema, async (request) => {
345
+ if (request.params.uri === "strapi://tools/guide") {
346
+ return {
347
+ contents: [
348
+ {
349
+ uri: "strapi://tools/guide",
350
+ mimeType: "text/markdown",
351
+ text: guideMarkdown
352
+ }
353
+ ]
354
+ };
355
+ }
356
+ throw new Error(`Resource not found: ${request.params.uri}`);
357
+ });
358
+ const toolNames = toolList.map((t) => t.name);
180
359
  strapi.log.info("[ai-sdk:mcp] MCP server created with tools:", { tools: toolNames });
360
+ strapi.log.debug("[ai-sdk:mcp] Server instructions:", instructions);
181
361
  return server;
182
362
  }
183
363
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
@@ -280,6 +460,15 @@ class AIProvider {
280
460
  class ToolRegistry {
281
461
  constructor() {
282
462
  this.tools = /* @__PURE__ */ new Map();
463
+ this.sourceMeta = /* @__PURE__ */ new Map();
464
+ }
465
+ /** Register metadata for a tool source (plugin namespace) */
466
+ setSourceMeta(sourceId, meta) {
467
+ this.sourceMeta.set(sourceId, meta);
468
+ }
469
+ /** Get metadata for a tool source, if provided */
470
+ getSourceMeta(sourceId) {
471
+ return this.sourceMeta.get(sourceId);
283
472
  }
284
473
  register(def) {
285
474
  this.tools.set(def.name, def);
@@ -1492,6 +1681,13 @@ function discoverPluginTools(strapi, registry) {
1492
1681
  const count = registerContributedTools(strapi, registry, pluginName, contributed);
1493
1682
  if (count > 0) {
1494
1683
  strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1684
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1685
+ if (typeof aiToolsService.getMeta === "function") {
1686
+ const meta = aiToolsService.getMeta();
1687
+ if (meta?.label && meta?.description) {
1688
+ registry.setSourceMeta(safeName, meta);
1689
+ }
1690
+ }
1495
1691
  }
1496
1692
  } catch (err) {
1497
1693
  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,246 @@ 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 zodToInputSchema(schema2) {
151
+ const shape = schema2.shape;
152
+ const properties = {};
153
+ const required = [];
154
+ for (const [key, fieldDef] of Object.entries(shape)) {
155
+ const prop = zodFieldToJsonSchema(fieldDef);
156
+ properties[key] = prop;
157
+ if (!isOptionalOrDefaulted(fieldDef)) {
158
+ required.push(key);
159
+ }
160
+ }
161
+ const result = {
162
+ type: "object",
163
+ properties,
164
+ additionalProperties: false
165
+ };
166
+ if (required.length > 0) {
167
+ result.required = required;
168
+ }
169
+ return result;
170
+ }
171
+ function isOptionalOrDefaulted(field) {
172
+ if (!field) return true;
173
+ const def = field._def;
174
+ if (!def) return false;
175
+ const typeName = def.typeName;
176
+ if (typeName === "ZodOptional" || typeName === "ZodDefault") return true;
177
+ if (def.innerType) return isOptionalOrDefaulted(def.innerType);
178
+ return false;
179
+ }
180
+ function zodFieldToJsonSchema(field) {
181
+ if (!field?._def) return {};
182
+ const def = field._def;
183
+ const typeName = def.typeName;
184
+ const prop = {};
185
+ if (def.description) prop.description = def.description;
186
+ switch (typeName) {
187
+ case "ZodString":
188
+ prop.type = "string";
189
+ break;
190
+ case "ZodNumber":
191
+ prop.type = "number";
192
+ if (def.checks) {
193
+ for (const check of def.checks) {
194
+ if (check.kind === "min") prop.minimum = check.value;
195
+ if (check.kind === "max") prop.maximum = check.value;
196
+ if (check.kind === "int") prop.type = "integer";
197
+ }
198
+ }
199
+ break;
200
+ case "ZodBoolean":
201
+ prop.type = "boolean";
202
+ break;
203
+ case "ZodEnum":
204
+ prop.type = "string";
205
+ prop.enum = def.values;
206
+ break;
207
+ case "ZodArray":
208
+ prop.type = "array";
209
+ if (def.type) {
210
+ prop.items = zodFieldToJsonSchema(def.type);
211
+ }
212
+ break;
213
+ case "ZodObject":
214
+ prop.type = "object";
215
+ if (field.shape) {
216
+ const nested = {};
217
+ for (const [k, v] of Object.entries(field.shape)) {
218
+ nested[k] = zodFieldToJsonSchema(v);
219
+ }
220
+ prop.properties = nested;
221
+ prop.additionalProperties = false;
222
+ }
223
+ break;
224
+ case "ZodOptional":
225
+ return { ...zodFieldToJsonSchema(def.innerType), ...def.description ? { description: def.description } : {} };
226
+ case "ZodDefault":
227
+ return {
228
+ ...zodFieldToJsonSchema(def.innerType),
229
+ default: def.defaultValue(),
230
+ ...def.description ? { description: def.description } : {}
231
+ };
232
+ case "ZodEffects":
233
+ return zodFieldToJsonSchema(def.schema);
234
+ case "ZodNullable":
235
+ return zodFieldToJsonSchema(def.innerType);
236
+ case "ZodUnion":
237
+ if (def.options?.length) {
238
+ return zodFieldToJsonSchema(def.options[0]);
239
+ }
240
+ break;
241
+ case "ZodRecord":
242
+ prop.type = "object";
243
+ break;
244
+ }
245
+ return prop;
246
+ }
100
247
  function createMcpServer(strapi) {
101
248
  const plugin = strapi.plugin("ai-sdk");
102
249
  const registry = plugin.toolRegistry;
103
250
  if (!registry) {
104
251
  throw new Error("Tool registry not initialized");
105
252
  }
106
- const server = new McpServer(
253
+ const instructions = buildInstructions(registry);
254
+ const server = new Server(
107
255
  {
108
- name: "ai-sdk-mcp",
256
+ name: "strapi-ai-sdk",
109
257
  version: "1.0.0"
110
258
  },
111
259
  {
112
260
  capabilities: {
113
261
  tools: {},
114
262
  resources: {}
115
- }
263
+ },
264
+ instructions
116
265
  }
117
266
  );
118
- const toolNames = [];
267
+ const toolList = [];
268
+ const toolHandlers = /* @__PURE__ */ new Map();
119
269
  for (const [name, def] of registry.getPublic()) {
120
270
  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
- };
271
+ const source = getToolSource(name);
272
+ toolList.push({
273
+ name: mcpName,
274
+ title: toTitle(mcpName, source),
275
+ description: def.description,
276
+ inputSchema: zodToInputSchema(def.schema),
277
+ annotations: {
278
+ readOnlyHint: def.publicSafe ?? false,
279
+ destructiveHint: false
139
280
  }
140
- );
281
+ });
282
+ toolHandlers.set(mcpName, def);
141
283
  }
284
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
285
+ strapi.log.debug("[ai-sdk:mcp] Listing tools");
286
+ return { tools: toolList };
287
+ });
288
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
289
+ const { name, arguments: args } = request.params;
290
+ strapi.log.debug(`[ai-sdk:mcp] Tool call: ${name}`);
291
+ const def = toolHandlers.get(name);
292
+ if (!def) {
293
+ return {
294
+ content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
295
+ isError: true
296
+ };
297
+ }
298
+ try {
299
+ const result = await def.execute(args ?? {}, strapi);
300
+ return {
301
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
302
+ };
303
+ } catch (error) {
304
+ strapi.log.error(`[ai-sdk:mcp] Tool ${name} failed:`, {
305
+ error: error instanceof Error ? error.message : String(error)
306
+ });
307
+ return {
308
+ content: [{ type: "text", text: JSON.stringify({ error: true, message: String(error) }) }],
309
+ isError: true
310
+ };
311
+ }
312
+ });
142
313
  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
- );
314
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
315
+ resources: [
316
+ {
317
+ uri: "strapi://tools/guide",
318
+ name: "Tool Guide",
319
+ description: "Complete guide to all available Strapi AI tools with parameters and usage examples",
320
+ mimeType: "text/markdown"
321
+ }
322
+ ]
323
+ }));
324
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
325
+ if (request.params.uri === "strapi://tools/guide") {
326
+ return {
327
+ contents: [
328
+ {
329
+ uri: "strapi://tools/guide",
330
+ mimeType: "text/markdown",
331
+ text: guideMarkdown
332
+ }
333
+ ]
334
+ };
335
+ }
336
+ throw new Error(`Resource not found: ${request.params.uri}`);
337
+ });
338
+ const toolNames = toolList.map((t) => t.name);
160
339
  strapi.log.info("[ai-sdk:mcp] MCP server created with tools:", { tools: toolNames });
340
+ strapi.log.debug("[ai-sdk:mcp] Server instructions:", instructions);
161
341
  return server;
162
342
  }
163
343
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
@@ -260,6 +440,15 @@ class AIProvider {
260
440
  class ToolRegistry {
261
441
  constructor() {
262
442
  this.tools = /* @__PURE__ */ new Map();
443
+ this.sourceMeta = /* @__PURE__ */ new Map();
444
+ }
445
+ /** Register metadata for a tool source (plugin namespace) */
446
+ setSourceMeta(sourceId, meta) {
447
+ this.sourceMeta.set(sourceId, meta);
448
+ }
449
+ /** Get metadata for a tool source, if provided */
450
+ getSourceMeta(sourceId) {
451
+ return this.sourceMeta.get(sourceId);
263
452
  }
264
453
  register(def) {
265
454
  this.tools.set(def.name, def);
@@ -1472,6 +1661,13 @@ function discoverPluginTools(strapi, registry) {
1472
1661
  const count = registerContributedTools(strapi, registry, pluginName, contributed);
1473
1662
  if (count > 0) {
1474
1663
  strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1664
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1665
+ if (typeof aiToolsService.getMeta === "function") {
1666
+ const meta = aiToolsService.getMeta();
1667
+ if (meta?.label && meta?.description) {
1668
+ registry.setSourceMeta(safeName, meta);
1669
+ }
1670
+ }
1475
1671
  }
1476
1672
  } catch (err) {
1477
1673
  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.9.0",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",