Skip to main content

forge_core/config/
mcp_config.rs

1//! MCP (Model Context Protocol) server configuration.
2
3use std::time::Duration;
4
5use crate::error::{ForgeError, Result};
6use serde::{Deserialize, Serialize};
7
8use super::default_true;
9use super::types::DurationStr;
10
11/// MCP server configuration.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct McpConfig {
15    /// Enable MCP endpoint exposure.
16    #[serde(default)]
17    pub enabled: bool,
18
19    /// Enable OAuth 2.1 Authorization Code + PKCE for MCP clients.
20    /// When true, Forge acts as an OAuth 2.1 Authorization Server so MCP
21    /// clients like Claude Code can auto-authenticate via browser login.
22    /// Requires `auth.jwt_secret` to be set.
23    #[serde(default)]
24    pub oauth: bool,
25
26    /// MCP endpoint path under the gateway API namespace.
27    #[serde(default = "default_mcp_path")]
28    pub path: String,
29
30    /// Session TTL duration (e.g. "1h", "30m").
31    #[serde(default = "default_mcp_session_ttl")]
32    pub session_ttl: DurationStr,
33
34    /// Allowed origins for Origin header validation.
35    #[serde(default)]
36    pub allowed_origins: Vec<String>,
37
38    /// Enforce MCP-Protocol-Version header on post-initialize requests.
39    #[serde(default = "default_true")]
40    pub require_protocol_version_header: bool,
41
42    /// Maximum total MCP sessions across all users.
43    #[serde(default = "default_max_mcp_sessions")]
44    pub max_sessions: usize,
45
46    /// Maximum sessions a single authenticated user can hold.
47    #[serde(default = "default_max_sessions_per_user")]
48    pub max_sessions_per_user: usize,
49
50    /// Allow unauthenticated dynamic client registration (RFC 7591).
51    ///
52    /// When **false** (default), `POST /_api/oauth/register` returns 403
53    /// to anonymous callers. This blocks anyone on the internet from
54    /// registering an OAuth client and being handed a `client_id` they
55    /// can use to drive the authorization flow.
56    ///
57    /// Enable only if your trust model is "any caller may register a
58    /// client" (typical for public IDE integrations behind a per-IP rate
59    /// limit). Even when enabled, registrations remain capped by the
60    /// `MAX_REGISTERED_CLIENTS` limit and the per-IP rate window.
61    #[serde(default)]
62    pub allow_unauthenticated_dcr: bool,
63}
64
65impl Default for McpConfig {
66    fn default() -> Self {
67        Self {
68            enabled: false,
69            oauth: false,
70            path: default_mcp_path(),
71            session_ttl: default_mcp_session_ttl(),
72            allowed_origins: Vec::new(),
73            max_sessions: default_max_mcp_sessions(),
74            max_sessions_per_user: default_max_sessions_per_user(),
75            require_protocol_version_header: default_true(),
76            allow_unauthenticated_dcr: false,
77        }
78    }
79}
80
81impl McpConfig {
82    /// Paths reserved by the gateway that MCP must not collide with.
83    pub(crate) const RESERVED_PATHS: &[&str] = &[
84        "/health",
85        "/ready",
86        "/rpc",
87        "/events",
88        "/subscribe",
89        "/unsubscribe",
90        "/subscribe-job",
91        "/subscribe-workflow",
92        "/metrics",
93    ];
94
95    /// Validate the MCP configuration.
96    pub fn validate(&self) -> Result<()> {
97        if self.path.is_empty() || !self.path.starts_with('/') {
98            return Err(ForgeError::config(
99                "mcp.path must start with '/' (example: /mcp)",
100            ));
101        }
102        if self.path.contains(' ') {
103            return Err(ForgeError::config("mcp.path cannot contain spaces"));
104        }
105        if Self::RESERVED_PATHS.contains(&self.path.as_str()) {
106            return Err(ForgeError::config(format!(
107                "mcp.path '{}' conflicts with a reserved gateway route",
108                self.path
109            )));
110        }
111        if self.session_ttl.as_secs() == 0 {
112            return Err(ForgeError::config("mcp.session_ttl must be greater than 0"));
113        }
114        Ok(())
115    }
116}
117
118fn default_max_mcp_sessions() -> usize {
119    10_000
120}
121
122fn default_max_sessions_per_user() -> usize {
123    100
124}
125
126fn default_mcp_path() -> String {
127    "/mcp".to_string()
128}
129
130fn default_mcp_session_ttl() -> DurationStr {
131    DurationStr::new(Duration::from_secs(3600))
132}