🦀

Rustで自作MCPサーバー

に公開

はじめに

みなさんそろそろ「こんなものをMCPサーバー化してみました!」という記事は見飽きてきた頃だと思います。
この記事ではSpecificationを読み解き、公式SDKを使わずにMCPサーバーを実装します。なお、MCPの本質でない部分ではサードパーティークレートを使用します。使用したクレートは以下の通りです。

Cargo.toml
async-trait = "0.1.88"
schemars = "0.8.22"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_repr = "0.1.20"
tokio = { version = "1.45.0", features = ["fs", "io-std", "io-util", "macros", "rt-multi-thread"] }
tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }

ロギングをサボればtracingtracing-subscriberは必要ありません。また、非同期ではなく同期で動作するのでもよければasync-traittokioも必要ありません。

書くこと

  • MCPの具体的な内部仕様
  • Rustでの実装方法

書かないこと

  • 「そもそもMCPとは何ぞや」といった話
  • Rustの基本的な言語機能やライブラリの使い方(適宜TRPLcrates.iodocs.rsなどを参照してください)

MCPの内部仕様

仕様は公式サイトにまとめられています。現時点で2024-11-05と2025-03-26の2つのバージョンがありますが、本記事は最新版である2025-03-26に準拠します。

早速Specificationを頭から読み始めていきましょう...と言いたいところですが、Lifecycleの項を先に読んだ方がわかりやすいです。
MCPによるセッション全体の図解があるので見てみましょう。

セッション全体の概要(出典: Lifecycle - Model Context Protocol

中間に「Normal Protocol Operations」とありますが、実際のところサーバーはクライアントにどのような要求をされうるのでしょうか?それを知るためにServer Features / Overviewの項を見てみましょう。以下の3つの機能が定義されていることがわかります。

  • Prompts: 言語モデルとの相互作用を補助するための、前もって定義されたテンプレートや指示
    • 例: スラッシュコマンドやメニューオプション
  • Resources: モデルに追加の情報を提供する、構造化されたデータやコンテンツ
    • 例: ファイルの内容、git履歴
  • Tools: モデルがアクションを実行したり情報を取得したりするための、実行可能な関数
    • 例: APIへのPOST、ファイル書き込み

ResourceとToolの使い分けがややわかりにくいですが、公式の実装例を見るとほとんどの機能がToolとして実装されているため、迷ったらToolsの使用を検討するといいのかもしれません。

サーバーがクライアントに何かを要求することもできます。以下の2つの機能が定義されています。

  • Roots: ファイルシステム上のルート(プロジェクトルート)を取得する(ルートは複数あっても良い)
  • Sampling: クライアントを通じて言語モデルを呼び出す

Transportsの項を見ると、クライアントとサーバーの間の通信にはJSON-RPC 2.0を使用することがわかります。Transportの実装としては標準入出力とStreamable HTTPが定義されています。

実装するもの

ここからはRustでMCPサーバーを実装していきます。今回はMCPの理解を目的としているため、以下のようなごく簡単なものを実装します。

  • tool: 与えられた文字列を逆順にして返すツール
  • resource: 円周率が小数点以下100桁書かれたファイル(pi.txt)
  • prompts: なし
  • sampling: なし
  • transport: stdioのみ

https://github.com/inomata137/mcp_rs

アーキテクチャ

MCPサーバーを実装するにあたり、変更が少ないであろう、アプリケーション全体の土台になるものは何でしょうか?MCPの仕様そのものです。一方で、具体的な個々のツールやリソースは比較的自由に追加したり変更したりできます。
そこで、クライアント・サーバーの情報、Tools、Resourcesなどの概念をクリーンアーキテクチャで言うところのEntitiesとして実装し、具体的な個々のToolsやResourcesはDBのような一番外側のレイヤーとして実装します。Cargoのワークスペース機能を活かし、以下のような構成にします。

  • controller: methodとusecaseの対応付け
  • domain: MCP仕様に登場する概念の定義
  • jsonrpc: JSON-RPC 2.0の表現
  • protocol: いわゆるusecase
  • resources: Resourcesの具体実装
  • tools: Toolsの具体実装
  • transport: Stdio Transportの実装

jsonrpcについては既存のクレートもあるのですが、どちらかというとRPCクライアント向けのライブラリのようで、不要なものも多くオーバースペック気味だったため自前で実装することにします。

メンバー間の依存関係は以下の通りです。

土台の実装

以下を実行してアプリケーションを作成します。

$ cargo new mcp_rs
$ cd mcp_rs

ワークスペースを有効化するためCargo.tomlに

[workspace]
members = []

と追記し、さらに以下を実行します。

$ cargo new --lib controller
$ cargo new --lib domain
$ cargo new --lib jsonrpc
$ cargo new --lib protocol
$ cargo new --lib resources
$ cargo new --lib tools
$ cargo new --lib transport

使用する外部クレートをCargo.tomlに記載しておきます[1]

[workspace.dependencies]
async-trait = "0.1.88"
schemars = "0.8.22"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
serde_repr = "0.1.20"
tokio = { version = "1.45.0", features = ["fs", "io-std", "io-util", "macros", "rt-multi-thread"] }

各メンバーの実装

jsonrpc

jsonrpcクレートではJSON-RPC 2.0の表現を実装します。ResuestResponseErrorObjectなどの型を定義します。エラーコードの一覧もここで定義します。

jsonrpc
├── src
│   ├── error.rs
│   ├── id.rs
│   ├── lib.rs
│   └── version.rs
└── Cargo.toml

Responseの定義は

#[derive(serde::Serialize)]
#[serde(remote = "Result")]
enum Output<T, E> {
    #[serde(rename = "result")]
    Ok(T),
    #[serde(rename = "error")]
    Err(E),
}

#[derive(serde::Serialize)]
pub struct Response<T>
where
    T: serde::Serialize,
{
    pub jsonrpc: JsonRpcVersion,
    pub id: Option<RequestId>,
    #[serde(flatten)]
    #[serde(with = "Output")]
    pub output: Result<T, error::ErrorObject>,
}

となっています。outputが成功か失敗かによりJSONでのキーが変わるため、ResultOutput経由でシリアライズし、Response上に展開するというややテクいことをする必要があります。

また、バッチリクエストに対応するため、enum Batchable<T>を定義しておきます。

#[derive(serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum Batchable<T> {
    Single(T),
    Batch(Vec<T>),
}

domain

domainクレートではMCP仕様に登場する概念の定義を実装します。

domain
├── src
│   ├── entity
│   │   ├── annotation.rs # toolやresourceで使用するAnnotationの定義
│   │   ├── client.rs # クライアント情報やクライアントのCapabilitiesの定義
│   │   ├── mod.rs
│   │   ├── resource.rs # resourceの定義
│   │   ├── server.rs # サーバー情報やサーバーのCapabilitiesの定義
│   │   └── tool.rs # toolの定義
│   ├── repository
│   │   ├── mod.rs
│   │   ├── resource.rs # ResourcesRepositoryトレイトの定義
│   │   └── tool.rs # ToolsRepositoryトレイトの定義
│   └── lib.rs
└── Cargo.toml

詳細はGitHubを見てもらうことにして、雰囲気を掴んでもらうためにToolsRepositoryトレイトの定義を載せておきます。

domain/src/entity/tool.rsの内容
domain/src/entity/tool.rs
#[derive(serde::Serialize)]
pub struct TextContent {
    pub text: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub annotations: Option<super::annotation::Annotations>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageContent {
    pub data: String,
    pub mime_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub annotations: Option<super::annotation::Annotations>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AudioContent {
    pub data: String,
    pub mime_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub annotations: Option<super::annotation::Annotations>,
}

#[derive(serde::Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Content {
    Text(TextContent),
    Image(ImageContent),
    Audio(AudioContent),
    // Resource,
}

impl From<TextContent> for Content {
    fn from(content: TextContent) -> Self {
        Content::Text(content)
    }
}

impl From<ImageContent> for Content {
    fn from(content: ImageContent) -> Self {
        Content::Image(content)
    }
}

impl From<AudioContent> for Content {
    fn from(content: AudioContent) -> Self {
        Content::Audio(content)
    }
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolAnnotations {
    #[serde(skip_serializing_if = "Option::is_none")]
    title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    read_only_hint: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    destructive_hint: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    idempotent_hint: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    open_world_hint: Option<bool>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ToolInfo {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    pub input_schema: schemars::schema::RootSchema,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub annotations: Option<ToolAnnotations>,
}

#[derive(serde::Serialize)]
pub struct CallToolResult {
    pub content: Vec<Content>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub is_error: Option<bool>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListToolsResult {
    pub tools: Vec<ToolInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

#[non_exhaustive]
pub enum ToolError {
    ParameterMissing,
    UnknownTool(String),
    InternalError,
}
domain/src/repository/tool.rs
#[async_trait::async_trait]
pub trait ToolsRepository {
    async fn call(
        &self,
        name: &str,
        args: Option<serde_json::Value>,
    ) -> Result<crate::entity::tool::CallToolResult, crate::entity::tool::ToolError>;

    fn list(
        &self,
        cursor: Option<String>,
    ) -> Result<crate::entity::tool::ListToolsResult, crate::entity::tool::ToolError>;
}

以下に実装の要点をまとめます。

  • toolの引数のスキーマにはschemarsクレートのschemars::schema::RootSchemaを使用します
  • DTOをサボるためdomainレイヤーの型にserde::Serializeを実装します
  • Repositoryトレイトのメソッドを非同期にするため、async-traitクレートを使用します

protocol

protocolクレートはいわゆるusecase層に相当します。

protocol
├── src
│   ├── usecase
│   │   ├── lifecycle.rs # InitializeUsecaseトレイトなどを定義
│   │   ├── mod.rs
│   │   ├── ping.rs # PingUsecaseトレイトなどを定義
│   │   ├── resource.rs # ListResourcesUsecaseトレイトなどを定義
│   │   └── tool.rs # ListToolsUsecaseトレイトなどを定義
│   ├── interactor.rs # ServerImpl構造体を定義しServerトレイトを実装
│   ├── lib.rs
│   ├── server.rs # Serverトレイトを定義し各種Usecaseトレイトを実装
│   └── version.rs # プロトコルバージョンを定義
└── Cargo.toml

各種Usecaseトレイトは

#[async_trait::async_trait]
pub trait CallToolUsecase {
    async fn call_tool(&self, params: CallToolParams) -> Result<CallToolResult, CallToolError>;
}

のように定義されています。server.rsではServerトレイトを定義してこれらを一つにまとめています。これにより、Serverトレイトを実装したものはただちに全てのUsecaseを実装することになります。

// server.rs
pub trait Server {
    fn before_init(
        &self,
        params: &crate::usecase::lifecycle::InitializeParams,
    ) -> Result<(), jsonrpc::ErrorObject>;
    fn after_init(&self, params: &crate::usecase::lifecycle::InitializedParams);
    fn info(&self) -> domain::entity::server::ServerInfo;
    fn capabilities(&self) -> domain::entity::server::ServerCapabilities;
    fn instructions(&self) -> Option<String>;
    fn tools_repository(
        &self,
    ) -> std::sync::Arc<dyn domain::repository::tool::ToolsRepository + Sync + Send>;
    fn resources_repository(
        &self,
    ) -> std::sync::Arc<dyn domain::repository::resource::ResourcesRepository + Sync + Send>;
}

impl<S: Server> crate::usecase::lifecycle::InitializeUsecase for S {
    ..
}

impl<S: Server> crate::usecase::ping::PingUsecase for S {
    ..
}

impl<S: Server> crate::usecase::tool::ListToolsUsecase for S {
    ..
}

// 以下略

interactor.rsではServerImpl構造体を定義しServerトレイトを実装しています。Dependency Injectionのため動的ディスパッチを用います。

// interactor.rs
pub struct ServerImpl {
    pub info: domain::entity::server::ServerInfo,
    pub capabilities: domain::entity::server::ServerCapabilities,
    pub tools_repository:
        std::sync::Arc<dyn domain::repository::tool::ToolsRepository + Sync + Send>,
    pub resources_repository:
        std::sync::Arc<dyn domain::repository::resource::ResourcesRepository + Sync + Send>,
}

impl crate::server::Server for ServerImpl {
    ..
}

controller

controllerクレートはmethodとusecaseの対応付けを行います。presenterも兼ねているため、Usecaseの出力を加工してjsonrpc::Response型に変換するのもこのレイヤーです。

controller
├── src
│   ├── lib.rs
│   ├── lifecycle.rs # initializeなどのmethodのハンドラー
│   ├── ping.rs # ping methodのハンドラー
│   ├── resource.rs # resources関連のmethodのハンドラー
│   └── tool.rs # tools関連のmethodのハンドラー
└── Cargo.toml

例としてtool.rsの内容を以下に示します。引数serverの型を&impl protocol::server::Serverとすることで、実装がServerImplの詳細に依存せずServerトレイトにのみ依存することを強制しています。

controller/src/tool.rs
pub fn list_tools(
    server: &impl protocol::server::Server,
    req: jsonrpc::Request,
) -> jsonrpc::Response<protocol::usecase::tool::ListToolsResult> {
    use protocol::usecase::tool::ListToolsUsecase;

    let output = match req.params.map(serde_json::from_value).transpose() {
        Ok(params) => server
            .list_tools(params)
            .map_err(jsonrpc::ErrorObject::from),
        Err(err) => Err(jsonrpc::ErrorObject::parameter_invalid(err)),
    };
    jsonrpc::Response::new(req.id, output)
}

pub async fn call_tool(
    server: &(impl protocol::server::Server + Sync),
    req: jsonrpc::Request,
) -> jsonrpc::Response<protocol::usecase::tool::CallToolResult> {
    use protocol::usecase::tool::CallToolUsecase;

    let output = match req.params.map(serde_json::from_value) {
        Some(Ok(params)) => server
            .call_tool(params)
            .await
            .map_err(jsonrpc::ErrorObject::from),
        Some(Err(err)) => Err(jsonrpc::ErrorObject::parameter_invalid(err)),
        None => Err(jsonrpc::ErrorObject::parameter_missing()),
    };
    jsonrpc::Response::new(req.id, output)
}

lib.rsではこれらのハンドラーをmethodと紐付け、さらにバッチリクエストにも対応します。

controller/src/lib.rs
mod lifecycle;
mod ping;
mod resource;
mod tool;

async fn handle_single_request(
    server: &(impl protocol::server::Server + Send + Sync),
    req: jsonrpc::Request,
) -> Option<jsonrpc::Response<serde_json::Value>> {
    match req.method.as_str() {
        "initialize" => Some(lifecycle::initialize(server, req).into_json()),
        "notifications/initialized" => {
            lifecycle::initialized(server, req);
            None
        }
        "tools/list" => Some(tool::list_tools(server, req).into_json()),
        "tools/call" => Some(tool::call_tool(server, req).await.into_json()),
        "resources/list" => Some(resource::list_resources(server, req).await.into_json()),
        "resources/read" => Some(resource::read_resource(server, req).await.into_json()),
        "ping" => Some(ping::ping(server, req).into_json()),
        _ => Some(jsonrpc::Response::<serde_json::Value>::new(
            req.id,
            Err(jsonrpc::ErrorObject::method_not_found(&req.method)),
        )),
    }
}

pub async fn handle_batchable_request(
    server: &(impl protocol::server::Server + Send + Sync),
    reqs: jsonrpc::BatchableRequest,
) -> Option<jsonrpc::BatchableResponse> {
    match reqs {
        jsonrpc::BatchableRequest::Single(req) => {
            handle_single_request(server, req).await.map(Into::into)
        }
        jsonrpc::BatchableRequest::Batch(reqs) => {
            let mut responses = Vec::new();
            for req in reqs {
                if let Some(res) = handle_single_request(server, req).await {
                    responses.push(res);
                }
            }
            Some(responses.into())
        }
    }
}

各所でjsonrpc::Response::into_json(self)を実行しているのは、serde::Serializeトレイトがdyn compatibleでないためです。本来であればjsonrpc::BatchableResponse

pub type BatchableResponse = Batchable<Response<Box<dyn serde::Serialize>>>;

のように書きたいところですが、それができないため

// jsonrpc/src/lib.rs
pub type BatchableResponse = Batchable<Response<serde_json::Value>>;

としています。

tools

toolsクレートではToolsRepositoryトレイトを実装したToolsRepositoryImpl構造体を定義します。

tools
├── src
│   ├── lib.rs
│   └── str_rev.rs
└── Cargo.toml

今回はtoolが1つしかないのでRepositoryの実装から直接toolを実行したりtoolの情報を得たりしますが、toolが大量にあり動的に増えたり減ったりする場合はTool構造体を定義してstd::collections::BTreeMap<&'static str, Tool>のような型のラッパーとして定義することもできます。

tools/src/lib.rs
mod str_rev;

#[derive(Default)]
pub struct ToolsRepositoryImpl;

impl ToolsRepositoryImpl {
    pub fn new() -> Self {
        Self
    }
}

#[async_trait::async_trait]
impl domain::repository::tool::ToolsRepository for ToolsRepositoryImpl {
    async fn call(
        &self,
        name: &str,
        args: Option<serde_json::Value>,
    ) -> Result<domain::entity::tool::CallToolResult, domain::entity::tool::ToolError> {
        match name {
            "str_rev" => match args {
                Some(args) => Ok(str_rev::call(args)),
                None => Err(domain::entity::tool::ToolError::ParameterMissing),
            },
            _ => Err(domain::entity::tool::ToolError::UnknownTool(name.into())),
        }
    }

    fn list(
        &self,
        _cursor: Option<String>,
    ) -> Result<domain::entity::tool::ListToolsResult, domain::entity::tool::ToolError> {
        Ok(domain::entity::tool::ListToolsResult {
            tools: vec![str_rev::info()],
            next_cursor: None,
        })
    }
}
tools/src/str_rev.rsの内容
tools/src/str_rev.rs
#[derive(schemars::JsonSchema, serde::Deserialize)]
struct StrRevInput {
    text: String,
}

pub fn info() -> domain::entity::tool::ToolInfo {
    domain::entity::tool::ToolInfo {
        name: "str_rev".to_string(),
        description: Some("Reverses a string".to_string()),
        input_schema: schemars::schema_for!(StrRevInput),
        annotations: None,
    }
}

pub fn call(args: serde_json::Value) -> domain::entity::tool::CallToolResult {
    match serde_json::from_value(args) {
        Ok(StrRevInput { text }) => domain::entity::tool::CallToolResult {
            content: vec![
                domain::entity::tool::TextContent {
                    text: text.chars().rev().collect::<String>(),
                    annotations: None,
                }
                .into(),
            ],
            is_error: None,
        },
        Err(e) => domain::entity::tool::CallToolResult {
            content: vec![
                domain::entity::tool::TextContent {
                    text: e.to_string(),
                    annotations: None,
                }
                .into(),
            ],
            is_error: Some(true),
        },
    }
}

resources

resourcesクレートではResourcesRepositoryトレイトを実装したResourcesRepositoryImpl構造体を定義します。

resources
├── src
│   ├── lib.rs
│   └── pi.rs
└── Cargo.toml

resourceも今回は1つしかないのでRepositoryの実装にハードコードします。

resources/src/lib.rs
mod pi;

#[derive(Default)]
pub struct ResourcesRepositoryImpl;

impl ResourcesRepositoryImpl {
    pub fn new() -> Self {
        Self
    }
}

#[async_trait::async_trait]
impl domain::repository::resource::ResourcesRepository for ResourcesRepositoryImpl {
    async fn list(
        &self,
        _cursor: Option<String>,
    ) -> Result<
        domain::entity::resource::ListResourcesResult,
        domain::entity::resource::ResourceError,
    > {
        Ok(domain::entity::resource::ListResourcesResult {
            resources: vec![pi::info()?],
            next_cursor: None,
        })
    }

    async fn read(
        &self,
        uri: &str,
    ) -> Result<domain::entity::resource::ReadResourceResult, domain::entity::resource::ResourceError>
    {
        match uri {
            pi::URI => pi::read().await,
            _ => Err(domain::entity::resource::ResourceError::NotFound(
                uri.into(),
            )),
        }
    }
}
resources/src/pi.rsの内容
resources/src/pi.rs
pub const URI: &str = "text:///pi.txt";
const NAME: &str = "Value of pi";
const MIME_TYPE: &str = "text/plain";
const DATA: &str = "3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679";
const SIZE: usize = DATA.len();

pub fn info()
-> Result<domain::entity::resource::ResourceInfo, domain::entity::resource::ResourceError> {
    Ok(domain::entity::resource::ResourceInfo {
        uri: URI.into(),
        name: NAME.into(),
        description: None,
        mime_type: Some(MIME_TYPE.into()),
        annotations: None,
        size: Some(SIZE),
    })
}

pub async fn read()
-> Result<domain::entity::resource::ReadResourceResult, domain::entity::resource::ResourceError> {
    Ok(domain::entity::resource::ReadResourceResult {
        contents: vec![domain::entity::resource::ResourceContent {
            uri: URI.into(),
            mime_type: Some(MIME_TYPE.into()),
            data: domain::entity::resource::ResourceData::Text(DATA.into()),
        }],
    })
}

transport

MCPのSpecificationではTransportレイヤーとしてstdioとStreamable HTTPの2つを定義しています。今回はstdioのみに対応します。

transport
├── src
│   ├── lib.rs
│   └── stdio.rs
└── Cargo.toml
transport/src/stdio.rs
pub struct Stdio {
    reader: tokio::io::BufReader<tokio::io::Stdin>,
    writer: tokio::io::Stdout,
}

impl Stdio {
    pub fn new() -> Self {
        let stdin = tokio::io::stdin();
        let reader = tokio::io::BufReader::new(stdin);
        let writer = tokio::io::stdout();
        Stdio { reader, writer }
    }
}

impl Default for Stdio {
    fn default() -> Self {
        Self::new()
    }
}

impl Stdio {
    pub async fn receive(&mut self) -> tokio::io::Result<jsonrpc::BatchableRequest> {
        ..
    }

    pub async fn send(&mut self, res: jsonrpc::BatchableResponse) -> tokio::io::Result<()> {
        ..
    }
}
省略部の実装
impl Stdio {
    pub async fn receive(&mut self) -> tokio::io::Result<jsonrpc::BatchableRequest> {
        loop {
            use tokio::io::AsyncBufReadExt;

            let mut line = String::new();
            self.reader.read_line(&mut line).await?;
            if line.ends_with('\n') {
                line.pop();
            }
            tracing::debug!("Received line: {}", line);
            let req = serde_json::from_str(&line);
            match req {
                Ok(req) => {
                    return Ok(req);
                }
                Err(e) => {
                    let res = jsonrpc::Response::<()>::new(
                        None,
                        Err(jsonrpc::ErrorObject {
                            code: e.into(),
                            message: "failed to receive request".into(),
                            data: None,
                        }),
                    )
                    .into();
                    self.send(res).await?;
                }
            }
        }
    }

    pub async fn send(&mut self, res: jsonrpc::BatchableResponse) -> tokio::io::Result<()> {
        use tokio::io::AsyncWriteExt;

        let data = serde_json::to_string(&res).map_err(|_| {
            tokio::io::Error::new(
                tokio::io::ErrorKind::InvalidData,
                "Failed to serialize data",
            )
        })?;
        tracing::debug!("Sending response: {}", data);
        self.writer.write_all(data.as_ref()).await?;
        self.writer.write_all(b"\n").await?;
        self.writer.flush().await
    }
}

Dependency Injectionと仕上げ

ここまででMCPサーバーを動かすために必要なパーツが揃いました。あとはこれらを繋げるだけです。server()関数の戻り値の型をimpl protocol::server::Serverとすることで、呼び出す側がServerImplの詳細に依存しないことを強制しています。

src/di.rs
pub fn server() -> impl protocol::server::Server {
    let tools_repository = tools::ToolsRepositoryImpl::new();
    let resources_repository = resources::ResourcesRepositoryImpl::new();
    protocol::interactor::ServerImpl::new(tools_repository, resources_repository)
}
src/logger.rs
src/logger.rs
#[cfg(debug_assertions)]
const LOG_LEVEL: &str = "debug";
#[cfg(not(debug_assertions))]
const LOG_LEVEL: &str = "info";

pub fn init_logger() -> Result<(), Box<dyn std::error::Error>> {
    use tracing_subscriber::layer::SubscriberExt;
    use tracing_subscriber::util::SubscriberInitExt;

    let env_filter =
        tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| LOG_LEVEL.into());

    let log_file = std::fs::OpenOptions::new()
        .append(true)
        .create(true)
        .truncate(false)
        .open("log.txt")?;

    let subscriber = tracing_subscriber::fmt::layer()
        .with_writer(log_file)
        .with_ansi(false)
        .with_file(true)
        .with_line_number(true)
        .with_target(false);

    tracing_subscriber::registry()
        .with(subscriber)
        .with(env_filter)
        .try_init()?;

    Ok(())
}
src/main.rs
mod di;
mod logger;

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    logger::init_logger().expect("Failed to initialize logger");
    let mut transport = transport::Stdio::new();
    let server = di::server();

    loop {
        let req = transport.receive().await?;
        let res = controller::handle_batchable_request(&server, req).await;
        if let Some(res) = res {
            transport.send(res).await?;
        }
    }
}

実行してみる

cargo runしてターミナルからJSONを手入力してもいいですが、面倒なので公式のinspectorを使用します。なお、このinspectorは記事執筆時点でprotocol version 2024-11-05にしか対応していないため注意が必要です。

bunx @modelcontextprotocol/inspector cargo run -qr

終わりに

公式SDKを使わずに実装することで仕様への理解がより深められたのではないでしょうか。

Promptsなどの新機能を追加する際は

  1. domainにentity定義(および必要ならrepository定義)を追加
  2. protocolにusecase定義
  3. protocolのServerトレイトにusecaseを実装
  4. controllerでmethodと対応付け

の順に実装すれば追加できます。また、toolやresourceを追加する際はtoolsクレートやresourcesクレートに実装を追加するだけでOKです。

様々な拡張を行い、「ぼくのかんがえたさいきょうのMCPサーバー」を作ってみてください。

脚注
  1. workspace.dependenciesをcargo addで弄れる日はいつ来るのやら...... ↩︎

Discussion