systemlink-cli 1.3.1__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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/mcp_server.py ADDED
@@ -0,0 +1,748 @@
1
+ """MCP (Model Context Protocol) server for slcli.
2
+
3
+ Exposes slcli commands as MCP tools for AI assistants (VS Code Copilot,
4
+ Claude Desktop, Cursor, etc.).
5
+
6
+ Run with:
7
+ slcli mcp serve
8
+
9
+ Or directly:
10
+ slcli-mcp
11
+
12
+ Test with the MCP Inspector (SSE transport, no AI client needed):
13
+ slcli mcp serve --transport sse
14
+
15
+ Requires the ``mcp`` package:
16
+ poetry install --with mcp
17
+ # or: pip install "mcp>=1.0"
18
+ """
19
+
20
+ import asyncio
21
+ import json
22
+ import sys
23
+ import urllib.parse
24
+ from typing import Any, Dict, List, Literal, Optional
25
+
26
+ from mcp.server.fastmcp import FastMCP # type: ignore[import-untyped]
27
+
28
+ # Module-level FastMCP instance — also usable directly via `mcp dev slcli/mcp_server.py`
29
+ server = FastMCP("slcli")
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Capabilities resource — read this to answer "what can I do?" without
34
+ # expanding all 24 tool schemas. URI: slcli://capabilities
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _CAPABILITIES = """# slcli MCP capabilities
38
+
39
+ ## Read tools
40
+ - workspace_list(take) — list workspaces; returns id/name/enabled
41
+ - tag_list(path, workspace, take) — list tags with current value; path supports globs
42
+ - tag_get(path) — single tag metadata + current value
43
+ - tag_history(path, take) — historical tag values, most recent first
44
+ - system_list(state, take) — list NI hardware systems; state=CONNECTED|DISCONNECTED
45
+ - system_get(system_id) — full system details
46
+ - asset_list(calibration_status, workspace, model, take) — list assets; model is a substring match
47
+ - asset_get(asset_id) — full asset details
48
+ - asset_calibration_summary() — fleet-wide calibration counts
49
+ - alarm_list(severity, workspace, take) — active alarms; severity=CRITICAL|HIGH|MEDIUM|LOW
50
+ - testmonitor_result_list(status, program_name, serial_number, part_number, operator,
51
+ host_name, workspace, filter, skip, take)
52
+ — status values: PASSED|FAILED|RUNNING|ERRORED|TERMINATED|TIMEDOUT|WAITING|SKIPPED
53
+ — filter is a raw Dynamic LINQ expression, e.g. 'StartedAt > "2026-01-01T00:00:00Z"'
54
+ — use skip+take to paginate: skip=0/100/200...
55
+ — default take=100; max take=1000
56
+ - testmonitor_result_get(result_id) — full test result
57
+ - testmonitor_result_summary(workspace, program_name, serial_number, part_number, host_name, filter)
58
+ — returns total + byStatus counts; uses take=0/returnCount=True so very efficient
59
+ — use this for "how many" questions before fetching full results
60
+ - testmonitor_step_list(result_id, take) — steps for a result
61
+ - routine_list(enabled, api_version, take) — automation routines; v2=tag-event, v1=notebooks
62
+ - routine_get(routine_id, api_version) — full routine details
63
+ - user_list(take, include_disabled, workspace, filter) — users
64
+ - file_list(take, workspace, name_filter) — uploaded files
65
+ - notebook_list(take, workspace) — Jupyter notebooks (SLS + SLE)
66
+
67
+ ## Write tools
68
+ - tag_set_value(path, value, data_type) — write a tag value
69
+ - routine_enable(routine_id, api_version) — enable a routine
70
+ - routine_disable(routine_id, api_version) — disable a routine
71
+ - workspace_create(name) — create a workspace
72
+ - workspace_disable(workspace_id, workspace_name) — disable a workspace
73
+
74
+ ## Usage tips
75
+ - Start with workspace_list to get workspace IDs used by other tools.
76
+ - For "how many" questions use testmonitor_result_summary (no data fetch, just counts).
77
+ - For large datasets: testmonitor_result_list returns up to 100 results by default.
78
+ Paginate with skip=100, skip=200, etc.
79
+ - For station/host queries use host_name filter (substring match against HostName field).
80
+ - Group-by aggregation (by operator, program family, etc.) is not natively supported;
81
+ the model must paginate and aggregate client-side, or use multiple summary calls.
82
+ """
83
+
84
+
85
+ @server.resource("slcli://capabilities")
86
+ def capabilities() -> str:
87
+ """Compact overview of all slcli MCP tools. Read this before asking what the server can do."""
88
+ return _CAPABILITIES
89
+
90
+
91
+ def _esc(v: str) -> str:
92
+ """Escape double-quotes in a filter string value so the LINQ expression stays valid."""
93
+ return v.replace('"', '\\"')
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Tools — function signatures drive the JSON schema automatically.
98
+ # Raise an exception to signal an error; FastMCP converts it to an error response.
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ @server.tool()
103
+ def workspace_list(take: int = 25) -> str:
104
+ """List workspaces (id, name, enabled, default). Call first to get workspace IDs."""
105
+ from .utils import get_base_url, make_api_request
106
+
107
+ url = f"{get_base_url()}/niuser/v1/workspaces?take={take}"
108
+ resp = make_api_request("GET", url)
109
+ return json.dumps(resp.json().get("workspaces", []), default=str)
110
+
111
+
112
+ @server.tool()
113
+ def tag_list(path: str = "", workspace: str = "", take: int = 25) -> str:
114
+ """List tags with current value. Filter by path glob (e.g. 'sensor.*') or workspace ID."""
115
+ from .utils import get_base_url, make_api_request
116
+
117
+ filter_parts: List[str] = []
118
+ if path:
119
+ filter_parts.append(f'path = "{_esc(path)}"')
120
+ if workspace:
121
+ filter_parts.append(f'workspace = "{_esc(workspace)}"')
122
+
123
+ payload: Dict[str, Any] = {
124
+ "filter": " && ".join(filter_parts),
125
+ "take": take,
126
+ "orderBy": "TIMESTAMP",
127
+ "descending": True,
128
+ }
129
+ url = f"{get_base_url()}/nitag/v2/query-tags-with-values"
130
+ resp = make_api_request("POST", url, payload=payload)
131
+ return json.dumps(resp.json().get("tagsWithValues", []), default=str)
132
+
133
+
134
+ @server.tool()
135
+ def tag_get(path: str) -> str:
136
+ """Get a single tag's metadata and current value. Use tag_list to discover paths."""
137
+ from .utils import get_base_url, make_api_request
138
+
139
+ if not path:
140
+ raise ValueError("'path' is required")
141
+
142
+ encoded_path = urllib.parse.quote(path, safe="")
143
+ tag_url = f"{get_base_url()}/nitag/v2/tags/{encoded_path}"
144
+ tag_resp = make_api_request("GET", tag_url)
145
+ tag_data: Dict[str, Any] = tag_resp.json()
146
+
147
+ # Augment with the current value (best-effort; may 404 if tag has no value yet)
148
+ try:
149
+ val_url = f"{get_base_url()}/nitag/v2/tags/{encoded_path}/values/current"
150
+ val_resp = make_api_request("GET", val_url)
151
+ tag_data["currentValue"] = val_resp.json()
152
+ except Exception: # noqa: BLE001
153
+ tag_data["currentValue"] = None
154
+
155
+ return json.dumps(tag_data, default=str)
156
+
157
+
158
+ @server.tool()
159
+ def system_list(
160
+ state: Optional[Literal["CONNECTED", "DISCONNECTED"]] = None,
161
+ take: int = 25,
162
+ ) -> str:
163
+ """List NI hardware systems (id, alias, host, state, OS). Filter by connection state."""
164
+ from .utils import get_base_url, make_api_request
165
+
166
+ payload: Dict[str, Any] = {"take": take}
167
+ if state:
168
+ payload["filter"] = f'connected.data.state = "{state.upper()}"'
169
+
170
+ url = f"{get_base_url()}/nisysmgmt/v1/query-systems"
171
+ resp = make_api_request("POST", url, payload=payload)
172
+ data = resp.json()
173
+
174
+ # The systems API can return either a list (one entry per system) or a
175
+ # dict with a "data" key — normalise to a flat list.
176
+ if isinstance(data, list):
177
+ systems = [item.get("data", item) for item in data if isinstance(item, dict)]
178
+ else:
179
+ systems = data.get("data", data.get("systems", []))
180
+ return json.dumps(systems, default=str)
181
+
182
+
183
+ @server.tool()
184
+ def asset_list(
185
+ calibration_status: Optional[
186
+ Literal["OK", "APPROACHING_RECOMMENDED_DUE_DATE", "PAST_RECOMMENDED_DUE_DATE"]
187
+ ] = None,
188
+ workspace: Optional[str] = None,
189
+ model: Optional[str] = None,
190
+ take: int = 25,
191
+ ) -> str:
192
+ """List assets (id, name, model, serial). Filter by calibration_status, workspace, model."""
193
+ from .utils import get_base_url, make_api_request
194
+
195
+ filter_parts: List[str] = []
196
+ if calibration_status:
197
+ filter_parts.append(f'CalibrationStatus = "{calibration_status}"')
198
+ if workspace:
199
+ filter_parts.append(f'Workspace = "{_esc(workspace)}"')
200
+ if model:
201
+ filter_parts.append(f'ModelName.Contains("{_esc(model)}")')
202
+
203
+ payload: Dict[str, Any] = {
204
+ "skip": 0,
205
+ "take": take,
206
+ "descending": False,
207
+ "returnCount": True,
208
+ }
209
+ if filter_parts:
210
+ payload["filter"] = " and ".join(filter_parts)
211
+
212
+ url = f"{get_base_url()}/niapm/v1/query-assets"
213
+ resp = make_api_request("POST", url, payload=payload)
214
+ return json.dumps(resp.json().get("assets", []), default=str)
215
+
216
+
217
+ @server.tool()
218
+ def testmonitor_result_list(
219
+ status: Optional[
220
+ Literal[
221
+ "PASSED",
222
+ "FAILED",
223
+ "RUNNING",
224
+ "ERRORED",
225
+ "TERMINATED",
226
+ "TIMEDOUT",
227
+ "WAITING",
228
+ "SKIPPED",
229
+ ]
230
+ ] = None,
231
+ program_name: Optional[str] = None,
232
+ serial_number: Optional[str] = None,
233
+ part_number: Optional[str] = None,
234
+ operator: Optional[str] = None,
235
+ host_name: Optional[str] = None,
236
+ workspace: Optional[str] = None,
237
+ filter: Optional[str] = None, # noqa: A002
238
+ skip: int = 0,
239
+ take: int = 100,
240
+ ) -> str:
241
+ """List test results. Filter by status, program_name, serial_number, host_name, etc."""
242
+ from .utils import get_base_url, make_api_request
243
+
244
+ filter_parts: List[str] = []
245
+ if status:
246
+ filter_parts.append(f'Status = "{status.upper()}"')
247
+ if program_name:
248
+ filter_parts.append(f'ProgramName.Contains("{_esc(program_name)}")')
249
+ if serial_number:
250
+ filter_parts.append(f'SerialNumber.Contains("{_esc(serial_number)}")')
251
+ if part_number:
252
+ filter_parts.append(f'PartNumber.Contains("{_esc(part_number)}")')
253
+ if operator:
254
+ filter_parts.append(f'Operator.Contains("{_esc(operator)}")')
255
+ if host_name:
256
+ filter_parts.append(f'HostName.Contains("{_esc(host_name)}")')
257
+ if workspace:
258
+ filter_parts.append(f'Workspace = "{_esc(workspace)}"')
259
+ if filter:
260
+ filter_parts.append(filter)
261
+
262
+ payload: Dict[str, Any] = {"skip": skip, "take": take, "descending": True}
263
+ if filter_parts:
264
+ payload["filter"] = " && ".join(filter_parts)
265
+
266
+ url = f"{get_base_url()}/nitestmonitor/v2/query-results"
267
+ resp = make_api_request("POST", url, payload=payload)
268
+ return json.dumps(resp.json().get("results", []), default=str)
269
+
270
+
271
+ @server.tool()
272
+ def routine_list(
273
+ enabled: Optional[bool] = None,
274
+ api_version: Literal["v1", "v2"] = "v2",
275
+ take: int = 25,
276
+ ) -> str:
277
+ """List automation routines. v2=tag-event/alarm (default), v1=notebook. Filter by enabled."""
278
+ from .utils import get_base_url, make_api_request
279
+
280
+ params: List[str] = [f"take={take}"]
281
+ if enabled is True:
282
+ params.append("Enabled=true")
283
+ elif enabled is False:
284
+ params.append("Enabled=false")
285
+
286
+ url = f"{get_base_url()}/niroutine/{api_version}/routines?{'&'.join(params)}"
287
+ resp = make_api_request("GET", url)
288
+ return json.dumps(resp.json().get("routines", []), default=str)
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Phase 2 tools — get-by-ID and first mutation operations
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def _detect_tag_type(value_str: str) -> str:
297
+ """Infer a SystemLink tag type from the string representation of a value.
298
+
299
+ Returns one of: BOOLEAN, INT, DOUBLE, STRING.
300
+ """
301
+ if value_str.lower() in ("true", "false"):
302
+ return "BOOLEAN"
303
+ if "." not in value_str and "e" not in value_str.lower():
304
+ try:
305
+ int(value_str)
306
+ return "INT"
307
+ except ValueError:
308
+ pass
309
+ try:
310
+ float(value_str)
311
+ return "DOUBLE"
312
+ except ValueError:
313
+ pass
314
+ return "STRING"
315
+
316
+
317
+ @server.tool()
318
+ def tag_set_value(
319
+ path: str,
320
+ value: str,
321
+ data_type: Optional[
322
+ Literal["DOUBLE", "INT", "STRING", "BOOLEAN", "U_INT64", "DATE_TIME"]
323
+ ] = None,
324
+ ) -> str:
325
+ """Write a value to a tag by exact path. Auto-detects type if data_type is omitted."""
326
+ from .utils import get_base_url, make_api_request
327
+
328
+ if not path:
329
+ raise ValueError("'path' is required")
330
+ if value is None:
331
+ raise ValueError("'value' is required")
332
+
333
+ encoded_path = urllib.parse.quote(path, safe="")
334
+
335
+ # Resolve the type to use
336
+ if data_type:
337
+ tag_type: str = data_type
338
+ else:
339
+ # Fetch tag metadata to use the registered type
340
+ try:
341
+ meta_resp = make_api_request("GET", f"{get_base_url()}/nitag/v2/tags/{encoded_path}")
342
+ tag_type = meta_resp.json().get("type") or _detect_tag_type(value)
343
+ except Exception: # noqa: BLE001
344
+ tag_type = _detect_tag_type(value)
345
+
346
+ # Normalise the value string for the API
347
+ api_value: str = value
348
+ if tag_type == "BOOLEAN":
349
+ api_value = "true" if value.lower() == "true" else "false"
350
+ # U_INT64 and DATE_TIME: pass value through as-is
351
+
352
+ payload: Dict[str, Any] = {"value": {"value": api_value, "type": tag_type}}
353
+ url = f"{get_base_url()}/nitag/v2/tags/{encoded_path}/values/current"
354
+ make_api_request("PUT", url, payload=payload)
355
+
356
+ return json.dumps({"path": path, "value": api_value, "type": tag_type})
357
+
358
+
359
+ @server.tool()
360
+ def system_get(system_id: str) -> str:
361
+ """Get full details of a single system by ID. Use system_list to discover IDs."""
362
+ from .utils import get_base_url, make_api_request
363
+
364
+ if not system_id:
365
+ raise ValueError("'system_id' is required")
366
+
367
+ url = f"{get_base_url()}/nisysmgmt/v1/systems?id={urllib.parse.quote(system_id, safe='')}"
368
+ resp = make_api_request("GET", url)
369
+ data = resp.json()
370
+
371
+ # Response is a list of wrapped entries — take the first match
372
+ items: List[Any] = data if isinstance(data, list) else data.get("data", [])
373
+ if not items:
374
+ raise ValueError(f"System '{system_id}' not found")
375
+
376
+ first = items[0]
377
+ system_data: Any = first.get("data", first) if isinstance(first, dict) else first
378
+ return json.dumps(system_data, default=str)
379
+
380
+
381
+ @server.tool()
382
+ def asset_get(asset_id: str) -> str:
383
+ """Get full asset details including calibration history. Use asset_list to find IDs."""
384
+ from .utils import get_base_url, make_api_request
385
+
386
+ if not asset_id:
387
+ raise ValueError("'asset_id' is required")
388
+
389
+ url = f"{get_base_url()}/niapm/v1/assets/{urllib.parse.quote(asset_id, safe='')}"
390
+ resp = make_api_request("GET", url)
391
+ return json.dumps(resp.json(), default=str)
392
+
393
+
394
+ @server.tool()
395
+ def testmonitor_result_get(result_id: str) -> str:
396
+ """Get full test result details. Use testmonitor_result_list to find IDs."""
397
+ from .utils import get_base_url, make_api_request
398
+
399
+ if not result_id:
400
+ raise ValueError("'result_id' is required")
401
+
402
+ url = f"{get_base_url()}/nitestmonitor/v2/results/{urllib.parse.quote(result_id, safe='')}"
403
+ resp = make_api_request("GET", url)
404
+ return json.dumps(resp.json(), default=str)
405
+
406
+
407
+ @server.tool()
408
+ def routine_get(routine_id: str, api_version: Literal["v1", "v2"] = "v2") -> str:
409
+ """Get full details of a single routine by ID. Use routine_list to discover IDs."""
410
+ from .utils import get_base_url, make_api_request
411
+
412
+ if not routine_id:
413
+ raise ValueError("'routine_id' is required")
414
+
415
+ url = f"{get_base_url()}/niroutine/{api_version}/routines/{urllib.parse.quote(routine_id, safe='')}"
416
+ resp = make_api_request("GET", url)
417
+ return json.dumps(resp.json(), default=str)
418
+
419
+
420
+ @server.tool()
421
+ def routine_enable(routine_id: str, api_version: Literal["v1", "v2"] = "v2") -> str:
422
+ """Enable an automation routine by ID."""
423
+ from .utils import get_base_url, make_api_request
424
+
425
+ if not routine_id:
426
+ raise ValueError("'routine_id' is required")
427
+
428
+ url = f"{get_base_url()}/niroutine/{api_version}/routines/{urllib.parse.quote(routine_id, safe='')}"
429
+ make_api_request("PATCH", url, payload={"enabled": True})
430
+ return json.dumps({"id": routine_id, "enabled": True})
431
+
432
+
433
+ @server.tool()
434
+ def routine_disable(routine_id: str, api_version: Literal["v1", "v2"] = "v2") -> str:
435
+ """Disable an automation routine by ID."""
436
+ from .utils import get_base_url, make_api_request
437
+
438
+ if not routine_id:
439
+ raise ValueError("'routine_id' is required")
440
+
441
+ url = f"{get_base_url()}/niroutine/{api_version}/routines/{urllib.parse.quote(routine_id, safe='')}"
442
+ make_api_request("PATCH", url, payload={"enabled": False})
443
+ return json.dumps({"id": routine_id, "enabled": False})
444
+
445
+
446
+ # ---------------------------------------------------------------------------
447
+ # Phase 3 tools — broader coverage
448
+ # ---------------------------------------------------------------------------
449
+
450
+
451
+ @server.tool()
452
+ def user_list(
453
+ take: int = 25,
454
+ include_disabled: bool = False,
455
+ workspace: Optional[str] = None,
456
+ filter: Optional[str] = None, # noqa: A002
457
+ ) -> str:
458
+ """List users (id, firstName, lastName, email, status). Set include_disabled=True for all."""
459
+ from .utils import get_base_url, make_api_request
460
+
461
+ filter_parts: List[str] = []
462
+ if not include_disabled:
463
+ filter_parts.append('status = "active"')
464
+ if workspace:
465
+ filter_parts.append(f'workspace = "{workspace}"')
466
+ if filter:
467
+ filter_parts.append(filter)
468
+
469
+ payload: Dict[str, Any] = {
470
+ "take": take,
471
+ "sortby": "firstName",
472
+ "order": "ascending",
473
+ }
474
+ if filter_parts:
475
+ payload["filter"] = " and ".join(filter_parts)
476
+
477
+ url = f"{get_base_url()}/niuser/v1/users/query"
478
+ resp = make_api_request("POST", url, payload=payload)
479
+ return json.dumps(resp.json().get("users", []), default=str)
480
+
481
+
482
+ @server.tool()
483
+ def testmonitor_step_list(
484
+ result_id: str,
485
+ take: int = 100,
486
+ ) -> str:
487
+ """List steps for a result ID. Use testmonitor_result_list to find result IDs."""
488
+ from .utils import get_base_url, make_api_request
489
+
490
+ if not result_id:
491
+ raise ValueError("'result_id' is required")
492
+
493
+ payload: Dict[str, Any] = {
494
+ "filter": "resultId == @0",
495
+ "substitutions": [result_id],
496
+ "take": take,
497
+ }
498
+ url = f"{get_base_url()}/nitestmonitor/v2/query-steps"
499
+ resp = make_api_request("POST", url, payload=payload)
500
+ return json.dumps(resp.json().get("steps", []), default=str)
501
+
502
+
503
+ @server.tool()
504
+ def file_list(
505
+ take: int = 25,
506
+ workspace: Optional[str] = None,
507
+ name_filter: Optional[str] = None,
508
+ ) -> str:
509
+ """List files (id, name, size, created). Filter by workspace or name_filter."""
510
+ from .utils import get_base_url, make_api_request
511
+
512
+ filter_parts: List[str] = []
513
+ if workspace:
514
+ filter_parts.append(f'workspaceId:("{workspace}")')
515
+ if name_filter:
516
+ filter_parts.append(f'(name:("*{name_filter}*") OR extension:("*{name_filter}*"))')
517
+
518
+ payload: Dict[str, Any] = {
519
+ "take": take,
520
+ "orderBy": "updated",
521
+ "orderByDescending": True,
522
+ }
523
+ if filter_parts:
524
+ payload["filter"] = " AND ".join(filter_parts)
525
+
526
+ url = f"{get_base_url()}/nifile/v1/service-groups/Default/search-files"
527
+ resp = make_api_request("POST", url, payload=payload)
528
+ data = resp.json()
529
+ # API returns either a list directly or a dict with a "files" key
530
+ files: Any = data if isinstance(data, list) else data.get("files", data.get("data", []))
531
+ return json.dumps(files, default=str)
532
+
533
+
534
+ @server.tool()
535
+ def asset_calibration_summary() -> str:
536
+ """Fleet-wide calibration counts: total, approaching due, past due, out for calibration."""
537
+ from .utils import get_base_url, make_api_request
538
+
539
+ url = f"{get_base_url()}/niapm/v1/asset-summary"
540
+ resp = make_api_request("GET", url)
541
+ return json.dumps(resp.json(), default=str)
542
+
543
+
544
+ @server.tool()
545
+ def testmonitor_result_summary(
546
+ workspace: Optional[str] = None,
547
+ program_name: Optional[str] = None,
548
+ serial_number: Optional[str] = None,
549
+ part_number: Optional[str] = None,
550
+ host_name: Optional[str] = None,
551
+ filter: Optional[str] = None, # noqa: A002
552
+ ) -> str:
553
+ """Count test results by status, no data fetch. Filter by program, serial, part, host."""
554
+ from .utils import get_base_url, make_api_request
555
+
556
+ # Build base filter parts (applied to every per-status query)
557
+ base_filter_parts: List[str] = []
558
+ base_subs: List[Any] = []
559
+ if workspace:
560
+ idx = len(base_subs)
561
+ base_filter_parts.append(f"Workspace == @{idx}")
562
+ base_subs.append(workspace)
563
+ if program_name:
564
+ idx = len(base_subs)
565
+ base_filter_parts.append(f"ProgramName.Contains(@{idx})")
566
+ base_subs.append(program_name)
567
+ if serial_number:
568
+ idx = len(base_subs)
569
+ base_filter_parts.append(f"SerialNumber.Contains(@{idx})")
570
+ base_subs.append(serial_number)
571
+ if part_number:
572
+ idx = len(base_subs)
573
+ base_filter_parts.append(f"PartNumber.Contains(@{idx})")
574
+ base_subs.append(part_number)
575
+ if host_name:
576
+ idx = len(base_subs)
577
+ base_filter_parts.append(f"HostName.Contains(@{idx})")
578
+ base_subs.append(host_name)
579
+ if filter:
580
+ base_filter_parts.append(filter)
581
+
582
+ url = f"{get_base_url()}/nitestmonitor/v2/query-results"
583
+
584
+ def _count(extra_filter: Optional[str], extra_subs: List[Any]) -> int:
585
+ """Return totalCount for a filtered query (take=0, returnCount=True)."""
586
+ parts = list(base_filter_parts)
587
+ subs = list(base_subs)
588
+ if extra_filter:
589
+ parts.append(extra_filter)
590
+ subs.extend(extra_subs)
591
+ payload: Dict[str, Any] = {"take": 0, "returnCount": True}
592
+ if parts:
593
+ payload["filter"] = " && ".join(parts)
594
+ if subs:
595
+ payload["substitutions"] = subs
596
+ try:
597
+ r = make_api_request("POST", url, payload=payload)
598
+ return int(r.json().get("totalCount", 0))
599
+ except Exception: # noqa: BLE001
600
+ return -1
601
+
602
+ total = _count(None, [])
603
+
604
+ status_types = ["PASSED", "FAILED", "RUNNING", "ERRORED", "TERMINATED", "TIMEDOUT"]
605
+ counts: Dict[str, int] = {}
606
+ for status in status_types:
607
+ idx = len(base_subs)
608
+ counts[status] = _count(f"status.statusType == @{idx}", [status])
609
+
610
+ return json.dumps({"total": total, "byStatus": counts})
611
+
612
+
613
+ @server.tool()
614
+ def notebook_list(
615
+ take: int = 25,
616
+ workspace: Optional[str] = None,
617
+ ) -> str:
618
+ """List Jupyter notebooks (id, name, description). Works on both SLS and SLE platforms."""
619
+ from .platform import PLATFORM_SLS, get_platform
620
+ from .utils import get_base_url, make_api_request
621
+
622
+ is_sls = get_platform() == PLATFORM_SLS
623
+ if is_sls:
624
+ base = f"{get_base_url()}/ninbexec/v2"
625
+ query_url = f"{base}/query-notebooks"
626
+ else:
627
+ base = f"{get_base_url()}/ninotebook/v1"
628
+ query_url = f"{base}/notebook/query"
629
+
630
+ payload: Dict[str, Any] = {"take": take}
631
+ if workspace:
632
+ payload["filter"] = f'workspace = "{workspace}"'
633
+
634
+ resp = make_api_request("POST", query_url, payload=payload)
635
+ return json.dumps(resp.json().get("notebooks", []), default=str)
636
+
637
+
638
+ # ---------------------------------------------------------------------------
639
+ # Phase 4 tools — alarms, tag history, workspace mutations
640
+ # ---------------------------------------------------------------------------
641
+
642
+
643
+ @server.tool()
644
+ def alarm_list(
645
+ severity: Optional[Literal["CRITICAL", "HIGH", "MEDIUM", "LOW"]] = None,
646
+ workspace: Optional[str] = None,
647
+ take: int = 25,
648
+ ) -> str:
649
+ """List active alarm instances. Filter by severity (CRITICAL/HIGH/MEDIUM/LOW) or workspace."""
650
+ from .utils import get_base_url, make_api_request
651
+
652
+ params: List[str] = [f"take={take}"]
653
+ if severity:
654
+ params.append(f"severity={severity}")
655
+ if workspace:
656
+ params.append(f"workspace={urllib.parse.quote(workspace, safe='')}")
657
+
658
+ url = f"{get_base_url()}/nialarm/v1/active-instances?{'&'.join(params)}"
659
+ resp = make_api_request("GET", url)
660
+ data = resp.json()
661
+ # API may return either a list or a dict with an "alarmInstances" / "instances" key
662
+ if isinstance(data, list):
663
+ return json.dumps(data, default=str)
664
+ return json.dumps(
665
+ data.get("alarmInstances", data.get("instances", data.get("items", []))),
666
+ default=str,
667
+ )
668
+
669
+
670
+ @server.tool()
671
+ def tag_history(path: str, take: int = 25) -> str:
672
+ """Get historical tag values by exact path, most recent first. Use tag_get for current value."""
673
+ from .utils import get_base_url, make_api_request
674
+
675
+ if not path:
676
+ raise ValueError("'path' is required")
677
+
678
+ encoded_path = urllib.parse.quote(path, safe="")
679
+ url = f"{get_base_url()}/nitag/v2/tags/{encoded_path}/values/history?take={take}"
680
+ resp = make_api_request("GET", url)
681
+ data = resp.json()
682
+ # API returns {"tagsWithAggregates": [...]} or a list or a "values" key
683
+ if isinstance(data, list):
684
+ return json.dumps(data, default=str)
685
+ return json.dumps(
686
+ data.get("values", data.get("tagsWithAggregates", [])),
687
+ default=str,
688
+ )
689
+
690
+
691
+ @server.tool()
692
+ def workspace_create(name: str) -> str:
693
+ """Create a new workspace by name (enabled by default). Returns the object with generated ID."""
694
+ from .utils import get_base_url, make_api_request
695
+
696
+ if not name:
697
+ raise ValueError("'name' is required")
698
+
699
+ url = f"{get_base_url()}/niuser/v1/workspaces"
700
+ payload: Dict[str, Any] = {"name": name, "enabled": True}
701
+ resp = make_api_request("POST", url, payload=payload)
702
+ return json.dumps(resp.json(), default=str)
703
+
704
+
705
+ @server.tool()
706
+ def workspace_disable(workspace_id: str, workspace_name: str) -> str:
707
+ """Disable a workspace (data preserved). Both id and name required; use workspace_list first."""
708
+ from .utils import get_base_url, make_api_request
709
+
710
+ if not workspace_id:
711
+ raise ValueError("'workspace_id' is required")
712
+ if not workspace_name:
713
+ raise ValueError("'workspace_name' is required")
714
+
715
+ url = f"{get_base_url()}/niuser/v1/workspaces/{urllib.parse.quote(workspace_id, safe='')}"
716
+ payload: Dict[str, Any] = {"name": workspace_name, "enabled": False}
717
+ make_api_request("PUT", url, payload=payload)
718
+ return json.dumps({"id": workspace_id, "name": workspace_name, "enabled": False})
719
+
720
+
721
+ # ---------------------------------------------------------------------------
722
+ # Entry point
723
+ # ---------------------------------------------------------------------------
724
+
725
+
726
+ async def _run() -> None:
727
+ """Run the MCP server connected to stdio."""
728
+ print("slcli MCP server ready — waiting for client", file=sys.stderr, flush=True)
729
+ await server.run_stdio_async()
730
+
731
+
732
+ def main() -> None:
733
+ """Entry point for the ``slcli-mcp`` executable.
734
+
735
+ Starts the stdio MCP server. Clients (VS Code Copilot, Claude Desktop,
736
+ Cursor) should configure this as:
737
+ command: slcli
738
+ args: [mcp, serve]
739
+ """
740
+ try:
741
+ asyncio.run(_run())
742
+ except KeyboardInterrupt:
743
+ print("slcli MCP server stopped", file=sys.stderr, flush=True)
744
+ sys.exit(0)
745
+
746
+
747
+ if __name__ == "__main__":
748
+ main()