こちらの投稿を多くの方に見ていただきました。ありがとうございます。
今回はMCPの公式SDKとHonoを使ってMCPサーバーを作ってみる手順を紹介します。
こちらの記事を参考にさせていただきました。というか、ほぼそのままです(笑)
HonoでMCPサーバーを動かす
https://zenn.dev/georgia1/articles/dd4fb566e470fe
単純なMCPサーバーを構築するだけだととても簡単にできたので、オプションとして認証を付けた3パターンを紹介します。
- 認証なし
- APIキーによる認証
- OAuth認証
ソースコードはすべてGitHubに公開しているので実際に構築される方はこちらも参照してください。
余談ですが、今回初めてHonoを使いました。
めっちゃお手軽かつ、Lambdaと相性がいいなと感じまして、今後も使っていきたいなと思います。
認証なし版
こちらのSAMプロジェクトです。
ライブラリーを追加します。
npm add @modelcontextprotocol/sdk hono fetch-to-node
ポイントをまとめるとこのようになります。
- AWS Lambda上で動作させるため、
Express
の代わりにHono
を使う - Streamable HTTP仕様をステートレスで使う
- MCPのTypeScript SDKとHonoの互換性のため
fetch-to-node
を使う
このことだけ気をつければ、ほぼ公式SDKのサンプルのままです。
diffのマークが付いているところがExpressの場合との差分です
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+ import { toFetchResponse, toReqRes } from 'fetch-to-node';
+ import { Hono } from 'hono';
+ import { handle } from 'hono/aws-lambda';
import { z } from 'zod';
+ const app = new Hono();
const server = new McpServer({
name: 'example-server',
version: '1.0.0',
});
server.registerTool(
'calculate-bmi',
{
description: 'Calculate Body Mass Index',
inputSchema: {
weightKg: z.number(),
heightM: z.number(),
},
},
async ({ weightKg, heightM }) => ({
content: [
{
type: 'text',
text: String(weightKg / (heightM * heightM)),
},
],
}),
);
app.post('/mcp', async (c) => {
console.log('Received POST MCP request');
const body = await c.req.json();
try {
const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: undefined,
+ enableJsonResponse: true,
});
+ const { req, res } = toReqRes(c.req.raw);
res.on('close', () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, body);
+ return await toFetchResponse(res);
} catch (error) {
console.error('Error handling MCP request:', error);
return c.json(
{
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
},
500,
);
}
});
+ export const lambdaHandler = handle(app);
ビルドしてデプロイします。
sam build && sam deploy
API GatewayのURLの末尾に/mcp
を付与したURLがエンドポイントとなります。
https://**********.execute-api.us-east-1.amazonaws.com/mcp
VSCodeをMCPクライアントとして使用する場合は、mcp.json
にURLを指定します。
{
"servers": {
"simple-mcp": {
"url": "https://**********.execute-api.us-east-1.amazonaws.com/mcp"
}
}
}
APIキー認証版
こちらのSAMプロジェクトです。
認証なしとの違いは、HonoのBearer Auth Middlewareを使用し、HTTPヘッダーで認証を行います。
bearerAuth
をインポートし、ミドルウェアを作成します。作成したミドルウェアをAPIの定義時に指定します。
以下の場合は、環境変数にセットしたAPI_KEY
が唯一正しいAPIキーとなります。複数のキーを指定する方法や関数でチェックすることもできるようです。(あくまでサンプルとして環境変数を使用してますので、機密情報の取り扱いはご注意ください)
import { bearerAuth } from 'hono/bearer-auth';
const authMiddleware = bearerAuth({
token: process.env.API_KEY || '',
invalidTokenMessage: 'Invalid API key',
noAuthenticationHeaderMessage: 'Authorization header is required',
});
app.post('/mcp', authMiddleware, async (c) => {
VSCodeをMCPクライアントとして使用する場合は、mcp.json
にURLとヘッダーを指定します。
JSONに認証情報を記載するのはあまりおすすめできないため、inputs
として設定するほうが良いと思います。設定方法は公式ドキュメントを参照ください。こちら
{
"servers": {
"apikey-authorization": {
"url": "https://**********.execute-api.us-east-1.amazonaws.com/mcp",
"headers": {
"Authorization": "Bearer your-secret-api-key-here"
}
},
}
}
OAuth認証
こちらのSAMプロジェクトです。
さて、OAuth認証対応版ですが、ここまでと異なり、かなり複雑です。
というのも、MCPの認証の仕様として、Dynamic Client Registration
というプロトコルに対応する必要があります。(こちら)
Amazon Cognitoが残念ながらDynamic Client Registrationに対応しておらず、不足している部分を自前の実装で対応しました。正規の認証フローと異なる部分もあるので、セキュリティ的にあまりよろしくない可能性も高いのですが、「一応動くもの」として紹介させてもらいます。
実装の詳細はGitHubを参照してもらうとして、ここではDynamic Client Registrationのフローを眺めてみようと思います。
Phase 1: Initial MCP Request (Unauthenticated)
まずは、認証情報なしに、/mcp
エンドポイントにPOSTリクエストを送信します。認証が必要なので、401で返答するのですが、この際にHTTPヘッダーへWWW-Authenticate
を付与します。WWW-Authenticateには次のステップで使用する/.well-known/oauth-protected-resource
エンドポイントのURLが含まれます。
API Gatewayのハマりポイントとして、REST APIの場合のみ、 レスポンスのHTTPヘッダーに含めたWWW-Authenticate
がX-Amzn-Remapped-WWW-Authenticate
に置き換わります。回避方法が見つけられませんでした。
そのため、HTTP API(新しい方)を使うとこの制約はないので、こちらを採用しております。
数日ハマりましたね、これ。
https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html
Phase 2: Metadata Discovery
MCPクライアントは、認証に必要なメタデータを取得します。
具体的には/.well-known/oauth-protected-resource
エンドポイントと/.well-known/oauth-authorization-server
にリクエストを送信します。
メタデータとしてこのあと呼び出す/register
やauthorize
、token
などのエンドポイント情報が取得します。
Phase 3: Dynamic Client Registration
ここでクライアントを認証サーバーに登録します。登録の結果として、認証処理に必要なClient IDを取得します。
今回実装したものでは、Client IDをCognitoのClinent IDと一致させていますが、Dynamic Client Registrationの仕様としては推奨されない形だと思います。
Cognitoを使用するための制限として割り切ってますが、採用の是非がご検討ください。
Phase 4: Authorization Flow Start
必要な情報が揃ったので、認証処理を開始しますが、ここもひとひねり入ってます。
Cognitoには事前にコールバックURLを登録しておく必要がありますが、MCPクライアントが動的なため、Cognitoには登録されていないコールバックURL(VSCodeが待ち構えるURL)へリダイレクトする必要があります。事前登録済みではないコールバックURLを指定すると、そもそも認証前にエラーとなってしまいます。
その対策として、/callbackとしてエンドポイントを用意し、そこで一度リダイレクトを受けた後、再度MCPクライアントのコールバックURLに再リダイレクトする仕組みとしました。
これもやっていいかはわかりません。。
Phase 5: Cognito Authentication
ここはCognitoのHosted UIでの認証を行うフローです。通常のCognitoのログインと変わりません。
ログインが成功したら、コールバックエンドポイントにリダイレクトします。
Phase 6: OAuth Callback Processing
コールバックエンドポイントは、MCPクライアントがトークン発行に使用するcodeなどの情報を詰め直してMCPクライアントに再リダイレクトを行います。
Phase 7: Token Exchange
いよいよトークンを発行するのですが、ここもプロキシを挟む必要がありました。トークンを発行する際に必要なパラメーターの中にコールバックURLの情報があるため、どうしてもこうなってしまいました。
Phase 8: Authenticated MCP Request
無事、トークンが発行されたので、MCPクライアントは、認証情報を付与して/MCP
へリクエストできるようになりました。
まとめ
Honoがいい!
MCPの公式SDKを使いつつ、Lambda上でMCPサーバーが構築できるので色々使い道がありそうです。
OAuth認証がこれでいいかは若干怪しいですが、少なくとも、個人や自社向けのMCPサーバーをサーバーレスで構築できるので、可能性は無限大!
APIキー認証をもうちょっと高度化すれば実用的になりそうに思います。