🔰

MCPを理解するための Mcp Server 構築入門

に公開

MCP(Model Context Protocol)は、AIエージェントと外部システムをつなぐための重要な仕組みとして注目を集めています。私も MCP Server を使ってみて利便性が高まることを実感しています。
しかし、次のような悩みが出てきました。

  1. MCP の挙動を理解できていない → 使いこなせているか?
  2. 自分の用途に合った MCP Server が提供されていない → 自作できないか?
  3. 自社サービスの MCP Server を立てられないか → ビジネスチャンス!?

そこで、ドキュメントや MCP ライブラリ(mark3labs/mcp-go)のコードを参考に自分で MCP Server を構築したところ、1・2 の課題を解消できました。

この記事では、敢えてMCP ライブラリを使わずに1から MCP Server を構築することを通じて、同じ悩みを持つ人の助けになれば幸いです。

前提

Protocol Version: 2025-03-26
mark3labs/mcp-go: v0.26.0
MCP Host: Cursor

Model Context Protocol (MCP) とは何か?

MCP はAIモデルと外部システムの間で情報のやり取りを行うためのルール(プロトコル)です。この標準化によって、AIモデルと外部システムが相互接続しやすくなります。

では、なぜ外部システムと連携する必要があるのでしょうか?主な理由として、次のような点が挙げられます

  • 動的なデータにリアルタイムでアクセスできる(例:最新のタスク情報など)
  • 必要なときに必要な情報だけを取得できる(プロンプトに全て書く必要がなくなる)
  • 外部システムの操作が可能になる(エージェントからAPIを通じて実行指示を出せる)

MCPを活用することで、AIモデルを現実の業務や情報環境により適応させることができます。

MCP アーキテクチャ

  • MCP ホスト
    AI モデルを搭載したアプリケーションです。例えば、Cursor や Claude Desktop などが該当します。これらのアプリケーションは、MCP クライアントを通じて外部データにアクセスします。

  • MCP クライアント
    MCP クライアントは、MCP ホスト内に組み込まれたコンポーネントで、MCP サーバとの通信を担当します。各クライアントは1つのMCPサーバと状態を持ったセッションを確立し、リクエストを送信して機能を利用します。

  • MCP サーバ
    MCP サーバは、特定のデータソースや機能へのアクセスを提供するサーバです。例えば、ファイルシステム、データベース、Web API などへのアクセスを扱うことができます。サーバはクライアントからのリクエストを処理し、必要なデータや機能を提供します。

MCPサーバーが提供する3つの機能

MCPサーバーは、AIモデルとのやりとりをより柔軟に拡張するために、3つの基本機能を提供します。これらは、モデルにコンテキストを与えたり、行動を起こさせたりするための仕組みです。

プロンプト(Prompts):モデルのふるまいを誘導するためのテンプレートや指示(例:定型の入力指示)

リソース(Resources):モデルに追加の文脈や情報を与えるための構造化データ(例:ファイルの内容)

ツール(Tools):モデルが外部と連携して実際にアクションを実行するための機能(例:APIへのPOSTリクエスト)

本記事では、この中でもツール機能を持った MCP Serverの実装に焦点を当てます。

ライフサイクル

MCP では、クライアントとサーバーの接続に対して明確なライフサイクルが定義されています。これにより、通信の整合性や機能のやり取り、状態の管理が正しく行われるようになっています。

下図は、MCP におけるクライアントとサーバー間の通信ライフサイクルを示しています。

初期化(Initialization)
クライアントがサーバーと接続する際に、使用するプロトコルのバージョンや、互いに利用可能な機能の確認を行います。いわば「通信を始める前のすり合わせ」の段階です。

操作(Operation)
メインとなるやり取りのフェーズです。クライアントは、MCP サーバーに対してリクエストを送り、サーバーは必要な処理を行って応答を返します。ここではツール機能を使ったやりとりについて記載しています。

終了(Shutdown)
セッションを正常にクローズするための処理を行います。

MCPクライアントとサーバー間のすべてのメッセージは、JSON-RPC 2.0仕様に準拠する必要があります(Messages)。

MCP Server を1から実装する

ツール機能を持った MCP Server を Go で実装していきます。
今回は例として、ドキュメント管理 SaaS の「esa」から指定した記事情報を取得するサーバーを構築します。

私が所属する組織ではナレッジを esa に蓄積しており、AI モデルにその情報へアクセスする能力を与えることで、より有用なアシスタントとして活用するのが目的です。

処理フローは次のようになります。

  1. Host に 「<esa記事URL>を参考にコードを修正して」 という指示を出す
  2. MCP Client が MCP Server のツール実行を呼び出す
  3. MCP Server が esa 記事取得APIを使って記事情報を取得する
  4. Host が取得した記事情報を元にコードを修正する

それでは、MCP Server の実装について順を追って説明していきます。

入出力処理

まずはじめに MCP Client からリクエストを受信し、レスポンスを返す処理を実装します。
MCP のトランスポート層は基本的に stdio と Streamable HTTP の2種類がありますが、今回は stdio を使用します。つまり、クライアントからのリクエストを標準入力として受信し、レスポンスを標準出力に書き込みます。

func main() {
	ctx := context.Background()
	reader := bufio.NewReader(os.Stdin)
	for {
		// 1. リクエストの読み込み
		request, err := readRequest(ctx, reader)
		fmt.Fprintf(os.Stderr, "[debug:request] \n %v \n", request)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
			return
		}
		if request == "\n" {
			fmt.Fprintln(os.Stderr, "Please Input Info")
			continue
		}
		// 2. リクエストのハンドリング
		response := handle(ctx, []byte(request))
		if response == nil {
			continue
		}
		responseBytes, err := json.Marshal(response)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error marshalling response: %v\n", err)
			continue
		}
		// 3. レスポンスの出力
		fmt.Fprintf(os.Stderr, "[debug:response]\n %v \n", string(responseBytes))
		fmt.Fprintln(os.Stdout, string(responseBytes))
	}
}

func readRequest(ctx context.Context, reader *bufio.Reader) (string, error) {
	readChan := make(chan string, 1)
	errChan := make(chan error, 1)
	done := make(chan struct{})
	defer close(done)

	go func() {
		select {
		case <-done:
			return
		default:
			// ユーザが入力するまでブロック
			line, err := reader.ReadString('\n')
			if err != nil {
				select {
				case errChan <- err:
				case <-done:

				}
				return
			}
			select {
			case readChan <- line:
			case <-done:
			}
		}
	}()

	select {
	case <-ctx.Done():
		return "", ctx.Err()
	case err := <-errChan:
		return "", err
	case line := <-readChan:
		return line, nil
	}
}

「リクエストの読み込み → リクエストのハンドリング → レスポンスの出力」を for 文で繰り返しています。

  1. リクエストの読み込み:readRequest() でユーザの入力を待ち受ける形でブロックし、受け取った入力結果を返します。ブロックするものの、コンテキストが閉じられた時やエラーが発生した時には抜けられるように ゴルーチンと select 文を使っています。これは mcp-go の実装そのままのコードです。
  2. リクエストのハンドリング:後述します。
  3. レスポンスの出力:os.Stdout にレスポンスを書き込むことで MCP Client にレスポンスを送ります。標準出力をレスポンスとして使用する関係上、デバッグログは標準エラー出力(os.Stderr)に書き込みます。

リクエストのハンドリング

MCP Client からのリクエストをパースし次の流れで処理を行います。

  1. JSON-RPC バージョンの互換性チェック
  2. リクエストIDの存在チェック
  3. リクエストメソッドに応じた処理の実行(initialization/ToolsList/ToolsCall)

チェックに失敗した場合は、エラーレスポンスを返します(Error Handling)。
以降、initialization・ToolsList・ToolsCall についてそれぞれ解説していきます。

func handle(ctx context.Context, msg []byte) *Response {
	response := &Response{JSONRPC: "2.0"}
	var baseRequestMessage struct {
		JSONRPC string    `json:"jsonrpc"`
		Method  MCPMethod `json:"method"`
		ID      any       `json:"id"`
		Result  any       `json:"result,omitempty"`
	}
	if err := json.Unmarshal(msg, &baseRequestMessage); err != nil {
		response.Error = &Error{
			Code:    PARSE_ERROR,
			Message: "Failed to parse message",
		}
		return response
	}
	response.ID = baseRequestMessage.ID
	// 1. JSON-RPC バージョンの互換性チェック
	if baseRequestMessage.JSONRPC != "2.0" {
		response.Error = &Error{
			Code:    INVALID_REQUEST,
			Message: "Invalid JSON-RPC version",
		}
		return response
	}

	// 2. リクエストIDの存在チェック
	if baseRequestMessage.ID == nil {
		fmt.Fprintf(os.Stderr, "[debug:notification]\n\n")
		return nil // 通知はレスポンスを返さない
	}

	// 3. リクエストメソッドに応じた処理の実行
	switch baseRequestMessage.Method {
	// initialization
	case MethodInitialize:
		response.Result = map[string]interface{}{
			"protocolVersion": "2025-03-26",
			"capabilities": map[string]interface{}{
				"tools": map[string]bool{
					"listChanged": true,
				},
			},
			"serverInfo": map[string]string{
				"name":    "esa.io Link Resolver",
				"version": "1.0.0",
			},
		}
		return response
	// toolsList
	case MethodToolsList:
		response.Result = map[string]interface{}{
			"tools": []map[string]interface{}{
				{
					"name":        "get_esa_post",
					"description": "Use this tool when a user provides a link to an esa.io post. It fetches the post content from esa.io using the team name and post number.",
					"inputSchema": map[string]interface{}{
						"type": "object",
						"properties": map[string]interface{}{
							"post_number": map[string]string{
								"description": "The post number at the end of the esa.io URL, e.g., '123' in 'https://example-team.esa.io/posts/123'",
								"type":        "number",
							},
							"team_name": map[string]string{
								"description": "The team name shown in the esa.io URL, e.g., 'example-team' in 'https://example-team.esa.io/posts/123'",
								"type":        "string",
							},
						},
						"required": []string{"post_number", "team_name"},
					},
				},
			},
		}
		return response
	// toolsCall
	case MethodToolsCall:
		var callToolRequest struct {
			Params struct {
				Name      string                 `json:"name"`
				Arguments map[string]interface{} `json:"arguments,omitempty"`
			} `json:"params"`
		}
		if unmarshalErr := json.Unmarshal(msg, &callToolRequest); unmarshalErr != nil {
			response.Error = &Error{
				Code:    INVALID_REQUEST,
				Message: "Failed to parse tool call request",
			}
			return response
		}
		post, err := getEsaPost(
			ctx,
			callToolRequest.Params.Arguments["team_name"].(string),
			int(callToolRequest.Params.Arguments["post_number"].(float64)),
		)
		if err != nil {
			response.Error = &Error{
				Code:    INTERNAL_ERROR,
				Message: err.Error(),
			}
			return response

		}
		response.Result = map[string]interface{}{
			"content": []map[string]interface{}{
				{
					"type": "text",
					"text": post,
				},
			},
		}
		return response
	default:
		response.Error = &Error{
			Code:    METHOD_NOT_FOUND,
			Message: fmt.Sprintf("Method %s not found", baseRequestMessage.Method),
		}
		return response
	}
}

type Response struct {
	JSONRPC string      `json:"jsonrpc"`
	ID      any         `json:"id"`
	Result  interface{} `json:"result,omitempty"`
	Error   *Error      `json:"error,omitempty"`
}

initialization

初期化フェーズは、クライアントとサーバー間の最初のやり取りです。このフェーズでは、プロトコルバージョンの互換性を確立したり、互いが持つ機能を確認したりします。

MCP クライアントからは次のようなリクエストが送信されます。

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-03-26",
    "capabilities": {
      "tools": true,
      "prompts": false,
      "resources": true,
      "logging": false,
      "roots": {
        "listChanged": false
      }
    },
    "clientInfo": {
      "name": "cursor-vscode",
      "version": "1.0.0"
    }
  }
}

サーバは次のようなレスポンスを返すように実装しています。

{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "protocolVersion": "2025-03-26",
    "serverInfo": {
      "name": "esa.io Link Resolver",
      "version": "1.0.0"
    }
  }
} 
  • protocolVersion:プロトコルバージョンは 2025-03-26
  • capabilities:ツール機能を保有
    • listChanged は使用可能なツールのリストが変更されたときにサーバーが通知を発行するかどうかを提示。
  • serverInfo:サーバ名、バージョン

初期化が成功した後、MCP クライアントから初期化通知が送信されます。

{
  "jsonrpc": "2.0",
  "method": "notifications/initialized"
}

notification は一方向のメッセージとして送信されるもので、受信者は応答を返しません。

Listing Tools

初期化が完了した後、クライアントは利用可能なツールを見つけるために tools/list リクエストを送信します。

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list"
}

サーバは提供可能なツールとそれを呼び出すのに必要な情報を返します。

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "get_esa_post",
        "description": "Use this tool when a user provides a link to an esa.io post. It fetches the post content from esa.io using the team name and post number.",
        "inputSchema": {
          "properties": {
            "post_number": {
              "description": "The post number at the end of the esa.io URL, e.g., '123' in 'https://example-team.esa.io/posts/123'",
              "type": "number"
            },
            "team_name": {
              "description": "The team name shown in the esa.io URL, e.g., 'example-team' in 'https://example-team.esa.io/posts/123'",
              "type": "string"
            }
          },
          "required": [
            "post_number",
            "team_name"
          ],
          "type": "object"
        }
      }
    ]
  }
}
  • name:ツールの名前
  • description:ツールの説明
  • properties:クライアントがツール呼び出しを実行する時にサーバへ渡すパラメータ。記事取得APIを叩くために必要な team_namepost_number という2つのパラメータを定義しています。
  • required:ツールを使用するときに必須となるプロパティ

Listing Tools が完了したらツールを呼び出す準備は完了です。

Calling Tools

クライアントはtools/call リクエストを送信してツール呼び出しを依頼します。

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "get_esa_post",
    "arguments": {
      "post_number": 123,
      "team_name": "some_team"
    }
  }
}
  • name:呼び出すツール名
  • arguments:ツールを実行するためのパラメータ。tools/list レスポンスに含まれるものと同じものがここに入ってきます。

サーバはリクエストに含まれるパラメータを抽出して記事取得APIを叩き、取得した記事情報をレスポンスに含めて返します。

func getEsaPost(ctx context.Context, teamName string, postNumber int) (string, error) {
	token := os.Getenv("ESA_API_TOKEN")
	if token == "" {
		return "", errors.New("API TOKENが設定されていません")
	}
	req, err := http.NewRequestWithContext(
		ctx,
		"GET",
		fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber),
		nil,
	)
	if err != nil {
		return "", errors.New("リクエスト生成エラー: " + err.Error())
	}
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", errors.New("esa API呼び出しエラー: " + err.Error())
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", errors.New("レスポンス読み込みエラー: " + err.Error())
	}
	if resp.StatusCode != 200 {
		var apiErr struct {
			error   string `json:"error"`
			message string `json:"message"`
		}
		_ = json.Unmarshal(body, &apiErr)
		return "", fmt.Errorf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message)
	}
	var post struct {
		Name   string `json:"name"`
		BodyMd string `json:"body_md"`
		Url    string `json:"url"`
	}
	err = json.Unmarshal(body, &post)
	if err != nil {
		return "", fmt.Errorf("記事データのパースエラー: %w", err)
	}
	return fmt.Sprintf("# %s\n\n%s\n\nURL: %s", post.Name, post.BodyMd, post.Url), nil
}
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "content": [
      {
        "text": "記事の内容がここに入ります",
        "type": "text"
      }
    ]
  }
}

Cursor での設定

ここまでの全ソースコードはこのようになります。
package main

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
)

func main() {
	ctx := context.Background()
	reader := bufio.NewReader(os.Stdin)
	for {
		// 1. リクエストの読み込み
		request, err := readRequest(ctx, reader)
		fmt.Fprintf(os.Stderr, "[debug:request] \n %v \n", request)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
			return
		}
		if request == "\n" {
			fmt.Fprintln(os.Stderr, "Please Input Info")
			continue
		}
		// 2. リクエストのハンドリング
		response := handle(ctx, []byte(request))
		if response == nil {
			continue
		}
		responseBytes, err := json.Marshal(response)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Error marshalling response: %v\n", err)
			continue
		}
		// 3. レスポンスの出力
		fmt.Fprintf(os.Stderr, "[debug:response]\n %v \n", string(responseBytes))
		fmt.Fprintln(os.Stdout, string(responseBytes))
	}
}

type MCPMethod string

const (
	MethodInitialize MCPMethod = "initialize"
	MethodToolsList  MCPMethod = "tools/list"
	MethodToolsCall  MCPMethod = "tools/call"
)

const (
	PARSE_ERROR      = -32700
	INVALID_REQUEST  = -32600
	METHOD_NOT_FOUND = -32601
	INTERNAL_ERROR   = -32603
)

func handle(ctx context.Context, msg []byte) *Response {
	response := &Response{JSONRPC: "2.0"}
	var baseRequestMessage struct {
		JSONRPC string    `json:"jsonrpc"`
		Method  MCPMethod `json:"method"`
		ID      any       `json:"id"`
		Result  any       `json:"result,omitempty"`
	}
	if err := json.Unmarshal(msg, &baseRequestMessage); err != nil {
		response.Error = &Error{
			Code:    PARSE_ERROR,
			Message: "Failed to parse message",
		}
		return response
	}
	response.ID = baseRequestMessage.ID
	// 1. JSON-RPC バージョンの互換性チェック
	if baseRequestMessage.JSONRPC != "2.0" {
		response.Error = &Error{
			Code:    INVALID_REQUEST,
			Message: "Invalid JSON-RPC version",
		}
		return response
	}

	// 2. リクエストIDの存在チェック
	if baseRequestMessage.ID == nil {
		fmt.Fprintf(os.Stderr, "[debug:notification]\n\n")
		return nil // 通知はレスポンスを返さない
	}

	// 3. リクエストメソッドに応じた処理の実行
	switch baseRequestMessage.Method {
	// initialization
	case MethodInitialize:
		response.Result = map[string]interface{}{
			"protocolVersion": "2025-03-26",
			"capabilities": map[string]interface{}{
				"tools": map[string]bool{
					"listChanged": true,
				},
			},
			"serverInfo": map[string]string{
				"name":    "get esa post server",
				"version": "1.0.0",
			},
		}
		return response
	// toolsList
	case MethodToolsList:
		response.Result = map[string]interface{}{
			"tools": []map[string]interface{}{
				{
					"name":        "get_esa_post",
					"description": "Gets the specified post from the esa API",
					"inputSchema": map[string]interface{}{
						"type": "object",
						"properties": map[string]interface{}{
							"post_number": map[string]string{
								"description": "post number",
								"type":        "number",
							},
							"team_name": map[string]string{
								"description": "team name",
								"type":        "string",
							},
						},
						"required": []string{"post_number", "team_name"},
					},
				},
			},
		}
		return response
	// toolsCall
	case MethodToolsCall:
		var callToolRequest struct {
			Params struct {
				Name      string                 `json:"name"`
				Arguments map[string]interface{} `json:"arguments,omitempty"`
			} `json:"params"`
		}
		if unmarshalErr := json.Unmarshal(msg, &callToolRequest); unmarshalErr != nil {
			response.Error = &Error{
				Code:    INVALID_REQUEST,
				Message: "Failed to parse tool call request",
			}
			return response
		}
		post, err := getEsaPost(
			ctx,
			callToolRequest.Params.Arguments["team_name"].(string),
			int(callToolRequest.Params.Arguments["post_number"].(float64)),
		)
		if err != nil {
			response.Error = &Error{
				Code:    INTERNAL_ERROR,
				Message: err.Error(),
			}
			return response

		}
		response.Result = map[string]interface{}{
			"content": []map[string]interface{}{
				{
					"type": "text",
					"text": post,
				},
			},
		}
		return response
	default:
		response.Error = &Error{
			Code:    METHOD_NOT_FOUND,
			Message: fmt.Sprintf("Method %s not found", baseRequestMessage.Method),
		}
		return response
	}
}

func getEsaPost(ctx context.Context, teamName string, postNumber int) (string, error) {
	token := os.Getenv("ESA_API_TOKEN")
	if token == "" {
		return "", errors.New("API TOKENが設定されていません")
	}
	req, err := http.NewRequestWithContext(
		ctx,
		"GET",
		fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber),
		nil,
	)
	if err != nil {
		return "", errors.New("リクエスト生成エラー: " + err.Error())
	}
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", errors.New("esa API呼び出しエラー: " + err.Error())
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", errors.New("レスポンス読み込みエラー: " + err.Error())
	}
	if resp.StatusCode != 200 {
		var apiErr struct {
			error   string `json:"error"`
			message string `json:"message"`
		}
		_ = json.Unmarshal(body, &apiErr)
		return "", fmt.Errorf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message)
	}
	var post struct {
		Name   string `json:"name"`
		BodyMd string `json:"body_md"`
	}
	err = json.Unmarshal(body, &post)
	if err != nil {
		return "", fmt.Errorf("記事データのパースエラー: %w", err)
	}
	return fmt.Sprintf("# %s\n\n%s", post.Name, post.BodyMd), nil
}

type Response struct {
	JSONRPC string      `json:"jsonrpc"`
	ID      any         `json:"id"`
	Result  interface{} `json:"result,omitempty"`
	Error   *Error      `json:"error,omitempty"`
}

type Error struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// readRequest reads a single line from the input reader in a context-aware manner.
// It uses channels to make the read operation cancellable via context.
// Returns the read line and any error encountered. If the context is cancelled,
// returns an empty string and the context's error. EOF is returned when the input
// stream is closed.
func readRequest(ctx context.Context, reader *bufio.Reader) (string, error) {
	readChan := make(chan string, 1)
	errChan := make(chan error, 1)
	done := make(chan struct{})
	defer close(done)

	go func() {
		select {
		case <-done:
			return
		default:
			// ユーザが入力するまでブロック
			line, err := reader.ReadString('\n')
			if err != nil {
				select {
				case errChan <- err:
				case <-done:

				}
				return
			}
			select {
			case readChan <- line:
			case <-done:
			}
		}
	}()

	select {
	case <-ctx.Done():
		return "", ctx.Err()
	case err := <-errChan:
		return "", err
	case line := <-readChan:
		return line, nil
	}
}

上記コードを main.go にまとめて次のようにビルドします。

go build -o esa-mcp-server

続いて mcp.json に MCP Server の設定を記載します。

mcp.json
{
  "mcpServers": {
    "esa-mcp-server": {
      "command": "[your_path]/esa-mcp-server",
      "args": [],
      "env":{
        "ESA_API_TOKEN": "{取得した esa api token}"
      },
      "disabled":false,
      "autoApprove":[]
    }
  }
}

commandにはビルド成果物であるバイナリファイルへのパスを記載し、env には esa にアクセスするためのAPIトークンを設定します。

これで Cursor から作成した MCP Server を使って操作できるようになりました!

mcp-go を使うと簡単に実装できる

今回は MCP の仕組みを理解するために敢えて MCP ライブラリを使わずに実装しましたが、mcp-go を使って実装すると非常にシンプルに書けます。

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

func main() {
	s := server.NewMCPServer(
		"esa.io Link Resolver",
		"1.0.0",
		server.WithRecovery(),
	)

	// ツールの定義(tools/list で返す値)
	getEsaPostTool := mcp.NewTool("get_esa_post",
		mcp.WithDescription("Use this tool when a user provides a link to an esa.io post. It fetches the post content from esa.io using the team name and post number."),
		mcp.WithString("team_name",
			mcp.Required(),
			mcp.Description("The team name shown in the esa.io URL, e.g., 'example-team' in 'https://example-team.esa.io/posts/123'"),
		),
		mcp.WithNumber("post_number",
			mcp.Required(),
			mcp.Description("The post number at the end of the esa.io URL, e.g., '123' in 'https://example-team.esa.io/posts/123'"),
		),
	)

	// ツール実行時のアクションを定義
	s.AddTool(getEsaPostTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
		teamName := request.Params.Arguments["team_name"].(string)
		postNumber := int(request.Params.Arguments["post_number"].(float64))

		token := os.Getenv("ESA_API_TOKEN")
		if token == "" {
			return nil, errors.New("API_TOKENが設定されていません")
		}

		url := fmt.Sprintf("https://api.esa.io/v1/teams/%s/posts/%d", teamName, postNumber)
		req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
		if err != nil {
			return nil, errors.New("リクエスト生成エラー: " + err.Error())
		}
		req.Header.Set("Authorization", "Bearer "+token)
		req.Header.Set("Content-Type", "application/json")

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return nil, errors.New("esa API呼び出しエラー: " + err.Error())
		}
		defer resp.Body.Close()

		body, err := io.ReadAll(resp.Body)
		if err != nil {
			return nil, errors.New("レスポンス読み込みエラー: " + err.Error())
		}

		if resp.StatusCode != 200 {
			var apiErr struct {
				error   string `json:"error"`
				message string `json:"message"`
			}
			_ = json.Unmarshal(body, &apiErr)
			return nil, errors.New(fmt.Sprintf("esa APIエラー: %s (%s)", apiErr.error, apiErr.message))
		}

		var post struct {
			Name   string `json:"name"`
			BodyMd string `json:"body_md"`
		}
		err = json.Unmarshal(body, &post)
		if err != nil {
			return nil, errors.New("記事データのパースエラー: " + err.Error())
		}
		return mcp.NewToolResultText(fmt.Sprintf("# %s\n\n%s", post.Name, post.BodyMd)), nil
	})

	// Start the server
	if err := server.ServeStdio(s); err != nil {
		fmt.Printf("Server error: %v\n", err)
	}
}

ユーザ独自の実装(ツール定義とツール実行)にフォーカスでき、それ以外の部分はライブラリに任せることが可能になっています。

ここまでの内容を抑えていれば基本的な MCP の挙動は理解できているので、皆さんも MCP ライブラリのコードを読んで使えるようになっているはずです。
いざ自分で MCP Server を構築する際は、mcp-go を試してみてください。

まとめ

MCP Server を1から実装することで、MCP の理解を深めることができたと思います。
これをきっかけに MCP をより使いこなしてもらえると嬉しいです。

Discussion