MCP Extensions
Build standalone MCP server extensions that expose tools for AI agents via the Model Context Protocol.
On this page
MCP (Model Context Protocol) extensions are standalone binaries that expose tools for AI agents. Unlike library extensions that compile into the main binary, MCP servers run as separate processes. They listen on TCP ports and serve tool requests via the MCP protocol, enabling AI clients like Claude to execute operations in your systemprompt.io environment.
Standalone Binary Pattern
MCP extensions are not library extensions. They do not implement the Extension trait or register with the runtime. Instead, they are independent executables with their own entry point, their own database connection, and their own lifecycle.
This separation provides important benefits:
- Independent scaling - Run multiple MCP server instances on different machines
- Process isolation - MCP server crashes do not affect the main runtime
- Resource control - Allocate specific CPU and memory limits
- Separate deployment - Update MCP servers without redeploying the main binary
For CLI tools that agents invoke via subprocess rather than the MCP protocol, see CLI Extensions.
Project Structure
extensions/mcp/systemprompt/
├── Cargo.toml
└── src/
├── main.rs # Entry point with bootstrap
├── lib.rs # Crate root
├── cli.rs # CLI subprocess execution
├── error.rs # Server error types
├── tools.rs # Tool definitions and schemas
└── server/
├── mod.rs # rmcp ServerHandler implementation
└── tool.rs # Per-call RBAC, auditing, dispatch
Entry Point
The MCP server entry point bootstraps from systemprompt.io's configuration system and starts an HTTP server:
use anyhow::{Context, Result};
use std::{env, sync::Arc};
use systemprompt::config::{ProfileBootstrap, SecretsBootstrap, init_config};
use systemprompt::identifiers::McpServerId;
use systemprompt::system::AppContext;
use systemprompt_mcp_agent::SystempromptServer;
use tokio::net::TcpListener;
const DEFAULT_SERVICE_ID: &str = "systemprompt";
const DEFAULT_PORT: u16 = 5010;
#[tokio::main]
async fn main() -> Result<()> {
systemprompt::logging::init_console_logging();
// Bootstrap from profile and secrets
ProfileBootstrap::init().context("Failed to initialize profile")?;
SecretsBootstrap::init().context("Failed to initialize secrets")?;
init_config().context("Failed to initialize configuration")?;
// Create application context with database access
let ctx = Arc::new(
AppContext::new()
.await
.context("Failed to initialize application context")?,
);
// Get service ID and port from environment or use defaults
let service_id = env::var("MCP_SERVICE_ID")
.map_or_else(|_| McpServerId::new(DEFAULT_SERVICE_ID), McpServerId::new);
let port = env::var("MCP_PORT")
.ok()
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(DEFAULT_PORT);
// Create MCP server and router
let server = SystempromptServer::new(
Arc::clone(ctx.db_pool()),
service_id.clone(),
Arc::clone(ctx.authz_hook()),
)
.context("Failed to initialize SystempromptServer")?;
let router = systemprompt::mcp::create_router(
server,
ctx.db_pool(),
systemprompt::mcp::McpHttpConfig::default(),
);
let addr = format!("0.0.0.0:{port}");
let listener = TcpListener::bind(&addr).await?;
tracing::info!(
service_id = %service_id,
addr = %addr,
"systemprompt.io MCP server listening"
);
axum::serve(listener, router).await?;
Ok(())
}
Key bootstrap steps:
- ProfileBootstrap - Loads the active profile configuration
- SecretsBootstrap - Loads secrets from environment or files
- init_config - Initializes the configuration system
- AppContext - Creates database pool and shared resources
This allows MCP servers to share the same configuration and database as the main runtime.
Server Implementation
The MCP server implements the protocol handler and registers tools:
use rmcp::model::{
CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ErrorData as McpError, ServerHandler};
use systemprompt::database::DbPool;
use systemprompt::identifiers::McpServerId;
use systemprompt::security::authz::SharedAuthzHook;
#[derive(Clone, Debug)]
pub struct SystempromptServer {
service_id: McpServerId,
db_pool: DbPool,
authz_hook: SharedAuthzHook,
}
impl ServerHandler for SystempromptServer {
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_ctx: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, McpError>> + '_ {
std::future::ready(Ok(ListToolsResult {
tools: tools::list_tools(),
next_cursor: None,
meta: None,
}))
}
async fn call_tool(
&self,
request: CallToolRequestParams,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
// Authenticate, audit, and dispatch to the matching tool handler
// (see the RBAC section below).
dispatch_tool(self, request, ctx).await
}
}
CRITICAL: RBAC and RequestContext
All MCP tool handlers MUST extract RequestContext for proper execution tracking and artifact persistence.
Without proper context extraction, tools will fail with foreign key constraint errors when the agent framework attempts to persist artifacts.
Required Pattern
use systemprompt::mcp::middleware::enforce_rbac_from_registry;
use systemprompt::mcp::repository::ToolUsageRepository;
use systemprompt::mcp::models::{ToolExecutionRequest, ToolExecutionResult, ExecutionStatus};
use chrono::Utc;
async fn call_tool(
&self,
request: CallToolRequestParams,
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tool_name = request.name.to_string();
let started_at = Utc::now();
// 1. Enforce RBAC and extract RequestContext
let auth_result = enforce_rbac_from_registry(&ctx, self.service_id.as_str()).await?;
let authenticated_ctx = auth_result
.expect_authenticated("my-server requires OAuth")?;
let request_context = authenticated_ctx.context.clone(); // CRITICAL!
// 2. Track execution start
let execution_request = ToolExecutionRequest {
tool_name: tool_name.clone(),
server_name: self.service_id.to_string(),
input: serde_json::to_value(&request.arguments).unwrap_or_default(),
started_at,
context: request_context.clone(),
request_method: Some("mcp".to_string()),
request_source: Some("my-server".to_string()),
ai_tool_call_id: None,
};
let mcp_execution_id = self.tool_usage_repo
.start_execution(&execution_request)
.await?;
// 3. Execute tool logic...
let result = handle_tool(...).await;
// 4. Track execution completion
let execution_result = ToolExecutionResult {
output: result.as_ref().ok().and_then(|r| r.structured_content.clone()),
output_schema: None,
status: if result.is_ok() { "success" } else { "failed" }.to_string(),
error_message: result.as_ref().err().map(|e| e.message.to_string()),
started_at,
completed_at: Utc::now(),
};
self.tool_usage_repo
.complete_execution(&mcp_execution_id, &execution_result)
.await?;
result
}
Why This Matters
- Artifact Persistence: The agent framework uses
task_idfrom RequestContext to persist artifacts with valid foreign keys - Execution Tracking:
mcp_tool_executionstable tracks all tool calls for debugging and analytics - Trace Visibility: Without execution tracking,
mcp_callscounter in traces will always be zero
See the MCP extensions documentation for complete requirements.
Tool Definitions
Each tool has a name, description, and input schema. Schemas are derived from typed structs with schemars, never hand-rolled JSON:
use rmcp::model::Tool;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use systemprompt::models::artifacts::{CliArtifact, ToolResponse};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CliInput {
/// The CLI command to execute (without 'systemprompt' prefix). Examples:
/// 'plugins run discord send "message"', 'core skills list'
pub command: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliOutput {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub success: bool,
}
#[must_use]
pub fn input_schema() -> serde_json::Value {
schemars::schema_for!(CliInput).to_value()
}
#[must_use]
pub fn output_schema() -> serde_json::Value {
ToolResponse::<CliArtifact>::schema()
}
The input and output schemas are attached to the Tool returned from list_tools, so MCP clients see fully typed contracts.
Configuration
Register MCP servers in services/mcp/:
# services/mcp/systemprompt.yaml
mcp_servers:
systemprompt:
type: internal
binary: systemprompt-mcp-agent
package: systemprompt
port: 5010
enabled: true
display_in_web: true
removable: false
description: systemprompt.io MCP Server - Execute CLI commands (admin only)
oauth:
required: true
scopes:
- admin
audience: mcp
Cargo Configuration
[package]
name = "systemprompt-mcp-agent"
version.workspace = true
edition.workspace = true
publish = false
[[bin]]
name = "systemprompt-mcp-agent"
path = "src/main.rs"
[dependencies]
systemprompt = { workspace = true, features = ["full"] }
rmcp = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[lints]
workspace = true
Building
# Build MCP server
cargo build --release -p systemprompt-mcp-agent
# Or build all MCP servers
systemprompt build mcp --release
Testing
# Check server status
systemprompt plugins mcp status
# List available tools
systemprompt plugins mcp tools --server systemprompt
# Start server manually
./target/release/systemprompt-mcp-agent
Claude Desktop Integration
Add to Claude Desktop configuration:
{
"mcpServers": {
"systemprompt": {
"url": "http://localhost:8080/api/v1/mcp/systemprompt/mcp",
"transport": "streamable-http"
}
}
}
Detailed Documentation
For in-depth guides on specific topics:
| Topic | Document |
|---|---|
| Tool Organization | Tool Structure |
| Resources & Templates | MCP Resources |
| Skill Integration | MCP Skills |
| Response Patterns | MCP Responses |
| AI Integration | MCP AI Integration |
Related Skills
Use systemprompt core skills list to find MCP-related skills, or see the Skills Service documentation.