mcp-testkit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """MCP Test Server — 65 deterministic tools for MCP protocol testing."""
2
+
3
+ __version__ = "0.1.0"
mcp_test_server/api.py ADDED
@@ -0,0 +1,659 @@
1
+ """HTTP REST API endpoints for MCP test server tools.
2
+
3
+ Provides /api/* REST endpoints that delegate to the same MCP tool implementations,
4
+ plus /api-docs serving the OpenAPI 3.0 spec.
5
+ """
6
+
7
+ import json
8
+
9
+ from starlette.requests import Request
10
+ from starlette.responses import JSONResponse
11
+ from starlette.routing import Route
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Endpoint registry — single source of truth for routes + OpenAPI generation
16
+ # ---------------------------------------------------------------------------
17
+ # (path, method, tool_name, summary, params)
18
+ # params: list of (name, json_schema_type, required, description)
19
+
20
+ ENDPOINTS = [
21
+ # --- Math (GET) ---
22
+ (
23
+ "/api/math/add",
24
+ "GET",
25
+ "math_add",
26
+ "Add two numbers",
27
+ [("a", "number", True, "First number"), ("b", "number", True, "Second number")],
28
+ ),
29
+ (
30
+ "/api/math/subtract",
31
+ "GET",
32
+ "math_subtract",
33
+ "Subtract b from a",
34
+ [("a", "number", True, "First number"), ("b", "number", True, "Number to subtract")],
35
+ ),
36
+ (
37
+ "/api/math/multiply",
38
+ "GET",
39
+ "math_multiply",
40
+ "Multiply two numbers",
41
+ [("a", "number", True, "First number"), ("b", "number", True, "Second number")],
42
+ ),
43
+ (
44
+ "/api/math/divide",
45
+ "GET",
46
+ "math_divide",
47
+ "Divide a by b",
48
+ [("a", "number", True, "Dividend"), ("b", "number", True, "Divisor")],
49
+ ),
50
+ (
51
+ "/api/math/modulo",
52
+ "GET",
53
+ "math_modulo",
54
+ "Compute a modulo b",
55
+ [("a", "number", True, "Dividend"), ("b", "number", True, "Divisor")],
56
+ ),
57
+ (
58
+ "/api/math/power",
59
+ "GET",
60
+ "math_power",
61
+ "Raise base to exponent",
62
+ [("base", "number", True, "Base"), ("exponent", "number", True, "Exponent")],
63
+ ),
64
+ (
65
+ "/api/math/factorial",
66
+ "GET",
67
+ "math_factorial",
68
+ "Compute n factorial",
69
+ [("n", "integer", True, "Non-negative integer")],
70
+ ),
71
+ (
72
+ "/api/math/fibonacci",
73
+ "GET",
74
+ "math_fibonacci",
75
+ "Compute nth Fibonacci number",
76
+ [("n", "integer", True, "Non-negative integer (0-indexed)")],
77
+ ),
78
+ # --- String (POST) ---
79
+ (
80
+ "/api/string/reverse",
81
+ "POST",
82
+ "string_reverse",
83
+ "Reverse a string",
84
+ [("text", "string", True, "Text to reverse")],
85
+ ),
86
+ (
87
+ "/api/string/uppercase",
88
+ "POST",
89
+ "string_uppercase",
90
+ "Convert to uppercase",
91
+ [("text", "string", True, "Text to convert")],
92
+ ),
93
+ (
94
+ "/api/string/lowercase",
95
+ "POST",
96
+ "string_lowercase",
97
+ "Convert to lowercase",
98
+ [("text", "string", True, "Text to convert")],
99
+ ),
100
+ ("/api/string/length", "POST", "string_length", "Get string length", [("text", "string", True, "Text to measure")]),
101
+ (
102
+ "/api/string/char-count",
103
+ "POST",
104
+ "string_char_count",
105
+ "Count character occurrences",
106
+ [("text", "string", True, "Text to search"), ("char", "string", True, "Character to count")],
107
+ ),
108
+ (
109
+ "/api/string/replace",
110
+ "POST",
111
+ "string_replace",
112
+ "Replace substrings",
113
+ [
114
+ ("text", "string", True, "Source text"),
115
+ ("old", "string", True, "Substring to find"),
116
+ ("new", "string", True, "Replacement"),
117
+ ],
118
+ ),
119
+ (
120
+ "/api/string/split",
121
+ "POST",
122
+ "string_split",
123
+ "Split string by delimiter",
124
+ [("text", "string", True, "Text to split"), ("delimiter", "string", True, "Delimiter")],
125
+ ),
126
+ (
127
+ "/api/string/join",
128
+ "POST",
129
+ "string_join",
130
+ "Join strings with delimiter",
131
+ [("items", "array", True, "List of strings"), ("delimiter", "string", True, "Delimiter")],
132
+ ),
133
+ # --- Collection (POST) ---
134
+ (
135
+ "/api/collection/sort",
136
+ "POST",
137
+ "collection_sort",
138
+ "Sort a list",
139
+ [("items", "array", True, "List to sort"), ("reverse", "boolean", False, "Reverse order")],
140
+ ),
141
+ (
142
+ "/api/collection/flatten",
143
+ "POST",
144
+ "collection_flatten",
145
+ "Flatten nested lists",
146
+ [("items", "array", True, "Nested list")],
147
+ ),
148
+ (
149
+ "/api/collection/merge",
150
+ "POST",
151
+ "collection_merge",
152
+ "Merge two dicts",
153
+ [("dict_a", "object", True, "Base dict"), ("dict_b", "object", True, "Override dict")],
154
+ ),
155
+ (
156
+ "/api/collection/filter-gt",
157
+ "POST",
158
+ "collection_filter_gt",
159
+ "Filter numbers greater than threshold",
160
+ [("items", "array", True, "List of numbers"), ("threshold", "number", True, "Threshold")],
161
+ ),
162
+ (
163
+ "/api/collection/unique",
164
+ "POST",
165
+ "collection_unique",
166
+ "Remove duplicates preserving order",
167
+ [("items", "array", True, "List to deduplicate")],
168
+ ),
169
+ (
170
+ "/api/collection/group-by",
171
+ "POST",
172
+ "collection_group_by",
173
+ "Group objects by key",
174
+ [("items", "array", True, "List of objects"), ("key", "string", True, "Key to group by")],
175
+ ),
176
+ (
177
+ "/api/collection/zip",
178
+ "POST",
179
+ "collection_zip",
180
+ "Zip two lists into pairs",
181
+ [("list_a", "array", True, "First list"), ("list_b", "array", True, "Second list")],
182
+ ),
183
+ (
184
+ "/api/collection/chunk",
185
+ "POST",
186
+ "collection_chunk",
187
+ "Split list into chunks",
188
+ [("items", "array", True, "List to split"), ("size", "integer", True, "Chunk size")],
189
+ ),
190
+ # --- Encoding (POST) ---
191
+ (
192
+ "/api/encoding/base64-encode",
193
+ "POST",
194
+ "encoding_base64_encode",
195
+ "Base64-encode a string",
196
+ [("text", "string", True, "Text to encode")],
197
+ ),
198
+ (
199
+ "/api/encoding/base64-decode",
200
+ "POST",
201
+ "encoding_base64_decode",
202
+ "Base64-decode a string",
203
+ [("data", "string", True, "Base64 data to decode")],
204
+ ),
205
+ (
206
+ "/api/encoding/url-encode",
207
+ "POST",
208
+ "encoding_url_encode",
209
+ "URL-encode a string",
210
+ [("text", "string", True, "Text to encode")],
211
+ ),
212
+ (
213
+ "/api/encoding/url-decode",
214
+ "POST",
215
+ "encoding_url_decode",
216
+ "URL-decode a string",
217
+ [("text", "string", True, "URL-encoded text to decode")],
218
+ ),
219
+ (
220
+ "/api/encoding/hex-encode",
221
+ "POST",
222
+ "encoding_hex_encode",
223
+ "Hex-encode a string",
224
+ [("text", "string", True, "Text to encode")],
225
+ ),
226
+ (
227
+ "/api/encoding/hex-decode",
228
+ "POST",
229
+ "encoding_hex_decode",
230
+ "Hex-decode a string",
231
+ [("data", "string", True, "Hex data to decode")],
232
+ ),
233
+ ("/api/encoding/md5", "POST", "encoding_md5", "Compute MD5 hash", [("text", "string", True, "Text to hash")]),
234
+ (
235
+ "/api/encoding/sha256",
236
+ "POST",
237
+ "encoding_sha256",
238
+ "Compute SHA-256 hash",
239
+ [("text", "string", True, "Text to hash")],
240
+ ),
241
+ # --- DateTime (POST) ---
242
+ (
243
+ "/api/datetime/parse",
244
+ "POST",
245
+ "datetime_parse",
246
+ "Parse ISO date string into components",
247
+ [("date_string", "string", True, "ISO date string (e.g. 2024-03-15 or 2024-03-15T10:30:00)")],
248
+ ),
249
+ (
250
+ "/api/datetime/format",
251
+ "POST",
252
+ "datetime_format",
253
+ "Format date components to string",
254
+ [
255
+ ("year", "integer", True, "Year"),
256
+ ("month", "integer", True, "Month"),
257
+ ("day", "integer", True, "Day"),
258
+ ("format", "string", True, "strftime format string"),
259
+ ],
260
+ ),
261
+ (
262
+ "/api/datetime/add-days",
263
+ "POST",
264
+ "datetime_add_days",
265
+ "Add days to a date",
266
+ [
267
+ ("date_string", "string", True, "ISO date string"),
268
+ ("days", "integer", True, "Days to add (negative to subtract)"),
269
+ ],
270
+ ),
271
+ (
272
+ "/api/datetime/diff",
273
+ "POST",
274
+ "datetime_diff",
275
+ "Compute days between two dates",
276
+ [("date_a", "string", True, "First date"), ("date_b", "string", True, "Second date")],
277
+ ),
278
+ (
279
+ "/api/datetime/day-of-week",
280
+ "POST",
281
+ "datetime_day_of_week",
282
+ "Get weekday name for a date",
283
+ [("date_string", "string", True, "ISO date string")],
284
+ ),
285
+ (
286
+ "/api/datetime/is-leap-year",
287
+ "POST",
288
+ "datetime_is_leap_year",
289
+ "Check if year is a leap year",
290
+ [("year", "integer", True, "Year to check")],
291
+ ),
292
+ (
293
+ "/api/datetime/days-in-month",
294
+ "POST",
295
+ "datetime_days_in_month",
296
+ "Get days in a month",
297
+ [("year", "integer", True, "Year"), ("month", "integer", True, "Month (1-12)")],
298
+ ),
299
+ (
300
+ "/api/datetime/week-number",
301
+ "POST",
302
+ "datetime_week_number",
303
+ "Get ISO week number",
304
+ [("date_string", "string", True, "ISO date string")],
305
+ ),
306
+ # --- Validation (POST) ---
307
+ (
308
+ "/api/validation/is-email",
309
+ "POST",
310
+ "validation_is_email",
311
+ "Check email format",
312
+ [("text", "string", True, "Text to validate")],
313
+ ),
314
+ (
315
+ "/api/validation/is-url",
316
+ "POST",
317
+ "validation_is_url",
318
+ "Check URL format",
319
+ [("text", "string", True, "Text to validate")],
320
+ ),
321
+ (
322
+ "/api/validation/is-ipv4",
323
+ "POST",
324
+ "validation_is_ipv4",
325
+ "Check IPv4 address",
326
+ [("text", "string", True, "Text to validate")],
327
+ ),
328
+ (
329
+ "/api/validation/is-ipv6",
330
+ "POST",
331
+ "validation_is_ipv6",
332
+ "Check IPv6 address",
333
+ [("text", "string", True, "Text to validate")],
334
+ ),
335
+ (
336
+ "/api/validation/is-uuid",
337
+ "POST",
338
+ "validation_is_uuid",
339
+ "Check UUID format",
340
+ [("text", "string", True, "Text to validate")],
341
+ ),
342
+ (
343
+ "/api/validation/is-json",
344
+ "POST",
345
+ "validation_is_json",
346
+ "Check valid JSON",
347
+ [("text", "string", True, "Text to validate")],
348
+ ),
349
+ (
350
+ "/api/validation/is-palindrome",
351
+ "POST",
352
+ "validation_is_palindrome",
353
+ "Check palindrome",
354
+ [("text", "string", True, "Text to check")],
355
+ ),
356
+ (
357
+ "/api/validation/matches-regex",
358
+ "POST",
359
+ "validation_matches_regex",
360
+ "Check regex match",
361
+ [("text", "string", True, "Text to test"), ("pattern", "string", True, "Regex pattern")],
362
+ ),
363
+ # --- Conversion (GET) ---
364
+ (
365
+ "/api/conversion/celsius-to-fahrenheit",
366
+ "GET",
367
+ "conversion_celsius_to_fahrenheit",
368
+ "Convert Celsius to Fahrenheit",
369
+ [("value", "number", True, "Temperature in Celsius")],
370
+ ),
371
+ (
372
+ "/api/conversion/fahrenheit-to-celsius",
373
+ "GET",
374
+ "conversion_fahrenheit_to_celsius",
375
+ "Convert Fahrenheit to Celsius",
376
+ [("value", "number", True, "Temperature in Fahrenheit")],
377
+ ),
378
+ (
379
+ "/api/conversion/km-to-miles",
380
+ "GET",
381
+ "conversion_km_to_miles",
382
+ "Convert kilometers to miles",
383
+ [("value", "number", True, "Distance in kilometers")],
384
+ ),
385
+ (
386
+ "/api/conversion/miles-to-km",
387
+ "GET",
388
+ "conversion_miles_to_km",
389
+ "Convert miles to kilometers",
390
+ [("value", "number", True, "Distance in miles")],
391
+ ),
392
+ (
393
+ "/api/conversion/bytes-to-human",
394
+ "GET",
395
+ "conversion_bytes_to_human",
396
+ "Convert bytes to human-readable string",
397
+ [("bytes", "integer", True, "Number of bytes")],
398
+ ),
399
+ (
400
+ "/api/conversion/rgb-to-hex",
401
+ "GET",
402
+ "conversion_rgb_to_hex",
403
+ "Convert RGB to hex color",
404
+ [
405
+ ("r", "integer", True, "Red (0-255)"),
406
+ ("g", "integer", True, "Green (0-255)"),
407
+ ("b", "integer", True, "Blue (0-255)"),
408
+ ],
409
+ ),
410
+ (
411
+ "/api/conversion/hex-to-rgb",
412
+ "GET",
413
+ "conversion_hex_to_rgb",
414
+ "Convert hex color to RGB",
415
+ [("hex_color", "string", True, "Hex color (with or without #)")],
416
+ ),
417
+ (
418
+ "/api/conversion/decimal-to-binary",
419
+ "GET",
420
+ "conversion_decimal_to_binary",
421
+ "Convert decimal to binary",
422
+ [("value", "integer", True, "Decimal integer")],
423
+ ),
424
+ # --- Echo (mixed) ---
425
+ ("/api/echo", "POST", "echo", "Echo back the input message", [("message", "string", True, "Message to echo")]),
426
+ (
427
+ "/api/echo/error",
428
+ "POST",
429
+ "echo_error",
430
+ "Always returns an error (for testing)",
431
+ [("message", "string", True, "Error message")],
432
+ ),
433
+ (
434
+ "/api/echo/large",
435
+ "POST",
436
+ "echo_large",
437
+ "Return deterministic text of ~N KB",
438
+ [("size_kb", "integer", True, "Approximate size in kilobytes")],
439
+ ),
440
+ (
441
+ "/api/echo/nested",
442
+ "GET",
443
+ "echo_nested",
444
+ "Return nested JSON to given depth",
445
+ [("depth", "integer", True, "Nesting depth")],
446
+ ),
447
+ ("/api/echo/types", "GET", "echo_types", "Return object with all JSON types", []),
448
+ ("/api/echo/empty", "GET", "echo_empty", "Return empty string", []),
449
+ (
450
+ "/api/echo/multiple",
451
+ "POST",
452
+ "echo_multiple",
453
+ "Return multiple content blocks",
454
+ [("messages", "array", True, "List of message strings")],
455
+ ),
456
+ (
457
+ "/api/echo/schema",
458
+ "POST",
459
+ "echo_schema",
460
+ "Echo back all parameter types",
461
+ [
462
+ ("str_param", "string", True, "String parameter"),
463
+ ("int_param", "integer", True, "Integer parameter"),
464
+ ("float_param", "number", True, "Float parameter"),
465
+ ("bool_param", "boolean", True, "Boolean parameter"),
466
+ ("list_param", "array", True, "List parameter"),
467
+ ("obj_param", "object", True, "Object parameter"),
468
+ ],
469
+ ),
470
+ # --- Weather (GET) ---
471
+ (
472
+ "/api/weather",
473
+ "GET",
474
+ "get_weather",
475
+ "Get weather for a city (always returns fixed 77F sunny)",
476
+ [("city", "string", True, "City name")],
477
+ ),
478
+ ]
479
+
480
+ # Type converters for GET query parameters
481
+ _CONVERTERS = {
482
+ "number": float,
483
+ "integer": int,
484
+ "string": str,
485
+ "boolean": lambda s: s.lower() in ("true", "1", "yes"),
486
+ }
487
+
488
+
489
+ # ---------------------------------------------------------------------------
490
+ # Handler factories
491
+ # ---------------------------------------------------------------------------
492
+
493
+
494
+ def _make_get_handler(mcp, tool_name, params):
495
+ """Create a GET handler that extracts typed query params and calls the MCP tool."""
496
+ param_converters = [(p[0], _CONVERTERS[p[1]]) for p in params]
497
+
498
+ async def handler(request: Request):
499
+ try:
500
+ args = {}
501
+ for name, converter in param_converters:
502
+ val = request.query_params.get(name)
503
+ if val is not None:
504
+ args[name] = converter(val)
505
+ return JSONResponse(await _call_tool(mcp, tool_name, args))
506
+ except Exception as e:
507
+ return JSONResponse({"error": str(e)}, status_code=400)
508
+
509
+ return handler
510
+
511
+
512
+ def _make_post_handler(mcp, tool_name):
513
+ """Create a POST handler that passes the JSON body directly to the MCP tool."""
514
+
515
+ async def handler(request: Request):
516
+ try:
517
+ body = await request.json()
518
+ return JSONResponse(await _call_tool(mcp, tool_name, body))
519
+ except Exception as e:
520
+ return JSONResponse({"error": str(e)}, status_code=400)
521
+
522
+ return handler
523
+
524
+
525
+ async def _call_tool(mcp, tool_name, args):
526
+ """Call an MCP tool and return the parsed JSON result."""
527
+ try:
528
+ result = await mcp.call_tool(tool_name, args)
529
+ if isinstance(result, tuple):
530
+ result = result[0]
531
+ if len(result) == 1:
532
+ try:
533
+ return json.loads(result[0].text)
534
+ except (json.JSONDecodeError, AttributeError):
535
+ return {"result": result[0].text}
536
+ # Multiple content blocks (e.g., echo_multiple)
537
+ return {"results": [r.text for r in result]}
538
+ except Exception as e:
539
+ return {"error": str(e)}
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # OpenAPI spec generation
544
+ # ---------------------------------------------------------------------------
545
+
546
+
547
+ def _build_openapi_spec():
548
+ """Generate OpenAPI 3.0 spec from the endpoint registry."""
549
+ paths = {}
550
+ for path, method, _tool, summary, params in ENDPOINTS:
551
+ method_lower = method.lower()
552
+ operation = {
553
+ "summary": summary,
554
+ "operationId": _tool,
555
+ "responses": {
556
+ "200": {
557
+ "description": "Success",
558
+ "content": {"application/json": {"schema": {"type": "object"}}},
559
+ },
560
+ "400": {
561
+ "description": "Bad request",
562
+ "content": {
563
+ "application/json": {
564
+ "schema": {
565
+ "type": "object",
566
+ "properties": {"error": {"type": "string"}},
567
+ }
568
+ }
569
+ },
570
+ },
571
+ },
572
+ }
573
+
574
+ if method == "GET":
575
+ operation["parameters"] = [
576
+ {
577
+ "name": p[0],
578
+ "in": "query",
579
+ "required": p[2],
580
+ "description": p[3],
581
+ "schema": {"type": p[1]},
582
+ }
583
+ for p in params
584
+ ]
585
+ else: # POST
586
+ if params:
587
+ properties = {p[0]: {"type": p[1], "description": p[3]} for p in params}
588
+ required = [p[0] for p in params if p[2]]
589
+ operation["requestBody"] = {
590
+ "required": True,
591
+ "content": {
592
+ "application/json": {
593
+ "schema": {
594
+ "type": "object",
595
+ "properties": properties,
596
+ "required": required,
597
+ }
598
+ }
599
+ },
600
+ }
601
+
602
+ # Group by tag (first path segment after /api/)
603
+ tag = path.split("/")[2] if len(path.split("/")) > 2 else "other"
604
+ operation["tags"] = [tag]
605
+
606
+ paths.setdefault(path, {})[method_lower] = operation
607
+
608
+ return {
609
+ "openapi": "3.0.3",
610
+ "info": {
611
+ "title": "MCP Test Server API",
612
+ "description": "REST API for 65 deterministic MCP test tools across 8 groups.",
613
+ "version": "1.0.0",
614
+ },
615
+ "paths": paths,
616
+ "components": {
617
+ "securitySchemes": {
618
+ "BearerAuth": {
619
+ "type": "http",
620
+ "scheme": "bearer",
621
+ }
622
+ }
623
+ },
624
+ }
625
+
626
+
627
+ _OPENAPI_SPEC = None
628
+
629
+
630
+ def _get_openapi_spec():
631
+ """Return cached OpenAPI spec (generated once on first access)."""
632
+ global _OPENAPI_SPEC
633
+ if _OPENAPI_SPEC is None:
634
+ _OPENAPI_SPEC = _build_openapi_spec()
635
+ return _OPENAPI_SPEC
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # Public API
640
+ # ---------------------------------------------------------------------------
641
+
642
+
643
+ def create_api_routes(mcp):
644
+ """Create all HTTP API routes + /api-docs for the given FastMCP server."""
645
+ routes = []
646
+ for path, method, tool_name, _summary, params in ENDPOINTS:
647
+ if method == "GET":
648
+ handler = _make_get_handler(mcp, tool_name, params)
649
+ else:
650
+ handler = _make_post_handler(mcp, tool_name)
651
+ routes.append(Route(path, handler, methods=[method]))
652
+
653
+ # OpenAPI docs endpoint
654
+ async def api_docs(request: Request):
655
+ return JSONResponse(_get_openapi_spec())
656
+
657
+ routes.append(Route("/api-docs", api_docs, methods=["GET"]))
658
+
659
+ return routes