Rustで自作MCPサーバー
はじめに
みなさんそろそろ「こんなものをMCPサーバー化してみました!」という記事は見飽きてきた頃だと思います。
この記事ではSpecificationを読み解き、公式SDKを使わずにMCPサーバーを実装します。なお、MCPの本質でない部分ではサードパーティークレートを使用します。使用したクレートは以下の通りです。
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"] }
ロギングをサボればtracing
とtracing-subscriber
は必要ありません。また、非同期ではなく同期で動作するのでもよければasync-trait
とtokio
も必要ありません。
書くこと
- MCPの具体的な内部仕様
- Rustでの実装方法
書かないこと
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のみ
アーキテクチャ
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の表現を実装します。Resuest
、Response
、ErrorObject
などの型を定義します。エラーコードの一覧もここで定義します。
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でのキーが変わるため、Result
をOutput
経由でシリアライズし、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の内容
#[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,
}
#[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
トレイトにのみ依存することを強制しています。
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と紐付け、さらにバッチリクエストにも対応します。
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>
のような型のラッパーとして定義することもできます。
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の内容
#[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の実装にハードコードします。
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の内容
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
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
の詳細に依存しないことを強制しています。
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
#[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(())
}
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などの新機能を追加する際は
- domainにentity定義(および必要ならrepository定義)を追加
- protocolにusecase定義
- protocolの
Server
トレイトにusecaseを実装 - controllerでmethodと対応付け
の順に実装すれば追加できます。また、toolやresourceを追加する際はtoolsクレートやresourcesクレートに実装を追加するだけでOKです。
様々な拡張を行い、「ぼくのかんがえたさいきょうのMCPサーバー」を作ってみてください。
-
workspace.dependenciesを
cargo add
で弄れる日はいつ来るのやら...... ↩︎
Discussion