iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
AI & Data

30 天從 0 至 1 建立一個自已的 AI 學習工具人系列 第 17

30-17: [實作-8] 將我們的學習記錄,記錄到 Notion 之 2 ( MCP Client )

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250930/200893584voGoWutup.png

在上一篇文章中我們將 MCP Server 已經實作完了,接下來我們就要來實作 MCP Client 與實際上的串接到我們的工具人上。

https://ithelp.ithome.com.tw/upload/images/20250930/20089358XDLADla1dT.png

🚀 Step 1. LangChain MCP Adapters

事實上在 LangChain 要串接 MCP Server 還算蠻簡單的,只要使用官方的 LangChain.js MCP Adapters 就好了。

https://github.com/langchain-ai/langchainjs/tree/main/libs/langchain-mcp-adapters

npm install @langchain/mcp-adapters

然後你現在應該也會部到這個錯誤,因為他還沒支援 1.0.0 版本的 LangChain。

$ npm install @langchain/mcp-adapters
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: 2025-ai-mark@1.0.0
npm error Found: @langchain/core@1.0.0-alpha.6
npm error node_modules/@langchain/core
npm error   @langchain/core@"^1.0.0-alpha.5" from the root project
npm error
npm error Could not resolve dependency:
npm error peer @langchain/core@"^0.3.66" from @langchain/mcp-adapters@0.6.0
npm error node_modules/@langchain/mcp-adapters
npm error   @langchain/mcp-adapters@"*" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.

所以你可以先使用以下的方法強行安裝,目前試了一下是沒什麼問題,不過還是要提醒,建議一切以官方 stable 版本為主,這個還是以實驗為主

  "overrides": {
    "@langchain/mcp-adapters": {
      "@langchain/core": "1.0.0-alpha.5"
    }
  }

接下來我們程式碼大概就是如下,這裡就只貼部份的程式碼,反正使用起來就是透過 MCP Client 取得 tools,接下來再丟給 createAgent 裡的 tools 中,用法和 function calling 差不多。

import { MultiServerMCPClient } from "@langchain/mcp-adapters";

const notionMcpClient = new MultiServerMCPClient({
   notion: {
        url: "http://localhost:3002/notion/mcp",
      },
    });
const notionMcpTools = await notionMcpClient.getTools();
    
    
    
export class SummaryAgent {
  private checkpointSaver: BaseCheckpointSaver;
  private configurable: Configurable;
  private agent: any;

  constructor(
    checkpointSaver: BaseCheckpointSaver,
    configurable: Configurable,
    notionMcpTools: DynamicStructuredTool[]
  ) {
    this.checkpointSaver = checkpointSaver;
    this.configurable = configurable;
    this.agent = createAgent({
      model: "openai:gpt-5",
      tools: [getLearningRecords, ...notionMcpTools],
      checkpointer: this.checkpointSaver,
      // ref: https://blog.langchain.com/agent-middleware/
      middleware: [cleanMessageMiddleware],
      responseFormat: ResponseFormatSchema,
    });
  }

🚀 Step 2. 修改 Agent 的 responseFormat

我這裡有進行以下的修改,來當成不同的情境使用時,回傳的格式,如下所示。

const SummaryResponseSchema = z
  .object({
    task: z.literal(TaskEnum.SUMMARY).describe("只要回傳 summary"),
    response: z.object({
      youLearned: z.string().describe("你今天學了什麼(總結版)"),
      yourOutput: z.string().describe("你今天的產出"),
      feedback: z.string().describe("自我回饋"),
      afterThoughtQuestions: z.string().describe("課後思考"),
      createdAt: z.string().describe("這份總結生成時間(ISO)"),
    }),
  })
  .describe("總結學習的回應格式,當沒有用工具時,只進行總結時,回傳這個格式");

const GetLearningRecordsResponseSchema = z
  .object({
    task: z.literal(TaskEnum.GET_LEARNING_RECORDS).describe("只要回傳 get-learning-records"),
    response: z.object({
      youLearned: z.string().describe("學了什麼(總結版)"),
      yourOutput: z.string().describe("產出"),
      feedback: z.string().describe("自我回饋"),
      afterThoughtQuestions: z.string().describe("課後思考"),
      createdAt: z.string().describe("這份總結生成時間(ISO)"),
    }),
  })
  .describe(
    "取得學習記錄的回應格式,有用到 getLearningRecords 工具時,回傳這個格式"
  );

const CreateNotionPageResponseSchema = z
  .object({
    task: z.literal(TaskEnum.CREATE_NOTION_PAGE).describe("只要回傳 create-notion-page"),
    response: z.object({
      success: z.boolean().describe("是否成功建立 Notion 筆記"),
      message: z.string().describe("回傳 create_page 工具的結果"),
    }),
  })
  .describe(
    "建立 Notion 筆記的回應格式,有用到 createNotionPage MCP 工具時,回傳這個格式"
  );

然後我的 Agent 使用的方式如下 :

export class SummaryAgent {
  private checkpointSaver: BaseCheckpointSaver;
  private configurable: Configurable;
  private agent: any;

  constructor(
    checkpointSaver: BaseCheckpointSaver,
    configurable: Configurable,
    notionMcpTools: DynamicStructuredTool[]
  ) {
    this.checkpointSaver = checkpointSaver;
    this.configurable = configurable;
    this.agent = createAgent({
      model: "openai:gpt-5-mini",
      tools: [getLearningRecords, ...notionMcpTools],
      checkpointer: this.checkpointSaver,
      // ref: https://blog.langchain.com/agent-middleware/
      middleware: [cleanMessageMiddleware],
      responseFormat: toolStrategy([
        SummaryResponseSchema,
        GetLearningRecordsResponseSchema,
        CreateNotionPageResponseSchema,
      ]),
    });
  }

然後有幾個小提醒 :

  1. 如果我們是使用 toolStrategy 的情況,事實上就代表 LangChain 會幫我們建立 tool 給 LLM。
  2. describe 記得要打,因為那個是要給 AI 判斷要用那個。
  3. task enum 那是個坑,我發現如果打什麼情境要帶那個,反而常常出現以下的錯誤,不如直接將每一個後面加上說,只要回傳 xxx 就好,不然 LLM 產的 task enum 常常有錯,導致對不上我們 z 的 enum value 就出現這個錯了。
] Chat error: StructuredOutputParsingError: Failed to parse structured output for tool 'extract-2': [0] - Property "task" does not match schema. [0] - Instance does not match "get-learning-records".. [0] at ToolStrategy.parse (/Users/marklin/Documents/github/2025-AI-Mark/node_modules/langchain/src/agents/responses.ts:141:13) [0] at #handleSingleStructuredOutput (/Users/marklin/Documents/github/2025-AI-Mark/node_modules/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts:346:39) [0] at AgentNode.#invokeModel (/Users/marklin/Documents/github/2025-AI-Mark/node_modules/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts:296:17) [0] at process.processTicksAndRejections

🚀 Step 3. 嘗試看看叫我們的工具人將學習記錄,建立成 Notion Page.

然後這個是我們的溝通過程,你看最後他還有提到 notion 連結給你。

https://ithelp.ithome.com.tw/upload/images/20251001/20089358b2vSdogG6f.png

以下我們的的 Notion 結果。

https://ithelp.ithome.com.tw/upload/images/20251001/20089358LOFHrMsjTJ.png

🚀 如果想自已寫 MCP Cleint

大概就是如下,這個是請 AI 寫的,但如果真的要自幹一個,那事實上大概會長的如下,然後再想辦法讓 LangChain 的 Agent 或是 LangGraph Workflow 當判斷到某種情況下呼叫 callTool 之類的。

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types";

interface Tool {
  name: string;
  description?: string;
  inputSchema?: any;
}

class MCPTestClient {
  private client: Client;
  private transport: StreamableHTTPClientTransport | null = null;
  private serverUrl: string;

  constructor(serverUrl: string) {
    this.serverUrl = serverUrl;
    this.client = new Client(
      {
        name: "mcp-test-client",
        version: "1.0.0",
      },
      {
        capabilities: {
          resources: {},
          tools: {},
        },
      }
    );
  }

  async connect(): Promise<void> {
    try {
      console.log(`🔗 正在連接到 MCP server: ${this.serverUrl}`);
      
      console.log('new SSEClientTransport',new URL(this.serverUrl));
      this.transport = new StreamableHTTPClientTransport(new URL(this.serverUrl));
      await this.client.connect(this.transport);
      
      console.log("✅ 成功連接到 MCP server");
    } catch (error) {
      console.error("❌ 連接失敗:", error);
      throw error;
    }
  }

  async listTools(): Promise<Tool[]> {
    try {
      console.log("tools/list request");
      const response = await this.client.request(
        { method: "tools/list" },
        ListToolsResultSchema
      );
      console.log("tools/list response",response);
      
      console.log("🔧 可用工具:");
      response.tools.forEach((tool: Tool, index: number) => {
        console.log(`  ${index + 1}. ${tool.name}: ${tool.description || "無描述"}`);
      });
      
      return response.tools;
    } catch (error) {
      console.error("❌ 獲取工具列表失敗:", error);
      return [];
    }
  }

  async callTool(name: string, arguments_?: any): Promise<any> {
    const startTime = Date.now();
    
    try {
      console.log(`🚀 調用工具: ${name}`, arguments_ ? `參數: ${JSON.stringify(arguments_, null, 2)}` : "");
      
      const response = await this.client.request(
        { 
          method: "tools/call",
          params: {
            name,
            arguments: arguments_ || {}
          }
        },
        CallToolResultSchema
      );
      
      const duration = Date.now() - startTime;
      console.log(`✅ 工具 ${name} 執行成功 (${duration}ms)`);
      console.log("📄 結果:", JSON.stringify(response, null, 2));
      
      return {
        tool: name,
        success: true,
        result: response,
        duration
      };
    } catch (error) {
      const duration = Date.now() - startTime;
      console.error(`❌ 工具 ${name} 執行失敗 (${duration}ms):`, error);
      
      return {
        tool: name,
        success: false,
        error: error instanceof Error ? error.message : String(error),
        duration
      };
    }
  }
}

export { MCPTestClient };

🚀 小總結

事實上這篇文章看似簡單,但事實上也不太像我想的那麼簡單,我覺得比較大的問題在於,以下兩個 :

  • 有時後會走錯 tool。
  • 有時後會走錯 structuredResponse。

這裡又調整了好幾次 tool 與 structuredResponse 的 Prompt 最後才比較能成功完成,真的後來發現開發到現在,讓 AI 乖乖跟這你腦袋做事情才是最難的。


上一篇
30-16: [實作-7] 將我們的學習記錄,記錄到 Notion 之 1 ( MCP Server )
下一篇
30-18: [知識] 可以讓 AI 工具人知道外面世界的工具 3 - A2A ( Agent2Agent )
系列文
30 天從 0 至 1 建立一個自已的 AI 學習工具人24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言