📌

MastraがMCP OAuthに対応してないので自分で書いてみた

に公開

タイトルの通りこれを書いた。

https://github.com/kinoh/mastra/tree/feat/mcp-auth

背景

Notionは内部表現が非常に構造化されていてLLM-friendlyでない、嗚呼Obsidianを讃えよと評判(身の回り調べ)なのだが、7/1にリリースされたremote MCP server(https://mcp.notion.com/mcp)は不思議な力でmarkdownによる読み書きができる。

しかし私の今使っているMastraのMCPClientは認証をサポートしていない。

以前からあったnpm版はもうリリースが止まっていて、普通に@makenotion/notion-mcp-serverを動かすとAPI token is invalidと言われる。
ntn_…のトークンは新しいフォーマットらしいので、最新のコードをビルドして使うと動くものの、ブロックの中身をどうこうと言ってただのAPIのラッパーのようだ。

やはりremote MCP serverの方を使いたい。

https://github.com/mastra-ai/mastra/issues/5342

issueはあるが優先度は低そうだ。つまり…自分で書くしかない。

実装

claude codeに書かせるといつも通り動かなくて苦労したが、結局MCP SDKのサンプルコードがあって普通にこれで良かった。

https://github.com/modelcontextprotocol/typescript-sdk/blob/4d0977bc9169965233120e823c8024e210132ad9/src/examples/client/simpleOAuthClient.ts

あとclaudeが書いてきたGitHubのMCPはこういう問題で動かなかったり、複数クライアントを考慮したり。

使い方

publishしていないが、ビルドしたものを突っ込んだreleaseブランチを置いたのでパッケージとして使用できる。(サブディレクトリにあるのでGitPkgを使う。)

    "@mastra/mcp": "https://gitpkg.vercel.app/kinoh/mastra/packages/mcp?release",

examples/mcp-authを見れば分かるように、onAuthURLで認証URLの提示方法を提供すれば最低限動く。ただ基本的にはtokenStorageで保管場所も指定した方が良い。(機密情報なので。よく見るようにconfigパッケージで管理すべきなのかもしれないが、自分のユースケースではコンテナ内で動かすに当たり面倒そうだったので止めた。)

https://github.com/kinoh/mastra/tree/feat/mcp-auth/examples/mcp-oauth

複数のクライアントに対応する場合、clientId(ユーザー名)を渡すために動的設定をする必要がある。現実には以下のようなコードになるだろう。(0.0.0.0は良くないが)

mcp.ts
import { MCPClient } from '@mastra/mcp'

export function getDynamicMCP(clientId: string, onAuth: (server: string, url: string) => Promise<void>): MCPClient {
  const callbackServerConfig = {
    publicUrl: 'https://example.com/oauth/callback',
    host: '0.0.0.0',
    port: 3000,
  }
  if (process.env.NODE_ENV !== 'production') {
    callbackServerConfig.publicUrl = 'http://localhost:3000/oauth/callback'
  }

  return new MCPClient({
    id: clientId,
    servers: {
      notion: {
        url: new URL('https://mcp.notion.com/mcp'),
        oauth: {
          onAuthURL: async (authUrl: string): Promise<void> => {
            await onAuth('notion', authUrl)
          },
          callbackServerConfig,
          tokenStorage: `./mcp_tokens.json`,
        },
      },
    },
  })
}

あとは既に認証してファイルにトークンがある場合、以下のように素手でコネクションを貼ってリクエストを飛ばしたりもできる。

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { MastraOAuthClientProvider } from '@mastra/mcp';

async function main() {
  const transport = new StreamableHTTPClientTransport(
    new URL('https://mcp.notion.com/mcp'),
    {
      authProvider: new MastraOAuthClientProvider({
        tokenStorage: './mcp_tokens.json',
        onAuthURL: async (url: string): Promise<void> => {}
      }, 'notion', 'test-user')
    }
  )

  const client = new Client(
    {
      name: 'test-client',
      version: '1.0.0'
    }
  )

  // 既に認証されていないとUnauthorizedErrorになる
  // https://github.com/kinoh/mastra/blob/11ec8a1feb1bc6534e812e399f16e248f5e57ed9/packages/mcp/src/client/client.ts#L306
  // のように認証処理が要る
  await client.connect(transport)

  const result = await client.callTool({
    name: 'notion-update-page',
    arguments: {
      data: {
        page_id: 'a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8',
        command: 'replace_content',
        new_str: '## todo\n\n- [ ] foo\n- [ ] bar',
      }
    }
  })

  console.log(result)
}

main().catch(console.log)

Discussion