From 10576e502ee48f5c8c497962fe8075fc2542c8e2 Mon Sep 17 00:00:00 2001 From: hifi-phil Date: Mon, 6 Oct 2025 08:52:21 +0100 Subject: [PATCH 1/3] Release/16.0.0 beta.2 (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add .env file support for MCP server configuration - Centralize configuration management in new config.ts module - Load environment variables from .env file (default) or custom path via --env flag - Support CLI argument overrides for all configuration options (--umbraco-*) - Track configuration sources (CLI vs ENV) for transparency - Add comprehensive validation and error reporting for missing credentials - Update documentation with .env usage examples and .mcp.json configuration - Refactor tests to use centralized config system - Remove deprecated env.ts helper - Improve multi-culture document test with proper setup and cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve copy-document tool with flattened schema and clearer workflow (#30) - Flatten tool parameter schema for better LLM usability - Replace nested `id` + `data` structure with top-level parameters - Use `idToCopy` instead of `id` for clarity - Move `relateToOriginal` and `includeDescendants` to top level - Make `parentId` optional (omit for root, provide for specific parent) - Add comprehensive tool description with workflow examples - Document the empty string return value behavior - Provide clear copy-only vs copy-and-update workflow patterns - Explain search-document requirement for post-copy operations - Update e2e test to use update-document instead of search - Simplify workflow: copy → update → publish → delete - Remove unnecessary search-document and document-type lookups - Update allowed tools list to match actual workflow - Pin mcp-server-tester to version 1.4.0 for consistency - Update copy-document unit tests to match new schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Phil Whittaker Co-authored-by: Claude * Add universal media upload tools with URL and base64 support (#31) * Update create-temporary-file to accept base64 encoded data - Changed schema from ReadStream to base64 string input for MCP compatibility - Converts base64 → Buffer → temp file → ReadStream for Umbraco API - Uses os.tmpdir() for temporary file storage - Automatic cleanup of temp files in finally block - Updated tests to use base64 encoding - All tests passing (11/11) This makes the tool compatible with LLM/MCP usage where files are provided as base64 strings rather than file system streams. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add editorAlias and entityType to media value objects - Updated media-builder to include editorAlias and entityType in image values - Fixed focalPoint to use correct properties (left, top) - Changed temporaryFileId property name (was temporaryFilId) - Added documentation to create-media tool with complete example - Documented API quirk in docs/comments.md - Added experimental test-file-format tool for testing file upload formats These fields are required by the Umbraco API but not documented in the OpenAPI spec. Without them, media items are created but files are not properly uploaded/attached. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add universal media upload tools with URL and base64 support - Add create-media tool supporting filePath, URL, and base64 sources - Add create-media-multiple tool for batch uploads (max 20 files) - Implement automatic MIME type detection using mime-types library - Add comprehensive media upload helpers with proper error handling - Fix extension handling: only add to temp files, not media item names - Add test infrastructure including builders and helpers - Add integration tests with snapshot testing - Support all media types: Image, File, Video, Audio, SVG, etc. Technical improvements: - Use mime-types library for robust MIME type to extension mapping - Proper temp file cleanup after uploads - SVG media type auto-correction (Image → Vector Graphic) - Continue-on-error strategy for batch uploads - Comprehensive test coverage with proper cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove obsolete TEST_FILE_FORMAT_README.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove obsolete test-file-format.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Phil Whittaker Co-authored-by: Claude --------- Co-authored-by: Phil Whittaker Co-authored-by: Claude --- README.md | 63 ++++ docs/proto-docs/universal-media-upload.md | 125 ++++++++ docs/tool-collection-filtering.md | 14 +- jest.setup.ts | 12 +- package-lock.json | 303 ++++++++++++++++-- package.json | 14 +- src/config.ts | 279 ++++++++++++++++ .../__tests__/collection-filtering.test.ts | 72 +++-- .../tool-factory-integration.test.ts | 42 ++- .../config/collection-config-loader.ts | 12 +- src/helpers/config/env.ts | 23 -- src/helpers/mcp/create-umbraco-tool.ts | 2 +- src/index.ts | 10 +- src/orval/client/umbraco-axios.ts | 44 ++- src/test-helpers/create-snapshot-result.ts | 19 +- .../create-document.test.ts.snap | 22 ++ .../document/__tests__/copy-document.test.ts | 20 +- .../__tests__/create-document.test.ts | 103 ++++-- .../tools/document/post/copy-document.ts | 28 +- .../get-indexer-by-index-name.test.ts.snap | 2 +- .../__snapshots__/get-indexer.test.ts.snap | 2 +- .../create-media-multiple.test.ts.snap | 125 ++++++++ .../__snapshots__/create-media.test.ts.snap | 55 ++-- .../__snapshots__/get-media-urls.test.ts.snap | 2 +- .../__snapshots__/index.test.ts.snap | 3 + .../__tests__/create-media-multiple.test.ts | 212 ++++++++++++ .../media/__tests__/create-media.test.ts | 172 ++++++---- .../__tests__/get-collection-media.test.ts | 7 +- .../get-media-are-referenced.test.ts | 7 +- .../media/__tests__/helpers/media-builder.ts | 11 +- .../helpers/media-test-helper.test.ts | 15 +- .../helpers/media-upload-helpers.test.ts | 87 +++++ .../tools/media/__tests__/sort-media.test.ts | 7 +- .../media/__tests__/test-files/example.pdf | Bin 0 -> 73 bytes .../media/__tests__/test-files/example.svg | 4 + src/umb-management-api/tools/media/index.ts | 2 + .../tools/media/post/create-media-multiple.ts | 104 ++++++ .../tools/media/post/create-media.ts | 90 ++++-- .../post/helpers/media-upload-helpers.ts | 302 +++++++++++++++++ .../tools/server/__tests__/index.test.ts | 2 +- .../__snapshots__/get-tag.test.ts.snap | 2 +- .../tools/tag/__tests__/get-tag.test.ts | 4 +- .../execute-template-query.test.ts.snap | 4 +- .../create-temporary-file.test.ts.snap | 14 +- .../__tests__/create-temporary-file.test.ts | 25 +- .../post/create-temporary-file.ts | 85 +++-- src/umb-management-api/tools/tool-factory.ts | 35 +- .../get-user-current.test.ts.snap | 2 +- .../__snapshots__/get-user.test.ts.snap | 2 +- .../create-blog-post-config.json | 2 +- .../create-blog-post/create-blog-post.yaml | 17 +- 51 files changed, 2263 insertions(+), 347 deletions(-) create mode 100644 docs/proto-docs/universal-media-upload.md create mode 100644 src/config.ts delete mode 100644 src/helpers/config/env.ts create mode 100644 src/umb-management-api/tools/media/__tests__/__snapshots__/create-media-multiple.test.ts.snap create mode 100644 src/umb-management-api/tools/media/__tests__/create-media-multiple.test.ts create mode 100644 src/umb-management-api/tools/media/__tests__/helpers/media-upload-helpers.test.ts create mode 100644 src/umb-management-api/tools/media/__tests__/test-files/example.pdf create mode 100644 src/umb-management-api/tools/media/__tests__/test-files/example.svg create mode 100644 src/umb-management-api/tools/media/post/create-media-multiple.ts create mode 100644 src/umb-management-api/tools/media/post/helpers/media-upload-helpers.ts diff --git a/README.md b/README.md index 82fb0e4..8ac07a7 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,34 @@ claude mcp list This will add umbraco-mcp to the existing project in the claude.json config file. +#### Configuration via .mcp.json (Project-specific) + +For project-specific Claude Code configuration, create a `.mcp.json` file in your project root that references environment variables for sensitive data: + +```json +{ + "mcpServers": { + "umbraco-mcp": { + "command": "npx", + "args": ["@umbraco-cms/mcp-dev@beta"], + "env": { + "NODE_TLS_REJECT_UNAUTHORIZED": "0", + "UMBRACO_CLIENT_ID": "umbraco-back-office-mcp", + "UMBRACO_CLIENT_SECRET": "your-client-secret-here", + "UMBRACO_BASE_URL": "https://localhost:44391", + "UMBRACO_INCLUDE_TOOL_COLLECTIONS": "culture,document,media", + "UMBRACO_EXCLUDE_TOOLS": "delete-document,empty-recycle-bin" + } + } + } +} +``` + +Using the `.mcp.json` file allows you to: +- Configure MCP servers per project +- Share configuration with team members (commit to version control) +- Override global Claude Code MCP settings for specific projects +- Move the environment varaibles to a .env file to prevent leaking of secrets to your code repo @@ -142,6 +170,7 @@ Add the following to the config file and update the env variables. ``` + #### Authentication Configuration Keys - `UMBRACO_CLIENT_ID` @@ -156,6 +185,40 @@ Umbraco API User client secert Url of the Umbraco site, it only needs to be the scheme and domain e.g https://example.com +### Environment Configuration Options + +The Umbraco MCP server supports environment configuration via: +1. **Environment variables in MCP client config as above** (Claude Desktop, VS Code, etc.) +2. **Local `.env` file** for development (see `.env.example`) +3. **CLI arguments** when running directly + +**Configuration precedence:** CLI arguments > Environment variables > `.env` file + +#### Using a `.env` file (Development) + +For local development, you can create a `.env` file in the project root: + +```bash +# Edit with your values +UMBRACO_CLIENT_ID=your-api-user-id +UMBRACO_CLIENT_SECRET=your-api-secret +UMBRACO_BASE_URL=http://localhost:56472 +``` + +The `.env` file is gitignored to keep your secrets secure. + +#### CLI Arguments + +You can also pass configuration via CLI arguments: + +```bash +npx @umbraco-cms/mcp-dev@beta \ + --umbraco-client-id="your-id" \ + --umbraco-client-secret="your-secret" \ + --umbraco-base-url="http://localhost:56472" \ + --env="/path/to/custom/.env" +``` + ## API Coverage This MCP server provides **comprehensive coverage** of the Umbraco Management API. We have achieved **full parity** with all applicable endpoints, implementing tools for every operational endpoint suitable for AI-assisted content management. diff --git a/docs/proto-docs/universal-media-upload.md b/docs/proto-docs/universal-media-upload.md new file mode 100644 index 0000000..03c2c8d --- /dev/null +++ b/docs/proto-docs/universal-media-upload.md @@ -0,0 +1,125 @@ +# Universal Media Upload Implementation + +## Overview + +The media upload system has been redesigned to provide a unified, simplified interface for uploading any type of media file to Umbraco. This simplifies the standard two-step process (create temporary file → create media) with a single tool call that handles all media types. + +## Architecture Decision: Single Universal Tool + +Instead of creating separate tools for each media type (create-image, create-pdf, create-video, etc.), we implemented a **single universal tool** that: +- Accepts any media type via an explicit `mediaTypeName` parameter +- Trusts the LLM to specify the correct media type based on context +- Only validates SVG files (the one exception where file type matters for technical reasons) +- Supports custom media types created in Umbraco via dynamic API lookup + +### Why Trust the LLM? + +**Advantages:** +- ✅ LLMs understand semantic context better than file extensions +- ✅ Simpler implementation - no complex extension mapping tables +- ✅ Works seamlessly with custom media types +- ✅ Explicit is better than implicit +- ✅ Dynamic lookup ensures compatibility with any Umbraco installation + +**The Only Exception - SVG:** +- SVGs can be mistaken for images by LLMs +- File extension check is simple and reliable for this one case +- Auto-correct prevents technical errors (SVG uploaded as "Image" type fails in Umbraco) + +## Tools Implemented + +### 1. create-media + +**Purpose:** Upload any single media file to Umbraco + +**Schema:** +```typescript +{ + sourceType: "filePath" | "url" | "base64", + name: string, + mediaTypeName: string, // Required: explicit media type + filePath?: string, // Required if sourceType = "filePath" + fileUrl?: string, // Required if sourceType = "url" + fileAsBase64?: string, // Required if sourceType = "base64" + parentId?: string // Optional: parent folder UUID +} +``` + +**Supported Media Types:** +- **Image** - jpg, png, gif, webp (supports cropping features) +- **Article** - pdf, docx, doc +- **Audio** - mp3, wav, etc. +- **Video** - mp4, webm, etc. +- **Vector Graphic (SVG)** - svg files only +- **File** - any other file type +- **Custom** - any custom media type name created in Umbraco + +**Source Types:** +1. **filePath** - Most efficient, zero token overhead, works with any size file +2. **url** - Fetch from web URL +3. **base64** - Only for small files (<10KB) due to token usage + +**Example Usage:** +```typescript +// Upload an image from local filesystem +{ + sourceType: "filePath", + name: "Product Photo", + mediaTypeName: "Image", + filePath: "/path/to/image.jpg" +} + +// Upload a PDF from URL +{ + sourceType: "url", + name: "Annual Report", + mediaTypeName: "Article", + fileUrl: "https://example.com/report.pdf" +} + +// Upload small image as base64 +{ + sourceType: "base64", + name: "Icon", + mediaTypeName: "Image", + fileAsBase64: "iVBORw0KGgoAAAANS..." +} +``` + +### 2. create-media-multiple + +**Purpose:** Batch upload multiple media files (maximum 20 per batch) + +**Schema:** +```typescript +{ + sourceType: "filePath" | "url", // No base64 for batch uploads + files: Array<{ + name: string, + filePath?: string, + fileUrl?: string, + mediaTypeName?: string // Optional per-file override, defaults to "File" + }>, + parentId?: string // Optional: parent folder for all files +} +``` + +**Features:** +- Sequential processing to avoid API overload +- Continue-on-error strategy - individual failures don't stop the batch +- Returns detailed results per file with success/error status +- Validates 20-file batch limit + +**Example Usage:** +```typescript +{ + sourceType: "filePath", + files: [ + { name: "Photo 1", filePath: "/path/to/photo1.jpg", mediaTypeName: "Image" }, + { name: "Photo 2", filePath: "/path/to/photo2.jpg", mediaTypeName: "Image" }, + { name: "Document", filePath: "/path/to/doc.pdf", mediaTypeName: "Article" } + ], + parentId: "parent-folder-id" +} +``` + diff --git a/docs/tool-collection-filtering.md b/docs/tool-collection-filtering.md index 0d7cac8..1e19f48 100644 --- a/docs/tool-collection-filtering.md +++ b/docs/tool-collection-filtering.md @@ -89,16 +89,16 @@ Some collections have dependencies that are automatically resolved: ### Configuration Loading -Configuration is loaded from environment variables with automatic parsing: +Configuration is loaded from the server config object: ```typescript export class CollectionConfigLoader { - static loadFromEnv(): CollectionConfiguration { + static loadFromConfig(config: UmbracoServerConfig): CollectionConfiguration { return { - enabledCollections: env.UMBRACO_INCLUDE_TOOL_COLLECTIONS ?? DEFAULT_COLLECTION_CONFIG.enabledCollections, - disabledCollections: env.UMBRACO_EXCLUDE_TOOL_COLLECTIONS ?? DEFAULT_COLLECTION_CONFIG.disabledCollections, - enabledTools: env.UMBRACO_INCLUDE_TOOLS ?? DEFAULT_COLLECTION_CONFIG.enabledTools, - disabledTools: env.UMBRACO_EXCLUDE_TOOLS ?? DEFAULT_COLLECTION_CONFIG.disabledTools, + enabledCollections: config.includeToolCollections ?? DEFAULT_COLLECTION_CONFIG.enabledCollections, + disabledCollections: config.excludeToolCollections ?? DEFAULT_COLLECTION_CONFIG.disabledCollections, + enabledTools: config.includeTools ?? DEFAULT_COLLECTION_CONFIG.enabledTools, + disabledTools: config.excludeTools ?? DEFAULT_COLLECTION_CONFIG.disabledTools, }; } } @@ -108,7 +108,7 @@ export class CollectionConfigLoader { The `UmbracoToolFactory` processes configuration and loads tools: -1. Load configuration from environment variables +1. Load configuration from server config 2. Validate collection names and dependencies 3. Resolve collection dependencies automatically 4. Filter collections based on configuration diff --git a/jest.setup.ts b/jest.setup.ts index 3d5cf87..e6b58ea 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,4 +1,12 @@ import dotenv from 'dotenv'; +import { initializeUmbracoAxios } from './src/orval/client/umbraco-axios.js'; -// Load environment variables from .env.test -dotenv.config({ path: '.env' }); \ No newline at end of file +// Load environment variables from .env +dotenv.config({ path: '.env' }); + +// Initialize Umbraco Axios client with environment variables +initializeUmbracoAxios({ + clientId: process.env.UMBRACO_CLIENT_ID || '', + clientSecret: process.env.UMBRACO_CLIENT_SECRET || '', + baseUrl: process.env.UMBRACO_BASE_URL || '' +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4fb46d3..bbdeb78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,14 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.9.0", "@types/uuid": "^10.0.0", + "@types/yargs": "^17.0.33", "axios": "^1.8.4", + "dotenv": "^16.5.0", "form-data": "^4.0.4", + "mime-types": "^3.0.1", "qs": "^6.14.0", "uuid": "^11.1.0", + "yargs": "^18.0.0", "zod": "^3.24.3" }, "bin": { @@ -23,10 +27,10 @@ "devDependencies": { "@types/dotenv": "^6.1.1", "@types/jest": "^29.5.14", + "@types/mime-types": "^3.0.1", "@types/node": "^22.14.1", "@types/qs": "^6.9.18", "copyfiles": "^2.4.1", - "dotenv": "^16.5.0", "glob": "^11.0.2", "jest": "^29.7.0", "jest-extended": "^4.0.2", @@ -2754,6 +2758,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.14.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", @@ -2800,7 +2811,7 @@ "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -2808,8 +2819,7 @@ "node_modules/@types/yargs-parser": { "version": "21.0.3", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/abort-controller": { "version": "3.0.0", @@ -3525,18 +3535,96 @@ "dev": true }, "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "license": "ISC", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/co": { @@ -4036,7 +4124,6 @@ "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4361,7 +4448,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -4909,12 +4995,23 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6024,6 +6121,40 @@ } } }, + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/jest-config": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", @@ -7302,6 +7433,21 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/oas-resolver/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/oas-resolver/node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", @@ -7312,6 +7458,25 @@ "node": ">= 6" } }, + "node_modules/oas-resolver/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/oas-schema-walker": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", @@ -9059,6 +9224,21 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/swagger2openapi/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/swagger2openapi/node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", @@ -9069,6 +9249,25 @@ "node": ">= 6" } }, + "node_modules/swagger2openapi/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -10049,7 +10248,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -10075,22 +10273,20 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/yargs-parser": { @@ -10103,6 +10299,65 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", + "integrity": "sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index a4f5268..1401d20 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "inspect": "npx @modelcontextprotocol/inspector node dist/index.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "patch-publish-alpha": "npm version prerelease --preid=alpha && npm publish --tag alpha --access public", - "eval-mcp:basic": "npx mcp-server-tester evals tests/e2e/basic/basic-tests.yaml --server-config tests/e2e/basic/basic-tests-config.json", - "eval-mcp:create-data-type": "npx mcp-server-tester evals tests/e2e/create-data-type/create-data-type.yaml --server-config tests/e2e/create-data-type/create-data-type-config.json", - "eval-mcp:create-document-type": "npx mcp-server-tester evals tests/e2e/create-document-type/create-document-type.yaml --server-config tests/e2e/create-document-type/create-document-type-config.json", - "eval-mcp:create-blog-post": "npx mcp-server-tester evals tests/e2e/create-blog-post/create-blog-post.yaml --server-config tests/e2e/create-blog-post/create-blog-post-config.json", + "eval-mcp:basic": "npx mcp-server-tester@1.4.0 evals tests/e2e/basic/basic-tests.yaml --server-config tests/e2e/basic/basic-tests-config.json", + "eval-mcp:create-data-type": "npx mcp-server-tester@1.4.0 evals tests/e2e/create-data-type/create-data-type.yaml --server-config tests/e2e/create-data-type/create-data-type-config.json", + "eval-mcp:create-document-type": "npx mcp-server-tester@1.4.0 evals tests/e2e/create-document-type/create-document-type.yaml --server-config tests/e2e/create-document-type/create-document-type-config.json", + "eval-mcp:create-blog-post": "npx mcp-server-tester@1.4.0 evals tests/e2e/create-blog-post/create-blog-post.yaml --server-config tests/e2e/create-blog-post/create-blog-post-config.json", "eval-mcp:all": "npm run eval-mcp:basic && npm run eval-mcp:create-data-type && npm run eval-mcp:create-document-type && npm run eval-mcp:create-blog-post" }, "engines": { @@ -49,19 +49,23 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.9.0", "@types/uuid": "^10.0.0", + "@types/yargs": "^17.0.33", "axios": "^1.8.4", + "dotenv": "^16.5.0", "form-data": "^4.0.4", + "mime-types": "^3.0.1", "qs": "^6.14.0", "uuid": "^11.1.0", + "yargs": "^18.0.0", "zod": "^3.24.3" }, "devDependencies": { "@types/dotenv": "^6.1.1", "@types/jest": "^29.5.14", + "@types/mime-types": "^3.0.1", "@types/node": "^22.14.1", "@types/qs": "^6.9.18", "copyfiles": "^2.4.1", - "dotenv": "^16.5.0", "glob": "^11.0.2", "jest": "^29.7.0", "jest-extended": "^4.0.2", diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ba6e115 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,279 @@ +import { config as loadEnv } from "dotenv"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { resolve } from "path"; + +export interface UmbracoAuthConfig { + clientId: string; + clientSecret: string; + baseUrl: string; +} + +export interface UmbracoServerConfig { + auth: UmbracoAuthConfig; + includeToolCollections?: string[]; + excludeToolCollections?: string[]; + includeTools?: string[]; + excludeTools?: string[]; + configSources: { + clientId: "cli" | "env"; + clientSecret: "cli" | "env"; + baseUrl: "cli" | "env"; + includeToolCollections?: "cli" | "env" | "none"; + excludeToolCollections?: "cli" | "env" | "none"; + includeTools?: "cli" | "env" | "none"; + excludeTools?: "cli" | "env" | "none"; + envFile: "cli" | "default"; + }; +} + +function maskSecret(secret: string): string { + if (!secret || secret.length <= 4) return "****"; + return `****${secret.slice(-4)}`; +} + +interface CliArgs { + "umbraco-client-id"?: string; + "umbraco-client-secret"?: string; + "umbraco-base-url"?: string; + "umbraco-include-tool-collections"?: string; + "umbraco-exclude-tool-collections"?: string; + "umbraco-include-tools"?: string; + "umbraco-exclude-tools"?: string; + env?: string; +} + +export function getServerConfig(isStdioMode: boolean): UmbracoServerConfig { + // Parse command line arguments + const argv = yargs(hideBin(process.argv)) + .options({ + "umbraco-client-id": { + type: "string", + description: "Umbraco API client ID", + }, + "umbraco-client-secret": { + type: "string", + description: "Umbraco API client secret", + }, + "umbraco-base-url": { + type: "string", + description: "Umbraco base URL (e.g., https://localhost:44391)", + }, + "umbraco-include-tool-collections": { + type: "string", + description: "Comma-separated list of tool collections to include", + }, + "umbraco-exclude-tool-collections": { + type: "string", + description: "Comma-separated list of tool collections to exclude", + }, + "umbraco-include-tools": { + type: "string", + description: "Comma-separated list of tools to include", + }, + "umbraco-exclude-tools": { + type: "string", + description: "Comma-separated list of tools to exclude", + }, + env: { + type: "string", + description: "Path to custom .env file to load environment variables from", + }, + }) + .help() + .version(process.env.NPM_PACKAGE_VERSION ?? "unknown") + .parseSync() as CliArgs; + + // Load environment variables ASAP from custom path or default + let envFilePath: string; + let envFileSource: "cli" | "default"; + + if (argv["env"]) { + envFilePath = resolve(argv["env"]); + envFileSource = "cli"; + } else { + envFilePath = resolve(process.cwd(), ".env"); + envFileSource = "default"; + } + + // Override anything auto-loaded from .env if a custom file is provided. + loadEnv({ path: envFilePath, override: true }); + + const auth: UmbracoAuthConfig = { + clientId: "", + clientSecret: "", + baseUrl: "", + }; + + const config: Omit = { + includeToolCollections: undefined, + excludeToolCollections: undefined, + includeTools: undefined, + excludeTools: undefined, + configSources: { + clientId: "env", + clientSecret: "env", + baseUrl: "env", + includeToolCollections: "none", + excludeToolCollections: "none", + includeTools: "none", + excludeTools: "none", + envFile: envFileSource, + }, + }; + + // Handle UMBRACO_CLIENT_ID + if (argv["umbraco-client-id"]) { + auth.clientId = argv["umbraco-client-id"]; + config.configSources.clientId = "cli"; + } else if (process.env.UMBRACO_CLIENT_ID) { + auth.clientId = process.env.UMBRACO_CLIENT_ID; + config.configSources.clientId = "env"; + } + + // Handle UMBRACO_CLIENT_SECRET + if (argv["umbraco-client-secret"]) { + auth.clientSecret = argv["umbraco-client-secret"]; + config.configSources.clientSecret = "cli"; + } else if (process.env.UMBRACO_CLIENT_SECRET) { + auth.clientSecret = process.env.UMBRACO_CLIENT_SECRET; + config.configSources.clientSecret = "env"; + } + + // Handle UMBRACO_BASE_URL + if (argv["umbraco-base-url"]) { + auth.baseUrl = argv["umbraco-base-url"]; + config.configSources.baseUrl = "cli"; + } else if (process.env.UMBRACO_BASE_URL) { + auth.baseUrl = process.env.UMBRACO_BASE_URL; + config.configSources.baseUrl = "env"; + } + + // Handle UMBRACO_INCLUDE_TOOL_COLLECTIONS + if (argv["umbraco-include-tool-collections"]) { + config.includeToolCollections = argv["umbraco-include-tool-collections"] + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + config.configSources.includeToolCollections = "cli"; + } else if (process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS) { + config.includeToolCollections = process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + config.configSources.includeToolCollections = "env"; + } + + // Handle UMBRACO_EXCLUDE_TOOL_COLLECTIONS + if (argv["umbraco-exclude-tool-collections"]) { + config.excludeToolCollections = argv["umbraco-exclude-tool-collections"] + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + config.configSources.excludeToolCollections = "cli"; + } else if (process.env.UMBRACO_EXCLUDE_TOOL_COLLECTIONS) { + config.excludeToolCollections = process.env.UMBRACO_EXCLUDE_TOOL_COLLECTIONS + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + config.configSources.excludeToolCollections = "env"; + } + + // Handle UMBRACO_INCLUDE_TOOLS + if (argv["umbraco-include-tools"]) { + config.includeTools = argv["umbraco-include-tools"] + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + config.configSources.includeTools = "cli"; + } else if (process.env.UMBRACO_INCLUDE_TOOLS) { + config.includeTools = process.env.UMBRACO_INCLUDE_TOOLS + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + config.configSources.includeTools = "env"; + } + + // Handle UMBRACO_EXCLUDE_TOOLS + if (argv["umbraco-exclude-tools"]) { + config.excludeTools = argv["umbraco-exclude-tools"] + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + config.configSources.excludeTools = "cli"; + } else if (process.env.UMBRACO_EXCLUDE_TOOLS) { + config.excludeTools = process.env.UMBRACO_EXCLUDE_TOOLS + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + config.configSources.excludeTools = "env"; + } + + // Validate configuration + if (!auth.clientId) { + console.error( + "UMBRACO_CLIENT_ID is required (via CLI argument --umbraco-client-id or .env file)", + ); + process.exit(1); + } + + if (!auth.clientSecret) { + console.error( + "UMBRACO_CLIENT_SECRET is required (via CLI argument --umbraco-client-secret or .env file)", + ); + process.exit(1); + } + + if (!auth.baseUrl) { + console.error( + "UMBRACO_BASE_URL is required (via CLI argument --umbraco-base-url or .env file)", + ); + process.exit(1); + } + + // Log configuration sources + if (!isStdioMode) { + console.log("\nUmbraco MCP Configuration:"); + console.log(`- ENV_FILE: ${envFilePath} (source: ${config.configSources.envFile})`); + console.log( + `- UMBRACO_CLIENT_ID: ${auth.clientId} (source: ${config.configSources.clientId})`, + ); + console.log( + `- UMBRACO_CLIENT_SECRET: ${maskSecret(auth.clientSecret)} (source: ${config.configSources.clientSecret})`, + ); + console.log( + `- UMBRACO_BASE_URL: ${auth.baseUrl} (source: ${config.configSources.baseUrl})`, + ); + + if (config.includeToolCollections) { + console.log( + `- UMBRACO_INCLUDE_TOOL_COLLECTIONS: ${config.includeToolCollections.join(", ")} (source: ${config.configSources.includeToolCollections})`, + ); + } + + if (config.excludeToolCollections) { + console.log( + `- UMBRACO_EXCLUDE_TOOL_COLLECTIONS: ${config.excludeToolCollections.join(", ")} (source: ${config.configSources.excludeToolCollections})`, + ); + } + + if (config.includeTools) { + console.log( + `- UMBRACO_INCLUDE_TOOLS: ${config.includeTools.join(", ")} (source: ${config.configSources.includeTools})`, + ); + } + + if (config.excludeTools) { + console.log( + `- UMBRACO_EXCLUDE_TOOLS: ${config.excludeTools.join(", ")} (source: ${config.configSources.excludeTools})`, + ); + } + + console.log(); // Empty line for better readability + } + + return { + ...config, + auth, + }; +} diff --git a/src/helpers/config/__tests__/collection-filtering.test.ts b/src/helpers/config/__tests__/collection-filtering.test.ts index 9667de8..73b5f61 100644 --- a/src/helpers/config/__tests__/collection-filtering.test.ts +++ b/src/helpers/config/__tests__/collection-filtering.test.ts @@ -436,34 +436,64 @@ describe('Collection Filtering', () => { }); }); - describe('Environment Variable Parsing', () => { - it('should parse comma-separated collection names', async () => { - process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = 'culture, data-type , document'; - - // Force re-import of env module after setting environment variables - jest.resetModules(); - const { CollectionConfigLoader } = await import("@/helpers/config/collection-config-loader.js"); - - const config = CollectionConfigLoader.loadFromEnv(); - + describe('Configuration Loading', () => { + it('should parse comma-separated collection names', () => { + const serverConfig = { + auth: { clientId: 'test', clientSecret: 'test', baseUrl: 'http://test' }, + includeToolCollections: ['culture', 'data-type', 'document'], + excludeToolCollections: [], + includeTools: [], + excludeTools: [], + configSources: { + clientId: 'env' as const, + clientSecret: 'env' as const, + baseUrl: 'env' as const, + envFile: 'default' as const + } + }; + + const config = CollectionConfigLoader.loadFromConfig(serverConfig); + expect(config.enabledCollections).toEqual(['culture', 'data-type', 'document']); }); - it('should handle empty values', async () => { - process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = ''; - - // Force re-import of env module after setting environment variables - jest.resetModules(); - const { CollectionConfigLoader } = await import("@/helpers/config/collection-config-loader.js"); - - const config = CollectionConfigLoader.loadFromEnv(); - + it('should handle empty values', () => { + const serverConfig = { + auth: { clientId: 'test', clientSecret: 'test', baseUrl: 'http://test' }, + includeToolCollections: [], + excludeToolCollections: [], + includeTools: [], + excludeTools: [], + configSources: { + clientId: 'env' as const, + clientSecret: 'env' as const, + baseUrl: 'env' as const, + envFile: 'default' as const + } + }; + + const config = CollectionConfigLoader.loadFromConfig(serverConfig); + expect(config.enabledCollections).toEqual([]); }); it('should load configuration structure correctly', () => { - const config = CollectionConfigLoader.loadFromEnv(); - + const serverConfig = { + auth: { clientId: 'test', clientSecret: 'test', baseUrl: 'http://test' }, + includeToolCollections: undefined, + excludeToolCollections: undefined, + includeTools: undefined, + excludeTools: undefined, + configSources: { + clientId: 'env' as const, + clientSecret: 'env' as const, + baseUrl: 'env' as const, + envFile: 'default' as const + } + }; + + const config = CollectionConfigLoader.loadFromConfig(serverConfig); + expect(config).toHaveProperty('enabledCollections'); expect(config).toHaveProperty('disabledCollections'); expect(config).toHaveProperty('enabledTools'); diff --git a/src/helpers/config/__tests__/tool-factory-integration.test.ts b/src/helpers/config/__tests__/tool-factory-integration.test.ts index 17cd021..b3be7c6 100644 --- a/src/helpers/config/__tests__/tool-factory-integration.test.ts +++ b/src/helpers/config/__tests__/tool-factory-integration.test.ts @@ -3,10 +3,30 @@ import { jest } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { UmbracoToolFactory } from "../../../umb-management-api/tools/tool-factory.js"; import { CurrentUserResponseModel } from "@/umb-management-api/schemas/index.js"; +import type { UmbracoServerConfig } from "../../../config.js"; // Mock environment variables for testing const originalEnv = process.env; +// Helper to create mock config from process.env +const getMockConfig = (): UmbracoServerConfig => ({ + auth: { + clientId: "test-client", + clientSecret: "test-secret", + baseUrl: "http://localhost:56472" + }, + includeToolCollections: process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS?.split(',').map(c => c.trim()).filter(Boolean), + excludeToolCollections: process.env.UMBRACO_EXCLUDE_TOOL_COLLECTIONS?.split(',').map(c => c.trim()).filter(Boolean), + includeTools: process.env.UMBRACO_INCLUDE_TOOLS?.split(',').map(t => t.trim()).filter(Boolean), + excludeTools: process.env.UMBRACO_EXCLUDE_TOOLS?.split(',').map(t => t.trim()).filter(Boolean), + configSources: { + clientId: "env", + clientSecret: "env", + baseUrl: "env", + envFile: "default" + } +}); + const mockUser: CurrentUserResponseModel = { id: "test-user", userName: "testuser", @@ -60,7 +80,7 @@ describe('UmbracoToolFactory Integration', () => { }); it('should load tools from all collections by default', () => { - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Verify server.tool was called (should include tools from all collections) expect(mockServer.tool).toHaveBeenCalled(); @@ -70,7 +90,7 @@ describe('UmbracoToolFactory Integration', () => { it('should only load tools from enabled collections', () => { process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = 'culture,data-type'; - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Verify tools were loaded expect(mockServer.tool).toHaveBeenCalled(); @@ -86,14 +106,14 @@ describe('UmbracoToolFactory Integration', () => { it('should handle empty enabled collections list', () => { process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = ''; - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should still load tools (empty list means load all) expect(mockServer.tool).toHaveBeenCalled(); }); it('should load tools from all converted collections', () => { - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should load tools from all converted collections const toolCalls = mockServer.tool.mock.calls.map(call => call[0]); @@ -112,7 +132,7 @@ describe('UmbracoToolFactory Integration', () => { jest.resetModules(); const { UmbracoToolFactory } = await import("../../../umb-management-api/tools/tool-factory.js"); - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); const toolCalls = mockServer.tool.mock.calls.map(call => call[0]); // Should not include the culture tool (from the excluded collection) @@ -132,7 +152,7 @@ describe('UmbracoToolFactory Integration', () => { jest.resetModules(); const { UmbracoToolFactory } = await import("../../../umb-management-api/tools/tool-factory.js"); - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should warn about invalid collection name expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('invalid-collection-name')); @@ -151,7 +171,7 @@ describe('UmbracoToolFactory Integration', () => { allowedSections: [] // No access to any sections }; - UmbracoToolFactory(mockServer, restrictedUser); + UmbracoToolFactory(mockServer, restrictedUser, getMockConfig()); // Verify that tools were registered (the tool enablement logic handles permissions) // This test verifies the factory still runs but individual tools check permissions @@ -162,14 +182,14 @@ describe('UmbracoToolFactory Integration', () => { // This test verifies that if collections had dependencies, they would be included process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = 'culture,data-type'; - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should successfully load without errors expect(mockServer.tool).toHaveBeenCalled(); }); it('should maintain tool registration order', () => { - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Verify tools were registered in some order expect(mockServer.tool.mock.calls.length).toBeGreaterThan(0); @@ -189,7 +209,7 @@ describe('UmbracoToolFactory Integration', () => { it('should handle whitespace in collection names', () => { process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = ' culture , data-type , '; - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should parse correctly despite whitespace expect(mockServer.tool).toHaveBeenCalled(); @@ -198,7 +218,7 @@ describe('UmbracoToolFactory Integration', () => { it('should handle empty collection names in list', () => { process.env.UMBRACO_INCLUDE_TOOL_COLLECTIONS = 'culture,,data-type'; - UmbracoToolFactory(mockServer, mockUser); + UmbracoToolFactory(mockServer, mockUser, getMockConfig()); // Should handle empty values gracefully expect(mockServer.tool).toHaveBeenCalled(); diff --git a/src/helpers/config/collection-config-loader.ts b/src/helpers/config/collection-config-loader.ts index 648c22a..4eaca45 100644 --- a/src/helpers/config/collection-config-loader.ts +++ b/src/helpers/config/collection-config-loader.ts @@ -1,13 +1,13 @@ -import env from "./env.js"; import { CollectionConfiguration, DEFAULT_COLLECTION_CONFIG } from "../../types/collection-configuration.js"; +import type { UmbracoServerConfig } from "../../config.js"; export class CollectionConfigLoader { - static loadFromEnv(): CollectionConfiguration { + static loadFromConfig(config: UmbracoServerConfig): CollectionConfiguration { return { - enabledCollections: env.UMBRACO_INCLUDE_TOOL_COLLECTIONS ?? DEFAULT_COLLECTION_CONFIG.enabledCollections, - disabledCollections: env.UMBRACO_EXCLUDE_TOOL_COLLECTIONS ?? DEFAULT_COLLECTION_CONFIG.disabledCollections, - enabledTools: env.UMBRACO_INCLUDE_TOOLS ?? DEFAULT_COLLECTION_CONFIG.enabledTools, - disabledTools: env.UMBRACO_EXCLUDE_TOOLS ?? DEFAULT_COLLECTION_CONFIG.disabledTools, + enabledCollections: config.includeToolCollections ?? DEFAULT_COLLECTION_CONFIG.enabledCollections, + disabledCollections: config.excludeToolCollections ?? DEFAULT_COLLECTION_CONFIG.disabledCollections, + enabledTools: config.includeTools ?? DEFAULT_COLLECTION_CONFIG.enabledTools, + disabledTools: config.excludeTools ?? DEFAULT_COLLECTION_CONFIG.disabledTools, }; } } \ No newline at end of file diff --git a/src/helpers/config/env.ts b/src/helpers/config/env.ts deleted file mode 100644 index d063fa6..0000000 --- a/src/helpers/config/env.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod'; - -const envSchema = z.object({ - UMBRACO_CLIENT_ID: z.string(), - UMBRACO_CLIENT_SECRET: z.string(), - UMBRACO_BASE_URL: z.string().url(), - UMBRACO_EXCLUDE_TOOLS: z.string().optional() - .transform((val) => val?.split(',').map(tool => tool.trim())) - .pipe(z.array(z.string()).optional()), - UMBRACO_INCLUDE_TOOLS: z.string().optional() - .transform((val) => val?.split(',').map(tool => tool.trim()).filter(Boolean)) - .pipe(z.array(z.string()).optional()), - - // Collection-level filtering - UMBRACO_INCLUDE_TOOL_COLLECTIONS: z.string().optional() - .transform((val) => val?.split(',').map(collection => collection.trim()).filter(Boolean)) - .pipe(z.array(z.string()).optional()), - UMBRACO_EXCLUDE_TOOL_COLLECTIONS: z.string().optional() - .transform((val) => val?.split(',').map(collection => collection.trim()).filter(Boolean)) - .pipe(z.array(z.string()).optional()), -}); - -export default envSchema.parse(process.env); diff --git a/src/helpers/mcp/create-umbraco-tool.ts b/src/helpers/mcp/create-umbraco-tool.ts index ba1c1ae..7a91743 100644 --- a/src/helpers/mcp/create-umbraco-tool.ts +++ b/src/helpers/mcp/create-umbraco-tool.ts @@ -6,7 +6,7 @@ import { CurrentUserResponseModel } from "@/umb-management-api/schemas/index.js" export const CreateUmbracoTool = ( name: string, - description: string, + description: string, schema: Args, handler: ToolCallback, enabled?: (user: CurrentUserResponseModel) => boolean diff --git a/src/index.ts b/src/index.ts index ccdf338..fc1aa96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,8 +7,16 @@ import { UmbracoToolFactory } from "./umb-management-api/tools/tool-factory.js"; import { ResourceFactory } from "./umb-management-api/resources/resource-factory.js"; import { UmbracoManagementClient } from "@umb-management-client"; +import { getServerConfig } from "./config.js"; +import { initializeUmbracoAxios } from "./orval/client/umbraco-axios.js"; const main = async () => { + // Load and validate configuration + const config = getServerConfig(true); // true = stdio mode (no logging) + + // Initialize Axios client with configuration + initializeUmbracoAxios(config.auth); + // Create an MCP server const server = UmbracoMcpServer.GetServer(); const client = UmbracoManagementClient.getClient(); @@ -16,7 +24,7 @@ const main = async () => { const user = await client.getUserCurrent(); ResourceFactory(server); - UmbracoToolFactory(server, user); + UmbracoToolFactory(server, user, config); // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); diff --git a/src/orval/client/umbraco-axios.ts b/src/orval/client/umbraco-axios.ts index 67acbf4..cbf8676 100644 --- a/src/orval/client/umbraco-axios.ts +++ b/src/orval/client/umbraco-axios.ts @@ -1,36 +1,46 @@ import qs from "qs"; import Axios from "axios"; -import env from "@/helpers/config/env.js"; +import type { UmbracoAuthConfig } from "../../config.js"; -const client_id = env.UMBRACO_CLIENT_ID; -const client_secret = env.UMBRACO_CLIENT_SECRET; -const grant_type = "client_credentials"; +// Module-level variables for configuration +let authConfig: UmbracoAuthConfig | null = null; -const baseURL = env.UMBRACO_BASE_URL; +// Initialize the client with configuration +export function initializeUmbracoAxios(config: UmbracoAuthConfig): void { + authConfig = config; -if (!baseURL) - throw new Error("Missing required environment variable: UMBRACO_BASE_URL"); -if (!client_id) - throw new Error("Missing required environment variable: UMBRACO_CLIENT_ID"); -if (!client_secret && client_id !== "umbraco-swagger") - throw new Error( - "Missing required environment variable: UMBRACO_CLIENT_SECRET" - ); + const { clientId, clientSecret, baseUrl } = config; + + if (!baseUrl) + throw new Error("Missing required configuration: baseUrl"); + if (!clientId) + throw new Error("Missing required configuration: clientId"); + if (!clientSecret && clientId !== "umbraco-swagger") + throw new Error("Missing required configuration: clientSecret"); + + // Update base URL for existing instance + UmbracoAxios.defaults.baseURL = baseUrl; +} +const grant_type = "client_credentials"; const tokenPath = "/umbraco/management/api/v1/security/back-office/token"; -export const UmbracoAxios = Axios.create({ baseURL }); // Set base URL from config +export const UmbracoAxios = Axios.create(); let accessToken: string | null = null; let tokenExpiry: number | null = null; // Function to fetch a new access token const fetchAccessToken = async (): Promise => { + if (!authConfig) { + throw new Error("UmbracoAxios not initialized. Call initializeUmbracoAxios first."); + } + const response = await Axios.post( - `${baseURL}${tokenPath}`, + `${authConfig.baseUrl}${tokenPath}`, { - client_id, - client_secret: client_secret ?? "", + client_id: authConfig.clientId, + client_secret: authConfig.clientSecret ?? "", grant_type, }, { diff --git a/src/test-helpers/create-snapshot-result.ts b/src/test-helpers/create-snapshot-result.ts index 06ab12a..106622f 100644 --- a/src/test-helpers/create-snapshot-result.ts +++ b/src/test-helpers/create-snapshot-result.ts @@ -89,6 +89,13 @@ export function createSnapshotResult(result: any, idToReplace?: string) { url.replace(/\/[a-f0-9]{40}\.jpg/, "/NORMALIZED_AVATAR.jpg") ); } + // Normalize media URLs that contain dynamic path segments + if (item.urlInfos && Array.isArray(item.urlInfos)) { + item.urlInfos = item.urlInfos.map((urlInfo: any) => ({ + ...urlInfo, + url: urlInfo.url ? urlInfo.url.replace(/\/media\/[a-z0-9]+\//i, "/media/NORMALIZED_PATH/") : urlInfo.url + })); + } return item; } @@ -138,6 +145,13 @@ export function createSnapshotResult(result: any, idToReplace?: string) { url.replace(/\/[a-f0-9]{40}\.jpg/, "/NORMALIZED_AVATAR.jpg") ); } + // Normalize media URLs that contain dynamic path segments + if (parsed.urlInfos && Array.isArray(parsed.urlInfos)) { + parsed.urlInfos = parsed.urlInfos.map((urlInfo: any) => ({ + ...urlInfo, + url: urlInfo.url ? urlInfo.url.replace(/\/media\/[a-z0-9]+\//i, "/media/NORMALIZED_PATH/") : urlInfo.url + })); + } // Normalize document version references if (parsed.document) { parsed.document = { ...parsed.document, id: BLANK_UUID }; @@ -168,10 +182,11 @@ export function createSnapshotResult(result: any, idToReplace?: string) { // For list responses const parsed = JSON.parse(item.text); if (Array.isArray(parsed)) { - // Handle ancestors API response + // Handle ancestors API response and other array responses + const normalized = parsed.map(normalizeItem); return { ...item, - text: JSON.stringify(parsed.map(normalizeItem)), + text: JSON.stringify(normalized), }; } // Handle other list responses diff --git a/src/umb-management-api/tools/document/__tests__/__snapshots__/create-document.test.ts.snap b/src/umb-management-api/tools/document/__tests__/__snapshots__/create-document.test.ts.snap index 116994d..afaa0da 100644 --- a/src/umb-management-api/tools/document/__tests__/__snapshots__/create-document.test.ts.snap +++ b/src/umb-management-api/tools/document/__tests__/__snapshots__/create-document.test.ts.snap @@ -71,3 +71,25 @@ exports[`create-document should create a document with additional properties 2`] ], } `; + +exports[`create-document should create a document with empty cultures array (null culture) 1`] = ` +{ + "content": [ + { + "text": """", + "type": "text", + }, + ], +} +`; + +exports[`create-document should create a document with specific cultures 1`] = ` +{ + "content": [ + { + "text": """", + "type": "text", + }, + ], +} +`; diff --git a/src/umb-management-api/tools/document/__tests__/copy-document.test.ts b/src/umb-management-api/tools/document/__tests__/copy-document.test.ts index b645d3e..ccb34e0 100644 --- a/src/umb-management-api/tools/document/__tests__/copy-document.test.ts +++ b/src/umb-management-api/tools/document/__tests__/copy-document.test.ts @@ -34,15 +34,12 @@ describe("copy-document", () => { .withRootDocumentType() .create(); - // Copy the document to root (no target) + // Copy the document to root (no parentId means root) const result = await CopyDocumentTool().handler( { - id: docBuilder.getId(), - data: { - target: null, - relateToOriginal: false, - includeDescendants: false, - }, + idToCopy: docBuilder.getId(), + relateToOriginal: false, + includeDescendants: false, }, { signal: new AbortController().signal } ); @@ -73,12 +70,9 @@ describe("copy-document", () => { it("should handle non-existent document", async () => { const result = await CopyDocumentTool().handler( { - id: BLANK_UUID, - data: { - target: null, - relateToOriginal: false, - includeDescendants: false, - }, + idToCopy: BLANK_UUID, + relateToOriginal: false, + includeDescendants: false, }, { signal: new AbortController().signal } ); diff --git a/src/umb-management-api/tools/document/__tests__/create-document.test.ts b/src/umb-management-api/tools/document/__tests__/create-document.test.ts index 06d69c7..380f5f0 100644 --- a/src/umb-management-api/tools/document/__tests__/create-document.test.ts +++ b/src/umb-management-api/tools/document/__tests__/create-document.test.ts @@ -2,6 +2,7 @@ import CreateDocumentTool from "../post/create-document.js"; import { DocumentTestHelper } from "./helpers/document-test-helper.js"; import { jest } from "@jest/globals"; import { ROOT_DOCUMENT_TYPE_ID } from "../../../../constants/constants.js"; +import { UmbracoManagementClient } from "@umb-management-client"; const TEST_DOCUMENT_NAME = "_Test Document Created"; @@ -76,26 +77,88 @@ describe("create-document", () => { }); it("should create a document with specific cultures", async () => { - // Create document with specific cultures - const docModel = { - documentTypeId: ROOT_DOCUMENT_TYPE_ID, - name: TEST_DOCUMENT_NAME, - cultures: ["en-US", "da-DK"], - values: [], - }; - - const result = await CreateDocumentTool().handler(docModel, { - signal: new AbortController().signal, - }); - - expect(result).toMatchSnapshot(); - - const item = await DocumentTestHelper.findDocument(TEST_DOCUMENT_NAME); - expect(item).toBeDefined(); - // Should have variants for both cultures - expect(item!.variants).toHaveLength(2); - const cultures = item!.variants.map(v => v.culture).sort(); - expect(cultures).toEqual(["da-DK", "en-US"]); + const client = UmbracoManagementClient.getClient(); + + // Track what we need to restore after the test + let createdLanguage = false; + let originalVariesByCulture = false; + + try { + // Verify da-DK language exists, if not create it + const languagesResponse = await client.getLanguage({}); + const hasDaDK = languagesResponse.items.some(lang => lang.isoCode === "da-DK"); + + if (!hasDaDK) { + console.log("Creating da-DK language as it doesn't exist"); + await client.postLanguage({ + name: "Danish (Denmark)", + isoCode: "da-DK", + fallbackIsoCode: null, + isDefault: false, + isMandatory: false + }); + createdLanguage = true; + } + + // Verify root document type allows multiple cultures + const rootDocType = await client.getDocumentTypeById(ROOT_DOCUMENT_TYPE_ID); + originalVariesByCulture = rootDocType.variesByCulture ?? false; + + if (rootDocType.allowedAsRoot && rootDocType.variesByCulture === false) { + console.log("Updating root document type to allow culture variation"); + await client.putDocumentTypeById(ROOT_DOCUMENT_TYPE_ID, { + ...rootDocType, + variesByCulture: true + }); + } + + // Create document with specific cultures + const docModel = { + documentTypeId: ROOT_DOCUMENT_TYPE_ID, + name: TEST_DOCUMENT_NAME, + cultures: ["en-US", "da-DK"], + values: [], + }; + + const result = await CreateDocumentTool().handler(docModel, { + signal: new AbortController().signal, + }); + + expect(result).toMatchSnapshot(); + + const item = await DocumentTestHelper.findDocument(TEST_DOCUMENT_NAME); + expect(item).toBeDefined(); + // Should have variants for both cultures + expect(item!.variants).toHaveLength(2); + const itemCultures = item!.variants.map(v => v.culture).sort(); + expect(itemCultures).toEqual(["da-DK", "en-US"]); + } finally { + // Restore original configuration to avoid affecting other tests + + // Restore document type configuration if we changed it + if (originalVariesByCulture === false) { + try { + const rootDocType = await client.getDocumentTypeById(ROOT_DOCUMENT_TYPE_ID); + await client.putDocumentTypeById(ROOT_DOCUMENT_TYPE_ID, { + ...rootDocType, + variesByCulture: false + }); + console.log("Restored root document type variesByCulture to false"); + } catch (error) { + console.log("Error restoring document type configuration:", error); + } + } + + // Delete the language if we created it + if (createdLanguage) { + try { + await client.deleteLanguageByIsoCode("da-DK"); + console.log("Deleted da-DK language created for test"); + } catch (error) { + console.log("Error deleting da-DK language:", error); + } + } + } }); it("should create a document with empty cultures array (null culture)", async () => { diff --git a/src/umb-management-api/tools/document/post/copy-document.ts b/src/umb-management-api/tools/document/post/copy-document.ts index b06f86f..ea1c10d 100644 --- a/src/umb-management-api/tools/document/post/copy-document.ts +++ b/src/umb-management-api/tools/document/post/copy-document.ts @@ -1,10 +1,16 @@ import { UmbracoManagementClient } from "@umb-management-client"; -import { postDocumentByIdCopyBody } from "@/umb-management-api/umbracoManagementAPI.zod.js"; import { CreateUmbracoTool } from "@/helpers/mcp/create-umbraco-tool.js"; import { z } from "zod"; -import { CurrentUserResponseModel } from "@/umb-management-api/schemas/index.js"; +import { CopyDocumentRequestModel, CurrentUserResponseModel } from "@/umb-management-api/schemas/index.js"; import { UmbracoDocumentPermissions } from "../constants.js"; +const copyDocumentSchema = z.object({ + parentId: z.string().uuid("Must be a valid document UUID of the parent node").optional(), + idToCopy: z.string().uuid("Must be a valid document UUID that belongs to the parent document's children"), + relateToOriginal: z.boolean().describe("Relate the copy to the original document. This is usually set to false unless specified."), + includeDescendants: z.boolean().describe("If true, all descendant documents (children, grandchildren, etc.) will also be copied. This is usually set to false unless specified."), +}); + const CopyDocumentTool = CreateUmbracoTool( "copy-document", `Copy a document to a new location. This is also the recommended way to create new documents. @@ -25,13 +31,19 @@ const CopyDocumentTool = CreateUmbracoTool( Example workflows: 1. Copy only: copy-document (creates draft copy) 2. Copy and update: copy-document → search-document → update-document → publish-document`, - { - id: z.string().uuid(), - data: z.object(postDocumentByIdCopyBody.shape), - }, - async (model: { id: string; data: any }) => { + copyDocumentSchema.shape, + async (model) => { const client = UmbracoManagementClient.getClient(); - const response = await client.postDocumentByIdCopy(model.id, model.data); + + const payload: CopyDocumentRequestModel = { + target: model.parentId ? { + id: model.parentId, + } : undefined, + relateToOriginal: model.relateToOriginal, + includeDescendants: model.includeDescendants, + }; + + const response = await client.postDocumentByIdCopy(model.idToCopy, payload); return { content: [ { diff --git a/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer-by-index-name.test.ts.snap b/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer-by-index-name.test.ts.snap index 6b883d4..1eae418 100644 --- a/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer-by-index-name.test.ts.snap +++ b/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer-by-index-name.test.ts.snap @@ -4,7 +4,7 @@ exports[`get-indexer-by-index-name should get index by name 1`] = ` { "content": [ { - "text": "{"name":"ExternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"ExternalSearcher","documentCount":118,"fieldCount":50,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":true,"SupportProtectedContent":false}}", + "text": "{"name":"ExternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"ExternalSearcher","documentCount":72,"fieldCount":49,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":true,"SupportProtectedContent":false}}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer.test.ts.snap b/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer.test.ts.snap index 9eec768..299fc38 100644 --- a/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer.test.ts.snap +++ b/src/umb-management-api/tools/indexer/__tests__/__snapshots__/get-indexer.test.ts.snap @@ -4,7 +4,7 @@ exports[`get-indexer should list all indexes with default parameters 1`] = ` { "content": [ { - "text": "{"total":6,"items":[{"name":"DeliveryApiContentIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"DeliveryApiContentSearcher","documentCount":570,"fieldCount":20,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/deliveryapicontentindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/deliveryapicontentindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":false,"PublishedValuesOnly":false}},{"name":"ExternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"ExternalSearcher","documentCount":118,"fieldCount":50,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":true,"SupportProtectedContent":false}},{"name":"InternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"InternalSearcher","documentCount":142,"fieldCount":51,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"CultureInvariantWhitespaceAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/internalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/internalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false,"SupportProtectedContent":true}},{"name":"MembersIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"MembersSearcher","documentCount":2,"fieldCount":9,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"CultureInvariantWhitespaceAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/membersindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/membersindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false,"IncludeFields":["id","nodeName","updateDate","loginName","email","__Key"]}},{"name":"PDFIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"PDFSearcher","documentCount":0,"fieldCount":0,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/pdfindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/pdfindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory"}},{"name":"WorkflowIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"WorkflowSearcher","documentCount":0,"fieldCount":0,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/workflowindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/workflowindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false}}]}", + "text": "{"total":6,"items":[{"name":"DeliveryApiContentIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"DeliveryApiContentSearcher","documentCount":39,"fieldCount":20,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/deliveryapicontentindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/deliveryapicontentindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":false,"PublishedValuesOnly":false}},{"name":"ExternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"ExternalSearcher","documentCount":72,"fieldCount":49,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/externalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":true,"SupportProtectedContent":false}},{"name":"InternalIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"InternalSearcher","documentCount":72,"fieldCount":49,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"CultureInvariantWhitespaceAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/internalindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/internalindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false,"SupportProtectedContent":true}},{"name":"MembersIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"MembersSearcher","documentCount":1,"fieldCount":9,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"CultureInvariantWhitespaceAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/membersindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/membersindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false,"IncludeFields":["id","nodeName","updateDate","loginName","email","__Key"]}},{"name":"PDFIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"PDFSearcher","documentCount":0,"fieldCount":0,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/pdfindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/pdfindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory"}},{"name":"WorkflowIndex","healthStatus":{"status":"Healthy","message":null},"canRebuild":true,"searcherName":"WorkflowSearcher","documentCount":0,"fieldCount":0,"providerProperties":{"CommitCount":0,"DefaultAnalyzer":"StandardAnalyzer","LuceneDirectory":"NRTCachingDirectory","LuceneIndexFolder":"/niofsdirectory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/workflowindex lockfactory=noprefixsimplefslockfactory@/users/philw/projects/umbraco-mcp/infrastructure/test-umbraco/mcptestsite/umbraco/data/temp/examineindexes/workflowindex","DirectoryFactory":"Umbraco.Cms.Infrastructure.Examine.ConfigurationEnabledDirectoryFactory","EnableDefaultEventHandler":true,"PublishedValuesOnly":false}}]}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media-multiple.test.ts.snap b/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media-multiple.test.ts.snap new file mode 100644 index 0000000..a5813c8 --- /dev/null +++ b/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media-multiple.test.ts.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`create-media-multiple should continue processing on individual file errors 1`] = ` +{ + "content": [ + { + "text": "{ + "summary": "Processed 2 files: 1 succeeded, 1 failed", + "results": [ + { + "success": true, + "name": "_Test Batch Image 1" + }, + { + "success": false, + "name": "_Test Batch Image 2", + "error": "File not found: /non/existent/file.jpg" + } + ] +}", + "type": "text", + }, + ], +} +`; + +exports[`create-media-multiple should create multiple files with mixed media types 1`] = ` +{ + "content": [ + { + "text": "{ + "summary": "Processed 2 files: 2 succeeded, 0 failed", + "results": [ + { + "success": true, + "name": "_Test Mixed Image" + }, + { + "success": true, + "name": "_Test Mixed File" + } + ] +}", + "type": "text", + }, + ], +} +`; + +exports[`create-media-multiple should create multiple images in batch 1`] = ` +{ + "content": [ + { + "text": "{ + "summary": "Processed 2 files: 2 succeeded, 0 failed", + "results": [ + { + "success": true, + "name": "_Test Batch Image 1" + }, + { + "success": true, + "name": "_Test Batch Image 2" + } + ] +}", + "type": "text", + }, + ], +} +`; + +exports[`create-media-multiple should create multiple media from URLs 1`] = ` +{ + "content": [ + { + "text": "{ + "summary": "Processed 2 files: 2 succeeded, 0 failed", + "results": [ + { + "success": true, + "name": "_Test URL Batch Image 1" + }, + { + "success": true, + "name": "_Test URL Batch Image 2" + } + ] +}", + "type": "text", + }, + ], +} +`; + +exports[`create-media-multiple should handle batch size limit validation 1`] = ` +{ + "content": [ + { + "text": "Batch upload limited to 20 files per call. You provided 21 files. Please split into multiple batches.", + "type": "text", + }, + ], + "isError": true, +} +`; + +exports[`create-media-multiple should use default File media type when not specified 1`] = ` +{ + "content": [ + { + "text": "{ + "summary": "Processed 1 files: 1 succeeded, 0 failed", + "results": [ + { + "success": true, + "name": "_Test Batch File 1" + } + ] +}", + "type": "text", + }, + ], +} +`; diff --git a/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media.test.ts.snap b/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media.test.ts.snap index d72c355..f9e9199 100644 --- a/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media.test.ts.snap +++ b/src/umb-management-api/tools/media/__tests__/__snapshots__/create-media.test.ts.snap @@ -1,56 +1,69 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`create-media should create a media item 1`] = ` +exports[`create-media should create file media 1`] = ` { "content": [ { - "text": """", + "text": "Media "_Test File Upload" created successfully", "type": "text", }, ], } `; -exports[`create-media should create a media item 2`] = ` +exports[`create-media should create image media 1`] = ` { - "createDate": "", - "hasChildren": false, - "id": "00000000-0000-0000-0000-000000000000", - "isTrashed": false, - "mediaType": { - "collection": null, - "icon": "icon-picture", - "id": "cc07b313-0843-4aa8-bbda-871c8da728c8", - }, - "noAccess": false, - "parent": null, - "variants": [ + "content": [ + { + "text": "Media "_Test Image Upload" created successfully", + "type": "text", + }, + ], +} +`; + +exports[`create-media should create media from URL 1`] = ` +{ + "content": [ { - "culture": null, - "name": "_Test Media Created", + "text": "Media "_Test URL Image Upload" created successfully", + "type": "text", + }, + ], +} +`; + +exports[`create-media should create media from base64 1`] = ` +{ + "content": [ + { + "text": "Media "_Test Base64 Image Upload.png" created successfully", + "type": "text", }, ], } `; -exports[`create-media should create a media item with parent 1`] = ` +exports[`create-media should handle invalid media type 1`] = ` { "content": [ { - "text": """", + "text": "Error creating media: Media type 'NonExistentMediaType' not found. Available types: None found", "type": "text", }, ], + "isError": true, } `; -exports[`create-media should handle invalid temporary file id 1`] = ` +exports[`create-media should handle non-existent file path 1`] = ` { "content": [ { - "text": """", + "text": "Error creating media: File not found: /non/existent/file.jpg", "type": "text", }, ], + "isError": true, } `; diff --git a/src/umb-management-api/tools/media/__tests__/__snapshots__/get-media-urls.test.ts.snap b/src/umb-management-api/tools/media/__tests__/__snapshots__/get-media-urls.test.ts.snap index b9f5102..54ba388 100644 --- a/src/umb-management-api/tools/media/__tests__/__snapshots__/get-media-urls.test.ts.snap +++ b/src/umb-management-api/tools/media/__tests__/__snapshots__/get-media-urls.test.ts.snap @@ -4,7 +4,7 @@ exports[`get-media-urls should get media URLs 1`] = ` { "content": [ { - "text": "[{"id":"00000000-0000-0000-0000-000000000000","urlInfos":[]}]", + "text": "[{"id":"00000000-0000-0000-0000-000000000000","urlInfos":[{"culture":null,"url":"http://localhost:56472/media/NORMALIZED_PATH/example.jpg"}]}]", "type": "text", }, ], diff --git a/src/umb-management-api/tools/media/__tests__/__snapshots__/index.test.ts.snap b/src/umb-management-api/tools/media/__tests__/__snapshots__/index.test.ts.snap index 744327d..214c03a 100644 --- a/src/umb-management-api/tools/media/__tests__/__snapshots__/index.test.ts.snap +++ b/src/umb-management-api/tools/media/__tests__/__snapshots__/index.test.ts.snap @@ -7,6 +7,7 @@ exports[`media-tool-index should have all tools when user has all required acces "get-media-children", "get-media-root", "create-media", + "create-media-multiple", "delete-media", "update-media", "get-media-configuration", @@ -44,6 +45,7 @@ exports[`media-tool-index should have management tools when user has media secti "get-media-children", "get-media-root", "create-media", + "create-media-multiple", "delete-media", "update-media", "get-media-configuration", @@ -75,6 +77,7 @@ exports[`media-tool-index should have tree tools when user has media tree access "get-media-children", "get-media-root", "create-media", + "create-media-multiple", "delete-media", "update-media", "get-media-configuration", diff --git a/src/umb-management-api/tools/media/__tests__/create-media-multiple.test.ts b/src/umb-management-api/tools/media/__tests__/create-media-multiple.test.ts new file mode 100644 index 0000000..153b2ab --- /dev/null +++ b/src/umb-management-api/tools/media/__tests__/create-media-multiple.test.ts @@ -0,0 +1,212 @@ +import CreateMediaMultipleTool from "../post/create-media-multiple.js"; +import { MediaTestHelper } from "./helpers/media-test-helper.js"; +import { jest } from "@jest/globals"; +import { join } from "path"; +import { EXAMPLE_IMAGE_PATH } from "@/constants/constants.js"; + +// Test constants +const TEST_BATCH_IMAGE_1 = "_Test Batch Image 1"; +const TEST_BATCH_IMAGE_2 = "_Test Batch Image 2"; +const TEST_BATCH_FILE_1 = "_Test Batch File 1"; +const TEST_MIXED_IMAGE = "_Test Mixed Image"; +const TEST_MIXED_FILE = "_Test Mixed File"; +const TEST_URL_BATCH_IMAGE_1 = "_Test URL Batch Image 1"; +const TEST_URL_BATCH_IMAGE_2 = "_Test URL Batch Image 2"; + +const TEST_IMAGE_PATH = join(process.cwd(), EXAMPLE_IMAGE_PATH); +const TEST_PDF_PATH = join(process.cwd(), "/src/umb-management-api/tools/media/__tests__/test-files/example.pdf"); +const TEST_IMAGE_URL = "http://localhost:56472/media/qbflidnm/phone-pen-binder.jpg"; + +describe("create-media-multiple", () => { + let originalConsoleError: typeof console.error; + let originalConsoleWarn: typeof console.warn; + + beforeEach(() => { + originalConsoleError = console.error; + originalConsoleWarn = console.warn; + console.error = jest.fn(); + console.warn = jest.fn(); + }); + + afterEach(async () => { + // Clean up all test media + await MediaTestHelper.cleanup(TEST_BATCH_IMAGE_1); + await MediaTestHelper.cleanup(TEST_BATCH_IMAGE_2); + await MediaTestHelper.cleanup(TEST_BATCH_FILE_1); + await MediaTestHelper.cleanup(TEST_MIXED_IMAGE); + await MediaTestHelper.cleanup(TEST_MIXED_FILE); + await MediaTestHelper.cleanup(TEST_URL_BATCH_IMAGE_1); + await MediaTestHelper.cleanup(TEST_URL_BATCH_IMAGE_2); + + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + }); + + it("should create multiple images in batch", async () => { + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "filePath", + files: [ + { + name: TEST_BATCH_IMAGE_1, + filePath: TEST_IMAGE_PATH, + mediaTypeName: "Image", + }, + { + name: TEST_BATCH_IMAGE_2, + filePath: TEST_IMAGE_PATH, + mediaTypeName: "Image", + }, + ], + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + const found1 = await MediaTestHelper.findMedia(TEST_BATCH_IMAGE_1); + expect(found1).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found1!)).toBe(TEST_BATCH_IMAGE_1); + + const found2 = await MediaTestHelper.findMedia(TEST_BATCH_IMAGE_2); + expect(found2).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found2!)).toBe(TEST_BATCH_IMAGE_2); + }); + + it("should create multiple files with mixed media types", async () => { + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "filePath", + files: [ + { + name: TEST_MIXED_IMAGE, + filePath: TEST_IMAGE_PATH, + mediaTypeName: "Image", + }, + { + name: TEST_MIXED_FILE, + filePath: TEST_PDF_PATH, + mediaTypeName: "File", + }, + ], + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + const foundImage = await MediaTestHelper.findMedia(TEST_MIXED_IMAGE); + expect(foundImage).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(foundImage!)).toBe(TEST_MIXED_IMAGE); + + const foundFile = await MediaTestHelper.findMedia(TEST_MIXED_FILE); + expect(foundFile).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(foundFile!)).toBe(TEST_MIXED_FILE); + }); + + it("should use default File media type when not specified", async () => { + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "filePath", + files: [ + { + name: TEST_BATCH_FILE_1, + filePath: TEST_PDF_PATH, + // mediaTypeName not specified - should default to "File" + }, + ], + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + const found = await MediaTestHelper.findMedia(TEST_BATCH_FILE_1); + expect(found).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found!)).toBe(TEST_BATCH_FILE_1); + }); + + it("should handle batch size limit validation", async () => { + // Create an array of 21 files to exceed the limit + const files = Array.from({ length: 21 }, (_, i) => ({ + name: `_Test Batch ${i}`, + filePath: TEST_IMAGE_PATH, + mediaTypeName: "Image", + })); + + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "filePath", + files, + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + expect(result.isError).toBe(true); + }); + + it("should continue processing on individual file errors", async () => { + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "filePath", + files: [ + { + name: TEST_BATCH_IMAGE_1, + filePath: TEST_IMAGE_PATH, + mediaTypeName: "Image", + }, + { + name: TEST_BATCH_IMAGE_2, + filePath: "/non/existent/file.jpg", // This will fail + mediaTypeName: "Image", + }, + ], + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + // First file should be created + const found1 = await MediaTestHelper.findMedia(TEST_BATCH_IMAGE_1); + expect(found1).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found1!)).toBe(TEST_BATCH_IMAGE_1); + + // Second file should not be created + const found2 = await MediaTestHelper.findMedia(TEST_BATCH_IMAGE_2); + expect(found2).toBeUndefined(); + }); + + it("should create multiple media from URLs", async () => { + const result = await CreateMediaMultipleTool().handler( + { + sourceType: "url", + files: [ + { + name: TEST_URL_BATCH_IMAGE_1, // Extension only added to temp file, not media name + fileUrl: TEST_IMAGE_URL, + mediaTypeName: "Image", + }, + { + name: TEST_URL_BATCH_IMAGE_2, // Extension only added to temp file, not media name + fileUrl: TEST_IMAGE_URL, + mediaTypeName: "Image", + }, + ], + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + // Media items should have original names (no extension added) + const found1 = await MediaTestHelper.findMedia(TEST_URL_BATCH_IMAGE_1); + expect(found1).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found1!)).toBe(TEST_URL_BATCH_IMAGE_1); + + const found2 = await MediaTestHelper.findMedia(TEST_URL_BATCH_IMAGE_2); + expect(found2).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found2!)).toBe(TEST_URL_BATCH_IMAGE_2); + }); +}); diff --git a/src/umb-management-api/tools/media/__tests__/create-media.test.ts b/src/umb-management-api/tools/media/__tests__/create-media.test.ts index 0deb04d..5c99bf5 100644 --- a/src/umb-management-api/tools/media/__tests__/create-media.test.ts +++ b/src/umb-management-api/tools/media/__tests__/create-media.test.ts @@ -1,92 +1,144 @@ import CreateMediaTool from "../post/create-media.js"; -import { MediaBuilder } from "./helpers/media-builder.js"; import { MediaTestHelper } from "./helpers/media-test-helper.js"; import { jest } from "@jest/globals"; -import { TemporaryFileBuilder } from "../../temporary-file/__tests__/helpers/temporary-file-builder.js"; +import { join } from "path"; +import { EXAMPLE_IMAGE_PATH } from "@/constants/constants.js"; -const TEST_MEDIA_NAME = "_Test Media Created"; +// Test constants +const TEST_IMAGE_NAME = "_Test Image Upload"; +const TEST_FILE_NAME = "_Test File Upload"; +const TEST_URL_IMAGE_NAME = "_Test URL Image Upload"; +const TEST_BASE64_IMAGE_NAME = "_Test Base64 Image Upload"; + +const TEST_IMAGE_PATH = join(process.cwd(), EXAMPLE_IMAGE_PATH); +const TEST_PDF_PATH = join(process.cwd(), "/src/umb-management-api/tools/media/__tests__/test-files/example.pdf"); +const TEST_IMAGE_URL = "http://localhost:56472/media/qbflidnm/phone-pen-binder.jpg"; + +// Small 1x1 red pixel PNG as base64 for testing +const TEST_BASE64_IMAGE = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; describe("create-media", () => { let originalConsoleError: typeof console.error; - let tempFileBuilder: TemporaryFileBuilder; + let originalConsoleWarn: typeof console.warn; - beforeEach(async () => { + beforeEach(() => { originalConsoleError = console.error; + originalConsoleWarn = console.warn; console.error = jest.fn(); - - tempFileBuilder = await new TemporaryFileBuilder() - .withExampleFile() - .create(); + console.warn = jest.fn(); }); afterEach(async () => { + // Clean up all test media + await MediaTestHelper.cleanup(TEST_IMAGE_NAME); + await MediaTestHelper.cleanup(TEST_FILE_NAME); + await MediaTestHelper.cleanup(TEST_URL_IMAGE_NAME); + await MediaTestHelper.cleanup(`${TEST_BASE64_IMAGE_NAME}.png`); + console.error = originalConsoleError; - await MediaTestHelper.cleanup(TEST_MEDIA_NAME); + console.warn = originalConsoleWarn; }); - it("should create a media item", async () => { - // Create media model using builder - const mediaModel = new MediaBuilder() - .withName(TEST_MEDIA_NAME) - .withImageMediaType() - .withImageValue(tempFileBuilder.getId()) - .build(); + it("should create image media", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "filePath", + name: TEST_IMAGE_NAME, + mediaTypeName: "Image", + filePath: TEST_IMAGE_PATH, + }, + { signal: new AbortController().signal } + ); + + expect(result).toMatchSnapshot(); + + const found = await MediaTestHelper.findMedia(TEST_IMAGE_NAME); + expect(found).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found!)).toBe(TEST_IMAGE_NAME); + }); - // Create the media - const result = await CreateMediaTool().handler(mediaModel, { - signal: new AbortController().signal - }); + it("should create file media", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "filePath", + name: TEST_FILE_NAME, + mediaTypeName: "File", + filePath: TEST_PDF_PATH, + }, + { signal: new AbortController().signal } + ); - // Verify the handler response using snapshot expect(result).toMatchSnapshot(); - // Verify the created item exists and matches expected values - const item = await MediaTestHelper.findMedia(TEST_MEDIA_NAME); - expect(item).toBeDefined(); - const norm = { ...MediaTestHelper.normaliseIds(item!), createDate: "" }; - expect(norm).toMatchSnapshot(); + const found = await MediaTestHelper.findMedia(TEST_FILE_NAME); + expect(found).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found!)).toBe(TEST_FILE_NAME); }); - it("should create a media item with parent", async () => { - // Create parent folder - const parentBuilder = await new MediaBuilder() - .withName("_Test Parent Folder") - .withFolderMediaType() - .create(); - - // Create media model with parent - const mediaModel = new MediaBuilder() - .withName(TEST_MEDIA_NAME) - .withImageMediaType() - .withParent(parentBuilder.getId()) - .withImageValue(tempFileBuilder.getId()) - .build(); - - const result = await CreateMediaTool().handler(mediaModel, { - signal: new AbortController().signal - }); + it("should handle non-existent file path", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "filePath", + name: TEST_IMAGE_NAME, + mediaTypeName: "Image", + filePath: "/non/existent/file.jpg", + }, + { signal: new AbortController().signal } + ); expect(result).toMatchSnapshot(); + expect(result.isError).toBe(true); + }); - const item = await MediaTestHelper.findMedia(TEST_MEDIA_NAME); - expect(item).toBeDefined(); - expect(item!.parent?.id).toBe(parentBuilder.getId()); + it("should handle invalid media type", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "filePath", + name: TEST_IMAGE_NAME, + mediaTypeName: "NonExistentMediaType", + filePath: TEST_IMAGE_PATH, + }, + { signal: new AbortController().signal } + ); - // Cleanup parent - await MediaTestHelper.cleanup("_Test Parent Folder"); + expect(result).toMatchSnapshot(); + expect(result.isError).toBe(true); }); - it("should handle invalid temporary file id", async () => { - const mediaModel = new MediaBuilder() - .withName(TEST_MEDIA_NAME) - .withImageMediaType() - .withImageValue("invalid-temp-file-id") - .build(); + it("should create media from URL", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "url", + name: `${TEST_URL_IMAGE_NAME}`, + mediaTypeName: "Image", + fileUrl: TEST_IMAGE_URL, + }, + { signal: new AbortController().signal } + ); - const result = await CreateMediaTool().handler(mediaModel, { - signal: new AbortController().signal - }); + expect(result).toMatchSnapshot(); + + // Media item name should be the original name, not with extension added + const found = await MediaTestHelper.findMedia(TEST_URL_IMAGE_NAME); + expect(found).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found!)).toBe(TEST_URL_IMAGE_NAME); + }); + + it("should create media from base64", async () => { + const result = await CreateMediaTool().handler( + { + sourceType: "base64", + name: `${TEST_BASE64_IMAGE_NAME}.png`, + mediaTypeName: "Image", + fileAsBase64: TEST_BASE64_IMAGE, + }, + { signal: new AbortController().signal } + ); expect(result).toMatchSnapshot(); + + const found = await MediaTestHelper.findMedia(`${TEST_BASE64_IMAGE_NAME}.png`); + expect(found).toBeDefined(); + expect(MediaTestHelper.getNameFromItem(found!)).toBe(`${TEST_BASE64_IMAGE_NAME}.png`); }); -}); \ No newline at end of file +}); diff --git a/src/umb-management-api/tools/media/__tests__/get-collection-media.test.ts b/src/umb-management-api/tools/media/__tests__/get-collection-media.test.ts index 5ecfc41..c59a2b9 100644 --- a/src/umb-management-api/tools/media/__tests__/get-collection-media.test.ts +++ b/src/umb-management-api/tools/media/__tests__/get-collection-media.test.ts @@ -12,6 +12,7 @@ const TEST_MEDIA_NAME_2 = "_Test Collection Media 2"; describe("get-collection-media", () => { let originalConsoleError: typeof console.error; let tempFileBuilder: TemporaryFileBuilder; + let tempFileBuilder2: TemporaryFileBuilder; beforeEach(async () => { originalConsoleError = console.error; @@ -20,6 +21,10 @@ describe("get-collection-media", () => { tempFileBuilder = await new TemporaryFileBuilder() .withExampleFile() .create(); + + tempFileBuilder2 = await new TemporaryFileBuilder() + .withExampleFile() + .create(); }); afterEach(async () => { @@ -39,7 +44,7 @@ describe("get-collection-media", () => { await new MediaBuilder() .withName(TEST_MEDIA_NAME_2) .withImageMediaType() - .withImageValue(tempFileBuilder.getId()) + .withImageValue(tempFileBuilder2.getId()) .create(); const result = await GetCollectionMediaTool().handler( diff --git a/src/umb-management-api/tools/media/__tests__/get-media-are-referenced.test.ts b/src/umb-management-api/tools/media/__tests__/get-media-are-referenced.test.ts index c4d484a..7d92854 100644 --- a/src/umb-management-api/tools/media/__tests__/get-media-are-referenced.test.ts +++ b/src/umb-management-api/tools/media/__tests__/get-media-are-referenced.test.ts @@ -20,6 +20,7 @@ const TEST_DOCUMENT_NAME_2 = "_Test Document With Media 2"; describe("get-media-are-referenced", () => { let originalConsoleError: typeof console.error; let tempFileBuilder: TemporaryFileBuilder; + let tempFileBuilder2: TemporaryFileBuilder; beforeEach(async () => { originalConsoleError = console.error; @@ -28,6 +29,10 @@ describe("get-media-are-referenced", () => { tempFileBuilder = await new TemporaryFileBuilder() .withExampleFile() .create(); + + tempFileBuilder2 = await new TemporaryFileBuilder() + .withExampleFile() + .create(); }); afterEach(async () => { @@ -77,7 +82,7 @@ describe("get-media-are-referenced", () => { const unreferencedMedia = await new MediaBuilder() .withName(TEST_MEDIA_NAME_2) .withImageMediaType() - .withImageValue(tempFileBuilder.getId()) + .withImageValue(tempFileBuilder2.getId()) .create(); // Create a document type with a media picker diff --git a/src/umb-management-api/tools/media/__tests__/helpers/media-builder.ts b/src/umb-management-api/tools/media/__tests__/helpers/media-builder.ts index 808fd8f..ebbf9f8 100644 --- a/src/umb-management-api/tools/media/__tests__/helpers/media-builder.ts +++ b/src/umb-management-api/tools/media/__tests__/helpers/media-builder.ts @@ -5,6 +5,7 @@ import { } from "@/umb-management-api/schemas/index.js"; import { postMediaBody } from "@/umb-management-api/umbracoManagementAPI.zod.js"; import { MediaTestHelper } from "./media-test-helper.js"; +import { v4 as uuidv4 } from "uuid"; import { FOLDER_MEDIA_TYPE_ID, IMAGE_MEDIA_TYPE_ID, @@ -46,22 +47,24 @@ export class MediaBuilder { return this; } - withImageValue(temporaryFieldId: string): MediaBuilder { + withImageValue(temporaryFileId: string): MediaBuilder { this.model.values = [ { alias: "umbracoFile", + editorAlias: "Umbraco.ImageCropper", + entityType: "media-property-value", value: { crops: [], culture: null, segment: null, focalPoint: { - left: 0.5, + top: 0.5, right: 0.5, }, - temporaryFieldId: temporaryFieldId, + temporaryFileId: temporaryFileId, }, }, - ]; + ] as any; return this; } diff --git a/src/umb-management-api/tools/media/__tests__/helpers/media-test-helper.test.ts b/src/umb-management-api/tools/media/__tests__/helpers/media-test-helper.test.ts index 2a272e8..75019a0 100644 --- a/src/umb-management-api/tools/media/__tests__/helpers/media-test-helper.test.ts +++ b/src/umb-management-api/tools/media/__tests__/helpers/media-test-helper.test.ts @@ -152,17 +152,26 @@ describe("MediaTestHelper", () => { // Create children const childIds: string[] = []; for (const name of childNames) { + // Create a new temp file for each child since temp files can only be used once + const childTempFile = await new TemporaryFileBuilder() + .withExampleFile() + .create(); + const childBuilder = await new MediaBuilder() .withName(name) .withImageMediaType() - .withImageValue(tempFileBuilder.getId()) + .withImageValue(childTempFile.getId()) .withParent(rootId) .create(); childIds.push(childBuilder.getId()); } - // Assert getChildren returns the correct media items in order + // Assert getChildren returns media items including our test items const fetchedMedia = await MediaTestHelper.getChildren(rootId, 10); - expect(fetchedMedia.map((media) => media.id)).toEqual(childIds); + const fetchedIds = fetchedMedia.map((media) => media.id); + + // Verify our children are present (other items may exist from other tests) + expect(fetchedIds).toContain(childIds[0]); + expect(fetchedIds).toContain(childIds[1]); // Cleanup await MediaTestHelper.cleanup(rootName); for (const name of childNames) { diff --git a/src/umb-management-api/tools/media/__tests__/helpers/media-upload-helpers.test.ts b/src/umb-management-api/tools/media/__tests__/helpers/media-upload-helpers.test.ts new file mode 100644 index 0000000..10c70bf --- /dev/null +++ b/src/umb-management-api/tools/media/__tests__/helpers/media-upload-helpers.test.ts @@ -0,0 +1,87 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; + +// Import the function +import { createFileStream } from "../../post/helpers/media-upload-helpers.js"; + +describe("media-upload-helpers", () => { + describe("createFileStream - file path source", () => { + it("should create stream from file path", async () => { + // Create a temporary test file + const testContent = "test content"; + const testFileName = `test-${Date.now()}-${Math.random()}.txt`; + const testFilePath = path.join(os.tmpdir(), testFileName); + + fs.writeFileSync(testFilePath, testContent); + + // Verify file exists before proceeding + expect(fs.existsSync(testFilePath)).toBe(true); + + try { + const { readStream, tempFilePath } = await createFileStream( + "filePath", + testFilePath, + undefined, + undefined, + testFileName, + "test-id" + ); + + // Assert + expect(readStream).toBeDefined(); + expect(tempFilePath).toBeNull(); // filePath source doesn't create temp files + + // Cleanup - properly close stream first + await new Promise((resolve) => { + readStream.on('close', () => resolve()); + readStream.close(); + }); + } finally { + // Cleanup test file + if (fs.existsSync(testFilePath)) { + fs.unlinkSync(testFilePath); + } + } + }); + }); + + describe("createFileStream - base64 source", () => { + it("should create stream from base64 data", async () => { + // Arrange + const testBase64 = Buffer.from("test content").toString("base64"); + const fileName = "test-base64.txt"; + + try { + // Act + const { readStream, tempFilePath } = await createFileStream( + "base64", + undefined, + undefined, + testBase64, + fileName, + "test-id" + ); + + // Assert + expect(readStream).toBeDefined(); + expect(tempFilePath).toBeDefined(); + expect(tempFilePath).toContain("test-base64.txt"); + + // Cleanup - properly close stream first + await new Promise((resolve) => { + readStream.on('close', () => resolve()); + readStream.close(); + }); + + if (tempFilePath && fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + } catch (error) { + // Ensure cleanup even on error + throw error; + } + }); + }); + +}); diff --git a/src/umb-management-api/tools/media/__tests__/sort-media.test.ts b/src/umb-management-api/tools/media/__tests__/sort-media.test.ts index f6141a1..caa9b61 100644 --- a/src/umb-management-api/tools/media/__tests__/sort-media.test.ts +++ b/src/umb-management-api/tools/media/__tests__/sort-media.test.ts @@ -11,6 +11,7 @@ const TEST_MEDIA_NAME_2 = "_Test Media Sort 2"; describe("sort-media", () => { let originalConsoleError: typeof console.error; let tempFileBuilder: TemporaryFileBuilder; + let tempFileBuilder2: TemporaryFileBuilder; beforeEach(async () => { originalConsoleError = console.error; @@ -19,6 +20,10 @@ describe("sort-media", () => { tempFileBuilder = await new TemporaryFileBuilder() .withExampleFile() .create(); + + tempFileBuilder2 = await new TemporaryFileBuilder() + .withExampleFile() + .create(); }); afterEach(async () => { @@ -43,7 +48,7 @@ describe("sort-media", () => { const media2Builder = await new MediaBuilder() .withName(TEST_MEDIA_NAME_2) .withImageMediaType() - .withImageValue(tempFileBuilder.getId()) + .withImageValue(tempFileBuilder2.getId()) .withParent(folderBuilder.getId()) .create(); diff --git a/src/umb-management-api/tools/media/__tests__/test-files/example.pdf b/src/umb-management-api/tools/media/__tests__/test-files/example.pdf new file mode 100644 index 0000000000000000000000000000000000000000..da42939fe569f60ed32f8dc543ac02c1b20748bc GIT binary patch literal 73 zcmWGZEiO?AaB)*`&d)1J%_~vRRS3z*ELH%bM1|bUyv*Fh9EB2)qO{DMRE4zsB8A-4 Vl*~kh(t@1)#1ybpW?s4;7XX%N89@L5 literal 0 HcmV?d00001 diff --git a/src/umb-management-api/tools/media/__tests__/test-files/example.svg b/src/umb-management-api/tools/media/__tests__/test-files/example.svg new file mode 100644 index 0000000..2244773 --- /dev/null +++ b/src/umb-management-api/tools/media/__tests__/test-files/example.svg @@ -0,0 +1,4 @@ + + + Test + diff --git a/src/umb-management-api/tools/media/index.ts b/src/umb-management-api/tools/media/index.ts index bfe568a..7c0af88 100644 --- a/src/umb-management-api/tools/media/index.ts +++ b/src/umb-management-api/tools/media/index.ts @@ -1,4 +1,5 @@ import CreateMediaTool from "./post/create-media.js"; +import CreateMediaMultipleTool from "./post/create-media-multiple.js"; import DeleteMediaTool from "./delete/delete-media.js"; import GetMediaByIdTool from "./get/get-media-by-id.js"; import UpdateMediaTool from "./put/update-media.js"; @@ -49,6 +50,7 @@ export const MediaCollection: ToolCollectionExport = { if (AuthorizationPolicies.SectionAccessMedia(user)) { tools.push(CreateMediaTool()); + tools.push(CreateMediaMultipleTool()); tools.push(DeleteMediaTool()); tools.push(UpdateMediaTool()); tools.push(GetMediaConfigurationTool()); diff --git a/src/umb-management-api/tools/media/post/create-media-multiple.ts b/src/umb-management-api/tools/media/post/create-media-multiple.ts new file mode 100644 index 0000000..306d75e --- /dev/null +++ b/src/umb-management-api/tools/media/post/create-media-multiple.ts @@ -0,0 +1,104 @@ +import { UmbracoManagementClient } from "@umb-management-client"; +import { CreateUmbracoTool } from "@/helpers/mcp/create-umbraco-tool.js"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; +import { uploadMediaFile } from "./helpers/media-upload-helpers.js"; + +const createMediaMultipleSchema = z.object({ + sourceType: z.enum(["filePath", "url"]).describe("Media source type: 'filePath' for local files (most efficient), 'url' for web files. Base64 not supported for batch uploads due to token usage."), + files: z.array(z.object({ + name: z.string().describe("The name of the media item"), + filePath: z.string().optional().describe("Absolute path to the file (required if sourceType is 'filePath')"), + fileUrl: z.string().url().optional().describe("URL to fetch the file from (required if sourceType is 'url')"), + mediaTypeName: z.string().optional().describe("Optional override: 'Image', 'Article', 'Audio', 'Video', 'Vector Graphic (SVG)', 'File', or custom media type name. If not specified, defaults to 'File'"), + })).describe("Array of files to upload (maximum 20 files per batch)"), + parentId: z.string().uuid().optional().describe("Parent folder ID (defaults to root)"), +}); + +type CreateMediaMultipleParams = z.infer; + +interface UploadResult { + success: boolean; + name: string; + error?: string; +} + +const CreateMediaMultipleTool = CreateUmbracoTool( + "create-media-multiple", + `Batch upload multiple media files to Umbraco (maximum 20 files per batch). + + Supports any file type: images, documents, audio, video, SVG, or custom types. + + Source Types: + 1. filePath - Most efficient for local files, works with any size + 2. url - Fetch from web URL + + Note: base64 is not supported for batch uploads due to token usage. + + The tool processes files sequentially and returns detailed results for each file. + If some files fail, others will continue processing (continue-on-error strategy).`, + createMediaMultipleSchema.shape, + async (model: CreateMediaMultipleParams) => { + // Validate batch size + if (model.files.length > 20) { + return { + content: [{ + type: "text" as const, + text: `Batch upload limited to 20 files per call. You provided ${model.files.length} files. Please split into multiple batches.` + }], + isError: true + }; + } + + const results: UploadResult[] = []; + const client = UmbracoManagementClient.getClient(); + + // Process files sequentially + for (const file of model.files) { + try { + const temporaryFileId = uuidv4(); + const defaultMediaType = file.mediaTypeName || 'File'; + + const actualName = await uploadMediaFile(client, { + sourceType: model.sourceType, + name: file.name, + mediaTypeName: defaultMediaType, + filePath: file.filePath, + fileUrl: file.fileUrl, + fileAsBase64: undefined, + parentId: model.parentId, + temporaryFileId, + }); + + results.push({ + success: true, + name: actualName, + }); + + } catch (error) { + results.push({ + success: false, + name: file.name, + error: (error as Error).message + }); + } + } + + const successCount = results.filter(r => r.success).length; + const failureCount = results.filter(r => !r.success).length; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + summary: `Processed ${model.files.length} files: ${successCount} succeeded, ${failureCount} failed`, + results + }, null, 2), + }, + ], + }; + } +); + +export default CreateMediaMultipleTool; diff --git a/src/umb-management-api/tools/media/post/create-media.ts b/src/umb-management-api/tools/media/post/create-media.ts index 1ba1449..9d05426 100644 --- a/src/umb-management-api/tools/media/post/create-media.ts +++ b/src/umb-management-api/tools/media/post/create-media.ts @@ -1,26 +1,80 @@ import { UmbracoManagementClient } from "@umb-management-client"; import { CreateUmbracoTool } from "@/helpers/mcp/create-umbraco-tool.js"; -import { postMediaBody } from "@/umb-management-api/umbracoManagementAPI.zod.js"; +import { z } from "zod"; +import { v4 as uuidv4 } from "uuid"; +import { uploadMediaFile } from "./helpers/media-upload-helpers.js"; + +const createMediaSchema = z.object({ + sourceType: z.enum(["filePath", "url", "base64"]).describe("Media source type: 'filePath' for local files (most efficient), 'url' for web files, 'base64' for embedded data (small files only)"), + name: z.string().describe("The name of the media item"), + mediaTypeName: z.string().describe("Media type: 'Image', 'Article', 'Audio', 'Video', 'Vector Graphic (SVG)', 'File', or custom media type name"), + filePath: z.string().optional().describe("Absolute path to the file (required if sourceType is 'filePath')"), + fileUrl: z.string().url().optional().describe("URL to fetch the file from (required if sourceType is 'url')"), + fileAsBase64: z.string().optional().describe("Base64 encoded file data (required if sourceType is 'base64')"), + parentId: z.string().uuid().optional().describe("Parent folder ID (defaults to root)"), +}); + +type CreateMediaParams = z.infer; const CreateMediaTool = CreateUmbracoTool( "create-media", - `Creates a media item. - Use this endpoint to create media items like images, files, or folders. - The process is as follows: - - Create a temporary file using the temporary file endpoint - - Use the temporary file id when creating a media item using this endpoint`, - postMediaBody.shape, - async (model) => { - const client = UmbracoManagementClient.getClient(); - const response = await client.postMedia(model); - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(response), - }, - ], - }; + `Upload any media file to Umbraco (images, documents, audio, video, SVG, or custom types). + + Media Types: + - Image: jpg, png, gif, webp, etc. (supports cropping) + - Article: pdf, docx, doc (documents) + - Audio: mp3, wav, etc. + - Video: mp4, webm, etc. + - Vector Graphic (SVG): svg files only + - File: any other file type + - Custom: any custom media type created in Umbraco + + Source Types: + 1. filePath - Most efficient for local files, works with any size + 2. url - Fetch from web URL + 3. base64 - Only for small files (<10KB) due to token usage + + The tool automatically: + - Creates temporary files + - Detects and validates media types (auto-corrects SVG vs Image) + - Configures correct property editors (ImageCropper vs UploadField) + - Cleans up temporary files`, + createMediaSchema.shape, + async (model: CreateMediaParams) => { + try { + const client = UmbracoManagementClient.getClient(); + const temporaryFileId = uuidv4(); + + const actualName = await uploadMediaFile(client, { + sourceType: model.sourceType, + name: model.name, + mediaTypeName: model.mediaTypeName, + filePath: model.filePath, + fileUrl: model.fileUrl, + fileAsBase64: model.fileAsBase64, + parentId: model.parentId, + temporaryFileId, + }); + + return { + content: [ + { + type: "text" as const, + text: `Media "${actualName}" created successfully`, + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error creating media: ${(error as Error).message}`, + }, + ], + isError: true, + }; + } } ); diff --git a/src/umb-management-api/tools/media/post/helpers/media-upload-helpers.ts b/src/umb-management-api/tools/media/post/helpers/media-upload-helpers.ts new file mode 100644 index 0000000..a0f6d29 --- /dev/null +++ b/src/umb-management-api/tools/media/post/helpers/media-upload-helpers.ts @@ -0,0 +1,302 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import axios from "axios"; +import mime from "mime-types"; + +/** + * Maps MIME types to file extensions using the mime-types library. + * Returns undefined if MIME type is unknown. + */ +function getExtensionFromMimeType(mimeType: string | undefined): string | undefined { + if (!mimeType) return undefined; + + // Remove charset and other parameters, then get extension + const baseMimeType = mimeType.split(';')[0].trim(); + const extension = mime.extension(baseMimeType); + + return extension ? `.${extension}` : undefined; +} + +/** + * Validates and corrects media type for SVG files. + * SVG files should use "Vector Graphic (SVG)" media type, not "Image". + */ +export function validateMediaTypeForSvg( + filePath: string | undefined, + fileUrl: string | undefined, + fileName: string, + mediaTypeName: string +): string { + // Check if any of the file identifiers end with .svg + const isSvg = + filePath?.toLowerCase().endsWith('.svg') || + fileUrl?.toLowerCase().endsWith('.svg') || + fileName.toLowerCase().endsWith('.svg'); + + if (isSvg && mediaTypeName === 'Image') { + console.warn('SVG detected - using Vector Graphic media type instead of Image'); + return 'Vector Graphic (SVG)'; + } + + return mediaTypeName; +} + +/** + * Fetches media type ID from Umbraco API by name. + * Throws error with helpful message if media type not found. + */ +export async function fetchMediaTypeId(client: any, mediaTypeName: string): Promise { + const response = await client.getItemMediaTypeSearch({ query: mediaTypeName }); + + const mediaType = response.items.find( + (mt: any) => mt.name.toLowerCase() === mediaTypeName.toLowerCase() + ); + + if (!mediaType) { + const availableTypes = response.items.map((mt: any) => mt.name).join(', '); + throw new Error( + `Media type '${mediaTypeName}' not found. Available types: ${availableTypes || 'None found'}` + ); + } + + return mediaType.id; +} + +/** + * Gets the appropriate editor alias based on media type. + * Image uses ImageCropper, everything else uses UploadField. + */ +export function getEditorAlias(mediaTypeName: string): string { + return mediaTypeName === 'Image' ? 'Umbraco.ImageCropper' : 'Umbraco.UploadField'; +} + +/** + * Builds the value structure for media creation based on media type. + * Image media type requires crops and focal point, others just need temporaryFileId. + */ +export function buildValueStructure(mediaTypeName: string, temporaryFileId: string) { + const base = { + alias: "umbracoFile", + editorAlias: getEditorAlias(mediaTypeName), + entityType: "media-property-value", + }; + + if (mediaTypeName === 'Image') { + return { + ...base, + value: { + crops: [], + culture: null, + segment: null, + focalPoint: { + top: 0.5, + right: 0.5, + }, + temporaryFileId + } + }; + } + + return { + ...base, + value: { temporaryFileId } + }; +} + +/** + * Creates a file stream based on source type. + * Returns the stream and the temporary file path (if created). + * Note: Temp files need a filename for Umbraco's string parsing to work correctly. + * Exported for testing. + */ +export async function createFileStream( + sourceType: "filePath" | "url" | "base64", + filePath: string | undefined, + fileUrl: string | undefined, + fileAsBase64: string | undefined, + fileName: string, + temporaryFileId: string +): Promise<{ readStream: fs.ReadStream; tempFilePath: string | null }> { + let tempFilePath: string | null = null; + let readStream: fs.ReadStream; + + switch (sourceType) { + case "filePath": + if (!filePath) { + throw new Error("filePath is required when sourceType is 'filePath'"); + } + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + readStream = fs.createReadStream(filePath); + break; + + case "url": + if (!fileUrl) { + throw new Error("fileUrl is required when sourceType is 'url'"); + } + try { + const response = await axios.get(fileUrl, { + responseType: 'arraybuffer', + timeout: 30000, + validateStatus: (status) => status < 500, // Don't throw on 4xx errors + }); + + if (response.status >= 400) { + throw new Error(`Failed to fetch file from URL: HTTP ${response.status} ${response.statusText}`); + } + + // Extract extension from URL, or try to detect from Content-Type header + let fileNameWithExtension = fileName; + if (!fileName.includes('.')) { + const urlPath = new URL(fileUrl).pathname; + const urlExtension = path.extname(urlPath); + + if (urlExtension) { + // Use extension from URL + fileNameWithExtension = `${fileName}${urlExtension}`; + } else { + // Try to detect extension from Content-Type header + const contentType = response.headers['content-type'] as string | undefined; + const extensionFromMime = getExtensionFromMimeType(contentType); + if (extensionFromMime) { + fileNameWithExtension = `${fileName}${extensionFromMime}`; + } else { + // Default to .bin if we can't determine the type + fileNameWithExtension = `${fileName}.bin`; + } + } + } + + // Use the filename with extension so Umbraco can parse it correctly + tempFilePath = path.join(os.tmpdir(), fileNameWithExtension); + fs.writeFileSync(tempFilePath, response.data); + readStream = fs.createReadStream(tempFilePath); + } catch (error) { + const axiosError = error as any; + if (axiosError.response) { + throw new Error(`Failed to fetch URL: HTTP ${axiosError.response.status} - ${axiosError.response.statusText} (${fileUrl})`); + } else if (axiosError.code === 'ECONNABORTED') { + throw new Error(`Request timeout after 30s fetching URL: ${fileUrl}`); + } else if (axiosError.code) { + throw new Error(`Network error (${axiosError.code}) fetching URL: ${fileUrl} - ${axiosError.message}`); + } + throw new Error(`Failed to fetch URL: ${fileUrl} - ${(error as Error).message}`); + } + break; + + case "base64": + if (!fileAsBase64) { + throw new Error("fileAsBase64 is required when sourceType is 'base64'"); + } + const fileContent = Buffer.from(fileAsBase64, 'base64'); + // Use just the filename so Umbraco can parse it correctly + tempFilePath = path.join(os.tmpdir(), fileName); + fs.writeFileSync(tempFilePath, fileContent); + readStream = fs.createReadStream(tempFilePath); + break; + } + + return { readStream, tempFilePath }; +} + +/** + * Cleans up a temporary file if it exists. + */ +export function cleanupTempFile(tempFilePath: string | null): void { + if (tempFilePath && fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch (e) { + console.error('Failed to cleanup temp file:', e); + } + } +} + +/** + * Uploads media file to Umbraco. + * Handles the complete workflow: file stream creation, temporary file upload, and media creation. + * Returns the actual name used (with extension if added from URL). + */ +export async function uploadMediaFile( + client: any, + params: { + sourceType: "filePath" | "url" | "base64"; + name: string; + mediaTypeName: string; + filePath?: string; + fileUrl?: string; + fileAsBase64?: string; + parentId?: string; + temporaryFileId: string; + } +): Promise { + let tempFilePath: string | null = null; + + try { + // Step 1: Validate media type (SVG special case - only auto-correction we do) + // We trust the LLM for all other media type decisions + const validatedMediaTypeName = validateMediaTypeForSvg( + params.filePath, + params.fileUrl, + params.name, + params.mediaTypeName + ); + + // Step 2: Fetch media type ID + const mediaTypeId = await fetchMediaTypeId(client, validatedMediaTypeName); + + // Step 3: Create file stream + const { readStream, tempFilePath: createdTempPath } = await createFileStream( + params.sourceType, + params.filePath, + params.fileUrl, + params.fileAsBase64, + params.name, + params.temporaryFileId + ); + tempFilePath = createdTempPath; + + // Step 4: Upload to temporary file endpoint + try { + await client.postTemporaryFile({ + Id: params.temporaryFileId, + File: readStream, + }); + } catch (error) { + const err = error as any; + const errorData = err.response?.data + ? (typeof err.response.data === 'string' ? err.response.data : JSON.stringify(err.response.data)) + : err.message; + throw new Error(`Failed to upload temporary file: ${err.response?.status || 'Unknown error'} - ${errorData}`); + } + + // Step 5: Build value structure + const valueStructure = buildValueStructure(validatedMediaTypeName, params.temporaryFileId); + + // Step 6: Create media item + try { + await client.postMedia({ + mediaType: { id: mediaTypeId }, + variants: [ + { + culture: null, + segment: null, + name: params.name, // Use original name provided by user + }, + ], + values: [valueStructure] as any, + parent: params.parentId ? { id: params.parentId } : null, + }); + } catch (error) { + const err = error as any; + throw new Error(`Failed to create media item: ${err.response?.status || 'Unknown error'} - ${JSON.stringify(err.response?.data) || err.message}`); + } + + // Return the original name (not the temp filename with extension) + return params.name; + } finally { + cleanupTempFile(tempFilePath); + } +} diff --git a/src/umb-management-api/tools/server/__tests__/index.test.ts b/src/umb-management-api/tools/server/__tests__/index.test.ts index 7b3a1f4..e543b45 100644 --- a/src/umb-management-api/tools/server/__tests__/index.test.ts +++ b/src/umb-management-api/tools/server/__tests__/index.test.ts @@ -16,7 +16,7 @@ describe("server-tool-index", () => { it("should have all tools when user is admin", () => { const userMock = { allowedSections: [], - userGroupIds: [{ id: AdminGroupKeyString.toLowerCase(), name: "Administrators" }] + userGroupIds: [{ id: AdminGroupKeyString.toLowerCase() }] } as Partial; const tools = ServerTools(userMock as CurrentUserResponseModel); diff --git a/src/umb-management-api/tools/tag/__tests__/__snapshots__/get-tag.test.ts.snap b/src/umb-management-api/tools/tag/__tests__/__snapshots__/get-tag.test.ts.snap index 39c4337..5e1ca6c 100644 --- a/src/umb-management-api/tools/tag/__tests__/__snapshots__/get-tag.test.ts.snap +++ b/src/umb-management-api/tools/tag/__tests__/__snapshots__/get-tag.test.ts.snap @@ -26,7 +26,7 @@ exports[`get-tag should return tags that match query 1`] = ` { "content": [ { - "text": "{"total":1,"items":[{"id":"1b6f050c-5587-45d3-b73e-c433130b1f05","text":"test-tag","group":"default","nodeCount":1}]}", + "text": "{"total":1,"items":[{"id":"00000000-0000-0000-0000-000000000000","text":"test-tag","group":"default","nodeCount":1}]}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/tag/__tests__/get-tag.test.ts b/src/umb-management-api/tools/tag/__tests__/get-tag.test.ts index 89c11d9..e134af5 100644 --- a/src/umb-management-api/tools/tag/__tests__/get-tag.test.ts +++ b/src/umb-management-api/tools/tag/__tests__/get-tag.test.ts @@ -105,7 +105,9 @@ describe("get-tag", () => { { signal: new AbortController().signal } ); - expect(result).toMatchSnapshot(); + // Normalize the result for snapshot testing + const normalizedResult = createSnapshotResult(result); + expect(normalizedResult).toMatchSnapshot(); }); }); \ No newline at end of file diff --git a/src/umb-management-api/tools/template/__tests__/__snapshots__/execute-template-query.test.ts.snap b/src/umb-management-api/tools/template/__tests__/__snapshots__/execute-template-query.test.ts.snap index 29d84ff..4111275 100644 --- a/src/umb-management-api/tools/template/__tests__/__snapshots__/execute-template-query.test.ts.snap +++ b/src/umb-management-api/tools/template/__tests__/__snapshots__/execute-template-query.test.ts.snap @@ -4,7 +4,7 @@ exports[`execute-template-query should execute a simple template query 1`] = ` { "content": [ { - "text": "{"queryExpression":"Umbraco.ContentAtRoot().FirstOrDefault()\\n .Children()\\n .Where(x => x.IsVisible())\\n .Take(10)","sampleResults":[{"icon":"icon-document color-blue","name":"Features"},{"icon":"icon-document color-blue","name":"About"},{"icon":"icon-thumbnail-list color-blue","name":"Blog"},{"icon":"icon-message color-blue","name":"Contact"},{"icon":"icon-search color-blue","name":"Search"},{"icon":"icon-users color-blue","name":"Authors"}],"resultCount":6,"executionTime":0}", + "text": "{"queryExpression":"Umbraco.ContentAtRoot().FirstOrDefault()\\n .Children()\\n .Where(x => x.IsVisible())\\n .Take(10)","sampleResults":[],"resultCount":0,"executionTime":0}", "type": "text", }, ], @@ -26,7 +26,7 @@ exports[`execute-template-query should execute a template query with filters and { "content": [ { - "text": "{"queryExpression":"Umbraco.ContentAtRoot().FirstOrDefault()\\n .Children()\\n .Where(x => x.IsVisible())\\n .OrderByDescending(x => x.createDate)\\n .Take(10)","sampleResults":[{"icon":"icon-search color-blue","name":"Search"},{"icon":"icon-document color-blue","name":"Features"},{"icon":"icon-message color-blue","name":"Contact"},{"icon":"icon-thumbnail-list color-blue","name":"Blog"},{"icon":"icon-users color-blue","name":"Authors"},{"icon":"icon-document color-blue","name":"About"}],"resultCount":6,"executionTime":0}", + "text": "{"queryExpression":"Umbraco.ContentAtRoot().FirstOrDefault()\\n .Children()\\n .Where(x => x.IsVisible())\\n .OrderByDescending(x => x.createDate)\\n .Take(10)","sampleResults":[],"resultCount":0,"executionTime":0}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/temporary-file/__tests__/__snapshots__/create-temporary-file.test.ts.snap b/src/umb-management-api/tools/temporary-file/__tests__/__snapshots__/create-temporary-file.test.ts.snap index 931cd31..171eeac 100644 --- a/src/umb-management-api/tools/temporary-file/__tests__/__snapshots__/create-temporary-file.test.ts.snap +++ b/src/umb-management-api/tools/temporary-file/__tests__/__snapshots__/create-temporary-file.test.ts.snap @@ -21,21 +21,11 @@ exports[`create-temporary-file should create a temporary file 2`] = ` ] `; -exports[`create-temporary-file should handle file not found 1`] = ` +exports[`create-temporary-file should handle empty base64 1`] = ` { "content": [ { - "text": "Error using create-temporary-file: -{ - "message": "ENOENT: no such file or directory, open ", - "cause": { - "errno": -2, - "code": "ENOENT", - "syscall": "open", - "path": "" - }, - "path": "" -}", + "text": "{"id":"00000000-0000-0000-0000-000000000000"}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/temporary-file/__tests__/create-temporary-file.test.ts b/src/umb-management-api/tools/temporary-file/__tests__/create-temporary-file.test.ts index 5ff900b..13091ad 100644 --- a/src/umb-management-api/tools/temporary-file/__tests__/create-temporary-file.test.ts +++ b/src/umb-management-api/tools/temporary-file/__tests__/create-temporary-file.test.ts @@ -2,7 +2,7 @@ import { createSnapshotResult } from "@/test-helpers/create-snapshot-result.js"; import CreateTemporaryFileTool from "../post/create-temporary-file.js"; import { TemporaryFileTestHelper } from "./helpers/temporary-file-helper.js"; import { jest } from "@jest/globals"; -import { createReadStream } from "fs"; +import { readFileSync } from "fs"; import { join } from "path"; import { v4 as uuidv4 } from "uuid"; import { EXAMPLE_IMAGE_PATH } from "@/constants/constants.js"; @@ -23,14 +23,16 @@ describe("create-temporary-file", () => { }); it("should create a temporary file", async () => { - const fileStream = createReadStream( + const fileBuffer = readFileSync( join(process.cwd(), EXAMPLE_IMAGE_PATH) ); + const fileAsBase64 = fileBuffer.toString('base64'); const result = await CreateTemporaryFileTool().handler( { - Id: testId, - File: fileStream, + id: testId, + fileName: "example.jpg", + fileAsBase64: fileAsBase64, }, { signal: new AbortController().signal } ); @@ -40,21 +42,22 @@ describe("create-temporary-file", () => { const items = await TemporaryFileTestHelper.findTemporaryFiles(testId); items[0].id = "NORMALIZED_ID"; items[0].availableUntil = "NORMALIZED_DATE"; + items[0].fileName = "example.jpg"; // Normalize the UUID prefix from temp file expect(items).toMatchSnapshot(); }); - it("should handle file not found", async () => { + it("should handle empty base64", async () => { const result = await CreateTemporaryFileTool().handler( { - Id: "test-id", - File: createReadStream("nonexistent.jpg"), + id: testId, + fileName: "test.jpg", + fileAsBase64: "", }, { signal: new AbortController().signal } ); - // Normalize the error code in the text, different OS's have different error codes - result.content[0].text = (result.content[0].text as string).replace('"errno": -4058', '"errno": -2'); - - expect(TemporaryFileTestHelper.cleanFilePaths(result)).toMatchSnapshot(); + // Empty base64 creates an empty file, which should succeed + // The API will accept it even though it's a 0-byte file + expect(createSnapshotResult(result, testId)).toMatchSnapshot(); }); }); diff --git a/src/umb-management-api/tools/temporary-file/post/create-temporary-file.ts b/src/umb-management-api/tools/temporary-file/post/create-temporary-file.ts index 6b24832..cc5f525 100644 --- a/src/umb-management-api/tools/temporary-file/post/create-temporary-file.ts +++ b/src/umb-management-api/tools/temporary-file/post/create-temporary-file.ts @@ -1,30 +1,77 @@ import { UmbracoManagementClient } from "@umb-management-client"; import { CreateUmbracoTool } from "@/helpers/mcp/create-umbraco-tool.js"; -import { PostTemporaryFileBody } from "@/umb-management-api/temporary-file/schemas/index.js"; -import { postTemporaryFileBody } from "@/umb-management-api/temporary-file/types.zod.js"; +import { z } from "zod"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +// MCP-friendly schema that accepts base64 encoded file data +const createTemporaryFileSchema = z.object({ + id: z.string().uuid().describe("Unique identifier for the temporary file"), + fileName: z.string().describe("Name of the file"), + fileAsBase64: z.string().describe("File content encoded as base64 string"), +}); + +type CreateTemporaryFileParams = z.infer; const CreateTemporaryFileTool = CreateUmbracoTool( "create-temporary-file", - `Creates a new temporary file. The file will be deleted after 10 minutes. - The file must be updated as a stream. + `Creates a new temporary file. The file will be deleted after 10 minutes. The temporary file id is used when uploading media files to Umbraco. The process is as follows: - - Create a temporary fileusing this endpoint + - Create a temporary file using this endpoint - Use the temporary file id when creating a media item using the media post endpoint - `, - postTemporaryFileBody.shape, - async (model: PostTemporaryFileBody) => { - const client = UmbracoManagementClient.getClient(); - await client.postTemporaryFile(model); - - return { - content: [ - { - type: "text" as const, - text: JSON.stringify({ id: model.Id }), - }, - ], - }; + + Provide the file content as a base64 encoded string.`, + createTemporaryFileSchema.shape, + async (model: CreateTemporaryFileParams) => { + let tempFilePath: string | null = null; + + try { + // Convert base64 to Buffer + const fileContent = Buffer.from(model.fileAsBase64, 'base64'); + + // Write to temp file (required for fs.ReadStream which the API client needs) + tempFilePath = path.join(os.tmpdir(), `umbraco-upload-${model.id}-${model.fileName}`); + fs.writeFileSync(tempFilePath, fileContent); + + // Create ReadStream for Umbraco API + const readStream = fs.createReadStream(tempFilePath); + + const client = UmbracoManagementClient.getClient(); + await client.postTemporaryFile({ + Id: model.id, + File: readStream, + }); + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ id: model.id }), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `Error creating temporary file: ${(error as Error).message}`, + }, + ], + isError: true, + }; + } finally { + // Cleanup temp file + if (tempFilePath && fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch (e) { + console.error('Failed to cleanup temp file:', e); + } + } + } } ); diff --git a/src/umb-management-api/tools/tool-factory.ts b/src/umb-management-api/tools/tool-factory.ts index 6f7e273..d84ecb0 100644 --- a/src/umb-management-api/tools/tool-factory.ts +++ b/src/umb-management-api/tools/tool-factory.ts @@ -43,7 +43,7 @@ import { ToolDefinition } from "types/tool-definition.js"; import { ToolCollectionExport } from "types/tool-collection.js"; import { CollectionConfigLoader } from "@/helpers/config/collection-config-loader.js"; import { CollectionConfiguration } from "../../types/collection-configuration.js"; -import env from "@/helpers/config/env.js"; +import type { UmbracoServerConfig } from "../../config.js"; // Available collections (converted to new format) const availableCollections: ToolCollectionExport[] = [ @@ -85,19 +85,22 @@ const availableCollections: ToolCollectionExport[] = [ StaticFileCollection ]; -// Enhanced mapTools with collection filtering (existing function signature) -const mapTools = (server: McpServer, +// Enhanced mapTools with collection filtering +const mapTools = ( + server: McpServer, user: CurrentUserResponseModel, - tools: ToolDefinition[]) => { + tools: ToolDefinition[], + config: CollectionConfiguration +) => { return tools.forEach(tool => { // Check if user has permission for this tool const userHasPermission = (tool.enabled === undefined || tool.enabled(user)); if (!userHasPermission) return; - - // Apply existing tool-level filtering (preserves current behavior) - if (env.UMBRACO_EXCLUDE_TOOLS?.includes(tool.name)) return; - if (env.UMBRACO_INCLUDE_TOOLS?.length && !env.UMBRACO_INCLUDE_TOOLS.includes(tool.name)) return; - + + // Apply tool-level filtering from configuration + if (config.disabledTools?.includes(tool.name)) return; + if (config.enabledTools?.length && !config.enabledTools.includes(tool.name)) return; + // Register the tool server.tool(tool.name, tool.description, tool.schema, tool.handler); }) @@ -165,19 +168,19 @@ function getEnabledCollections(config: CollectionConfiguration): ToolCollectionE ); } -export function UmbracoToolFactory(server: McpServer, user: CurrentUserResponseModel) { - // Load collection configuration - const config = CollectionConfigLoader.loadFromEnv(); - +export function UmbracoToolFactory(server: McpServer, user: CurrentUserResponseModel, serverConfig: UmbracoServerConfig) { + // Load collection configuration from server config + const config = CollectionConfigLoader.loadFromConfig(serverConfig); + // Validate configuration validateConfiguration(config, availableCollections); - + // Get enabled collections based on configuration const enabledCollections = getEnabledCollections(config); - + // Load tools from enabled collections only enabledCollections.forEach(collection => { const tools = collection.tools(user); - mapTools(server, user, tools); + mapTools(server, user, tools, config); }); } diff --git a/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user-current.test.ts.snap b/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user-current.test.ts.snap index c7b66f9..6ffbc0b 100644 --- a/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user-current.test.ts.snap +++ b/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user-current.test.ts.snap @@ -4,7 +4,7 @@ exports[`get-user-current should get current authenticated user information 1`] { "content": [ { - "text": "{"id":"00000000-0000-0000-0000-000000000000","languageIsoCode":"en-US","documentStartNodeIds":[],"hasDocumentRootAccess":true,"mediaStartNodeIds":[],"hasMediaRootAccess":true,"avatarUrls":[],"languages":[],"hasAccessToAllLanguages":true,"hasAccessToSensitiveData":false,"fallbackPermissions":["Umb.Document.Create","Umb.Document.Update","Umb.Document.Delete","Umb.Document.Move","Umb.Document.Duplicate","Umb.Document.Sort","Umb.Document.Rollback","Umb.Document.PublicAccess","Umb.Document.CultureAndHostnames","Umb.Document.Publish","Umb.Document.Permissions","Umb.Document.Unpublish","Umb.Document.Read","Umb.Document.CreateBlueprint","Umb.Document.Notifications",":","5","7","T","Umb.Document.PropertyValue.Read","Umb.Document.PropertyValue.Write","Workflow.ReleaseSet.Create","Workflow.ReleaseSet.Read","Workflow.ReleaseSet.Update","Workflow.ReleaseSet.Delete","Workflow.ReleaseSet.Publish","Workflow.AlternateVersion.Create","Workflow.AlternateVersion.Read","Workflow.AlternateVersion.Update","Workflow.AlternateVersion.Delete","Workflow.AlternateVersion.Publish"],"permissions":[],"allowedSections":["Umb.Section.Content","Umb.Section.Forms","Umb.Section.Media","Umb.Section.Members","Umb.Section.Packages","Umb.Section.Settings","Umb.Section.Translation","Umb.Section.Workflow","Umb.Section.Users"],"isAdmin":true,"email":"mcp@admin.com","userName":"mcp@admin.com","name":"MCP User","userGroupIds":[{"id":"e5e7f6c8-7f9c-4b5b-8d5d-9e1e5a4f7e4d"}]}", + "text": "{"id":"00000000-0000-0000-0000-000000000000","languageIsoCode":"en-US","documentStartNodeIds":[],"hasDocumentRootAccess":true,"mediaStartNodeIds":[],"hasMediaRootAccess":true,"avatarUrls":["http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=30&height=30","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=60&height=60","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=90&height=90","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=150&height=150","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=300&height=300"],"languages":[],"hasAccessToAllLanguages":true,"hasAccessToSensitiveData":false,"fallbackPermissions":["Umb.Document.Create","Umb.Document.Update","Umb.Document.Delete","Umb.Document.Move","Umb.Document.Duplicate","Umb.Document.Sort","Umb.Document.Rollback","Umb.Document.PublicAccess","Umb.Document.CultureAndHostnames","Umb.Document.Publish","Umb.Document.Permissions","Umb.Document.Unpublish","Umb.Document.Read","Umb.Document.CreateBlueprint","Umb.Document.Notifications",":","5","7","T","Umb.Document.PropertyValue.Read","Umb.Document.PropertyValue.Write","Workflow.ReleaseSet.Create","Workflow.ReleaseSet.Read","Workflow.ReleaseSet.Update","Workflow.ReleaseSet.Delete","Workflow.ReleaseSet.Publish","Workflow.AlternateVersion.Create","Workflow.AlternateVersion.Read","Workflow.AlternateVersion.Update","Workflow.AlternateVersion.Delete","Workflow.AlternateVersion.Publish"],"permissions":[],"allowedSections":["Umb.Section.Content","Umb.Section.Forms","Umb.Section.Media","Umb.Section.Members","Umb.Section.Packages","Umb.Section.Settings","Umb.Section.Translation","Umb.Section.Workflow","Umb.Section.Users"],"isAdmin":true,"email":"a@a.co.uk","userName":"a@a.co.uk","name":"MCP User","userGroupIds":[{"id":"e5e7f6c8-7f9c-4b5b-8d5d-9e1e5a4f7e4d"}]}", "type": "text", }, ], diff --git a/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user.test.ts.snap b/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user.test.ts.snap index a192896..233eea6 100644 --- a/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user.test.ts.snap +++ b/src/umb-management-api/tools/user/__tests__/__snapshots__/get-user.test.ts.snap @@ -4,7 +4,7 @@ exports[`get-user should get users list with default parameters 1`] = ` { "content": [ { - "text": "{"total":1,"items":[{"id":"00000000-0000-0000-0000-000000000000","languageIsoCode":"en-US","documentStartNodeIds":[],"hasDocumentRootAccess":false,"mediaStartNodeIds":[],"hasMediaRootAccess":false,"avatarUrls":[],"state":"Active","failedLoginAttempts":0,"createDate":"NORMALIZED_DATE","updateDate":"NORMALIZED_DATE","lastLoginDate":"NORMALIZED_DATE","lastLockoutDate":null,"lastPasswordChangeDate":"NORMALIZED_DATE","isAdmin":true,"kind":"Api","email":"mcp@admin.com","userName":"mcp@admin.com","name":"MCP User","userGroupIds":[{"id":"e5e7f6c8-7f9c-4b5b-8d5d-9e1e5a4f7e4d"}]}]}", + "text": "{"total":1,"items":[{"id":"00000000-0000-0000-0000-000000000000","languageIsoCode":"en-US","documentStartNodeIds":[],"hasDocumentRootAccess":false,"mediaStartNodeIds":[],"hasMediaRootAccess":false,"avatarUrls":["http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=30&height=30","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=60&height=60","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=90&height=90","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=150&height=150","http://localhost:56472/media/UserAvatars/NORMALIZED_AVATAR.jpg?rmode=crop&width=300&height=300"],"state":"Active","failedLoginAttempts":0,"createDate":"NORMALIZED_DATE","updateDate":"NORMALIZED_DATE","lastLoginDate":"NORMALIZED_DATE","lastLockoutDate":null,"lastPasswordChangeDate":"NORMALIZED_DATE","isAdmin":true,"kind":"Api","email":"a@a.co.uk","userName":"a@a.co.uk","name":"MCP User","userGroupIds":[{"id":"e5e7f6c8-7f9c-4b5b-8d5d-9e1e5a4f7e4d"}]}]}", "type": "text", }, ], diff --git a/tests/e2e/create-blog-post/create-blog-post-config.json b/tests/e2e/create-blog-post/create-blog-post-config.json index 717fe96..4da782d 100644 --- a/tests/e2e/create-blog-post/create-blog-post-config.json +++ b/tests/e2e/create-blog-post/create-blog-post-config.json @@ -7,7 +7,7 @@ "UMBRACO_CLIENT_ID": "umbraco-back-office-mcp", "UMBRACO_CLIENT_SECRET": "1234567890", "UMBRACO_BASE_URL": "http://localhost:56472", - "UMBRACO_INCLUDE_TOOLS": "search-document,get-document-by-id,copy-document,publish-document,delete-document,get-document-root,get-document-children,get-document-publish,get-document-type-by-id,get-all-document-types" + "UMBRACO_INCLUDE_TOOLS": "get-document-root,get-document-children,get-document-publish,get-document-by-id,copy-document,publish-document,delete-document,update-document" } } } diff --git a/tests/e2e/create-blog-post/create-blog-post.yaml b/tests/e2e/create-blog-post/create-blog-post.yaml index 0377f11..bdde703 100644 --- a/tests/e2e/create-blog-post/create-blog-post.yaml +++ b/tests/e2e/create-blog-post/create-blog-post.yaml @@ -9,30 +9,31 @@ evals: - name: "Create and manage blog post workflow" prompt: | Complete these tasks in order: - 1. Copy an existing blog post document and create a new blog post document with the following details: + 1. Get the root document of umbraco + 2. Find the Blogs document under the root node + 3. Copy an existing blog post document + 4. Update the copied blog post document with the following details: - Title: "_Test Blog Post - Creating Amazing Content" - Content/Body: A rich text content about creating amazing content for blogs - Author: "Paul Seal" - 2. Publish the blog post - 3. Delete the blog post - 4. When successfully completed all tasks, say 'The blog post workflow has completed successfully', nothing else + 5. Publish the blog post + 6. Delete the blog post + 7. When successfully completed all tasks, say 'The blog post workflow has completed successfully', nothing else expected_tool_calls: required: - - "search-document" - "copy-document" - "get-document-by-id" + - "update-document" - "publish-document" - "delete-document" allowed: - - "search-document" - "get-document-root" - "get-document-children" - "get-document-by-id" - "copy-document" - "publish-document" - - "get-document-publish" - "delete-document" - - "get-document-type-by-id" + - "update-document" response_scorers: - type: 'llm-judge' criteria: 'Did the last assistant step say "The blog post workflow has completed successfully"' From 347c701f4448687c665bd41bd917e7c7f3af7db3 Mon Sep 17 00:00:00 2001 From: hifi-phil Date: Mon, 6 Oct 2025 08:57:34 +0100 Subject: [PATCH 2/3] Release/16.0.0 beta.2 (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add .env file support for MCP server configuration - Centralize configuration management in new config.ts module - Load environment variables from .env file (default) or custom path via --env flag - Support CLI argument overrides for all configuration options (--umbraco-*) - Track configuration sources (CLI vs ENV) for transparency - Add comprehensive validation and error reporting for missing credentials - Update documentation with .env usage examples and .mcp.json configuration - Refactor tests to use centralized config system - Remove deprecated env.ts helper - Improve multi-culture document test with proper setup and cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Improve copy-document tool with flattened schema and clearer workflow (#30) - Flatten tool parameter schema for better LLM usability - Replace nested `id` + `data` structure with top-level parameters - Use `idToCopy` instead of `id` for clarity - Move `relateToOriginal` and `includeDescendants` to top level - Make `parentId` optional (omit for root, provide for specific parent) - Add comprehensive tool description with workflow examples - Document the empty string return value behavior - Provide clear copy-only vs copy-and-update workflow patterns - Explain search-document requirement for post-copy operations - Update e2e test to use update-document instead of search - Simplify workflow: copy → update → publish → delete - Remove unnecessary search-document and document-type lookups - Update allowed tools list to match actual workflow - Pin mcp-server-tester to version 1.4.0 for consistency - Update copy-document unit tests to match new schema 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Phil Whittaker Co-authored-by: Claude * Add universal media upload tools with URL and base64 support (#31) * Update create-temporary-file to accept base64 encoded data - Changed schema from ReadStream to base64 string input for MCP compatibility - Converts base64 → Buffer → temp file → ReadStream for Umbraco API - Uses os.tmpdir() for temporary file storage - Automatic cleanup of temp files in finally block - Updated tests to use base64 encoding - All tests passing (11/11) This makes the tool compatible with LLM/MCP usage where files are provided as base64 strings rather than file system streams. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add editorAlias and entityType to media value objects - Updated media-builder to include editorAlias and entityType in image values - Fixed focalPoint to use correct properties (left, top) - Changed temporaryFileId property name (was temporaryFilId) - Added documentation to create-media tool with complete example - Documented API quirk in docs/comments.md - Added experimental test-file-format tool for testing file upload formats These fields are required by the Umbraco API but not documented in the OpenAPI spec. Without them, media items are created but files are not properly uploaded/attached. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Add universal media upload tools with URL and base64 support - Add create-media tool supporting filePath, URL, and base64 sources - Add create-media-multiple tool for batch uploads (max 20 files) - Implement automatic MIME type detection using mime-types library - Add comprehensive media upload helpers with proper error handling - Fix extension handling: only add to temp files, not media item names - Add test infrastructure including builders and helpers - Add integration tests with snapshot testing - Support all media types: Image, File, Video, Audio, SVG, etc. Technical improvements: - Use mime-types library for robust MIME type to extension mapping - Proper temp file cleanup after uploads - SVG media type auto-correction (Image → Vector Graphic) - Continue-on-error strategy for batch uploads - Comprehensive test coverage with proper cleanup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove obsolete TEST_FILE_FORMAT_README.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Remove obsolete test-file-format.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Phil Whittaker Co-authored-by: Claude * Update package.json --------- Co-authored-by: Phil Whittaker Co-authored-by: Claude --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1401d20..da25e46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@umbraco-cms/mcp-dev", - "version": "16.0.0-beta.1", + "version": "16.0.0-beta.2", "type": "module", "description": "A model context protocol (MCP) server for Umbraco CMS", "main": "index.js", From 6a44ef92752959956cdf3046cb39e9b55ddf7344 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:56:33 +0000 Subject: [PATCH 3/3] Bump validator from 13.15.0 to 13.15.20 Bumps [validator](https://github.com/validatorjs/validator.js) from 13.15.0 to 13.15.20. - [Release notes](https://github.com/validatorjs/validator.js/releases) - [Changelog](https://github.com/validatorjs/validator.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/validatorjs/validator.js/compare/13.15.0...13.15.20) --- updated-dependencies: - dependency-name: validator dependency-version: 13.15.20 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbdeb78..4135533 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@umbraco-cms/mcp-dev", - "version": "16.0.0-beta.1", + "version": "16.0.0-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@umbraco-cms/mcp-dev", - "version": "16.0.0-beta.1", + "version": "16.0.0-beta.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.9.0", @@ -10029,9 +10029,9 @@ } }, "node_modules/validator": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", - "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "version": "13.15.20", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz", + "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==", "dev": true, "license": "MIT", "engines": {