Skip to content
/ loom Public

Commit 1f8ae14

Browse files
ghuntleyclaude
andcommitted
Add MCP server endpoint for weaver provisioning
Implement Model Context Protocol (MCP) server at POST /mcp, enabling MCP clients like Claude Desktop to create ephemeral K8s execution environments. Features: - JSON-RPC 2.0 protocol with MCP 2025-11-25 spec - create_weaver tool for pod provisioning - Optional session management via Mcp-Session-Id header - Reuses existing auth and org membership checks - Audit logging with source: "mcp" New files: - specs/mcp-system.md: Full protocol documentation - routes/mcp/: types, error, session, tools, handler modules Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2b6a19d commit 1f8ae14

File tree

10 files changed

+1837
-1
lines changed

10 files changed

+1837
-1
lines changed

crates/loom-server/src/api.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ pub struct AppState {
119119
pub clips_git_store: Option<Arc<loom_server_clips::ClipsGitStore>>,
120120
pub whatsapp_repo: Option<Arc<loom_server_whatsapp::WhatsAppRepository>>,
121121
pub whatsapp_service: Option<Arc<loom_server_whatsapp::WhatsAppService>>,
122+
pub mcp_sessions: Option<Arc<routes::mcp::McpSessionStore>>,
122123
}
123124

124125
/// Creates the application state, initializing optional components.
@@ -347,6 +348,10 @@ pub async fn create_app_state(
347348
));
348349
tracing::info!("WhatsApp integration initialized");
349350

351+
// Initialize MCP session store
352+
let mcp_sessions = routes::mcp::create_session_store();
353+
tracing::info!("MCP session store initialized");
354+
350355
AppState {
351356
repo,
352357
user_repo,
@@ -408,6 +413,7 @@ pub async fn create_app_state(
408413
clips_git_store: Some(clips_git_store),
409414
whatsapp_repo: Some(whatsapp_repo),
410415
whatsapp_service: Some(whatsapp_service),
416+
mcp_sessions: Some(mcp_sessions),
411417
}
412418
}
413419

@@ -1828,7 +1834,9 @@ pub fn create_router(state: AppState) -> Router {
18281834
.route(
18291835
"/api/weavers/cleanup",
18301836
post(routes::weaver::trigger_cleanup),
1831-
);
1837+
)
1838+
// MCP endpoint for weaver provisioning
1839+
.route("/mcp", post(routes::mcp::mcp_handler));
18321840
}
18331841

18341842
// Add WireGuard tunnel routes if enabled
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2025 Geoffrey Huntley <ghuntley@ghuntley.com>. All rights
2+
// reserved. SPDX-License-Identifier: Proprietary
3+
4+
//! MCP-specific error types and JSON-RPC error code mappings.
5+
6+
use axum::{
7+
http::StatusCode,
8+
response::{IntoResponse, Response},
9+
Json,
10+
};
11+
use loom_server_weaver::ProvisionerError;
12+
13+
use super::types::{JsonRpcError, JsonRpcId, JsonRpcResponse};
14+
15+
/// Standard JSON-RPC 2.0 error codes.
16+
pub mod error_codes {
17+
/// Invalid JSON was received by the server.
18+
pub const PARSE_ERROR: i32 = -32700;
19+
/// The JSON sent is not a valid Request object.
20+
pub const INVALID_REQUEST: i32 = -32600;
21+
/// The method does not exist / is not available.
22+
pub const METHOD_NOT_FOUND: i32 = -32601;
23+
/// Invalid method parameter(s).
24+
pub const INVALID_PARAMS: i32 = -32602;
25+
/// Internal JSON-RPC error.
26+
pub const INTERNAL_ERROR: i32 = -32603;
27+
28+
/// Custom: Tool not found.
29+
pub const TOOL_NOT_FOUND: i32 = -32001;
30+
/// Custom: Forbidden (insufficient permissions).
31+
pub const FORBIDDEN: i32 = -32002;
32+
/// Custom: Unauthorized (authentication required).
33+
pub const UNAUTHORIZED: i32 = -32003;
34+
}
35+
36+
/// MCP-specific errors.
37+
#[derive(Debug, thiserror::Error)]
38+
pub enum McpError {
39+
/// Invalid JSON received.
40+
#[error("Parse error: {0}")]
41+
ParseError(String),
42+
43+
/// Invalid JSON-RPC request.
44+
#[error("Invalid request: {0}")]
45+
InvalidRequest(String),
46+
47+
/// Method not found.
48+
#[error("Method not found: {0}")]
49+
MethodNotFound(String),
50+
51+
/// Invalid method parameters.
52+
#[error("Invalid params: {0}")]
53+
InvalidParams(String),
54+
55+
/// Internal server error.
56+
#[error("Internal error: {0}")]
57+
Internal(String),
58+
59+
/// Tool not found.
60+
#[error("Tool not found: {0}")]
61+
ToolNotFound(String),
62+
63+
/// Forbidden (insufficient permissions).
64+
#[error("Forbidden: {0}")]
65+
Forbidden(String),
66+
67+
/// Unauthorized (authentication required).
68+
#[error("Unauthorized: {0}")]
69+
Unauthorized(String),
70+
71+
/// Provisioner error.
72+
#[error("Provisioner error: {0}")]
73+
Provisioner(ProvisionerError),
74+
}
75+
76+
impl McpError {
77+
/// Get the JSON-RPC error code for this error.
78+
pub fn code(&self) -> i32 {
79+
match self {
80+
McpError::ParseError(_) => error_codes::PARSE_ERROR,
81+
McpError::InvalidRequest(_) => error_codes::INVALID_REQUEST,
82+
McpError::MethodNotFound(_) => error_codes::METHOD_NOT_FOUND,
83+
McpError::InvalidParams(_) => error_codes::INVALID_PARAMS,
84+
McpError::Internal(_) => error_codes::INTERNAL_ERROR,
85+
McpError::ToolNotFound(_) => error_codes::TOOL_NOT_FOUND,
86+
McpError::Forbidden(_) => error_codes::FORBIDDEN,
87+
McpError::Unauthorized(_) => error_codes::UNAUTHORIZED,
88+
McpError::Provisioner(_) => error_codes::INTERNAL_ERROR,
89+
}
90+
}
91+
92+
/// Convert to a JSON-RPC error object.
93+
pub fn to_json_rpc_error(&self) -> JsonRpcError {
94+
JsonRpcError {
95+
code: self.code(),
96+
message: self.to_string(),
97+
data: None,
98+
}
99+
}
100+
101+
/// Convert to a JSON-RPC response with the given request ID.
102+
pub fn to_response(&self, id: Option<JsonRpcId>) -> JsonRpcResponse {
103+
JsonRpcResponse::error(id, self.to_json_rpc_error())
104+
}
105+
}
106+
107+
/// Response wrapper for MCP errors that implements IntoResponse.
108+
pub struct McpErrorResponse {
109+
pub error: McpError,
110+
pub id: Option<JsonRpcId>,
111+
}
112+
113+
impl McpErrorResponse {
114+
pub fn new(error: McpError, id: Option<JsonRpcId>) -> Self {
115+
Self { error, id }
116+
}
117+
}
118+
119+
impl IntoResponse for McpErrorResponse {
120+
fn into_response(self) -> Response {
121+
let status = match &self.error {
122+
McpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
123+
_ => StatusCode::OK,
124+
};
125+
126+
let response = self.error.to_response(self.id);
127+
(status, Json(response)).into_response()
128+
}
129+
}
130+
131+
impl From<ProvisionerError> for McpError {
132+
fn from(err: ProvisionerError) -> Self {
133+
match &err {
134+
ProvisionerError::WeaverNotFound { .. } => {
135+
McpError::InvalidParams(format!("Weaver not found: {err}"))
136+
}
137+
ProvisionerError::TooManyWeavers { .. } => {
138+
McpError::InvalidParams(format!("Too many weavers: {err}"))
139+
}
140+
ProvisionerError::InvalidLifetime { .. } => {
141+
McpError::InvalidParams(format!("Invalid lifetime: {err}"))
142+
}
143+
_ => McpError::Internal(err.to_string()),
144+
}
145+
}
146+
}
147+
148+
#[cfg(test)]
149+
mod tests {
150+
use super::*;
151+
152+
#[test]
153+
fn test_error_codes() {
154+
assert_eq!(McpError::ParseError("test".into()).code(), -32700);
155+
assert_eq!(McpError::InvalidRequest("test".into()).code(), -32600);
156+
assert_eq!(McpError::MethodNotFound("test".into()).code(), -32601);
157+
assert_eq!(McpError::InvalidParams("test".into()).code(), -32602);
158+
assert_eq!(McpError::Internal("test".into()).code(), -32603);
159+
assert_eq!(McpError::ToolNotFound("test".into()).code(), -32001);
160+
assert_eq!(McpError::Forbidden("test".into()).code(), -32002);
161+
assert_eq!(McpError::Unauthorized("test".into()).code(), -32003);
162+
}
163+
164+
#[test]
165+
fn test_to_json_rpc_error() {
166+
let err = McpError::MethodNotFound("tools/unknown".into());
167+
let json_err = err.to_json_rpc_error();
168+
assert_eq!(json_err.code, -32601);
169+
assert!(json_err.message.contains("tools/unknown"));
170+
}
171+
172+
#[test]
173+
fn test_to_response() {
174+
let err = McpError::InvalidParams("missing required field".into());
175+
let response = err.to_response(Some(JsonRpcId::Number(1)));
176+
assert!(response.error.is_some());
177+
assert!(response.result.is_none());
178+
assert_eq!(response.id, Some(JsonRpcId::Number(1)));
179+
}
180+
}

0 commit comments

Comments
 (0)