✨ feat(kebab-mcp): handler skeleton + initialize handshake (fb-30)

KebabHandler implements rmcp::ServerHandler::get_info — returns
serverInfo (name="kebab", version from CARGO_PKG_VERSION) and
capabilities.tools. KebabAppState wraps Config in Arc for cheap clone
into per-request task scope. serve_stdio entry builds a multi-thread
tokio runtime and runs the server until client closes the stream.

rmcp 1.6 API used:
- rmcp::ServerHandler trait (re-exported from handler::server)
- ServerInfo::new(caps).with_server_info(impl) builder (not struct-init:
  InitializeResult/Implementation are #[non_exhaustive])
- ServerCapabilities::builder().enable_tools().build() — builder macro
  generated, confirms the plan-literal pattern works
- Implementation::new(name, version) — non-exhaustive constructor
- rmcp::transport::stdio() returns (tokio::io::Stdin, tokio::io::Stdout)
  tuple; tuple impls IntoTransport via AsyncRead+AsyncWrite blanket
- handler.serve(transport).await → RunningService<RoleServer, H>
  (ServiceExt::serve, returns Result<_, ServerInitializeError>)
- service.waiting().await → Result<QuitReason, JoinError>
- serve_stdio is plain fn wrapping a manually-built tokio runtime
  (avoids nested-runtime hazard if kebab-cli ever gains its own rt)

Tools wire-up lands in subsequent tasks (one tool per task).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 15:31:13 +09:00
parent 2c09ed6af4
commit 8f6e6bc01a
3 changed files with 84 additions and 3 deletions

View File

@@ -6,11 +6,52 @@
use anyhow::Result;
use rmcp::ServerHandler;
use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
use rmcp::service::ServiceExt;
use rmcp::transport::stdio;
use kebab_config::Config;
pub mod state;
pub use state::KebabAppState;
#[derive(Clone)]
pub struct KebabHandler {
state: KebabAppState,
}
impl KebabHandler {
pub fn new(state: KebabAppState) -> Self {
Self { state }
}
pub fn state(&self) -> &KebabAppState {
&self.state
}
}
impl ServerHandler for KebabHandler {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("kebab", env!("CARGO_PKG_VERSION")))
}
}
/// Run the MCP server on stdio JSON-RPC. Blocks until the client closes
/// the stream (typically when the agent host exits).
pub fn serve_stdio(_cfg: Config) -> Result<()> {
// Skeleton — actual rmcp wiring lands in Task 3.
anyhow::bail!("kebab-mcp: serve_stdio not yet implemented")
pub fn serve_stdio(cfg: Config) -> Result<()> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
runtime.block_on(serve_stdio_async(cfg))
}
async fn serve_stdio_async(cfg: Config) -> Result<()> {
tracing::info!("kebab-mcp: starting stdio server");
let state = KebabAppState::new(cfg);
let handler = KebabHandler::new(state);
let service = handler.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,21 @@
//! Long-lived server state — holds Config so per-request handlers don't
//! reload from disk. Future: cache opened SqliteStore / Lance handles
//! here so first tool call pays the cost, subsequent calls hit warm
//! state.
use std::sync::Arc;
use kebab_config::Config;
#[derive(Clone)]
pub struct KebabAppState {
pub config: Arc<Config>,
}
impl KebabAppState {
pub fn new(config: Config) -> Self {
Self {
config: Arc::new(config),
}
}
}

View File

@@ -0,0 +1,19 @@
//! Integration: KebabHandler::get_info returns correct kebab serverInfo.
//! Doesn't exercise full transport — that lands when we have at least
//! one tool to call (Task 4+).
use kebab_config::Config;
use kebab_mcp::{KebabAppState, KebabHandler};
use rmcp::ServerHandler;
#[tokio::test]
async fn initialize_returns_kebab_server_info() {
let cfg = Config::defaults();
let state = KebabAppState::new(cfg);
let handler = KebabHandler::new(state);
let info = handler.get_info();
assert_eq!(info.server_info.name, "kebab");
assert!(!info.server_info.version.is_empty());
assert!(info.capabilities.tools.is_some());
}