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.
Files changed (96) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/Cargo.lock +1323 -0
  3. package/Cargo.toml +54 -0
  4. package/LICENSE +21 -0
  5. package/README.md +372 -0
  6. package/docs/api-parity.md +49 -0
  7. package/docs/cloning-plan.md +281 -0
  8. package/docs/compat-baseline.md +535 -0
  9. package/docs/format-porting.md +86 -0
  10. package/docs/junior-task-template.md +62 -0
  11. package/docs/junior-workflow.md +87 -0
  12. package/docs/migrating-from-jscpd.md +193 -0
  13. package/docs/npm-release.md +116 -0
  14. package/docs/public-benchmark-suite.md +81 -0
  15. package/docs/release-checklist.md +200 -0
  16. package/docs/release-decisions.md +103 -0
  17. package/docs/release-readiness.md +51 -0
  18. package/docs/upstream-bugs.md +501 -0
  19. package/docs/upstream-issue-drafts.md +393 -0
  20. package/docs/user-guide.md +309 -0
  21. package/examples/dump_oxc_tokens.rs +112 -0
  22. package/examples/library_api.rs +42 -0
  23. package/npm/bin/jscpd-rs.js +6 -0
  24. package/npm/bin/jscpd-server.js +6 -0
  25. package/npm/lib/run-binary.js +68 -0
  26. package/npm/scripts/postinstall.js +50 -0
  27. package/package.json +53 -0
  28. package/skills/dry-refactoring/SKILL.md +63 -0
  29. package/skills/jscpd/SKILL.md +85 -0
  30. package/src/app.rs +512 -0
  31. package/src/bin/jscpd-server.rs +429 -0
  32. package/src/blame.rs +130 -0
  33. package/src/cli/config.rs +543 -0
  34. package/src/cli/parsing.rs +301 -0
  35. package/src/cli/tests.rs +543 -0
  36. package/src/cli.rs +671 -0
  37. package/src/detector/matching/secondary.rs +387 -0
  38. package/src/detector/matching.rs +274 -0
  39. package/src/detector/model.rs +190 -0
  40. package/src/detector/prepare.rs +71 -0
  41. package/src/detector/skip_local.rs +40 -0
  42. package/src/detector/statistics.rs +138 -0
  43. package/src/detector/store.rs +96 -0
  44. package/src/detector/tests.rs +238 -0
  45. package/src/detector.rs +265 -0
  46. package/src/files/discovery.rs +508 -0
  47. package/src/files/gitignore.rs +203 -0
  48. package/src/files/paths.rs +68 -0
  49. package/src/files/shebang.rs +106 -0
  50. package/src/files/tests.rs +523 -0
  51. package/src/files.rs +25 -0
  52. package/src/formats.rs +570 -0
  53. package/src/lib.rs +433 -0
  54. package/src/main.rs +26 -0
  55. package/src/report/ai.rs +125 -0
  56. package/src/report/badge.rs +238 -0
  57. package/src/report/console.rs +180 -0
  58. package/src/report/console_common.rs +37 -0
  59. package/src/report/console_full.rs +139 -0
  60. package/src/report/csv.rs +65 -0
  61. package/src/report/escape.rs +8 -0
  62. package/src/report/file_output.rs +28 -0
  63. package/src/report/html/assets.rs +47 -0
  64. package/src/report/html.rs +336 -0
  65. package/src/report/json.rs +119 -0
  66. package/src/report/markdown.rs +125 -0
  67. package/src/report/sarif.rs +302 -0
  68. package/src/report/silent.rs +22 -0
  69. package/src/report/source.rs +38 -0
  70. package/src/report/summary.rs +50 -0
  71. package/src/report/test_support.rs +133 -0
  72. package/src/report/threshold.rs +76 -0
  73. package/src/report/xcode.rs +90 -0
  74. package/src/report/xml.rs +119 -0
  75. package/src/report.rs +250 -0
  76. package/src/server/mcp.rs +942 -0
  77. package/src/server.rs +1081 -0
  78. package/src/tokenizer/apex.rs +97 -0
  79. package/src/tokenizer/blocks.rs +532 -0
  80. package/src/tokenizer/embedded.rs +106 -0
  81. package/src/tokenizer/generic.rs +511 -0
  82. package/src/tokenizer/hash.rs +27 -0
  83. package/src/tokenizer/ignore.rs +33 -0
  84. package/src/tokenizer/line_index.rs +33 -0
  85. package/src/tokenizer/markdown.rs +289 -0
  86. package/src/tokenizer/markup_attrs.rs +289 -0
  87. package/src/tokenizer/oxc/fallback.rs +275 -0
  88. package/src/tokenizer/oxc/jsx.rs +168 -0
  89. package/src/tokenizer/oxc/kind.rs +177 -0
  90. package/src/tokenizer/oxc/lexical.rs +67 -0
  91. package/src/tokenizer/oxc.rs +659 -0
  92. package/src/tokenizer/scan.rs +88 -0
  93. package/src/tokenizer/tap.rs +150 -0
  94. package/src/tokenizer/tests.rs +915 -0
  95. package/src/tokenizer.rs +328 -0
  96. 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
+ }