mcp-stata 1.6.2__py3-none-any.whl → 1.7.3__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.

Potentially problematic release.


This version of mcp-stata might be problematic. Click here for more details.

mcp_stata/ui_http.py CHANGED
@@ -27,6 +27,7 @@ class ViewHandle:
27
27
  view_id: str
28
28
  dataset_id: str
29
29
  frame: str
30
+ filter_expr: str
30
31
  obs_indices: list[int]
31
32
  filtered_n: int
32
33
  created_at: float
@@ -86,7 +87,7 @@ class UIChannelManager:
86
87
  return UIChannelInfo(base_url=base_url, token=self._token or "", expires_at=self._expires_at)
87
88
 
88
89
  def capabilities(self) -> dict[str, bool]:
89
- return {"dataBrowser": True, "filtering": True}
90
+ return {"dataBrowser": True, "filtering": True, "sorting": True}
90
91
 
91
92
  def current_dataset_id(self) -> str:
92
93
  with self._lock:
@@ -138,6 +139,7 @@ class UIChannelManager:
138
139
  view_id=view_id,
139
140
  dataset_id=current_id,
140
141
  frame=frame,
142
+ filter_expr=filter_expr,
141
143
  obs_indices=obs_indices,
142
144
  filtered_n=len(obs_indices),
143
145
  created_at=now,
@@ -290,11 +292,16 @@ class UIChannelManager:
290
292
  body = self._read_json()
291
293
  if body is None:
292
294
  return
295
+ # Debug logging to diagnose limit parameter issues
296
+ import sys
297
+ print(f"[DEBUG] /v1/page request body: {body}", file=sys.stderr, flush=True)
298
+ print(f"[DEBUG] limit value: {body.get('limit')!r} (type: {type(body.get('limit')).__name__})", file=sys.stderr, flush=True)
293
299
  try:
294
300
  resp = handle_page_request(manager, body, view_id=None)
295
301
  self._send_json(200, resp)
296
302
  return
297
303
  except HTTPError as e:
304
+ print(f"[DEBUG] HTTPError: {e.code} - {e.message}", file=sys.stderr, flush=True)
298
305
  self._error(e.status, e.code, e.message, stata_rc=e.stata_rc)
299
306
  return
300
307
  except Exception as e:
@@ -347,11 +354,16 @@ class UIChannelManager:
347
354
  body = self._read_json()
348
355
  if body is None:
349
356
  return
357
+ # Debug logging to diagnose limit parameter issues
358
+ import sys
359
+ print(f"[DEBUG] /v1/views/{view_id}/page request body: {body}", file=sys.stderr, flush=True)
360
+ print(f"[DEBUG] limit value: {body.get('limit')!r} (type: {type(body.get('limit')).__name__})", file=sys.stderr, flush=True)
350
361
  try:
351
362
  resp = handle_page_request(manager, body, view_id=view_id)
352
363
  self._send_json(200, resp)
353
364
  return
354
365
  except HTTPError as e:
366
+ print(f"[DEBUG] HTTPError: {e.code} - {e.message}", file=sys.stderr, flush=True)
355
367
  self._error(e.status, e.code, e.message, stata_rc=e.stata_rc)
356
368
  return
357
369
  except Exception as e:
@@ -455,16 +467,42 @@ def handle_page_request(manager: UIChannelManager, body: dict[str, Any], *, view
455
467
  dataset_id = view.dataset_id
456
468
  frame = view.frame
457
469
 
458
- offset = int(body.get("offset", 0) or 0)
459
- limit = int(body.get("limit", 0) or 0)
470
+ # Parse offset (default 0 is valid since offset >= 0)
471
+ try:
472
+ offset = int(body.get("offset") or 0)
473
+ except (ValueError, TypeError) as e:
474
+ raise HTTPError(400, "invalid_request", f"offset must be a valid integer, got: {body.get('offset')!r}")
475
+
476
+ # Parse limit (no default - must be explicitly provided)
477
+ limit_raw = body.get("limit")
478
+ if limit_raw is None:
479
+ raise HTTPError(400, "invalid_request", "limit is required")
480
+ try:
481
+ limit = int(limit_raw)
482
+ except (ValueError, TypeError) as e:
483
+ raise HTTPError(400, "invalid_request", f"limit must be a valid integer, got: {limit_raw!r}")
484
+
460
485
  vars_req = body.get("vars", [])
461
486
  include_obs_no = bool(body.get("includeObsNo", False))
462
- max_chars_req = int(body.get("maxChars", max_chars) or max_chars)
487
+
488
+ # Parse sortBy parameter
489
+ sort_by = body.get("sortBy", [])
490
+ if sort_by is not None and not isinstance(sort_by, list):
491
+ raise HTTPError(400, "invalid_request", f"sortBy must be an array, got: {type(sort_by).__name__}")
492
+ if sort_by and not all(isinstance(s, str) for s in sort_by):
493
+ raise HTTPError(400, "invalid_request", "sortBy must be an array of strings")
494
+
495
+ # Parse maxChars
496
+ max_chars_raw = body.get("maxChars", max_chars)
497
+ try:
498
+ max_chars_req = int(max_chars_raw or max_chars)
499
+ except (ValueError, TypeError) as e:
500
+ raise HTTPError(400, "invalid_request", f"maxChars must be a valid integer, got: {max_chars_raw!r}")
463
501
 
464
502
  if offset < 0:
465
- raise HTTPError(400, "invalid_request", "offset must be >= 0")
503
+ raise HTTPError(400, "invalid_request", f"offset must be >= 0, got: {offset}")
466
504
  if limit <= 0:
467
- raise HTTPError(400, "invalid_request", "limit must be > 0")
505
+ raise HTTPError(400, "invalid_request", f"limit must be > 0, got: {limit}")
468
506
  if limit > max_limit:
469
507
  raise HTTPError(400, "request_too_large", f"limit must be <= {max_limit}")
470
508
  if max_chars_req <= 0:
@@ -490,6 +528,20 @@ def handle_page_request(manager: UIChannelManager, body: dict[str, Any], *, view
490
528
  filtered_n = view.filtered_n
491
529
 
492
530
  try:
531
+ # Apply sorting if requested
532
+ if sort_by:
533
+ try:
534
+ manager._client.apply_sort(sort_by)
535
+ # If sorting with a filtered view, re-compute indices after sort
536
+ if view_id is not None:
537
+ assert view is not None
538
+ obs_indices = manager._client.compute_view_indices(view.filter_expr)
539
+ filtered_n = len(obs_indices)
540
+ except ValueError as e:
541
+ raise HTTPError(400, "invalid_request", f"Invalid sort specification: {e}")
542
+ except RuntimeError as e:
543
+ raise HTTPError(500, "internal_error", f"Failed to apply sort: {e}")
544
+
493
545
  dataset_state = manager._client.get_dataset_state()
494
546
  page = manager._client.get_page(
495
547
  offset=offset,
@@ -499,6 +551,9 @@ def handle_page_request(manager: UIChannelManager, body: dict[str, Any], *, view
499
551
  max_chars=max_chars_req,
500
552
  obs_indices=obs_indices,
501
553
  )
554
+ except HTTPError:
555
+ # Re-raise HTTPError exceptions as-is
556
+ raise
502
557
  except RuntimeError as e:
503
558
  # StataClient uses RuntimeError("No data in memory") for empty dataset.
504
559
  msg = str(e) or "No data in memory"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-stata
3
- Version: 1.6.2
3
+ Version: 1.7.3
4
4
  Summary: A lightweight Model Context Protocol (MCP) server for Stata. Execute commands, inspect data, retrieve stored results (`r()`/`e()`), and view graphs in your chat interface. Built for economists who want to integrate LLM assistance into their Stata workflow.
5
5
  Project-URL: Homepage, https://github.com/tmonk/mcp-stata
6
6
  Project-URL: Repository, https://github.com/tmonk/mcp-stata
@@ -259,6 +259,14 @@ VS Code documents `.vscode/mcp.json` and the `servers` schema, including `type`
259
259
  * `get_stored_results()`: Get `r()` and `e()` scalars/macros as JSON.
260
260
  * `get_variable_list()`: JSON list of variables and labels.
261
261
 
262
+ ### Cancellation
263
+
264
+ - Clients may cancel an in-flight request by sending the MCP notification `notifications/cancelled` with `params.requestId` set to the original tool call ID.
265
+ - Client guidance:
266
+ 1. Pass a `_meta.progressToken` when invoking the tool if you want progress updates (optional).
267
+ 2. If you need to cancel, send `notifications/cancelled` with the same requestId. You may also stop tailing the log file path once you receive cancellation confirmation (the tool call will return an error indicating cancellation).
268
+ 3. Be prepared for partial output in the log file; cancellation is best-effort and depends on Stata surfacing `BreakError`.
269
+
262
270
  Resources exposed for MCP clients:
263
271
 
264
272
  * `stata://data/summary` → `summarize`
@@ -292,7 +300,8 @@ Call the MCP tool `get_ui_channel()` and parse the JSON:
292
300
  "expiresAt": 1730000000,
293
301
  "capabilities": {
294
302
  "dataBrowser": true,
295
- "filtering": true
303
+ "filtering": true,
304
+ "sorting": true
296
305
  }
297
306
  }
298
307
  ```
@@ -333,10 +342,67 @@ curl -sS \
333
342
  "$BASE_URL/v1/page"
334
343
  ```
335
344
 
345
+ #### Sorting
346
+
347
+ The `/v1/page` and `/v1/views/:viewId/page` endpoints support sorting via the optional `sortBy` parameter:
348
+
349
+ ```bash
350
+ # Sort by price ascending
351
+ curl -sS \
352
+ -H "Authorization: Bearer $TOKEN" \
353
+ -H "Content-Type: application/json" \
354
+ -d '{"datasetId":"...","offset":0,"limit":50,"vars":["price","mpg"],"sortBy":["price"]}' \
355
+ "$BASE_URL/v1/page"
356
+
357
+ # Sort by price descending
358
+ curl -sS \
359
+ -H "Authorization: Bearer $TOKEN" \
360
+ -H "Content-Type: application/json" \
361
+ -d '{"datasetId":"...","offset":0,"limit":50,"vars":["price","mpg"],"sortBy":["-price"]}' \
362
+ "$BASE_URL/v1/page"
363
+
364
+ # Multi-variable sort: foreign ascending, then price descending
365
+ curl -sS \
366
+ -H "Authorization: Bearer $TOKEN" \
367
+ -H "Content-Type: application/json" \
368
+ -d '{"datasetId":"...","offset":0,"limit":50,"vars":["foreign","price","mpg"],"sortBy":["foreign","-price"]}' \
369
+ "$BASE_URL/v1/page"
370
+ ```
371
+
372
+ **Sort specification format:**
373
+ - `sortBy` is an array of strings (variable names with optional prefix)
374
+ - No prefix or `+` prefix = ascending order (e.g., `"price"` or `"+price"`)
375
+ - `-` prefix = descending order (e.g., `"-price"`)
376
+ - Multiple variables are supported for multi-level sorting
377
+ - Uses Stata's `gsort` command internally
378
+
379
+ **Sorting with filtered views:**
380
+ - Sorting is fully supported with filtered views
381
+ - The sort is applied to the entire dataset, then filtered indices are re-computed
382
+ - Example: Filter for `price < 5000`, then sort descending by price
383
+
384
+ ```bash
385
+ # Create a filtered view
386
+ curl -sS \
387
+ -H "Authorization: Bearer $TOKEN" \
388
+ -H "Content-Type: application/json" \
389
+ -d '{"datasetId":"...","frame":"default","filterExpr":"price < 5000"}' \
390
+ "$BASE_URL/v1/views"
391
+ # Returns: {"view": {"id": "view_abc123", "filteredN": 37}}
392
+
393
+ # Get sorted page from filtered view
394
+ curl -sS \
395
+ -H "Authorization: Bearer $TOKEN" \
396
+ -H "Content-Type: application/json" \
397
+ -d '{"offset":0,"limit":50,"vars":["price","mpg"],"sortBy":["-price"]}' \
398
+ "$BASE_URL/v1/views/view_abc123/page"
399
+ ```
400
+
336
401
  Notes:
337
402
 
338
403
  - `datasetId` is used for cache invalidation. If the dataset changes due to running Stata commands, the server will report a new dataset id and view handles become invalid.
339
404
  - Filter expressions are evaluated in Python using values read from Stata via `sfi.Data.get`. Use boolean operators like `==`, `!=`, `<`, `>`, and `and`/`or` (Stata-style `&`/`|` are also accepted).
405
+ - Sorting modifies the dataset order in memory using `gsort`. When combined with views, the filtered indices are automatically re-computed after sorting.
340
406
 
341
407
  ## License
342
408
 
@@ -0,0 +1,14 @@
1
+ mcp_stata/__init__.py,sha256=kJKKRn7lGuVCuS2-GaN5VoVcvnxtNlfuswW_VOlYqwg,98
2
+ mcp_stata/discovery.py,sha256=jQN9uvBNHF_hCCU9k6BDtSdDxiUVpvXcOJwpWYwo55c,17430
3
+ mcp_stata/graph_detector.py,sha256=-dJIU1Dq_c1eQSk4eegUi0gU2N-tFqjFGM0tE1E32KM,16066
4
+ mcp_stata/models.py,sha256=QETpYKO3yILy_L6mhouVEanvUIvu4ww_CAAFuiP2YdM,1201
5
+ mcp_stata/server.py,sha256=PV8ragGMeHT72zgVx5DJp3vt8CPqT8iwdvJ8GXSctds,15989
6
+ mcp_stata/stata_client.py,sha256=06cA5K4vwXc_kNCwIifUL8eSSYsIYtM5zArhJcLcUlo,101267
7
+ mcp_stata/streaming_io.py,sha256=GVaXgTtxx8YLY6RWqdTcO2M3QSqxLsefqkmnlNO1nTI,6974
8
+ mcp_stata/ui_http.py,sha256=w1tYxNuwuhkjyfWHxUnpd1DcVBaakjPkEnWr-Fo1lWo,24193
9
+ mcp_stata/smcl/smcl2html.py,sha256=wi91mOMeV9MCmHtNr0toihNbaiDCNZ_NP6a6xEAzWLM,2624
10
+ mcp_stata-1.7.3.dist-info/METADATA,sha256=cOSWlFgl296f5UhvozBLCPpe7tWS7kcVWGBNlnqO2Hs,15951
11
+ mcp_stata-1.7.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ mcp_stata-1.7.3.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
13
+ mcp_stata-1.7.3.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
14
+ mcp_stata-1.7.3.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- mcp_stata/__init__.py,sha256=kJKKRn7lGuVCuS2-GaN5VoVcvnxtNlfuswW_VOlYqwg,98
2
- mcp_stata/discovery.py,sha256=K8SK4oEnLESxnJWcBsLK76s85yNVdbLlfOql_Rf94B4,8390
3
- mcp_stata/graph_detector.py,sha256=-dJIU1Dq_c1eQSk4eegUi0gU2N-tFqjFGM0tE1E32KM,16066
4
- mcp_stata/models.py,sha256=QETpYKO3yILy_L6mhouVEanvUIvu4ww_CAAFuiP2YdM,1201
5
- mcp_stata/server.py,sha256=9_0i8xux11NpZ6yJwOniqQo-Cs3ph2mrkr0gwZ9GZ1I,15671
6
- mcp_stata/stata_client.py,sha256=floQMie0j7xI1PUeVu3mbmv4_eztw2CLjviZ7fdmClo,90673
7
- mcp_stata/streaming_io.py,sha256=GVaXgTtxx8YLY6RWqdTcO2M3QSqxLsefqkmnlNO1nTI,6974
8
- mcp_stata/ui_http.py,sha256=UhIPzCpBelVpZWUgwBnGpOCAEpG2vFVvX-TxRuyhkR0,21210
9
- mcp_stata/smcl/smcl2html.py,sha256=wi91mOMeV9MCmHtNr0toihNbaiDCNZ_NP6a6xEAzWLM,2624
10
- mcp_stata-1.6.2.dist-info/METADATA,sha256=buX6iqLIsnmtBlVueebezon9o3OYxL6I0DYmmwHOV0I,13141
11
- mcp_stata-1.6.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- mcp_stata-1.6.2.dist-info/entry_points.txt,sha256=TcOgrtiTL4LGFEDb1pCrQWA-fUZvIujDOvQ-bWFh5Z8,52
13
- mcp_stata-1.6.2.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
14
- mcp_stata-1.6.2.dist-info/RECORD,,