get a working mcp server

This commit is contained in:
Vincent Stuyck 2025-12-19 01:50:07 +01:00
parent 29399508a0
commit 8a3ec2915c
5 changed files with 3113 additions and 2 deletions

2761
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,3 +4,14 @@ version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
rmcp = { version = "0.11.0", features = ["auth", "schemars", "server", "transport-streamable-http-server"] }
schemars = "1.1.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
tokio-util = "0.7.17"
rmcp-actix-web = "0.9.0"
actix-web = "4.12.1"
log = "0.4.29"
simplelog = "0.12.2"

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# Use the official Rust image as build environment
FROM rust:1.90 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get upgrade -y
WORKDIR /app
COPY --from=builder /app/target/release/checkmk-mcp /app/checkmk-mcp
RUN useradd -r -s /bin/false -m -d /app appuser
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["./checkmk-mcp"]

39
docker-compose.yml Normal file
View File

@ -0,0 +1,39 @@
volumes:
ollama:
external: true
open-webui:
external: true
networks:
checkmk_msp:
services:
checkmk-mcp:
build: .
container_name: checkmk-mcp-server
ports:
- "8000:8000"
environment:
- RUST_LOG=debug
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/mcp"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- checkmk_msp
open-webui:
image: ghcr.io/open-webui/open-webui:ollama
container_name: open-webui
gpus: all
ports:
- "8080:8080"
environment:
OLLAMA_HOST: 0.0.0.0:11434
volumes:
- ollama:/root/.ollama
- open-webui:/app/backend/data
networks:
- checkmk_msp

View File

@ -1,3 +1,280 @@
fn main() {
println!("Hello, world!");
use std::sync::Arc;
use actix_web::{App, HttpServer, middleware::Logger, web};
use log::{LevelFilter, debug, trace};
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler, handler::server::{router::prompt::PromptRouter, tool::ToolRouter, wrapper::Parameters}, model::*, prompt, prompt_handler, prompt_router, service::RequestContext, tool, tool_handler, tool_router, transport::streamable_http_server::session::local::LocalSessionManager};
use rmcp_actix_web::transport::StreamableHttpService;
use serde_json::json;
use simplelog::{ColorChoice, ConfigBuilder, TermLogger, TerminalMode};
use tokio::sync::Mutex;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct StructRequest {
pub a: i32,
pub b: i32,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ExamplePromptArgs {
/// A message to put in the prompt
pub message: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct CounterAnalysisArgs {
/// The target value you're trying to reach
pub goal: i32,
/// Preferred strategy: 'fast' or 'careful'
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
}
#[derive(Clone)]
pub struct Counter {
counter: Arc<Mutex<i32>>,
tool_router: ToolRouter<Counter>,
prompt_router: PromptRouter<Counter>,
}
#[tool_router]
impl Counter {
#[allow(dead_code)]
pub fn new() -> Self {
Self {
counter: Arc::new(Mutex::new(0)),
tool_router: Self::tool_router(),
prompt_router: Self::prompt_router(),
}
}
fn _create_resource_text(&self, uri: &str, name: &str) -> Resource {
RawResource::new(uri, name.to_string()).no_annotation()
}
#[tool(description = "Increment the counter by 1")]
async fn increment(&self) -> Result<CallToolResult, McpError> {
let mut counter = self.counter.lock().await;
*counter += 1;
debug!("incrementing with 1, result: {counter}");
Ok(CallToolResult::success(vec![Content::text(
counter.to_string(),
)]))
}
#[tool(description = "Decrement the counter by 1")]
async fn decrement(&self) -> Result<CallToolResult, McpError> {
let mut counter = self.counter.lock().await;
*counter -= 1;
debug!("decrementing with 1, result: {counter}");
Ok(CallToolResult::success(vec![Content::text(
counter.to_string(),
)]))
}
#[tool(description = "Get the current counter value")]
async fn get_value(&self) -> Result<CallToolResult, McpError> {
let counter = self.counter.lock().await;
debug!("current value: {counter}");
Ok(CallToolResult::success(vec![Content::text(
counter.to_string(),
)]))
}
#[tool(description = "Say hello to the client")]
fn say_hello(&self) -> Result<CallToolResult, McpError> {
debug!("hello!");
Ok(CallToolResult::success(vec![Content::text("hello")]))
}
#[tool(description = "Repeat what you say")]
fn echo(&self, Parameters(object): Parameters<JsonObject>) -> Result<CallToolResult, McpError> {
debug!("{object:?}");
Ok(CallToolResult::success(vec![Content::text(
serde_json::Value::Object(object).to_string(),
)]))
}
#[tool(description = "Calculate the sum of two numbers")]
fn sum(
&self,
Parameters(StructRequest { a, b }): Parameters<StructRequest>,
) -> Result<CallToolResult, McpError> {
debug!("the sum of {a} + {b} = {}", a + b);
Ok(CallToolResult::success(vec![Content::text(
(a + b).to_string(),
)]))
}
}
#[prompt_router]
impl Counter {
/// This is an example prompt that takes one required argument, message
#[prompt(
name = "example_prompt",
meta = Meta(rmcp::object!({"meta_key": "meta_value"}))
)]
async fn example_prompt(
&self,
Parameters(args): Parameters<ExamplePromptArgs>,
_ctx: RequestContext<RoleServer>,
) -> Result<Vec<PromptMessage>, McpError> {
let prompt = format!(
"This is an example prompt with your message here: '{}'",
args.message
);
Ok(vec![PromptMessage {
role: PromptMessageRole::User,
content: PromptMessageContent::text(prompt),
}])
}
/// Analyze the current counter value and suggest next steps
#[prompt(name = "counter_analysis")]
async fn counter_analysis(
&self,
Parameters(args): Parameters<CounterAnalysisArgs>,
_ctx: RequestContext<RoleServer>,
) -> Result<GetPromptResult, McpError> {
let strategy = args.strategy.unwrap_or_else(|| "careful".to_string());
let current_value = *self.counter.lock().await;
let difference = args.goal - current_value;
let messages = vec![
PromptMessage::new_text(
PromptMessageRole::Assistant,
"I'll analyze the counter situation and suggest the best approach.",
),
PromptMessage::new_text(
PromptMessageRole::User,
format!(
"Current counter value: {}\nGoal value: {}\nDifference: {}\nStrategy preference: {}\n\nPlease analyze the situation and suggest the best approach to reach the goal.",
current_value, args.goal, difference, strategy
),
),
];
Ok(GetPromptResult {
description: Some(format!(
"Counter analysis for reaching {} from {}",
args.goal, current_value
)),
messages,
})
}
}
#[tool_handler(meta = Meta(rmcp::object!({"tool_meta_key": "tool_meta_value"})))]
#[prompt_handler(meta = Meta(rmcp::object!({"router_meta_key": "router_meta_value"})))]
impl ServerHandler for Counter {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities::builder()
.enable_prompts()
.enable_resources()
.enable_tools()
.build(),
server_info: Implementation::from_build_env(),
instructions: Some("This server provides counter tools and prompts. Tools: increment, decrement, get_value, say_hello, echo, sum. Prompts: example_prompt (takes a message), counter_analysis (analyzes counter state with a goal).".to_string()),
}
}
async fn list_resources(
&self,
_request: Option<PaginatedRequestParam>,
_: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, McpError> {
Ok(ListResourcesResult {
resources: vec![
self._create_resource_text("str:////Users/to/some/path/", "cwd"),
self._create_resource_text("memo://insights", "memo-name"),
],
next_cursor: None,
meta: None,
})
}
async fn read_resource(
&self,
ReadResourceRequestParam { uri }: ReadResourceRequestParam,
_: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
match uri.as_str() {
"str:////Users/to/some/path/" => {
let cwd = "/Users/to/some/path/";
Ok(ReadResourceResult {
contents: vec![ResourceContents::text(cwd, uri)],
})
}
"memo://insights" => {
let memo = "Business Intelligence Memo\n\nAnalysis has revealed 5 key insights ...";
Ok(ReadResourceResult {
contents: vec![ResourceContents::text(memo, uri)],
})
}
_ => Err(McpError::resource_not_found(
"resource_not_found",
Some(json!({
"uri": uri
})),
)),
}
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParam>,
_: RequestContext<RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult {
next_cursor: None,
resource_templates: Vec::new(),
meta: None,
})
}
async fn initialize(
&self,
request: InitializeRequestParam,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, McpError> {
trace!("received request: {request:?}");
trace!("with context: {context:?}");
Ok(self.get_info())
}
}
const BIND_ADDRESS: &str = "0.0.0.0:8000";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
TermLogger::init(
LevelFilter::Trace,
ConfigBuilder::default()
.add_filter_ignore_str("actix_http")
.add_filter_ignore_str("mio")
.add_filter_ignore_str("actix_server")
.add_filter_ignore_str("actix_web")
.build(),
TerminalMode::Stderr,
ColorChoice::Auto,
)
.unwrap();
let http_service = StreamableHttpService::builder()
.service_factory(Arc::new(|| Ok(Counter::new())))
.session_manager(Arc::new(LocalSessionManager::default()))
.stateful_mode(true)
.build();
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
.route("/health", web::get().to(|| async { "OK" }))
.service(web::scope("/api/v1/mcp").service(http_service.clone().scope()))
})
.bind(BIND_ADDRESS)?
.run()
.await?;
Ok(())
}