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 +178 -4
- package/dist/server/index.js +314 -45
- package/dist/server/index.mjs +314 -45
- package/dist/server/src/lib/tool-registry.d.ts +14 -0
- package/dist/server/src/lib/types.d.ts +3 -3
- package/dist/server/src/mcp/server.d.ts +6 -2
- package/package.json +1 -1
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
|
|
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:
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const anthropic = require("@ai-sdk/anthropic");
|
|
3
|
-
const
|
|
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(
|
|
50
|
+
return extractType(def.innerType);
|
|
50
51
|
case "default":
|
|
51
|
-
return extractType(
|
|
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
|
|
344
|
+
const instructions = buildInstructions(registry);
|
|
345
|
+
const server = new index_js.Server(
|
|
127
346
|
{
|
|
128
|
-
name: "ai-sdk
|
|
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
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
mcpName,
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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}`);
|
package/dist/server/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
-
import {
|
|
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(
|
|
30
|
+
return extractType(def.innerType);
|
|
30
31
|
case "default":
|
|
31
|
-
return extractType(
|
|
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
|
|
324
|
+
const instructions = buildInstructions(registry);
|
|
325
|
+
const server = new Server(
|
|
107
326
|
{
|
|
108
|
-
name: "ai-sdk
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
mcpName,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 {
|
|
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:
|
|
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?: (() =>
|
|
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 {
|
|
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):
|
|
11
|
+
export declare function createMcpServer(strapi: Core.Strapi): Server;
|
package/package.json
CHANGED