Building MCP Servers

Create Model Context Protocol servers for AI tool execution

This guide shows how to create MCP (Model Context Protocol) servers that provide tools for AI clients like Claude Desktop and ChatGPT.

Overview

MCP servers expose tools and resources that AI clients can discover and use. SystemPrompt provides HTTP-native transports with built-in OAuth2 authentication.

Prerequisites

  • SystemPrompt project setup
  • Understanding of the MCP crate
  • Rust 1.75+

Project Setup

Create your MCP server in extensions/mcp/:

cd your-project/extensions/mcp
cargo new --lib my-tools

Update Cargo.toml:

[package]
name = "mcp-my-tools"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "mcp-my-tools"
path = "src/main.rs"

[dependencies]
systemprompt-mcp = { path = "../../../core/crates/domain/mcp" }
rmcp = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"

Step 1: Define Tools

Create src/tools.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct WeatherParams {
    pub location: String,
    pub units: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct WeatherResult {
    pub location: String,
    pub temperature: f64,
    pub conditions: String,
    pub humidity: u8,
}

pub async fn get_weather(params: WeatherParams) -> Result<WeatherResult, String> {
    // In production, call a real weather API
    Ok(WeatherResult {
        location: params.location,
        temperature: 72.0,
        conditions: "Sunny".into(),
        humidity: 45,
    })
}

#[derive(Debug, Serialize, Deserialize)]
pub struct SearchParams {
    pub query: String,
    pub limit: Option<u32>,
}

#[derive(Debug, Serialize)]
pub struct SearchResult {
    pub results: Vec<SearchItem>,
}

#[derive(Debug, Serialize)]
pub struct SearchItem {
    pub title: String,
    pub url: String,
    pub snippet: String,
}

pub async fn search_web(params: SearchParams) -> Result<SearchResult, String> {
    // In production, call a search API
    let limit = params.limit.unwrap_or(5);
    Ok(SearchResult {
        results: vec![
            SearchItem {
                title: format!("Result for: {}", params.query),
                url: "https://example.com".into(),
                snippet: "Example search result...".into(),
            }
        ],
    })
}

Step 2: Create the Server

Create src/main.rs:

mod tools;

use rmcp::{
    handler::server::wrapper::Json,
    model::{Tool, ServerCapabilities, ServerInfo},
    protocol::JsonRpcMessage,
    service::server::{McpServer, McpServerHandler},
};
use serde_json::Value;
use std::sync::Arc;

struct MyToolsServer;

impl McpServerHandler for MyToolsServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            name: "my-tools".into(),
            version: env!("CARGO_PKG_VERSION").into(),
        }
    }

    fn get_capabilities(&self) -> ServerCapabilities {
        ServerCapabilities {
            tools: Some(Default::default()),
            ..Default::default()
        }
    }

    async fn list_tools(&self) -> Vec<Tool> {
        vec![
            Tool {
                name: "get_weather".into(),
                description: Some("Get current weather for a location".into()),
                input_schema: serde_json::json!({
                    "type": "object",
                    "properties": {
                        "location": {
                            "type": "string",
                            "description": "City name or coordinates"
                        },
                        "units": {
                            "type": "string",
                            "enum": ["celsius", "fahrenheit"],
                            "default": "fahrenheit"
                        }
                    },
                    "required": ["location"]
                }),
            },
            Tool {
                name: "search_web".into(),
                description: Some("Search the web for information".into()),
                input_schema: serde_json::json!({
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "Search query"
                        },
                        "limit": {
                            "type": "integer",
                            "description": "Max results",
                            "default": 5
                        }
                    },
                    "required": ["query"]
                }),
            },
        ]
    }

    async fn call_tool(&self, name: &str, args: Value) -> Result<Value, String> {
        match name {
            "get_weather" => {
                let params: tools::WeatherParams =
                    serde_json::from_value(args).map_err(|e| e.to_string())?;
                let result = tools::get_weather(params).await?;
                Ok(serde_json::to_value(result).unwrap())
            }
            "search_web" => {
                let params: tools::SearchParams =
                    serde_json::from_value(args).map_err(|e| e.to_string())?;
                let result = tools::search_web(params).await?;
                Ok(serde_json::to_value(result).unwrap())
            }
            _ => Err(format!("Unknown tool: {}", name)),
        }
    }
}

#[tokio::main]
async fn main() {
    let server = McpServer::new(Arc::new(MyToolsServer));

    // Run with stdio transport for local development
    server.run_stdio().await.unwrap();
}

Step 3: Define Server Configuration

Create services/mcp/my-tools.yaml:

name: my-tools
description: Weather and search tools
binary: mcp-my-tools

transport:
  type: streamable-http
  endpoint: "/api/v1/mcp/my-tools/mcp"

oauth:
  required: true
  scopes: ["tools:read"]
  audience: ["systemprompt"]

tools:
  - name: get_weather
    description: Get current weather for a location
    parameters:
      location:
        type: string
        required: true
        description: City name or coordinates
      units:
        type: string
        enum: ["celsius", "fahrenheit"]
        default: "fahrenheit"

  - name: search_web
    description: Search the web for information
    parameters:
      query:
        type: string
        required: true
        description: Search query
      limit:
        type: integer
        default: 5
        description: Maximum number of results

Step 4: Build and Register

Build the MCP server:

just build-mcp
# or
cargo build --release -p mcp-my-tools

Sync to database:

systemprompt cloud sync local mcp --direction to-db -y

Start SystemPrompt:

just start

Step 5: Test

Check the MCP registry:

curl http://localhost:3000/api/v1/mcp/registry

Test tool listing:

curl -X POST http://localhost:3000/api/v1/mcp/my-tools/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}'

Test tool execution:

curl -X POST http://localhost:3000/api/v1/mcp/my-tools/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "get_weather",
      "arguments": {"location": "San Francisco"}
    },
    "id": 2
  }'

Step 6: Connect Claude Desktop

Add to Claude Desktop configuration:

{
  "mcpServers": {
    "my-tools": {
      "url": "http://localhost:3000/api/v1/mcp/my-tools/mcp",
      "transport": "streamable-http"
    }
  }
}

For authenticated access:

{
  "mcpServers": {
    "my-tools": {
      "url": "https://your-tenant.systemprompt.io/api/v1/mcp/my-tools/mcp",
      "transport": "streamable-http",
      "headers": {
        "Authorization": "Bearer YOUR_ACCESS_TOKEN"
      }
    }
  }
}

Adding Resources

MCP servers can also provide resources (files, content):

impl McpServerHandler for MyToolsServer {
    async fn list_resources(&self) -> Vec<Resource> {
        vec![
            Resource {
                uri: "mytools://config".into(),
                name: "Configuration".into(),
                mime_type: Some("application/json".into()),
            }
        ]
    }

    async fn read_resource(&self, uri: &str) -> Result<ResourceContent, String> {
        match uri {
            "mytools://config" => Ok(ResourceContent {
                uri: uri.into(),
                mime_type: Some("application/json".into()),
                text: Some(serde_json::json!({"version": "1.0"}).to_string()),
                blob: None,
            }),
            _ => Err(format!("Unknown resource: {}", uri)),
        }
    }
}

Next Steps

  • Add OAuth2 scopes for fine-grained permissions
  • Implement SSE transport for real-time updates
  • Add logging and error handling
  • See systemprompt-mcp for advanced features