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