wrg-mcp-server 1.0.0__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,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: wrg_mcp_server
3
+ Version: 1.0.0
4
+ Summary: WRG MCP server — exposes WinstonRedGuard tools to Claude and AI agents
5
+ Author-email: Yakuphan Yucel <yakuphan.yucel11@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/yakuphanycl/WinstonRedGuard/tree/main/apps/wrg_mcp_server
8
+ Project-URL: Repository, https://github.com/yakuphanycl/WinstonRedGuard
9
+ Project-URL: Issues, https://github.com/yakuphanycl/WinstonRedGuard/issues
10
+ Project-URL: Documentation, https://github.com/yakuphanycl/WinstonRedGuard/tree/main/apps/wrg_mcp_server#readme
11
+ Keywords: ai-agents,claude,mcp,model-context-protocol,winstonredguard
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: mcp<2.0,>=1.0.0
26
+ Provides-Extra: remote
27
+ Requires-Dist: httpx<1.0,>=0.27; extra == "remote"
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=9.0.3; extra == "dev"
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
31
+
32
+ <!-- mcp-name: io.github.yakuphanycl/wrg-mcp-server -->
33
+
34
+ # wrg_mcp_server
35
+
36
+ MCP (Model Context Protocol) server exposing the WinstonRedGuard monorepo to Claude and other MCP-compatible AI agents. Built on `FastMCP` — registers tools from every active WRG app so an agent can inspect the repo, run pipelines, query memory, and call remote services without shelling out.
37
+
38
+ ## Transports
39
+
40
+ ```bash
41
+ wrg-mcp-server --transport stdio # Claude Desktop / Claude Code
42
+ wrg-mcp-server --transport streamable-http # default HTTP (recommended)
43
+ wrg-mcp-server --transport sse # legacy HTTP
44
+ ```
45
+
46
+ Flags: `--host 0.0.0.0` · `--port 8080` · `--mcp-path /mcp`
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ cd apps/wrg_mcp_server
52
+ pip install -e . # core: MCP + local tools only
53
+ pip install -e ".[remote]" # adds httpx for site_* / pulseboard_* tools
54
+ pip install -e ".[dev]" # pytest + pytest-asyncio
55
+ ```
56
+
57
+ ## Tools exposed
58
+
59
+ ### Local (subprocess-backed, always available)
60
+
61
+ | Tool | What it does |
62
+ |---|---|
63
+ | `connector_status` | Report which remote services are configured |
64
+ | `app_list`, `app_info` | Query `app_registry/data/registry.json` |
65
+ | `governance_run` | Execute `governance_check` across one or all apps |
66
+ | `release_check` | Run the `tools/release_check.ps1` gate |
67
+ | `pipeline_list`, `pipeline_show`, `pipeline_run` | `wrg_pipeline` DAG operations |
68
+ | `pulse_check` | Invoke `wrg-pulse check` |
69
+ | `memory_get`, `memory_set`, `memory_list`, `memory_search` | `wrg_memory` key-value access |
70
+ | `research_history`, `research_report`, `research_scan`, `research_watch`, `research_scan_summary` | `research_motor` runs and artifacts |
71
+ | `vault_audit` | `wrg_vault` audit ledger inspection |
72
+ | `scheduler_task_list`, `scheduler_tick_dry_run` | `wrg_scheduler` inspection |
73
+
74
+ ### Remote (HTTP, opt-in via env)
75
+
76
+ | Tool | Upstream |
77
+ |---|---|
78
+ | `site_health`, `site_get`, `site_post` | Company site API (`WRG_SITE_BASE_URL`) |
79
+ | `pulseboard_health`, `pulseboard_list_repos`, `pulseboard_add_repo`, `pulseboard_delete_repo`, `pulseboard_get_pulse` | `pulseboard` dashboard (`WRG_PULSEBOARD_BASE_URL`) |
80
+
81
+ Remote tools return `{"ok": false, "error": "httpx not installed — remote tools unavailable"}` when the `[remote]` extra is not installed.
82
+
83
+ ## Environment
84
+
85
+ ### Repo discovery
86
+
87
+ | Variable | Default | Purpose |
88
+ |---|---|---|
89
+ | `WRG_REPO_ROOT` | auto-detect (walk up until `apps/` + `CLAUDE.md`) | Required when installed from wheel outside the monorepo |
90
+
91
+ ### Mutation gate (default: off)
92
+
93
+ State-changing tools (`memory_set`, `pipeline_run`) refuse to execute unless:
94
+
95
+ ```bash
96
+ WRG_MCP_ALLOW_MUTATIONS=1
97
+ ```
98
+
99
+ This prevents an MCP client from silently writing memory or launching pipelines on a read-only connection.
100
+
101
+ ### Remote service config
102
+
103
+ Per service (`SITE` / `PULSEBOARD`), prefix with `WRG_<SERVICE>_`:
104
+
105
+ | Variable | Default | Purpose |
106
+ |---|---|---|
107
+ | `*_BASE_URL` | — | Enables the service (unset = service disabled) |
108
+ | `*_TOKEN` | — | Bearer token for `Authorization` header |
109
+ | `*_AUTH_HEADER` | `Authorization` | Override header name |
110
+ | `*_AUTH_SCHEME` | `Bearer` | Override token scheme |
111
+ | `*_SESSION_COOKIE` | — | Optional `Cookie` header |
112
+ | `*_EXTRA_HEADERS` | — | JSON object of extra headers |
113
+ | `*_TIMEOUT_SECONDS` | `WRG_HTTP_TIMEOUT_SECONDS` (20.0) | Per-request timeout |
114
+ | `*_VERIFY_TLS` | `WRG_HTTP_VERIFY_TLS` (true) | TLS verification |
115
+
116
+ ## Claude Code / Claude Desktop integration
117
+
118
+ Add to your MCP client config:
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "wrg": {
124
+ "command": "wrg-mcp-server",
125
+ "args": ["--transport", "stdio"],
126
+ "env": {
127
+ "WRG_REPO_ROOT": "D:\\dev\\WinstonRedGuard",
128
+ "WRG_MCP_ALLOW_MUTATIONS": "0"
129
+ }
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ ## Architecture
136
+
137
+ ```
138
+ FastMCP server
139
+ ├── server.py — tool registration, remote HTTP dispatch
140
+ ├── config.py — ServiceConfig / AppConfig from env (frozen dataclasses)
141
+ ├── http_utils.py — URL builder, response parser
142
+ ├── local_tools.py — subprocess wrappers for WRG CLIs (~20 tools)
143
+ └── cli.py — argparse entry point
144
+ ```
145
+
146
+ Local tools use `subprocess.run` with `stdin=DEVNULL` (not asyncio subprocess) — avoids a Windows pipe-blocking deadlock under anyio. Tool dispatch is wrapped in `anyio.to_thread.run_sync` so the MCP event loop stays responsive.
147
+
148
+ ## Tests
149
+
150
+ ```bash
151
+ pytest -q
152
+ ```
153
+
154
+ ## Status
155
+
156
+ Production — 1045 lines, covers every active WRG app, drives the `mcp__wrg__*` tools visible in connected Claude sessions.
@@ -0,0 +1,125 @@
1
+ <!-- mcp-name: io.github.yakuphanycl/wrg-mcp-server -->
2
+
3
+ # wrg_mcp_server
4
+
5
+ MCP (Model Context Protocol) server exposing the WinstonRedGuard monorepo to Claude and other MCP-compatible AI agents. Built on `FastMCP` — registers tools from every active WRG app so an agent can inspect the repo, run pipelines, query memory, and call remote services without shelling out.
6
+
7
+ ## Transports
8
+
9
+ ```bash
10
+ wrg-mcp-server --transport stdio # Claude Desktop / Claude Code
11
+ wrg-mcp-server --transport streamable-http # default HTTP (recommended)
12
+ wrg-mcp-server --transport sse # legacy HTTP
13
+ ```
14
+
15
+ Flags: `--host 0.0.0.0` · `--port 8080` · `--mcp-path /mcp`
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ cd apps/wrg_mcp_server
21
+ pip install -e . # core: MCP + local tools only
22
+ pip install -e ".[remote]" # adds httpx for site_* / pulseboard_* tools
23
+ pip install -e ".[dev]" # pytest + pytest-asyncio
24
+ ```
25
+
26
+ ## Tools exposed
27
+
28
+ ### Local (subprocess-backed, always available)
29
+
30
+ | Tool | What it does |
31
+ |---|---|
32
+ | `connector_status` | Report which remote services are configured |
33
+ | `app_list`, `app_info` | Query `app_registry/data/registry.json` |
34
+ | `governance_run` | Execute `governance_check` across one or all apps |
35
+ | `release_check` | Run the `tools/release_check.ps1` gate |
36
+ | `pipeline_list`, `pipeline_show`, `pipeline_run` | `wrg_pipeline` DAG operations |
37
+ | `pulse_check` | Invoke `wrg-pulse check` |
38
+ | `memory_get`, `memory_set`, `memory_list`, `memory_search` | `wrg_memory` key-value access |
39
+ | `research_history`, `research_report`, `research_scan`, `research_watch`, `research_scan_summary` | `research_motor` runs and artifacts |
40
+ | `vault_audit` | `wrg_vault` audit ledger inspection |
41
+ | `scheduler_task_list`, `scheduler_tick_dry_run` | `wrg_scheduler` inspection |
42
+
43
+ ### Remote (HTTP, opt-in via env)
44
+
45
+ | Tool | Upstream |
46
+ |---|---|
47
+ | `site_health`, `site_get`, `site_post` | Company site API (`WRG_SITE_BASE_URL`) |
48
+ | `pulseboard_health`, `pulseboard_list_repos`, `pulseboard_add_repo`, `pulseboard_delete_repo`, `pulseboard_get_pulse` | `pulseboard` dashboard (`WRG_PULSEBOARD_BASE_URL`) |
49
+
50
+ Remote tools return `{"ok": false, "error": "httpx not installed — remote tools unavailable"}` when the `[remote]` extra is not installed.
51
+
52
+ ## Environment
53
+
54
+ ### Repo discovery
55
+
56
+ | Variable | Default | Purpose |
57
+ |---|---|---|
58
+ | `WRG_REPO_ROOT` | auto-detect (walk up until `apps/` + `CLAUDE.md`) | Required when installed from wheel outside the monorepo |
59
+
60
+ ### Mutation gate (default: off)
61
+
62
+ State-changing tools (`memory_set`, `pipeline_run`) refuse to execute unless:
63
+
64
+ ```bash
65
+ WRG_MCP_ALLOW_MUTATIONS=1
66
+ ```
67
+
68
+ This prevents an MCP client from silently writing memory or launching pipelines on a read-only connection.
69
+
70
+ ### Remote service config
71
+
72
+ Per service (`SITE` / `PULSEBOARD`), prefix with `WRG_<SERVICE>_`:
73
+
74
+ | Variable | Default | Purpose |
75
+ |---|---|---|
76
+ | `*_BASE_URL` | — | Enables the service (unset = service disabled) |
77
+ | `*_TOKEN` | — | Bearer token for `Authorization` header |
78
+ | `*_AUTH_HEADER` | `Authorization` | Override header name |
79
+ | `*_AUTH_SCHEME` | `Bearer` | Override token scheme |
80
+ | `*_SESSION_COOKIE` | — | Optional `Cookie` header |
81
+ | `*_EXTRA_HEADERS` | — | JSON object of extra headers |
82
+ | `*_TIMEOUT_SECONDS` | `WRG_HTTP_TIMEOUT_SECONDS` (20.0) | Per-request timeout |
83
+ | `*_VERIFY_TLS` | `WRG_HTTP_VERIFY_TLS` (true) | TLS verification |
84
+
85
+ ## Claude Code / Claude Desktop integration
86
+
87
+ Add to your MCP client config:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "wrg": {
93
+ "command": "wrg-mcp-server",
94
+ "args": ["--transport", "stdio"],
95
+ "env": {
96
+ "WRG_REPO_ROOT": "D:\\dev\\WinstonRedGuard",
97
+ "WRG_MCP_ALLOW_MUTATIONS": "0"
98
+ }
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ ## Architecture
105
+
106
+ ```
107
+ FastMCP server
108
+ ├── server.py — tool registration, remote HTTP dispatch
109
+ ├── config.py — ServiceConfig / AppConfig from env (frozen dataclasses)
110
+ ├── http_utils.py — URL builder, response parser
111
+ ├── local_tools.py — subprocess wrappers for WRG CLIs (~20 tools)
112
+ └── cli.py — argparse entry point
113
+ ```
114
+
115
+ Local tools use `subprocess.run` with `stdin=DEVNULL` (not asyncio subprocess) — avoids a Windows pipe-blocking deadlock under anyio. Tool dispatch is wrapped in `anyio.to_thread.run_sync` so the MCP event loop stays responsive.
116
+
117
+ ## Tests
118
+
119
+ ```bash
120
+ pytest -q
121
+ ```
122
+
123
+ ## Status
124
+
125
+ Production — 1045 lines, covers every active WRG app, drives the `mcp__wrg__*` tools visible in connected Claude sessions.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wrg_mcp_server"
7
+ version = "1.0.0"
8
+ description = "WRG MCP server — exposes WinstonRedGuard tools to Claude and AI agents"
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Yakuphan Yucel", email = "yakuphan.yucel11@gmail.com" }]
13
+ keywords = [
14
+ "ai-agents",
15
+ "claude",
16
+ "mcp",
17
+ "model-context-protocol",
18
+ "winstonredguard",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 5 - Production/Stable",
22
+ "Environment :: Console",
23
+ "Intended Audience :: Developers",
24
+ "Operating System :: OS Independent",
25
+ "Programming Language :: Python :: 3",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Software Development",
30
+ "Topic :: Software Development :: Quality Assurance",
31
+ "Topic :: Utilities",
32
+ ]
33
+ dependencies = [
34
+ "mcp>=1.0.0,<2.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/yakuphanycl/WinstonRedGuard/tree/main/apps/wrg_mcp_server"
39
+ Repository = "https://github.com/yakuphanycl/WinstonRedGuard"
40
+ Issues = "https://github.com/yakuphanycl/WinstonRedGuard/issues"
41
+ Documentation = "https://github.com/yakuphanycl/WinstonRedGuard/tree/main/apps/wrg_mcp_server#readme"
42
+
43
+ [project.optional-dependencies]
44
+ remote = [
45
+ "httpx>=0.27,<1.0",
46
+ ]
47
+ dev = [
48
+ "pytest>=9.0.3",
49
+ "pytest-asyncio>=0.24",
50
+ ]
51
+
52
+ [project.scripts]
53
+ wrg-mcp-server = "wrg_mcp_server.cli:main"
54
+
55
+ [tool.setuptools.packages.find]
56
+ where = ["src"]
57
+
58
+ [tool.pytest.ini_options]
59
+ asyncio_mode = "auto"
60
+ testpaths = ["tests"]
61
+
62
+ [tool.coverage.report]
63
+ # Baseline no-regression floor. Apps above this today (measured 2026-04-21)
64
+ # shouldn't drop below it; raise individually once they stabilise higher.
65
+ fail_under = 60
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """wrg_mcp_server package."""
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "1.0.0"
@@ -0,0 +1,50 @@
1
+ """CLI entrypoint for wrg_mcp_server.
2
+
3
+ Supports three transports:
4
+ stdio — for Claude Desktop / Claude Code integration
5
+ streamable-http — recommended HTTP transport (default)
6
+ sse — legacy HTTP transport
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+
15
+ def main() -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="wrg-mcp-server",
18
+ description="WinstonRedGuard MCP server",
19
+ )
20
+ parser.add_argument(
21
+ "--transport",
22
+ choices=["stdio", "streamable-http", "sse"],
23
+ default="streamable-http",
24
+ help="MCP transport (default: streamable-http)",
25
+ )
26
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
27
+ parser.add_argument("--port", type=int, default=8080, help="Bind port (default: 8080)")
28
+ parser.add_argument("--mcp-path", default="/mcp", help="HTTP endpoint path (default: /mcp)")
29
+
30
+ args = parser.parse_args()
31
+
32
+ from wrg_mcp_server.server import create_mcp_server
33
+
34
+ server = create_mcp_server(
35
+ host=args.host,
36
+ port=args.port,
37
+ streamable_http_path=args.mcp_path,
38
+ )
39
+
40
+ print(
41
+ f"wrg-mcp-server starting (transport={args.transport}, "
42
+ f"host={args.host}, port={args.port})",
43
+ file=sys.stderr,
44
+ )
45
+ server.run(transport=args.transport)
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())
@@ -0,0 +1,153 @@
1
+ """Environment-driven configuration for wrg_mcp_server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from typing import Mapping
8
+
9
+
10
+ class ConfigError(RuntimeError):
11
+ """Raised when connector configuration is invalid."""
12
+
13
+
14
+ def _parse_bool(value: str | None, *, default: bool) -> bool:
15
+ if value is None:
16
+ return default
17
+ normalized = value.strip().lower()
18
+ if normalized in {"1", "true", "yes", "on"}:
19
+ return True
20
+ if normalized in {"0", "false", "no", "off"}:
21
+ return False
22
+ raise ConfigError(f"Invalid boolean value: {value!r}")
23
+
24
+
25
+ def _parse_float(value: str | None, *, default: float, key: str) -> float:
26
+ if value is None or not value.strip():
27
+ return default
28
+ try:
29
+ parsed = float(value)
30
+ except ValueError as exc:
31
+ raise ConfigError(f"{key} must be a number: {value!r}") from exc
32
+ if parsed <= 0:
33
+ raise ConfigError(f"{key} must be > 0: {value!r}")
34
+ return parsed
35
+
36
+
37
+ def _parse_headers_json(raw: str | None, *, key: str) -> dict[str, str]:
38
+ if raw is None or not raw.strip():
39
+ return {}
40
+ try:
41
+ obj = json.loads(raw)
42
+ except json.JSONDecodeError as exc:
43
+ raise ConfigError(f"{key} must be valid JSON object") from exc
44
+
45
+ if not isinstance(obj, dict):
46
+ raise ConfigError(f"{key} must be a JSON object")
47
+
48
+ out: dict[str, str] = {}
49
+ for k, v in obj.items():
50
+ if not isinstance(k, str):
51
+ raise ConfigError(f"{key} keys must be strings")
52
+ if not isinstance(v, str):
53
+ raise ConfigError(f"{key} values must be strings")
54
+ out[k] = v
55
+ return out
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ServiceConfig:
60
+ """Configuration for one upstream service."""
61
+
62
+ base_url: str
63
+ token: str | None
64
+ auth_header: str
65
+ auth_scheme: str
66
+ session_cookie: str | None
67
+ extra_headers: dict[str, str]
68
+ timeout_seconds: float
69
+ verify_tls: bool
70
+
71
+ def build_headers(self) -> dict[str, str]:
72
+ headers: dict[str, str] = dict(self.extra_headers)
73
+ if self.token:
74
+ token_value = f"{self.auth_scheme} {self.token}".strip()
75
+ headers[self.auth_header] = token_value
76
+ if self.session_cookie:
77
+ headers["Cookie"] = self.session_cookie
78
+ return headers
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class AppConfig:
83
+ """Top-level connector configuration."""
84
+
85
+ site: ServiceConfig | None
86
+ pulseboard: ServiceConfig | None
87
+
88
+ @classmethod
89
+ def from_env(cls, env: Mapping[str, str]) -> "AppConfig":
90
+ default_timeout = _parse_float(
91
+ env.get("WRG_HTTP_TIMEOUT_SECONDS"),
92
+ default=20.0,
93
+ key="WRG_HTTP_TIMEOUT_SECONDS",
94
+ )
95
+ default_verify_tls = _parse_bool(
96
+ env.get("WRG_HTTP_VERIFY_TLS"),
97
+ default=True,
98
+ )
99
+
100
+ site = _service_from_env(
101
+ env=env,
102
+ prefix="WRG_SITE",
103
+ base_url=env.get("WRG_SITE_BASE_URL"),
104
+ default_timeout=default_timeout,
105
+ default_verify_tls=default_verify_tls,
106
+ )
107
+ pulseboard = _service_from_env(
108
+ env=env,
109
+ prefix="WRG_PULSEBOARD",
110
+ base_url=env.get("WRG_PULSEBOARD_BASE_URL"),
111
+ default_timeout=default_timeout,
112
+ default_verify_tls=default_verify_tls,
113
+ )
114
+ return cls(site=site, pulseboard=pulseboard)
115
+
116
+
117
+ def _service_from_env(
118
+ *,
119
+ env: Mapping[str, str],
120
+ prefix: str,
121
+ base_url: str | None,
122
+ default_timeout: float,
123
+ default_verify_tls: bool,
124
+ ) -> ServiceConfig | None:
125
+ if base_url is None or not base_url.strip():
126
+ return None
127
+
128
+ timeout_seconds = _parse_float(
129
+ env.get(f"{prefix}_TIMEOUT_SECONDS"),
130
+ default=default_timeout,
131
+ key=f"{prefix}_TIMEOUT_SECONDS",
132
+ )
133
+ verify_tls = _parse_bool(
134
+ env.get(f"{prefix}_VERIFY_TLS"),
135
+ default=default_verify_tls,
136
+ )
137
+ extra_headers = _parse_headers_json(
138
+ env.get(f"{prefix}_EXTRA_HEADERS"),
139
+ key=f"{prefix}_EXTRA_HEADERS",
140
+ )
141
+ auth_scheme = env.get(f"{prefix}_AUTH_SCHEME", "Bearer").strip()
142
+ auth_header = env.get(f"{prefix}_AUTH_HEADER", "Authorization").strip()
143
+
144
+ return ServiceConfig(
145
+ base_url=base_url.rstrip("/"),
146
+ token=(env.get(f"{prefix}_TOKEN") or "").strip() or None,
147
+ auth_header=auth_header or "Authorization",
148
+ auth_scheme=auth_scheme,
149
+ session_cookie=(env.get(f"{prefix}_SESSION_COOKIE") or "").strip() or None,
150
+ extra_headers=extra_headers,
151
+ timeout_seconds=timeout_seconds,
152
+ verify_tls=verify_tls,
153
+ )
@@ -0,0 +1,70 @@
1
+ """HTTP utility helpers.
2
+
3
+ These are only used when httpx is installed (for remote site/pulseboard tools).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Mapping
9
+ from urllib.parse import parse_qsl, urlencode, urljoin, urlparse
10
+
11
+
12
+ def normalize_relative_path(path: str) -> str:
13
+ """Normalize user path and reject absolute/unsafe input."""
14
+ clean = path.strip()
15
+ if not clean:
16
+ return "/"
17
+ if "://" in clean:
18
+ raise ValueError("Absolute URLs are not allowed; provide a relative path.")
19
+ if clean.startswith("//"):
20
+ raise ValueError("Path must not start with //.")
21
+ if not clean.startswith("/"):
22
+ clean = f"/{clean}"
23
+ return clean
24
+
25
+
26
+ def build_url(base_url: str, path: str, query: Mapping[str, Any] | None = None) -> str:
27
+ """Build a final URL from base and safe relative path."""
28
+ normalized = normalize_relative_path(path)
29
+ parsed = urlparse(normalized)
30
+ if parsed.scheme or parsed.netloc:
31
+ raise ValueError("Path must be relative.")
32
+
33
+ absolute = urljoin(f"{base_url.rstrip('/')}/", parsed.path.lstrip("/"))
34
+
35
+ merged: dict[str, str] = dict(parse_qsl(parsed.query, keep_blank_values=True))
36
+ if query:
37
+ for key, value in query.items():
38
+ if value is None:
39
+ continue
40
+ merged[str(key)] = str(value)
41
+ if not merged:
42
+ return absolute
43
+ return f"{absolute}?{urlencode(merged)}"
44
+
45
+
46
+ def parse_response(response: Any) -> dict[str, Any]:
47
+ """Parse httpx response body in a tool-friendly format."""
48
+ content_type = response.headers.get("content-type", "")
49
+ body: Any
50
+ if "application/json" in content_type.lower():
51
+ try:
52
+ body = response.json()
53
+ except ValueError:
54
+ body = _truncate_text(response.text)
55
+ else:
56
+ body = _truncate_text(response.text)
57
+
58
+ return {
59
+ "ok": response.is_success,
60
+ "status_code": response.status_code,
61
+ "url": str(response.url),
62
+ "content_type": content_type,
63
+ "body": body,
64
+ }
65
+
66
+
67
+ def _truncate_text(value: str, max_chars: int = 4000) -> str:
68
+ if len(value) <= max_chars:
69
+ return value
70
+ return f"{value[:max_chars]}...(truncated)"