strapi-plugin-ai-sdk 0.2.0 → 0.4.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");
@@ -337,20 +340,46 @@ async function listContentTypes(strapi) {
337
340
  return { contentTypes: contentTypes2, components };
338
341
  }
339
342
  const MAX_PAGE_SIZE = 50;
343
+ const LARGE_CONTENT_FIELDS = ["content", "blocks", "body", "richText", "markdown", "html"];
340
344
  const searchContentSchema = zod.z.object({
341
345
  contentType: zod.z.string().describe(
342
346
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
343
347
  ),
344
348
  query: zod.z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
345
349
  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."),
350
+ 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
351
  sort: zod.z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
348
352
  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})`)
353
+ pageSize: zod.z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`),
354
+ 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."),
355
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"'),
356
+ 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.'),
357
+ 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
358
  });
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.";
359
+ 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.";
360
+ function stripLargeFields(obj) {
361
+ const stripped = {};
362
+ for (const [key, value] of Object.entries(obj)) {
363
+ if (!LARGE_CONTENT_FIELDS.includes(key)) {
364
+ stripped[key] = value;
365
+ }
366
+ }
367
+ return stripped;
368
+ }
352
369
  async function searchContent(strapi, params) {
353
- const { contentType, query, filters, fields, sort, page = 1, pageSize = 10 } = params;
370
+ const {
371
+ contentType,
372
+ query,
373
+ filters,
374
+ fields,
375
+ sort,
376
+ page = 1,
377
+ pageSize = 10,
378
+ status,
379
+ locale,
380
+ populate = "*",
381
+ includeContent = false
382
+ } = params;
354
383
  if (!strapi.contentTypes[contentType]) {
355
384
  throw new Error(`Content type "${contentType}" does not exist.`);
356
385
  }
@@ -360,16 +389,22 @@ async function searchContent(strapi, params) {
360
389
  ...filters ? { filters } : {},
361
390
  ...fields ? { fields } : {},
362
391
  ...sort ? { sort } : {},
392
+ ...status ? { status } : {},
393
+ ...locale ? { locale } : {},
363
394
  page,
364
395
  pageSize: clampedPageSize,
365
- populate: "*"
396
+ populate
366
397
  });
367
398
  const total = await strapi.documents(contentType).count({
368
399
  ...query ? { _q: query } : {},
369
- ...filters ? { filters } : {}
400
+ ...filters ? { filters } : {},
401
+ ...status ? { status } : {},
402
+ ...locale ? { locale } : {}
370
403
  });
404
+ const shouldStrip = !includeContent && !fields;
405
+ const processedResults = shouldStrip ? results.map((doc) => stripLargeFields(doc)) : results;
371
406
  return {
372
- results,
407
+ results: processedResults,
373
408
  pagination: {
374
409
  page,
375
410
  pageSize: clampedPageSize,
@@ -382,11 +417,12 @@ const writeContentSchema = zod.z.object({
382
417
  action: zod.z.enum(["create", "update"]).describe("Whether to create a new document or update an existing one"),
383
418
  documentId: zod.z.string().optional().describe("Required for update — the document ID to update"),
384
419
  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.")
420
+ status: zod.z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft."),
421
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
386
422
  });
387
423
  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
424
  async function writeContent(strapi, params) {
389
- const { contentType, action, documentId, data, status } = params;
425
+ const { contentType, action, documentId, data, status, locale } = params;
390
426
  if (!strapi.contentTypes[contentType]) {
391
427
  throw new Error(`Content type "${contentType}" does not exist.`);
392
428
  }
@@ -398,14 +434,25 @@ async function writeContent(strapi, params) {
398
434
  const document2 = await docs.create({
399
435
  data,
400
436
  ...status ? { status } : {},
437
+ ...locale ? { locale } : {},
401
438
  populate: "*"
402
439
  });
403
440
  return { action: "create", document: document2 };
404
441
  }
442
+ const existing = await docs.findOne({
443
+ documentId,
444
+ ...locale ? { locale } : {}
445
+ });
446
+ if (!existing) {
447
+ throw new Error(
448
+ `Document with ID "${documentId}" not found in "${contentType}".`
449
+ );
450
+ }
405
451
  const document = await docs.update({
406
452
  documentId,
407
453
  data,
408
454
  ...status ? { status } : {},
455
+ ...locale ? { locale } : {},
409
456
  populate: "*"
410
457
  });
411
458
  return { action: "update", document };
@@ -514,6 +561,88 @@ async function recallMemories(strapi, params, context) {
514
561
  return { success: false, memories: [], count: 0 };
515
562
  }
516
563
  }
564
+ const findOneContentSchema = zod.z.object({
565
+ contentType: zod.z.string().describe(
566
+ 'The content type UID to fetch from, e.g. "api::article.article"'
567
+ ),
568
+ documentId: zod.z.string().describe("The document ID to retrieve"),
569
+ 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(
570
+ 'Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'
571
+ ),
572
+ fields: zod.z.array(zod.z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
573
+ status: zod.z.enum(["draft", "published"]).optional().describe("Document status filter."),
574
+ locale: zod.z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
575
+ });
576
+ 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.";
577
+ async function findOneContent(strapi, params) {
578
+ const { contentType, documentId, populate = "*", fields, status, locale } = params;
579
+ if (!strapi.contentTypes[contentType]) {
580
+ throw new Error(`Content type "${contentType}" does not exist.`);
581
+ }
582
+ const document = await strapi.documents(contentType).findOne({
583
+ documentId,
584
+ ...fields ? { fields } : {},
585
+ ...status ? { status } : {},
586
+ ...locale ? { locale } : {},
587
+ populate
588
+ });
589
+ if (!document) {
590
+ throw new Error(
591
+ `Document with ID "${documentId}" not found in "${contentType}".`
592
+ );
593
+ }
594
+ return { document };
595
+ }
596
+ const uploadMediaSchema = zod.z.object({
597
+ url: zod.z.string().describe("URL of the file to upload"),
598
+ name: zod.z.string().optional().describe("Custom filename (optional, defaults to filename from URL)"),
599
+ caption: zod.z.string().optional().describe("Caption for the media file"),
600
+ alternativeText: zod.z.string().optional().describe("Alternative text for accessibility")
601
+ });
602
+ 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.";
603
+ async function uploadMedia(strapi, params) {
604
+ const { url, name, caption, alternativeText } = params;
605
+ const response = await fetch(url, { redirect: "follow" });
606
+ if (!response.ok) {
607
+ throw new Error(`Failed to fetch file from URL: ${response.status} ${response.statusText}`);
608
+ }
609
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
610
+ const arrayBuffer = await response.arrayBuffer();
611
+ const buffer = Buffer.from(arrayBuffer);
612
+ const finalUrl = response.url || url;
613
+ const urlPath = new URL(finalUrl).pathname;
614
+ const urlFilename = urlPath.split("/").pop() || "upload";
615
+ const filename = name || urlFilename;
616
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "strapi-upload-"));
617
+ const tmpPath = path.join(tmpDir, filename);
618
+ fs.writeFileSync(tmpPath, buffer);
619
+ const fileData = {
620
+ filepath: tmpPath,
621
+ originalFilename: filename,
622
+ mimetype: contentType,
623
+ size: buffer.length
624
+ };
625
+ const fileInfo = {};
626
+ if (caption) fileInfo.caption = caption;
627
+ if (alternativeText) fileInfo.alternativeText = alternativeText;
628
+ let uploadedFile;
629
+ try {
630
+ [uploadedFile] = await strapi.plugins.upload.services.upload.upload({
631
+ data: { fileInfo },
632
+ files: fileData
633
+ });
634
+ } finally {
635
+ try {
636
+ fs.unlinkSync(tmpPath);
637
+ } catch {
638
+ }
639
+ }
640
+ return {
641
+ file: uploadedFile,
642
+ message: `File "${uploadedFile.name}" uploaded successfully (ID: ${uploadedFile.id}).`,
643
+ usage: `To link this file to a content type field, use writeContent with: { "fieldName": ${uploadedFile.id} }`
644
+ };
645
+ }
517
646
  const listContentTypesTool = {
518
647
  name: "listContentTypes",
519
648
  description: listContentTypesDescription,
@@ -613,10 +742,28 @@ const recallMemoriesTool = {
613
742
  execute: async (args, strapi, context) => recallMemories(strapi, args, context),
614
743
  internal: true
615
744
  };
745
+ const findOneContentTool = {
746
+ name: "findOneContent",
747
+ description: findOneContentDescription,
748
+ schema: findOneContentSchema,
749
+ execute: async (args, strapi) => {
750
+ const result = await findOneContent(strapi, args);
751
+ const sanitizedDoc = await sanitizeOutput(strapi, args.contentType, result.document);
752
+ return { ...result, document: sanitizedDoc };
753
+ }
754
+ };
755
+ const uploadMediaTool = {
756
+ name: "uploadMedia",
757
+ description: uploadMediaDescription,
758
+ schema: uploadMediaSchema,
759
+ execute: async (args, strapi) => uploadMedia(strapi, args)
760
+ };
616
761
  const builtInTools = [
617
762
  listContentTypesTool,
618
763
  searchContentTool,
619
764
  writeContentTool,
765
+ findOneContentTool,
766
+ uploadMediaTool,
620
767
  triggerAnimationTool,
621
768
  sendEmailTool,
622
769
  saveMemoryTool,
@@ -1359,19 +1506,19 @@ function loadPatterns(config2) {
1359
1506
  return sources.map((p) => new RegExp(p, "i"));
1360
1507
  }
1361
1508
  function extractUserInput(ctx) {
1362
- const path = ctx.path;
1509
+ const path2 = ctx.path;
1363
1510
  const method = ctx.method;
1364
1511
  const body = ctx.request.body;
1365
- if (path.endsWith("/mcp") && (method === "GET" || method === "DELETE")) {
1512
+ if (path2.endsWith("/mcp") && (method === "GET" || method === "DELETE")) {
1366
1513
  return null;
1367
1514
  }
1368
- if (path.endsWith("/mcp") && method === "POST") {
1515
+ if (path2.endsWith("/mcp") && method === "POST") {
1369
1516
  if (body && typeof body === "object" && "params" in body) {
1370
1517
  return { text: JSON.stringify(body.params), route: "mcp" };
1371
1518
  }
1372
1519
  return null;
1373
1520
  }
1374
- if (path.endsWith("/chat") && method === "POST") {
1521
+ if (path2.endsWith("/chat") && method === "POST") {
1375
1522
  if (body && Array.isArray(body.messages)) {
1376
1523
  const messages = body.messages;
1377
1524
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -1390,9 +1537,9 @@ function extractUserInput(ctx) {
1390
1537
  }
1391
1538
  return null;
1392
1539
  }
1393
- if ((path.endsWith("/ask") || path.endsWith("/ask-stream")) && method === "POST") {
1540
+ if ((path2.endsWith("/ask") || path2.endsWith("/ask-stream")) && method === "POST") {
1394
1541
  if (body && typeof body.prompt === "string") {
1395
- return { text: body.prompt, route: path.endsWith("/ask-stream") ? "ask-stream" : "ask" };
1542
+ return { text: body.prompt, route: path2.endsWith("/ask-stream") ? "ask-stream" : "ask" };
1396
1543
  }
1397
1544
  return null;
1398
1545
  }
@@ -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";
@@ -336,20 +339,46 @@ async function listContentTypes(strapi) {
336
339
  return { contentTypes: contentTypes2, components };
337
340
  }
338
341
  const MAX_PAGE_SIZE = 50;
342
+ const LARGE_CONTENT_FIELDS = ["content", "blocks", "body", "richText", "markdown", "html"];
339
343
  const searchContentSchema = z.object({
340
344
  contentType: z.string().describe(
341
345
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
342
346
  ),
343
347
  query: z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
344
348
  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."),
349
+ 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
350
  sort: z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
347
351
  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})`)
352
+ pageSize: z.number().optional().default(10).describe(`Results per page (max ${MAX_PAGE_SIZE})`),
353
+ status: z.enum(["draft", "published"]).optional().describe("Filter by document status. Published documents have a publishedAt date. If omitted, Strapi returns drafts by default."),
354
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"'),
355
+ 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.'),
356
+ 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
357
  });
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.";
358
+ 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.";
359
+ function stripLargeFields(obj) {
360
+ const stripped = {};
361
+ for (const [key, value] of Object.entries(obj)) {
362
+ if (!LARGE_CONTENT_FIELDS.includes(key)) {
363
+ stripped[key] = value;
364
+ }
365
+ }
366
+ return stripped;
367
+ }
351
368
  async function searchContent(strapi, params) {
352
- const { contentType, query, filters, fields, sort, page = 1, pageSize = 10 } = params;
369
+ const {
370
+ contentType,
371
+ query,
372
+ filters,
373
+ fields,
374
+ sort,
375
+ page = 1,
376
+ pageSize = 10,
377
+ status,
378
+ locale,
379
+ populate = "*",
380
+ includeContent = false
381
+ } = params;
353
382
  if (!strapi.contentTypes[contentType]) {
354
383
  throw new Error(`Content type "${contentType}" does not exist.`);
355
384
  }
@@ -359,16 +388,22 @@ async function searchContent(strapi, params) {
359
388
  ...filters ? { filters } : {},
360
389
  ...fields ? { fields } : {},
361
390
  ...sort ? { sort } : {},
391
+ ...status ? { status } : {},
392
+ ...locale ? { locale } : {},
362
393
  page,
363
394
  pageSize: clampedPageSize,
364
- populate: "*"
395
+ populate
365
396
  });
366
397
  const total = await strapi.documents(contentType).count({
367
398
  ...query ? { _q: query } : {},
368
- ...filters ? { filters } : {}
399
+ ...filters ? { filters } : {},
400
+ ...status ? { status } : {},
401
+ ...locale ? { locale } : {}
369
402
  });
403
+ const shouldStrip = !includeContent && !fields;
404
+ const processedResults = shouldStrip ? results.map((doc) => stripLargeFields(doc)) : results;
370
405
  return {
371
- results,
406
+ results: processedResults,
372
407
  pagination: {
373
408
  page,
374
409
  pageSize: clampedPageSize,
@@ -381,11 +416,12 @@ const writeContentSchema = z.object({
381
416
  action: z.enum(["create", "update"]).describe("Whether to create a new document or update an existing one"),
382
417
  documentId: z.string().optional().describe("Required for update — the document ID to update"),
383
418
  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.")
419
+ status: z.enum(["draft", "published"]).optional().describe("Document status. Defaults to draft."),
420
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
385
421
  });
386
422
  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
423
  async function writeContent(strapi, params) {
388
- const { contentType, action, documentId, data, status } = params;
424
+ const { contentType, action, documentId, data, status, locale } = params;
389
425
  if (!strapi.contentTypes[contentType]) {
390
426
  throw new Error(`Content type "${contentType}" does not exist.`);
391
427
  }
@@ -397,14 +433,25 @@ async function writeContent(strapi, params) {
397
433
  const document2 = await docs.create({
398
434
  data,
399
435
  ...status ? { status } : {},
436
+ ...locale ? { locale } : {},
400
437
  populate: "*"
401
438
  });
402
439
  return { action: "create", document: document2 };
403
440
  }
441
+ const existing = await docs.findOne({
442
+ documentId,
443
+ ...locale ? { locale } : {}
444
+ });
445
+ if (!existing) {
446
+ throw new Error(
447
+ `Document with ID "${documentId}" not found in "${contentType}".`
448
+ );
449
+ }
404
450
  const document = await docs.update({
405
451
  documentId,
406
452
  data,
407
453
  ...status ? { status } : {},
454
+ ...locale ? { locale } : {},
408
455
  populate: "*"
409
456
  });
410
457
  return { action: "update", document };
@@ -513,6 +560,88 @@ async function recallMemories(strapi, params, context) {
513
560
  return { success: false, memories: [], count: 0 };
514
561
  }
515
562
  }
563
+ const findOneContentSchema = z.object({
564
+ contentType: z.string().describe(
565
+ 'The content type UID to fetch from, e.g. "api::article.article"'
566
+ ),
567
+ documentId: z.string().describe("The document ID to retrieve"),
568
+ populate: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())]).optional().default("*").describe(
569
+ 'Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'
570
+ ),
571
+ fields: z.array(z.string()).optional().describe("Specific fields to return. If omitted, returns all fields."),
572
+ status: z.enum(["draft", "published"]).optional().describe("Document status filter."),
573
+ locale: z.string().optional().describe('Locale code for i18n content, e.g. "en" or "fr"')
574
+ });
575
+ 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.";
576
+ async function findOneContent(strapi, params) {
577
+ const { contentType, documentId, populate = "*", fields, status, locale } = params;
578
+ if (!strapi.contentTypes[contentType]) {
579
+ throw new Error(`Content type "${contentType}" does not exist.`);
580
+ }
581
+ const document = await strapi.documents(contentType).findOne({
582
+ documentId,
583
+ ...fields ? { fields } : {},
584
+ ...status ? { status } : {},
585
+ ...locale ? { locale } : {},
586
+ populate
587
+ });
588
+ if (!document) {
589
+ throw new Error(
590
+ `Document with ID "${documentId}" not found in "${contentType}".`
591
+ );
592
+ }
593
+ return { document };
594
+ }
595
+ const uploadMediaSchema = z.object({
596
+ url: z.string().describe("URL of the file to upload"),
597
+ name: z.string().optional().describe("Custom filename (optional, defaults to filename from URL)"),
598
+ caption: z.string().optional().describe("Caption for the media file"),
599
+ alternativeText: z.string().optional().describe("Alternative text for accessibility")
600
+ });
601
+ 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.";
602
+ async function uploadMedia(strapi, params) {
603
+ const { url, name, caption, alternativeText } = params;
604
+ const response = await fetch(url, { redirect: "follow" });
605
+ if (!response.ok) {
606
+ throw new Error(`Failed to fetch file from URL: ${response.status} ${response.statusText}`);
607
+ }
608
+ const contentType = response.headers.get("content-type") || "application/octet-stream";
609
+ const arrayBuffer = await response.arrayBuffer();
610
+ const buffer = Buffer.from(arrayBuffer);
611
+ const finalUrl = response.url || url;
612
+ const urlPath = new URL(finalUrl).pathname;
613
+ const urlFilename = urlPath.split("/").pop() || "upload";
614
+ const filename = name || urlFilename;
615
+ const tmpDir = mkdtempSync(join(tmpdir(), "strapi-upload-"));
616
+ const tmpPath = join(tmpDir, filename);
617
+ writeFileSync(tmpPath, buffer);
618
+ const fileData = {
619
+ filepath: tmpPath,
620
+ originalFilename: filename,
621
+ mimetype: contentType,
622
+ size: buffer.length
623
+ };
624
+ const fileInfo = {};
625
+ if (caption) fileInfo.caption = caption;
626
+ if (alternativeText) fileInfo.alternativeText = alternativeText;
627
+ let uploadedFile;
628
+ try {
629
+ [uploadedFile] = await strapi.plugins.upload.services.upload.upload({
630
+ data: { fileInfo },
631
+ files: fileData
632
+ });
633
+ } finally {
634
+ try {
635
+ unlinkSync(tmpPath);
636
+ } catch {
637
+ }
638
+ }
639
+ return {
640
+ file: uploadedFile,
641
+ message: `File "${uploadedFile.name}" uploaded successfully (ID: ${uploadedFile.id}).`,
642
+ usage: `To link this file to a content type field, use writeContent with: { "fieldName": ${uploadedFile.id} }`
643
+ };
644
+ }
516
645
  const listContentTypesTool = {
517
646
  name: "listContentTypes",
518
647
  description: listContentTypesDescription,
@@ -612,10 +741,28 @@ const recallMemoriesTool = {
612
741
  execute: async (args, strapi, context) => recallMemories(strapi, args, context),
613
742
  internal: true
614
743
  };
744
+ const findOneContentTool = {
745
+ name: "findOneContent",
746
+ description: findOneContentDescription,
747
+ schema: findOneContentSchema,
748
+ execute: async (args, strapi) => {
749
+ const result = await findOneContent(strapi, args);
750
+ const sanitizedDoc = await sanitizeOutput(strapi, args.contentType, result.document);
751
+ return { ...result, document: sanitizedDoc };
752
+ }
753
+ };
754
+ const uploadMediaTool = {
755
+ name: "uploadMedia",
756
+ description: uploadMediaDescription,
757
+ schema: uploadMediaSchema,
758
+ execute: async (args, strapi) => uploadMedia(strapi, args)
759
+ };
615
760
  const builtInTools = [
616
761
  listContentTypesTool,
617
762
  searchContentTool,
618
763
  writeContentTool,
764
+ findOneContentTool,
765
+ uploadMediaTool,
619
766
  triggerAnimationTool,
620
767
  sendEmailTool,
621
768
  saveMemoryTool,
@@ -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.4.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",