# Directory Structure

```
├── .gitattributes
├── .github
│   └── workflows
│       └── publish.yml
├── .gitignore
├── .npmignore
├── code.md
├── glama.json
├── LICENSE
├── package-lock.json
├── package.json
├── product.md
├── README_zh.md
├── README.md
├── src
│   └── index.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
node_modules/
build/
*.log
.env*
```

--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------

```
# Auto detect text files and perform LF normalization
* text=auto

```

--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------

```
# Source files
src/
*.ts
tsconfig.json

# Development files
.gitignore
.npmignore

# Documentation (keep README files)
# README.md
# README_zh.md

# Development dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# IDE files
.vscode/
.idea/
*.swp
*.swo

# OS files
.DS_Store
Thumbs.db

# Test files
test/
tests/
*.test.js
*.spec.js

# Coverage
coverage/
.nyc_output/

# Logs
logs/
*.log
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# pixabay-mcp MCP Server

[中文版](README_zh.md)

A Model Context Protocol (MCP) server for Pixabay image and video search with structured results & runtime validation.

<a href="https://glama.ai/mcp/servers/@zym9863/pixabay-mcp">
  <img width="380" height="200" src="https://glama.ai/mcp/servers/@zym9863/pixabay-mcp/badge" alt="Pixabay Server MCP server" />
</a>

This TypeScript MCP server exposes Pixabay search tools over stdio so AI assistants / agents can retrieve media safely and reliably.

Highlights:
- Image & video search tools (Pixabay official API)
- Runtime argument validation (enums, ranges, semantic checks)
- Consistent error logging without leaking sensitive keys
- Planned structured JSON payloads for easier downstream automation (see Roadmap)

## Features

### Tools
`search_pixabay_images`
  - Required: `query` (string)
  - Optional: `image_type` (all|photo|illustration|vector), `orientation` (all|horizontal|vertical), `per_page` (3-200)
  - Returns: human-readable text block (current) + (planned) structured JSON array of hits

`search_pixabay_videos`
  - Required: `query`
  - Optional: `video_type` (all|film|animation), `orientation`, `per_page` (3-200), `min_duration`, `max_duration`
  - Returns: human-readable text block + (planned) structured JSON with duration & URLs

### Configuration
Environment variables:
| Name | Required | Default | Description |
| ---- | -------- | ------- | ----------- |
| `PIXABAY_API_KEY` | Yes | - | Your Pixabay API key (images & videos) |
| `PIXABAY_TIMEOUT_MS` | No | 10000 (planned) | Request timeout once feature lands |
| `PIXABAY_RETRY` | No | 0 (planned) | Number of retry attempts for transient network errors |

Notes:
- Safe search is enabled by default.
- Keys are never echoed back in structured errors or logs.

## Usage Examples

Current (text only response excerpt):
```
Found 120 images for "cat":
- cat, pet, animal (User: Alice): https://.../medium1.jpg
- kitten, cute (User: Bob): https://.../medium2.jpg
```

Planned structured result (Roadmap v0.4+):
```jsonc
{
  "content": [
    { "type": "text", "text": "Found 120 images for \"cat\":\n- ..." },
    {
      "type": "json",
      "data": {
        "query": "cat",
        "totalHits": 120,
        "page": 1,
        "perPage": 20,
        "hits": [
          { "id": 123, "tags": ["cat","animal"], "user": "Alice", "previewURL": "...", "webformatURL": "...", "largeImageURL": "..." }
        ]
      }
    }
  ]
}
```

Error response (planned shape):
```json
{
  "content": [{ "type": "text", "text": "Pixabay API error: 400 ..." }],
  "isError": true,
  "metadata": { "status": 400, "code": "UPSTREAM_BAD_REQUEST", "hint": "Check API key or parameters" }
}
```

## Development

Install dependencies:
```bash
npm install
```

Build the server:
```bash
npm run build
```

Watch mode:
```bash
npm run watch
```

## Installation

### Option 1: Using npx (Recommended)

Add this to your Claude Desktop configuration:

On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
On Windows: `%APPDATA%/Claude/claude_desktop_config.json`

```json
{
  "mcpServers": {
    "pixabay-mcp": {
      "command": "npx",
      "args": ["pixabay-mcp@latest"],
      "env": {
        "PIXABAY_API_KEY": "your_api_key_here"
      }
    }
  }
}
```

### Option 2: Local Installation

1. Clone and build the project:

```bash
git clone https://github.com/zym9863/pixabay-mcp.git
cd pixabay-mcp
npm install
npm run build
```

2. Add the server config:

```json
{
  "mcpServers": {
    "pixabay-mcp": {
      "command": "/path/to/pixabay-mcp/build/index.js",
      "env": {
        "PIXABAY_API_KEY": "your_api_key_here"
      }
    }
  }
}
```

### API Key Setup

Get your Pixabay API key from [https://pixabay.com/api/docs/](https://pixabay.com/api/docs/) and set it in the configuration above. The same key grants access to both image and video endpoints.

### Debugging

Since MCP servers communicate over stdio, debugging can be challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), which is available as a package script:

```bash
npm run inspector
```

The Inspector will provide a URL to access debugging tools in your browser.

## Roadmap (Condensed)
| Version | Focus | Key Items |
| ------- | ----- | --------- |
| v0.4 | Structured & Reliability | JSON payload, timeout, structured errors |
| v0.5 | UX & Pagination | page/order params, limited retry, modular refactor, tests |
| v0.6 | Multi-source Exploration | Evaluate integrating Unsplash/Pexels abstraction |

See `product.md` for full backlog & prioritization.

## Contributing
Planned contributions welcome once tests & module split land (v0.5 target). Feel free to open issues for API shape / schema suggestions.

## License
MIT

## Disclaimer
This project is not affiliated with Pixabay. Respect Pixabay's Terms of Service and rate limits.

```

--------------------------------------------------------------------------------
/glama.json:
--------------------------------------------------------------------------------

```json
{
    "$schema": "https://glama.ai/mcp/schemas/server.json",
    "maintainers": [
    "zym9863"
    ]
}
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

```

--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------

```yaml
﻿name: Publish package

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org/
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build project
        run: npm run build

      - name: Publish to npm
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm publish --access public

```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "pixabay-mcp",
  "version": "0.3.0",
  "description": "A Model Context Protocol server for Pixabay image search",
  "author": "zym9863",
  "license": "MIT",
  "keywords": ["mcp", "pixabay", "image-search", "model-context-protocol"],
  "repository": {
    "type": "git",
    "url": "https://github.com/zym9863/pixabay-mcp.git"
  },
  "homepage": "https://github.com/zym9863/pixabay-mcp#readme",
  "bugs": {
    "url": "https://github.com/zym9863/pixabay-mcp/issues"
  },
  "type": "module",
  "bin": {
    "pixabay-mcp": "./build/index.js"
  },
  "files": [
    "build"
  ],
  "scripts": {
    "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
    "prepare": "npm run build",
    "watch": "tsc --watch",
    "inspector": "npx @modelcontextprotocol/inspector build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "0.6.0",
    "axios": "^1.8.4"
  },
  "devDependencies": {
    "@types/node": "^20.11.24",
    "typescript": "^5.3.3"
  }
}

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/usr/bin/env node

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  McpError,
  ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import axios from 'axios';

// Pixabay API Key from environment variable
const API_KEY = process.env.PIXABAY_API_KEY;
const PIXABAY_API_URL = 'https://pixabay.com/api/';
const PIXABAY_VIDEO_API_URL = 'https://pixabay.com/api/videos/';
const IMAGE_TYPE_OPTIONS = ["all", "photo", "illustration", "vector"];
const ORIENTATION_OPTIONS = ["all", "horizontal", "vertical"];
const VIDEO_TYPE_OPTIONS = ["all", "film", "animation"];
const MIN_PER_PAGE = 3;
const MAX_PER_PAGE = 200;

// Interface for Pixabay API response (simplified)
interface PixabayImage {
  id: number;
  pageURL: string;
  type: string;
  tags: string;
  previewURL: string;
  webformatURL: string;
  largeImageURL: string;
  views: number;
  downloads: number;
  likes: number;
  user: string;
}

interface PixabayResponse {
  total: number;
  totalHits: number;
  hits: PixabayImage[];
}

/**
 * 视频对象接口，包含视频的详细信息
 */
interface PixabayVideo {
  id: number;
  pageURL: string;
  type: string;
  tags: string;
  duration: number;
  views: number;
  downloads: number;
  likes: number;
  user: string;
  /**
   * 视频文件对象，包含不同分辨率的视频 URL
   */
  videos: {
    large?: {
      url: string;
      width: number;
      height: number;
      size: number;
    };
    medium?: {
      url: string;
      width: number;
      height: number;
      size: number;
    };
    small?: {
      url: string;
      width: number;
      height: number;
      size: number;
    };
    tiny?: {
      url: string;
      width: number;
      height: number;
      size: number;
    };
  };
}

/**
 * 视频搜索 API 响应接口
 */
interface PixabayVideoResponse {
  total: number;
  totalHits: number;
  hits: PixabayVideo[];
}

// Type guard for tool arguments
const isValidSearchArgs = (
  args: any
): args is { query: string; image_type?: string; orientation?: string; per_page?: number } =>
  typeof args === 'object' &&
  args !== null &&
  typeof args.query === 'string' &&
  (args.image_type === undefined || typeof args.image_type === 'string') &&
  (args.orientation === undefined || typeof args.orientation === 'string') &&
  (args.per_page === undefined || typeof args.per_page === 'number');

/**
 * 视频搜索参数的类型守卫函数
 */
const isValidVideoSearchArgs = (
  args: any
): args is { query: string; video_type?: string; orientation?: string; per_page?: number; min_duration?: number; max_duration?: number } =>
  typeof args === 'object' &&
  args !== null &&
  typeof args.query === 'string' &&
  (args.video_type === undefined || typeof args.video_type === 'string') &&
  (args.orientation === undefined || typeof args.orientation === 'string') &&
  (args.per_page === undefined || typeof args.per_page === 'number') &&
  (args.min_duration === undefined || typeof args.min_duration === 'number') &&
  (args.max_duration === undefined || typeof args.max_duration === 'number');

/**
 * 验证图片检索参数，确保符合 Pixabay API 的要求。
 */
function assertValidImageSearchParams(args: { image_type?: string; orientation?: string; per_page?: number }): void {
  const { image_type, orientation, per_page } = args;

  if (image_type !== undefined && !IMAGE_TYPE_OPTIONS.includes(image_type)) {
    throw new McpError(
      ErrorCode.InvalidParams,
      `image_type 必须是 ${IMAGE_TYPE_OPTIONS.join(', ')} 之一。`
    );
  }

  if (orientation !== undefined && !ORIENTATION_OPTIONS.includes(orientation)) {
    throw new McpError(
      ErrorCode.InvalidParams,
      `orientation 必须是 ${ORIENTATION_OPTIONS.join(', ')} 之一。`
    );
  }

  if (per_page !== undefined) {
    if (!Number.isInteger(per_page)) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'per_page 必须是整数。'
      );
    }
    if (per_page < MIN_PER_PAGE || per_page > MAX_PER_PAGE) {
      throw new McpError(
        ErrorCode.InvalidParams,
        `per_page 需在 ${MIN_PER_PAGE}-${MAX_PER_PAGE} 范围内。`
      );
    }
  }
}

/**
 * 验证视频检索参数，确保符合 Pixabay API 的要求。
 */
function assertValidVideoSearchParams(args: {
  video_type?: string;
  orientation?: string;
  per_page?: number;
  min_duration?: number;
  max_duration?: number;
}): void {
  const { video_type, orientation, per_page, min_duration, max_duration } = args;

  if (video_type !== undefined && !VIDEO_TYPE_OPTIONS.includes(video_type)) {
    throw new McpError(
      ErrorCode.InvalidParams,
      `video_type 必须是 ${VIDEO_TYPE_OPTIONS.join(', ')} 之一。`
    );
  }

  if (orientation !== undefined && !ORIENTATION_OPTIONS.includes(orientation)) {
    throw new McpError(
      ErrorCode.InvalidParams,
      `orientation 必须是 ${ORIENTATION_OPTIONS.join(', ')} 之一。`
    );
  }

  if (per_page !== undefined) {
    if (!Number.isInteger(per_page)) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'per_page 必须是整数。'
      );
    }
    if (per_page < MIN_PER_PAGE || per_page > MAX_PER_PAGE) {
      throw new McpError(
        ErrorCode.InvalidParams,
        `per_page 需在 ${MIN_PER_PAGE}-${MAX_PER_PAGE} 范围内。`
      );
    }
  }

  if (min_duration !== undefined) {
    if (!Number.isInteger(min_duration) || min_duration < 0) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'min_duration 必须是大于等于 0 的整数。'
      );
    }
  }

  if (max_duration !== undefined) {
    if (!Number.isInteger(max_duration) || max_duration < 0) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'max_duration 必须是大于等于 0 的整数。'
      );
    }
  }

  if (
    min_duration !== undefined &&
    max_duration !== undefined &&
    max_duration < min_duration
  ) {
    throw new McpError(
      ErrorCode.InvalidParams,
      'max_duration 需大于或等于 min_duration。'
    );
  }
}

/**
 * 输出 Pixabay API 错误日志，避免泄露敏感凭据信息。
 */
function logPixabayError(source: 'image' | 'video' | 'server', error: unknown): void {
  if (axios.isAxiosError(error)) {
    const responseData = error.response?.data as { message?: string } | undefined;
    console.error(`[Pixabay ${source} error]`, {
      status: error.response?.status,
      statusText: error.response?.statusText,
      code: error.code,
      message: responseData?.message ?? error.message,
    });
    return;
  }

  if (error instanceof Error) {
    console.error(`[Pixabay ${source} error]`, {
      message: error.message,
    });
    return;
  }

  console.error(`[Pixabay ${source} error]`, {
    error: String(error),
  });
}

/**
 * Create an MCP server for Pixabay.
 */
const server = new Server(
  {
    name: "pixabay-mcp",
    version: "0.3.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);

/**
 * Handler that lists available tools.
 * Exposes a "search_pixabay_images" tool.
 */
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "search_pixabay_images",
        description: "Search for images on Pixabay",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string",
              description: "Search query terms"
            },
            image_type: {
              type: "string",
              enum: ["all", "photo", "illustration", "vector"],
              description: "Filter results by image type",
              default: "all"
            },
            orientation: {
              type: "string",
              enum: ["all", "horizontal", "vertical"],
              description: "Filter results by image orientation",
              default: "all"
            },
            per_page: {
              type: "number",
              description: "Number of results per page (3-200)",
              default: 20,
              minimum: 3,
              maximum: 200
            }
          },
          required: ["query"]
        }
      },
      {
        name: "search_pixabay_videos",
        description: "Search for videos on Pixabay",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string",
              description: "Search query terms"
            },
            video_type: {
              type: "string",
              enum: ["all", "film", "animation"],
              description: "Filter results by video type",
              default: "all"
            },
            orientation: {
              type: "string",
              enum: ["all", "horizontal", "vertical"],
              description: "Filter results by video orientation",
              default: "all"
            },
            per_page: {
              type: "number",
              description: "Number of results per page (3-200)",
              default: 20,
              minimum: 3,
              maximum: 200
            },
            min_duration: {
              type: "number",
              description: "Minimum video duration in seconds",
              minimum: 0
            },
            max_duration: {
              type: "number",
              description: "Maximum video duration in seconds",
              minimum: 0
            }
          },
          required: ["query"]
        }
      }
    ]
  };
});

/**
 * Handler for the search_pixabay_images and search_pixabay_videos tools.
 */
interface ToolCallRequest {
  params: {
    name: string;
    arguments?: Record<string, unknown>;
  };
}

server.setRequestHandler(CallToolRequestSchema, async (request: ToolCallRequest) => {
  if (!['search_pixabay_images', 'search_pixabay_videos'].includes(request.params.name)) {
    throw new McpError(
      ErrorCode.MethodNotFound,
      `Unknown tool: ${request.params.name}`
    );
  }

  if (!API_KEY) {
    throw new McpError(
      ErrorCode.InternalError,
      'Pixabay API key (PIXABAY_API_KEY) is not configured in the environment.'
    );
  }

  // 处理图片搜索
  if (request.params.name === 'search_pixabay_images') {
    if (!isValidSearchArgs(request.params.arguments)) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'Invalid search arguments. "query" (string) is required.'
      );
    }

    assertValidImageSearchParams(request.params.arguments);

    const { query, image_type = 'all', orientation = 'all', per_page = 20 } = request.params.arguments;

    try {
      const response = await axios.get<PixabayResponse>(PIXABAY_API_URL, {
        params: {
          key: API_KEY,
          q: query,
          image_type: image_type,
          orientation: orientation,
          per_page: per_page,
          safesearch: true // Enable safe search by default
        },
      });

      if (response.data.hits.length === 0) {
        return {
          content: [{
            type: "text",
            text: `No images found for query: "${query}"`
          }]
        };
      }

      // Format the results
      const resultsText = response.data.hits.map((hit: PixabayImage) =>
        `- ${hit.tags} (User: ${hit.user}): ${hit.webformatURL}`
      ).join('\n');

      return {
        content: [{
          type: "text",
          text: `Found ${response.data.totalHits} images for "${query}":\n${resultsText}`
        }]
      };

    } catch (error: unknown) {
      let errorMessage = 'Failed to fetch images from Pixabay.';
      if (axios.isAxiosError(error)) {
        errorMessage = `Pixabay API error: ${error.response?.status} ${error.response?.data?.message || error.message}`;
        // Pixabay might return 400 for invalid key, but doesn't give a clear message
        if (error.response?.status === 400) {
           errorMessage += '. Please check if the API key is valid.';
        }
      } else if (error instanceof Error) {
        errorMessage = error.message;
      }
      logPixabayError('image', error);
      return {
        content: [{
          type: "text",
          text: errorMessage
        }],
        isError: true
      };
    }
  }

  // 处理视频搜索
  if (request.params.name === 'search_pixabay_videos') {
    if (!isValidVideoSearchArgs(request.params.arguments)) {
      throw new McpError(
        ErrorCode.InvalidParams,
        'Invalid video search arguments. "query" (string) is required.'
      );
    }

    assertValidVideoSearchParams(request.params.arguments);

    const {
      query,
      video_type = 'all',
      orientation = 'all',
      per_page = 20,
      min_duration,
      max_duration
    } = request.params.arguments;

    try {
      const params: any = {
        key: API_KEY,
        q: query,
        video_type: video_type,
        orientation: orientation,
        per_page: per_page,
        safesearch: true
      };

      // 只有在提供了时长参数时才添加到请求中
      if (min_duration !== undefined) {
        params.min_duration = min_duration;
      }
      if (max_duration !== undefined) {
        params.max_duration = max_duration;
      }

      const response = await axios.get<PixabayVideoResponse>(PIXABAY_VIDEO_API_URL, {
        params
      });

      if (response.data.hits.length === 0) {
        return {
          content: [{
            type: "text",
            text: `No videos found for query: "${query}"`
          }]
        };
      }

      // 格式化视频搜索结果
      const resultsText = response.data.hits.map((hit: PixabayVideo) => {
        const duration = Math.floor(hit.duration);
        const videoUrl = hit.videos.medium?.url || hit.videos.small?.url || hit.videos.tiny?.url || 'No video URL available';
        return `- ${hit.tags} (User: ${hit.user}, Duration: ${duration}s): ${videoUrl}`;
      }).join('\n');

      return {
        content: [{
          type: "text",
          text: `Found ${response.data.totalHits} videos for "${query}":\n${resultsText}`
        }]
      };

    } catch (error: unknown) {
      let errorMessage = 'Failed to fetch videos from Pixabay.';
      if (axios.isAxiosError(error)) {
        errorMessage = `Pixabay Video API error: ${error.response?.status} ${error.response?.data?.message || error.message}`;
        if (error.response?.status === 400) {
           errorMessage += '. Please check if the API key is valid.';
        }
      } else if (error instanceof Error) {
        errorMessage = error.message;
      }
      logPixabayError('video', error);
      return {
        content: [{
          type: "text",
          text: errorMessage
        }],
        isError: true
      };
    }
  }

  // 这个不应该被执行到，但添加以确保类型安全
  throw new McpError(
    ErrorCode.MethodNotFound,
    `Unhandled tool: ${request.params.name}`
  );
});

/**
 * Start the server using stdio transport.
 */
async function main() {
  const transport = new StdioServerTransport();
  server.onerror = (error: Error) => logPixabayError('server', error); // Add basic error logging
  process.on('SIGINT', async () => { // Graceful shutdown
      await server.close();
      process.exit(0);
  });
  await server.connect(transport);
  console.error('Pixabay MCP server running on stdio'); // Log to stderr so it doesn't interfere with stdout JSON-RPC
}

main().catch((error: unknown) => {
  console.error("Server failed to start.");
  logPixabayError('server', error);
  process.exit(1);
});

```

