iT邦幫忙

2025 iThome 鐵人賽

DAY 16
0
AI & Data

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

30-16: [實作-7] 將我們的學習記錄,記錄到 Notion 之 1 ( MCP Server )

  • 分享至 

  • xImage
  •  

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

上一篇文章中,我們已經將 MCP 的基本原因已經理解的差不多了,接下來我們將透過 Notion 這個範例來學習一下如何建立 MCP Server 與 MCP Client,然後完成需要 :

當用戶說將學習記錄儲放到 Notion 中,那 AI 工具人就會透過 MCP 來處理這個功能。

當然這個功能事實上用 Function Calling 也可以完成,但這裡是要來學學如果建立 MCP,因此請別以為只有用 MCP 才能完成喔。

然後整個流程大約如下圖所示。

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

🚀 Step 1. Notion 設定

首先我們要先確 notion 中取得到 integration secret key,有了他我們就可以打 api 了。

就進入到這個網站中,然後建立一個新的 integration。
https://www.notion.so/profile/integrations/form/new-integration

然後建立完後會看到如下的畫面,其中 internalintegration secret key 就是我們要的東西。

https://ithelp.ithome.com.tw/upload/images/20250930/200893580PHPxnJUDV.png

然後還有個地方,我們可以設定這個 integration 的活動範圍,例如下圖我只限制他在這個 page 下。

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

🤔 驗證,打看看 api 來建立個測試頁面

接下來我只要用以下的 curl 打,然後帶上兩個東西,就可以驗證了。

  • token ( internalintegration secret key )
  • page_id
curl -X POST https://api.notion.com/v1/pages \
  -H 'Authorization: Bearer '"{token}"'' \
  -H "Content-Type: application/json" \
  -H "Notion-Version: 2025-09-03" \
  --data '{
	"parent": { "page_id": "{page_id}" },
	"properties": {
		"title": {
      "title": [{ "type": "text", "text": { "content": "MCP 測試" } }]
		}
	},
	"children": [
    {
      "object": "block",
      "type": "paragraph",
      "paragraph": {
        "rich_text": [{ "type": "text", "text": { "content": "這是 MCP 的前測" } }]
      }
    }
  ]
}'

執行完後你應該會看到這個畫面,這就代表 ok ~

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

🤔 page_id 要去那拿呢 ?

就是在網址,以下圖的範例來說,我是要帶fac17442ffa94d899332a60f725fb74d

注意: 前面那個 title 的 Mark- prefix 別帶。

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

🚀 Step 2. 建立 MCP Server

這裡我們是直接用官方的 MCP Server SDK 來建立的,Langchain 看起來還沒有支援 MCP Server。

https://github.com/modelcontextprotocol/typescript-sdk

然後還有使用 notion 的官方的 sdk 套件。

https://github.com/makenotion/notion-sdk-js

npm install @modelcontextprotocol/sdk @notionhq/client --save

主要分兩個部份的程式碼 :

  • MCP Server
  • Notion MCP Tool

🤔 MCP Server 的程式碼
我這裡就是和官網的寫的差不多,你可以直接貼上使用。

https://github.com/modelcontextprotocol/typescript-sdk?tab=readme-ov-file

然後比較不同的應該是,下面的程式碼是註冊 MCP tool 的地方 setupTools,this.server 就是 MCP Server,因為我這裡是將每個 tool 都建立成一個 tool 類別,然後這裡就只是將這個 tool 註冊而以。

然後 pageId 可以不用這樣處理,可以用環境變數,或是其它方法來取得,我這裡就只先做個簡單版本。其它應該就差不多這樣。

import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@notionhq/client";
import { z } from "zod";
import crypto from "crypto";
import dotenv from "dotenv";
import cors from "cors";

import { CreatePageTool } from "./tools/createPage";

dotenv.config();

const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};

export type LearningRecord = {
  _id: string;
  youLearned: string;
  yourOutput: string;
  feedback: string;
  afterThoughtQuestions: string;
  createdAt: string;
};

class NotionMCPServer {
  private server: McpServer;
  private notion: Client;

  constructor() {
    this.notion = new Client({
      auth: process.env.NOTION_API_TOKEN,
      notionVersion: "2022-06-28",
    });
  }

  private setupTools(): void {
    const pageId = "fac17442ffa94d899332a60f725fb74d";
    const createPageTool = new CreatePageTool(this.notion, pageId);
    this.server.registerTool(
      createPageTool.name,
      createPageTool.initialize(),
      createPageTool.handle
    );
  }

  /**
   * Log errors to stderr
   */
  private logError(message: string): void {
    console.error(`[Notion MCP Server] Error: ${message}`);
  }

  /**
   * Start the MCP server
   */
  async start(): Promise<void> {
    const app = express();
    app.use(express.json());
    app.use(
      cors({
        origin: "*",
        exposedHeaders: ["mcp-session-id"],
        allowedHeaders: ["Content-Type", "mcp-session-id"],
        methods: ["GET", "POST", "OPTIONS"],
      })
    );
    app.post("/notion/mcp", async (req, res) => {
      console.log(
        "MCP POST request received:",
        req.body?.method || "unknown method"
      );

      const sessionId = req.headers["mcp-session-id"] as string | undefined;
      let transport: StreamableHTTPServerTransport;

      if (sessionId && transports[sessionId]) {
        console.log("Reusing existing transport for session:", sessionId);
        transport = transports[sessionId];
      } else if (!sessionId && isInitializeRequest(req.body)) {
        console.log("Creating new transport");
        transport = new StreamableHTTPServerTransport({
          sessionIdGenerator: () => crypto.randomUUID(),
          onsessioninitialized: (sessionId) => {
            console.log("Session initialized:", sessionId);
            transports[sessionId] = transport;
          },
        });
        transport.onclose = () => {
          if (transport.sessionId) {
            delete transports[transport.sessionId];
          }
        };

        this.server = new McpServer(
          {
            name: "notion-mcp-server",
            version: "1.0.0",
          },
          {
            capabilities: {
              tools: {
                listChanged: true,
              },
            },
          }
        );
        this.setupTools();
        await this.server.connect(transport);
      } else {
        // Invalid request
        res.status(400).json({
          jsonrpc: "2.0",
          error: {
            code: -32000,
            message: "Bad Request: No valid session ID provided",
          },
          id: null,
        });
        return;
      }

      try {
        console.log("transport handleRequest init");
        await transport.handleRequest(req, res, req.body);
      } catch (error) {
        console.error("Error handling request:", error);
        if (!res.headersSent) {
          res.status(500).json({ error: "Internal server error" });
        }
      }
    });

    app.listen(3002, () => {
      console.log(
        "Notion MCP Server started on http://localhost:3002/notion/mcp"
      );
    });
  }
}

(async () => {
  const notionMCPServer = new NotionMCPServer();
  await notionMCPServer.start();
})();

🤔 Notion Tool 的程式碼

然後下面就是上面註冊的工具程式碼,然後幾個重點 :

  • initialize : 就是產生出要給 MCP Server 的 tool 資訊,就是要介紹你這個 tool 的功用與參數。
  • handle : 就是實際上執行這個 tool 的程式碼。
  • convertToNotionBlocks : 這裡就是將我們的學習資訊轉換成 Notion 看的懂的文件,然後我覺得看這一份文件應該就夠了。 https://developers.notion.com/reference/block
import { Client } from "@notionhq/client";
import { z } from "zod";
import moment from "moment";

import { LearningRecord } from "../index";
import BaseTool from "../../interface/baseTool";

export class CreatePageTool implements BaseTool {
  private notion: Client;
  private pageId: string;
  public name = "create_page";

  constructor(notion: Client, pageId: string) {
    this.notion = notion;
    this.pageId = pageId;
  }

  public initialize() {
    return {
      description: `
        ## 使用時機 (Use it when): 
          1.當學生想將學習記錄存入 Notion 時使用 (Use it when the student wants to save the learning record to Notion)
        `,
      inputSchema: {
        learningRecords: z
          .array(
            z.object({
              youLearned: z.string().describe("學生學習的內容"),
              yourOutput: z
                .string()
                .describe("學生對學習的回應與產出的教材內容"),
              feedback: z.string().describe("你對學習的回饋"),
              afterThoughtQuestions: z
                .string()
                .describe("學習後的課程思考的問題"),
              createdAt: z.string().describe("學生學習的時間"),
            })
          )
          .describe("學習記錄的陣列"),
      },
    };
  }

  public handle = async (input: { learningRecords: LearningRecord[] }) => {
    let index = 1;
    const promises = input.learningRecords.map(async (learningRecord) => {
      const pageData: any = {
        properties: {
          title: {
            title: [
              {
                text: {
                  content: `${moment(learningRecord.createdAt).format(
                    "YYYY-MM-DD"
                  )}-學習記錄-${index}`,
                },
              },
            ],
          },
        },
        parent: { page_id: this.pageId },
        children: this.convertToNotionBlocks(learningRecord),
      };

      index++;
      return this.notion.pages.create(pageData);
    });

    await Promise.all(promises);

    return {
      success: true,
    };
  };

  // notion 的 block 結構參考
  // page: https://developers.notion.com/docs/working-with-page-content
  // block: https://developers.notion.com/reference/block
  private convertToNotionBlocks(learningRecord: LearningRecord): any[] {
    const blocks: any[] = [];

    blocks.push(this.createHeadingBlock("heading_2", "你學習的內容"));
    blocks.push(this.createParagraphBlock(learningRecord.youLearned));
    blocks.push(this.createHeadingBlock("heading_2", "你的產出"));
    blocks.push(this.createParagraphBlock(learningRecord.yourOutput));
    blocks.push(this.createHeadingBlock("heading_2", "回饋"));
    blocks.push(this.createParagraphBlock(learningRecord.feedback));
    blocks.push(this.createHeadingBlock("heading_2", "課後思考的問題"));
    blocks.push(
      this.createParagraphBlock(learningRecord.afterThoughtQuestions)
    );
    blocks.push(this.createHeadingBlock("heading_3", "時間"));
    blocks.push(
      this.createParagraphBlock(
        moment(learningRecord.createdAt).format("YYYY-MM-DD HH:mm:ss")
      )
    );

    return blocks;
  }

  private createHeadingBlock(
    type: "heading_1" | "heading_2" | "heading_3",
    content: string
  ): object {
    return {
      type,
      [type]: {
        rich_text: [
          {
            type: "text",
            text: { content },
          },
        ],
      },
    };
  }

  private createParagraphBlock(content: string): object {
    return {
      type: "paragraph",
      paragraph: {
        rich_text: [
          {
            type: "text",
            text: { content },
          },
        ],
      },
    };
  }
}

🚀 Step 3. 用 Postman 來驗證一下我們的 Server

先執行以下的程式碼,如果不知道 tsx 的就麻煩自已去 google 一下 ~

tsx watch mcp-server/notion/index.ts

> 2025-ai-mark@1.0.0 notion:dev
> tsx watch mcp-server/notion/index.ts

[dotenv@17.2.2] injecting env (3) from .env -- tip: 🔐 prevent building .env in docker: https://dotenvx.com/prebuild
Notion MCP Server started on http://localhost:3002/notion/mcp

接下來打開你的 Postman 然後選擇這個 MCP。

https://ithelp.ithome.com.tw/upload/images/20250930/20089358166TZ2DHvN.png

然後輸入上面看到的 server 位置,然後點 connect,然後看到以下的畫面,那就代表 MCP initialize 成功 ~ 可以用這篇來複習一下 MCP 的 Lifecycle。

30-15: [知識] - 可以讓 AI 工具人知道外面世界的工具 2 - MCP 原理

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

接下來就是要進行 tool calling,然後你的根據他的欄位輸入,最後看到以下的結果就 ok 囉。

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

然後最後再去我們的 notion 看一下,到了這裡我們事實上就可以確認我們的 Notion MCP Server 是 work 的囉。

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

🤔 備註: 有時後你在用 Postman 會看到以下的錯誤,可能原因有 2

"Error calling method: tools/call. Error: Error POSTing to endpoint (HTTP 400): {\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32000,\"message\":\"Bad Request: Server not initialized\"},\"id\":null}"

第一種可能原因在於,你沒有設 cors。但是有些人如果沒有,且是直接用自已寫的 mcp client 來試也會 work,因為 cors 主要是用在瀏覽器上的檢查。

    app.use(
      cors({
        origin: "*",
        exposedHeaders: ["mcp-session-id"],
        allowedHeaders: ["Content-Type", "mcp-session-id"],
        methods: ["GET", "POST", "OPTIONS"],
      })
    );

第二種原因你沒有執行 MCP Initizate,如下圖有 connected 就是 ok。

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

然後下圖就是沒有執行 MCP Initizate 的樣子。

https://ithelp.ithome.com.tw/upload/images/20250930/200893585nHiU5JmsI.png

🚀 小總結

這篇文章中我們學習到了以下的事情 :

  1. 使用 Notion SDK 來建立我們的 Page,並且也簡單的學了一下 Notion Page 的結構。
  2. 使用 MCP SDK 建立 Notion 用的 MCP Server,然後我們這裡通信是以 Streamable HTTP 來當範例。
  3. 最後使用 Postman 來驗證我們的 MCP Server 是正常 work 的。

接下來下一篇文章我們將要來說說 LangChain + MCP Client 端的建置。


上一篇
30-15: [知識] - 可以讓 AI 工具人知道外面世界的工具 2 - MCP 原理
系列文
30 天從 0 至 1 建立一個自已的 AI 學習工具人16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言