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