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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
use anyhow::Result;
use clap::Args;
use std::path::PathBuf;
use super::OutputConfig;
#[derive(Args)]
pub struct ServeArgs {
/// Directory to serve (defaults to current directory)
#[arg(default_value = ".")]
path: PathBuf,
/// Run HTTP server instead of MCP stdio server
#[arg(long)]
http: bool,
/// HTTP server port (default: 3030)
#[arg(long, default_value = "3030")]
port: u16,
/// Run MCP server alongside HTTP server
#[arg(long)]
mcp: bool,
/// Run MCP server over HTTP transport (Streamable HTTP, network-accessible)
#[arg(long)]
mcp_http: bool,
/// Port for MCP HTTP transport (default: 3031)
#[arg(long, default_value = "3031")]
mcp_port: u16,
/// Serve the local index only, ignoring any configured bobbin server.
///
/// Use this when the local index is the one you mean — a repo-scoped MCP
/// server on a machine that also has a global `[server].url`.
#[arg(long)]
no_remote: bool,
}
/// Resolve the bobbin server this MCP server should proxy to, if any.
///
/// `output.server` is the CLI's own resolution — `--server` / `BOBBIN_SERVER`
/// > repo config > global config — so the MCP surface and the CLI surface now
/// answer from the same corpus. They did not before: `bobbin connect` wrote
/// the URL, the CLI honoured it and `bobbin serve` ignored it, so the MCP
/// tools every agent actually uses answered from an empty or repo-local index
/// while `bobbin search` answered from the fleet index (aegis-wbbycq).
///
/// Two deliberate exceptions:
///
/// * `--no-remote` — an explicit local-only override.
/// * `--http` — this process is SERVING the HTTP API, so it is the origin of
/// the index, not a client of one. The fleet's own index server runs
/// `bobbin serve --http --port 3000 --mcp-http --mcp-port 3031` on a host
/// whose global config names the very route that fronts it; without this
/// guard that server would proxy its own MCP tools back to itself through
/// the load balancer.
fn resolve_remote(args: &ServeArgs, output: &OutputConfig) -> Option<String> {
if args.no_remote || args.http {
return None;
}
output.server.clone()
}
pub async fn run(args: ServeArgs, output: OutputConfig) -> Result<()> {
let remote = resolve_remote(&args, &output);
let role = output.role.clone();
// With a remote configured the directory need not be a bobbin repo at all,
// so a non-existent path is the only thing worth rejecting here. The old
// unconditional `canonicalize` was fine because a local index was always
// required; now "." in a scratch directory is a legitimate invocation.
let repo_root = args
.path
.canonicalize()
.map_err(|e| anyhow::anyhow!("Invalid path: {}", e))?;
match (args.http, args.mcp, args.mcp_http) {
// HTTP API + MCP stdio: run both concurrently
(true, true, false) => {
let http_root = repo_root.clone();
let http_port = args.port;
let mcp_root = repo_root;
tokio::select! {
result = crate::http::run_server(http_root, http_port) => {
result?;
}
result = crate::mcp::run_server(mcp_root, remote, role) => {
result?;
}
}
Ok(())
}
// HTTP API + MCP over HTTP: run both concurrently
(true, _, true) => {
let http_root = repo_root.clone();
let http_port = args.port;
let mcp_root = repo_root;
let mcp_port = args.mcp_port;
tokio::select! {
result = crate::http::run_server(http_root, http_port) => {
result?;
}
result = crate::mcp::run_http_server(mcp_root, mcp_port, remote, role) => {
result?;
}
}
Ok(())
}
// MCP over HTTP only
(false, _, true) => {
crate::mcp::run_http_server(repo_root, args.mcp_port, remote, role).await
}
// HTTP API only
(true, false, false) => crate::http::run_server(repo_root, args.port).await,
// MCP stdio only (default)
_ => crate::mcp::run_server(repo_root, remote, role).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(http: bool, no_remote: bool) -> ServeArgs {
ServeArgs {
path: PathBuf::from("."),
http,
port: 3030,
mcp: false,
mcp_http: false,
mcp_port: 3031,
no_remote,
}
}
fn output(server: Option<&str>) -> OutputConfig {
OutputConfig {
json: false,
quiet: false,
verbose: false,
server: server.map(str::to_string),
role: "default".to_string(),
}
}
#[test]
fn stdio_mcp_uses_the_configured_server() {
assert_eq!(
resolve_remote(&args(false, false), &output(Some("http://search.example"))),
Some("http://search.example".to_string())
);
}
#[test]
fn no_server_configured_stays_local() {
assert_eq!(resolve_remote(&args(false, false), &output(None)), None);
}
#[test]
fn no_remote_flag_overrides_the_configured_server() {
assert_eq!(
resolve_remote(&args(false, true), &output(Some("http://search.example"))),
None
);
}
/// The self-proxy guard. A process serving the HTTP API is the index
/// origin; if it also honoured `[server].url` it would proxy its own MCP
/// tools back through the route that fronts it.
#[test]
fn an_http_index_server_never_proxies_to_itself() {
assert_eq!(
resolve_remote(&args(true, false), &output(Some("http://search.example"))),
None
);
}
}