跳至主要內容
技術

MCP Server 開發:讓你的服務成為 AI 工具

MCP Server 開發:讓你的服務成為 AI 工具
Claude API & Agent SDK 完全指南 第 12 / 15 篇

本篇是「Claude API & Agent SDK 完全指南」系列的第 12 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

在你讀到這一章之前,你可能已經用過 MCP 了——作為一個使用者。

你在 Claude Code 裡連接了 GitHub MCP server,Claude Code 就能直接查 PR、建立 Issue。你連接了 Filesystem MCP,Claude Code 就能讀取你本機的任何檔案。你連接了 Postgres MCP,AI 就能直接查你的資料庫。

這就是 MCP 從使用者角度的樣子:讓 AI 客戶端取用更多能力

但這一章,我們要換一個視角——開發者視角:如果你想讓自己開發的服務,被任何支援 MCP 的 AI 客戶端使用,你需要開發一個 MCP Server。

這個思路轉換其實很重要。MCP 不只是讓你「用更多工具」,而是讓你「把你的服務暴露給所有 AI 工具」。你開發一個 Jira MCP server,它可以被 Claude Code 用、被 Claude.ai 用、被 Cursor 用、被任何未來出現的 MCP 客戶端用。一次開發,到處可用。

MCP Server 的三種能力

在動手寫程式碼之前,先理解 MCP Server 能提供哪三種能力。

Tools(工具):讓 AI 執行操作。這是最常用的能力——AI 呼叫你的工具,你的工具做某件事,回傳結果。搜尋 Jira issue、建立 GitHub PR、查詢資料庫記錄——這些都是 Tools。

Resources(資源):提供唯讀的資料。Resources 跟 Tools 的差別是:Tools 是「做某件事」,Resources 是「讀取某樣東西」。你的文件庫、設定檔、日誌記錄——這些適合暴露為 Resources。AI 客戶端可以瀏覽和讀取你的 Resources,就像在資料夾裡找檔案一樣。

Prompts(提示範本):預定義的提示模板。你可以把常用的提示場景封裝成 Prompts,讓 AI 客戶端可以選擇使用。例如「程式碼審查」、「撰寫 commit message」——這些都可以封裝成可重用的 Prompt。

三者之中,Tools 是最重要的,你 90% 的時間會在開發 Tools。但 Resources 和 Prompts 是完整 MCP Server 的組成部分,不能忽視。

開發環境設定

MCP 有 Python 和 TypeScript 兩個官方 SDK。我通常選擇跟我的後端語言一致——如果已有 Python 服務,用 Python SDK;如果是 Node.js 服務,用 TypeScript SDK。

Python SDK 安裝:

pip install mcp
# 或用 uv(速度更快,推薦)
uv add mcp

TypeScript SDK 安裝:

npm install @modelcontextprotocol/sdk
# 或
yarn add @modelcontextprotocol/sdk

我這一章用 Python 示範,TypeScript 的概念完全相同,只是 API 語法不同。

建立 MCP Server 骨架

最簡單的 MCP server 只需要幾行:

from mcp.server.fastmcp import FastMCP

# 建立 server 實例
mcp = FastMCP("my-first-mcp-server")

# 啟動 server(使用 stdio transport)
if __name__ == "__main__":
    mcp.run()

FastMCP 是 MCP Python SDK 的高階 API,使用 decorator 方式定義 Tools 和 Resources,減少樣板程式碼。

現在執行這個程式,你會得到一個可運作的 MCP server——雖然它什麼也做不了,但架構已經到位。

定義 Tools

Tools 是 MCP Server 最核心的功能。用 @mcp.tool() decorator 定義:

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel

mcp = FastMCP("jira-mcp-server")

@mcp.tool()
def search_issues(
    project_key: str,
    status: str = "Open",
    max_results: int = 10
) -> str:
    """
    搜尋 Jira 中的 Issue。

    Args:
        project_key: 專案代碼,例如 "BACKEND" 或 "FRONTEND"
        status: Issue 狀態,可以是 "Open", "In Progress", "Done"
        max_results: 最多回傳幾筆,預設 10

    Returns:
        符合條件的 Issue 列表(JSON 格式)
    """
    # 實際的 Jira API 呼叫
    issues = jira_client.search_issues(
        f"project = {project_key} AND status = '{status}'",
        maxResults=max_results
    )

    return format_issues_as_json(issues)

幾個關鍵點:

函數的 docstring 非常重要。 AI 客戶端用 docstring 來理解這個工具做什麼、什麼時候該用。寫清楚的 docstring 讓 AI 更精準地決定何時呼叫你的工具。

型別標注讓 MCP SDK 自動生成 JSON Schema。 AI 客戶端用 JSON Schema 來知道每個參數的型別和格式,所以型別標注要準確。

回傳值應該是字串或可序列化的資料。 MCP 工具的回傳值最終會被轉成字串給 AI 客戶端看。

讓我們把 Jira MCP server 繼續完善:

from mcp.server.fastmcp import FastMCP
import anthropic
import requests
import json
import os
from datetime import datetime

mcp = FastMCP("jira-mcp-server")

# Jira 客戶端設定
JIRA_BASE_URL = os.environ["JIRA_BASE_URL"]  # 例如 https://yourcompany.atlassian.net
JIRA_EMAIL = os.environ["JIRA_EMAIL"]
JIRA_API_TOKEN = os.environ["JIRA_API_TOKEN"]

def jira_request(method: str, endpoint: str, data: dict = None) -> dict:
    """Jira API 的底層呼叫"""
    url = f"{JIRA_BASE_URL}/rest/api/3/{endpoint}"
    auth = (JIRA_EMAIL, JIRA_API_TOKEN)
    headers = {"Content-Type": "application/json"}

    response = requests.request(
        method,
        url,
        auth=auth,
        headers=headers,
        json=data
    )
    response.raise_for_status()
    return response.json() if response.content else {}

@mcp.tool()
def search_issues(
    project_key: str,
    status: str = None,
    assignee: str = None,
    max_results: int = 10
) -> str:
    """
    搜尋 Jira Project 中的 Issue。

    Args:
        project_key: 專案代碼(必填),例如 "BACKEND"
        status: 篩選狀態,例如 "In Progress"、"To Do"、"Done"(選填)
        assignee: 篩選負責人的帳號 ID(選填)
        max_results: 最多回傳筆數,預設 10,最大 50

    Returns:
        Issue 列表,包含 key、標題、狀態、負責人、建立時間
    """
    jql_parts = [f"project = {project_key}"]
    if status:
        jql_parts.append(f"status = '{status}'")
    if assignee:
        jql_parts.append(f"assignee = '{assignee}'")
    jql_parts.append("ORDER BY created DESC")

    jql = " AND ".join(jql_parts[:len(jql_parts)-1]) + f" {jql_parts[-1]}"

    result = jira_request(
        "GET",
        f"search?jql={jql}&maxResults={max_results}&fields=summary,status,assignee,created,priority"
    )

    issues = []
    for issue in result.get("issues", []):
        fields = issue["fields"]
        issues.append({
            "key": issue["key"],
            "summary": fields["summary"],
            "status": fields["status"]["name"],
            "assignee": fields["assignee"]["displayName"] if fields.get("assignee") else "未指派",
            "priority": fields["priority"]["name"] if fields.get("priority") else "Medium",
            "created": fields["created"][:10],  # 只取日期部分
        })

    return json.dumps(issues, ensure_ascii=False, indent=2)

@mcp.tool()
def get_issue(issue_key: str) -> str:
    """
    取得單一 Jira Issue 的詳細資訊。

    Args:
        issue_key: Issue 代碼,例如 "BACKEND-123"

    Returns:
        Issue 的完整資訊,包含描述、留言、子任務
    """
    result = jira_request(
        "GET",
        f"issue/{issue_key}?fields=summary,description,status,assignee,priority,comment,subtasks,labels"
    )

    fields = result["fields"]

    # 整理留言
    comments = []
    for comment in fields.get("comment", {}).get("comments", [])[-5:]:  # 最近 5 則
        comments.append({
            "author": comment["author"]["displayName"],
            "body": comment["body"][:200],  # 截斷過長的留言
            "created": comment["created"][:10],
        })

    return json.dumps({
        "key": issue_key,
        "summary": fields["summary"],
        "description": str(fields.get("description", ""))[:500],
        "status": fields["status"]["name"],
        "assignee": fields["assignee"]["displayName"] if fields.get("assignee") else "未指派",
        "labels": fields.get("labels", []),
        "subtasks": [{"key": s["key"], "summary": s["fields"]["summary"]} for s in fields.get("subtasks", [])],
        "recent_comments": comments,
    }, ensure_ascii=False, indent=2)

@mcp.tool()
def create_issue(
    project_key: str,
    summary: str,
    description: str = "",
    issue_type: str = "Task",
    priority: str = "Medium",
    assignee_account_id: str = None,
    labels: list[str] = None
) -> str:
    """
    在 Jira 建立新的 Issue。

    Args:
        project_key: 要建立在哪個 Project 下,例如 "BACKEND"
        summary: Issue 標題(必填)
        description: 詳細描述(選填,支援 Markdown 格式)
        issue_type: Issue 類型,可以是 "Task"、"Bug"、"Story"、"Epic"
        priority: 優先級,可以是 "Highest"、"High"、"Medium"、"Low"、"Lowest"
        assignee_account_id: 負責人的 Jira 帳號 ID(選填)
        labels: 標籤列表(選填),例如 ["backend", "urgent"]

    Returns:
        新建立的 Issue 代碼和連結
    """
    fields = {
        "project": {"key": project_key},
        "summary": summary,
        "issuetype": {"name": issue_type},
        "priority": {"name": priority},
    }

    if description:
        # Jira Cloud 用 Atlassian Document Format(ADF),這裡用簡化版
        fields["description"] = {
            "type": "doc",
            "version": 1,
            "content": [{"type": "paragraph", "content": [{"type": "text", "text": description}]}]
        }

    if assignee_account_id:
        fields["assignee"] = {"accountId": assignee_account_id}

    if labels:
        fields["labels"] = labels

    result = jira_request("POST", "issue", {"fields": fields})

    return json.dumps({
        "key": result["key"],
        "url": f"{JIRA_BASE_URL}/browse/{result['key']}",
        "message": f"Issue {result['key']} 已成功建立"
    }, ensure_ascii=False)

@mcp.tool()
def update_issue_status(
    issue_key: str,
    target_status: str
) -> str:
    """
    更新 Jira Issue 的狀態。

    Args:
        issue_key: Issue 代碼,例如 "BACKEND-123"
        target_status: 目標狀態名稱,例如 "In Progress"、"Done"、"To Do"

    Returns:
        更新結果
    """
    # 先取得可用的 transitions
    transitions_result = jira_request("GET", f"issue/{issue_key}/transitions")
    transitions = transitions_result.get("transitions", [])

    # 找到符合目標狀態的 transition
    target_transition = None
    for transition in transitions:
        if transition["to"]["name"].lower() == target_status.lower():
            target_transition = transition
            break

    if not target_transition:
        available = [t["to"]["name"] for t in transitions]
        return json.dumps({
            "error": f"找不到狀態 '{target_status}'",
            "available_statuses": available
        }, ensure_ascii=False)

    # 執行 transition
    jira_request("POST", f"issue/{issue_key}/transitions", {
        "transition": {"id": target_transition["id"]}
    })

    return json.dumps({
        "message": f"Issue {issue_key} 狀態已更新為 '{target_status}'"
    }, ensure_ascii=False)

定義 Resources

Resources 讓 AI 客戶端可以瀏覽和讀取你的資料。對 Jira server 來說,可以把常用的 JQL 查詢結果暴露為 Resources:

from mcp.server.fastmcp import FastMCP
from mcp.types import Resource
import json

@mcp.resource("jira://projects")
def list_projects() -> str:
    """列出所有可存取的 Jira Project"""
    result = jira_request("GET", "project")
    projects = [
        {"key": p["key"], "name": p["name"], "type": p["projectTypeKey"]}
        for p in result
    ]
    return json.dumps(projects, ensure_ascii=False, indent=2)

@mcp.resource("jira://project/{project_key}/open-issues")
def get_open_issues(project_key: str) -> str:
    """取得指定 Project 的所有未完成 Issue"""
    result = jira_request(
        "GET",
        f"search?jql=project={project_key} AND status != Done ORDER BY priority DESC&maxResults=50"
    )
    return json.dumps(result.get("issues", []), ensure_ascii=False, indent=2)

Resources 使用 URI 模板——jira://project/{project_key}/open-issues 中的 {project_key} 是動態參數,AI 客戶端可以填入任何值來讀取對應的資源。

定義 Prompts

Prompts 讓你封裝常用的提示場景:

from mcp.server.fastmcp import FastMCP
from mcp.types import PromptMessage

@mcp.prompt()
def daily_standup_prompt(project_key: str) -> list[PromptMessage]:
    """
    生成每日站會的摘要提示。

    Args:
        project_key: 要摘要的 Project 代碼
    """
    return [
        {
            "role": "user",
            "content": f"""請幫我準備 {project_key} 專案的每日站會摘要。

            步驟:
            1. 用 search_issues 查詢昨天更新的 Issue
            2. 查詢目前 In Progress 的 Issue
            3. 查詢今天 Due 的 Issue
            4. 整理成站會格式:
               - 昨天完成了什麼
               - 今天要做什麼
               - 有什麼 Blocker

            保持簡潔,每個項目一行。
            """
        }
    ]

本地測試

開發 MCP server 的時候,最常用的測試方式是直接用 MCP Inspector:

# 方式一:Python MCP CLI(需先 pip install 'mcp[cli]')
mcp dev server.py

# 方式二:npm 版 MCP Inspector,直接掛上你的 server
npx @modelcontextprotocol/inspector python server.py

MCP Inspector 會啟動一個 Web UI,讓你直接呼叫你的 Tools 和瀏覽你的 Resources,不需要透過 Claude 就能測試。

或者更簡單——直接寫測試:

import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def test_jira_server():
    server_params = StdioServerParameters(
        command="python",
        args=["jira_server.py"],
        env={
            "JIRA_BASE_URL": "https://test.atlassian.net",
            "JIRA_EMAIL": "test@example.com",
            "JIRA_API_TOKEN": "test-token",
        }
    )

    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()

            # 列出所有可用的 tools
            tools = await session.list_tools()
            print("Available tools:")
            for tool in tools.tools:
                print(f"  - {tool.name}: {tool.description[:50]}...")

            # 測試 search_issues tool
            result = await session.call_tool(
                "search_issues",
                {"project_key": "BACKEND", "status": "In Progress"}
            )
            print("\nsearch_issues result:")
            print(result.content[0].text)

asyncio.run(test_jira_server())

連接到 Claude Code

~/.claude.json(或專案的 .claude/settings.json)中新增你的 MCP server:

{
  "mcpServers": {
    "jira": {
      "command": "python",
      "args": ["/path/to/jira_server.py"],
      "env": {
        "JIRA_BASE_URL": "https://yourcompany.atlassian.net",
        "JIRA_EMAIL": "your-email@company.com",
        "JIRA_API_TOKEN": "${JIRA_API_TOKEN}"
      }
    }
  }
}

注意 ${JIRA_API_TOKEN} 這種語法——Claude Code 會從環境變數讀取這個值,不要把 API token 直接寫在設定檔裡(那個檔案可能被 commit 到 git)。

重啟 Claude Code 後,你應該能看到 Jira 的 Tools 出現在可用工具列表中。試試看說「搜尋 BACKEND 專案裡所有 In Progress 的 issue」——Claude Code 會自動呼叫你的 MCP server。

連接到 Claude.ai

Claude.ai(網頁版)也支援 MCP。在 Settings → Integrations → Add integration 中填入你的 MCP server 連接資訊。

不過有個重要差異:Claude.ai 是雲端服務,它需要能透過網路訪問你的 MCP server。如果你的 server 只在本機跑(stdio transport),Claude.ai 無法連接。你需要把 server 部署到公開可訪問的位置,並使用 SSE(Server-Sent Events)或 WebSocket transport:

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("jira-mcp-server")

# ... 定義 Tools, Resources, Prompts ...

if __name__ == "__main__":
    import uvicorn
    # 使用 HTTP/SSE transport 讓 Claude.ai 可以連接
    mcp.run(transport="sse", host="0.0.0.0", port=8000)

打包與發布

如果你想讓其他人也能用你的 MCP server,可以發布到 npm 或 PyPI。

Python 打包(使用 uv):

# pyproject.toml
[project]
name = "jira-mcp-server"
version = "0.1.0"
description = "A Jira MCP server for Claude"
dependencies = ["mcp>=1.0.0", "requests>=2.31.0"]

[project.scripts]
jira-mcp-server = "jira_mcp_server:main"

發布後,其他人可以這樣安裝和使用:

{
  "mcpServers": {
    "jira": {
      "command": "uvx",
      "args": ["jira-mcp-server"],
      "env": {
        "JIRA_BASE_URL": "https://yourcompany.atlassian.net"
      }
    }
  }
}

安全考量:認證、Rate Limiting、輸入驗證

MCP server 的安全問題很容易被忽視,但非常重要。

認證:不要讓任何人都能呼叫你的 server。

對於 stdio transport(本機用),認證相對簡單——只要本機的 Claude Code 才能呼叫。但對於 HTTP/SSE transport(雲端部署),你需要 API key 或 OAuth:

from fastapi import Header, HTTPException

@app.middleware("http")
async def authenticate(request, call_next):
    api_key = request.headers.get("X-API-Key")
    if api_key != os.environ["MCP_API_KEY"]:
        raise HTTPException(status_code=401, detail="Unauthorized")
    return await call_next(request)

Rate Limiting:防止 AI 瘋狂呼叫你的 API。

AI 客戶端可能在短時間內大量呼叫你的工具(尤其是 agent 在 loop 裡執行的時候)。一定要加入 rate limiting:

from collections import defaultdict
import time

class RateLimiter:
    def __init__(self, max_calls: int, window_seconds: int):
        self.max_calls = max_calls
        self.window = window_seconds
        self.calls = defaultdict(list)

    def is_allowed(self, key: str) -> bool:
        now = time.time()
        self.calls[key] = [t for t in self.calls[key] if now - t < self.window]
        if len(self.calls[key]) >= self.max_calls:
            return False
        self.calls[key].append(now)
        return True

rate_limiter = RateLimiter(max_calls=60, window_seconds=60)

@mcp.tool()
def search_issues(project_key: str, ...) -> str:
    if not rate_limiter.is_allowed("search_issues"):
        return json.dumps({"error": "Rate limit exceeded. Please try again in a minute."})
    # ... 正常邏輯

輸入驗證:永遠不要信任 AI 傳來的輸入。

import re

@mcp.tool()
def search_issues(project_key: str, ...) -> str:
    # 驗證 project_key 格式(只允許大寫字母和數字)
    if not re.match(r'^[A-Z][A-Z0-9]{0,9}$', project_key):
        return json.dumps({"error": f"無效的 project key 格式:{project_key}"})

    # 避免 JQL injection
    # 不要直接把用戶輸入塞進 JQL 字串
    safe_project_key = project_key.strip().upper()
    # ...

MCP server 開發本質上跟任何 API 開發一樣——輸入驗證、認證、限流都是基本功,不能省略。


下一章,我們轉向一個每個 AI 應用開發者遲早都會面對的問題:成本控制。你的 agent 系統功能再強,如果每個月要燒掉 $5000 的 API 費用,那商業模式就很難跑通。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →