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..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",
@@ -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 0000000..da42939
Binary files /dev/null and b/src/umb-management-api/tools/media/__tests__/test-files/example.pdf differ
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 @@
+
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"'