MCP Server 開發:讓你的服務成為 AI 工具
本篇是「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 費用,那商業模式就很難跑通。