strapi-plugin-ai-sdk 0.6.10 → 0.7.1

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
 
@@ -6,7 +6,7 @@ const reactRouterDom = require("react-router-dom");
6
6
  const designSystem = require("@strapi/design-system");
7
7
  const react = require("react");
8
8
  const styled = require("styled-components");
9
- const index = require("./index-BNk29VRc.js");
9
+ const index = require("./index-D0dDUeEO.js");
10
10
  const icons = require("@strapi/icons");
11
11
  const Markdown = require("react-markdown");
12
12
  const remarkGfm = require("remark-gfm");
@@ -107,15 +107,17 @@ function toUIMessages(messages) {
107
107
  parts: [{ type: "text", text: message.content }]
108
108
  }));
109
109
  }
110
- async function fetchChatStream(messages) {
110
+ async function fetchChatStream(messages, enabledToolSources) {
111
111
  const token = getToken();
112
+ const body = { messages: toUIMessages(messages) };
113
+ if (enabledToolSources) body.enabledToolSources = enabledToolSources;
112
114
  const response = await fetch(`${getBackendURL()}/${index.PLUGIN_ID}/chat`, {
113
115
  method: "POST",
114
116
  headers: {
115
117
  "Content-Type": "application/json",
116
118
  ...token ? { Authorization: `Bearer ${token}` } : {}
117
119
  },
118
- body: JSON.stringify({ messages: toUIMessages(messages) })
120
+ body: JSON.stringify(body)
119
121
  });
120
122
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
121
123
  const reader = response.body?.getReader();
@@ -160,7 +162,7 @@ function useChat(options) {
160
162
  setIsLoading(true);
161
163
  setError(null);
162
164
  try {
163
- const reader = await fetchChatStream([...messages, userMessage]);
165
+ const reader = await fetchChatStream([...messages, userMessage], options?.enabledToolSources);
164
166
  let streamStarted = false;
165
167
  const result = await readSSEStream(reader, {
166
168
  onTextDelta: (content) => {
@@ -392,6 +394,51 @@ function useMemories() {
392
394
  }, []);
393
395
  return { memories, loading, addMemory, editMemory, removeMemory, refresh: load };
394
396
  }
397
+ const STORAGE_KEY = `${index.PLUGIN_ID}:enabledToolSources`;
398
+ function useToolSources() {
399
+ const [sources, setSources] = react.useState([]);
400
+ const [enabledSources, setEnabledSources] = react.useState(() => {
401
+ try {
402
+ const stored = localStorage.getItem(STORAGE_KEY);
403
+ if (stored) return new Set(JSON.parse(stored));
404
+ } catch {
405
+ }
406
+ return /* @__PURE__ */ new Set();
407
+ });
408
+ const [loaded, setLoaded] = react.useState(false);
409
+ react.useEffect(() => {
410
+ const token = getToken();
411
+ fetch(`${getBackendURL()}/${index.PLUGIN_ID}/tool-sources`, {
412
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
413
+ }).then((res) => res.json()).then((json) => {
414
+ const data = json.data ?? [];
415
+ setSources(data);
416
+ setEnabledSources((prev) => {
417
+ if (prev.size === 0) {
418
+ const all = new Set(data.filter((s) => s.id !== "built-in").map((s) => s.id));
419
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...all]));
420
+ return all;
421
+ }
422
+ return prev;
423
+ });
424
+ setLoaded(true);
425
+ }).catch(() => setLoaded(true));
426
+ }, []);
427
+ const toggleSource = react.useCallback((id) => {
428
+ setEnabledSources((prev) => {
429
+ const next = new Set(prev);
430
+ if (next.has(id)) next.delete(id);
431
+ else next.add(id);
432
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
433
+ return next;
434
+ });
435
+ }, []);
436
+ const enabledToolSources = react.useMemo(
437
+ () => loaded ? [...enabledSources] : void 0,
438
+ [enabledSources, loaded]
439
+ );
440
+ return { sources, enabledSources, enabledToolSources, toggleSource, loaded };
441
+ }
395
442
  const SidebarRoot = styled__default.default.div`
396
443
  width: ${({ $open }) => $open ? "260px" : "0px"};
397
444
  min-width: ${({ $open }) => $open ? "260px" : "0px"};
@@ -618,6 +665,142 @@ function MemoryPanel({ memories, open, onDelete }) {
618
665
  ] })
619
666
  ] });
620
667
  }
668
+ const Wrapper = styled__default.default.div`
669
+ position: relative;
670
+ `;
671
+ const IconBtn = styled__default.default.button`
672
+ display: flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ width: 32px;
676
+ height: 32px;
677
+ border: 1px solid #dcdce4;
678
+ border-radius: 4px;
679
+ background: #ffffff;
680
+ color: #666687;
681
+ cursor: pointer;
682
+ flex-shrink: 0;
683
+
684
+ &:hover {
685
+ background: #f0f0f5;
686
+ color: #4945ff;
687
+ border-color: #4945ff;
688
+ }
689
+
690
+ svg {
691
+ width: 16px;
692
+ height: 16px;
693
+ }
694
+ `;
695
+ const Popover = styled__default.default.div`
696
+ position: absolute;
697
+ top: 40px;
698
+ left: 0;
699
+ z-index: 10;
700
+ width: 240px;
701
+ max-height: 360px;
702
+ overflow-y: auto;
703
+ background: #ffffff;
704
+ border: 1px solid #dcdce4;
705
+ border-radius: 4px;
706
+ box-shadow: 0 2px 12px rgba(33, 33, 52, 0.12);
707
+ padding: 8px 0;
708
+ `;
709
+ const SourceRow = styled__default.default.label`
710
+ display: flex;
711
+ flex-direction: column;
712
+ padding: 6px 12px;
713
+ cursor: pointer;
714
+ font-size: 13px;
715
+ color: #32324d;
716
+
717
+ &:hover {
718
+ background: #f6f6f9;
719
+ }
720
+ `;
721
+ const SourceMain = styled__default.default.div`
722
+ display: flex;
723
+ flex-direction: row;
724
+ align-items: center;
725
+ gap: 8px;
726
+ `;
727
+ const Badge = styled__default.default.div`
728
+ font-size: 11px;
729
+ color: #a5a5ba;
730
+ margin-left: 24px;
731
+ margin-top: 1px;
732
+ `;
733
+ const Toggle = styled__default.default.input`
734
+ accent-color: #4945ff;
735
+ flex-shrink: 0;
736
+ width: 16px;
737
+ height: 16px;
738
+ margin: 0;
739
+ `;
740
+ const Header = styled__default.default.div`
741
+ padding: 6px 12px 4px;
742
+ font-size: 11px;
743
+ font-weight: 600;
744
+ color: #a5a5ba;
745
+ text-transform: uppercase;
746
+ letter-spacing: 0.5px;
747
+ `;
748
+ function ToolSourcePicker({ sources, enabledSources, onToggle }) {
749
+ const [open, setOpen] = react.useState(false);
750
+ const ref = react.useRef(null);
751
+ react.useEffect(() => {
752
+ if (!open) return;
753
+ function handleClick(e) {
754
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
755
+ }
756
+ document.addEventListener("mousedown", handleClick);
757
+ return () => document.removeEventListener("mousedown", handleClick);
758
+ }, [open]);
759
+ const builtIn = sources.find((s) => s.id === "built-in");
760
+ const plugins = sources.filter((s) => s.id !== "built-in");
761
+ if (sources.length === 0) return null;
762
+ return /* @__PURE__ */ jsxRuntime.jsxs(Wrapper, { ref, children: [
763
+ /* @__PURE__ */ jsxRuntime.jsx(IconBtn, { onClick: () => setOpen((p) => !p), "aria-label": "Tool sources", children: /* @__PURE__ */ jsxRuntime.jsxs("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
764
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M9.5 2.5L13 6l-7 7H2.5v-3.5l7-7z" }),
765
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M8 4l4 4" })
766
+ ] }) }),
767
+ open && /* @__PURE__ */ jsxRuntime.jsxs(Popover, { children: [
768
+ builtIn && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
769
+ /* @__PURE__ */ jsxRuntime.jsx(Header, { children: "Built-in" }),
770
+ /* @__PURE__ */ jsxRuntime.jsxs(SourceRow, { children: [
771
+ /* @__PURE__ */ jsxRuntime.jsxs(SourceMain, { children: [
772
+ /* @__PURE__ */ jsxRuntime.jsx(Toggle, { type: "checkbox", checked: true, disabled: true }),
773
+ builtIn.label
774
+ ] }),
775
+ /* @__PURE__ */ jsxRuntime.jsxs(Badge, { children: [
776
+ builtIn.toolCount,
777
+ " tools"
778
+ ] })
779
+ ] })
780
+ ] }),
781
+ plugins.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
782
+ /* @__PURE__ */ jsxRuntime.jsx(Header, { children: "Plugins" }),
783
+ plugins.map((s) => /* @__PURE__ */ jsxRuntime.jsxs(SourceRow, { children: [
784
+ /* @__PURE__ */ jsxRuntime.jsxs(SourceMain, { children: [
785
+ /* @__PURE__ */ jsxRuntime.jsx(
786
+ Toggle,
787
+ {
788
+ type: "checkbox",
789
+ checked: enabledSources.has(s.id),
790
+ onChange: () => onToggle(s.id)
791
+ }
792
+ ),
793
+ s.label
794
+ ] }),
795
+ /* @__PURE__ */ jsxRuntime.jsxs(Badge, { children: [
796
+ s.toolCount,
797
+ " tools"
798
+ ] })
799
+ ] }, s.id))
800
+ ] })
801
+ ] })
802
+ ] });
803
+ }
621
804
  const SCORE_LABELS = {
622
805
  1: "Negligible",
623
806
  2: "Minor",
@@ -1355,9 +1538,11 @@ function Chat() {
1355
1538
  removeConversation
1356
1539
  } = useConversations();
1357
1540
  const { memories, removeMemory, refresh: refreshMemories } = useMemories();
1541
+ const { sources, enabledSources, enabledToolSources, toggleSource } = useToolSources();
1358
1542
  const { messages, sendMessage, isLoading, error } = useChat({
1359
1543
  initialMessages,
1360
- conversationId: activeId
1544
+ conversationId: activeId,
1545
+ enabledToolSources
1361
1546
  });
1362
1547
  react.useEffect(() => {
1363
1548
  if (prevIsLoadingRef.current && !isLoading && messages.length > 0) {
@@ -1431,6 +1616,14 @@ function Chat() {
1431
1616
  ] })
1432
1617
  }
1433
1618
  ),
1619
+ /* @__PURE__ */ jsxRuntime.jsx(
1620
+ ToolSourcePicker,
1621
+ {
1622
+ sources,
1623
+ enabledSources,
1624
+ onToggle: toggleSource
1625
+ }
1626
+ ),
1434
1627
  /* @__PURE__ */ jsxRuntime.jsx("div", { style: { flex: 1 } }),
1435
1628
  /* @__PURE__ */ jsxRuntime.jsx(
1436
1629
  ToggleSidebarBtn,
@@ -1,10 +1,10 @@
1
- import { jsxs, jsx } from "react/jsx-runtime";
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { Layouts, Page } from "@strapi/strapi/admin";
3
3
  import { Link, useNavigate, Routes, Route } from "react-router-dom";
4
4
  import { Box, Typography, TextInput, Button, Main, SearchForm, Searchbar, Table, Thead, Tr, Th, Tbody, Td, Flex, Pagination, Modal, Field, Textarea, SingleSelect, SingleSelectOption } from "@strapi/design-system";
5
- import { useState, useEffect, useCallback, forwardRef, useRef, useMemo } from "react";
5
+ import { useState, useEffect, useCallback, useMemo, useRef, forwardRef } from "react";
6
6
  import styled from "styled-components";
7
- import { P as PLUGIN_ID } from "./index-CFO5UshL.mjs";
7
+ import { P as PLUGIN_ID } from "./index-skxI4tiW.mjs";
8
8
  import { Plus, Trash, Sparkle, ArrowLeft, Pencil } from "@strapi/icons";
9
9
  import Markdown from "react-markdown";
10
10
  import remarkGfm from "remark-gfm";
@@ -101,15 +101,17 @@ function toUIMessages(messages) {
101
101
  parts: [{ type: "text", text: message.content }]
102
102
  }));
103
103
  }
104
- async function fetchChatStream(messages) {
104
+ async function fetchChatStream(messages, enabledToolSources) {
105
105
  const token = getToken();
106
+ const body = { messages: toUIMessages(messages) };
107
+ if (enabledToolSources) body.enabledToolSources = enabledToolSources;
106
108
  const response = await fetch(`${getBackendURL()}/${PLUGIN_ID}/chat`, {
107
109
  method: "POST",
108
110
  headers: {
109
111
  "Content-Type": "application/json",
110
112
  ...token ? { Authorization: `Bearer ${token}` } : {}
111
113
  },
112
- body: JSON.stringify({ messages: toUIMessages(messages) })
114
+ body: JSON.stringify(body)
113
115
  });
114
116
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
115
117
  const reader = response.body?.getReader();
@@ -154,7 +156,7 @@ function useChat(options) {
154
156
  setIsLoading(true);
155
157
  setError(null);
156
158
  try {
157
- const reader = await fetchChatStream([...messages, userMessage]);
159
+ const reader = await fetchChatStream([...messages, userMessage], options?.enabledToolSources);
158
160
  let streamStarted = false;
159
161
  const result = await readSSEStream(reader, {
160
162
  onTextDelta: (content) => {
@@ -386,6 +388,51 @@ function useMemories() {
386
388
  }, []);
387
389
  return { memories, loading, addMemory, editMemory, removeMemory, refresh: load };
388
390
  }
391
+ const STORAGE_KEY = `${PLUGIN_ID}:enabledToolSources`;
392
+ function useToolSources() {
393
+ const [sources, setSources] = useState([]);
394
+ const [enabledSources, setEnabledSources] = useState(() => {
395
+ try {
396
+ const stored = localStorage.getItem(STORAGE_KEY);
397
+ if (stored) return new Set(JSON.parse(stored));
398
+ } catch {
399
+ }
400
+ return /* @__PURE__ */ new Set();
401
+ });
402
+ const [loaded, setLoaded] = useState(false);
403
+ useEffect(() => {
404
+ const token = getToken();
405
+ fetch(`${getBackendURL()}/${PLUGIN_ID}/tool-sources`, {
406
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
407
+ }).then((res) => res.json()).then((json) => {
408
+ const data = json.data ?? [];
409
+ setSources(data);
410
+ setEnabledSources((prev) => {
411
+ if (prev.size === 0) {
412
+ const all = new Set(data.filter((s) => s.id !== "built-in").map((s) => s.id));
413
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...all]));
414
+ return all;
415
+ }
416
+ return prev;
417
+ });
418
+ setLoaded(true);
419
+ }).catch(() => setLoaded(true));
420
+ }, []);
421
+ const toggleSource = useCallback((id) => {
422
+ setEnabledSources((prev) => {
423
+ const next = new Set(prev);
424
+ if (next.has(id)) next.delete(id);
425
+ else next.add(id);
426
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
427
+ return next;
428
+ });
429
+ }, []);
430
+ const enabledToolSources = useMemo(
431
+ () => loaded ? [...enabledSources] : void 0,
432
+ [enabledSources, loaded]
433
+ );
434
+ return { sources, enabledSources, enabledToolSources, toggleSource, loaded };
435
+ }
389
436
  const SidebarRoot = styled.div`
390
437
  width: ${({ $open }) => $open ? "260px" : "0px"};
391
438
  min-width: ${({ $open }) => $open ? "260px" : "0px"};
@@ -612,6 +659,142 @@ function MemoryPanel({ memories, open, onDelete }) {
612
659
  ] })
613
660
  ] });
614
661
  }
662
+ const Wrapper = styled.div`
663
+ position: relative;
664
+ `;
665
+ const IconBtn = styled.button`
666
+ display: flex;
667
+ align-items: center;
668
+ justify-content: center;
669
+ width: 32px;
670
+ height: 32px;
671
+ border: 1px solid #dcdce4;
672
+ border-radius: 4px;
673
+ background: #ffffff;
674
+ color: #666687;
675
+ cursor: pointer;
676
+ flex-shrink: 0;
677
+
678
+ &:hover {
679
+ background: #f0f0f5;
680
+ color: #4945ff;
681
+ border-color: #4945ff;
682
+ }
683
+
684
+ svg {
685
+ width: 16px;
686
+ height: 16px;
687
+ }
688
+ `;
689
+ const Popover = styled.div`
690
+ position: absolute;
691
+ top: 40px;
692
+ left: 0;
693
+ z-index: 10;
694
+ width: 240px;
695
+ max-height: 360px;
696
+ overflow-y: auto;
697
+ background: #ffffff;
698
+ border: 1px solid #dcdce4;
699
+ border-radius: 4px;
700
+ box-shadow: 0 2px 12px rgba(33, 33, 52, 0.12);
701
+ padding: 8px 0;
702
+ `;
703
+ const SourceRow = styled.label`
704
+ display: flex;
705
+ flex-direction: column;
706
+ padding: 6px 12px;
707
+ cursor: pointer;
708
+ font-size: 13px;
709
+ color: #32324d;
710
+
711
+ &:hover {
712
+ background: #f6f6f9;
713
+ }
714
+ `;
715
+ const SourceMain = styled.div`
716
+ display: flex;
717
+ flex-direction: row;
718
+ align-items: center;
719
+ gap: 8px;
720
+ `;
721
+ const Badge = styled.div`
722
+ font-size: 11px;
723
+ color: #a5a5ba;
724
+ margin-left: 24px;
725
+ margin-top: 1px;
726
+ `;
727
+ const Toggle = styled.input`
728
+ accent-color: #4945ff;
729
+ flex-shrink: 0;
730
+ width: 16px;
731
+ height: 16px;
732
+ margin: 0;
733
+ `;
734
+ const Header = styled.div`
735
+ padding: 6px 12px 4px;
736
+ font-size: 11px;
737
+ font-weight: 600;
738
+ color: #a5a5ba;
739
+ text-transform: uppercase;
740
+ letter-spacing: 0.5px;
741
+ `;
742
+ function ToolSourcePicker({ sources, enabledSources, onToggle }) {
743
+ const [open, setOpen] = useState(false);
744
+ const ref = useRef(null);
745
+ useEffect(() => {
746
+ if (!open) return;
747
+ function handleClick(e) {
748
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
749
+ }
750
+ document.addEventListener("mousedown", handleClick);
751
+ return () => document.removeEventListener("mousedown", handleClick);
752
+ }, [open]);
753
+ const builtIn = sources.find((s) => s.id === "built-in");
754
+ const plugins = sources.filter((s) => s.id !== "built-in");
755
+ if (sources.length === 0) return null;
756
+ return /* @__PURE__ */ jsxs(Wrapper, { ref, children: [
757
+ /* @__PURE__ */ jsx(IconBtn, { onClick: () => setOpen((p) => !p), "aria-label": "Tool sources", children: /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [
758
+ /* @__PURE__ */ jsx("path", { d: "M9.5 2.5L13 6l-7 7H2.5v-3.5l7-7z" }),
759
+ /* @__PURE__ */ jsx("path", { d: "M8 4l4 4" })
760
+ ] }) }),
761
+ open && /* @__PURE__ */ jsxs(Popover, { children: [
762
+ builtIn && /* @__PURE__ */ jsxs(Fragment, { children: [
763
+ /* @__PURE__ */ jsx(Header, { children: "Built-in" }),
764
+ /* @__PURE__ */ jsxs(SourceRow, { children: [
765
+ /* @__PURE__ */ jsxs(SourceMain, { children: [
766
+ /* @__PURE__ */ jsx(Toggle, { type: "checkbox", checked: true, disabled: true }),
767
+ builtIn.label
768
+ ] }),
769
+ /* @__PURE__ */ jsxs(Badge, { children: [
770
+ builtIn.toolCount,
771
+ " tools"
772
+ ] })
773
+ ] })
774
+ ] }),
775
+ plugins.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
776
+ /* @__PURE__ */ jsx(Header, { children: "Plugins" }),
777
+ plugins.map((s) => /* @__PURE__ */ jsxs(SourceRow, { children: [
778
+ /* @__PURE__ */ jsxs(SourceMain, { children: [
779
+ /* @__PURE__ */ jsx(
780
+ Toggle,
781
+ {
782
+ type: "checkbox",
783
+ checked: enabledSources.has(s.id),
784
+ onChange: () => onToggle(s.id)
785
+ }
786
+ ),
787
+ s.label
788
+ ] }),
789
+ /* @__PURE__ */ jsxs(Badge, { children: [
790
+ s.toolCount,
791
+ " tools"
792
+ ] })
793
+ ] }, s.id))
794
+ ] })
795
+ ] })
796
+ ] });
797
+ }
615
798
  const SCORE_LABELS = {
616
799
  1: "Negligible",
617
800
  2: "Minor",
@@ -1349,9 +1532,11 @@ function Chat() {
1349
1532
  removeConversation
1350
1533
  } = useConversations();
1351
1534
  const { memories, removeMemory, refresh: refreshMemories } = useMemories();
1535
+ const { sources, enabledSources, enabledToolSources, toggleSource } = useToolSources();
1352
1536
  const { messages, sendMessage, isLoading, error } = useChat({
1353
1537
  initialMessages,
1354
- conversationId: activeId
1538
+ conversationId: activeId,
1539
+ enabledToolSources
1355
1540
  });
1356
1541
  useEffect(() => {
1357
1542
  if (prevIsLoadingRef.current && !isLoading && messages.length > 0) {
@@ -1425,6 +1610,14 @@ function Chat() {
1425
1610
  ] })
1426
1611
  }
1427
1612
  ),
1613
+ /* @__PURE__ */ jsx(
1614
+ ToolSourcePicker,
1615
+ {
1616
+ sources,
1617
+ enabledSources,
1618
+ onToggle: toggleSource
1619
+ }
1620
+ ),
1428
1621
  /* @__PURE__ */ jsx("div", { style: { flex: 1 } }),
1429
1622
  /* @__PURE__ */ jsx(
1430
1623
  ToggleSidebarBtn,
@@ -37,7 +37,7 @@ const index = {
37
37
  defaultMessage: PLUGIN_ID
38
38
  },
39
39
  Component: async () => {
40
- const { App } = await Promise.resolve().then(() => require("./App-BGIUzHMh.js"));
40
+ const { App } = await Promise.resolve().then(() => require("./App-DcU3uMiY.js"));
41
41
  return App;
42
42
  }
43
43
  });
@@ -36,7 +36,7 @@ const index = {
36
36
  defaultMessage: PLUGIN_ID
37
37
  },
38
38
  Component: async () => {
39
- const { App } = await import("./App-v0CobEGM.mjs");
39
+ const { App } = await import("./App-Dwx6guC-.mjs");
40
40
  return App;
41
41
  }
42
42
  });
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-BNk29VRc.js");
2
+ const index = require("../_chunks/index-D0dDUeEO.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-CFO5UshL.mjs";
1
+ import { i } from "../_chunks/index-skxI4tiW.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -0,0 +1,8 @@
1
+ import type { ToolSource } from '../hooks/useToolSources';
2
+ interface Props {
3
+ sources: ToolSource[];
4
+ enabledSources: Set<string>;
5
+ onToggle: (id: string) => void;
6
+ }
7
+ export declare function ToolSourcePicker({ sources, enabledSources, onToggle }: Props): import("react/jsx-runtime").JSX.Element | null;
8
+ export {};
@@ -15,6 +15,7 @@ export interface UseChatOptions {
15
15
  initialMessages?: Message[];
16
16
  conversationId?: string | null;
17
17
  onStreamStart?: () => void;
18
+ enabledToolSources?: string[];
18
19
  }
19
20
  export declare function useChat(options?: UseChatOptions): {
20
21
  messages: Message[];
@@ -0,0 +1,13 @@
1
+ export interface ToolSource {
2
+ id: string;
3
+ label: string;
4
+ toolCount: number;
5
+ tools: string[];
6
+ }
7
+ export declare function useToolSources(): {
8
+ sources: ToolSource[];
9
+ enabledSources: Set<string>;
10
+ enabledToolSources: string[] | undefined;
11
+ toggleSource: (id: string) => void;
12
+ loaded: boolean;
13
+ };
@@ -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");
@@ -204,6 +204,29 @@ class ToolRegistry {
204
204
  }
205
205
  return result;
206
206
  }
207
+ /** Returns metadata about tool sources grouped by plugin prefix */
208
+ getToolSources() {
209
+ const groups = /* @__PURE__ */ new Map();
210
+ for (const name of this.tools.keys()) {
211
+ const sepIndex = name.indexOf("__");
212
+ if (sepIndex === -1) {
213
+ const list = groups.get("built-in") ?? [];
214
+ list.push(name);
215
+ groups.set("built-in", list);
216
+ } else {
217
+ const prefix = name.substring(0, sepIndex);
218
+ const list = groups.get(prefix) ?? [];
219
+ list.push(name);
220
+ groups.set(prefix, list);
221
+ }
222
+ }
223
+ return Array.from(groups.entries()).map(([id, tools]) => ({
224
+ id,
225
+ label: id === "built-in" ? "Built-in Tools" : id,
226
+ toolCount: tools.length,
227
+ tools
228
+ }));
229
+ }
207
230
  /** Only tools marked safe for unauthenticated public chat */
208
231
  getPublicSafe() {
209
232
  const result = /* @__PURE__ */ new Map();
@@ -1225,6 +1248,51 @@ const bootstrap = ({ strapi }) => {
1225
1248
  toolRegistry.register(tool);
1226
1249
  }
1227
1250
  plugin.toolRegistry = toolRegistry;
1251
+ const pluginNames = Object.keys(strapi.plugins).filter((n) => n !== PLUGIN_ID$2);
1252
+ strapi.log.info(`[${PLUGIN_ID$2}] Scanning ${pluginNames.length} plugins for ai-tools: [${pluginNames.join(", ")}]`);
1253
+ for (const [pluginName, pluginInstance] of Object.entries(strapi.plugins)) {
1254
+ if (pluginName === PLUGIN_ID$2) continue;
1255
+ try {
1256
+ let aiToolsService = null;
1257
+ try {
1258
+ aiToolsService = strapi.plugin(pluginName)?.service?.("ai-tools");
1259
+ } catch {
1260
+ }
1261
+ if (!aiToolsService) {
1262
+ try {
1263
+ aiToolsService = pluginInstance.service?.("ai-tools");
1264
+ } catch {
1265
+ }
1266
+ }
1267
+ if (!aiToolsService?.getTools) {
1268
+ strapi.log.debug(`[${PLUGIN_ID$2}] No ai-tools service on plugin: ${pluginName}`);
1269
+ continue;
1270
+ }
1271
+ strapi.log.info(`[${PLUGIN_ID$2}] Found ai-tools service on plugin: ${pluginName}`);
1272
+ const contributed = aiToolsService.getTools();
1273
+ if (!Array.isArray(contributed)) continue;
1274
+ let count = 0;
1275
+ for (const tool of contributed) {
1276
+ if (!tool.name || !tool.execute || !tool.schema) {
1277
+ strapi.log.warn(`[${PLUGIN_ID$2}] Invalid tool from ${pluginName}: ${tool.name || "unnamed"}`);
1278
+ continue;
1279
+ }
1280
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1281
+ const namespacedName = `${safeName}__${tool.name}`;
1282
+ if (toolRegistry.has(namespacedName)) {
1283
+ strapi.log.warn(`[${PLUGIN_ID$2}] Duplicate tool: ${namespacedName}`);
1284
+ continue;
1285
+ }
1286
+ toolRegistry.register({ ...tool, name: namespacedName });
1287
+ count++;
1288
+ }
1289
+ if (count > 0) {
1290
+ strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1291
+ }
1292
+ } catch (err) {
1293
+ strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
1294
+ }
1295
+ }
1228
1296
  plugin.createMcpServer = () => createMcpServer(strapi);
1229
1297
  plugin.mcpSessions = /* @__PURE__ */ new Map();
1230
1298
  strapi.log.info(`[${PLUGIN_ID$2}] MCP endpoint available at: /api/${PLUGIN_ID$2}/mcp`);
@@ -1537,7 +1605,7 @@ function validateBody(ctx) {
1537
1605
  return { prompt, system };
1538
1606
  }
1539
1607
  function validateChatBody(ctx) {
1540
- const { messages, system } = ctx.request.body;
1608
+ const { messages, system, enabledToolSources } = ctx.request.body;
1541
1609
  if (!messages || !Array.isArray(messages) || messages.length === 0) {
1542
1610
  ctx.badRequest("messages is required and must be a non-empty array");
1543
1611
  return null;
@@ -1546,7 +1614,11 @@ function validateChatBody(ctx) {
1546
1614
  ctx.badRequest("system must be a string if provided");
1547
1615
  return null;
1548
1616
  }
1549
- return { messages, system };
1617
+ if (enabledToolSources !== void 0 && (!Array.isArray(enabledToolSources) || !enabledToolSources.every((s) => typeof s === "string"))) {
1618
+ ctx.badRequest("enabledToolSources must be an array of strings if provided");
1619
+ return null;
1620
+ }
1621
+ return { messages, system, enabledToolSources };
1550
1622
  }
1551
1623
  function createSSEStream(ctx) {
1552
1624
  ctx.set({
@@ -1613,7 +1685,11 @@ const controller = ({ strapi }) => ({
1613
1685
  const service2 = getService(strapi, ctx);
1614
1686
  if (!service2) return;
1615
1687
  const adminUserId = ctx.state?.user?.id;
1616
- const result = await service2.chat(body.messages, { system: body.system, adminUserId });
1688
+ const result = await service2.chat(body.messages, {
1689
+ system: body.system,
1690
+ adminUserId,
1691
+ enabledToolSources: body.enabledToolSources
1692
+ });
1617
1693
  const response = result.toUIMessageStreamResponse();
1618
1694
  ctx.status = 200;
1619
1695
  ctx.set("Content-Type", "text/event-stream; charset=utf-8");
@@ -1641,6 +1717,15 @@ const controller = ({ strapi }) => ({
1641
1717
  ctx.set("x-vercel-ai-ui-message-stream", "v1");
1642
1718
  ctx.body = node_stream.Readable.fromWeb(response.body);
1643
1719
  },
1720
+ async getToolSources(ctx) {
1721
+ const plugin = strapi.plugin("ai-sdk");
1722
+ const registry = plugin.toolRegistry;
1723
+ if (!registry) {
1724
+ ctx.badRequest("Tool registry not initialized");
1725
+ return;
1726
+ }
1727
+ ctx.body = { data: registry.getToolSources() };
1728
+ },
1644
1729
  async serveWidget(ctx) {
1645
1730
  const pluginRoot = path__namespace.resolve(__dirname, "..", "..");
1646
1731
  const widgetPath = path__namespace.join(pluginRoot, "dist", "widget", "widget.js");
@@ -2411,6 +2496,12 @@ const contentAPIRoutes = {
2411
2496
  const adminAPIRoutes = {
2412
2497
  type: "admin",
2413
2498
  routes: [
2499
+ {
2500
+ method: "GET",
2501
+ path: "/tool-sources",
2502
+ handler: "controller.getToolSources",
2503
+ config: { policies: [] }
2504
+ },
2414
2505
  {
2415
2506
  method: "POST",
2416
2507
  path: "/chat",
@@ -2534,8 +2625,16 @@ function createTools(strapi, context) {
2534
2625
  if (!registry) {
2535
2626
  throw new Error("Tool registry not initialized");
2536
2627
  }
2628
+ const enabledSources = context?.enabledToolSources;
2537
2629
  const tools = {};
2538
2630
  for (const [name, def] of registry.getAll()) {
2631
+ if (enabledSources) {
2632
+ const sepIndex = name.indexOf("__");
2633
+ if (sepIndex !== -1) {
2634
+ const prefix = name.substring(0, sepIndex);
2635
+ if (!enabledSources.includes(prefix)) continue;
2636
+ }
2637
+ }
2539
2638
  tools[name] = ai.tool({
2540
2639
  description: def.description,
2541
2640
  inputSchema: ai.zodSchema(def.schema),
@@ -2598,6 +2697,8 @@ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulf
2598
2697
 
2599
2698
  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
2699
 
2700
+ 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.
2701
+
2601
2702
  Strapi filter syntax for searchContent and aggregateContent:
2602
2703
  - Scalar fields: { title: { $containsi: "hello" } }
2603
2704
  - Relation (manyToOne): { author: { name: { $eq: "John" } } }
@@ -2657,7 +2758,7 @@ const service = ({ strapi }) => {
2657
2758
  const maxSteps = config2?.maxSteps ?? DEFAULT_MAX_STEPS;
2658
2759
  const trimmedMessages = messages.length > maxMessages ? messages.slice(-maxMessages) : messages;
2659
2760
  const modelMessages = await ai.convertToModelMessages(trimmedMessages);
2660
- const tools = createTools(strapi, { adminUserId: options2?.adminUserId });
2761
+ const tools = createTools(strapi, { adminUserId: options2?.adminUserId, enabledToolSources: options2?.enabledToolSources });
2661
2762
  const toolsDescription = describeTools(tools);
2662
2763
  let system = composeSystemPrompt(config2, toolsDescription, options2?.system);
2663
2764
  if (options2?.adminUserId) {
@@ -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");
@@ -184,6 +184,29 @@ class ToolRegistry {
184
184
  }
185
185
  return result;
186
186
  }
187
+ /** Returns metadata about tool sources grouped by plugin prefix */
188
+ getToolSources() {
189
+ const groups = /* @__PURE__ */ new Map();
190
+ for (const name of this.tools.keys()) {
191
+ const sepIndex = name.indexOf("__");
192
+ if (sepIndex === -1) {
193
+ const list = groups.get("built-in") ?? [];
194
+ list.push(name);
195
+ groups.set("built-in", list);
196
+ } else {
197
+ const prefix = name.substring(0, sepIndex);
198
+ const list = groups.get(prefix) ?? [];
199
+ list.push(name);
200
+ groups.set(prefix, list);
201
+ }
202
+ }
203
+ return Array.from(groups.entries()).map(([id, tools]) => ({
204
+ id,
205
+ label: id === "built-in" ? "Built-in Tools" : id,
206
+ toolCount: tools.length,
207
+ tools
208
+ }));
209
+ }
187
210
  /** Only tools marked safe for unauthenticated public chat */
188
211
  getPublicSafe() {
189
212
  const result = /* @__PURE__ */ new Map();
@@ -1205,6 +1228,51 @@ const bootstrap = ({ strapi }) => {
1205
1228
  toolRegistry.register(tool2);
1206
1229
  }
1207
1230
  plugin.toolRegistry = toolRegistry;
1231
+ const pluginNames = Object.keys(strapi.plugins).filter((n) => n !== PLUGIN_ID$2);
1232
+ strapi.log.info(`[${PLUGIN_ID$2}] Scanning ${pluginNames.length} plugins for ai-tools: [${pluginNames.join(", ")}]`);
1233
+ for (const [pluginName, pluginInstance] of Object.entries(strapi.plugins)) {
1234
+ if (pluginName === PLUGIN_ID$2) continue;
1235
+ try {
1236
+ let aiToolsService = null;
1237
+ try {
1238
+ aiToolsService = strapi.plugin(pluginName)?.service?.("ai-tools");
1239
+ } catch {
1240
+ }
1241
+ if (!aiToolsService) {
1242
+ try {
1243
+ aiToolsService = pluginInstance.service?.("ai-tools");
1244
+ } catch {
1245
+ }
1246
+ }
1247
+ if (!aiToolsService?.getTools) {
1248
+ strapi.log.debug(`[${PLUGIN_ID$2}] No ai-tools service on plugin: ${pluginName}`);
1249
+ continue;
1250
+ }
1251
+ strapi.log.info(`[${PLUGIN_ID$2}] Found ai-tools service on plugin: ${pluginName}`);
1252
+ const contributed = aiToolsService.getTools();
1253
+ if (!Array.isArray(contributed)) continue;
1254
+ let count = 0;
1255
+ for (const tool2 of contributed) {
1256
+ if (!tool2.name || !tool2.execute || !tool2.schema) {
1257
+ strapi.log.warn(`[${PLUGIN_ID$2}] Invalid tool from ${pluginName}: ${tool2.name || "unnamed"}`);
1258
+ continue;
1259
+ }
1260
+ const safeName = pluginName.replace(/[^a-zA-Z0-9_-]/g, "_");
1261
+ const namespacedName = `${safeName}__${tool2.name}`;
1262
+ if (toolRegistry.has(namespacedName)) {
1263
+ strapi.log.warn(`[${PLUGIN_ID$2}] Duplicate tool: ${namespacedName}`);
1264
+ continue;
1265
+ }
1266
+ toolRegistry.register({ ...tool2, name: namespacedName });
1267
+ count++;
1268
+ }
1269
+ if (count > 0) {
1270
+ strapi.log.info(`[${PLUGIN_ID$2}] Registered ${count} tools from plugin: ${pluginName}`);
1271
+ }
1272
+ } catch (err) {
1273
+ strapi.log.warn(`[${PLUGIN_ID$2}] Tool discovery failed for ${pluginName}: ${err}`);
1274
+ }
1275
+ }
1208
1276
  plugin.createMcpServer = () => createMcpServer(strapi);
1209
1277
  plugin.mcpSessions = /* @__PURE__ */ new Map();
1210
1278
  strapi.log.info(`[${PLUGIN_ID$2}] MCP endpoint available at: /api/${PLUGIN_ID$2}/mcp`);
@@ -1517,7 +1585,7 @@ function validateBody(ctx) {
1517
1585
  return { prompt, system };
1518
1586
  }
1519
1587
  function validateChatBody(ctx) {
1520
- const { messages, system } = ctx.request.body;
1588
+ const { messages, system, enabledToolSources } = ctx.request.body;
1521
1589
  if (!messages || !Array.isArray(messages) || messages.length === 0) {
1522
1590
  ctx.badRequest("messages is required and must be a non-empty array");
1523
1591
  return null;
@@ -1526,7 +1594,11 @@ function validateChatBody(ctx) {
1526
1594
  ctx.badRequest("system must be a string if provided");
1527
1595
  return null;
1528
1596
  }
1529
- return { messages, system };
1597
+ if (enabledToolSources !== void 0 && (!Array.isArray(enabledToolSources) || !enabledToolSources.every((s) => typeof s === "string"))) {
1598
+ ctx.badRequest("enabledToolSources must be an array of strings if provided");
1599
+ return null;
1600
+ }
1601
+ return { messages, system, enabledToolSources };
1530
1602
  }
1531
1603
  function createSSEStream(ctx) {
1532
1604
  ctx.set({
@@ -1593,7 +1665,11 @@ const controller = ({ strapi }) => ({
1593
1665
  const service2 = getService(strapi, ctx);
1594
1666
  if (!service2) return;
1595
1667
  const adminUserId = ctx.state?.user?.id;
1596
- const result = await service2.chat(body.messages, { system: body.system, adminUserId });
1668
+ const result = await service2.chat(body.messages, {
1669
+ system: body.system,
1670
+ adminUserId,
1671
+ enabledToolSources: body.enabledToolSources
1672
+ });
1597
1673
  const response = result.toUIMessageStreamResponse();
1598
1674
  ctx.status = 200;
1599
1675
  ctx.set("Content-Type", "text/event-stream; charset=utf-8");
@@ -1621,6 +1697,15 @@ const controller = ({ strapi }) => ({
1621
1697
  ctx.set("x-vercel-ai-ui-message-stream", "v1");
1622
1698
  ctx.body = Readable.fromWeb(response.body);
1623
1699
  },
1700
+ async getToolSources(ctx) {
1701
+ const plugin = strapi.plugin("ai-sdk");
1702
+ const registry = plugin.toolRegistry;
1703
+ if (!registry) {
1704
+ ctx.badRequest("Tool registry not initialized");
1705
+ return;
1706
+ }
1707
+ ctx.body = { data: registry.getToolSources() };
1708
+ },
1624
1709
  async serveWidget(ctx) {
1625
1710
  const pluginRoot = path.resolve(__dirname, "..", "..");
1626
1711
  const widgetPath = path.join(pluginRoot, "dist", "widget", "widget.js");
@@ -2391,6 +2476,12 @@ const contentAPIRoutes = {
2391
2476
  const adminAPIRoutes = {
2392
2477
  type: "admin",
2393
2478
  routes: [
2479
+ {
2480
+ method: "GET",
2481
+ path: "/tool-sources",
2482
+ handler: "controller.getToolSources",
2483
+ config: { policies: [] }
2484
+ },
2394
2485
  {
2395
2486
  method: "POST",
2396
2487
  path: "/chat",
@@ -2514,8 +2605,16 @@ function createTools(strapi, context) {
2514
2605
  if (!registry) {
2515
2606
  throw new Error("Tool registry not initialized");
2516
2607
  }
2608
+ const enabledSources = context?.enabledToolSources;
2517
2609
  const tools = {};
2518
2610
  for (const [name, def] of registry.getAll()) {
2611
+ if (enabledSources) {
2612
+ const sepIndex = name.indexOf("__");
2613
+ if (sepIndex !== -1) {
2614
+ const prefix = name.substring(0, sepIndex);
2615
+ if (!enabledSources.includes(prefix)) continue;
2616
+ }
2617
+ }
2519
2618
  tools[name] = tool({
2520
2619
  description: def.description,
2521
2620
  inputSchema: zodSchema(def.schema),
@@ -2578,6 +2677,8 @@ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulf
2578
2677
 
2579
2678
  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
2679
 
2680
+ 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.
2681
+
2581
2682
  Strapi filter syntax for searchContent and aggregateContent:
2582
2683
  - Scalar fields: { title: { $containsi: "hello" } }
2583
2684
  - Relation (manyToOne): { author: { name: { $eq: "John" } } }
@@ -2637,7 +2738,7 @@ const service = ({ strapi }) => {
2637
2738
  const maxSteps = config2?.maxSteps ?? DEFAULT_MAX_STEPS;
2638
2739
  const trimmedMessages = messages.length > maxMessages ? messages.slice(-maxMessages) : messages;
2639
2740
  const modelMessages = await convertToModelMessages(trimmedMessages);
2640
- const tools = createTools(strapi, { adminUserId: options2?.adminUserId });
2741
+ const tools = createTools(strapi, { adminUserId: options2?.adminUserId, enabledToolSources: options2?.enabledToolSources });
2641
2742
  const toolsDescription = describeTools(tools);
2642
2743
  let system = composeSystemPrompt(config2, toolsDescription, options2?.system);
2643
2744
  if (options2?.adminUserId) {
@@ -14,6 +14,7 @@ declare const controller: ({ strapi }: {
14
14
  * Public chat endpoint - restricted tools, public memories, no admin auth
15
15
  */
16
16
  publicChat(ctx: Context): Promise<void>;
17
+ getToolSources(ctx: Context): Promise<void>;
17
18
  serveWidget(ctx: Context): Promise<void>;
18
19
  };
19
20
  export default controller;
@@ -7,6 +7,7 @@ declare const _default: {
7
7
  askStream(ctx: import("koa").Context): Promise<void>;
8
8
  chat(ctx: import("koa").Context): Promise<void>;
9
9
  publicChat(ctx: import("koa").Context): Promise<void>;
10
+ getToolSources(ctx: import("koa").Context): Promise<void>;
10
11
  serveWidget(ctx: import("koa").Context): Promise<void>;
11
12
  };
12
13
  mcp: ({ strapi }: {
@@ -45,6 +45,7 @@ declare const _default: {
45
45
  askStream(ctx: import("koa").Context): Promise<void>;
46
46
  chat(ctx: import("koa").Context): Promise<void>;
47
47
  publicChat(ctx: import("koa").Context): Promise<void>;
48
+ getToolSources(ctx: import("koa").Context): Promise<void>;
48
49
  serveWidget(ctx: import("koa").Context): Promise<void>;
49
50
  };
50
51
  mcp: ({ strapi }: {
@@ -118,7 +119,7 @@ declare const _default: {
118
119
  handler: string;
119
120
  config: {
120
121
  policies: any[];
121
- middlewares: string[];
122
+ middlewares?: undefined;
122
123
  };
123
124
  } | {
124
125
  method: string;
@@ -126,7 +127,7 @@ declare const _default: {
126
127
  handler: string;
127
128
  config: {
128
129
  policies: any[];
129
- middlewares?: undefined;
130
+ middlewares: string[];
130
131
  };
131
132
  })[];
132
133
  };
@@ -144,6 +145,7 @@ declare const _default: {
144
145
  chat(messages: import("ai").UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>[], options?: {
145
146
  system?: string;
146
147
  adminUserId?: number;
148
+ enabledToolSources?: string[];
147
149
  }): Promise<import("./lib/ai-provider").StreamTextRawResult>;
148
150
  publicChat(messages: import("ai").UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>[], options?: {
149
151
  system?: string;
@@ -2,6 +2,7 @@ import type { Core } from '@strapi/strapi';
2
2
  import type { z } from 'zod';
3
3
  export interface ToolContext {
4
4
  adminUserId?: number;
5
+ enabledToolSources?: string[];
5
6
  }
6
7
  export interface ToolDefinition {
7
8
  name: string;
@@ -13,6 +14,8 @@ export interface ToolDefinition {
13
14
  /** If true, tool is safe for unauthenticated public chat (read-only) */
14
15
  publicSafe?: boolean;
15
16
  }
17
+ /** Type alias for external plugin authors to import when contributing tools */
18
+ export type AiToolContribution = ToolDefinition;
16
19
  export declare class ToolRegistry {
17
20
  private readonly tools;
18
21
  register(def: ToolDefinition): void;
@@ -23,6 +26,13 @@ export declare class ToolRegistry {
23
26
  getAll(): Map<string, ToolDefinition>;
24
27
  /** Only tools that should be exposed via MCP (non-internal) */
25
28
  getPublic(): Map<string, ToolDefinition>;
29
+ /** Returns metadata about tool sources grouped by plugin prefix */
30
+ getToolSources(): Array<{
31
+ id: string;
32
+ label: string;
33
+ toolCount: number;
34
+ tools: string[];
35
+ }>;
26
36
  /** Only tools marked safe for unauthenticated public chat */
27
37
  getPublicSafe(): Map<string, ToolDefinition>;
28
38
  }
@@ -20,6 +20,7 @@ export declare function validateBody(ctx: Context): {
20
20
  export declare function validateChatBody(ctx: Context): {
21
21
  messages: UIMessage[];
22
22
  system?: string;
23
+ enabledToolSources?: string[];
23
24
  } | null;
24
25
  /**
25
26
  * Setup SSE stream with proper headers
@@ -6,7 +6,7 @@ declare const _default: {
6
6
  handler: string;
7
7
  config: {
8
8
  policies: any[];
9
- middlewares: string[];
9
+ middlewares?: undefined;
10
10
  };
11
11
  } | {
12
12
  method: string;
@@ -14,7 +14,7 @@ declare const _default: {
14
14
  handler: string;
15
15
  config: {
16
16
  policies: any[];
17
- middlewares?: undefined;
17
+ middlewares: string[];
18
18
  };
19
19
  })[];
20
20
  };
@@ -27,7 +27,7 @@ declare const routes: {
27
27
  handler: string;
28
28
  config: {
29
29
  policies: any[];
30
- middlewares: string[];
30
+ middlewares?: undefined;
31
31
  };
32
32
  } | {
33
33
  method: string;
@@ -35,7 +35,7 @@ declare const routes: {
35
35
  handler: string;
36
36
  config: {
37
37
  policies: any[];
38
- middlewares?: undefined;
38
+ middlewares: string[];
39
39
  };
40
40
  })[];
41
41
  };
@@ -11,6 +11,7 @@ declare const _default: {
11
11
  chat(messages: import("ai").UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>[], options?: {
12
12
  system?: string;
13
13
  adminUserId?: number;
14
+ enabledToolSources?: string[];
14
15
  }): Promise<import("../lib/ai-provider").StreamTextRawResult>;
15
16
  publicChat(messages: import("ai").UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>[], options?: {
16
17
  system?: string;
@@ -17,6 +17,7 @@ declare const service: ({ strapi }: {
17
17
  chat(messages: UIMessage[], options?: {
18
18
  system?: string;
19
19
  adminUserId?: number;
20
+ enabledToolSources?: string[];
20
21
  }): Promise<StreamTextRawResult>;
21
22
  /**
22
23
  * Public chat - restricted tools, public memories, no admin auth
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.10",
2
+ "version": "0.7.1",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",