AG Day 27 MCP 進階:resources、prompts 與多工具 server
執行需求:CPU 可跑。昨天的 AG Day 26 第一個 MCP server:用 FastMCP(原文連結)我們用 FastMCP 把昨天的最小骨架升級成正式的 MCP server,並示範了 echo 與 add 兩個 tool。今天要把 server 的另外兩種能力補齊:resources(可被客戶端存取的資料,例如研究筆記、設定檔、知識快照)與 prompts(預先設計好的提示範本,可帶參數)。把這三種能力組合起來,server 就不再只是「員工」,而是「員工加圖書館加顧問公司」。我們會把 AG Day 23 引用與出處(原文連結)建立的 SQLite 知識庫包成 resource、把研究報告模板包成可重複使用的 prompt,並設計一個包含三種能力的完整 server。本篇完全在本機 CPU 上執行,不需要任何 API 金鑰。
引言
昨天的 server 只能「做事」——呼叫工具就執行一段邏輯、回傳結果。但在真實的研究助理場景裡,使用者經常會需要「讀資料」:他想看看昨天抓了哪些來源、某個主題曾經寫過哪些報告、儲存的引用清單長什麼樣子。這些需求用 tool 來實作當然可以(fetch_note、fetch_report),但規格已經為這種「客戶端主動拉取」的需求設計了一個更合適的能力:resource。
同樣地,研究助理也經常會需要「套用對話策略」:當使用者要求「寫一份正式報告」,我們可能想套用「結論先行、引述加註、不確定要坦白」這套策略;當使用者要求「快速摘要」,策略又完全不同。如果每次都要在 system prompt 重新描述這些規則,會很冗長也容易漏寫。MCP 的 prompt 能力正好解決這個問題:伺服器可以預先設計好幾套帶參數的範本,客戶端或使用者直接挑選套用。
今天的目標是把這三種能力放在同一個 server 裡,讓一個伺服器同時具備「做事、讀資料、給建議」三種角色。程式碼會比昨天略長,但每一段都會對應到一個明確的能力,讀完之後你應該能直接把這套模式套到你自己的應用領域(客服、財務報表、法律書狀⋯⋯)。
原理/觀念
Resource:帶 URI 的可存取資料
在 MCP 規格裡,resource 用「URI」作為識別:notes://research/last-week 可能代表「上週所有研究筆記的清單」,report://2026-08-05 可能代表「2026-08-05 那一天產出的報告」。URI 可以是任何合理的字串,常見的慣例是用 scheme(notes://、reports://、config://)區分資料類型,路徑部分用動態參數(例如日期、編號)做變化。
Resource 與 tool 的差異有兩個重點。第一,resource 通常不是由模型主動呼叫,而是由客戶端根據上下文決定要不要讀取:例如「使用者開啟了某個檔案」「使用者提到了上週的會議」這類觸發條件,由客戶端判斷後主動 resources/read。第二,resource 的內容通常較大且較靜態:它是「資料」而不是「動作」,可以被快取、可以分頁、可以訂閱變更(規格有列出 resources/subscribe,但實作細節依版本而異)。
FastMCP 用 @mcp.resource(uri) 裝飾器把函式註冊成 resource。函式的回傳值會被自動包成 resource 內容物件,內容可以是純文字、JSON 編碼後的字串、或二進位資料的 base64 表示。今天我們只示範文字與 JSON 兩種,圖片與其他型別依需求再擴充。
Prompt:可重複使用的對話範本
Prompt 是「預先寫好的提示範本」。與 resource 不同,prompt 通常帶有參數,使用時可以根據當下情境填入。例如一個「研究助理報告」prompt 可能長這樣:
請依下列主題撰寫研究報告:
主題:{topic}
引用規則:每個事實性敘述後面加上 [n],n 為引用編號
長度限制:1500 字以內
當使用者說「幫我寫一份關於邊緣運算功耗的報告」,客戶端可以把 topic 填入「邊緣運算功耗」,再把整段 prompt 餵給模型。這個模式的價值是「把對話策略集中管理」:客戶端不需要知道每個 prompt 的細節,只需要呼叫 prompts/list 取得清單,再根據使用者需求選用。
FastMCP 用 @mcp.prompt() 裝飾器註冊 prompt。函式簽名裡的參數會自動成為 prompt 的輸入欄位;函式回傳的字串就是 prompt 內容。今天我們會示範兩個 prompt:研究報告與摘要改寫。
三種能力該怎麼選
「這個東西到底應該做成 tool、resource 還是 prompt?」是 MCP 設計裡最常見的取捨問題。一個粗略的判斷原則是:有副作用、需要執行某段邏輯 → tool;是被讀取的靜態或半靜態資料 → resource;是對話策略或使用流程 → prompt。但這條界線不是死的:一段「把資料庫內容摘要給模型」的邏輯既可以做成 tool(summarize_notes(topic))也可以做成 resource(notes://{topic}/summary)。選哪個,取決於「誰觸發它」——由模型決定觸發的,比較像 tool;由客戶端或使用者根據上下文觸發的,比較像 resource。
多工具 server 的命名空間紀律
當一個 server 同時提供十幾個工具、幾個 resource、幾個 prompt 時,命名空間就會開始碰撞。常見的紀律有兩條:第一,工具名稱用動詞開頭(fetch_page、search_web、add_numbers),讓模型一看就知道這是做什麼;第二,resource URI 用 scheme 前綴(notes://、reports://),讓客戶端能用前綴區分資料來源;prompt 則用領域前綴(research_report、chat_summarize),避免不同領域的 prompt 名稱混雜。
第二條紀律在多個 server 並存時更重要:當你同時掛了三個 MCP server,工具名稱可能重複(echo、echo_text 都是合理的命名)。FastMCP 在單一 server 內不允許同名工具,但你可以在 name 裡加上副檔名或前綴(research-agent-basic、research-agent-notes),讓客戶端在呼叫時依 server 區分。今天的範例會在 server name 加上 -research 後綴,示範這個命名空間設計。
完整實作
接下來在昨天建立的 basic_server.py 基礎上,加入 resources 與 prompts。先安裝額外依賴、再擴充 server、最後用官方客戶端驗證三種能力都能正常運作。
第一步,先把我們需要的依賴安裝好。今天會用到 pydantic 做資源內容的結構化驗證,以及昨天的 FastMCP:
cd research-agent
uv add fastmcp pydantic
uv lock
段落說明:FastMCP 在內部已經把 pydantic 列為依賴,這裡再 add pydantic 是為了顯式控制版本,讓團隊其他人能直接看到我們用了什麼 Pydantic(v2 系列)。如果你的環境裡已經有 Pydantic,uv add 不會降級,只會鎖定到與目前 FastMCP 相容的版本。版本具體為何請以 uv.lock 為準。
第二步,建立存放 resources 與 prompts 的子模組。為了讓 server 主檔保持簡潔,我們把這兩種能力拆到獨立的檔案:
touch research-agent/src/research_agent/mcp_servers/resources.py
touch research-agent/src/research_agent/mcp_servers/prompts.py
段落說明:拆檔的理由是「關注點分離」:server 主檔負責啟動與模組組裝,resources 模組只負責資料暴露,prompts 模組只負責對話範本。之後若要新增工具、資源或 prompt,只要在對應的檔案裡加一個函式與一個裝飾器即可,主檔不需修改。
第三步,建立 resource。我們的 resource 會從 AG Day 23 建立的 data/knowledge.db 讀取文獻清單與單一文獻內容。注意資源內容需要是可序列化的純資料:
"""research-agent/src/research_agent/mcp_servers/resources.py
暴露 SQLite 知識庫的內容作為 MCP resources。
"""
import sqlite3
from pathlib import Path
from basic_server import mcp
DB_PATH = Path("data/knowledge.db")
@mcp.resource("research://documents/index")
def documents_index() -> str:
"""列出所有已抓取的文獻(id、標題、抓取時間)。"""
if not DB_PATH.exists():
return "[]"
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(
"SELECT id, title, fetched_at FROM documents ORDER BY fetched_at DESC LIMIT 50"
).fetchall()
finally:
conn.close()
import json
return json.dumps(
[
{"id": row[0], "title": row[1] or "(未命名)", "fetched_at": row[2]}
for row in rows
],
ensure_ascii=False,
)
@mcp.resource("research://documents/{doc_id}")
def document_detail(doc_id: str) -> str:
"""取得單一文獻的標題與抓取時間;doc_id 是文獻識別。"""
if not DB_PATH.exists():
return "{}"
conn = sqlite3.connect(DB_PATH)
try:
row = conn.execute(
"SELECT id, title, fetched_at FROM documents WHERE id = ?",
(doc_id,),
).fetchone()
finally:
conn.close()
if row is None:
return "{}"
import json
return json.dumps(
{"id": row[0], "title": row[1] or "(未命名)", "fetched_at": row[2]},
ensure_ascii=False,
)
段落說明:research:// 是我們為這個 server 選定的 scheme,讓客戶端能用前綴區分這是研究助理提供的資源。資源 URI 內的 {doc_id} 是 FastMCP 的動態參數語法,客戶端呼叫 resources/read 時可以代入實際值,FastMCP 會把這個值傳給函式。我們刻意把 import json 放在函式內部而非模組頂層,這是為了讓資源函式在 SQLite 不存在時也能被 import(不會拋 FileNotFoundError)。實務上若你的 SQLite 一定存在,可以把 import 提到頂層。
第四步,建立 prompt。我們設計兩個 prompt:研究報告(需要 topic 參數)與摘要改寫(需要 text 與 target_length 兩個參數):
"""research-agent/src/research_agent/mcp_servers/prompts.py
研究助理專用的提示範本,集中管理對話策略。
"""
from basic_server import mcp
@mcp.prompt()
def research_report(topic: str) -> str:
"""產生研究報告的提示範本;topic 為研究主題。"""
return (
"你是一名研究助理,請根據使用者提供的來源段落,"
f"撰寫一份關於「{topic}」的研究報告。\n"
"規則:\n"
"1. 每個事實性敘述後面加上 [n] 編號,n 為引用編號。\n"
"2. 一句話可引用多個來源,例如 [1][3]。\n"
"3. 若來源不足請明確寫『來源不足』,不要臆測。\n"
"4. 結論放在報告開頭,論點依重要性排列。\n"
"5. 全文使用繁體中文 Markdown,長度控制在 1500 字以內。"
)
@mcp.prompt()
def chat_summarize(text: str, target_length: int = 200) -> str:
"""把一段對話或文章摘要成指定字數。"""
return (
"請把下列文字摘要成約 " + str(target_length) + " 個繁體中文字:\n\n"
+ text
)
段落說明:research_report 接收一個 topic 參數,函式把參數嵌入 prompt 字串後回傳。FastMCP 會把這個函式連同它的參數清單一起暴露給客戶端,客戶端在使用時填入實際值。chat_summarize 則展示「帶預設值的參數」:target_length 預設 200,但客戶端可以覆寫。把這兩個 prompt 放在同一個檔案是為了「對話策略集中管理」;未來新增 prompt 只要在同一個檔案裡加函式即可。
第五步,把 resources 與 prompts 模組 import 進主檔,讓 server 啟動時自動收集所有能力:
"""research-agent/src/research_agent/mcp_servers/basic_server.py
包含三種能力的 FastMCP server:tools、resources、prompts。
"""
from fastmcp import FastMCP
mcp = FastMCP(name="research-agent-basic")
@mcp.tool()
def echo(text: str) -> str:
"""回傳收到的字串;用於驗證通訊是否正確。"""
return text
@mcp.tool()
def add(a: int, b: int) -> int:
"""把兩個整數相加並回傳結果。"""
return a + b
# 匯入其他模組;副作用是把它們的 @mcp.tool / @mcp.resource / @mcp.prompt 收集進來
from . import resources # noqa: E402, F401
from . import prompts # noqa: E402, F401
if __name__ == "__main__":
mcp.run()
段落說明:這是昨天介紹過的「模組化 import」慣例的延伸版。當一個檔案同時引入多個子模組時,請用註解說明這個 import 是為了觸發註冊,而不是真的要用這個符號,否則下一個讀你程式碼的人會以為是 bug 而把它刪掉。noqa 註解跟昨天一樣用來忽略 lint 警告。
第六步,用客戶端測試三種能力。我們擴充昨天的 stdio_client.py,把 list 與 read 工具/資源、取得 prompt 都跑一次:
"""research-agent/scripts/mcp_min/full_client.py
測試 basic_server 的三種能力:tools、resources、prompts。
"""
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
params = StdioServerParameters(
command="python",
args=["src/research_agent/mcp_servers/basic_server.py"],
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
resources = await session.list_resources()
prompts = await session.list_prompts()
print("=== Tools ===")
for tool in tools.tools:
print(f"- {tool.name}: {tool.description}")
print("=== Resources ===")
for resource in resources.resources:
print(f"- {resource.uri}: {resource.name}")
print("=== Prompts ===")
for prompt in prompts.prompts:
print(f"- {prompt.name}: {prompt.description}")
# 讀取資源
content = await session.read_resource("research://documents/index")
print("=== 文獻清單 ===")
print(content.contents[0].text)
# 取得 prompt
prompt = await session.get_prompt(
"research_report",
{"topic": "邊緣運算功耗"},
)
print("=== research_report prompt ===")
print(prompt.messages[0].content.text)
if __name__ == "__main__":
asyncio.run(main())
段落說明:list_resources() 回傳所有 resource 的 URI 與名稱;read_resource() 接受一個 URI 並回傳資源內容。get_prompt() 則需要傳入 prompt 名稱與參數字典;回傳的 messages 結構遵循 MCP 規格,每個 message 帶有 role 與 content,內容物件可以是文字或圖片。今天我們只用到文字內容。執行後你會看到類似以下的輸出:
uv run python scripts/mcp_min/full_client.py
=== Tools ===
- echo: 回傳收到的字串;用於驗證通訊是否正確
- add: 把兩個整數相加並回傳結果
=== Resources ===
- research://documents/index: documents_index
- research://documents/{doc_id}: document_detail
=== Prompts ===
- research_report: 產生研究報告的提示範本;topic 為研究主題
- chat_summarize: 把一段對話或文章摘要成指定字數
=== 文獻清單 ===
[{"id": "https://example.invalid/edge-power", "title": "(示範頁面)", "fetched_at": "2026-08-07T..."}]
=== research_report prompt ===
你是一名研究助理,請根據使用者提供的來源段落,撰寫一份關於「邊緣運算功耗」的研究報告。
規則:
1. 每個事實性敘述後面加上 [n] 編號,n 為引用編號。
2. 一句話可以引用多個來源,例如 [1][3]。
...
段落說明:上面看到的資源內容是 dry-run 與示意資料:你的 SQLite 若還沒有真實抓取紀錄,會得到空清單或預設值。Prompt 內容則是我們剛剛設計的範本,topic 被順利代入「邊緣運算功耗」。這個端到端測試確認了三件事:tools、resources、prompts 都被正確註冊;resource 能讀到 SQLite 資料;prompt 的參數能被正確填入。
第七步,做權限與範圍的最小區隔。雖然 MCP 規格本身不包含存取控制,但實務上你會希望「某些資源只能給特定使用者讀取」。最簡單的做法是在 resource 函式內部加上檢查:
"""research-agent/src/research_agent/mcp_servers/resources.py(節錄)
把權限檢查包裝成裝飾器,所有 resource 共用同一套邏輯。
"""
from functools import wraps
from basic_server import mcp
def require_role(role: str):
"""簡單的角色檢查裝飾器;正式產品請改用真實的身分驗證。"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
current = kwargs.pop("_current_role", "anonymous")
if current != role:
return "{\"error\": \"權限不足\"}"
return func(*args, **kwargs)
return wrapper
return decorator
@mcp.resource("research://admin/audit")
@require_role("admin")
def audit_log() -> str:
"""回傳最近的審查紀錄;僅限 admin 角色讀取。"""
return "[]"
段落說明:require_role 是教學用的極簡實作,它用一個隱含的 _current_role 參數決定是否放行。真正的權限檢查應該依賴外部的身分驗證系統(例如 OAuth、JWT),而不是把角色藏在函式參數裡。今天的範例只是示範「資源函式可以加上存取邏輯」,正式產品請把權限邏輯交給你的身分驗證層。這與 AG Day 37 談 prompt injection 與工具防護時會再次碰到的「信任邊界」主題有關。
第八步,把今天的進度記錄下來,並為明天的 client 章節預備。我們把 server 的三種能力整理成一張表,方便之後查閱:
"""research-agent/src/research_agent/mcp_servers/manifest.py
伺服器能力清單,供客戶端與部署工具使用。
"""
CAPABILITIES = {
"tools": [
{"name": "echo", "description": "回傳收到的字串"},
{"name": "add", "description": "把兩個整數相加"},
],
"resources": [
{"uri": "research://documents/index", "name": "documents_index"},
{"uri_pattern": "research://documents/{doc_id}", "name": "document_detail"},
{"uri": "research://admin/audit", "name": "audit_log", "role": "admin"},
],
"prompts": [
{"name": "research_report", "params": ["topic"]},
{"name": "chat_summarize", "params": ["text", "target_length"]},
],
}
def render() -> str:
import json
return json.dumps(CAPABILITIES, ensure_ascii=False, indent=2)
if __name__ == "__main__":
print(render())
段落說明:這個 manifest 不是 MCP 規格的一部分,它是我們自己寫的「對外公告」。當客戶端需要知道「這個 server 提供哪些能力」的靜態描述時,可以直接讀這個檔;當部署工具(例如 CI 流程或安全掃描)需要知道 server 暴露的表面積時,也可以用它來審查。把 manifest 與實際註冊保持同步是個工程紀律,可以在 server 啟動時自動產生,避免手動維護造成的落差。
常見錯誤與踩雷
第一個錯誤是 resource 的 URI 太通用了。像是 notes://all 這種 URI 看起來簡潔,但客戶端無法依 URI 模式做自動化處理;請用「scheme://類型/識別」的結構(例如 notes://user/2026-08-05),保留足夠的命名空間深度。
第二個錯誤是把 prompt 寫成純字串模板卻忘了帶參數。如果 prompt 裡寫了 {topic} 卻沒在函式簽名宣告 topic: str,客戶端不會知道要代入什麼。請把參數宣告與使用視為同一件事,缺一就會出現「客戶端送參數、伺服器忽略」或「客戶端沒送參數、伺服器拋例外」兩種失敗模式。
第三個錯誤是 resource 函式回傳值無法序列化。MCP 規格要求資源內容是可序列化的純資料。如果你回傳了一個 ORM 物件或自訂類別實例,序列化會失敗。請把資料先轉成 dict/list/基本型別,再用 JSON 字串包起來(範例裡用 json.dumps(...) 就是為了這個)。
第四個錯誤是忘記處理資源不存在。當客戶端送來 research://documents/no-such-id,我們的函式若沒處理「查不到」的情況,會回傳空字串或拋例外。請在函式內部處理空值並回傳明確的錯誤結構,例如 {"error": "not_found"},讓客戶端能區分「資源不存在」與「伺服器錯誤」。
第五個錯誤是 prompt 內容太長。prompt 會被整段塞進模型上下文,寫了一萬字的「完整對話策略」反而會擠壓後續訊息的空間。請把 prompt 控制在合理長度(通常一千字以內),把細節放在工具說明或 system prompt 裡。
第六個錯誤是把工具、資源、提示三者的命名混用。例如把 fetch_note 同時做成 tool 與 resource,客戶端就搞不清楚該用 tools/call 還是 resources/read。三種能力各有其使用情境,請在設計時先決定歸屬,事後才發現衝突要改的話,所有客戶端都會受影響。
第七個錯誤是 server name 沒換。當你複製 basic_server.py 改成另一個 server 卻忘記改 name,兩個 server 會在客戶端用相同名字出現,造成混淆。請把 server name 視為版本的一部分,每次改 server 都 bump 一下。
效能與實務提醒
先談 resource 的存取成本。讀 SQLite 通常很快,但若資源函式會觸發昂貴的計算(例如把整個知識庫做摘要),請考慮兩件事:第一,把昂貴的部分事先算好、存成另一個 resource URI(例如 research://summaries/2026-08-07),讓客戶端讀的是快取版本;第二,把昂貴運算移到背景行程,不要在 resource 函式內同步執行。這對應到 AG Day 31 的長任務主題。
再談 prompt 的快取。同一個 prompt 被大量重複使用時,可以考慮把填好參數的版本快取起來,避免每次都要重新拼接字串。但快取的代價是「如果 prompt 內容改了,已快取的版本不會自動失效」——請用 server name 或版本號作為快取鍵的一部分。
第三是權限與審查。當 server 同時提供公開資源與內部資源時,請在程式裡明確標示哪些資源是敏感的,並考慮加上審查紀錄(誰在什麼時候讀了什麼)。這個紀錄在 AG Day 35 談 Langfuse 時會再次出現,今天先把架構預留好。
第四是錯誤回傳的一致性。Resource 函式可以回傳「空字串」也可以回傳「{"error": "..."} JSON」,但請在所有 resource 函式裡遵守同一個約定。例如我們這裡選擇「錯誤也用 JSON 包」,這樣客戶端只要解析一次就知道結果。這個紀律對客戶端的程式碼複雜度影響很大。
第五是多 server 的負載平衡。當一台機器上同時跑了多個 MCP server,每個 server 都佔用一份記憶體與 stdin/stdout 通道。在本機開發時這不是問題,但若要把 server 部署到容器裡(AG Day 39),請考慮「每個容器只跑一個 server」的設計,避免一個 server 故障影響其他 server。
小結
今天我們把 MCP server 的另外兩種能力補齊,並示範了如何把它們組合起來。重點整理如下:
- Resources:用 URI 識別、由客戶端或上下文觸發、可被快取的靜態或半靜態資料。今天示範了 SQLite 知識庫的索引與單一文獻讀取。
- Prompts:可帶參數的對話範本,集中管理「如何跟模型對話」的策略。今天示範了研究報告與摘要改寫兩個範本。
- 命名空間紀律:工具用動詞、resource 用 URI scheme、prompt 用領域前綴;server name 視為版本的一部分。
- 權限與隔離:resource 函式可以加上存取邏輯;正式產品請把身分驗證交給外部系統。
- 模組化 import:所有裝飾器作用在同一個
FastMCP實例上,子模組用 import 副作用註冊。
今天的 server 已經具備了完整的 MCP 三能力組合。明天我們會從伺服器這一端轉到客戶端這一端:把 LangGraph 流程(AG Day 11 到 AG Day 18)整合進 MCP 客戶端,讓 research-agent 可以自動偵測、連線、呼叫任何遵循 MCP 的外部伺服器。這是從「自己寫工具」到「使用整個生態」的轉折點。
結語
今天我們把 server 從「單一能力的員工」擴張成「員工加圖書館加顧問公司」的三角色組合。Resources 讓客戶端能讀取背景資料,prompts 讓伺服器貢獻對話策略,而 tools 仍然是「做實際工作」的主力。三者搭配得當時,整個 MCP 生態就能形成正向迴圈:越多資源被暴露,越多 prompt 被使用,越多工具被呼叫,價值就越大。
明天,我們會進入「AG Day 28 MCP client:把研究助理接上 MCP 生態」,把視角從伺服器換到客戶端。我們會用官方 mcp.client 介面把多個 MCP server 接進 research-agent,讓代理在執行任務時能動態發現並呼叫外部工具與資源。我們也會把今天的 server 與 LangGraph 流程串起來,讓 research-agent 第一次具備「工具可插拔」的能力。
延伸資源
- Model Context Protocol 官方規格:
https://modelcontextprotocol.io/。resources、prompts、tools 三種能力的最新規範與版本演進。 - FastMCP 官方說明:
https://github.com/modelcontextprotocol/python-sdk。三種能力的裝飾器語法、URI 動態參數與權限設計範例。 - MCP Inspector 工具:
https://github.com/modelcontextprotocol/inspector。用圖形介面對 server 發送 list/read/call 等請求,驗證今天的範例。 - SQLite 官方說明:
https://sqlite.org/lang_select.html。resource 函式所用的查詢語法以此為準。 - Pydantic v2 官方說明:
https://docs.pydantic.dev/latest/。資源與 prompt 的結構化驗證與型別管理;之後 FastMCP 的內部結構也會用到。
留言
張貼留言