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 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 a Custom Tool
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
 
@@ -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" } } }
@@ -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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.10",
2
+ "version": "0.7.0",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",