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