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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- 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()
|