strapi-plugin-ai-sdk 0.6.9 → 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
 
@@ -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-DCEjJ0as.js");
9
+ const index = require("./index-BNk29VRc.js");
10
10
  const icons = require("@strapi/icons");
11
11
  const Markdown = require("react-markdown");
12
12
  const remarkGfm = require("remark-gfm");
@@ -618,6 +618,267 @@ function MemoryPanel({ memories, open, onDelete }) {
618
618
  ] })
619
619
  ] });
620
620
  }
621
+ const SCORE_LABELS = {
622
+ 1: "Negligible",
623
+ 2: "Minor",
624
+ 3: "Moderate",
625
+ 4: "Significant",
626
+ 5: "Critical"
627
+ };
628
+ const Card = styled__default.default.div`
629
+ margin-top: 8px;
630
+ border: 1px solid #dcdce4;
631
+ border-radius: 8px;
632
+ padding: 14px 16px;
633
+ background: #fff;
634
+ font-size: 13px;
635
+ `;
636
+ const Title = styled__default.default.div`
637
+ font-weight: 700;
638
+ font-size: 14px;
639
+ color: #32324d;
640
+ margin-bottom: 2px;
641
+ `;
642
+ const Description = styled__default.default.div`
643
+ color: #8e8ea9;
644
+ font-size: 12px;
645
+ margin-bottom: 10px;
646
+ `;
647
+ const Row = styled__default.default.div`
648
+ display: flex;
649
+ align-items: center;
650
+ gap: 10px;
651
+ margin-bottom: 8px;
652
+ flex-wrap: wrap;
653
+ `;
654
+ const Label = styled__default.default.label`
655
+ font-size: 12px;
656
+ font-weight: 600;
657
+ color: #666687;
658
+ min-width: 80px;
659
+ `;
660
+ const Select = styled__default.default.select`
661
+ padding: 4px 8px;
662
+ border: 1px solid #dcdce4;
663
+ border-radius: 4px;
664
+ font-size: 12px;
665
+ background: #fff;
666
+ color: #32324d;
667
+ `;
668
+ const DateInput = styled__default.default.input`
669
+ padding: 4px 8px;
670
+ border: 1px solid #dcdce4;
671
+ border-radius: 4px;
672
+ font-size: 12px;
673
+ color: #32324d;
674
+ `;
675
+ const AsapButton = styled__default.default.button`
676
+ padding: 4px 10px;
677
+ border: none;
678
+ border-radius: 4px;
679
+ background: #4945ff;
680
+ color: #fff;
681
+ font-size: 11px;
682
+ font-weight: 600;
683
+ cursor: pointer;
684
+
685
+ &:hover {
686
+ background: #3b38e0;
687
+ }
688
+ `;
689
+ const ScorePreview = styled__default.default.div`
690
+ font-size: 12px;
691
+ color: #666687;
692
+ margin-bottom: 10px;
693
+ font-weight: 500;
694
+ `;
695
+ const CreateButton = styled__default.default.button`
696
+ padding: 8px 18px;
697
+ border: none;
698
+ border-radius: 4px;
699
+ background: #4945ff;
700
+ color: #fff;
701
+ font-size: 13px;
702
+ font-weight: 600;
703
+ cursor: pointer;
704
+
705
+ &:disabled {
706
+ background: #a5a5ba;
707
+ cursor: not-allowed;
708
+ }
709
+
710
+ &:not(:disabled):hover {
711
+ background: #3b38e0;
712
+ }
713
+ `;
714
+ const SuccessBanner = styled__default.default.div`
715
+ margin-top: 8px;
716
+ border: 1px solid #c6f0c2;
717
+ border-radius: 8px;
718
+ padding: 14px 16px;
719
+ background: #eafbe7;
720
+ font-size: 13px;
721
+ color: #2f6846;
722
+ `;
723
+ const SuccessTitle = styled__default.default.div`
724
+ font-weight: 700;
725
+ margin-bottom: 4px;
726
+ `;
727
+ const TaskLink = styled__default.default(reactRouterDom.Link)`
728
+ color: #4945ff;
729
+ font-weight: 600;
730
+ text-decoration: none;
731
+ font-size: 12px;
732
+
733
+ &:hover {
734
+ text-decoration: underline;
735
+ }
736
+ `;
737
+ const ErrorText = styled__default.default.div`
738
+ color: #d02b20;
739
+ font-size: 12px;
740
+ margin-top: 4px;
741
+ `;
742
+ function TaskConfirmCard({ proposed }) {
743
+ const [consequence, setConsequence] = react.useState(null);
744
+ const [impact, setImpact] = react.useState(null);
745
+ const [dueDate, setDueDate] = react.useState(proposed.dueDate ?? "");
746
+ const [submitting, setSubmitting] = react.useState(false);
747
+ const [created, setCreated] = react.useState(null);
748
+ const [error, setError] = react.useState(null);
749
+ const score = consequence != null && impact != null ? consequence * impact : null;
750
+ async function handleCreate() {
751
+ if (consequence == null || impact == null) return;
752
+ setSubmitting(true);
753
+ setError(null);
754
+ try {
755
+ const token = getToken();
756
+ const backend = getBackendURL();
757
+ const res = await fetch(`${backend}/ai-sdk/tasks`, {
758
+ method: "POST",
759
+ headers: {
760
+ "Content-Type": "application/json",
761
+ ...token ? { Authorization: `Bearer ${token}` } : {}
762
+ },
763
+ body: JSON.stringify({
764
+ title: proposed.title,
765
+ description: proposed.description,
766
+ content: proposed.content,
767
+ priority: proposed.priority,
768
+ consequence,
769
+ impact,
770
+ dueDate: dueDate || void 0
771
+ })
772
+ });
773
+ if (!res.ok) {
774
+ const body = await res.json().catch(() => ({}));
775
+ throw new Error(body.error ?? `HTTP ${res.status}`);
776
+ }
777
+ const { data } = await res.json();
778
+ setCreated({
779
+ documentId: data.documentId,
780
+ title: data.title,
781
+ consequence: data.consequence,
782
+ impact: data.impact,
783
+ priority: data.priority
784
+ });
785
+ } catch (err) {
786
+ setError(err instanceof Error ? err.message : String(err));
787
+ } finally {
788
+ setSubmitting(false);
789
+ }
790
+ }
791
+ if (created) {
792
+ const s = created.consequence * created.impact;
793
+ return /* @__PURE__ */ jsxRuntime.jsxs(SuccessBanner, { children: [
794
+ /* @__PURE__ */ jsxRuntime.jsxs(SuccessTitle, { children: [
795
+ "Task created: ",
796
+ created.title
797
+ ] }),
798
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
799
+ "Score: ",
800
+ created.consequence,
801
+ " x ",
802
+ created.impact,
803
+ " = ",
804
+ s,
805
+ " · Priority: ",
806
+ created.priority
807
+ ] }),
808
+ /* @__PURE__ */ jsxRuntime.jsx("div", { style: { marginTop: 6 }, children: /* @__PURE__ */ jsxRuntime.jsx(TaskLink, { to: `/content-manager/collection-types/plugin::ai-sdk.task/${created.documentId}`, children: "Open in Content Manager" }) })
809
+ ] });
810
+ }
811
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
812
+ return /* @__PURE__ */ jsxRuntime.jsxs(Card, { children: [
813
+ /* @__PURE__ */ jsxRuntime.jsx(Title, { children: proposed.title }),
814
+ proposed.description && /* @__PURE__ */ jsxRuntime.jsx(Description, { children: proposed.description }),
815
+ /* @__PURE__ */ jsxRuntime.jsxs(Row, { children: [
816
+ /* @__PURE__ */ jsxRuntime.jsx(Label, { children: "Consequence" }),
817
+ /* @__PURE__ */ jsxRuntime.jsxs(
818
+ Select,
819
+ {
820
+ value: consequence ?? "",
821
+ onChange: (e) => setConsequence(e.target.value ? Number(e.target.value) : null),
822
+ children: [
823
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", children: "Select…" }),
824
+ [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ jsxRuntime.jsxs("option", { value: n, children: [
825
+ n,
826
+ " — ",
827
+ SCORE_LABELS[n]
828
+ ] }, n))
829
+ ]
830
+ }
831
+ )
832
+ ] }),
833
+ /* @__PURE__ */ jsxRuntime.jsxs(Row, { children: [
834
+ /* @__PURE__ */ jsxRuntime.jsx(Label, { children: "Impact" }),
835
+ /* @__PURE__ */ jsxRuntime.jsxs(
836
+ Select,
837
+ {
838
+ value: impact ?? "",
839
+ onChange: (e) => setImpact(e.target.value ? Number(e.target.value) : null),
840
+ children: [
841
+ /* @__PURE__ */ jsxRuntime.jsx("option", { value: "", children: "Select…" }),
842
+ [1, 2, 3, 4, 5].map((n) => /* @__PURE__ */ jsxRuntime.jsxs("option", { value: n, children: [
843
+ n,
844
+ " — ",
845
+ SCORE_LABELS[n]
846
+ ] }, n))
847
+ ]
848
+ }
849
+ )
850
+ ] }),
851
+ /* @__PURE__ */ jsxRuntime.jsxs(Row, { children: [
852
+ /* @__PURE__ */ jsxRuntime.jsx(Label, { children: "Due date" }),
853
+ /* @__PURE__ */ jsxRuntime.jsx(
854
+ DateInput,
855
+ {
856
+ type: "date",
857
+ value: dueDate,
858
+ onChange: (e) => setDueDate(e.target.value)
859
+ }
860
+ ),
861
+ /* @__PURE__ */ jsxRuntime.jsx(AsapButton, { type: "button", onClick: () => setDueDate(today), children: "ASAP" })
862
+ ] }),
863
+ score != null && /* @__PURE__ */ jsxRuntime.jsxs(ScorePreview, { children: [
864
+ "Score: ",
865
+ consequence,
866
+ " x ",
867
+ impact,
868
+ " = ",
869
+ score
870
+ ] }),
871
+ /* @__PURE__ */ jsxRuntime.jsx(
872
+ CreateButton,
873
+ {
874
+ disabled: consequence == null || impact == null || submitting,
875
+ onClick: handleCreate,
876
+ children: submitting ? "Creating…" : "Create Task"
877
+ }
878
+ ),
879
+ error && /* @__PURE__ */ jsxRuntime.jsx(ErrorText, { children: error })
880
+ ] });
881
+ }
621
882
  function buildContentManagerUrl(contentType, documentId) {
622
883
  const base = `/content-manager/collection-types/${contentType}`;
623
884
  return documentId ? `${base}/${documentId}` : base;
@@ -663,6 +924,31 @@ function extractContentLinks(toolCall) {
663
924
  }
664
925
  return [];
665
926
  }
927
+ const TASK_CONTENT_TYPE = "plugin::ai-sdk.task";
928
+ function extractTaskLinks(toolCall) {
929
+ if (toolCall.toolName !== "manageTask" || toolCall.output == null) return [];
930
+ const output = toolCall.output;
931
+ if (!output.success) return [];
932
+ const data = output.data;
933
+ if (!data) return [];
934
+ if (!Array.isArray(data)) {
935
+ const docId = data.documentId;
936
+ const title = data.title || docId;
937
+ if (docId && title) {
938
+ return [{ label: title, to: buildContentManagerUrl(TASK_CONTENT_TYPE, docId) }];
939
+ }
940
+ return [];
941
+ }
942
+ const links = [];
943
+ for (const task of data.slice(0, 5)) {
944
+ const docId = task.documentId;
945
+ const title = task.title || docId;
946
+ if (docId && title) {
947
+ links.push({ label: title, to: buildContentManagerUrl(TASK_CONTENT_TYPE, docId) });
948
+ }
949
+ }
950
+ return links;
951
+ }
666
952
  const ToolCallBox = styled__default.default.div`
667
953
  margin-top: 8px;
668
954
  border: 1px solid #dcdce4;
@@ -747,6 +1033,13 @@ const HIDDEN_TOOLS = /* @__PURE__ */ new Set();
747
1033
  function ToolCallDisplay({ toolCall }) {
748
1034
  const [expanded, setExpanded] = react.useState(false);
749
1035
  const contentLinks = extractContentLinks(toolCall);
1036
+ const taskLinks = extractTaskLinks(toolCall);
1037
+ if (toolCall.toolName === "manageTask" && toolCall.output != null) {
1038
+ const output = toolCall.output;
1039
+ if (output.status === "pending_confirmation" && output.proposed) {
1040
+ return /* @__PURE__ */ jsxRuntime.jsx(TaskConfirmCard, { proposed: output.proposed });
1041
+ }
1042
+ }
750
1043
  return /* @__PURE__ */ jsxRuntime.jsxs(ToolCallBox, { children: [
751
1044
  /* @__PURE__ */ jsxRuntime.jsxs(ToolCallHeader, { onClick: () => setExpanded(!expanded), children: [
752
1045
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: expanded ? "▼" : "▶" }),
@@ -756,7 +1049,10 @@ function ToolCallDisplay({ toolCall }) {
756
1049
  ] }),
757
1050
  toolCall.output === void 0 ? /* @__PURE__ */ jsxRuntime.jsx(Spinner, {}) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: { marginLeft: "auto", fontWeight: 400, opacity: 0.6 }, children: "completed" })
758
1051
  ] }),
759
- contentLinks.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ContentLinksRow, { children: contentLinks.map((link) => /* @__PURE__ */ jsxRuntime.jsx(ContentLinkChip, { to: link.to, children: link.label }, link.to)) }),
1052
+ (contentLinks.length > 0 || taskLinks.length > 0) && /* @__PURE__ */ jsxRuntime.jsxs(ContentLinksRow, { children: [
1053
+ contentLinks.map((link) => /* @__PURE__ */ jsxRuntime.jsx(ContentLinkChip, { to: link.to, children: link.label }, link.to)),
1054
+ taskLinks.map((link) => /* @__PURE__ */ jsxRuntime.jsx(ContentLinkChip, { to: link.to, children: link.label }, link.to))
1055
+ ] }),
760
1056
  expanded && /* @__PURE__ */ jsxRuntime.jsx(ToolCallContent, { children: toolCall.output === void 0 ? "Waiting for result..." : JSON.stringify(toolCall.output, null, 2) })
761
1057
  ] });
762
1058
  }