strapi-plugin-ai-sdk 0.2.0 → 0.5.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.
@@ -3,6 +3,9 @@ const anthropic = require("@ai-sdk/anthropic");
3
3
  const mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
4
4
  const ai = require("ai");
5
5
  const zod = require("zod");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
6
9
  const node_stream = require("node:stream");
7
10
  const node_crypto = require("node:crypto");
8
11
  const streamableHttp_js = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
@@ -144,6 +147,8 @@ function createTTSRegistry() {
144
147
  }
145
148
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
146
149
  const DEFAULT_TEMPERATURE = 0.7;
150
+ const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
151
+ const DEFAULT_MAX_CONVERSATION_MESSAGES = 40;
147
152
  function isPromptInput(input) {
148
153
  return "prompt" in input;
149
154
  }
@@ -337,20 +342,46 @@ async function listContentTypes(strapi) {
337
342
  return { contentTypes: contentTypes2, components };
338
343
  }
339
344
  const MAX_PAGE_SIZE = 50;
345
+ const LARGE_CONTENT_FIELDS = ["content", "blocks", "body", "richText", "markdown", "html"];
340
346
  const searchContentSchema = zod.z.object({
341
347
  contentType: zod.z.string().describe(
342
348
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
343
349
  ),
344
350
  query: zod.z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
345
351
  filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object, e.g. { username: { $containsi: "john" } }'),
346
- fields: zod.z.array(zod.z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
352
+ fields: zod.z.array(zod.z.string()).optional().describe("Specific fields to return. If omitted, returns all fields (large content fields stripped unless includeContent is true)."),
347
353
  sort: zod.z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
348
354
  page: zod.z.number().optional().default(1).describe("Page number (starts at 1)"),
349
- pageSize: zod.z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`)
355
+ pageSize: zod.z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`),
356
+ status: zod.z.enum(["draft", "published"]).optional().describe("Filter by document status. Published documents have a publishedAt date. If omitted, Strapi returns drafts by default."),
357
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"'),
358
+ populate: zod.z.union([zod.z.string(), zod.z.array(zod.z.string()), zod.z.record(zod.z.string(), zod.z.unknown())]).optional().describe('Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'),
359
+ includeContent: zod.z.boolean().optional().default(false).describe("When true, includes large content fields (content, blocks, body, etc.) in results. Default false to reduce context size.")
350
360
  });
351
- const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections.";
361
+ const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content.";
362
+ function stripLargeFields(obj) {
363
+ const stripped = {};
364
+ for (const [key, value] of Object.entries(obj)) {
365
+ if (!LARGE_CONTENT_FIELDS.includes(key)) {
366
+ stripped[key] = value;
367
+ }
368
+ }
369
+ return stripped;
370
+ }
352
371
  async function searchContent(strapi, params) {
353
- const { contentType, query, filters, fields, sort, page = 1, pageSize = 10 } = params;
372
+ const {
373
+ contentType,
374
+ query,
375
+ filters,
376
+ fields,
377
+ sort,
378
+ page = 1,
379
+ pageSize = 10,
380
+ status,
381
+ locale,
382
+ populate = "*",
383
+ includeContent = false
384
+ } = params;
354
385
  if (!strapi.contentTypes[contentType]) {
355
386
  throw new Error(`Content type "${contentType}" does not exist.`);
356
387
  }
@@ -360,16 +391,22 @@ async function searchContent(strapi, params) {
360
391
  ...filters ? { filters } : {},
361
392
  ...fields ? { fields } : {},
362
393
  ...sort ? { sort } : {},
394
+ ...status ? { status } : {},
395
+ ...locale ? { locale } : {},
363
396
  page,
364
397
  pageSize: clampedPageSize,
365
- populate: "*"
398
+ populate
366
399
  });
367
400
  const total = await strapi.documents(contentType).count({
368
401
  ...query ? { _q: query } : {},
369
- ...filters ? { filters } : {}
402
+ ...filters ? { filters } : {},
403
+ ...status ? { status } : {},
404
+ ...locale ? { locale } : {}
370
405
  });
406
+ const shouldStrip = !includeContent && !fields;
407
+ const processedResults = shouldStrip ? results.map((doc) => stripLargeFields(doc)) : results;
371
408
  return {
372
- results,
409
+ results: processedResults,
373
410
  pagination: {
374
411
  page,
375
412
  pageSize: clampedPageSize,
@@ -382,11 +419,12 @@ const writeContentSchema = zod.z.object({
382
419
  action: zod.z.enum(["create", "update"]).describe("Whether to create a new document or update an existing one"),
383
420
  documentId: zod.z.string().optional().describe("Required for update — the document ID to update"),
384
421
  data: zod.z.record(zod.z.string(), zod.z.unknown()).describe("The field values to set. Must match the content type schema."),
385
- status: zod.z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft.")
422
+ status: zod.z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft."),
423
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
386
424
  });
387
425
  const writeContentDescription = "Create or update a document in any Strapi content type. Use listContentTypes first to discover the schema, and searchContent to find existing documents for updates.";
388
426
  async function writeContent(strapi, params) {
389
- const { contentType, action, documentId, data, status } = params;
427
+ const { contentType, action, documentId, data, status, locale } = params;
390
428
  if (!strapi.contentTypes[contentType]) {
391
429
  throw new Error(`Content type "${contentType}" does not exist.`);
392
430
  }
@@ -398,14 +436,25 @@ async function writeContent(strapi, params) {
398
436
  const document2 = await docs.create({
399
437
  data,
400
438
  ...status ? { status } : {},
439
+ ...locale ? { locale } : {},
401
440
  populate: "*"
402
441
  });
403
442
  return { action: "create", document: document2 };
404
443
  }
444
+ const existing = await docs.findOne({
445
+ documentId,
446
+ ...locale ? { locale } : {}
447
+ });
448
+ if (!existing) {
449
+ throw new Error(
450
+ `Document with ID "${documentId}" not found in "${contentType}".`
451
+ );
452
+ }
405
453
  const document = await docs.update({
406
454
  documentId,
407
455
  data,
408
456
  ...status ? { status } : {},
457
+ ...locale ? { locale } : {},
409
458
  populate: "*"
410
459
  });
411
460
  return { action: "update", document };
@@ -514,6 +563,88 @@ async function recallMemories(strapi, params, context) {
514
563
  return { success: false, memories: [], count: 0 };
515
564
  }
516
565
  }
566
+ const findOneContentSchema = zod.z.object({
567
+ contentType: zod.z.string().describe(
568
+ 'The content type UID to fetch from, e.g. "api::article.article"'
569
+ ),
570
+ documentId: zod.z.string().describe("The document ID to retrieve"),
571
+ populate: zod.z.union([zod.z.string(), zod.z.array(zod.z.string()), zod.z.record(zod.z.string(), zod.z.unknown())]).optional().default("*").describe(
572
+ 'Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'
573
+ ),
574
+ fields: zod.z.array(zod.z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
575
+ status: zod.z.enum(["draft", "published"]).optional().describe("Document status filter."),
576
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
577
+ });
578
+ const findOneContentDescription = "Fetch a single document by its documentId from any Strapi content type. Returns the full document with all fields and populated relations. Use searchContent first to discover document IDs.";
579
+ async function findOneContent(strapi, params) {
580
+ const { contentType, documentId, populate = "*", fields, status, locale } = params;
581
+ if (!strapi.contentTypes[contentType]) {
582
+ throw new Error(`Content type "${contentType}" does not exist.`);
583
+ }
584
+ const document = await strapi.documents(contentType).findOne({
585
+ documentId,
586
+ ...fields ? { fields } : {},
587
+ ...status ? { status } : {},
588
+ ...locale ? { locale } : {},
589
+ populate
590
+ });
591
+ if (!document) {
592
+ throw new Error(
593
+ `Document with ID "${documentId}" not found in "${contentType}".`
594
+ );
595
+ }
596
+ return { document };
597
+ }
598
+ const uploadMediaSchema = zod.z.object({
599
+ url: zod.z.string().describe("URL of the file to upload"),
600
+ name: zod.z.string().optional().describe("Custom filename (optional, defaults to filename from URL)"),
601
+ caption: zod.z.string().optional().describe("Caption for the media file"),
602
+ alternativeText: zod.z.string().optional().describe("Alternative text for accessibility")
603
+ });
604
+ const uploadMediaDescription = "Upload a media file from a URL to the Strapi media library. Returns the uploaded file data. To link media to a content type field, use writeContent with the file ID.";
605
+ async function uploadMedia(strapi, params) {
606
+ const { url, name, caption, alternativeText } = params;
607
+ const response = await fetch(url, { redirect: "follow" });
608
+ if (!response.ok) {
609
+ throw new Error(`Failed to fetch file from URL: ${response.status} ${response.statusText}`);
610
+ }
611
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
612
+ const arrayBuffer = await response.arrayBuffer();
613
+ const buffer = Buffer.from(arrayBuffer);
614
+ const finalUrl = response.url || url;
615
+ const urlPath = new URL(finalUrl).pathname;
616
+ const urlFilename = urlPath.split("/").pop() || "upload";
617
+ const filename = name || urlFilename;
618
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "strapi-upload-"));
619
+ const tmpPath = path.join(tmpDir, filename);
620
+ fs.writeFileSync(tmpPath, buffer);
621
+ const fileData = {
622
+ filepath: tmpPath,
623
+ originalFilename: filename,
624
+ mimetype: contentType,
625
+ size: buffer.length
626
+ };
627
+ const fileInfo = {};
628
+ if (caption) fileInfo.caption = caption;
629
+ if (alternativeText) fileInfo.alternativeText = alternativeText;
630
+ let uploadedFile;
631
+ try {
632
+ [uploadedFile] = await strapi.plugins.upload.services.upload.upload({
633
+ data: { fileInfo },
634
+ files: fileData
635
+ });
636
+ } finally {
637
+ try {
638
+ fs.unlinkSync(tmpPath);
639
+ } catch {
640
+ }
641
+ }
642
+ return {
643
+ file: uploadedFile,
644
+ message: `File "${uploadedFile.name}" uploaded successfully (ID: ${uploadedFile.id}).`,
645
+ usage: `To link this file to a content type field, use writeContent with: { "fieldName": ${uploadedFile.id} }`
646
+ };
647
+ }
517
648
  const listContentTypesTool = {
518
649
  name: "listContentTypes",
519
650
  description: listContentTypesDescription,
@@ -613,10 +744,28 @@ const recallMemoriesTool = {
613
744
  execute: async (args, strapi, context) => recallMemories(strapi, args, context),
614
745
  internal: true
615
746
  };
747
+ const findOneContentTool = {
748
+ name: "findOneContent",
749
+ description: findOneContentDescription,
750
+ schema: findOneContentSchema,
751
+ execute: async (args, strapi) => {
752
+ const result = await findOneContent(strapi, args);
753
+ const sanitizedDoc = await sanitizeOutput(strapi, args.contentType, result.document);
754
+ return { ...result, document: sanitizedDoc };
755
+ }
756
+ };
757
+ const uploadMediaTool = {
758
+ name: "uploadMedia",
759
+ description: uploadMediaDescription,
760
+ schema: uploadMediaSchema,
761
+ execute: async (args, strapi) => uploadMedia(strapi, args)
762
+ };
616
763
  const builtInTools = [
617
764
  listContentTypesTool,
618
765
  searchContentTool,
619
766
  writeContentTool,
767
+ findOneContentTool,
768
+ uploadMediaTool,
620
769
  triggerAnimationTool,
621
770
  sendEmailTool,
622
771
  saveMemoryTool,
@@ -702,6 +851,8 @@ const config = {
702
851
  chatModel: "claude-sonnet-4-20250514",
703
852
  baseURL: void 0,
704
853
  systemPrompt: "",
854
+ maxOutputTokens: 8192,
855
+ maxConversationMessages: 40,
705
856
  mcp: {
706
857
  sessionTimeoutMs: 4 * 60 * 60 * 1e3,
707
858
  maxSessions: 100,
@@ -1359,19 +1510,19 @@ function loadPatterns(config2) {
1359
1510
  return sources.map((p) => new RegExp(p, "i"));
1360
1511
  }
1361
1512
  function extractUserInput(ctx) {
1362
- const path = ctx.path;
1513
+ const path2 = ctx.path;
1363
1514
  const method = ctx.method;
1364
1515
  const body = ctx.request.body;
1365
- if (path.endsWith("/mcp") && (method === "GET" || method === "DELETE")) {
1516
+ if (path2.endsWith("/mcp") && (method === "GET" || method === "DELETE")) {
1366
1517
  return null;
1367
1518
  }
1368
- if (path.endsWith("/mcp") && method === "POST") {
1519
+ if (path2.endsWith("/mcp") && method === "POST") {
1369
1520
  if (body && typeof body === "object" && "params" in body) {
1370
1521
  return { text: JSON.stringify(body.params), route: "mcp" };
1371
1522
  }
1372
1523
  return null;
1373
1524
  }
1374
- if (path.endsWith("/chat") && method === "POST") {
1525
+ if (path2.endsWith("/chat") && method === "POST") {
1375
1526
  if (body && Array.isArray(body.messages)) {
1376
1527
  const messages = body.messages;
1377
1528
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -1390,9 +1541,9 @@ function extractUserInput(ctx) {
1390
1541
  }
1391
1542
  return null;
1392
1543
  }
1393
- if ((path.endsWith("/ask") || path.endsWith("/ask-stream")) && method === "POST") {
1544
+ if ((path2.endsWith("/ask") || path2.endsWith("/ask-stream")) && method === "POST") {
1394
1545
  if (body && typeof body.prompt === "string") {
1395
- return { text: body.prompt, route: path.endsWith("/ask-stream") ? "ask-stream" : "ask" };
1546
+ return { text: body.prompt, route: path2.endsWith("/ask-stream") ? "ask-stream" : "ask" };
1396
1547
  }
1397
1548
  return null;
1398
1549
  }
@@ -1660,7 +1811,10 @@ const service = ({ strapi }) => {
1660
1811
  */
1661
1812
  async chat(messages, options2) {
1662
1813
  const config2 = strapi.config.get("plugin::ai-sdk");
1663
- const modelMessages = await ai.convertToModelMessages(messages);
1814
+ const maxMessages = config2?.maxConversationMessages ?? DEFAULT_MAX_CONVERSATION_MESSAGES;
1815
+ const maxOutputTokens = config2?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
1816
+ const trimmedMessages = messages.length > maxMessages ? messages.slice(-maxMessages) : messages;
1817
+ const modelMessages = await ai.convertToModelMessages(trimmedMessages);
1664
1818
  const tools = createTools(strapi, { adminUserId: options2?.adminUserId });
1665
1819
  const toolsDescription = describeTools(tools);
1666
1820
  let system = composeSystemPrompt(config2, toolsDescription, options2?.system);
@@ -1685,6 +1839,7 @@ ${lines.join("\n")}`;
1685
1839
  messages: modelMessages,
1686
1840
  system,
1687
1841
  tools,
1842
+ maxOutputTokens,
1688
1843
  stopWhen: ai.stepCountIs(6)
1689
1844
  });
1690
1845
  },
@@ -2,6 +2,9 @@ import { createAnthropic } from "@ai-sdk/anthropic";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { generateText, streamText, tool, zodSchema, convertToModelMessages, stepCountIs } from "ai";
4
4
  import { z } from "zod";
5
+ import { mkdtempSync, writeFileSync, unlinkSync } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { join } from "path";
5
8
  import { PassThrough, Readable } from "node:stream";
6
9
  import { randomUUID } from "node:crypto";
7
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -143,6 +146,8 @@ function createTTSRegistry() {
143
146
  }
144
147
  const DEFAULT_MODEL = "claude-sonnet-4-20250514";
145
148
  const DEFAULT_TEMPERATURE = 0.7;
149
+ const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
150
+ const DEFAULT_MAX_CONVERSATION_MESSAGES = 40;
146
151
  function isPromptInput(input) {
147
152
  return "prompt" in input;
148
153
  }
@@ -336,20 +341,46 @@ async function listContentTypes(strapi) {
336
341
  return { contentTypes: contentTypes2, components };
337
342
  }
338
343
  const MAX_PAGE_SIZE = 50;
344
+ const LARGE_CONTENT_FIELDS = ["content", "blocks", "body", "richText", "markdown", "html"];
339
345
  const searchContentSchema = z.object({
340
346
  contentType: z.string().describe(
341
347
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
342
348
  ),
343
349
  query: z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
344
350
  filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object, e.g. { username: { $containsi: "john" } }'),
345
- fields: z.array(z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
351
+ fields: z.array(z.string()).optional().describe("Specific fields to return. If omitted, returns all fields (large content fields stripped unless includeContent is true)."),
346
352
  sort: z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
347
353
  page: z.number().optional().default(1).describe("Page number (starts at 1)"),
348
- pageSize: z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`)
354
+ pageSize: z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`),
355
+ status: z.enum(["draft", "published"]).optional().describe("Filter by document status. Published documents have a publishedAt date. If omitted, Strapi returns drafts by default."),
356
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"'),
357
+ populate: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())]).optional().describe('Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'),
358
+ includeContent: z.boolean().optional().default(false).describe("When true, includes large content fields (content, blocks, body, etc.) in results. Default false to reduce context size.")
349
359
  });
350
- const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections.";
360
+ const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content.";
361
+ function stripLargeFields(obj) {
362
+ const stripped = {};
363
+ for (const [key, value] of Object.entries(obj)) {
364
+ if (!LARGE_CONTENT_FIELDS.includes(key)) {
365
+ stripped[key] = value;
366
+ }
367
+ }
368
+ return stripped;
369
+ }
351
370
  async function searchContent(strapi, params) {
352
- const { contentType, query, filters, fields, sort, page = 1, pageSize = 10 } = params;
371
+ const {
372
+ contentType,
373
+ query,
374
+ filters,
375
+ fields,
376
+ sort,
377
+ page = 1,
378
+ pageSize = 10,
379
+ status,
380
+ locale,
381
+ populate = "*",
382
+ includeContent = false
383
+ } = params;
353
384
  if (!strapi.contentTypes[contentType]) {
354
385
  throw new Error(`Content type "${contentType}" does not exist.`);
355
386
  }
@@ -359,16 +390,22 @@ async function searchContent(strapi, params) {
359
390
  ...filters ? { filters } : {},
360
391
  ...fields ? { fields } : {},
361
392
  ...sort ? { sort } : {},
393
+ ...status ? { status } : {},
394
+ ...locale ? { locale } : {},
362
395
  page,
363
396
  pageSize: clampedPageSize,
364
- populate: "*"
397
+ populate
365
398
  });
366
399
  const total = await strapi.documents(contentType).count({
367
400
  ...query ? { _q: query } : {},
368
- ...filters ? { filters } : {}
401
+ ...filters ? { filters } : {},
402
+ ...status ? { status } : {},
403
+ ...locale ? { locale } : {}
369
404
  });
405
+ const shouldStrip = !includeContent && !fields;
406
+ const processedResults = shouldStrip ? results.map((doc) => stripLargeFields(doc)) : results;
370
407
  return {
371
- results,
408
+ results: processedResults,
372
409
  pagination: {
373
410
  page,
374
411
  pageSize: clampedPageSize,
@@ -381,11 +418,12 @@ const writeContentSchema = z.object({
381
418
  action: z.enum(["create", "update"]).describe("Whether to create a new document or update an existing one"),
382
419
  documentId: z.string().optional().describe("Required for update — the document ID to update"),
383
420
  data: z.record(z.string(), z.unknown()).describe("The field values to set. Must match the content type schema."),
384
- status: z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft.")
421
+ status: z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft."),
422
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
385
423
  });
386
424
  const writeContentDescription = "Create or update a document in any Strapi content type. Use listContentTypes first to discover the schema, and searchContent to find existing documents for updates.";
387
425
  async function writeContent(strapi, params) {
388
- const { contentType, action, documentId, data, status } = params;
426
+ const { contentType, action, documentId, data, status, locale } = params;
389
427
  if (!strapi.contentTypes[contentType]) {
390
428
  throw new Error(`Content type "${contentType}" does not exist.`);
391
429
  }
@@ -397,14 +435,25 @@ async function writeContent(strapi, params) {
397
435
  const document2 = await docs.create({
398
436
  data,
399
437
  ...status ? { status } : {},
438
+ ...locale ? { locale } : {},
400
439
  populate: "*"
401
440
  });
402
441
  return { action: "create", document: document2 };
403
442
  }
443
+ const existing = await docs.findOne({
444
+ documentId,
445
+ ...locale ? { locale } : {}
446
+ });
447
+ if (!existing) {
448
+ throw new Error(
449
+ `Document with ID "${documentId}" not found in "${contentType}".`
450
+ );
451
+ }
404
452
  const document = await docs.update({
405
453
  documentId,
406
454
  data,
407
455
  ...status ? { status } : {},
456
+ ...locale ? { locale } : {},
408
457
  populate: "*"
409
458
  });
410
459
  return { action: "update", document };
@@ -513,6 +562,88 @@ async function recallMemories(strapi, params, context) {
513
562
  return { success: false, memories: [], count: 0 };
514
563
  }
515
564
  }
565
+ const findOneContentSchema = z.object({
566
+ contentType: z.string().describe(
567
+ 'The content type UID to fetch from, e.g. "api::article.article"'
568
+ ),
569
+ documentId: z.string().describe("The document ID to retrieve"),
570
+ populate: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())]).optional().default("*").describe(
571
+ 'Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'
572
+ ),
573
+ fields: z.array(z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
574
+ status: z.enum(["draft", "published"]).optional().describe("Document status filter."),
575
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
576
+ });
577
+ const findOneContentDescription = "Fetch a single document by its documentId from any Strapi content type. Returns the full document with all fields and populated relations. Use searchContent first to discover document IDs.";
578
+ async function findOneContent(strapi, params) {
579
+ const { contentType, documentId, populate = "*", fields, status, locale } = params;
580
+ if (!strapi.contentTypes[contentType]) {
581
+ throw new Error(`Content type "${contentType}" does not exist.`);
582
+ }
583
+ const document = await strapi.documents(contentType).findOne({
584
+ documentId,
585
+ ...fields ? { fields } : {},
586
+ ...status ? { status } : {},
587
+ ...locale ? { locale } : {},
588
+ populate
589
+ });
590
+ if (!document) {
591
+ throw new Error(
592
+ `Document with ID "${documentId}" not found in "${contentType}".`
593
+ );
594
+ }
595
+ return { document };
596
+ }
597
+ const uploadMediaSchema = z.object({
598
+ url: z.string().describe("URL of the file to upload"),
599
+ name: z.string().optional().describe("Custom filename (optional, defaults to filename from URL)"),
600
+ caption: z.string().optional().describe("Caption for the media file"),
601
+ alternativeText: z.string().optional().describe("Alternative text for accessibility")
602
+ });
603
+ const uploadMediaDescription = "Upload a media file from a URL to the Strapi media library. Returns the uploaded file data. To link media to a content type field, use writeContent with the file ID.";
604
+ async function uploadMedia(strapi, params) {
605
+ const { url, name, caption, alternativeText } = params;
606
+ const response = await fetch(url, { redirect: "follow" });
607
+ if (!response.ok) {
608
+ throw new Error(`Failed to fetch file from URL: ${response.status} ${response.statusText}`);
609
+ }
610
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
611
+ const arrayBuffer = await response.arrayBuffer();
612
+ const buffer = Buffer.from(arrayBuffer);
613
+ const finalUrl = response.url || url;
614
+ const urlPath = new URL(finalUrl).pathname;
615
+ const urlFilename = urlPath.split("/").pop() || "upload";
616
+ const filename = name || urlFilename;
617
+ const tmpDir = mkdtempSync(join(tmpdir(), "strapi-upload-"));
618
+ const tmpPath = join(tmpDir, filename);
619
+ writeFileSync(tmpPath, buffer);
620
+ const fileData = {
621
+ filepath: tmpPath,
622
+ originalFilename: filename,
623
+ mimetype: contentType,
624
+ size: buffer.length
625
+ };
626
+ const fileInfo = {};
627
+ if (caption) fileInfo.caption = caption;
628
+ if (alternativeText) fileInfo.alternativeText = alternativeText;
629
+ let uploadedFile;
630
+ try {
631
+ [uploadedFile] = await strapi.plugins.upload.services.upload.upload({
632
+ data: { fileInfo },
633
+ files: fileData
634
+ });
635
+ } finally {
636
+ try {
637
+ unlinkSync(tmpPath);
638
+ } catch {
639
+ }
640
+ }
641
+ return {
642
+ file: uploadedFile,
643
+ message: `File "${uploadedFile.name}" uploaded successfully (ID: ${uploadedFile.id}).`,
644
+ usage: `To link this file to a content type field, use writeContent with: { "fieldName": ${uploadedFile.id} }`
645
+ };
646
+ }
516
647
  const listContentTypesTool = {
517
648
  name: "listContentTypes",
518
649
  description: listContentTypesDescription,
@@ -612,10 +743,28 @@ const recallMemoriesTool = {
612
743
  execute: async (args, strapi, context) => recallMemories(strapi, args, context),
613
744
  internal: true
614
745
  };
746
+ const findOneContentTool = {
747
+ name: "findOneContent",
748
+ description: findOneContentDescription,
749
+ schema: findOneContentSchema,
750
+ execute: async (args, strapi) => {
751
+ const result = await findOneContent(strapi, args);
752
+ const sanitizedDoc = await sanitizeOutput(strapi, args.contentType, result.document);
753
+ return { ...result, document: sanitizedDoc };
754
+ }
755
+ };
756
+ const uploadMediaTool = {
757
+ name: "uploadMedia",
758
+ description: uploadMediaDescription,
759
+ schema: uploadMediaSchema,
760
+ execute: async (args, strapi) => uploadMedia(strapi, args)
761
+ };
615
762
  const builtInTools = [
616
763
  listContentTypesTool,
617
764
  searchContentTool,
618
765
  writeContentTool,
766
+ findOneContentTool,
767
+ uploadMediaTool,
619
768
  triggerAnimationTool,
620
769
  sendEmailTool,
621
770
  saveMemoryTool,
@@ -701,6 +850,8 @@ const config = {
701
850
  chatModel: "claude-sonnet-4-20250514",
702
851
  baseURL: void 0,
703
852
  systemPrompt: "",
853
+ maxOutputTokens: 8192,
854
+ maxConversationMessages: 40,
704
855
  mcp: {
705
856
  sessionTimeoutMs: 4 * 60 * 60 * 1e3,
706
857
  maxSessions: 100,
@@ -1659,7 +1810,10 @@ const service = ({ strapi }) => {
1659
1810
  */
1660
1811
  async chat(messages, options2) {
1661
1812
  const config2 = strapi.config.get("plugin::ai-sdk");
1662
- const modelMessages = await convertToModelMessages(messages);
1813
+ const maxMessages = config2?.maxConversationMessages ?? DEFAULT_MAX_CONVERSATION_MESSAGES;
1814
+ const maxOutputTokens = config2?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
1815
+ const trimmedMessages = messages.length > maxMessages ? messages.slice(-maxMessages) : messages;
1816
+ const modelMessages = await convertToModelMessages(trimmedMessages);
1663
1817
  const tools = createTools(strapi, { adminUserId: options2?.adminUserId });
1664
1818
  const toolsDescription = describeTools(tools);
1665
1819
  let system = composeSystemPrompt(config2, toolsDescription, options2?.system);
@@ -1684,6 +1838,7 @@ ${lines.join("\n")}`;
1684
1838
  messages: modelMessages,
1685
1839
  system,
1686
1840
  tools,
1841
+ maxOutputTokens,
1687
1842
  stopWhen: stepCountIs(6)
1688
1843
  });
1689
1844
  },
@@ -5,6 +5,8 @@ declare const _default: {
5
5
  chatModel: string;
6
6
  baseURL: any;
7
7
  systemPrompt: string;
8
+ maxOutputTokens: number;
9
+ maxConversationMessages: number;
8
10
  mcp: {
9
11
  sessionTimeoutMs: number;
10
12
  maxSessions: number;
@@ -16,6 +16,8 @@ declare const _default: {
16
16
  chatModel: string;
17
17
  baseURL: any;
18
18
  systemPrompt: string;
19
+ maxOutputTokens: number;
20
+ maxConversationMessages: number;
19
21
  mcp: {
20
22
  sessionTimeoutMs: number;
21
23
  maxSessions: number;
@@ -16,12 +16,16 @@ export interface MCPConfig {
16
16
  maxSessions?: number;
17
17
  cleanupInterval?: number;
18
18
  }
19
+ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 8192;
20
+ export declare const DEFAULT_MAX_CONVERSATION_MESSAGES = 40;
19
21
  export interface PluginConfig {
20
22
  anthropicApiKey: string;
21
23
  provider?: string;
22
24
  chatModel?: string;
23
25
  baseURL?: string;
24
26
  systemPrompt?: string;
27
+ maxOutputTokens?: number;
28
+ maxConversationMessages?: number;
25
29
  typecastApiKey?: string;
26
30
  typecastActorId?: string;
27
31
  mcp?: MCPConfig;
@@ -0,0 +1,30 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ import { z } from 'zod';
3
+ export declare const findOneContentSchema: z.ZodObject<{
4
+ contentType: z.ZodString;
5
+ documentId: z.ZodString;
6
+ populate: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>>;
7
+ fields: z.ZodOptional<z.ZodArray<z.ZodString>>;
8
+ status: z.ZodOptional<z.ZodEnum<{
9
+ draft: "draft";
10
+ published: "published";
11
+ }>>;
12
+ locale: z.ZodOptional<z.ZodString>;
13
+ }, z.core.$strip>;
14
+ export declare const findOneContentDescription = "Fetch a single document by its documentId from any Strapi content type. Returns the full document with all fields and populated relations. Use searchContent first to discover document IDs.";
15
+ export interface FindOneContentParams {
16
+ contentType: string;
17
+ documentId: string;
18
+ populate?: string | string[] | Record<string, unknown>;
19
+ fields?: string[];
20
+ status?: 'draft' | 'published';
21
+ locale?: string;
22
+ }
23
+ export interface FindOneContentResult {
24
+ document: any;
25
+ }
26
+ /**
27
+ * Core logic for fetching a single document by ID.
28
+ * Shared between AI SDK tool and MCP tool.
29
+ */
30
+ export declare function findOneContent(strapi: Core.Strapi, params: FindOneContentParams): Promise<FindOneContentResult>;
@@ -10,3 +10,7 @@ export { saveMemory, saveMemorySchema, saveMemoryDescription } from './save-memo
10
10
  export type { SaveMemoryParams, SaveMemoryResult } from './save-memory';
11
11
  export { recallMemories, recallMemoriesSchema, recallMemoriesDescription } from './recall-memories';
12
12
  export type { RecallMemoriesParams, RecallMemoriesResult } from './recall-memories';
13
+ export { findOneContent, findOneContentSchema, findOneContentDescription } from './find-one-content';
14
+ export type { FindOneContentParams, FindOneContentResult } from './find-one-content';
15
+ export { uploadMedia, uploadMediaSchema, uploadMediaDescription } from './upload-media';
16
+ export type { UploadMediaParams, UploadMediaResult } from './upload-media';
@@ -8,8 +8,15 @@ export declare const searchContentSchema: z.ZodObject<{
8
8
  sort: z.ZodOptional<z.ZodString>;
9
9
  page: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
10
10
  pageSize: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
11
+ status: z.ZodOptional<z.ZodEnum<{
12
+ draft: "draft";
13
+ published: "published";
14
+ }>>;
15
+ locale: z.ZodOptional<z.ZodString>;
16
+ populate: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
17
+ includeContent: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
11
18
  }, z.core.$strip>;
12
- export declare const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections.";
19
+ export declare const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. By default, large content fields are stripped from results \u2014 set includeContent to true or use fields to get full content.";
13
20
  export interface SearchContentParams {
14
21
  contentType: string;
15
22
  query?: string;
@@ -18,6 +25,10 @@ export interface SearchContentParams {
18
25
  sort?: string;
19
26
  page?: number;
20
27
  pageSize?: number;
28
+ status?: 'draft' | 'published';
29
+ locale?: string;
30
+ populate?: string | string[] | Record<string, unknown>;
31
+ includeContent?: boolean;
21
32
  }
22
33
  export interface SearchContentResult {
23
34
  results: any[];
@@ -0,0 +1,25 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ import { z } from 'zod';
3
+ export declare const uploadMediaSchema: z.ZodObject<{
4
+ url: z.ZodString;
5
+ name: z.ZodOptional<z.ZodString>;
6
+ caption: z.ZodOptional<z.ZodString>;
7
+ alternativeText: z.ZodOptional<z.ZodString>;
8
+ }, z.core.$strip>;
9
+ export declare const uploadMediaDescription = "Upload a media file from a URL to the Strapi media library. Returns the uploaded file data. To link media to a content type field, use writeContent with the file ID.";
10
+ export interface UploadMediaParams {
11
+ url: string;
12
+ name?: string;
13
+ caption?: string;
14
+ alternativeText?: string;
15
+ }
16
+ export interface UploadMediaResult {
17
+ file: any;
18
+ message: string;
19
+ usage: string;
20
+ }
21
+ /**
22
+ * Core logic for uploading media from a URL.
23
+ * Shared between AI SDK tool and MCP tool.
24
+ */
25
+ export declare function uploadMedia(strapi: Core.Strapi, params: UploadMediaParams): Promise<UploadMediaResult>;
@@ -12,6 +12,7 @@ export declare const writeContentSchema: z.ZodObject<{
12
12
  draft: "draft";
13
13
  published: "published";
14
14
  }>>;
15
+ locale: z.ZodOptional<z.ZodString>;
15
16
  }, z.core.$strip>;
16
17
  export declare const writeContentDescription = "Create or update a document in any Strapi content type. Use listContentTypes first to discover the schema, and searchContent to find existing documents for updates.";
17
18
  export interface WriteContentParams {
@@ -20,6 +21,7 @@ export interface WriteContentParams {
20
21
  documentId?: string;
21
22
  data: Record<string, unknown>;
22
23
  status?: 'draft' | 'published';
24
+ locale?: string;
23
25
  }
24
26
  export interface WriteContentResult {
25
27
  action: 'create' | 'update';
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from '../../lib/tool-registry';
2
+ export declare const findOneContentTool: ToolDefinition;
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from '../../lib/tool-registry';
2
+ export declare const uploadMediaTool: ToolDefinition;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.0",
2
+ "version": "0.5.0",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",
@@ -41,7 +41,8 @@
41
41
  "test:api": "npx tsx tests/ai-sdk.test.ts",
42
42
  "test:stream": "node tests/test-stream.mjs",
43
43
  "test:chat": "node tests/test-chat.mjs",
44
- "test:guardrails": "npx tsx tests/test-guardrails.ts"
44
+ "test:guardrails": "npx tsx tests/test-guardrails.ts",
45
+ "test:mcp": "npx tsx tests/mcp.test.ts"
45
46
  },
46
47
  "dependencies": {
47
48
  "@ai-sdk/anthropic": "^3.0.15",