strapi-plugin-ai-sdk 0.6.10 → 0.7.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 +151 -2
- package/dist/server/index.js +48 -1
- package/dist/server/index.mjs +48 -1
- package/dist/server/src/lib/tool-registry.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -286,9 +286,12 @@ The AI assistant has access to these tools. Tools marked as **public** are also
|
|
|
286
286
|
|------|----------|-------------|
|
|
287
287
|
| `listContentTypes` | `list_content_types` | List all Strapi content types and components with their fields and relations |
|
|
288
288
|
| `searchContent` | `search_content` | Search and query any content type with filters, sorting, and pagination |
|
|
289
|
+
| `aggregateContent` | `aggregate_content` | Count, group, and analyze content (faster than searchContent for analytics) |
|
|
289
290
|
| `writeContent` | `write_content` | Create or update documents in any content type |
|
|
290
291
|
| `sendEmail` | `send_email` | Send emails via the configured email provider (e.g. Resend) |
|
|
291
292
|
|
|
293
|
+
Additionally, the AI SDK automatically discovers tools from other installed plugins (see [Extending the Plugin](#adding-tools-from-other-plugins-convention-based-discovery)). For example, with the mentions and embeddings plugins installed, the AI also has access to `searchMentions`, `semanticSearch`, `ragQuery`, and more.
|
|
294
|
+
|
|
292
295
|
### Tool Details
|
|
293
296
|
|
|
294
297
|
**searchContent** parameters: `contentType` (required), `query`, `filters`, `fields`, `sort`, `page`, `pageSize` (max 50)
|
|
@@ -465,7 +468,151 @@ curl -N -X POST http://localhost:1337/api/ai-sdk/ask-stream \
|
|
|
465
468
|
|
|
466
469
|
## Extending the Plugin
|
|
467
470
|
|
|
468
|
-
### Adding
|
|
471
|
+
### Adding Tools from Other Plugins (Convention-Based Discovery)
|
|
472
|
+
|
|
473
|
+
Any Strapi plugin can contribute tools to the AI SDK by exposing an `ai-tools` service with a `getTools()` method. The AI SDK discovers these automatically at boot time -- no configuration required.
|
|
474
|
+
|
|
475
|
+
```mermaid
|
|
476
|
+
flowchart LR
|
|
477
|
+
subgraph "AI SDK Bootstrap"
|
|
478
|
+
B[Scan all plugins]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
subgraph "Plugin A"
|
|
482
|
+
A1[ai-tools service] --> A2["getTools()"]
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
subgraph "Plugin B"
|
|
486
|
+
B1[ai-tools service] --> B2["getTools()"]
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
B --> A1
|
|
490
|
+
B --> B1
|
|
491
|
+
A2 --> R[ToolRegistry]
|
|
492
|
+
B2 --> R
|
|
493
|
+
R --> Chat[Admin Chat]
|
|
494
|
+
R --> MCP[MCP Server]
|
|
495
|
+
R --> Public[Public Chat]
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
#### How It Works
|
|
499
|
+
|
|
500
|
+
1. On startup, the AI SDK scans every loaded plugin for an `ai-tools` service
|
|
501
|
+
2. If found, it calls `getTools()` which returns an array of `ToolDefinition` objects
|
|
502
|
+
3. Each tool is namespaced as `pluginName__toolName` (e.g., `octalens_mentions__searchMentions`) to prevent collisions
|
|
503
|
+
4. Discovered tools are registered in the shared `ToolRegistry` alongside built-in tools
|
|
504
|
+
5. All registered tools are available in admin chat, public chat (if `publicSafe: true`), and MCP
|
|
505
|
+
|
|
506
|
+
#### Creating an `ai-tools` Service in Your Plugin
|
|
507
|
+
|
|
508
|
+
**1. Define canonical tools** in `server/src/tools/`:
|
|
509
|
+
|
|
510
|
+
```typescript
|
|
511
|
+
// server/src/tools/my-tool.ts
|
|
512
|
+
import { z } from 'zod';
|
|
513
|
+
import type { Core } from '@strapi/strapi';
|
|
514
|
+
|
|
515
|
+
const schema = z.object({
|
|
516
|
+
query: z.string().describe('Search query'),
|
|
517
|
+
limit: z.number().min(1).max(50).optional().default(10).describe('Max results'),
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
export const mySearchTool = {
|
|
521
|
+
name: 'mySearch',
|
|
522
|
+
description: 'Search my plugin data with relevance ranking.',
|
|
523
|
+
schema,
|
|
524
|
+
execute: async (args: z.infer<typeof schema>, strapi: Core.Strapi) => {
|
|
525
|
+
const validated = schema.parse(args);
|
|
526
|
+
const results = await strapi.documents('plugin::my-plugin.item' as any).findMany({
|
|
527
|
+
filters: { title: { $containsi: validated.query } },
|
|
528
|
+
limit: validated.limit,
|
|
529
|
+
});
|
|
530
|
+
return { results, total: results.length };
|
|
531
|
+
},
|
|
532
|
+
publicSafe: true, // available in public chat (read-only operations)
|
|
533
|
+
};
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
**2. Create the `ai-tools` service:**
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
// server/src/services/ai-tools.ts
|
|
540
|
+
import type { Core } from '@strapi/strapi';
|
|
541
|
+
import { tools } from '../tools';
|
|
542
|
+
|
|
543
|
+
export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
|
544
|
+
getTools() {
|
|
545
|
+
return tools;
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**3. Register the service:**
|
|
551
|
+
|
|
552
|
+
```typescript
|
|
553
|
+
// server/src/services/index.ts
|
|
554
|
+
import myService from './my-service';
|
|
555
|
+
import aiTools from './ai-tools';
|
|
556
|
+
|
|
557
|
+
export default {
|
|
558
|
+
'my-service': myService,
|
|
559
|
+
'ai-tools': aiTools,
|
|
560
|
+
};
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
That's it. The AI SDK will discover and register your tools on the next Strapi restart.
|
|
564
|
+
|
|
565
|
+
#### ToolDefinition Interface
|
|
566
|
+
|
|
567
|
+
```typescript
|
|
568
|
+
interface ToolDefinition {
|
|
569
|
+
name: string; // camelCase, unique within your plugin
|
|
570
|
+
description: string; // Clear description for the AI model
|
|
571
|
+
schema: z.ZodObject<any>; // Zod schema for parameter validation
|
|
572
|
+
execute: (args: any, strapi: Core.Strapi, context?: ToolContext) => Promise<unknown>;
|
|
573
|
+
internal?: boolean; // If true, hidden from MCP (AI chat only)
|
|
574
|
+
publicSafe?: boolean; // If true, available in public/widget chat
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
#### Canonical Architecture Pattern
|
|
579
|
+
|
|
580
|
+
The recommended pattern is to define tools once in `server/src/tools/` and consume them from both the AI SDK service and MCP handlers:
|
|
581
|
+
|
|
582
|
+
```mermaid
|
|
583
|
+
flowchart TB
|
|
584
|
+
subgraph "Your Plugin"
|
|
585
|
+
T["server/src/tools/<br/>Canonical tool definitions<br/>(Zod schema + business logic)"]
|
|
586
|
+
|
|
587
|
+
subgraph "AI SDK Path"
|
|
588
|
+
S["services/ai-tools.ts<br/>getTools() → tools array"]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
subgraph "MCP Path"
|
|
592
|
+
M["mcp/tools/*.ts<br/>Thin wrappers → MCP envelope"]
|
|
593
|
+
MS["mcp/server.ts"]
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
T --> S
|
|
597
|
+
T --> M
|
|
598
|
+
M --> MS
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
S -->|"Discovery"| SDK["AI SDK ToolRegistry"]
|
|
602
|
+
MS -->|"JSON-RPC"| Clients["Claude Desktop / Cursor"]
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
This eliminates duplication -- business logic lives in one place, and each consumer (AI SDK, MCP) uses a thin adapter.
|
|
606
|
+
|
|
607
|
+
#### Real-World Examples
|
|
608
|
+
|
|
609
|
+
Two plugins already use this pattern:
|
|
610
|
+
|
|
611
|
+
**[strapi-octolens-mentions-plugin](../strapi-octolens-mentions-plugin/)** -- Contributes 4 tools: `searchMentions` (BM25 relevance search), `listMentions`, `getMention`, `updateMention`
|
|
612
|
+
|
|
613
|
+
**[strapi-content-embeddings](../strapi-content-embeddings/)** -- Contributes 5 tools: `semanticSearch` (vector similarity), `ragQuery` (RAG), `listEmbeddings`, `getEmbedding`, `createEmbedding`
|
|
614
|
+
|
|
615
|
+
### Adding a Custom Tool (Without a Plugin)
|
|
469
616
|
|
|
470
617
|
**Option A: Inside the plugin** -- create files in `tools/definitions/` and `tool-logic/`, add to the `builtInTools` array.
|
|
471
618
|
|
|
@@ -578,7 +725,7 @@ Error response format:
|
|
|
578
725
|
server/src/
|
|
579
726
|
index.ts # Server entry point
|
|
580
727
|
register.ts # Plugin register lifecycle
|
|
581
|
-
bootstrap.ts # Initialize providers, tools, MCP
|
|
728
|
+
bootstrap.ts # Initialize providers, tools, MCP, plugin tool discovery
|
|
582
729
|
destroy.ts # Graceful shutdown
|
|
583
730
|
config/index.ts # Plugin config defaults
|
|
584
731
|
guardrails/ # Input safety middleware
|
|
@@ -650,6 +797,8 @@ STRAPI_TOKEN=your-api-token npm run test:guardrails
|
|
|
650
797
|
## Documentation
|
|
651
798
|
|
|
652
799
|
- [Architecture](./docs/architecture.md) -- full system architecture, data flows, extension guides
|
|
800
|
+
- [Plugin Tool Discovery](./docs/plugin-tool-discovery.md) -- cross-plugin tool discovery architecture and implementation
|
|
801
|
+
- [Tool Standardization Spec](./docs/tool-standardization-spec.md) -- canonical tool format, Zod-first vs MCP-native comparison, portability
|
|
653
802
|
- [Guardrails](./docs/guardrails.md) -- guardrail system, pattern lists, `beforeProcess` hook API
|
|
654
803
|
- [Sending Emails with Resend](./docs/sending-emails-with-resend.md) -- Resend setup, email tool, domain verification
|
|
655
804
|
|
package/dist/server/index.js
CHANGED
|
@@ -31,7 +31,7 @@ function _interopNamespace(e) {
|
|
|
31
31
|
const fs__namespace = /* @__PURE__ */ _interopNamespace(fs$1);
|
|
32
32
|
const path__namespace = /* @__PURE__ */ _interopNamespace(path$1);
|
|
33
33
|
function toSnakeCase(str) {
|
|
34
|
-
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
34
|
+
return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
35
35
|
}
|
|
36
36
|
function createMcpServer(strapi) {
|
|
37
37
|
const plugin = strapi.plugin("ai-sdk");
|
|
@@ -1225,6 +1225,51 @@ const bootstrap = ({ strapi }) => {
|
|
|
1225
1225
|
toolRegistry.register(tool);
|
|
1226
1226
|
}
|
|
1227
1227
|
plugin.toolRegistry = toolRegistry;
|
|
1228
|
+
const pluginNames = Object.keys(strapi.plugins).filter((n) => n !== PLUGIN_ID$2);
|
|
1229
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Scanning ${pluginNames.length} plugins for ai-tools: [${pluginNames.join(", ")}]`);
|
|
1230
|
+
for (const [pluginName, pluginInstance] of Object.entries(strapi.plugins)) {
|
|
1231
|
+
if (pluginName === PLUGIN_ID$2) continue;
|
|
1232
|
+
try {
|
|
1233
|
+
let aiToolsService = null;
|
|
1234
|
+
try {
|
|
1235
|
+
aiToolsService = strapi.plugin(pluginName)?.service?.("ai-tools");
|
|
1236
|
+
} catch {
|
|
1237
|
+
}
|
|
1238
|
+
if (!aiToolsService) {
|
|
1239
|
+
try {
|
|
1240
|
+
aiToolsService = pluginInstance.service?.("ai-tools");
|
|
1241
|
+
} catch {
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
if (!aiToolsService?.getTools) {
|
|
1245
|
+
strapi.log.debug(`[${PLUGIN_ID$2}] No ai-tools service on plugin: ${pluginName}`);
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Found ai-tools service on plugin: ${pluginName}`);
|
|
1249
|
+
const contributed = aiToolsService.getTools();
|
|
1250
|
+
if (!Array.isArray(contributed)) continue;
|
|
1251
|
+
let count = 0;
|
|
1252
|
+
for (const tool of contributed) {
|
|
1253
|
+
if (!tool.name || !tool.execute || !tool.schema) {
|
|
1254
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Invalid tool from ${pluginName}: ${tool.name || "unnamed"}`);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1258
|
+
const namespacedName = `${safeName}__${tool.name}`;
|
|
1259
|
+
if (toolRegistry.has(namespacedName)) {
|
|
1260
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Duplicate tool: ${namespacedName}`);
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
toolRegistry.register({ ...tool, name: namespacedName });
|
|
1264
|
+
count++;
|
|
1265
|
+
}
|
|
1266
|
+
if (count > 0) {
|
|
1267
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
|
|
1268
|
+
}
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1228
1273
|
plugin.createMcpServer = () => createMcpServer(strapi);
|
|
1229
1274
|
plugin.mcpSessions = /* @__PURE__ */ new Map();
|
|
1230
1275
|
strapi.log.info(`[${PLUGIN_ID$2}] MCP endpoint available at: /api/${PLUGIN_ID$2}/mcp`);
|
|
@@ -2598,6 +2643,8 @@ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulf
|
|
|
2598
2643
|
|
|
2599
2644
|
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.
|
|
2600
2645
|
|
|
2646
|
+
Plugin tools: When specialized tools from plugins are available (listed below), ALWAYS prefer them over the generic searchContent tool. For example, use searchMentions for social mentions (it has BM25 relevance scoring), semanticSearch for vector similarity search across embeddings, and ragQuery for question-answering grounded in embedded content. Only fall back to searchContent when no specialized tool covers the query.
|
|
2647
|
+
|
|
2601
2648
|
Strapi filter syntax for searchContent and aggregateContent:
|
|
2602
2649
|
- Scalar fields: { title: { $containsi: "hello" } }
|
|
2603
2650
|
- Relation (manyToOne): { author: { name: { $eq: "John" } } }
|
package/dist/server/index.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import * as path from "node:path";
|
|
|
11
11
|
import { randomUUID } from "node:crypto";
|
|
12
12
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
13
13
|
function toSnakeCase(str) {
|
|
14
|
-
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
14
|
+
return str.replace(/:/g, "__").replace(/-/g, "_").replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
15
15
|
}
|
|
16
16
|
function createMcpServer(strapi) {
|
|
17
17
|
const plugin = strapi.plugin("ai-sdk");
|
|
@@ -1205,6 +1205,51 @@ const bootstrap = ({ strapi }) => {
|
|
|
1205
1205
|
toolRegistry.register(tool2);
|
|
1206
1206
|
}
|
|
1207
1207
|
plugin.toolRegistry = toolRegistry;
|
|
1208
|
+
const pluginNames = Object.keys(strapi.plugins).filter((n) => n !== PLUGIN_ID$2);
|
|
1209
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Scanning ${pluginNames.length} plugins for ai-tools: [${pluginNames.join(", ")}]`);
|
|
1210
|
+
for (const [pluginName, pluginInstance] of Object.entries(strapi.plugins)) {
|
|
1211
|
+
if (pluginName === PLUGIN_ID$2) continue;
|
|
1212
|
+
try {
|
|
1213
|
+
let aiToolsService = null;
|
|
1214
|
+
try {
|
|
1215
|
+
aiToolsService = strapi.plugin(pluginName)?.service?.("ai-tools");
|
|
1216
|
+
} catch {
|
|
1217
|
+
}
|
|
1218
|
+
if (!aiToolsService) {
|
|
1219
|
+
try {
|
|
1220
|
+
aiToolsService = pluginInstance.service?.("ai-tools");
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
if (!aiToolsService?.getTools) {
|
|
1225
|
+
strapi.log.debug(`[${PLUGIN_ID$2}] No ai-tools service on plugin: ${pluginName}`);
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Found ai-tools service on plugin: ${pluginName}`);
|
|
1229
|
+
const contributed = aiToolsService.getTools();
|
|
1230
|
+
if (!Array.isArray(contributed)) continue;
|
|
1231
|
+
let count = 0;
|
|
1232
|
+
for (const tool2 of contributed) {
|
|
1233
|
+
if (!tool2.name || !tool2.execute || !tool2.schema) {
|
|
1234
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Invalid tool from ${pluginName}: ${tool2.name || "unnamed"}`);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1238
|
+
const namespacedName = `${safeName}__${tool2.name}`;
|
|
1239
|
+
if (toolRegistry.has(namespacedName)) {
|
|
1240
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Duplicate tool: ${namespacedName}`);
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
toolRegistry.register({ ...tool2, name: namespacedName });
|
|
1244
|
+
count++;
|
|
1245
|
+
}
|
|
1246
|
+
if (count > 0) {
|
|
1247
|
+
strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
|
|
1248
|
+
}
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1208
1253
|
plugin.createMcpServer = () => createMcpServer(strapi);
|
|
1209
1254
|
plugin.mcpSessions = /* @__PURE__ */ new Map();
|
|
1210
1255
|
strapi.log.info(`[${PLUGIN_ID$2}] MCP endpoint available at: /api/${PLUGIN_ID$2}/mcp`);
|
|
@@ -2578,6 +2623,8 @@ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulf
|
|
|
2578
2623
|
|
|
2579
2624
|
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.
|
|
2580
2625
|
|
|
2626
|
+
Plugin tools: When specialized tools from plugins are available (listed below), ALWAYS prefer them over the generic searchContent tool. For example, use searchMentions for social mentions (it has BM25 relevance scoring), semanticSearch for vector similarity search across embeddings, and ragQuery for question-answering grounded in embedded content. Only fall back to searchContent when no specialized tool covers the query.
|
|
2627
|
+
|
|
2581
2628
|
Strapi filter syntax for searchContent and aggregateContent:
|
|
2582
2629
|
- Scalar fields: { title: { $containsi: "hello" } }
|
|
2583
2630
|
- Relation (manyToOne): { author: { name: { $eq: "John" } } }
|
|
@@ -13,6 +13,8 @@ export interface ToolDefinition {
|
|
|
13
13
|
/** If true, tool is safe for unauthenticated public chat (read-only) */
|
|
14
14
|
publicSafe?: boolean;
|
|
15
15
|
}
|
|
16
|
+
/** Type alias for external plugin authors to import when contributing tools */
|
|
17
|
+
export type AiToolContribution = ToolDefinition;
|
|
16
18
|
export declare class ToolRegistry {
|
|
17
19
|
private readonly tools;
|
|
18
20
|
register(def: ToolDefinition): void;
|
package/package.json
CHANGED