# Directory Structure ``` ├── .github │ ├── actions │ │ └── setup │ │ └── action.yml │ └── workflows │ └── release.yml ├── .gitignore ├── .npmignore ├── cld-mcp-server.png ├── cli.js ├── glama.json ├── LICENSE.md ├── package.json ├── pnpm-lock.yaml ├── README.md ├── src │ ├── index.js │ ├── tools │ │ ├── deleteAssetTool.js │ │ ├── deleteAssetTool.test.js │ │ ├── findAssetsTool.js │ │ ├── findAssetsTool.test.js │ │ ├── getAssetTool.js │ │ ├── getAssetTool.test.js │ │ ├── getCloudinaryTool.js │ │ ├── getUsageTool.js │ │ ├── getUsageTool.test.js │ │ ├── uploadTool.js │ │ └── uploadTool.test.js │ └── utils.js ├── test │ ├── cloudinary.mock.js │ └── vitest-setup.js └── vitest.config.js ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | llm-docs/ 2 | .idea/ 3 | node_modules/ 4 | .env 5 | dist/ 6 | coverage/ 7 | *.log 8 | test/server.test.js 9 | test/test.png 10 | test/cld.js 11 | ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` 1 | .idea/ 2 | .vscode/ 3 | .github/ 4 | src/ 5 | *.story.js 6 | *.stories.js 7 | stories/ 8 | *.test.js 9 | tests/ 10 | test/ 11 | mocks/ 12 | *.log 13 | jest* 14 | .editorconfig 15 | .eslintignore 16 | .eslintrc.js 17 | .circleci/ 18 | .sb-static/ 19 | .env 20 | .jest-cache/ 21 | .jest-coverage/ 22 | scripts/ 23 | .storybook/ 24 | tsconfig.json 25 | .eslintcache 26 | cypress/ 27 | rollup.config.js 28 | babel.config.js 29 | cypress.json 30 | 31 | .nyc_output/ 32 | coverage/ 33 | reports/ 34 | .reporters-config.json 35 | .nycrc 36 | 37 | test-setup.js 38 | codecov 39 | codecov.yml 40 | .mocharc.js 41 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | <img src="https://github.com/yoavniran/cloudinary-mcp-server/blob/main/cld-mcp-server.png?raw=true" width="120" height="120" align="center" /> 2 | 3 | # Cloudinary MCP Server 4 | 5 | <a href="https://glama.ai/mcp/servers/@yoavniran/cloudinary-mcp-server"> 6 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@yoavniran/cloudinary-mcp-server/badge" alt="cloudinary-mcp-server MCP server" /> 7 | </a> 8 | 9 | <p align="center"> 10 | <a href="https://badge.fury.io/js/cloudinary-mcp-server"> 11 | <img src="https://badge.fury.io/js/cloudinary-mcp-server.svg" alt="npm version" height="20"> 12 | </a> 13 | </p> 14 | 15 | A Model Context Protocol server that exposes Cloudinary Upload & Admin API methods as tools by AI assistants. 16 | This integration allows AI systems to trigger and interact with your Cloudinary cloud. 17 | 18 | ## How It Works 19 | 20 | The MCP server: 21 | 22 | - Makes calls on your behalf to the Cloudinary API 23 | - Enables uploading of assets to Cloudinary 24 | - Enables management of assets in your Cloudinary cloud 25 | 26 | It relies on the Cloudinary [API](https://cloudinary.com/documentation/admin_api) to perform these actions. Not all methods and parameters are supported. 27 | More will be added over time. 28 | 29 | Open an [issue](https://github.com/yoavniran/cloudinary-mcp-server/issues) with a request for specific method if you need it. 30 | 31 | ## Benefits 32 | 33 | - Turn your Cloudinary cloud actions into callable tools for AI assistants 34 | - Turn your Cloudinary assets into data for AI assistants 35 | 36 | ## Usage with Claude Desktop 37 | 38 | ### Prerequisites 39 | 40 | - NodeJS 41 | - MCP Client (like Claude Desktop App) 42 | - Create & Copy Cloudinary API Key/Secret at: [API KEYS](https://console.cloudinary.com/settings/api-keys) 43 | 44 | ### Installation 45 | 46 | To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your `claude_desktop_config.json`: 47 | 48 | ```json 49 | { 50 | "mcpServers": { 51 | "cloudinary-mcp-server": { 52 | "command": "npx", 53 | "args": ["-y", "cloudinary-mcp-server"], 54 | "env": { 55 | "CLOUDINARY_CLOUD_NAME": "<cloud name>", 56 | "CLOUDINARY_API_KEY": "<api-key>", 57 | "CLOUDINARY_API_SECRET": "<api-secret>" 58 | } 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | - `CLOUDINARY_CLOUD_NAME` - your cloud name 65 | - `CLOUDINARY_API_KEY` - The API Key for your cloud 66 | - `CLOUDINARY_API_SECRET` - The API Secret for your cloud 67 | 68 | 69 | ### Tools 70 | 71 | The following tools are available: 72 | 73 | 1. **upload** 74 | - Description: Upload a file (asset) to Cloudinary 75 | - Parameters: 76 | - `source`: URL, file path, base64 content, or binary data to upload 77 | - `folder`: Optional folder path in Cloudinary 78 | - `publicId`: Optional public ID for the uploaded asset 79 | - `resourceType`: Type of resource to upload (image, video, raw, auto) 80 | - `tags`: Comma-separated list of tags to assign to the asset 81 | 82 | 2. **delete-asset** 83 | - Description: Delete a file (asset) from Cloudinary 84 | - Parameters: 85 | - `publicId`: The public ID of the asset to delete 86 | - `assetId`: The asset ID of the asset to delete 87 | 88 | 3. **get-asset** 89 | - Description: Get the details of a specific file (asset) 90 | - Parameters: 91 | - `assetId`: The Cloudinary asset ID 92 | - `publicId`: The public ID of the asset 93 | - `resourceType`: Type of asset (image, raw, video) 94 | - `type`: Delivery type (upload, private, authenticated, etc.) 95 | - `tags`: Whether to include the list of tag names 96 | - `context`: Whether to include contextual metadata 97 | - `metadata`: Whether to include structured metadata 98 | 99 | 4. **find-assets** 100 | - Description: Search for existing files (assets) in Cloudinary with a query expression 101 | - Parameters: 102 | - `expression`: Search expression (e.g. 'tags=cat' or 'public_id:folder/*') 103 | - `resourceType`: Resource type (image, video, raw) 104 | - `maxResults`: Maximum number of results (1-500) 105 | - `nextCursor`: Next cursor for pagination 106 | - `tags`: Include tags in the response 107 | - `context`: Include context in the response 108 | 109 | 5. **get-usage** 110 | - Description: Get a report on the status of your product environment usage, including storage, credits, bandwidth, requests, number of resources, and add-on usage 111 | - Parameters: 112 | - `date`: Optional. The date for the usage report in the format: yyyy-mm-dd. Must be within the last 3 months. Default: the current date ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- ```markdown 1 | MIT License 2 | 3 | Copyright (c) 2025 Yoav Niran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | ``` -------------------------------------------------------------------------------- /glama.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://glama.ai/mcp/schemas/server.json", 3 | "maintainers": [ 4 | "yoavniran" 5 | ] 6 | } 7 | ``` -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- ```javascript 1 | #!/usr/bin/env node 2 | 3 | import("./src/index.js").catch(err => { 4 | console.error("Failed to start server:", err); 5 | process.exit(1); 6 | }); 7 | ``` -------------------------------------------------------------------------------- /src/tools/getCloudinaryTool.js: -------------------------------------------------------------------------------- ```javascript 1 | const getCloudinaryTool = (tool) => { 2 | return (cloudinary) => (params) => tool(cloudinary, params); 3 | }; 4 | 5 | export default getCloudinaryTool; 6 | ``` -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- ```javascript 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | setupFiles: "./test/vitest-setup.js", 7 | include: ["src/**/*.test.js?(x)"], 8 | }, 9 | }); 10 | ``` -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- ```javascript 1 | export const getToolError = (msg, cloudinary) => { 2 | const conf = cloudinary.config() 3 | return { 4 | content: [{ 5 | type: "text", 6 | text: `${msg} (cloud: ${conf.cloud_name}, key: ${conf.api_key.slice(0,4)}...)`, 7 | }], 8 | isError: true, 9 | }; 10 | }; 11 | ``` -------------------------------------------------------------------------------- /test/vitest-setup.js: -------------------------------------------------------------------------------- ```javascript 1 | import { afterEach } from "vitest"; 2 | 3 | // runs a cleanup after each test case (e.g. clearing jsdom) 4 | afterEach(() => { 5 | }); 6 | 7 | global.clearViMocks = (...mocks) => { 8 | mocks.forEach((mock) => { 9 | if (mock) { 10 | if (mock.mockClear) { 11 | mock.mockClear(); 12 | } else { 13 | global.clearViMocks(...Object.values(mock)); 14 | } 15 | } 16 | }); 17 | }; 18 | ``` -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Setup & install 2 | description: install pnpm & deps 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - uses: pnpm/action-setup@v4 8 | with: 9 | version: 9 10 | 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: "20.15" 14 | cache: "pnpm" 15 | cache-dependency-path: "./pnpm-lock.yaml" 16 | 17 | - run: pnpm install --frozen-lockfile 18 | shell: bash 19 | ``` -------------------------------------------------------------------------------- /test/cloudinary.mock.js: -------------------------------------------------------------------------------- ```javascript 1 | export const createCloudinaryMock = () => { 2 | return { 3 | config: () => ({ 4 | cloud_name: "test-cloud", 5 | api_key: "test-key", 6 | api_secret: "test-secret" 7 | }), 8 | uploader: { 9 | upload: vi.fn(), 10 | upload_stream: vi.fn() 11 | }, 12 | utils: { 13 | api_sign_request: vi.fn() 14 | }, 15 | api: { 16 | resource: vi.fn(), 17 | resources_by_asset_ids: vi.fn(), 18 | delete_resources: vi.fn(), 19 | search: vi.fn(), 20 | usage: vi.fn(), // Added usage method to the mock 21 | }, 22 | }; 23 | }; 24 | ``` -------------------------------------------------------------------------------- /src/tools/getUsageTool.js: -------------------------------------------------------------------------------- ```javascript 1 | import { z } from "zod"; 2 | import { getToolError } from "../utils.js"; 3 | import getCloudinaryTool from "./getCloudinaryTool.js"; 4 | 5 | export const getUsageToolParams = { 6 | date: z.string().optional().describe("The date for the usage report. Must be within the last 3 months and specified in the format: yyyy-mm-dd. Default: the current date") 7 | } 8 | 9 | const getUsageTool = async (cloudinary, { date }) => { 10 | try { 11 | const usageOptions = { 12 | date 13 | }; 14 | 15 | const usageResult = await cloudinary.api.usage(usageOptions); 16 | 17 | return { 18 | content: [ 19 | { 20 | type: "text", 21 | text: JSON.stringify(usageResult, null, 2) 22 | } 23 | ], 24 | isError: false, 25 | }; 26 | } catch (error) { 27 | return getToolError(`Error getting usage report from Cloudinary: ${error.message}`, cloudinary); 28 | } 29 | }; 30 | 31 | export default getCloudinaryTool(getUsageTool); 32 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "cloudinary-mcp-server", 3 | "version": "0.4.0", 4 | "description": "MCP server for Cloudinary's APIs", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "vitest --run", 9 | "inspect": "npx @modelcontextprotocol/inspector node src/index.js", 10 | "inspect-prod": "npx @modelcontextprotocol/inspector npx -y cloudinary-mcp-server" 11 | }, 12 | "author": { 13 | "name": "Yoav Niran", 14 | "url": "https://linkedin.com/in/yoavniran/" 15 | }, 16 | "bin": { 17 | "cloudinary-mcp-server": "./cli.js" 18 | }, 19 | "files": [ 20 | "LICENSE.md", 21 | "README.md", 22 | "src/", 23 | "package.json" 24 | ], 25 | "dependencies": { 26 | "@modelcontextprotocol/sdk": "latest", 27 | "cloudinary": "^1.41.3", 28 | "dotenv": "^16.4.7", 29 | "zod": "^3.24.2" 30 | }, 31 | "devDependencies": { 32 | "@testing-library/jest-dom": "^6.6.3", 33 | "chai": "^5.2.0", 34 | "release-it": "^18.1.2", 35 | "vitest": "^3.0.9" 36 | }, 37 | "private": false, 38 | "engines": { 39 | "node": ">=18.3.0" 40 | }, 41 | "keywords": [ 42 | "mcp", 43 | "mcp-server", 44 | "cloudinary" 45 | ] 46 | } 47 | ``` -------------------------------------------------------------------------------- /src/tools/uploadTool.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createCloudinaryMock } from "../../test/cloudinary.mock"; 2 | import getUploadTool from "./uploadTool.js"; 3 | 4 | describe("uploadTool", () => { 5 | let cloudinaryMock; 6 | let uploadTool; 7 | 8 | beforeEach(() => { 9 | cloudinaryMock = createCloudinaryMock(); 10 | uploadTool = getUploadTool(cloudinaryMock); 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("should upload from URL successfully", async () => { 15 | const mockResult = { public_id: "test123", secure_url: "https://res.cloudinary.com/test" }; 16 | cloudinaryMock.uploader.upload.mockResolvedValue(mockResult); 17 | 18 | const result = await uploadTool({ 19 | source: "https://example.com/image.jpg", 20 | resourceType: "image", 21 | }); 22 | 23 | expect(cloudinaryMock.uploader.upload).toHaveBeenCalledWith("https://example.com/image.jpg", 24 | expect.objectContaining({ resource_type: "image" })); 25 | expect(result.content[0].text).toContain("test123"); 26 | expect(result.isError).toBe(false); 27 | }); 28 | 29 | it("should handle errors during upload", async () => { 30 | const err = new Error("Upload failed") 31 | cloudinaryMock.uploader.upload.mockRejectedValue(err); 32 | 33 | const result = await uploadTool({ 34 | source: "https://example.com/image.jpg", 35 | resourceType: "image", 36 | }); 37 | 38 | expect(result.content[0].text).toContain("Error uploading to Cloudinary: Upload failed"); 39 | expect(result.isError).toBe(true); 40 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 41 | }); 42 | }); 43 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Create NPM Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | releaseType: 7 | type: choice 8 | description: 'Version Type' 9 | required: true 10 | default: 'patch' 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | dry: 16 | type: boolean 17 | description: 'Is Dry Run?' 18 | required: false 19 | default: false 20 | 21 | permissions: 22 | contents: write 23 | 24 | defaults: 25 | run: 26 | shell: bash 27 | 28 | jobs: 29 | release: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | - name: Define GIT author 36 | run: | 37 | git config user.email "ci@cld-mcp-server" 38 | git config user.name "CLD MCP-SERVER CI" 39 | 40 | - name: Set NPM Auth 41 | env: 42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | run: | 44 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 45 | 46 | - uses: ./.github/actions/setup 47 | 48 | - run: pnpm test 49 | 50 | - run: | 51 | git status 52 | 53 | - name: Release 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | run: | 57 | pnpm release-it ${{ inputs.releaseType }} ${{ inputs.dry && ' --dry-run' || '' }} 58 | ``` -------------------------------------------------------------------------------- /src/tools/findAssetsTool.js: -------------------------------------------------------------------------------- ```javascript 1 | import { z } from "zod"; 2 | import { getToolError } from "../utils.js"; 3 | import getCloudinaryTool from "./getCloudinaryTool.js"; 4 | 5 | export const findAssetsToolParams = { 6 | expression: z.string().optional().describe("Search expression (e.g. 'tags=cat' or 'public_id:folder/*')"), 7 | resourceType: z.enum(["image", "video", "raw"]).default("image").describe("Resource type"), 8 | maxResults: z.number().min(1).max(500).default(10).describe("Maximum number of results"), 9 | nextCursor: z.string().optional().describe("Next cursor for pagination"), 10 | tags: z.boolean().optional().describe("Include tags in the response"), 11 | context: z.boolean().optional().describe("Include context in the response"), 12 | }; 13 | 14 | const findAssetsTool = async (cloudinary, { 15 | expression, 16 | resourceType, 17 | maxResults, 18 | nextCursor, 19 | tags, 20 | context, 21 | }) => { 22 | try { 23 | const options = { 24 | expression, 25 | resource_type: resourceType, 26 | max_results: maxResults, 27 | next_cursor: nextCursor, 28 | tags, 29 | context, 30 | }; 31 | 32 | const result = await cloudinary.api.search(options); 33 | 34 | if (!result?.total_count) { 35 | return { 36 | content: [ 37 | { 38 | type: "text", 39 | text: "No assets found matching the search criteria", 40 | }, 41 | ], 42 | isError: false, 43 | }; 44 | } 45 | 46 | return { 47 | content: [{ 48 | type: "text", 49 | text: JSON.stringify(result, null, 2), 50 | }], 51 | isError: false, 52 | }; 53 | 54 | } catch (error) { 55 | return getToolError(`Error searching assets: ${error.message}`, cloudinary); 56 | } 57 | }; 58 | 59 | export default getCloudinaryTool(findAssetsTool); 60 | ``` -------------------------------------------------------------------------------- /src/tools/getAssetTool.js: -------------------------------------------------------------------------------- ```javascript 1 | import { z } from "zod"; 2 | import { getToolError } from "../utils.js"; 3 | import getCloudinaryTool from "./getCloudinaryTool.js"; 4 | 5 | export const getAssetToolParams = { 6 | assetId: z.string().optional().describe("The Cloudinary asset ID"), 7 | publicId: z.string().optional().describe("The public ID of the asset"), 8 | resourceType: z.enum(["image", "raw", "video"]).optional().describe("Type of asset. Default: image"), 9 | type: z.enum(["upload", "private", "authenticated", "fetch", "facebook", "twitter", "gravatar", "youtube", "hulu", "vimeo", "animoto", "worldstarhiphop", "dailymotion", "list"]).optional().describe("Delivery type. Default: upload"), 10 | tags: z.boolean().optional().describe("Whether to include the list of tag names. Default: false"), 11 | context: z.boolean().optional().describe("Whether to include contextual metadata. Default: false"), 12 | metadata: z.boolean().optional().describe("Whether to include structured metadata. Default: false"), 13 | }; 14 | 15 | const getAssetTool = async (cloudinary, params) => { 16 | if (!params.assetId && !params.publicId) { 17 | return getToolError("Error: Either assetId or publicId must be provided", cloudinary); 18 | } 19 | 20 | try { 21 | const { assetId, publicId, tags, context, metadata, resourceType, type } = params; 22 | let resource; 23 | 24 | if (publicId) { 25 | resource = await cloudinary.api.resource(publicId, { 26 | resource_type: resourceType, 27 | type: type, 28 | tags, 29 | context, 30 | metadata, 31 | }); 32 | } else { 33 | const result = await cloudinary.api 34 | .resources_by_asset_ids([assetId], { 35 | tags, context, metadata, 36 | }); 37 | 38 | resource = result.resources[0] || null; 39 | } 40 | 41 | return { 42 | content: [{ 43 | type: "text", 44 | text: JSON.stringify(resource, null, 2), 45 | }], 46 | isError: false, 47 | }; 48 | 49 | } catch (error) { 50 | return getToolError(`Error retrieving asset: ${error.message || "unknown error"}`, cloudinary); 51 | } 52 | }; 53 | 54 | export default getCloudinaryTool(getAssetTool); 55 | 56 | ``` -------------------------------------------------------------------------------- /src/tools/uploadTool.js: -------------------------------------------------------------------------------- ```javascript 1 | import { z } from "zod"; 2 | import { getToolError } from "../utils.js"; 3 | import getCloudinaryTool from "./getCloudinaryTool.js"; 4 | 5 | export const uploadToolParams = { 6 | source: z.union([ 7 | z.string().url().describe("URL of the image/video to upload"), 8 | z.string().describe("Base64 encoded file content or file path"), 9 | z.instanceof(Buffer).describe("Binary data to upload") 10 | ]).describe("The source media to upload (URL, file path, base64 content, or binary data)"), 11 | folder: z.string().optional().describe("Optional folder path in Cloudinary"), 12 | publicId: z.string().optional().describe("Optional public ID for the uploaded asset"), 13 | resourceType: z.enum(["image", "video", "raw", "auto"]).default("auto").describe("Type of resource to upload"), 14 | tags: z.string().optional().describe("A string containing Comma-separated list of tags to assign to the asset"), 15 | }; 16 | 17 | const uploadTool = async (cloudinary, { source, folder, publicId, resourceType, tags }) => { 18 | try { 19 | const uploadOptions = { 20 | resource_type: resourceType, 21 | folder, 22 | public_id: publicId, 23 | tags, 24 | }; 25 | 26 | let uploadResult; 27 | 28 | // Handle different source types 29 | if (typeof source === 'string') { 30 | uploadResult = await cloudinary.uploader.upload(source, uploadOptions); 31 | } else if (Buffer.isBuffer(source)) { 32 | // Handle Buffer data 33 | uploadResult = await new Promise((resolve, reject) => { 34 | const uploadStream = cloudinary.uploader.upload_stream( 35 | uploadOptions, 36 | (error, result) => { 37 | if (error) { 38 | return reject(error); 39 | } 40 | resolve(result); 41 | } 42 | ); 43 | uploadStream.end(source); 44 | }); 45 | } else { 46 | throw new Error("unknown source type: " + typeof source); 47 | } 48 | 49 | return { 50 | content: [ 51 | { 52 | type: "text", 53 | text: JSON.stringify(uploadResult, null, 2) 54 | } 55 | ], 56 | isError: false, 57 | }; 58 | } catch (error) { 59 | return getToolError(`Error uploading to Cloudinary: ${error.message}`, cloudinary); 60 | } 61 | } 62 | 63 | export default getCloudinaryTool(uploadTool); 64 | 65 | ``` -------------------------------------------------------------------------------- /src/tools/findAssetsTool.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createCloudinaryMock } from "../../test/cloudinary.mock"; 2 | import getFindAssetsTool from "./findAssetsTool.js"; 3 | 4 | describe("findAssetsTool", () => { 5 | let cloudinaryMock; 6 | let findAssetsTool; 7 | 8 | beforeEach(() => { 9 | cloudinaryMock = createCloudinaryMock(); 10 | findAssetsTool = getFindAssetsTool(cloudinaryMock); 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("should return assets when found", async () => { 15 | const mockResult = { 16 | total_count: 2, 17 | resources: [ 18 | { public_id: "test1", format: "jpg" }, 19 | { public_id: "test2", format: "png" }, 20 | ], 21 | }; 22 | cloudinaryMock.api.search.mockResolvedValue(mockResult); 23 | 24 | const result = await findAssetsTool({ 25 | expression: "tags=cat", 26 | resourceType: "image", 27 | maxResults: 10, 28 | }); 29 | 30 | expect(cloudinaryMock.api.search).toHaveBeenCalledWith({ 31 | expression: "tags=cat", 32 | resource_type: "image", 33 | max_results: 10, 34 | next_cursor: undefined, 35 | tags: undefined, 36 | context: undefined, 37 | }); 38 | expect(result.content[0].text).toContain("test1"); 39 | expect(result.content[0].text).toContain("test2"); 40 | expect(result.isError).toBe(false); 41 | }); 42 | 43 | it("should handle case when no assets are found", async () => { 44 | cloudinaryMock.api.search.mockResolvedValue({ total_count: 0, resources: [] }); 45 | 46 | const result = await findAssetsTool({ 47 | expression: "tags=nonexistent", 48 | resourceType: "image", 49 | maxResults: 10, 50 | }); 51 | 52 | expect(result.content[0].text).toBe("No assets found matching the search criteria"); 53 | expect(result.isError).toBeFalsy(); 54 | }); 55 | 56 | it("should handle API errors", async () => { 57 | cloudinaryMock.api.search.mockRejectedValue(new Error("Invalid search expression")); 58 | 59 | const result = await findAssetsTool({ 60 | expression: "invalid:syntax", 61 | resourceType: "image", 62 | maxResults: 10, 63 | }); 64 | 65 | expect(result.content[0].text).toContain("Error searching assets: Invalid search expression"); 66 | expect(result.isError).toBe(true); 67 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 68 | }); 69 | }); 70 | ``` -------------------------------------------------------------------------------- /src/tools/deleteAssetTool.js: -------------------------------------------------------------------------------- ```javascript 1 | import { z } from "zod"; 2 | import { getToolError } from "../utils.js"; 3 | import getCloudinaryTool from "./getCloudinaryTool.js"; 4 | 5 | export const deleteAssetToolParams = { 6 | publicId: z.string().optional().describe("The public ID of the asset to delete"), 7 | assetId: z.string().optional().describe("The asset ID of the asset to delete") 8 | }; 9 | 10 | const deleteWithAssetId = (assetIds) => { 11 | const config = cloudinary.config(); 12 | 13 | if (!assetIds || !Array.isArray(assetIds) || assetIds.length === 0) { 14 | return Promise.reject(new Error('You must provide an array of asset IDs')); 15 | } 16 | 17 | // Format asset_ids[] parameters according to the API requirements 18 | const formData = new URLSearchParams(); 19 | assetIds.forEach(id => formData.append('asset_ids[]', id)); 20 | 21 | // Build the request URL 22 | const apiUrl = `https://api.cloudinary.com/v1_1/${config.cloud_name}/resources`; 23 | 24 | return fetch(apiUrl, { 25 | method: 'DELETE', 26 | headers: { 27 | 'Authorization': `Basic ${Buffer.from(`${config.api_key}:${config.api_secret}`).toString('base64')}`, 28 | 'Content-Type': 'application/x-www-form-urlencoded' 29 | }, 30 | body: formData.toString() 31 | }); 32 | } 33 | 34 | const deleteAssetTool = async (cloudinary, { publicId, assetId }) => { 35 | try { 36 | let result; 37 | 38 | if (!publicId && !assetId) { 39 | throw new Error(`Must provide either publicId or assetId to delete`); 40 | } 41 | 42 | if (publicId) { 43 | // Delete by public ID using Cloudinary API 44 | result = await cloudinary.api.delete_resources(publicId); 45 | if (!result || result?.deleted[publicId] === "not_found") { 46 | return getToolError(`Failed to delete asset with publicId: '${publicId}' - not_found`, cloudinary); 47 | } 48 | } else { 49 | // Delete by asset ID using /resources endpoint 50 | result = await deleteWithAssetId([assetId]); 51 | 52 | if (!result.ok) { 53 | return getToolError(`Failed to delete asset with assetId: '${assetId}' - ${result.error.message}`, cloudinary); 54 | } 55 | } 56 | 57 | return { 58 | content: [{ 59 | type: "text", 60 | text: `Successfully deleted asset: '${publicId || assetId}'` 61 | }], 62 | isError: false, 63 | }; 64 | } catch (error) { 65 | return getToolError(`Error deleting asset: ${error.message}`, cloudinary); 66 | } 67 | }; 68 | 69 | export default getCloudinaryTool(deleteAssetTool); 70 | ``` -------------------------------------------------------------------------------- /src/tools/getAssetTool.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createCloudinaryMock } from "../../test/cloudinary.mock"; 2 | import getGetAssetTool from "./getAssetTool.js"; 3 | 4 | describe("getAssetTool", () => { 5 | let cloudinaryMock; 6 | let getAssetTool; 7 | 8 | beforeEach(() => { 9 | cloudinaryMock = createCloudinaryMock(); 10 | getAssetTool = getGetAssetTool(cloudinaryMock); 11 | vi.clearAllMocks(); 12 | }); 13 | 14 | it("should get asset by publicId", async () => { 15 | const mockAsset = { public_id: "test123", format: "jpg", url: "http://example.com/test.jpg" }; 16 | cloudinaryMock.api.resource.mockResolvedValue(mockAsset); 17 | 18 | const result = await getAssetTool({ 19 | publicId: "test123", 20 | resourceType: "image", 21 | }); 22 | 23 | expect(cloudinaryMock.api.resource).toHaveBeenCalledWith("test123", 24 | expect.objectContaining({ resource_type: "image" })); 25 | expect(result.content[0].text).toContain("test123"); 26 | expect(result.isError).toBe(false); 27 | }); 28 | 29 | it("should get asset by assetId", async () => { 30 | const mockAsset = { public_id: "test123", asset_id: "asset123" }; 31 | cloudinaryMock.api.resources_by_asset_ids.mockResolvedValue({ 32 | resources: [mockAsset], 33 | }); 34 | 35 | const result = await getAssetTool({ 36 | assetId: "asset123", 37 | }); 38 | 39 | expect(cloudinaryMock.api.resources_by_asset_ids).toHaveBeenCalledWith(["asset123"], expect.any(Object)); 40 | expect(result.content[0].text).toContain("test123"); 41 | expect(result.isError).toBe(false); 42 | }); 43 | 44 | it("should return error if neither publicId nor assetId is provided", async () => { 45 | const result = await getAssetTool({}); 46 | 47 | expect(result.content[0].text).toContain("Either assetId or publicId must be provided"); 48 | expect(result.isError).toBe(true); 49 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 50 | }); 51 | 52 | it("should handle API errors", async () => { 53 | cloudinaryMock.api.resource.mockRejectedValue(new Error("Resource not found")); 54 | 55 | const result = await getAssetTool({ 56 | publicId: "test123", 57 | }); 58 | 59 | expect(result.content[0].text).toContain("Error retrieving asset: Resource not found"); 60 | expect(result.isError).toBe(true); 61 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 62 | }); 63 | }); 64 | ``` -------------------------------------------------------------------------------- /src/tools/deleteAssetTool.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createCloudinaryMock } from "../../test/cloudinary.mock"; 2 | import getDeleteTool from "./deleteAssetTool.js"; 3 | 4 | describe("deleteAssetTool", () => { 5 | let cloudinaryMock; 6 | let deleteTool; 7 | 8 | global.fetch = vi.fn(); 9 | 10 | beforeEach(() => { 11 | cloudinaryMock = createCloudinaryMock(); 12 | deleteTool = getDeleteTool(cloudinaryMock); 13 | vi.clearAllMocks(); 14 | }); 15 | 16 | it("should delete asset by publicId", async () => { 17 | const mockResult = { deleted: { "test123": "deleted" } }; 18 | cloudinaryMock.api.delete_resources.mockResolvedValue(mockResult); 19 | 20 | const result = await deleteTool({ 21 | publicId: "test123", 22 | }); 23 | 24 | expect(cloudinaryMock.api.delete_resources).toHaveBeenCalledWith("test123"); 25 | expect(result.content[0].text).toContain("Successfully deleted asset: 'test123'"); 26 | expect(result.isError).toBe(false); 27 | }); 28 | 29 | it("should return error if deletion fails for nonexistent public ID", async () => { 30 | const mockResult = { deleted: { "test123": "not_found" } }; 31 | cloudinaryMock.api.delete_resources.mockResolvedValue(mockResult); 32 | 33 | const result = await deleteTool({ 34 | publicId: "test123", 35 | }); 36 | 37 | expect(result.content[0].text).toContain("Failed to delete asset with publicId: 'test123'"); 38 | expect(result.isError).toBe(true); 39 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 40 | }); 41 | 42 | it("should return error when neither publicId nor assetId is provided", async () => { 43 | const result = await deleteTool({}); 44 | 45 | expect(result.content[0].text).toContain("Must provide either publicId or assetId to delete"); 46 | expect(result.isError).toBe(true); 47 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 48 | }); 49 | 50 | it("should handle API errors", async () => { 51 | cloudinaryMock.api.delete_resources.mockRejectedValue(new Error("Permission denied")); 52 | 53 | const result = await deleteTool({ 54 | publicId: "test123", 55 | }); 56 | 57 | expect(result.content[0].text).toContain("Error deleting asset: Permission denied"); 58 | expect(result.isError).toBe(true); 59 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`) 60 | }); 61 | }); 62 | ``` -------------------------------------------------------------------------------- /src/tools/getUsageTool.test.js: -------------------------------------------------------------------------------- ```javascript 1 | import { createCloudinaryMock } from "../../test/cloudinary.mock"; 2 | import getUsageTool, { getUsageToolParams } from "./getUsageTool.js"; 3 | import { z } from "zod"; 4 | 5 | describe("getUsageTool", () => { 6 | let cloudinaryMock; 7 | let usageTool; 8 | 9 | beforeEach(() => { 10 | cloudinaryMock = createCloudinaryMock(); 11 | usageTool = getUsageTool(cloudinaryMock); 12 | vi.clearAllMocks(); 13 | }); 14 | 15 | it("should have proper parameter schema", () => { 16 | expect(getUsageToolParams).toBeDefined(); 17 | expect(getUsageToolParams.date).toBeInstanceOf(z.ZodOptional); 18 | }); 19 | 20 | it("should successfully get usage data without date parameter", async () => { 21 | const mockUsageData = { 22 | plan: "free", 23 | last_updated: "2023-05-10", 24 | transformations: { 25 | usage: 10, 26 | limit: 500 27 | }, 28 | objects: { 29 | usage: 5, 30 | limit: 100 31 | } 32 | }; 33 | 34 | cloudinaryMock.api.usage.mockResolvedValue(mockUsageData); 35 | 36 | const result = await usageTool({}); 37 | 38 | expect(cloudinaryMock.api.usage).toHaveBeenCalledWith({}); 39 | expect(result.isError).toBe(false); 40 | expect(result.content).toHaveLength(1); 41 | expect(result.content[0].type).toBe("text"); 42 | expect(JSON.parse(result.content[0].text)).toEqual(mockUsageData); 43 | }); 44 | 45 | it("should successfully get usage data with date parameter", async () => { 46 | const mockUsageData = { 47 | plan: "free", 48 | last_updated: "2023-04-15", 49 | transformations: { 50 | usage: 8, 51 | limit: 500 52 | }, 53 | objects: { 54 | usage: 3, 55 | limit: 100 56 | } 57 | }; 58 | 59 | const date = "2023-04-15"; 60 | cloudinaryMock.api.usage.mockResolvedValue(mockUsageData); 61 | 62 | const result = await usageTool({ date }); 63 | 64 | expect(cloudinaryMock.api.usage).toHaveBeenCalledWith({ date }); 65 | expect(result.isError).toBe(false); 66 | expect(result.content).toHaveLength(1); 67 | expect(result.content[0].type).toBe("text"); 68 | expect(JSON.parse(result.content[0].text)).toEqual(mockUsageData); 69 | }); 70 | 71 | it("should handle API errors", async () => { 72 | const errorMessage = "API Error: Invalid date format"; 73 | cloudinaryMock.api.usage.mockRejectedValue(new Error(errorMessage)); 74 | 75 | const result = await usageTool({ date: "invalid-date" }); 76 | 77 | expect(result.isError).toBe(true); 78 | expect(result.content).toHaveLength(1); 79 | expect(result.content[0].type).toBe("text"); 80 | expect(result.content[0].text).toContain(errorMessage); 81 | expect(result.content[0].text).toContain(`(cloud: ${cloudinaryMock.config().cloud_name}, key: ${cloudinaryMock.config().api_key.slice(0, 4)}...)`); 82 | }); 83 | }); 84 | ``` -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- ```javascript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { v2 as cloudinary } from "cloudinary"; 4 | import dotenv from "dotenv"; 5 | 6 | import getUploadTool, { uploadToolParams } from "./tools/uploadTool.js"; 7 | import getDeleteTool, { deleteAssetToolParams } from "./tools/deleteAssetTool.js"; 8 | import getGetAssetTool, { getAssetToolParams } from "./tools/getAssetTool.js"; 9 | import getFindAssetsTool, { findAssetsToolParams } from "./tools/findAssetsTool.js"; 10 | import getUsageTool, { getUsageToolParams } from "./tools/getUsageTool.js"; 11 | 12 | dotenv.config(); 13 | 14 | ["CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET"].forEach((envVar) => { 15 | if (!process.env[envVar]) { 16 | console.error(`Please provide ${envVar} environment variable.`); 17 | process.exit(1); 18 | } 19 | }); 20 | 21 | // Configure Cloudinary with environment variables 22 | cloudinary.config({ 23 | cloud_name: process.env.CLOUDINARY_CLOUD_NAME, 24 | api_key: process.env.CLOUDINARY_API_KEY, 25 | api_secret: process.env.CLOUDINARY_API_SECRET, 26 | }); 27 | 28 | // Create server instance 29 | const server = new McpServer({ 30 | name: "cloudinary", 31 | version: "1.0.0", 32 | }); 33 | 34 | /** 35 | * Tool for Uploading an asset to Cloudinary 36 | */ 37 | server.tool( 38 | "upload", 39 | "Upload a file (asset) to Cloudinary", 40 | uploadToolParams, 41 | getUploadTool(cloudinary), 42 | ); 43 | 44 | /** 45 | * Tool for Deleting an asset from Cloudinary 46 | */ 47 | server.tool( 48 | "delete-asset", 49 | "Delete a file (asset) from Cloudinary", 50 | deleteAssetToolParams, 51 | getDeleteTool(cloudinary), 52 | ); 53 | 54 | /** 55 | * Tool for Getting asset details from Cloudinary 56 | */ 57 | server.tool( 58 | "get-asset", 59 | "Get the details of a specific file (asset)", 60 | getAssetToolParams, 61 | getGetAssetTool(cloudinary), 62 | ); 63 | 64 | /** 65 | * Tool for finding assets in Cloudinary 66 | */ 67 | server.tool( 68 | "find-assets", 69 | "Search for existing files (assets) in Cloudinary with a query expression", 70 | findAssetsToolParams, 71 | getFindAssetsTool(cloudinary), 72 | ); 73 | 74 | /** 75 | * Tool for getting usage information from Cloudinary 76 | */ 77 | server.tool( 78 | "get-usage", 79 | "Get a report on the status of your product environment usage, including storage, credits, bandwidth, requests, number of resources, and add-on usage", 80 | getUsageToolParams, 81 | getUsageTool(cloudinary), 82 | ); 83 | 84 | 85 | // Start the server with stdio transport 86 | const main = async () => { 87 | const transport = new StdioServerTransport(); 88 | await server.connect(transport); 89 | console.error("Cloudinary MCP Server running on stdio"); 90 | }; 91 | 92 | main() 93 | .catch((error) => { 94 | console.error("Fatal error in main():", error); 95 | process.exit(1); 96 | }); 97 | 98 | // Export Cloudinary for testing 99 | export { 100 | cloudinary, 101 | }; 102 | ```