Skip to content

Commit 59cf209

Browse files
committed
fix(mcp): tolerate discovery probes before initialization
Return Method not found for unsupported extension requests without closing standalone or shared MCP sessions. Verify Copilot fallback initialization and subsequent tool calls. Fixes #497.
1 parent a260f75 commit 59cf209

4 files changed

Lines changed: 105 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to sem are documented in this file.
1414

1515
### Fixed
1616

17+
- **Copilot CLI can connect to the MCP server again.** Unsupported discovery probes return `Method not found` without closing the connection, allowing clients to fall back to `initialize` in both standalone and shared modes. Fixes #497.
1718
- **Indexed name lookup sees renames and added definitions in edited files.** `sem find` checks indexed file freshness and reparses changed files on demand, without requiring a whole dependency-graph refresh. Includes TypeScript, Python and Rust regression coverage.
1819
- **Dart dependency graphs now resolve ordinary calls, constructor-bound receivers and typed parameters.** Callers and refs no longer select a same-named Dart method for a TypeScript receiver (or vice versa); imported class owners take precedence. Persisted graph/query caches are invalidated so upgrades rebuild the affected edges. Fixes #491.
1920
- **Shared MCP clients keep independent context history and stay bound to their repository.** Concurrent daemon startup is serialized with an OS lock, stale sockets recover after crashes, and handshakes are bounded. Adds `sem mcp --status` for health checks and reproducible lifecycle coverage on macOS and Linux.

‎benchmarks/shared-mcp/run.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import time
1818

1919

20-
def read_reply(process, request_id):
20+
def read_reply(process, request_id, expected_error=None):
2121
deadline = time.monotonic() + 30
2222
while time.monotonic() < deadline:
2323
ready, _, _ = select.select([process.stdout], [], [], max(0, deadline - time.monotonic()))
@@ -28,13 +28,16 @@ def read_reply(process, request_id):
2828
raise RuntimeError("MCP exited before response")
2929
reply = json.loads(line)
3030
if reply.get("id") == request_id:
31+
if expected_error is not None:
32+
assert reply.get("error", {}).get("code") == expected_error, reply
33+
return reply["error"]
3134
if "error" in reply:
3235
raise RuntimeError(reply)
3336
return reply["result"]
3437
raise TimeoutError("MCP response timed out")
3538

3639

37-
def session(binary, repo, shared):
40+
def session(binary, repo, shared, discovery=False):
3841
env = dict(os.environ)
3942
env.pop("SEM_MCP_SHARED_DAEMON", None)
4043
if shared:
@@ -54,6 +57,9 @@ def send(method, params, ident=None):
5457
process.stdin.write((json.dumps(message) + "\n").encode())
5558
process.stdin.flush()
5659
try:
60+
if discovery:
61+
send("server/discover", {}, 0)
62+
read_reply(process, 0, expected_error=-32601)
5763
send("initialize", {"protocolVersion": "2025-03-26", "capabilities": {},
5864
"clientInfo": {"name": "shared-benchmark", "version": "1"}}, 1)
5965
read_reply(process, 1)
@@ -111,6 +117,10 @@ def main():
111117
stale.close()
112118
try:
113119
cold = session(binary, repo, True)
120+
# Copilot's discovery probe must not prevent legacy initialization
121+
# or actual tool calls, on either transport path.
122+
session(binary, repo, False, discovery=True)
123+
session(binary, repo, True, discovery=True)
114124
stop_daemon(repo, crash=True)
115125
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
116126
concurrent_results = list(pool.map(lambda _: session(binary, repo, True), range(8)))
@@ -130,6 +140,7 @@ def main():
130140
"build": str(binary), "concurrent_clients": len(concurrent_results),
131141
"stale_socket_recovery": True, "restart_recovery": True,
132142
"session_context_isolation": True,
143+
"discovery_fallback_standalone_and_shared": True,
133144
"cold_start_ms": cold["ms"],
134145
"model_tokens": None, "samples": samples}
135146
report["summary"] = {name: {"n": len(rows),

‎crates/sem-mcp/src/transport.rs‎

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use std::{future::Future, sync::Arc};
22

3-
use rmcp::model::{ClientJsonRpcMessage, ServerJsonRpcMessage};
3+
use rmcp::model::{
4+
ClientJsonRpcMessage, ClientRequest, ErrorCode, ErrorData, ServerJsonRpcMessage,
5+
};
46
use rmcp::transport::Transport;
57
use rmcp::RoleServer;
68
use serde::Serialize;
@@ -60,7 +62,35 @@ where
6062

6163
let line = without_line_ending(&line);
6264
match parse_client_message(line) {
63-
IncomingLine::Message(message) => return Some(*message),
65+
IncomingLine::Message(message) => {
66+
// Reject unsupported extension methods here, before RMCP's
67+
// initialize gate. Clients such as Copilot probe server/discover
68+
// first and fall back to initialize on Method not found.
69+
// Passing the probe to RMCP 1.x instead terminates the session.
70+
if let ClientJsonRpcMessage::Request(request) = message.as_ref() {
71+
if matches!(request.request, ClientRequest::CustomRequest(_)) {
72+
if let Err(error) = self
73+
.send(ServerJsonRpcMessage::error(
74+
ErrorData::new(
75+
ErrorCode::METHOD_NOT_FOUND,
76+
"Method not found",
77+
None,
78+
),
79+
request.id.clone(),
80+
))
81+
.await
82+
{
83+
tracing::error!(
84+
"Error writing method not found response: {}",
85+
error
86+
);
87+
return None;
88+
}
89+
continue;
90+
}
91+
}
92+
return Some(*message);
93+
}
6494
IncomingLine::Ignore => {}
6595
IncomingLine::ParseError => {
6696
tracing::debug!("Malformed JSON-RPC frame received");
@@ -242,6 +272,36 @@ mod tests {
242272
use rmcp::model::{ClientRequest, JsonRpcMessage, NumberOrString};
243273
use tokio::io::AsyncReadExt;
244274

275+
#[tokio::test]
276+
async fn unsupported_probes_preserve_ids_and_keep_transport_open() {
277+
let (mut client_input, server_input) = tokio::io::duplex(4096);
278+
let (server_output, client_output) = tokio::io::duplex(4096);
279+
let mut transport = ResilientStdioTransport::new(server_input, server_output);
280+
client_input
281+
.write_all(
282+
br#"{"jsonrpc":"2.0","id":0,"method":"server/discover","params":{}}
283+
{"jsonrpc":"2.0","id":"probe","method":"future/extension","params":{}}
284+
{"jsonrpc":"2.0","id":2,"method":"ping"}
285+
"#,
286+
)
287+
.await
288+
.unwrap();
289+
let message = tokio::time::timeout(std::time::Duration::from_secs(2), transport.receive())
290+
.await
291+
.unwrap()
292+
.unwrap();
293+
assert!(matches!(message, ClientJsonRpcMessage::Request(request)
294+
if matches!(request.request, ClientRequest::PingRequest(_))));
295+
let mut output = BufReader::new(client_output);
296+
for id in [serde_json::json!(0), serde_json::json!("probe")] {
297+
let mut line = String::new();
298+
output.read_line(&mut line).await.unwrap();
299+
let response: serde_json::Value = serde_json::from_str(&line).unwrap();
300+
assert_eq!(response["id"], id);
301+
assert_eq!(response["error"]["code"], -32601);
302+
}
303+
}
304+
245305
#[tokio::test]
246306
async fn malformed_json_emits_parse_error_and_keeps_reading() {
247307
let (mut client_input, server_input) = tokio::io::duplex(1024);

‎crates/sem-mcp/tests/mcp_protocol.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ struct McpClient {
3838

3939
impl McpClient {
4040
fn spawn(repo: &Path) -> Self {
41+
Self::spawn_with_discovery(repo, false)
42+
}
43+
44+
fn spawn_with_discovery(repo: &Path, discovery: bool) -> Self {
4145
let mut child = Command::new(env!("CARGO_BIN_EXE_sem-mcp"))
4246
.current_dir(repo)
4347
// These tests exercise one isolated stdio server each.
@@ -55,6 +59,15 @@ impl McpClient {
5559
stdout,
5660
next_id: 1,
5761
};
62+
if discovery {
63+
let response = client.request("server/discover", json!({
64+
"_meta": {
65+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
66+
"io.modelcontextprotocol/clientInfo": {"name": "copilot-cli", "version": "1.0.88"}
67+
}
68+
}));
69+
assert_eq!(response["error"]["code"], -32601, "{response}");
70+
}
5871
client.initialize();
5972
client
6073
}
@@ -173,6 +186,22 @@ fn tool_text(resp: &Value) -> String {
173186

174187
// ── Fixture repo ──
175188

189+
#[test]
190+
fn copilot_discovery_falls_back_to_initialize_and_tools_work() {
191+
let repo = fixture_repo();
192+
let mut client = McpClient::spawn_with_discovery(repo.path(), true);
193+
assert!(client
194+
.tools_list()
195+
.iter()
196+
.any(|tool| tool["name"] == "sem_entities"));
197+
let response = client.call_tool("sem_entities", json!({"path": "src/needle.py"}));
198+
assert!(tool_text(&response).contains("needle_target_fn"));
199+
// Unknown methods must also leave an initialized session usable.
200+
let response = client.request("future/extension", json!({}));
201+
assert_eq!(response["error"]["code"], -32601);
202+
assert!(!client.tools_list().is_empty());
203+
}
204+
176205
fn git(repo: &Path, args: &[&str]) {
177206
let status = Command::new("git")
178207
.current_dir(repo)

0 commit comments

Comments
 (0)