strapi-plugin-ai-sdk 0.7.9 → 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 +176 -4
- package/dist/server/index.js +263 -47
- package/dist/server/index.mjs +263 -47
- package/dist/server/src/lib/tool-registry.d.ts +14 -0
- package/dist/server/src/lib/trim-messages.d.ts +11 -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,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
|
|
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:
|
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,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
|
|
273
|
+
const instructions = buildInstructions(registry);
|
|
274
|
+
const server = new index_js.Server(
|
|
127
275
|
{
|
|
128
|
-
name: "ai-sdk
|
|
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
|
|
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
|
-
|
|
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
|
-
};
|
|
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.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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}`);
|
|
@@ -3131,6 +3327,26 @@ function describeTools(tools) {
|
|
|
3131
3327
|
return `Available tools:
|
|
3132
3328
|
${lines.join("\n")}`;
|
|
3133
3329
|
}
|
|
3330
|
+
function trimMessages(messages, max) {
|
|
3331
|
+
if (messages.length <= max) return messages;
|
|
3332
|
+
const sliced = messages.slice(-max);
|
|
3333
|
+
while (sliced.length > 0 && hasOrphanedToolCalls(sliced[0])) {
|
|
3334
|
+
sliced.shift();
|
|
3335
|
+
}
|
|
3336
|
+
return sliced;
|
|
3337
|
+
}
|
|
3338
|
+
function hasOrphanedToolCalls(message) {
|
|
3339
|
+
if (message.role !== "assistant") return false;
|
|
3340
|
+
if (message.parts) {
|
|
3341
|
+
return message.parts.some(
|
|
3342
|
+
(part) => part.type === "tool-invocation"
|
|
3343
|
+
);
|
|
3344
|
+
}
|
|
3345
|
+
if (message.toolInvocations?.length) {
|
|
3346
|
+
return true;
|
|
3347
|
+
}
|
|
3348
|
+
return false;
|
|
3349
|
+
}
|
|
3134
3350
|
const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.
|
|
3135
3351
|
|
|
3136
3352
|
For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
|
|
@@ -3194,7 +3410,7 @@ const service = ({ strapi }) => {
|
|
|
3194
3410
|
const maxMessages = config2?.maxConversationMessages ?? DEFAULT_MAX_CONVERSATION_MESSAGES;
|
|
3195
3411
|
const maxOutputTokens = config2?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
3196
3412
|
const maxSteps = config2?.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
3197
|
-
const trimmedMessages = messages
|
|
3413
|
+
const trimmedMessages = trimMessages(messages, maxMessages);
|
|
3198
3414
|
const modelMessages = await ai.convertToModelMessages(trimmedMessages);
|
|
3199
3415
|
const tools = createTools(strapi, { adminUserId: options2?.adminUserId, enabledToolSources: options2?.enabledToolSources });
|
|
3200
3416
|
const toolsDescription = describeTools(tools);
|
|
@@ -3239,7 +3455,7 @@ ${lines.join("\n")}`;
|
|
|
3239
3455
|
const publicModel = publicConfig?.chatModel ?? DEFAULT_PUBLIC_CHAT_MODEL;
|
|
3240
3456
|
const allowedContentTypes = publicConfig?.allowedContentTypes ?? [];
|
|
3241
3457
|
const publicToolSources = publicConfig?.publicToolSources;
|
|
3242
|
-
const trimmedMessages = messages
|
|
3458
|
+
const trimmedMessages = trimMessages(messages, maxMessages);
|
|
3243
3459
|
const modelMessages = await ai.convertToModelMessages(trimmedMessages);
|
|
3244
3460
|
const tools = createPublicTools(strapi, allowedContentTypes, publicToolSources);
|
|
3245
3461
|
const toolsDescription = describeTools(tools);
|
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,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
|
|
253
|
+
const instructions = buildInstructions(registry);
|
|
254
|
+
const server = new Server(
|
|
107
255
|
{
|
|
108
|
-
name: "ai-sdk
|
|
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
|
|
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
|
-
|
|
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
|
-
};
|
|
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.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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}`);
|
|
@@ -3111,6 +3307,26 @@ function describeTools(tools) {
|
|
|
3111
3307
|
return `Available tools:
|
|
3112
3308
|
${lines.join("\n")}`;
|
|
3113
3309
|
}
|
|
3310
|
+
function trimMessages(messages, max) {
|
|
3311
|
+
if (messages.length <= max) return messages;
|
|
3312
|
+
const sliced = messages.slice(-max);
|
|
3313
|
+
while (sliced.length > 0 && hasOrphanedToolCalls(sliced[0])) {
|
|
3314
|
+
sliced.shift();
|
|
3315
|
+
}
|
|
3316
|
+
return sliced;
|
|
3317
|
+
}
|
|
3318
|
+
function hasOrphanedToolCalls(message) {
|
|
3319
|
+
if (message.role !== "assistant") return false;
|
|
3320
|
+
if (message.parts) {
|
|
3321
|
+
return message.parts.some(
|
|
3322
|
+
(part) => part.type === "tool-invocation"
|
|
3323
|
+
);
|
|
3324
|
+
}
|
|
3325
|
+
if (message.toolInvocations?.length) {
|
|
3326
|
+
return true;
|
|
3327
|
+
}
|
|
3328
|
+
return false;
|
|
3329
|
+
}
|
|
3114
3330
|
const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.
|
|
3115
3331
|
|
|
3116
3332
|
For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
|
|
@@ -3174,7 +3390,7 @@ const service = ({ strapi }) => {
|
|
|
3174
3390
|
const maxMessages = config2?.maxConversationMessages ?? DEFAULT_MAX_CONVERSATION_MESSAGES;
|
|
3175
3391
|
const maxOutputTokens = config2?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
|
3176
3392
|
const maxSteps = config2?.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
3177
|
-
const trimmedMessages = messages
|
|
3393
|
+
const trimmedMessages = trimMessages(messages, maxMessages);
|
|
3178
3394
|
const modelMessages = await convertToModelMessages(trimmedMessages);
|
|
3179
3395
|
const tools = createTools(strapi, { adminUserId: options2?.adminUserId, enabledToolSources: options2?.enabledToolSources });
|
|
3180
3396
|
const toolsDescription = describeTools(tools);
|
|
@@ -3219,7 +3435,7 @@ ${lines.join("\n")}`;
|
|
|
3219
3435
|
const publicModel = publicConfig?.chatModel ?? DEFAULT_PUBLIC_CHAT_MODEL;
|
|
3220
3436
|
const allowedContentTypes = publicConfig?.allowedContentTypes ?? [];
|
|
3221
3437
|
const publicToolSources = publicConfig?.publicToolSources;
|
|
3222
|
-
const trimmedMessages = messages
|
|
3438
|
+
const trimmedMessages = trimMessages(messages, maxMessages);
|
|
3223
3439
|
const modelMessages = await convertToModelMessages(trimmedMessages);
|
|
3224
3440
|
const tools = createPublicTools(strapi, allowedContentTypes, publicToolSources);
|
|
3225
3441
|
const toolsDescription = describeTools(tools);
|
|
@@ -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;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai';
|
|
2
|
+
/**
|
|
3
|
+
* Trim messages to a max count while keeping tool call/result pairs intact.
|
|
4
|
+
*
|
|
5
|
+
* When slicing from the end, the first remaining message might be an assistant
|
|
6
|
+
* message with tool invocations whose results lived in earlier (now-dropped)
|
|
7
|
+
* messages. The AI SDK throws MissingToolResultsError if it encounters an
|
|
8
|
+
* unmatched tool call. This function drops leading assistant messages that
|
|
9
|
+
* contain tool invocations to prevent that.
|
|
10
|
+
*/
|
|
11
|
+
export declare function trimMessages(messages: UIMessage[], max: number): UIMessage[];
|
|
@@ -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