jscpd-rs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -0
- package/Cargo.lock +1323 -0
- package/Cargo.toml +54 -0
- package/LICENSE +21 -0
- package/README.md +372 -0
- package/docs/api-parity.md +49 -0
- package/docs/cloning-plan.md +281 -0
- package/docs/compat-baseline.md +535 -0
- package/docs/format-porting.md +86 -0
- package/docs/junior-task-template.md +62 -0
- package/docs/junior-workflow.md +87 -0
- package/docs/migrating-from-jscpd.md +193 -0
- package/docs/npm-release.md +116 -0
- package/docs/public-benchmark-suite.md +81 -0
- package/docs/release-checklist.md +200 -0
- package/docs/release-decisions.md +103 -0
- package/docs/release-readiness.md +51 -0
- package/docs/upstream-bugs.md +501 -0
- package/docs/upstream-issue-drafts.md +393 -0
- package/docs/user-guide.md +309 -0
- package/examples/dump_oxc_tokens.rs +112 -0
- package/examples/library_api.rs +42 -0
- package/npm/bin/jscpd-rs.js +6 -0
- package/npm/bin/jscpd-server.js +6 -0
- package/npm/lib/run-binary.js +68 -0
- package/npm/scripts/postinstall.js +50 -0
- package/package.json +53 -0
- package/skills/dry-refactoring/SKILL.md +63 -0
- package/skills/jscpd/SKILL.md +85 -0
- package/src/app.rs +512 -0
- package/src/bin/jscpd-server.rs +429 -0
- package/src/blame.rs +130 -0
- package/src/cli/config.rs +543 -0
- package/src/cli/parsing.rs +301 -0
- package/src/cli/tests.rs +543 -0
- package/src/cli.rs +671 -0
- package/src/detector/matching/secondary.rs +387 -0
- package/src/detector/matching.rs +274 -0
- package/src/detector/model.rs +190 -0
- package/src/detector/prepare.rs +71 -0
- package/src/detector/skip_local.rs +40 -0
- package/src/detector/statistics.rs +138 -0
- package/src/detector/store.rs +96 -0
- package/src/detector/tests.rs +238 -0
- package/src/detector.rs +265 -0
- package/src/files/discovery.rs +508 -0
- package/src/files/gitignore.rs +203 -0
- package/src/files/paths.rs +68 -0
- package/src/files/shebang.rs +106 -0
- package/src/files/tests.rs +523 -0
- package/src/files.rs +25 -0
- package/src/formats.rs +570 -0
- package/src/lib.rs +433 -0
- package/src/main.rs +26 -0
- package/src/report/ai.rs +125 -0
- package/src/report/badge.rs +238 -0
- package/src/report/console.rs +180 -0
- package/src/report/console_common.rs +37 -0
- package/src/report/console_full.rs +139 -0
- package/src/report/csv.rs +65 -0
- package/src/report/escape.rs +8 -0
- package/src/report/file_output.rs +28 -0
- package/src/report/html/assets.rs +47 -0
- package/src/report/html.rs +336 -0
- package/src/report/json.rs +119 -0
- package/src/report/markdown.rs +125 -0
- package/src/report/sarif.rs +302 -0
- package/src/report/silent.rs +22 -0
- package/src/report/source.rs +38 -0
- package/src/report/summary.rs +50 -0
- package/src/report/test_support.rs +133 -0
- package/src/report/threshold.rs +76 -0
- package/src/report/xcode.rs +90 -0
- package/src/report/xml.rs +119 -0
- package/src/report.rs +250 -0
- package/src/server/mcp.rs +942 -0
- package/src/server.rs +1081 -0
- package/src/tokenizer/apex.rs +97 -0
- package/src/tokenizer/blocks.rs +532 -0
- package/src/tokenizer/embedded.rs +106 -0
- package/src/tokenizer/generic.rs +511 -0
- package/src/tokenizer/hash.rs +27 -0
- package/src/tokenizer/ignore.rs +33 -0
- package/src/tokenizer/line_index.rs +33 -0
- package/src/tokenizer/markdown.rs +289 -0
- package/src/tokenizer/markup_attrs.rs +289 -0
- package/src/tokenizer/oxc/fallback.rs +275 -0
- package/src/tokenizer/oxc/jsx.rs +168 -0
- package/src/tokenizer/oxc/kind.rs +177 -0
- package/src/tokenizer/oxc/lexical.rs +67 -0
- package/src/tokenizer/oxc.rs +659 -0
- package/src/tokenizer/scan.rs +88 -0
- package/src/tokenizer/tap.rs +150 -0
- package/src/tokenizer/tests.rs +915 -0
- package/src/tokenizer.rs +328 -0
- package/src/verbose.rs +195 -0
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
use axum::Json;
|
|
2
|
+
use axum::body::{Bytes, to_bytes};
|
|
3
|
+
use axum::extract::State;
|
|
4
|
+
use axum::http::header::{ALLOW, CONTENT_TYPE};
|
|
5
|
+
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
|
|
6
|
+
use axum::response::{IntoResponse, Response};
|
|
7
|
+
use serde_json::{Map, Value, json};
|
|
8
|
+
|
|
9
|
+
use super::{CheckSnippetRequest, ServerService};
|
|
10
|
+
|
|
11
|
+
const MCP_SESSION_ID: &str = "mcp-session-id";
|
|
12
|
+
const JSONRPC_VERSION: &str = "2.0";
|
|
13
|
+
const PROTOCOL_VERSION: &str = "2024-11-05";
|
|
14
|
+
|
|
15
|
+
pub(super) async fn post_mcp(
|
|
16
|
+
State(service): State<ServerService>,
|
|
17
|
+
headers: HeaderMap,
|
|
18
|
+
body: Bytes,
|
|
19
|
+
) -> Response {
|
|
20
|
+
if !accepts_mcp_response(&headers) {
|
|
21
|
+
return jsonrpc_error(
|
|
22
|
+
StatusCode::NOT_ACCEPTABLE,
|
|
23
|
+
Value::Null,
|
|
24
|
+
-32000,
|
|
25
|
+
"Not Acceptable: Client must accept both application/json and text/event-stream",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let session_id = headers
|
|
30
|
+
.get(MCP_SESSION_ID)
|
|
31
|
+
.and_then(|value| value.to_str().ok())
|
|
32
|
+
.map(str::to_string);
|
|
33
|
+
let has_valid_session = session_id
|
|
34
|
+
.as_deref()
|
|
35
|
+
.is_some_and(|session_id| service.has_mcp_session(session_id));
|
|
36
|
+
|
|
37
|
+
if !has_json_content_type(&headers) {
|
|
38
|
+
return if has_valid_session {
|
|
39
|
+
unsupported_media_type_response()
|
|
40
|
+
} else {
|
|
41
|
+
bad_session_response()
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let payload = match serde_json::from_slice::<Value>(&body) {
|
|
46
|
+
Ok(payload) => payload,
|
|
47
|
+
Err(error) => {
|
|
48
|
+
return syntax_error_response(super::json_syntax_error_message(&body, &error));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
let mut response = match payload {
|
|
52
|
+
Value::Array(messages) => handle_mcp_batch(service, session_id.as_deref(), messages).await,
|
|
53
|
+
payload => handle_mcp_request(service, session_id.as_deref(), payload).await,
|
|
54
|
+
};
|
|
55
|
+
if has_valid_session && response.status() != StatusCode::ACCEPTED {
|
|
56
|
+
let value = HeaderValue::from_str(session_id.as_deref().expect("session id exists"))
|
|
57
|
+
.expect("valid MCP session id");
|
|
58
|
+
response
|
|
59
|
+
.headers_mut()
|
|
60
|
+
.insert(HeaderName::from_static(MCP_SESSION_ID), value);
|
|
61
|
+
}
|
|
62
|
+
response
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub(super) async fn method_not_allowed() -> Response {
|
|
66
|
+
let mut response = (
|
|
67
|
+
StatusCode::METHOD_NOT_ALLOWED,
|
|
68
|
+
Json(json!({ "error": "Method Not Allowed" })),
|
|
69
|
+
)
|
|
70
|
+
.into_response();
|
|
71
|
+
response
|
|
72
|
+
.headers_mut()
|
|
73
|
+
.insert(ALLOW, HeaderValue::from_static("POST"));
|
|
74
|
+
response
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async fn handle_mcp_request(
|
|
78
|
+
service: ServerService,
|
|
79
|
+
session_id: Option<&str>,
|
|
80
|
+
payload: Value,
|
|
81
|
+
) -> Response {
|
|
82
|
+
let request_id = request_id(&payload);
|
|
83
|
+
let Some(method) = payload.get("method").and_then(Value::as_str) else {
|
|
84
|
+
return jsonrpc_error(
|
|
85
|
+
StatusCode::BAD_REQUEST,
|
|
86
|
+
Value::Null,
|
|
87
|
+
-32700,
|
|
88
|
+
"Parse error: Invalid JSON-RPC message",
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if let Some(session_id) = session_id {
|
|
93
|
+
if !service.has_mcp_session(session_id) {
|
|
94
|
+
return bad_session_response();
|
|
95
|
+
}
|
|
96
|
+
} else if method != "initialize" {
|
|
97
|
+
return bad_session_response();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
match method {
|
|
101
|
+
"initialize" => initialize(service, request_id),
|
|
102
|
+
"notifications/initialized" => StatusCode::ACCEPTED.into_response(),
|
|
103
|
+
"tools/list" => jsonrpc_result(request_id, tools_list_result()),
|
|
104
|
+
"tools/call" => call_tool(service, request_id, payload),
|
|
105
|
+
"resources/list" => jsonrpc_result(request_id, resources_list_result()),
|
|
106
|
+
"resources/read" => read_resource(service, request_id, payload),
|
|
107
|
+
_ => jsonrpc_error(StatusCode::OK, request_id, -32601, "Method not found"),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async fn handle_mcp_batch(
|
|
112
|
+
service: ServerService,
|
|
113
|
+
session_id: Option<&str>,
|
|
114
|
+
messages: Vec<Value>,
|
|
115
|
+
) -> Response {
|
|
116
|
+
let mut results = Vec::new();
|
|
117
|
+
let mut response_session_id = None;
|
|
118
|
+
|
|
119
|
+
for message in messages {
|
|
120
|
+
let response = handle_mcp_request(service.clone(), session_id, message).await;
|
|
121
|
+
if response_session_id.is_none() {
|
|
122
|
+
response_session_id = response
|
|
123
|
+
.headers()
|
|
124
|
+
.get(MCP_SESSION_ID)
|
|
125
|
+
.and_then(|value| value.to_str().ok())
|
|
126
|
+
.map(str::to_string);
|
|
127
|
+
}
|
|
128
|
+
if response.status() == StatusCode::ACCEPTED {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let bytes = to_bytes(response.into_body(), usize::MAX)
|
|
133
|
+
.await
|
|
134
|
+
.unwrap_or_default();
|
|
135
|
+
if bytes.is_empty() {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
|
|
139
|
+
results.push(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let mut response = match results.len() {
|
|
144
|
+
0 => StatusCode::ACCEPTED.into_response(),
|
|
145
|
+
1 => Json(results.pop().expect("one response")).into_response(),
|
|
146
|
+
_ => Json(Value::Array(results)).into_response(),
|
|
147
|
+
};
|
|
148
|
+
if let Some(session_id) = response_session_id {
|
|
149
|
+
let value = HeaderValue::from_str(&session_id).expect("valid MCP session id");
|
|
150
|
+
response
|
|
151
|
+
.headers_mut()
|
|
152
|
+
.insert(HeaderName::from_static(MCP_SESSION_ID), value);
|
|
153
|
+
}
|
|
154
|
+
response
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
fn initialize(service: ServerService, request_id: Value) -> Response {
|
|
158
|
+
let session_id = service.create_mcp_session();
|
|
159
|
+
let mut response = jsonrpc_result(
|
|
160
|
+
request_id,
|
|
161
|
+
json!({
|
|
162
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
163
|
+
"capabilities": {
|
|
164
|
+
"logging": {},
|
|
165
|
+
"tools": {
|
|
166
|
+
"listChanged": true,
|
|
167
|
+
},
|
|
168
|
+
"resources": {
|
|
169
|
+
"listChanged": true,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
"serverInfo": {
|
|
173
|
+
"name": "jscpd-server",
|
|
174
|
+
"version": env!("CARGO_PKG_VERSION"),
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
let value = HeaderValue::from_str(&session_id).expect("valid MCP session id");
|
|
179
|
+
response
|
|
180
|
+
.headers_mut()
|
|
181
|
+
.insert(HeaderName::from_static(MCP_SESSION_ID), value);
|
|
182
|
+
response
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fn call_tool(service: ServerService, request_id: Value, payload: Value) -> Response {
|
|
186
|
+
let params = match payload.get("params").and_then(Value::as_object) {
|
|
187
|
+
Some(params) => params,
|
|
188
|
+
None => {
|
|
189
|
+
return jsonrpc_error(
|
|
190
|
+
StatusCode::OK,
|
|
191
|
+
request_id,
|
|
192
|
+
-32602,
|
|
193
|
+
"Invalid params: params must be an object",
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
let Some(name) = params.get("name").and_then(Value::as_str) else {
|
|
198
|
+
return jsonrpc_error(
|
|
199
|
+
StatusCode::OK,
|
|
200
|
+
request_id,
|
|
201
|
+
-32602,
|
|
202
|
+
"Invalid params: name must be a string",
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
let arguments = params
|
|
206
|
+
.get("arguments")
|
|
207
|
+
.and_then(Value::as_object)
|
|
208
|
+
.cloned()
|
|
209
|
+
.unwrap_or_default();
|
|
210
|
+
|
|
211
|
+
let result = match name {
|
|
212
|
+
"check_duplication" => check_duplication_tool(service, arguments),
|
|
213
|
+
"get_statistics" => get_statistics_tool(service),
|
|
214
|
+
"check_current_directory" => check_current_directory_tool(service),
|
|
215
|
+
_ => Err(format!("MCP error -32602: Tool {name} not found")),
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
match result {
|
|
219
|
+
Ok(result) => jsonrpc_result(request_id, result),
|
|
220
|
+
Err(message) => jsonrpc_result(request_id, tool_error(message)),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn check_duplication_tool(
|
|
225
|
+
service: ServerService,
|
|
226
|
+
arguments: Map<String, Value>,
|
|
227
|
+
) -> Result<Value, String> {
|
|
228
|
+
let code = string_argument(&arguments, "code", "check_duplication")?;
|
|
229
|
+
let format = string_argument(&arguments, "format", "check_duplication")?;
|
|
230
|
+
let recheck = bool_argument(&arguments, "recheck", "check_duplication")?.unwrap_or(false);
|
|
231
|
+
|
|
232
|
+
if recheck {
|
|
233
|
+
service
|
|
234
|
+
.recheck()
|
|
235
|
+
.map_err(|error| format!("Error checking duplication: {error}"))?;
|
|
236
|
+
}
|
|
237
|
+
let response = service
|
|
238
|
+
.check_snippet(CheckSnippetRequest { code, format })
|
|
239
|
+
.map_err(|error| format!("Error checking duplication: {error}"))?;
|
|
240
|
+
Ok(text_content(
|
|
241
|
+
serde_json::to_string_pretty(&response)
|
|
242
|
+
.map_err(|error| format!("Error checking duplication: {error}"))?,
|
|
243
|
+
))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
fn get_statistics_tool(service: ServerService) -> Result<Value, String> {
|
|
247
|
+
let statistics = service.statistics();
|
|
248
|
+
Ok(text_content(
|
|
249
|
+
serde_json::to_string_pretty(&statistics)
|
|
250
|
+
.map_err(|error| format!("Error getting statistics: {error}"))?,
|
|
251
|
+
))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fn check_current_directory_tool(service: ServerService) -> Result<Value, String> {
|
|
255
|
+
service
|
|
256
|
+
.recheck()
|
|
257
|
+
.map_err(|error| format!("Error starting recheck: {error}"))?;
|
|
258
|
+
let statistics = service.statistics();
|
|
259
|
+
Ok(text_content(serde_json::to_string(&statistics).map_err(
|
|
260
|
+
|error| format!("Error starting recheck: {error}"),
|
|
261
|
+
)?))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
fn read_resource(service: ServerService, request_id: Value, payload: Value) -> Response {
|
|
265
|
+
let uri = payload
|
|
266
|
+
.get("params")
|
|
267
|
+
.and_then(Value::as_object)
|
|
268
|
+
.and_then(|params| params.get("uri"))
|
|
269
|
+
.and_then(Value::as_str);
|
|
270
|
+
match uri {
|
|
271
|
+
Some("jscpd://statistics") => {
|
|
272
|
+
let statistics = service.statistics();
|
|
273
|
+
match serde_json::to_string_pretty(&statistics) {
|
|
274
|
+
Ok(text) => jsonrpc_result(
|
|
275
|
+
request_id,
|
|
276
|
+
json!({
|
|
277
|
+
"contents": [{
|
|
278
|
+
"uri": "jscpd://statistics",
|
|
279
|
+
"text": text,
|
|
280
|
+
}],
|
|
281
|
+
}),
|
|
282
|
+
),
|
|
283
|
+
Err(error) => jsonrpc_error(
|
|
284
|
+
StatusCode::OK,
|
|
285
|
+
request_id,
|
|
286
|
+
-32603,
|
|
287
|
+
format!("Error getting statistics resource: {error}"),
|
|
288
|
+
),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
Some(uri) => jsonrpc_error(
|
|
292
|
+
StatusCode::OK,
|
|
293
|
+
request_id,
|
|
294
|
+
-32602,
|
|
295
|
+
format!("MCP error -32602: Resource {uri} not found"),
|
|
296
|
+
),
|
|
297
|
+
None => jsonrpc_error(
|
|
298
|
+
StatusCode::OK,
|
|
299
|
+
request_id,
|
|
300
|
+
-32602,
|
|
301
|
+
"Invalid params: uri must be a string",
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
fn tools_list_result() -> Value {
|
|
307
|
+
json!({
|
|
308
|
+
"tools": [
|
|
309
|
+
{
|
|
310
|
+
"name": "check_duplication",
|
|
311
|
+
"description": "Check code snippet for duplications against the codebase",
|
|
312
|
+
"inputSchema": {
|
|
313
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
314
|
+
"type": "object",
|
|
315
|
+
"properties": {
|
|
316
|
+
"code": {
|
|
317
|
+
"type": "string",
|
|
318
|
+
"description": "Source code snippet to check for duplications",
|
|
319
|
+
},
|
|
320
|
+
"format": {
|
|
321
|
+
"type": "string",
|
|
322
|
+
"description": "Format of the code (e.g., \"javascript\", \"typescript\", \"python\")",
|
|
323
|
+
},
|
|
324
|
+
"recheck": {
|
|
325
|
+
"type": "boolean",
|
|
326
|
+
"description": "Trigger a re-scan of the current working directory before checking",
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
"required": ["code", "format"],
|
|
330
|
+
},
|
|
331
|
+
"execution": {
|
|
332
|
+
"taskSupport": "forbidden",
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
"name": "get_statistics",
|
|
337
|
+
"description": "Get overall project duplication statistics",
|
|
338
|
+
"inputSchema": {
|
|
339
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
340
|
+
"type": "object",
|
|
341
|
+
"properties": {},
|
|
342
|
+
},
|
|
343
|
+
"execution": {
|
|
344
|
+
"taskSupport": "forbidden",
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
"name": "check_current_directory",
|
|
349
|
+
"description": "Trigger a re-scan of the current working directory for duplications",
|
|
350
|
+
"inputSchema": {
|
|
351
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
352
|
+
"type": "object",
|
|
353
|
+
"properties": {},
|
|
354
|
+
},
|
|
355
|
+
"execution": {
|
|
356
|
+
"taskSupport": "forbidden",
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
fn resources_list_result() -> Value {
|
|
364
|
+
json!({
|
|
365
|
+
"resources": [{
|
|
366
|
+
"uri": "jscpd://statistics",
|
|
367
|
+
"name": "statistics",
|
|
368
|
+
"description": "Get overall project duplication statistics",
|
|
369
|
+
"mimeType": "application/json",
|
|
370
|
+
}],
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
fn accepts_mcp_response(headers: &HeaderMap) -> bool {
|
|
375
|
+
let Some(accept) = headers.get("accept").and_then(|value| value.to_str().ok()) else {
|
|
376
|
+
return false;
|
|
377
|
+
};
|
|
378
|
+
accept.contains("application/json") && accept.contains("text/event-stream")
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fn has_json_content_type(headers: &HeaderMap) -> bool {
|
|
382
|
+
let Some(content_type) = headers
|
|
383
|
+
.get(CONTENT_TYPE)
|
|
384
|
+
.and_then(|value| value.to_str().ok())
|
|
385
|
+
else {
|
|
386
|
+
return false;
|
|
387
|
+
};
|
|
388
|
+
let mime = content_type
|
|
389
|
+
.split(';')
|
|
390
|
+
.next()
|
|
391
|
+
.unwrap_or_default()
|
|
392
|
+
.trim()
|
|
393
|
+
.to_ascii_lowercase();
|
|
394
|
+
mime == "application/json" || mime.ends_with("+json")
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
fn unsupported_media_type_response() -> Response {
|
|
398
|
+
jsonrpc_error(
|
|
399
|
+
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
|
400
|
+
Value::Null,
|
|
401
|
+
-32000,
|
|
402
|
+
"Unsupported Media Type: Content-Type must be application/json",
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
fn syntax_error_response(message: String) -> Response {
|
|
407
|
+
(
|
|
408
|
+
StatusCode::BAD_REQUEST,
|
|
409
|
+
Json(json!({
|
|
410
|
+
"error": "SyntaxError",
|
|
411
|
+
"message": message,
|
|
412
|
+
"statusCode": 400,
|
|
413
|
+
})),
|
|
414
|
+
)
|
|
415
|
+
.into_response()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn string_argument(
|
|
419
|
+
arguments: &Map<String, Value>,
|
|
420
|
+
name: &str,
|
|
421
|
+
tool_name: &str,
|
|
422
|
+
) -> Result<String, String> {
|
|
423
|
+
let Some(value) = arguments.get(name) else {
|
|
424
|
+
return Err(input_validation_error(
|
|
425
|
+
tool_name,
|
|
426
|
+
"string",
|
|
427
|
+
name,
|
|
428
|
+
"undefined",
|
|
429
|
+
));
|
|
430
|
+
};
|
|
431
|
+
let Some(value) = value.as_str() else {
|
|
432
|
+
return Err(input_validation_error(
|
|
433
|
+
tool_name,
|
|
434
|
+
"string",
|
|
435
|
+
name,
|
|
436
|
+
received_type(value),
|
|
437
|
+
));
|
|
438
|
+
};
|
|
439
|
+
Ok(value.to_string())
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
fn bool_argument(
|
|
443
|
+
arguments: &Map<String, Value>,
|
|
444
|
+
name: &str,
|
|
445
|
+
tool_name: &str,
|
|
446
|
+
) -> Result<Option<bool>, String> {
|
|
447
|
+
let Some(value) = arguments.get(name) else {
|
|
448
|
+
return Ok(None);
|
|
449
|
+
};
|
|
450
|
+
value
|
|
451
|
+
.as_bool()
|
|
452
|
+
.map(Some)
|
|
453
|
+
.ok_or_else(|| input_validation_error(tool_name, "boolean", name, received_type(value)))
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
fn input_validation_error(tool_name: &str, expected: &str, field: &str, received: &str) -> String {
|
|
457
|
+
format!(
|
|
458
|
+
"MCP error -32602: Input validation error: Invalid arguments for tool {tool_name}: [\n {{\n \"expected\": \"{expected}\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"{field}\"\n ],\n \"message\": \"Invalid input: expected {expected}, received {received}\"\n }}\n]"
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn received_type(value: &Value) -> &'static str {
|
|
463
|
+
match value {
|
|
464
|
+
Value::Null => "null",
|
|
465
|
+
Value::Bool(_) => "boolean",
|
|
466
|
+
Value::Number(_) => "number",
|
|
467
|
+
Value::String(_) => "string",
|
|
468
|
+
Value::Array(_) => "array",
|
|
469
|
+
Value::Object(_) => "object",
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn request_id(payload: &Value) -> Value {
|
|
474
|
+
payload.get("id").cloned().unwrap_or(Value::Null)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
fn jsonrpc_result(id: Value, result: Value) -> Response {
|
|
478
|
+
Json(json!({
|
|
479
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
480
|
+
"id": id,
|
|
481
|
+
"result": result,
|
|
482
|
+
}))
|
|
483
|
+
.into_response()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
fn jsonrpc_error(status: StatusCode, id: Value, code: i64, message: impl Into<String>) -> Response {
|
|
487
|
+
(
|
|
488
|
+
status,
|
|
489
|
+
Json(json!({
|
|
490
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
491
|
+
"id": id,
|
|
492
|
+
"error": {
|
|
493
|
+
"code": code,
|
|
494
|
+
"message": message.into(),
|
|
495
|
+
},
|
|
496
|
+
})),
|
|
497
|
+
)
|
|
498
|
+
.into_response()
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
fn bad_session_response() -> Response {
|
|
502
|
+
jsonrpc_error(
|
|
503
|
+
StatusCode::BAD_REQUEST,
|
|
504
|
+
Value::Null,
|
|
505
|
+
-32000,
|
|
506
|
+
"Bad Request: No valid session ID provided",
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
fn text_content(text: String) -> Value {
|
|
511
|
+
json!({
|
|
512
|
+
"content": [{
|
|
513
|
+
"type": "text",
|
|
514
|
+
"text": text,
|
|
515
|
+
}],
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
fn tool_error(message: String) -> Value {
|
|
520
|
+
json!({
|
|
521
|
+
"isError": true,
|
|
522
|
+
"content": [{
|
|
523
|
+
"type": "text",
|
|
524
|
+
"text": message,
|
|
525
|
+
}],
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#[cfg(test)]
|
|
530
|
+
mod tests {
|
|
531
|
+
use std::fs;
|
|
532
|
+
use std::path::{Path, PathBuf};
|
|
533
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
534
|
+
|
|
535
|
+
use axum::body::to_bytes;
|
|
536
|
+
use serde_json::json;
|
|
537
|
+
|
|
538
|
+
use crate::cli::Options;
|
|
539
|
+
|
|
540
|
+
use super::*;
|
|
541
|
+
|
|
542
|
+
fn fixture_project() -> PathBuf {
|
|
543
|
+
let mut path = std::env::temp_dir();
|
|
544
|
+
let stamp = SystemTime::now()
|
|
545
|
+
.duration_since(UNIX_EPOCH)
|
|
546
|
+
.expect("time")
|
|
547
|
+
.as_nanos();
|
|
548
|
+
path.push(format!("jscpd-rs-mcp-{stamp}"));
|
|
549
|
+
fs::create_dir_all(&path).expect("create temp project");
|
|
550
|
+
let content = "const alpha = 1;\nconst beta = 2;\nconst gamma = alpha + beta;\n";
|
|
551
|
+
fs::write(path.join("a.js"), content).expect("write a.js");
|
|
552
|
+
fs::write(path.join("b.js"), content).expect("write b.js");
|
|
553
|
+
path
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
fn service_for(path: &Path) -> ServerService {
|
|
557
|
+
let options = Options {
|
|
558
|
+
paths: vec![path.to_path_buf()],
|
|
559
|
+
min_tokens: 5,
|
|
560
|
+
min_lines: 2,
|
|
561
|
+
max_size_bytes: 1024 * 1024,
|
|
562
|
+
..Options::default()
|
|
563
|
+
};
|
|
564
|
+
let service = ServerService::new(path.to_path_buf(), options);
|
|
565
|
+
service.initialize().expect("initialize");
|
|
566
|
+
service
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async fn response_json(response: Response) -> (StatusCode, HeaderMap, Value) {
|
|
570
|
+
let (parts, body) = response.into_parts();
|
|
571
|
+
let bytes = to_bytes(body, usize::MAX).await.expect("response body");
|
|
572
|
+
let value = if bytes.is_empty() {
|
|
573
|
+
Value::Null
|
|
574
|
+
} else {
|
|
575
|
+
serde_json::from_slice(&bytes).expect("json body")
|
|
576
|
+
};
|
|
577
|
+
(parts.status, parts.headers, value)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async fn mcp_json(
|
|
581
|
+
service: ServerService,
|
|
582
|
+
session_id: Option<&str>,
|
|
583
|
+
payload: Value,
|
|
584
|
+
) -> (StatusCode, HeaderMap, Value) {
|
|
585
|
+
response_json(handle_mcp_request(service, session_id, payload).await).await
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
fn initialize_payload(id: usize) -> Value {
|
|
589
|
+
json!({
|
|
590
|
+
"jsonrpc": "2.0",
|
|
591
|
+
"method": "initialize",
|
|
592
|
+
"params": {
|
|
593
|
+
"protocolVersion": "2024-11-05",
|
|
594
|
+
"capabilities": {},
|
|
595
|
+
"clientInfo": { "name": "test-client", "version": "1.0.0" },
|
|
596
|
+
},
|
|
597
|
+
"id": id,
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
fn mcp_headers(session_id: Option<&str>) -> HeaderMap {
|
|
602
|
+
let mut headers = HeaderMap::new();
|
|
603
|
+
headers.insert(
|
|
604
|
+
"accept",
|
|
605
|
+
HeaderValue::from_static("application/json, text/event-stream"),
|
|
606
|
+
);
|
|
607
|
+
headers.insert("content-type", HeaderValue::from_static("application/json"));
|
|
608
|
+
if let Some(session_id) = session_id {
|
|
609
|
+
headers.insert(
|
|
610
|
+
HeaderName::from_static(MCP_SESSION_ID),
|
|
611
|
+
HeaderValue::from_str(session_id).expect("session header"),
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
headers
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
fn mcp_headers_with_content_type(session_id: Option<&str>, content_type: &str) -> HeaderMap {
|
|
618
|
+
let mut headers = mcp_headers(session_id);
|
|
619
|
+
headers.insert(
|
|
620
|
+
CONTENT_TYPE,
|
|
621
|
+
HeaderValue::from_str(content_type).expect("content-type header"),
|
|
622
|
+
);
|
|
623
|
+
headers
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
fn assert_accepted_without_session_echo(status: StatusCode, headers: &HeaderMap, body: &Value) {
|
|
627
|
+
assert_eq!(status, StatusCode::ACCEPTED);
|
|
628
|
+
assert_eq!(body, &Value::Null);
|
|
629
|
+
assert!(
|
|
630
|
+
headers.get(MCP_SESSION_ID).is_none(),
|
|
631
|
+
"upstream does not echo session IDs on accepted notifications"
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#[tokio::test]
|
|
636
|
+
async fn mcp_initialize_creates_session() {
|
|
637
|
+
let path = fixture_project();
|
|
638
|
+
let service = service_for(&path);
|
|
639
|
+
|
|
640
|
+
let (status, headers, body) = mcp_json(service.clone(), None, initialize_payload(1)).await;
|
|
641
|
+
|
|
642
|
+
assert_eq!(status, StatusCode::OK);
|
|
643
|
+
assert_eq!(body["jsonrpc"], "2.0");
|
|
644
|
+
assert_eq!(body["id"], 1);
|
|
645
|
+
assert_eq!(body["result"]["serverInfo"]["name"], "jscpd-server");
|
|
646
|
+
assert_eq!(body["result"]["capabilities"]["tools"]["listChanged"], true);
|
|
647
|
+
assert_eq!(
|
|
648
|
+
body["result"]["capabilities"]["resources"]["listChanged"],
|
|
649
|
+
true
|
|
650
|
+
);
|
|
651
|
+
let session_id = headers
|
|
652
|
+
.get(MCP_SESSION_ID)
|
|
653
|
+
.and_then(|value| value.to_str().ok())
|
|
654
|
+
.expect("session id");
|
|
655
|
+
assert_uuid_v4_shape(session_id);
|
|
656
|
+
assert!(service.has_mcp_session(session_id));
|
|
657
|
+
fs::remove_dir_all(path).ok();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
#[tokio::test]
|
|
661
|
+
async fn mcp_tools_list_matches_upstream_schema_shape() {
|
|
662
|
+
let path = fixture_project();
|
|
663
|
+
let service = service_for(&path);
|
|
664
|
+
let session_id = service.create_mcp_session();
|
|
665
|
+
|
|
666
|
+
let (status, _headers, body) = mcp_json(
|
|
667
|
+
service,
|
|
668
|
+
Some(&session_id),
|
|
669
|
+
json!({
|
|
670
|
+
"jsonrpc": "2.0",
|
|
671
|
+
"method": "tools/list",
|
|
672
|
+
"id": 2,
|
|
673
|
+
}),
|
|
674
|
+
)
|
|
675
|
+
.await;
|
|
676
|
+
|
|
677
|
+
assert_eq!(status, StatusCode::OK);
|
|
678
|
+
for tool in body["result"]["tools"].as_array().expect("tools list") {
|
|
679
|
+
assert_eq!(
|
|
680
|
+
tool["inputSchema"]["$schema"],
|
|
681
|
+
"http://json-schema.org/draft-07/schema#"
|
|
682
|
+
);
|
|
683
|
+
assert_eq!(tool["execution"]["taskSupport"], "forbidden");
|
|
684
|
+
}
|
|
685
|
+
fs::remove_dir_all(path).ok();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
fn assert_uuid_v4_shape(session_id: &str) {
|
|
689
|
+
let bytes = session_id.as_bytes();
|
|
690
|
+
assert_eq!(session_id.len(), 36);
|
|
691
|
+
assert_eq!(bytes[8], b'-');
|
|
692
|
+
assert_eq!(bytes[13], b'-');
|
|
693
|
+
assert_eq!(bytes[18], b'-');
|
|
694
|
+
assert_eq!(bytes[23], b'-');
|
|
695
|
+
assert_eq!(bytes[14], b'4');
|
|
696
|
+
assert!(
|
|
697
|
+
matches!(bytes[19], b'8' | b'9' | b'a' | b'b'),
|
|
698
|
+
"UUID v4 variant nibble should be 8, 9, a, or b"
|
|
699
|
+
);
|
|
700
|
+
for (index, byte) in bytes.iter().enumerate() {
|
|
701
|
+
if matches!(index, 8 | 13 | 18 | 23) {
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
assert!(byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#[tokio::test]
|
|
709
|
+
async fn mcp_batch_requests_match_upstream_sdk_shape() {
|
|
710
|
+
let path = fixture_project();
|
|
711
|
+
let service = service_for(&path);
|
|
712
|
+
let session_id = service.create_mcp_session();
|
|
713
|
+
|
|
714
|
+
let response = post_mcp(
|
|
715
|
+
State(service.clone()),
|
|
716
|
+
mcp_headers(Some(&session_id)),
|
|
717
|
+
Bytes::from(
|
|
718
|
+
json!([{
|
|
719
|
+
"jsonrpc": "2.0",
|
|
720
|
+
"method": "tools/list",
|
|
721
|
+
"id": 5,
|
|
722
|
+
}])
|
|
723
|
+
.to_string(),
|
|
724
|
+
),
|
|
725
|
+
)
|
|
726
|
+
.await;
|
|
727
|
+
let (status, headers, body) = response_json(response).await;
|
|
728
|
+
|
|
729
|
+
assert_eq!(status, StatusCode::OK);
|
|
730
|
+
assert_eq!(body["id"], 5);
|
|
731
|
+
assert!(body["result"]["tools"].is_array());
|
|
732
|
+
assert_eq!(
|
|
733
|
+
headers
|
|
734
|
+
.get(MCP_SESSION_ID)
|
|
735
|
+
.and_then(|value| value.to_str().ok()),
|
|
736
|
+
Some(session_id.as_str())
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
let response = post_mcp(
|
|
740
|
+
State(service.clone()),
|
|
741
|
+
mcp_headers(Some(&session_id)),
|
|
742
|
+
Bytes::from(
|
|
743
|
+
json!([
|
|
744
|
+
{
|
|
745
|
+
"jsonrpc": "2.0",
|
|
746
|
+
"method": "tools/list",
|
|
747
|
+
"id": 6,
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
"jsonrpc": "2.0",
|
|
751
|
+
"method": "resources/list",
|
|
752
|
+
"id": 7,
|
|
753
|
+
}
|
|
754
|
+
])
|
|
755
|
+
.to_string(),
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
.await;
|
|
759
|
+
let (status, _headers, body) = response_json(response).await;
|
|
760
|
+
|
|
761
|
+
assert_eq!(status, StatusCode::OK);
|
|
762
|
+
let responses = body.as_array().expect("batch responses");
|
|
763
|
+
assert_eq!(responses.len(), 2);
|
|
764
|
+
assert_eq!(responses[0]["id"], 6);
|
|
765
|
+
assert_eq!(responses[1]["id"], 7);
|
|
766
|
+
assert!(responses[1]["result"]["resources"].is_array());
|
|
767
|
+
|
|
768
|
+
let response = post_mcp(
|
|
769
|
+
State(service),
|
|
770
|
+
mcp_headers(Some(&session_id)),
|
|
771
|
+
Bytes::from(json!([]).to_string()),
|
|
772
|
+
)
|
|
773
|
+
.await;
|
|
774
|
+
let (status, headers, body) = response_json(response).await;
|
|
775
|
+
|
|
776
|
+
assert_accepted_without_session_echo(status, &headers, &body);
|
|
777
|
+
fs::remove_dir_all(path).ok();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#[tokio::test]
|
|
781
|
+
async fn mcp_initialized_notification_omits_session_echo() {
|
|
782
|
+
let path = fixture_project();
|
|
783
|
+
let service = service_for(&path);
|
|
784
|
+
let session_id = service.create_mcp_session();
|
|
785
|
+
|
|
786
|
+
let response = post_mcp(
|
|
787
|
+
State(service),
|
|
788
|
+
mcp_headers(Some(&session_id)),
|
|
789
|
+
Bytes::from(
|
|
790
|
+
json!({
|
|
791
|
+
"jsonrpc": "2.0",
|
|
792
|
+
"method": "notifications/initialized",
|
|
793
|
+
})
|
|
794
|
+
.to_string(),
|
|
795
|
+
),
|
|
796
|
+
)
|
|
797
|
+
.await;
|
|
798
|
+
let (status, headers, body) = response_json(response).await;
|
|
799
|
+
|
|
800
|
+
assert_accepted_without_session_echo(status, &headers, &body);
|
|
801
|
+
fs::remove_dir_all(path).ok();
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
#[tokio::test]
|
|
805
|
+
async fn mcp_rejects_unsupported_content_type_like_upstream_sdk() {
|
|
806
|
+
let path = fixture_project();
|
|
807
|
+
let service = service_for(&path);
|
|
808
|
+
let initialize_payload = Bytes::from(initialize_payload(1).to_string());
|
|
809
|
+
|
|
810
|
+
let response = post_mcp(
|
|
811
|
+
State(service.clone()),
|
|
812
|
+
mcp_headers_with_content_type(None, "text/plain"),
|
|
813
|
+
initialize_payload,
|
|
814
|
+
)
|
|
815
|
+
.await;
|
|
816
|
+
let (status, _headers, body) = response_json(response).await;
|
|
817
|
+
|
|
818
|
+
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
819
|
+
assert_eq!(body["error"]["code"], -32000);
|
|
820
|
+
assert_eq!(
|
|
821
|
+
body["error"]["message"],
|
|
822
|
+
"Bad Request: No valid session ID provided"
|
|
823
|
+
);
|
|
824
|
+
|
|
825
|
+
let session_id = service.create_mcp_session();
|
|
826
|
+
let response = post_mcp(
|
|
827
|
+
State(service),
|
|
828
|
+
mcp_headers_with_content_type(Some(&session_id), "text/plain"),
|
|
829
|
+
Bytes::from(
|
|
830
|
+
json!({
|
|
831
|
+
"jsonrpc": "2.0",
|
|
832
|
+
"method": "tools/list",
|
|
833
|
+
"id": 2,
|
|
834
|
+
})
|
|
835
|
+
.to_string(),
|
|
836
|
+
),
|
|
837
|
+
)
|
|
838
|
+
.await;
|
|
839
|
+
let (status, headers, body) = response_json(response).await;
|
|
840
|
+
|
|
841
|
+
assert_eq!(status, StatusCode::UNSUPPORTED_MEDIA_TYPE);
|
|
842
|
+
assert_eq!(body["id"], Value::Null);
|
|
843
|
+
assert_eq!(body["error"]["code"], -32000);
|
|
844
|
+
assert_eq!(
|
|
845
|
+
body["error"]["message"],
|
|
846
|
+
"Unsupported Media Type: Content-Type must be application/json"
|
|
847
|
+
);
|
|
848
|
+
assert!(
|
|
849
|
+
headers.get(MCP_SESSION_ID).is_none(),
|
|
850
|
+
"upstream SDK does not echo session IDs on unsupported media type errors"
|
|
851
|
+
);
|
|
852
|
+
fs::remove_dir_all(path).ok();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
#[tokio::test]
|
|
856
|
+
async fn mcp_rejects_non_initialize_without_session() {
|
|
857
|
+
let path = fixture_project();
|
|
858
|
+
let service = service_for(&path);
|
|
859
|
+
|
|
860
|
+
let (status, _headers, body) = mcp_json(
|
|
861
|
+
service,
|
|
862
|
+
None,
|
|
863
|
+
json!({
|
|
864
|
+
"jsonrpc": "2.0",
|
|
865
|
+
"method": "tools/list",
|
|
866
|
+
"id": 2,
|
|
867
|
+
}),
|
|
868
|
+
)
|
|
869
|
+
.await;
|
|
870
|
+
|
|
871
|
+
assert_eq!(status, StatusCode::BAD_REQUEST);
|
|
872
|
+
assert_eq!(body["error"]["code"], -32000);
|
|
873
|
+
fs::remove_dir_all(path).ok();
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
#[tokio::test]
|
|
877
|
+
async fn mcp_check_duplication_tool_returns_content() {
|
|
878
|
+
let path = fixture_project();
|
|
879
|
+
let service = service_for(&path);
|
|
880
|
+
let session_id = service.create_mcp_session();
|
|
881
|
+
|
|
882
|
+
let (status, _headers, body) = mcp_json(
|
|
883
|
+
service,
|
|
884
|
+
Some(&session_id),
|
|
885
|
+
json!({
|
|
886
|
+
"jsonrpc": "2.0",
|
|
887
|
+
"method": "tools/call",
|
|
888
|
+
"params": {
|
|
889
|
+
"name": "check_duplication",
|
|
890
|
+
"arguments": {
|
|
891
|
+
"code": "const alpha = 1;\nconst beta = 2;\nconst gamma = alpha + beta;\n",
|
|
892
|
+
"format": "javascript",
|
|
893
|
+
"recheck": true,
|
|
894
|
+
},
|
|
895
|
+
},
|
|
896
|
+
"id": 3,
|
|
897
|
+
}),
|
|
898
|
+
)
|
|
899
|
+
.await;
|
|
900
|
+
|
|
901
|
+
assert_eq!(status, StatusCode::OK);
|
|
902
|
+
assert_eq!(body["id"], 3);
|
|
903
|
+
let content = body["result"]["content"][0]["text"]
|
|
904
|
+
.as_str()
|
|
905
|
+
.expect("text content");
|
|
906
|
+
assert!(content.contains("duplications"));
|
|
907
|
+
assert!(content.contains("totalDuplications"));
|
|
908
|
+
fs::remove_dir_all(path).ok();
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
#[tokio::test]
|
|
912
|
+
async fn mcp_statistics_resource_returns_stats() {
|
|
913
|
+
let path = fixture_project();
|
|
914
|
+
let service = service_for(&path);
|
|
915
|
+
let session_id = service.create_mcp_session();
|
|
916
|
+
|
|
917
|
+
let (status, _headers, body) = mcp_json(
|
|
918
|
+
service,
|
|
919
|
+
Some(&session_id),
|
|
920
|
+
json!({
|
|
921
|
+
"jsonrpc": "2.0",
|
|
922
|
+
"method": "resources/read",
|
|
923
|
+
"params": { "uri": "jscpd://statistics" },
|
|
924
|
+
"id": 4,
|
|
925
|
+
}),
|
|
926
|
+
)
|
|
927
|
+
.await;
|
|
928
|
+
|
|
929
|
+
assert_eq!(status, StatusCode::OK);
|
|
930
|
+
assert_eq!(body["id"], 4);
|
|
931
|
+
assert_eq!(body["result"]["contents"][0]["uri"], "jscpd://statistics");
|
|
932
|
+
assert!(
|
|
933
|
+
body["result"]["contents"][0].get("mimeType").is_none(),
|
|
934
|
+
"upstream resource reads omit content mimeType"
|
|
935
|
+
);
|
|
936
|
+
let content = body["result"]["contents"][0]["text"]
|
|
937
|
+
.as_str()
|
|
938
|
+
.expect("text content");
|
|
939
|
+
assert!(content.contains("statistics"));
|
|
940
|
+
fs::remove_dir_all(path).ok();
|
|
941
|
+
}
|
|
942
|
+
}
|