mwps-client 0.1.7__tar.gz

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,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: mwps-client
3
+ Version: 0.1.7
4
+ Summary: Official mwps client library: single entry point MwpsClient, one typed method per mwps command.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # mwps-client
9
+
10
+ Official Python client for the `mwps` server. `MwpsClient` is the single
11
+ entry point: it hides transport selection, TLS material, proxy routing,
12
+ request correlation and error mapping behind one typed async method per
13
+ `mwps` command (see `mwps_client/mwps_client/client.py`).
14
+
15
+ ## Installation
16
+
17
+ ```
18
+ pip install mwps-client
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import asyncio
25
+ from mwps_client import MwpsClient
26
+
27
+ async def main():
28
+ client = MwpsClient(base_url="https://<host>:8015", ca="/path/to/ca.crt")
29
+ result = await client.invoke_tool(tool_name="list_servers")
30
+ print(result)
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ ## Version lockstep
36
+
37
+ `mwps-client` carries no version of its own: its published version is
38
+ always identical to the `mwps` server version it was generated against
39
+ (C-068, ClientServerVersionLockstep). Both packages read the same
40
+ repository-root `VERSION` file — `mwps_client/VERSION` is a symlink to
41
+ `../VERSION` — so a client published from a given deploy always matches
42
+ the server it talks to. See `pipeline/README.md` for the release process
43
+ that keeps the two in lockstep.
@@ -0,0 +1,36 @@
1
+ # mwps-client
2
+
3
+ Official Python client for the `mwps` server. `MwpsClient` is the single
4
+ entry point: it hides transport selection, TLS material, proxy routing,
5
+ request correlation and error mapping behind one typed async method per
6
+ `mwps` command (see `mwps_client/mwps_client/client.py`).
7
+
8
+ ## Installation
9
+
10
+ ```
11
+ pip install mwps-client
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```python
17
+ import asyncio
18
+ from mwps_client import MwpsClient
19
+
20
+ async def main():
21
+ client = MwpsClient(base_url="https://<host>:8015", ca="/path/to/ca.crt")
22
+ result = await client.invoke_tool(tool_name="list_servers")
23
+ print(result)
24
+
25
+ asyncio.run(main())
26
+ ```
27
+
28
+ ## Version lockstep
29
+
30
+ `mwps-client` carries no version of its own: its published version is
31
+ always identical to the `mwps` server version it was generated against
32
+ (C-068, ClientServerVersionLockstep). Both packages read the same
33
+ repository-root `VERSION` file — `mwps_client/VERSION` is a symlink to
34
+ `../VERSION` — so a client published from a given deploy always matches
35
+ the server it talks to. See `pipeline/README.md` for the release process
36
+ that keeps the two in lockstep.
@@ -0,0 +1 @@
1
+ 0.1.7
@@ -0,0 +1,36 @@
1
+ from pathlib import Path
2
+
3
+ from .client import MwpsClient
4
+
5
+ # Lockstep version (C-068, ClientServerVersionLockstep): must always equal
6
+ # the mwps server's [project].version in the repo-root pyproject.toml. Both
7
+ # read the same root VERSION file (mwps_client/pyproject.toml derives its
8
+ # published version from it via
9
+ # [tool.setuptools.dynamic] version = {file = "VERSION"}, and
10
+ # mwps_client/VERSION is a symlink to ../VERSION).
11
+ #
12
+ # When installed (wheel/sdist), the version setuptools recorded at build
13
+ # time is authoritative and is read via importlib.metadata -- no network,
14
+ # no filesystem lookup outside the installed distribution's own metadata.
15
+ # When running from a checkout without an installed distribution (e.g. a
16
+ # fresh `python -c "import mwps_client"` before `pip install -e .`), fall
17
+ # back to reading the VERSION file next to this package's pyproject.toml.
18
+
19
+
20
+ def _read_version() -> str:
21
+ try:
22
+ from importlib.metadata import version
23
+
24
+ return version("mwps-client")
25
+ except Exception:
26
+ pass
27
+ try:
28
+ version_file = Path(__file__).resolve().parent.parent / "VERSION"
29
+ return version_file.read_text(encoding="utf-8").strip()
30
+ except OSError:
31
+ return "0.0.0"
32
+
33
+
34
+ __version__ = _read_version()
35
+
36
+ __all__ = ["MwpsClient", "__version__"]
@@ -0,0 +1,390 @@
1
+ """
2
+ mwps_client.client
3
+
4
+ MwpsClient: the sole entry point of the mwps-client package. Hides all
5
+ network interaction (transport selection, TLS material, proxy routing,
6
+ request correlation, transport-level retry, error mapping) behind one
7
+ async, typed method per mwps command.
8
+
9
+ Version note (C-068, ClientServerVersionLockstep): this module carries no
10
+ version string of its own. mwps-client's published version is always
11
+ identical to the mwps server version it was generated against and is
12
+ derived at build time; every server deployment obligatorily raises that
13
+ version, and mwps-client is republished to PyPI at the same new version in
14
+ the same delivery. Do not add a __version__ or literal version string here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ from typing import Any, Dict, List, Optional, cast
21
+ from urllib.parse import urlparse
22
+
23
+
24
+ class MwpsClientError(Exception):
25
+ """Transport-level error raised by MwpsClient; message is safe to display."""
26
+
27
+ def __init__(self, message: str, status: Optional[int] = None) -> None:
28
+ """Store error message and optional status code."""
29
+ super().__init__(message)
30
+ self.message = message
31
+ self.status = status
32
+
33
+
34
+ class MwpsClient:
35
+ """
36
+ Single entry point for the mwps-client package.
37
+
38
+ Hides transport selection, TLS material, proxy routing, request
39
+ correlation, transport-level retry, and error mapping behind one typed
40
+ async method per mwps command. All commands are invoked as JSON-RPC
41
+ calls through mcp_proxy_adapter's JsonRpcClient, routed via mcp-proxy.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ base_url: str,
47
+ token_header: Optional[str] = None,
48
+ token: Optional[str] = None,
49
+ cert: Optional[str] = None,
50
+ key: Optional[str] = None,
51
+ ca: Optional[str] = None,
52
+ timeout: float = 30.0,
53
+ ) -> None:
54
+ """
55
+ Initialize the client with mcp-proxy connection parameters.
56
+
57
+ Args:
58
+ base_url: mcp-proxy base URL (http, https, ws, or wss).
59
+ token_header: Optional HTTP header name carrying the auth token.
60
+ token: Optional auth token value.
61
+ cert: Optional path to client TLS certificate (mTLS). Only
62
+ takes effect together with `key`.
63
+ key: Optional path to client TLS private key (mTLS). Only
64
+ takes effect together with `cert`.
65
+ ca: Optional path to CA certificate for server verification.
66
+ Forwarded to the adapter whenever supplied, independently
67
+ of `cert`/`key` -- plain `https` with a self-signed or
68
+ private CA needs `ca` alone, with no client certificate.
69
+ timeout: Request timeout in seconds.
70
+ """
71
+ parsed = urlparse((base_url or "").rstrip("/"))
72
+ scheme = (parsed.scheme or "http").lower()
73
+ host = parsed.hostname or "localhost"
74
+ port = parsed.port
75
+ if port is None:
76
+ port = 443 if scheme in ("https", "wss") else 80
77
+ protocol = "https" if scheme in ("https", "wss") else "http"
78
+ self._adapter_params: Dict[str, Any] = {
79
+ "protocol": protocol,
80
+ "host": host,
81
+ "port": port,
82
+ "token_header": token_header,
83
+ "token": token,
84
+ "timeout": timeout,
85
+ }
86
+ if cert and key:
87
+ self._adapter_params["cert"] = cert
88
+ self._adapter_params["key"] = key
89
+ if ca:
90
+ self._adapter_params["ca"] = ca
91
+ self._client: Any = None
92
+ self._client_loop: Optional[asyncio.AbstractEventLoop] = None
93
+
94
+ async def _get_client(self) -> Any:
95
+ """Lazily create and cache the underlying JsonRpcClient for the running loop.
96
+
97
+ A cached adapter client holds an httpx.AsyncClient whose pooled
98
+ connections and internal async primitives are bound to the event
99
+ loop that was running when they were created. mwps-client is
100
+ typically driven by pipeline/runner.py, which invokes each check
101
+ through its own ``asyncio.run(...)`` call -- a fresh event loop
102
+ every time. Reusing the same adapter client across loops makes its
103
+ first request in a new loop fail with "Event loop is closed" (the
104
+ pooled connection tries to schedule I/O on the now-closed old
105
+ loop), even though the client itself is fine and later requests in
106
+ that same loop succeed once the stale connection is dropped.
107
+
108
+ To stay correct across any number of ``asyncio.run()`` calls, close
109
+ and drop the cached client whenever the running loop has changed
110
+ since it was created, then build a fresh one for the current loop.
111
+ """
112
+ running_loop = asyncio.get_running_loop()
113
+ if self._client is not None and self._client_loop is running_loop:
114
+ return self._client
115
+ if self._client is not None:
116
+ await self._close_adapter_client(self._client)
117
+ try:
118
+ from mcp_proxy_adapter.client.jsonrpc_client import JsonRpcClient
119
+ except ImportError as e:
120
+ raise MwpsClientError(
121
+ f"mcp_proxy_adapter client not available: {e}"
122
+ ) from e
123
+ self._client = JsonRpcClient(**self._adapter_params)
124
+ self._client_loop = running_loop
125
+ return self._client
126
+
127
+ @staticmethod
128
+ async def _close_adapter_client(client: Any) -> None:
129
+ """Best-effort close of a stale adapter client; never raises."""
130
+ close = getattr(client, "close", None)
131
+ if not callable(close):
132
+ return
133
+ try:
134
+ result = close()
135
+ if asyncio.iscoroutine(result):
136
+ await result
137
+ except Exception: # noqa: BLE001 -- teardown failure must not mask real errors
138
+ pass
139
+
140
+ async def aclose(self) -> None:
141
+ """Close the underlying adapter client, if one was created.
142
+
143
+ Safe to call from any running loop, and safe to call more than
144
+ once. Intended for callers (such as pipeline/main.py) that want to
145
+ release the transport deterministically after use instead of
146
+ relying on lazy per-loop recreation in ``_get_client``.
147
+ """
148
+ if self._client is None:
149
+ return
150
+ await self._close_adapter_client(self._client)
151
+ self._client = None
152
+ self._client_loop = None
153
+
154
+ async def _call_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]:
155
+ """
156
+ Invoke one mwps command via JSON-RPC and return its result.
157
+
158
+ Raises:
159
+ MwpsClientError: on any transport or protocol failure.
160
+ """
161
+ client = await self._get_client()
162
+ try:
163
+ response = await client.jsonrpc_call(command, params)
164
+ return cast(Dict[str, Any], client._extract_result(response))
165
+ except MwpsClientError:
166
+ raise
167
+ except Exception as e: # noqa: BLE001
168
+ resp = getattr(e, "response", None)
169
+ status = (
170
+ getattr(e, "status_code", None)
171
+ or getattr(e, "status", None)
172
+ or (getattr(resp, "status_code", None) if resp is not None else None)
173
+ )
174
+ raise MwpsClientError(str(e), status=status) from e
175
+
176
+ async def session_init(
177
+ self,
178
+ model: Optional[str] = None,
179
+ allowed_commands: Optional[List[str]] = None,
180
+ forbidden_commands: Optional[List[str]] = None,
181
+ standards: Optional[List[str]] = None,
182
+ session_rules: Optional[List[str]] = None,
183
+ ) -> Dict[str, Any]:
184
+ """
185
+ Create a new chat session and return its identifier.
186
+
187
+ Args:
188
+ model: Default MWPS model id to associate with the session.
189
+ allowed_commands: Allow-list of command names for the session.
190
+ forbidden_commands: Forbid-list of command names for the session.
191
+ standards: Canonical standards text blocks for session context.
192
+ session_rules: Session rules text blocks for session context.
193
+
194
+ Returns:
195
+ Object with session_id (string, UUID4).
196
+ """
197
+ params: Dict[str, Any] = {}
198
+ if model is not None:
199
+ params["model"] = model
200
+ if allowed_commands is not None:
201
+ params["allowed_commands"] = allowed_commands
202
+ if forbidden_commands is not None:
203
+ params["forbidden_commands"] = forbidden_commands
204
+ if standards is not None:
205
+ params["standards"] = standards
206
+ if session_rules is not None:
207
+ params["session_rules"] = session_rules
208
+ return await self._call_command("session_init", params)
209
+
210
+ async def session_update(
211
+ self,
212
+ session_id: str,
213
+ model: Optional[str] = None,
214
+ allowed_commands: Optional[List[str]] = None,
215
+ forbidden_commands: Optional[List[str]] = None,
216
+ standards: Optional[List[str]] = None,
217
+ session_rules: Optional[List[str]] = None,
218
+ ) -> Dict[str, Any]:
219
+ """
220
+ Update an existing session in place (full replacement of supplied lists).
221
+
222
+ Args:
223
+ session_id: UUID4 of the session to update.
224
+ model: Sets the session's default MWPS model.
225
+ allowed_commands: Replaces the session's allow-list.
226
+ forbidden_commands: Replaces the session's forbid-list.
227
+ standards: Replaces the session's standards context blocks.
228
+ session_rules: Replaces the session's session-rules context blocks.
229
+
230
+ Returns:
231
+ Object with ok (boolean).
232
+ """
233
+ params: Dict[str, Any] = {"session_id": session_id}
234
+ if model is not None:
235
+ params["model"] = model
236
+ if allowed_commands is not None:
237
+ params["allowed_commands"] = allowed_commands
238
+ if forbidden_commands is not None:
239
+ params["forbidden_commands"] = forbidden_commands
240
+ if standards is not None:
241
+ params["standards"] = standards
242
+ if session_rules is not None:
243
+ params["session_rules"] = session_rules
244
+ return await self._call_command("session_update", params)
245
+
246
+ async def add_command_to_session(
247
+ self,
248
+ session_id: str,
249
+ command_id: str,
250
+ ) -> Dict[str, Any]:
251
+ """
252
+ Grant a session access to one additional command.
253
+
254
+ Args:
255
+ session_id: UUID4 of the session.
256
+ command_id: Name of the command to allow.
257
+
258
+ Returns:
259
+ Object with ok (boolean).
260
+ """
261
+ params: Dict[str, Any] = {"session_id": session_id, "command_id": command_id}
262
+ return await self._call_command("add_command_to_session", params)
263
+
264
+ async def remove_command_from_session(
265
+ self,
266
+ session_id: str,
267
+ command_id: str,
268
+ ) -> Dict[str, Any]:
269
+ """
270
+ Revoke a session's access to one command.
271
+
272
+ Args:
273
+ session_id: UUID4 of the session.
274
+ command_id: Name of the command to remove.
275
+
276
+ Returns:
277
+ Object with ok (boolean).
278
+ """
279
+ params: Dict[str, Any] = {"session_id": session_id, "command_id": command_id}
280
+ return await self._call_command("remove_command_from_session", params)
281
+
282
+ async def get_model_context(
283
+ self,
284
+ session_id: str,
285
+ content: Optional[str] = None,
286
+ ) -> Dict[str, Any]:
287
+ """
288
+ Return the exact final context that would be sent to the model.
289
+
290
+ Args:
291
+ session_id: UUID4 of the session.
292
+ content: User message to append as the final message.
293
+
294
+ Returns:
295
+ Object with final_context, representation_model, messages,
296
+ tool_names, tools, messages_count.
297
+ """
298
+ params: Dict[str, Any] = {"session_id": session_id}
299
+ if content is not None:
300
+ params["content"] = content
301
+ return await self._call_command("get_model_context", params)
302
+
303
+ async def invoke_tool(
304
+ self,
305
+ tool_name: str,
306
+ arguments: Optional[Dict[str, Any]] = None,
307
+ ) -> Dict[str, Any]:
308
+ """
309
+ Invoke one proxy-registered tool the way the model would invoke it.
310
+
311
+ Args:
312
+ tool_name: The tool's display name, or a proxy meta-command
313
+ (list_servers, call_server, help).
314
+ arguments: Arguments passed to the tool.
315
+
316
+ Returns:
317
+ Object with content (string).
318
+ """
319
+ params: Dict[str, Any] = {"tool_name": tool_name}
320
+ if arguments is not None:
321
+ params["arguments"] = arguments
322
+ return await self._call_command("invoke_tool", params)
323
+
324
+ async def info(
325
+ self,
326
+ section: Optional[str] = None,
327
+ ) -> Dict[str, Any]:
328
+ """
329
+ Return server documentation: overview + table of contents, or one section.
330
+
331
+ Args:
332
+ section: Documentation section name (e.g. "commands",
333
+ "configuration"); omit for the overview and table of
334
+ contents.
335
+
336
+ Returns:
337
+ Without section: object with description, server (name,
338
+ version), sections (array of name/title/summary). With
339
+ section: object with section, title, content.
340
+ """
341
+ params: Dict[str, Any] = {}
342
+ if section is not None:
343
+ params["section"] = section
344
+ return await self._call_command("info", params)
345
+
346
+ async def mwps_chat(
347
+ self,
348
+ messages: Optional[List[Dict[str, str]]] = None,
349
+ model: Optional[str] = None,
350
+ stream: Optional[bool] = None,
351
+ max_tool_rounds: Optional[int] = None,
352
+ config_path: Optional[str] = None,
353
+ session_id: Optional[str] = None,
354
+ content: Optional[str] = None,
355
+ ) -> Dict[str, Any]:
356
+ """
357
+ Run one chat turn against MWPS, in direct mode or session mode.
358
+
359
+ Direct mode: supply `messages`. Session mode: supply `session_id`
360
+ together with `content`. Exactly one mode must be used per call.
361
+
362
+ Args:
363
+ messages: Chat messages for direct mode; each item has role
364
+ and content.
365
+ model: MWPS model name overriding the session/config default.
366
+ stream: If True, the reply is streamed.
367
+ max_tool_rounds: Maximum number of tool-call rounds.
368
+ config_path: Path to the adapter/workstation configuration file.
369
+ session_id: UUID4 of the session, for session mode.
370
+ content: New user message text for session mode.
371
+
372
+ Returns:
373
+ Object with message (string) and history (array of object).
374
+ """
375
+ params: Dict[str, Any] = {}
376
+ if messages is not None:
377
+ params["messages"] = messages
378
+ if model is not None:
379
+ params["model"] = model
380
+ if stream is not None:
381
+ params["stream"] = stream
382
+ if max_tool_rounds is not None:
383
+ params["max_tool_rounds"] = max_tool_rounds
384
+ if config_path is not None:
385
+ params["config_path"] = config_path
386
+ if session_id is not None:
387
+ params["session_id"] = session_id
388
+ if content is not None:
389
+ params["content"] = content
390
+ return await self._call_command("mwps_chat", params)
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: mwps-client
3
+ Version: 0.1.7
4
+ Summary: Official mwps client library: single entry point MwpsClient, one typed method per mwps command.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # mwps-client
9
+
10
+ Official Python client for the `mwps` server. `MwpsClient` is the single
11
+ entry point: it hides transport selection, TLS material, proxy routing,
12
+ request correlation and error mapping behind one typed async method per
13
+ `mwps` command (see `mwps_client/mwps_client/client.py`).
14
+
15
+ ## Installation
16
+
17
+ ```
18
+ pip install mwps-client
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import asyncio
25
+ from mwps_client import MwpsClient
26
+
27
+ async def main():
28
+ client = MwpsClient(base_url="https://<host>:8015", ca="/path/to/ca.crt")
29
+ result = await client.invoke_tool(tool_name="list_servers")
30
+ print(result)
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ ## Version lockstep
36
+
37
+ `mwps-client` carries no version of its own: its published version is
38
+ always identical to the `mwps` server version it was generated against
39
+ (C-068, ClientServerVersionLockstep). Both packages read the same
40
+ repository-root `VERSION` file — `mwps_client/VERSION` is a symlink to
41
+ `../VERSION` — so a client published from a given deploy always matches
42
+ the server it talks to. See `pipeline/README.md` for the release process
43
+ that keeps the two in lockstep.
@@ -0,0 +1,9 @@
1
+ README.md
2
+ VERSION
3
+ pyproject.toml
4
+ mwps_client/__init__.py
5
+ mwps_client/client.py
6
+ mwps_client.egg-info/PKG-INFO
7
+ mwps_client.egg-info/SOURCES.txt
8
+ mwps_client.egg-info/dependency_links.txt
9
+ mwps_client.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ mwps_client
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mwps-client"
7
+ # Per C-068 (ClientServerVersionLockstep), this client carries no static
8
+ # version of its own. Its version is always identical to the mwps server
9
+ # version and is derived and republished at every server deployment; it
10
+ # must never be hardcoded here as a literal version string.
11
+ dynamic = ["version"]
12
+ description = "Official mwps client library: single entry point MwpsClient, one typed method per mwps command."
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+
16
+ [tool.setuptools]
17
+ packages = ["mwps_client"]
18
+
19
+ [tool.setuptools.dynamic]
20
+ # Single source of truth is the repo-root VERSION file (mwps_client/VERSION
21
+ # is a symlink to ../VERSION), the same file the mwps server's root
22
+ # pyproject.toml reads (C-068, ClientServerVersionLockstep). See
23
+ # pipeline/README.md for the build-time lockstep requirement.
24
+ version = {file = "VERSION"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+