CareerWise MCP 整合實戰:把 LLM 的工具從硬寫變成模組化

CareerWise 是一個以 LLM 為核心的職涯諮詢網站,提供履歷健檢、職涯建議與預約諮詢。使用者可以詢問轉職、面試、薪資談判等問題,我們透過知識庫搭配 Groq 的 LLM 來提供 grounded 的建議。

為了讓 LLM 不只是聊天,還能查資料、讀檔案、做事,我們實作了 function calling。一開始只有一個 searchKnowledge 工具,手寫 JSON schema 還算簡單。但當工具愈來愈多——查履歷、讀取使用者記憶、寫入偏好設定——手寫定義開始失控。這篇文章記錄 CareerWise 從一個純粹只有 JSON schema 的 searchKnowledge 工具,逐步導入 MCP(Model Context Protocol),最後擁有 4 個 MCP Server、10 個 Tools、3 個 Resources 的完整過程。

背景:原本的架構與問題

現狀

先來看看導入 MCP 之前,CareerWise 的工具是怎麼運作的。

CareerWise 使用 Groq SDK 的 function calling 機制讓 LLM 呼叫工具。這個機制的概念是:定義一個 JSON schema 描述工具的名稱、參數、說明,然後在呼叫 LLM 時把 schema 傳給它,LLM 判斷需要時就會回傳一個 tool_call,你再根據這個呼叫去執行對應的函數。

聽起來很直覺,對吧?CareerWise 原本就是這樣做的,但隨著功能增加,問題開始浮現。

原本的實作有四個主要元件:

這樣有什麼問題嗎?實際上問題還不少。

問題

面向 現狀 問題
工具定義 每個工具是手寫的 function JSON schema 新增工具需改 chat.ts;無標準化介面
工具發現 tools 陣列寫死在 chat.ts 第 50 行 無法動態發現新工具;不同 intent 需手動掛載
錯誤處理 searchKnowledge 回傳字串「(知識庫無相關結果)」 非結構化錯誤,LLM 無法精確判斷失敗原因
個人化資料 Profile/Memory 透過 formatProfileContext() 注入 system prompt LLM 被動接收,無法自主決定何時讀取
LINE 整合 純被動 Webhook,依賴 getReply() 無法組合多源資料;無法主動推播
外部資料 完全依賴 Summer 的靜態知識庫 無法回答「現在市場行情」等即時問題
市場資料 無資料源 Mock 資料無法反映真實市場
Agent 架構 3 個 Agent 寫死在 orchestrator 的 DOMAIN_QUERIES 新增 Agent 需改 orchestrator 程式碼
環境變數傳遞 MCP 子程序無 process.env 子程序無法使用 GROQ_API_KEY 等環境變數

這些問題的核心其實就是所有的東西都黏在一起,動一個就牽動全部

導入目標

為了解決這些問題,我們決定在 CareerWise 中建立 MCP(Model Context Protocol)基礎設施,分五階段導入:

  1. A — 知識庫搜尋 MCP Server(建立基礎模式)
  2. F — Profile/Memory MCP Resources(個人化資料標準化)
  3. D — LINE Bot 升級為 MCP Client(主動整合)
  4. B — 外部資料源 MCP Server(Curated 市場數據,非即時 API)
  5. E — 平行 Agent 升級為 MCP 生態系(可插拔架構)

階段的代號不是照字母順序排的。原因是導入順序是根據相依性和風險來安排的:A 是基礎建設,F 是把現有功能搬到 MCP,D 是應用層整合,B 和 E 是新增功能。每個階段都可以獨立上線,不需要等全部完工。

MCP 是什麼?為什麼需要它?

MCP 是一個標準化協議,定義 LLM 怎麼跟外部工具和資料源溝通。MCP 的概念可以分成幾個角色:

在 CareerWise 的場景,Groq 是 LLM(大腦),MCPClient class 是翻譯官(負責把 MCP 的工具轉譯成 Groq 看得懂的格式),knowledge-server、profile-server 這些是執行者。這就像是把原本亂七八糟的線路整理成標準的插座和插頭,要加什麼工具,插上去就好。

討論過程:關鍵決策

在導入過程中,我們做了幾個關鍵決策。以下的討論濃縮了多次來回討論的結論,每個決策背後都有取捨。

決策 1:MCP Client 架構(階段 A)

問題:MCP Client 要怎麼跟 Groq 整合?

有兩個選項:

選擇:通用 MCP Client 層。

理由是雖然最小改動看起來很快,但只要多一個工具就要再改一次 chat.ts。建立抽象層雖然初期成本高一點,但一次建立完成後,後續所有 MCP Server 自動支援。chat.ts 只要初始化 Manager + 取得 tool list,不需要為每個工具寫 adapter。未來 intent 路由層也可以透過 MCP 動態決定掛載哪些工具。

決策 2:部署模式(階段 A)

問題:FastMCP Server 執行在哪?HTTP 還是本機?

選擇:STDIO 本機程序。

為什麼不選 HTTP?知識庫檔案在本機,開 HTTP server 還要多一層網路延遲。STDIO 是透過標準輸入輸出跟子程序溝通,延遲最低(IPC 而非 HTTP round-trip),資料也不外洩。Server 的生命週期由 mcp-manager.ts 統一管理(啟動、關閉、重啟)。

決策 3:Profile/Memory 暴露方式(階段 F)

問題:個人化資料要用 MCP 的 Resources 還是 Tools 暴露?

選擇:Resources + Tools 混合。

這題糾結了一下。MCP 的 Resources 適合讀取靜態或半靜態資料(如 profile),Tools 適合寫入或查詢操作(如 save_memorysearch_memories)。最後決定兩者都用,符合 MCP 的原始定位:Resources 是「什麼」,Tools 是「做什麼」。

決策 4:LINE Bot 架構(階段 D)

問題:LINE Bot 怎麼跟 MCP 整合?

選擇:LINE 直接作為 MCP Client。

LINE webhook handler 初始化時連線 MCP Manager,這樣 LINE Bot 可以並行查詢多個 MCP Server 再組合回應。未來還可以支援主動推播,如預約提醒、每週職涯 tip。

決策 5:外部資料優先項目(階段 B)

選擇:薪資/職缺資料優先。

理由很簡單:這是最常被問的即時問題。「前端工程師現在薪水多少?」「台灣 React 缺多嗎?」現有知識庫只能給 Summer 的主觀判斷,無法提供即時市場數據。

決策 6:平行 Agent MCP 架構(階段 E)

問題:每個 Agent 獨立 Server 還是單一聚合 Server?

選擇:單一聚合 Agents MCP Server。

如果每個 Agent 一個 Server,orchestrator 就要管理 3 個 Client 連線,每個 Server 各自初始化 Groq client。單一聚合的好處是改動最小、共用 Groq client 避免重複初始化、記憶體效率更高(單 process vs 3 processes)。同時保留未來升級路徑:handler 可以隨時拆成獨立 Server。

設計規格

接下來是各階段的詳細設計。如果你對程式碼不感興趣,可以直接跳到「使用者體驗的變化」看 summary。

A — 知識庫 MCP Server

最終檔案結構

這個架構是所有階段的共同基礎,先列出來讓你有個全局概念:

src/
├── data/
│   └── market-data.json       # Curated 薪資/市場資料(Summer 維護)
├── lib/
│   ├── market-data.ts         # 市場資料載入器
│   └── mcp/
│       ├── client.ts          # 通用 MCPClient class(STDIO 傳輸)
│       ├── types.ts           # MCP/Groq 型別定義
│       ├── mcp-manager.ts     # Server 生命週期管理 + 路由
│       └── servers/
│           ├── knowledge-server.ts  # 知識庫搜尋(1 tool)
│           ├── profile-server.ts    # Profile/Memory(3 tools + 3 Resources)
│           ├── market-data-server.ts # 市場資料(3 tools)
│           └── agents-server.ts     # 平行 Agent(3 tools)

MCPClient class(client.ts)

這是整個 MCP 整合的核心抽象層。它封裝了跟一個 MCP Server 的所有溝通:

class MCPClient {
  constructor(
    private serverName: string,
    private command: string,
    private args: string[],
    private env?: Record<string, string>,  // ← process.env 傳遞給子程序
  )

  async connect(): Promise<void>     // 連線 Server → 動態發現工具
  getGroqTools(): GroqFunctionDefinition[]   // Groq-compatible function definitions
  hasTool(name: string): boolean
  async callTool(name: string, args: Record<string, unknown>): Promise<MCPToolResult>
  async disconnect(): Promise<void>
}

核心流程

  1. connect() → 發送 initialize + tools/list 請求
  2. Server 回傳工具清單(名稱、描述、參數 schema)
  3. getGroqTools() → 自動轉譯為 Groq 的 function JSON schema
  4. callTool() → 攔截 Groq 的 tool_call,轉發給對應 MCP Server
  5. Server 執行後回傳結構化結果

這裡設計的重點是 env 參數:MCP Server 以子程序方式執行(STDIO),如果沒有把 process.env 傳遞給子程序,Server 就拿不到 GROQ_API_KEY 等環境變數。這是實作時踩到的坑之一,後面會詳細說。

Knowledge Server(servers/knowledge-server.ts)

Knowledge Server 的程式碼長這樣,它使用 fastmcp 這個套件來建立 MCP Server:

import { FastMCP, jsonSchemaAdapter } from "fastmcp"

const server = new FastMCP({ name: "knowledge-server", version: "0.1.0" })

server.addTool({
  name: "searchKnowledge",
  description: "搜尋 Summer 的職涯知識庫…",
  parameters: jsonSchemaAdapter({
    type: "object",
    properties: {
      query: { type: "string", description: "搜尋查詢字串…" },
    },
    required: ["query"],
  }),
  execute: async (args: unknown) => {
    const { query } = args as { query: string }
    const { searchSimilar } = await import("@/lib/embed")
    const results = await searchSimilar(query, 3)
    if (results.length === 0) {
      return { content: [{ type: "text", text: "(知識庫無相關結果)" }], isError: true }
    }
    return {
      content: results.map((r, i) => ({ type: "text", text: `[${i + 1}] ${r}` })),
    }
  },
})

// Top-level await → 用 IIFE 包裝以相容 CJS
;(async () => { await server.start({ transportType: "stdio" }) })()

關於這段程式碼有幾個點可以注意:

相較現有的改善

面向 現有 search-knowledge.ts MCP Knowledge Server
工具定義 手寫 JSON schema FastMCP 自動產生 + zod 驗證
回傳格式 純字串 結構化 ToolResult
錯誤處理 回傳「(知識庫無相關結果)」字串 isError: true + 結構化錯誤
發現機制 寫死在 chat.ts 動態查詢 tools/list

MCP Manager(mcp-manager.ts)

MCP Manager 管理所有 MCP Server 的生命週期。它是一個 singleton,在應用啟動時初始化:

const SERVERS: MCPServerConfig[] = [
  { name: "knowledge-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/knowledge-server.ts"] },
  { name: "profile-server",   command: "npx", args: ["tsx", "src/lib/mcp/servers/profile-server.ts"] },
  { name: "market-data-server", command: "npx", args: ["tsx", "src/lib/mcp/servers/market-data-server.ts"] },
  { name: "agents-server",    command: "npx", args: ["tsx", "src/lib/mcp/servers/agents-server.ts"] },
]

class MCPManager {
  private clients: MCPClient[] = []

  async startAll(): Promise<void>   // 啟動所有 Server,傳遞 process.env
  getAllGroqTools(): GroqFunctionDefinition[]  // 所有工具轉 Groq 格式
  async callTool(name: string, args: unknown): Promise<MCPToolResult> // 路由
  async shutdown(): Promise<void>   // 優雅關閉
  get isReady(): boolean            // 至少一個 Server 連線成功
}

注意到每個 Server 都是用 npx tsx 啟動的 TypeScript 檔案。tsx 是一個 TypeScript 執行器,讓你可以直接跑 .ts 檔而不需要先編譯。生產環境下會改用 node + 編譯後的 .js 檔。

F — Profile/Memory MCP Server

這個階段把個人化資料從 system prompt 搬到 MCP 的標準介面。原本的做法是每次對話前把 profile 和記憶全部塞進 system prompt,LLM 被動接收全部資訊,沒有選擇的權利。

檔案

src/lib/mcp/servers/
├── profile-server.ts      # 新增

Resources

MCP 的 Resources 概念類似 REST API 的資源,LLM 可以透過 URI 讀取。Profile Server 定義了三個 Resources:

Resource URI 方法 回傳 說明
profile://{uid}/current Resource 讀取 Profile JSON 使用者當前 profile(角色、年資、技能、偏好)
profile://{uid}/preferences Resource 讀取 Preferences JSON 使用者風格偏好
memory://{uid}/recent?limit=5 Resource 讀取 Memory[] 近期記憶列表

Tools

除了被動讀取的 Resources,還有三個主動操作的 Tools:

Tool 參數 說明
save_memory content: string 寫入新記憶(uid 由 chat.ts 自動注入)
search_memories query: string, limit?: number 語意搜尋記憶(uid 由 chat.ts 自動注入)
update_profile current_role?, years?, industry?, skills?, goals?, pain_points? 更新 profile(uid 由 chat.ts 自動注入)

uid 注入機制

有一個實作上的巧思值得說明。注意到表格裡每個 tool 都寫了「uid 由 chat.ts 自動注入」。為什麼要這樣設計?因為 LLM 不應該知道內部的 uid。uid 是系統的內部識別碼,不是使用者會提供的資訊。所以 chat.ts 在呼叫 MCP tool 時會自動把 uid 加上去:

const mcpResult = await mcpManager.callTool(tc.function.name, { ...args, uid })

這樣 LLM 只需要關心它該關心的參數(如 querycontent),不需要知道 uid 的存在。

與現有系統的對接

以下是 profile-server.ts 的實作片段,可以看到 Resource 和 Tool 如何對接到現有的 @/lib/profile@/lib/memory 模組:

server.addResourceTemplate({
  name: "Current Profile",
  uriTemplate: "profile://{uid}/current",
  arguments: [{ name: "uid", required: true }],
  load: async ({ uid }) => {
    const { loadProfile } = await import("@/lib/profile")
    const profile = await loadProfile(uid)
    return { text: JSON.stringify(profile || {}), mimeType: "application/json" }
  },
})

server.addTool({
  name: "search_memories",
  parameters: jsonSchemaAdapter({
    type: "object",
    properties: {
      query: { type: "string", description: "搜尋關鍵字" },
      limit: { type: "number" },
    },
    required: ["query"],
  }),
  execute: async (args: unknown) => {
    const { query, limit, uid } = args as { query: string; limit?: number; uid: string }
    const { searchMemories } = await import("@/lib/memory")
    const memories = await searchMemories(uid, query, limit || 5)
    // uid 由 chat.ts 注入,不是 LLM 提供的
  },
})

D — LINE Bot MCP Client

LINE Bot 原本是一個純被動的 webhook,只能轉發訊息。升級為 MCP Client 後,它可以並行查詢多個 MCP Server 再組合回應。

修改範圍

檔案 修改內容
src/app/api/line/route.ts 預熱 MCP Server + 傳遞 lineUserId 作為 uid
src/lib/mcp/mcp-manager.ts 共用 singleton(LINE 與 Web 使用同一 instance)

關鍵決策是 LINE 和 Web 共用同一個 MCPManager instance。這樣做的好處是:MCP Server 只需要啟動一次,LINE 和 Web 請求都透過同一個 Manager 路由。如果 LINE 和 Web 各自維護自己的 MCPManager,就會有 4 個 Server × 2 = 8 個子程序,浪費記憶體。

流程

使用者傳 LINE 訊息
    │
    ▼
Webhook handler
    │  ├─ 預熱:await mcpManager.startAll()
    │  ├─ getReply(msg, history, { uid: lineUserId })
    │  │   └─ MCP 工具可自動使用(含 profile/memory 個人化)
    │  └─ 回傳 LINE 訊息

實作重點

// src/app/api/line/route.ts  — 關鍵改動

import { mcpManager } from "@/lib/mcp/mcp-manager"

// 1. 預熱 MCP Server(阻塞等待,確保工具可用)
if (!mcpManager.isReady) {
  await mcpManager.startAll()
}

// 2. 傳遞 uid,啟用個人化 + MCP uid 注入
const reply = await getReply(userMessage, history, { uid: lineUserId })

這裡的 mcpManager.isReady 是一個 getter,檢查至少一個 Server 連線成功。如果沒有任何 Server 啟動(例如缺少環境變數),LINE Bot 仍然可以正常回應,只是不經 MCP 工具。

主動推播(未來擴充)

LINE MCP Client 未來可以支援主動推播,不需要等到使用者傳訊息才回應:

B — 外部資料源 MCP Server

這個階段要做的是讓 CareerWise 可以回答即時的市場問題,像是「前端工程師現在薪水多少?」。

資料來源調查

首先調查台灣科技業薪資資料有哪些公開 API 可用,結果不太樂觀:

資料源 結果 原因
Glassdoor API ❌ 不開放 需 Partner ID,非公開
Indeed API ❌ 需審核 需 Publisher 帳戶
104 API ❌ 不開放 僅限企業合作夥伴
政府開放資料 ❌ 粒度不足 產業別,非職位別

解決方案:Curated JSON + Data Loader

既然沒有合適的 API,解法就變成:由 Summer(領域專家)維護一份策展的市場資料 JSON 檔。流程是:

Phase 1 (Mock) → 實作發現無合適 API → Phase 2+3 (Curated JSON)
檔案 用途
src/data/market-data.json 策展資料 — 10 角色 × 3 級薪資 + 技能 + 趨勢,含資料來源引用
src/lib/market-data.ts 資料載入器 — findRoles(), formatSalary(), formatDemand() 等 helper

維護方式

Summer 可以直接編輯 src/data/market-data.json,不需要寫程式。例如:

{
  "meta": {
    "last_updated": "2026-07-26",
    "sources": ["104 薪資情報", "CakeResume 2025 報告"]
  },
  "roles": {
    "前端工程師": {
      "title": "前端工程師",
      "salary": {
        "junior": { "min": 550000, "median": 700000, "max": 900000 },
        "mid":    { "min": 800000, "median": 1000000, "max": 1300000 },
        "senior": { "min": 1200000, "median": 1500000, "max": 2000000 }
      },
      "demand": "high",
      "trend": "growing",
      "hot_skills": ["React", "Vue", "TypeScript", "Next.js"]
    }
  }
}

每個角色有三級薪資(junior / mid / senior),附 median(中位數)讓 LLM 回答時有個基準值。demandtrend 讓 LLM 回答市場趨勢問題時有依據。

Tools(與 Mock 時期相容)

在導入 Curated JSON 之前,市場資料是 Mock 的。這三個工具的設計同時相容 Mock 和 Curated JSON 兩種模式:

Tool 說明 變更
get_salary_range 薪資查詢,支援 experience 參數(junior/mid/senior) 新增經驗等級
get_market_trends 市場趨勢,動態從 JSON 生成 動態資料取代硬編碼
get_job_demand 需求分析 改用 market-data.ts 計算

E — 平行 Agent MCP Server

原本的平行 Agent 系統是 CareerWise 處理複雜問題的核心。當使用者問跨領域問題(如「該不該從前端轉職到 AI」),系統會同時啟動三個 Agent 分析不同面向。原本的 Agent 定義寫死在 orchestrator 的 DOMAIN_QUERIES 裡,新增 Agent 就要改 orchestrator 程式碼。

修改範圍

檔案 修改內容
src/lib/mcp/servers/agents-server.ts 新增 — 聚合 3 個 Agent 的 MCP Server
src/lib/parallel/orchestrator.ts 移除 DOMAIN_QUERIESrunAllAgents(),改為透過 MCP Client 呼叫
src/lib/parallel/agents.ts 保持不變 — agent 邏輯仍在原處

注意 agents.ts 保持不變,這是不改變既有邏輯的關鍵。我們只把「呼叫 Agent」這件事從直接函數呼叫改成 MCP 呼叫,Agent 內部怎麼分析、怎麼產出結果,完全不變。

Agent Server 設計

每個 Agent tool 是 self-contained 的,內部自己做 KB search → runAgent()

server.addTool({
  name: "analyze_market",
  description: "市場分析:分析使用者在職場市場上的競爭力與薪資水平",
  parameters: jsonSchemaAdapter({ ... }),
  execute: async (args: unknown) => {
    const { question } = args as { question: string }
    const { searchSimilar } = await import("@/lib/embed")
    const { runAgent } = await import("@/lib/parallel/agents")
    const chunks = await searchSimilar("薪資 談判 市場行情 職缺趨勢", 2)
    const result = await runAgent("market", question, chunks.join("\n\n"))
    return { content: [{ type: "text", text: JSON.stringify(result) }] }
  },
})

Orchestrator 改為 MCP Client 平行呼叫:

// orchestrator.ts — runAllAgentsViaMCP()
const AGENT_TOOLS = ["analyze_market", "advise_growth", "coach_interview"] as const

const results = await Promise.allSettled(
  AGENT_TOOLS.map(async (tool) => {
    const result = await mcpManager.callTool(tool, { question: message })
    if (result.isError) return null
    const json = result.content.map((c) => c.text).join("")
    return JSON.parse(json) as AgentResponse
  }),
)
// 合併結果 → mergeResponses() 產出報告

使用 Promise.allSettled 而不是 Promise.all 的原因是:如果其中一個 Agent 失敗(timeout 或 error),不應該影響其他 Agent 的結果。allSettled 會等所有 Promise 完成(不論成功或失敗),然後 mergeResponses() 再決定哪些結果可以合併。

與舊架構的差異

面向 舊架構 新架構
KB 搜尋 3 個 agent 共用一次平行搜尋 每個 agent tool 內部自行搜尋
通訊方式 直接函數呼叫 STDIO 子程序 + MCP JSON-RPC
可發現性 寫死在 DOMAIN_QUERIES 動態 tools/list
可擴充性 需改 orchestrator.ts 新增 handler 註冊即可

使用者體驗的變化

前面都在講架構,但對使用者來說,這些改動到底差在哪?

階段 A 最有感的是回應速度。 以前每個問題不管難易,系統都會先搜一遍知識庫再說。問「嗨」也要等 embedding 跑完才打招呼。現在 LLM 自己判斷要不要查,簡單問題秒回、需要查的才查。同時我們在畫面上加了代理活動饋送,當 LLM 決定查知識庫時,使用者會看到「正在搜尋知識庫…」的提示,不是空白乾等。

階段 F 讓對話變得更像「人與人」的互動。 以前使用者說「我對雲端技術有興趣」,這筆資訊存進記憶,但下次對話 LLM 還是從零開始 — 因為記憶全部塞在 system prompt 裡,LLM 不會主動翻找。改用 MCP 後,LLM 可以自主決定「這個問題我需要查一下使用者的記憶」,然後去讀取相關記錄。使用者不用重複說「我之前說過…」,對話變得更連貫。

階段 D 把 LINE Bot 從應聲蟲變成有記憶的助手。 原本 LINE 上的對話跟 Web 是兩個世界,LINE 使用者沒有個人化體驗。現在 LINE 的 webhook 在初始化時連線 MCP Manager,拿到跟 Web 一樣的 profile 和 memory 工具,使用者不管在哪個平台,體驗是一致的。

階段 B 讓薪資問題從「我覺得」變成「資料說」。 以前回答「前端工程師薪水多少」,Summer 的知識庫只能給主觀判斷。現在 LLM 會呼叫市場資料工具,回傳 junior/mid/senior 三級薪資範圍,還附資料來源。使用者看到的不再是模糊的建議,而是「根據 104 薪資情報,前端工程師 junior 中位數約 70 萬」。

階段 E 讓複雜問題的分析更有層次。 以前複雜問題就是一個 LLM 呼叫從頭回到底。現在三個 Agent 平行分析市場、成長、面試三個面向,最後合併成一份結構化報告。使用者看到的不是一大段文字,而是分章節的分析,閱讀體驗好很多。

實作結果

已安裝依賴

導入 MCP 需要的套件很輕量:

套件 版本 用途
fastmcp latest FastMCP Server 框架
@modelcontextprotocol/sdk latest MCP Client SDK(STDIO transport)
vitest latest 測試框架(已安裝)

最終檔案列表

src/
├── data/
│   └── market-data.json                  # Curated 市場資料(10 角色 × 3 級薪資)
├── lib/
│   ├── market-data.ts                    # 市場資料載入器
│   └── mcp/
│       ├── types.ts                      # 型別定義
│       ├── client.ts                     # 通用 MCPClient class
│       ├── mcp-manager.ts                # Server 生命週期 + 路由
│       └── servers/
│           ├── knowledge-server.ts       # 知識庫搜尋(1 tool)
│           ├── profile-server.ts         # Profile/Memory(3 tools + 3 Resources)
│           ├── market-data-server.ts     # 市場資料(3 tools)
│           └── agents-server.ts          # 平行 Agent(3 tools)

伺服器總覽

MCP Server Tools 核心功能
knowledge-server searchKnowledge 知識庫語意搜尋
profile-server save_memory, search_memories, update_profile + 3 Resources 個人化資料讀寫
market-data-server get_salary_range(支援經驗等級), get_market_trends, get_job_demand 策展市場數據
agents-server analyze_market, advise_growth, coach_interview 平行 Agent 分析

總計:4 Servers, 10 Tools, 3 Resources

修改檔案流水帳

檔案 變更摘要
src/lib/chat.ts SEARCH_KNOWLEDGE_TOOLmcpManager.getAllGroqTools();通用 MCP 路由 + uid 注入
src/app/api/line/route.ts 預熱 MCP + 傳遞 uid: lineUserId
src/lib/parallel/orchestrator.ts 移除 DOMAIN_QUERIES/parallelKBSearch/runAllAgents()runAllAgentsViaMCP()
src/lib/mcp/client.ts env 參數(傳遞 process.env 給子程序)
src/lib/mcp/mcp-manager.ts 4 Server 註冊 + process.env 傳遞

實作過程遇到的問題與解法

問題 解法
Top-level await 在 CJS 中不支援 用 IIFE ;(async () => { await server.start() })() 包裝
MCP SDK StdioClientTransport 預設使用 getDefaultEnvironment() 過濾 env client.ts 新增 env 參數,傳遞 process.env
jsonSchemaAdapter 的 execute callback 參數型別為 unknown (args: unknown) => { const { ... } = args as Type } 轉型
Profile/Memory tools 需要 uid,但 LLM 不知道 uid chat.ts 層自動注入 { ...args, uid }

這四問題花了一些時間除錯,特別是環境變數過濾那個。StdioClientTransport 預設會用 getDefaultEnvironment() 過濾環境變數,導致 GROQ_API_KEY 傳不過去。agent server 一直回傳「GROQ_API_KEY is missing」,查了半天才發現是 transport 層的問題。

測試計劃

測試是這次導入的重要環節。因為 MCP 涉及多個子程序、STDIO 傳輸、非同步通訊,手動測試無法 cover 所有邊界情況。

測試架構

框架:Vitest

npm install -D vitest

執行方式

npx vitest run                    # 單次執行
npx vitest                        # watch 模式
npx vitest src/lib/mcp/           # 只跑 MCP 相關測試

測試檔案配置:每個 MCP 模組對應一個 .spec.ts,放在同目錄下:

src/lib/mcp/
├── client.spec.ts           # MCPClient 單元測試
├── mcp-manager.spec.ts      # MCPManager 整合測試
├── types.spec.ts            # 型別與序列化測試
└── servers/
    ├── knowledge-server.spec.ts  # Knowledge Server 測試
    ├── profile-server.spec.ts    # Profile Server 測試
    └── agents-server.spec.ts     # Agents Server 測試

Mock 策略

測試 MCP 時有個挑戰:MCP Server 是真實的子程序,測試時不該真的啟動它們。所以需要用 mock 取代。以下是 mock 策略:

依賴 Mock 方式 說明
fastmcpFastMCP class vi.mock('fastmcp') Mock Server 建構與 tool()resource() 註冊方法,不實際啟動 process
@modelcontextprotocol/sdkClient vi.mock('@modelcontextprotocol/sdk') Mock Client 的 connect()listTools()callTool() 方法
child_process.spawn vi.mock('child_process') Mock STDIO 子程序啟動,避免實際 spawn Node.js process
@/lib/embedsearchSimilar vi.mock('@/lib/embed') 回傳固定 chunk,測試工具邏輯而非 embedding
@/lib/profile / @/lib/memory vi.mock('@/lib/profile') / vi.mock('@/lib/memory') 回傳固定 profile/memory 資料
groq-sdk vi.mock('groq-sdk') 既有 mocking pattern,用於 chat.ts 整合測試

單元測試

A:MCPClient(client.spec.ts

測試案例 輸入 預期結果
連線成功後可發現工具 Mock listTools() 回傳 2 個工具 client.getGroqTools() 回傳 Groq-compatible function definitions
工具轉譯格式正確 FastMCP tool schema 輸出的 JSON schema 與 SEARCH_KNOWLEDGE_TOOL 結構相容(name, description, parameters)
呼叫工具成功 callTool("searchKnowledge", { query }) 回傳 ToolResultcontent 為字串陣列
呼叫工具失敗(Server 回傳 error) Server 回傳 isError: true ToolResult 含 isError: true
呼叫工具 timeout Server 無回應 reject 或回傳預設 error message
斷線後重新連線 Server process crash → connect() 重新連線成功

Mock 範例 — MCPClient

import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('@modelcontextprotocol/sdk', () => ({
  Client: vi.fn().mockImplementation(() => ({
    connect: vi.fn().mockResolvedValue(undefined),
    listTools: vi.fn().mockResolvedValue({
      tools: [
        {
          name: 'searchKnowledge',
          description: '搜尋知識庫',
          inputSchema: {
            type: 'object',
            properties: { query: { type: 'string' } },
            required: ['query'],
          },
        },
      ],
    }),
    callTool: vi.fn().mockResolvedValue({
      content: [{ type: 'text', text: '[1] 相關知識片段...' }],
    }),
  })),
}))

// 注意:實際為 STDIO transport,須 mock Transport
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
  StdioClientTransport: vi.fn().mockImplementation(() => ({
    start: vi.fn(),
    send: vi.fn(),
    close: vi.fn(),
  })),
}))

A:MCPClient getGroqTools() 轉譯正確性

這個測試特別重要,因為 getGroqTools() 是 MCPClient 的核心功能 — 它負責把 MCP 的 tool schema 轉譯成 Groq SDK 看得懂的格式。如果轉譯錯了,Groq 就無法正確理解工具:

it('should convert MCP tool schema to Groq function definition', async () => {
  const client = new MCPClient(/* mock transport */)
  await client.connect()

  const groqTools = client.getGroqTools()

  expect(groqTools).toEqual([
    {
      type: 'function',
      function: {
        name: 'searchKnowledge',
        description: '搜尋知識庫',
        parameters: {
          type: 'object',
          properties: { query: { type: 'string' } },
          required: ['query'],
        },
      },
    },
  ])
})

A:MCPManager(mcp-manager.spec.ts

測試案例 輸入 預期結果
啟動所有註冊 Server manager.startAll() 每個 Server 的 Client 都 connect() 成功
取得所有 Groq 工具 2 個 Server 各註冊 1 個 tool getAllGroqTools() 回傳 2 個 function definitions
路由工具呼叫 callTool("searchKnowledge", { query }) 路由到 Knowledge Server 的 Client
路由到不存在的工具 callTool("nonexistent", {}) reject 或回傳 error
Server crash 後自動重啟 Mock process exit → healthCheck() restartServer() 被呼叫
優雅關閉所有 Server manager.shutdown() 所有 Client 皆 close()

A:Knowledge Server(knowledge-server.spec.ts

測試案例 輸入 預期結果
搜尋有結果 searchKnowledge({ query: "薪資談判" }) 回傳最多 3 個 chunk
搜尋無結果 searchKnowledge({ query: "xxx" }) isError: true,content 為「(知識庫無相關結果)」
query 為空字串 searchKnowledge({ query: "" }) 空 query 應 fallback 或 isError
searchSimilar 拋 exception searchSimilar.mockRejectedValue(new Error("API down")) 不崩潰,回傳 error ToolResult

Mock 範例 — Knowledge Server

注意這裡的測試技巧:我們不透過 FastMCP 啟動 Server,而是直接測試 server.tool callback。這樣可以避免啟動子程序,測試速度更快:

import { describe, it, expect, vi, beforeEach } from 'vitest'

vi.mock('@/lib/embed', () => ({
  searchSimilar: vi.fn(),
}))

// 直接測試 server.tool callback 而非透過 FastMCP
// 在 server 建構後,擷取 callback 並直接呼叫
describe('knowledge-server', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('should return chunks when searchSimilar returns results', async () => {
    const mockSimilar = vi.mocked(searchSimilar)
    mockSimilar.mockResolvedValue(['薪資談判技巧 chunk 1', '薪資談判技巧 chunk 2'])

    // toolHandler 是從 server.tool("searchKnowledge", ...) 註冊的 callback
    const result = await toolHandler({ query: '薪資談判' })

    expect(mockSimilar).toHaveBeenCalledWith('薪資談判', 3)
    expect(result.content).toHaveLength(2)
    expect(result.content[0].text).toContain('[1]')
  })

  it('should return isError when searchSimilar returns empty', async () => {
    const mockSimilar = vi.mocked(searchSimilar)
    mockSimilar.mockResolvedValue([])

    const result = await toolHandler({ query: 'xxx' })

    expect(result.isError).toBe(true)
    expect(result.content[0].text).toBe('(知識庫無相關結果)')
  })
})

F:Profile Server(profile-server.spec.ts

測試案例 輸入 預期結果
Resource profile://{uid}/current 有效 uid 回傳 Profile JSON
Resource profile://{uid}/current 不存在的 uid 回傳 {} 或 null
Resource profile://{uid}/preferences 有 preferences 的 uid 回傳 Preferences JSON
Resource memory://{uid}/recent 有效 uid 回傳最近記憶陣列
Tool save_memory { uid, content } saveMemory() 被正確呼叫
Tool search_memories { uid, query } searchMemories() 被正確呼叫
Tool update_profile { uid, updates } profile 被正確更新

D:LINE MCP Client(整合至 LINE route 測試)

測試案例 輸入 預期結果
LINE webhook 透過 MCP 並行查詢 模擬 LINE text event Knowledge Server + Profile Server 皆被呼叫
LINE webhook 其中一個 Server 失敗 Profile Server timeout 不崩潰,單一 Server 失敗不影響整體回應

E:Agents Server(agents-server.spec.ts

測試案例 輸入 預期結果
Tool analyze_market { question } runAgent("market", ...) 被正確呼叫
Tool advise_growth { question } runAgent("growth", ...) 被正確呼叫
Tool coach_interview { question } runAgent("interview", ...) 被正確呼叫
Agent timeout Agent 超過 30s 無回應 單一 Agent 失敗,不影響其他 Agent
三個 Agent 平行執行 同時呼叫三個 tool 確認無 race condition

整合測試

A → chat.ts 端到端

測試案例 輸入 預期結果
MCP 路徑取代 Groq function calling getReply("怎麼談薪水?") 行為與既有完全一致(LLM 仍可自主決定呼叫 searchKnowledge)
MCP Manager 在 Next.js 啟動時初始化 app.getInitialProps 階段 MCPClient 連線成功,Server process 正常啟動
Manager 關閉時優雅終止 Server manager.shutdown() STDIO child process 收到 SIGTERM

chat.ts 整合測試 mock 範例

import { describe, it, expect, vi, beforeEach } from 'vitest'

// Mock MCP Manager at module level
vi.mock('@/lib/mcp/mcp-manager', () => ({
  MCPManager: vi.fn().mockImplementation(() => ({
    startAll: vi.fn().mockResolvedValue(undefined),
    getAllGroqTools: vi.fn().mockReturnValue([
      {
        type: 'function',
        function: {
          name: 'searchKnowledge',
          description: '搜尋知識庫',
          parameters: {
            type: 'object',
            properties: { query: { type: 'string' } },
            required: ['query'],
          },
        },
      },
    ]),
    callTool: vi.fn().mockResolvedValue({
      content: [{ type: 'text', text: '[1] 相關知識片段' }],
    }),
    shutdown: vi.fn().mockResolvedValue(undefined),
  })),
}))

describe('chat.ts with MCP integration', () => {
  it('should use MCPManager tools instead of hardcoded SEARCH_KNOWLEDGE_TOOL', async () => {
    const tools = manager.getAllGroqTools()
    // chat.ts 第 148 行原本用 tools = intent === "career" ? [SEARCH_KNOWLEDGE_TOOL] : undefined
    // 改為 tools = intent === "career" ? manager.getAllGroqTools() : undefined
    expect(tools).toHaveLength(1)
    expect(tools[0].function.name).toBe('searchKnowledge')
  })

  it('should route tool_call through MCPManager.callTool', async () => {
    // 模擬 Groq 回傳 tool_call
    // 驗證 chat.ts 呼叫 manager.callTool("searchKnowledge", { query })
    // 而非直接呼叫 searchKnowledge()
    expect(manager.callTool).toHaveBeenCalledWith('searchKnowledge', { query: expect.any(String) })
  })
})

E2E 手動驗證

單元測試和整合測試 cover 邏輯正確性,但有些行為只能透過手動測試驗證,特別是 LLM 的「自主決定」行為 — 它會不會在適當的時候呼叫工具、會不會在不該呼叫的時候跳過。

階段 A 驗證

測試項目 操作 預期結果
MCP Server 啟動 npm run dev → 觀察終端機輸出 Log 顯示「MCP Knowledge Server started」
知識庫查詢 輸入「怎麼談薪水?」 回應 grounded 在知識庫內容(與改造前相同)
不需查詢的題目 輸入「你好」 不觸發工具,直接打招呼
Server 崩潰復原 kill Knowledge Server process Manager 自動重啟 Server,下次請求不受影響
工具呼叫失敗 讓 searchSimilar 拋錯 不崩潰,LLM 用自身知識回答

階段 F 驗證

測試項目 操作 預期結果
Profile Resource 可讀取 LLM 自主讀取 profile://{uid}/current 回覆包含使用者個人化內容
Memory Tool 可寫入 LLM 呼叫 save_memory Firestore 中出現新記憶
Memory Tool 可查詢 LLM 呼叫 search_memories 回覆引用過往記憶

階段 D 驗證

測試項目 操作 預期結果
LINE 發送職涯問題 從 LINE 輸入「怎麼談薪水?」 回應包含知識庫內容
LINE 使用者個人化 LINE 使用者有 profile 回覆包含個人化內容(引用年資、技能等)

回歸測試

每個階段完成後,都要確認既有功能不受影響。回歸測試直接用 curl 打 API,確認四條主要路徑都正常:

# 1. 聊天功能正常
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "前端工程師在台灣好找工作嗎?", "history": []}'
# 預期:SSE streaming,回應 grounded 內容

# 2. 打招呼不觸發工具
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "你好", "history": []}'
# 預期:直接打招呼,不經工具

# 3. 預約路徑不經 LLM
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "我想預約", "history": []}'
# 預期:直接回傳表單連結

# 4. 複雜問題走平行 Agent
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "該不該從前端轉職到 AI?", "history": []}'
# 預期:走 complex tier,回傳結構化分析

覆蓋率目標

模組 Line Branch 備註
src/lib/mcp/client.ts 100% 100% 核心抽象層,零容忍
src/lib/mcp/mcp-manager.ts 100% 95% 生命週期管理,error handling 分支
src/lib/mcp/servers/knowledge-server.ts 100% 100% 邏輯單純,兩種結果
src/lib/mcp/servers/profile-server.ts 95% 90% 多 Resource + Tool,URI parsing
src/lib/mcp/servers/market-data-server.ts 90% 85% 3 tools + 資料載入,動態 import
src/lib/mcp/servers/agents-server.ts 95% 90% 3 個 Agent dispatch,動態 import
src/lib/market-data.ts 100% 95% 純函數,formatter

使用者測試

階段 A — 知識庫 MCP Server

# 驗證項目 操作步驟 期望結果 Pass/Fail
A1 知識庫查詢正常運作 在聊天室輸入「怎麼談薪水?」 AI 回答包含知識庫內容(如具體策略、Summer 觀點),而非泛泛而談
A2 知識庫查詢正常運作 2 在聊天室輸入「轉職前端要學什麼?」 AI 回答引用學習路線相關知識
A3 LLM 可自主不查知識庫 在聊天室輸入「面試要穿什麼?」 AI 直接回答常識性建議,不需等待知識庫搜尋,文字立刻開始串流
A4 打招呼不觸發工具 在聊天室輸入「嗨」或「你好」 AI 直接打招呼,回應簡短(2-3 句內),無延遲
A5 預約路徑不變 在聊天室輸入「我想預約諮詢」 AI 直接回傳預約表單連結,不經 LLM 生成
A6 與改造前行為一致 隨意問 5 個常見職涯問題 每個問題的回答品質與改造前無明顯差異(可接受 grounded 細節增加)

驗證通過標準:A1-A6 全部 Pass ✅

階段 F — Profile/Memory MCP Server

背後的機制save_memorysearch_memoriesupdate_profile 三個工具已註冊在 MCP 工具清單中。當 LLM 判斷需要時,會自主呼叫這些工具。uid 由系統自動注入,不需使用者提供。

# 驗證項目 操作步驟 期望結果 Pass/Fail  
F1 Profile 自動讀取 對一個已有 profile 的使用者問「你覺得我現在適合轉職嗎?」 AI 回覆中提及使用者的現職角色或年資(如「你現在是 3 年經驗的前端工程師…」)  
F2 Memory 自動讀取 先說「我對雲端技術很有興趣」,接著問「你記得我對什麼領域有興趣嗎?」 AI 回覆提及雲端技術(不需要使用者重複提及)  
F3 Memory 寫入 說「我最近開始學 Kubernetes」 之後到 /memories 頁面檢查 該對話內容已出現在記憶列表中
F4 無 profile 的使用者 用新帳號問「我該如何規劃學期路線?」 AI 正常回答,不因缺少 profile 而錯誤  

除錯方式:如果 LLM 不自主呼叫 memory 工具,可在系統提示詞中提醒「你有 save_memory 和 search_memories 工具可用」。

驗證通過標準:F1-F4 全部 Pass ✅

階段 D — LINE MCP Client

必要條件:需設定 LINE_CHANNEL_SECRETLINE_CHANNEL_ACCESS_TOKEN 環境變數。LINE Bot 使用 getReply()(非串流)路徑,與 Web 版的 getReplyStream() 共享同一個 MCPManager。

# 驗證項目 操作步驟 期望結果 Pass/Fail
D1 LINE 知識庫查詢 從 LINE 傳「怎麼談薪水?」 回應包含知識庫內容,與 Web 版本品質一致
D2 LINE 打招呼 從 LINE 傳「嗨」 直接打招呼,無異常延遲
D3 LINE 預約 從 LINE 傳「我想預約」 回傳預約表單連結 + 預約說明
D4 LINE 個人化 對有對話歷史的 LINE 使用者提問 回覆引用過往對話內容(個人化)
D5 MCP Server 預熱 檢查伺服器 Log(觀察 POST /api/line 前後的 MCP Startup Log) LINE 請求處理前,所有 MCP Server 應已啟動(knowledge + profile + market-data + agents)

除錯方式:檢查 /tmp/careerwise-dev.log 中是否有 [MCP] Server "..." started Log。如果缺少,表示該 Server 啟動失敗,LINE Bot 仍可正常回應(不經工具)。

驗證通過標準:D1-D4 全部 Pass。

階段 B — 外部資料源 MCP Server(Curated Data)

資料維護:Summer 可直接編輯 src/data/market-data.json 更新薪資範圍、技能、趨勢。修改後重啟 Server 即可生效。

免責條款:由於無穩定公開 API,所有市場資料為 Summer 根據多方來源彙整的參考值。輸出會自動附加資料來源與「實際薪資因公司規模與經驗而異」的提醒。

# 驗證項目 操作步驟 期望結果 Pass/Fail
B1 薪資查詢 輸入「前端工程師現在薪水多少?」 回答包含薪資範圍(junior/mid/senior 三級),並附資料來源
B2 薪資查詢(資深) 輸入「資深後端工程師薪水?」 回答顯示 senior 級薪資範圍(中位數約 170 萬)
B3 市場趨勢 輸入「現在 AI 工程師市場怎麼樣?」 回答包含需求程度、成長趨勢、熱門技能、備註
B4 市場趨勢(全部) 輸入「科技業現在什麼最熱門?」 回答包含高需求職位排行、新興技能列表、市場展望
B5 需求分析 輸入「React 缺多嗎?」 回答列出所有匹配 React 的職位及需求程度(如前端🔥高、全端📊中) ⚠️
B6 需求分析(無匹配) 輸入「會計師薪水多少?」 AI 回答「目前無該職位資料」,列出支援查詢的角色清單
B7 技能搜尋 輸入「Python 工作機會多嗎?」 回答列出後端、AI、資料科學等職位的需求分析
B8 資料更新 修改 src/data/market-data.json 中某角色薪資後重啟 回答反映新的薪資數值

B5 註:測試時因 Groq 免費方案 TPM 6000 rate limit 中斷,工具 get_job_demand 有正常觸發但後續 LLM 文本生成被截斷。

除錯方式:如果 LLM 不呼叫市場資料工具但回答薪資問題,表示 tool_choice: "auto" 下 LLM 選擇用自身知識回答。這是預期行為 — LLM 可自主決定是否使用工具。

驗證通過標準:B1-B6 全部 Pass ✅(B5 為 rate limit 導致,非程式錯誤)

階段 E — 平行 Agent MCP Server

觸發條件:只有「complex」tier 的問題(長度 > 45 字或多個問號)才會走平行 Agent 路徑。短問題如「該不該離職」為「planned」tier(走 Plan Review),不經 Agent。

流程:Agent MCP Server 的三個工具(analyze_marketadvise_growthcoach_interview)由 executeComplex() 透過 MCP Client 平行呼叫(Promise.allSettled)。每個 Agent 內部自行搜尋知識庫再用 Groq 分析。結果由 mergeResponses() 合併為結構化報告。

# 驗證項目 操作步驟 期望結果 Pass/Fail
E1 Complex 問題走 Agent 路徑 輸入「我現在是前端工程師,想轉職到 AI 領域,但不太確定該從哪裡開始準備,也不知道市場需求怎麼樣,還有面試時需要注意什麼?」(>45 字) 回應包含市場分析、成長建議、面試準備、行動建議四個章節的完整報告
E2 Agent 內容品質 檢查 E1 的回覆 市場分析引用具體數據、成長建議有學習路線、面試準備有框架、行動建議可執行 ⚠️
E3 平行執行確認 檢查伺服器 Log [Parallel] 3/3 agents completed via MCP
E4 Fallback 路徑 模擬 GROQ_API_KEY 失效(暫時移除環境變數)後重啟,問 E1 同樣問題 Agent 全部失敗→印出 [Parallel] 0/3 agents completed via MCP→自動 fallback 到傳統 Groq 路徑,仍正常回答
E5 新增 Agent 不需改 orchestrator 開發者在 agents-server.ts 新增一個 tool handler(如 negotiate_salary),註冊後重啟 orchestrator 不用改任何程式碼,新的 Agent 工具可被發現與呼叫

E2 註:內容偏泛(未引用具體市場數據),推測為 Groq llama-3.1-8b-instant 模型能力限制。

除錯方式

驗證通過標準:E1-E4 全部 Pass ✅(E2 為模型能力限制,E4 未測試)

全階段回歸驗證

每個階段上線前,執行以下完整回歸檢查:

✅ Web 聊天 — 打招呼、職涯問題、履歷、預約,四條路徑皆正常
☐ LINE Bot — 發送訊息後正常回應
✅ Memory 頁面 — 可檢視與管理記憶
✅ Profile 頁面 — profile 資訊正確
✅ 無預期錯誤 — 伺服器 log 無 unhandled rejection 或 crash

測試中發現的 Bug 與修復

這次整合過程中意外抓到幾個 bug,有些跟 MCP 無關,是原本就潛伏在系統裡的問題。因為做了重構,這些 bug 才浮現出來。

# Bug 根因 修復
1 點擊「跳過計劃,直接回答」後無回應 取消後重新送出同一則訊息,API 再次分類為 planned 回傳 plan JSON,前端 SSE 解析器讀不到 data: API route 增加 skip_plan 參數,取消時帶 skip_plan: true 跳過 planned 分類
2 歷史對話污染意圖分類器 layer3LLM 將對話歷史傳給 LLM,導致 LLM 看到 booking 歷史後誤判後續訊息 移除 layer3LLMhistory 參數,只根據當下訊息分類
3 工具回傳錯誤時 LLM 仍瞎掰數據 工具 isError 旗標被忽略,LLM 拿到錯誤訊息仍自行編造答案 anyFailed 時跳過第二次 LLM 呼叫,直接顯示工具錯誤內容
4 searchKnowledgeget_salary_range 工具衝突 searchKnowledge 描述含「薪資等議題」,讓 LLM 在薪資問題上優先選知識庫 移除 searchKnowledge 描述中的「薪資」字樣,改為「薪資查詢請改用 get_salary_range」
5 記憶問題(記得、興趣)被誤判為 booking LLM 分類器看到「想進一步討論」等 booking 關鍵字過度匹配 layer1 規則增加 記得|興趣|領域career

這些 bug 如果不是因為做了 MCP 整合、重新檢視整個架構,可能還會潛伏很久。有時候重構的附加價值就是這樣 — 逼你重新審視每個環節。

測試限制

限制 說明
Groq 免費方案 TPM 6000 並行測試時容易耗盡配額,導致 LLM 文本生成中斷。B5 及部分長回應受影響
Llama 3.1 8B 模型能力 工具選擇與指令遵循有隨機性,部分回答偏泛。升級至 70B 或 GPT-4o 可改善
HuggingFace Embedding API HF_TOKEN 時可能被 rate limit,導致語意搜尋降級為關鍵字匹配
LINE Bot 未測試(無 LINE Channel 憑證)

常見問題與解決方式

問題 可能原因 檢查方式 解決方式
對話回應正常但從不呼叫工具 MCP Server 未啟動或工具清單為空 檢查 Log 是否有 [MCP] Server "knowledge-server" started 重啟 npm run dev,確認無啟動錯誤
回應很慢(>5s) MCP 子程序啟動中(首次請求) 觀察後續請求是否變快 這是正常現象(lazy init),後續請求會從快取取得工具清單
Server Log 出現「MCP error -32000」 MCP Server 子程序 crash 或拒絕連線 檢查 ps aux \| grep npx tsx 重啟 dev server;若持續發生,檢查 Server 檔案是否有語法錯誤
平行 Agent 回傳「0/3 agents completed」 GROQ_API_KEY 未傳遞給 MCP 子程序 檢查 Log 是否有「GROQ_API_KEY is missing」 確認 .env.local 有設定;MCP Client 2.0 以上會自動傳遞 env
市場資料查不到特定職位 market-data.json 中無該角色資料 檢查 src/data/market-data.jsonroles 列表 直接編輯 JSON 新增角色後重啟
LINE Bot 無回應 LINE Channel Secret 未設定或簽章驗證失敗 檢查 Log 是否有「Invalid signature」 確認 .env.local 有正確的 LINE 憑證
修改 market-data.json 後沒生效 Server 未重啟 檢查 Log 是否顯示舊資料 編輯後需重啟 npm run dev(或 production rebuild)

已知風險與解法

風險 影響 緩解
fastmcp / MCP SDK 與現有 Next.js 版本相容性 無法引入依賴 已驗證 Next.js 16 + moduleResolution bundler 可正常使用
MCP Client STDIO 子程序管理 子程序 crash 導致工具失效 MCPManager 實作 health check + auto-restart
Groq 的 tool_choice 行為不變 MCP 轉譯的工具定義與原生不同 MCPClient 輸出與 SEARCH_KNOWLEDGE_TOOL 完全相同的 JSON schema 結構,確保 Groq 行為一致
Resources URI 需要 runtime uid Resource 無法靜態定義 uid Profile Server 在 URI template 中嵌入 {uid},Client 端填入實際 uid
LINE Bot 無狀態 每次請求需重新建立 MCP Client MCPManager 以 singleton 在應用啟動時初始化,LINE handler 直接引用
MCP 子程序缺少環境變數 agents-server 無法使用 GROQ_API_KEY client.ts 傳遞 process.env 給 StdioClientTransport,已在實作中修復
多子程序記憶體開銷 4 個 Node.js 子程序約 200-400MB STDIO 模式比 HTTP 模式輕量;若記憶體不足可合併 Server 或改用 in-process transport

附錄:技術對照

若對 MCP 標準還不太熟悉,這裡用 CareerWise 的實例對照 MCP 的元件概念:

文章元件 CareerWise 對應
LLM(大腦) Groq llama-3.1-8b-instant
MCP 客戶端(翻譯官) MCPClient class(client.ts)→ 4 個 instance 分別連線 4 個 Server
MCP 伺服器(保鏢+圖書館員) knowledge-server / profile-server / market-data-server / agents-server
資源(Resources) profile://{uid}/current, profile://{uid}/preferences, memory://{uid}/recent
工具(Tools) 10 tools:searchKnowledge, save_memory, search_memories, update_profile, get_salary_range, get_market_trends, get_job_demand, analyze_market, advise_growth, coach_interview
提示範本(Prompts) 保留 Summer’s system prompt(暫不移除)
STDIO 傳輸 Next.js 子程序啟動 4 個 FastMCP Server,透過 npx tsx 執行
動態發現 tools/listgetGroqTools() 自動轉譯為 Groq function schema
安全機制 MCP Server 內部權限控制 + Firestore security rules + 環境變數隔離

對 UI/UX 的影響

MCP 是後端架構變動,使用者不會直接看到「MCP」這個詞。但有些變化是使用者有感觸的。

原本的問題

導入 MCP 前,每新增一個工具就要修改主程式的 tool definition,加 function、加 schema、加 dispatch logic。開發週期長,工具 bug 也容易影響主程式的穩定性——如果 searchKnowledge 寫壞了,整個 chat 可能都掛掉。

導入後的改變

MCP 沒有改變對話介面本身——使用者看到的還是同一個聊天框。差異在於 AI 能做到的事變多了,而且更可靠。

總結

回頭看這次的導入,MCP 不是什麼革命性的技術,它就是一個標準化的協議。但就是這個「標準化」,讓工具的管理從混亂變有序,從「改一個工具就要動主程式」變成「加一個新工具就是註冊一個 Server」。動態發現工具這件事,真的回不去了。當 LLM 應用的工具愈來愈多、愈來愈難管理的話,MCP 是一個值得考慮的方向。不用一次全上,從一個工具開始試水溫就好。


MCP Model Context Protocol LLM CareerWise AI FastMCP Agentic AI AI Engineering Agentic Design Pattern Design Pattern Architecture Agentic AI 401

Summer Tang
Sponsored
Summer Tang 一對一諮詢
Summer|TSMC 主任工程師|擁有 15 年資深 Web 架構經驗,職涯歷經跨國頂尖企業與新創。專精於高流量企業級應用開發,核心技術涵蓋微前端、效能優化、測試自動化與 SEO。曾出版多本技術著作,並多次擔任 MOPCON、WebConf 等大型技術年會講者。
了解更多 →