pcp-mcp 0.1.0__tar.gz → 1.0.1__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.
Files changed (31) hide show
  1. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/PKG-INFO +22 -10
  2. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/README.md +19 -8
  3. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/pyproject.toml +14 -2
  4. pcp_mcp-1.0.1/src/pcp_mcp/AGENTS.md +70 -0
  5. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/__init__.py +4 -0
  6. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/client.py +28 -0
  7. pcp_mcp-1.0.1/src/pcp_mcp/config.py +107 -0
  8. pcp_mcp-1.0.1/src/pcp_mcp/context.py +104 -0
  9. pcp_mcp-1.0.1/src/pcp_mcp/icons.py +31 -0
  10. pcp_mcp-1.0.1/src/pcp_mcp/middleware.py +75 -0
  11. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/models.py +10 -0
  12. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/prompts/__init__.py +18 -5
  13. pcp_mcp-1.0.1/src/pcp_mcp/py.typed +0 -0
  14. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/resources/catalog.py +76 -2
  15. pcp_mcp-1.0.1/src/pcp_mcp/resources/health.py +117 -0
  16. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/server.py +20 -0
  17. pcp_mcp-1.0.1/src/pcp_mcp/tools/AGENTS.md +61 -0
  18. pcp_mcp-1.0.1/src/pcp_mcp/tools/metrics.py +186 -0
  19. pcp_mcp-1.0.1/src/pcp_mcp/tools/system.py +481 -0
  20. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/utils/__init__.py +0 -4
  21. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/utils/extractors.py +18 -0
  22. pcp_mcp-0.1.0/src/pcp_mcp/config.py +0 -50
  23. pcp_mcp-0.1.0/src/pcp_mcp/context.py +0 -57
  24. pcp_mcp-0.1.0/src/pcp_mcp/resources/health.py +0 -74
  25. pcp_mcp-0.1.0/src/pcp_mcp/tools/metrics.py +0 -148
  26. pcp_mcp-0.1.0/src/pcp_mcp/tools/system.py +0 -229
  27. pcp_mcp-0.1.0/src/pcp_mcp/utils/decorators.py +0 -38
  28. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/errors.py +0 -0
  29. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/resources/__init__.py +0 -0
  30. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/tools/__init__.py +0 -0
  31. {pcp_mcp-0.1.0 → pcp_mcp-1.0.1}/src/pcp_mcp/utils/builders.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pcp-mcp
3
- Version: 0.1.0
3
+ Version: 1.0.1
4
4
  Summary: MCP server for Performance Co-Pilot
5
5
  Keywords: mcp,pcp,performance-co-pilot,monitoring,model-context-protocol
6
6
  Author: Major Hayden
7
7
  Author-email: Major Hayden <major@mhtx.net>
8
8
  License-Expression: MIT
9
- Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Intended Audience :: System Administrators
12
12
  Classifier: License :: OSI Approved :: MIT License
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Topic :: System :: Monitoring
19
19
  Classifier: Typing :: Typed
20
+ Requires-Dist: cachetools>=5.0
20
21
  Requires-Dist: fastmcp>=2.0.0
21
22
  Requires-Dist: httpx>=0.27
22
23
  Requires-Dist: pydantic-settings>=2.0.0
@@ -32,6 +33,14 @@ MCP server for [Performance Co-Pilot (PCP)](https://pcp.io/) metrics.
32
33
 
33
34
  Query system performance metrics via the Model Context Protocol - CPU, memory, disk I/O, network, processes, and more.
34
35
 
36
+ 📖 **[Full Documentation](https://major.github.io/pcp-mcp)** | 🚀 **[Getting Started](https://major.github.io/pcp-mcp/getting-started/)**
37
+
38
+ [![CI](https://github.com/major/pcp-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/major/pcp-mcp/actions/workflows/ci.yml)
39
+ [![codecov](https://codecov.io/gh/major/pcp-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/major/pcp-mcp)
40
+ [![PyPI version](https://badge.fury.io/py/pcp-mcp.svg)](https://pypi.org/project/pcp-mcp/)
41
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
42
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
43
+
35
44
  ## 🚀 Installation
36
45
 
37
46
  ```bash
@@ -47,15 +56,15 @@ uv add pcp-mcp
47
56
  ## 📋 Requirements
48
57
 
49
58
  - **Python**: 3.10+
50
- - **PCP**: Performance Co-Pilot with `pmproxy` running
59
+ - **PCP**: Performance Co-Pilot with `pmcd` and `pmproxy` running
51
60
  ```bash
52
61
  # Fedora/RHEL/CentOS
53
62
  sudo dnf install pcp
54
- sudo systemctl enable --now pmproxy
63
+ sudo systemctl enable --now pmcd pmproxy
55
64
 
56
65
  # Ubuntu/Debian
57
66
  sudo apt install pcp
58
- sudo systemctl enable --now pmproxy
67
+ sudo systemctl enable --now pmcd pmproxy
59
68
  ```
60
69
 
61
70
  ## ⚙️ Configuration
@@ -68,9 +77,12 @@ Configure via environment variables:
68
77
  | `PCP_PORT` | pmproxy port | `44322` |
69
78
  | `PCP_TARGET_HOST` | Target pmcd host to monitor | `localhost` |
70
79
  | `PCP_USE_TLS` | Use HTTPS for pmproxy | `false` |
80
+ | `PCP_TLS_VERIFY` | Verify TLS certificates | `true` |
81
+ | `PCP_TLS_CA_BUNDLE` | Path to custom CA bundle | (optional) |
71
82
  | `PCP_TIMEOUT` | Request timeout (seconds) | `30` |
72
83
  | `PCP_USERNAME` | HTTP basic auth user | (optional) |
73
84
  | `PCP_PASSWORD` | HTTP basic auth password | (optional) |
85
+ | `PCP_ALLOWED_HOSTS` | Hostspecs allowed via host param | (optional) |
74
86
 
75
87
  ## 🎯 Usage
76
88
 
@@ -163,9 +175,9 @@ For remote monitoring:
163
175
 
164
176
  Browse metrics via MCP resources:
165
177
 
166
- - `pcp://metrics` - List all available metrics (grouped by prefix)
167
- - `pcp://system/snapshot` - Latest system snapshot
168
- - `pcp://processes/top` - Top processes
178
+ - `pcp://health` - Quick system health summary
179
+ - `pcp://metrics/common` - Catalog of commonly used metrics
180
+ - `pcp://namespaces` - Live-discovered metric namespaces
169
181
 
170
182
  ## 💡 Use Cases
171
183
 
@@ -191,9 +203,9 @@ Ask Claude to:
191
203
  ## 🏗️ Architecture
192
204
 
193
205
  ```
194
- ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
206
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
195
207
  │ LLM │ ◄─MCP─► │ pcp-mcp │ ◄─HTTP─► │ pmproxy │ ◄─────► │ pmcd │
196
- └─────────┘ └─────────┘ └─────────┘ └─────────┘
208
+ └─────────┘ └─────────┘ └─────────┘ └─────────┘
197
209
  (REST API) (metrics)
198
210
  ```
199
211
 
@@ -4,6 +4,14 @@ MCP server for [Performance Co-Pilot (PCP)](https://pcp.io/) metrics.
4
4
 
5
5
  Query system performance metrics via the Model Context Protocol - CPU, memory, disk I/O, network, processes, and more.
6
6
 
7
+ 📖 **[Full Documentation](https://major.github.io/pcp-mcp)** | 🚀 **[Getting Started](https://major.github.io/pcp-mcp/getting-started/)**
8
+
9
+ [![CI](https://github.com/major/pcp-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/major/pcp-mcp/actions/workflows/ci.yml)
10
+ [![codecov](https://codecov.io/gh/major/pcp-mcp/branch/main/graph/badge.svg)](https://codecov.io/gh/major/pcp-mcp)
11
+ [![PyPI version](https://badge.fury.io/py/pcp-mcp.svg)](https://pypi.org/project/pcp-mcp/)
12
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
14
+
7
15
  ## 🚀 Installation
8
16
 
9
17
  ```bash
@@ -19,15 +27,15 @@ uv add pcp-mcp
19
27
  ## 📋 Requirements
20
28
 
21
29
  - **Python**: 3.10+
22
- - **PCP**: Performance Co-Pilot with `pmproxy` running
30
+ - **PCP**: Performance Co-Pilot with `pmcd` and `pmproxy` running
23
31
  ```bash
24
32
  # Fedora/RHEL/CentOS
25
33
  sudo dnf install pcp
26
- sudo systemctl enable --now pmproxy
34
+ sudo systemctl enable --now pmcd pmproxy
27
35
 
28
36
  # Ubuntu/Debian
29
37
  sudo apt install pcp
30
- sudo systemctl enable --now pmproxy
38
+ sudo systemctl enable --now pmcd pmproxy
31
39
  ```
32
40
 
33
41
  ## ⚙️ Configuration
@@ -40,9 +48,12 @@ Configure via environment variables:
40
48
  | `PCP_PORT` | pmproxy port | `44322` |
41
49
  | `PCP_TARGET_HOST` | Target pmcd host to monitor | `localhost` |
42
50
  | `PCP_USE_TLS` | Use HTTPS for pmproxy | `false` |
51
+ | `PCP_TLS_VERIFY` | Verify TLS certificates | `true` |
52
+ | `PCP_TLS_CA_BUNDLE` | Path to custom CA bundle | (optional) |
43
53
  | `PCP_TIMEOUT` | Request timeout (seconds) | `30` |
44
54
  | `PCP_USERNAME` | HTTP basic auth user | (optional) |
45
55
  | `PCP_PASSWORD` | HTTP basic auth password | (optional) |
56
+ | `PCP_ALLOWED_HOSTS` | Hostspecs allowed via host param | (optional) |
46
57
 
47
58
  ## 🎯 Usage
48
59
 
@@ -135,9 +146,9 @@ For remote monitoring:
135
146
 
136
147
  Browse metrics via MCP resources:
137
148
 
138
- - `pcp://metrics` - List all available metrics (grouped by prefix)
139
- - `pcp://system/snapshot` - Latest system snapshot
140
- - `pcp://processes/top` - Top processes
149
+ - `pcp://health` - Quick system health summary
150
+ - `pcp://metrics/common` - Catalog of commonly used metrics
151
+ - `pcp://namespaces` - Live-discovered metric namespaces
141
152
 
142
153
  ## 💡 Use Cases
143
154
 
@@ -163,9 +174,9 @@ Ask Claude to:
163
174
  ## 🏗️ Architecture
164
175
 
165
176
  ```
166
- ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
177
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
167
178
  │ LLM │ ◄─MCP─► │ pcp-mcp │ ◄─HTTP─► │ pmproxy │ ◄─────► │ pmcd │
168
- └─────────┘ └─────────┘ └─────────┘ └─────────┘
179
+ └─────────┘ └─────────┘ └─────────┘ └─────────┘
169
180
  (REST API) (metrics)
170
181
  ```
171
182
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pcp-mcp"
3
- version = "0.1.0"
3
+ version = "1.0.1"
4
4
  description = "MCP server for Performance Co-Pilot"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -8,7 +8,7 @@ authors = [{ name = "Major Hayden", email = "major@mhtx.net" }]
8
8
  requires-python = ">=3.10"
9
9
  keywords = ["mcp", "pcp", "performance-co-pilot", "monitoring", "model-context-protocol"]
10
10
  classifiers = [
11
- "Development Status :: 3 - Alpha",
11
+ "Development Status :: 5 - Production/Stable",
12
12
  "Intended Audience :: Developers",
13
13
  "Intended Audience :: System Administrators",
14
14
  "License :: OSI Approved :: MIT License",
@@ -21,6 +21,7 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
+ "cachetools>=5.0",
24
25
  "fastmcp>=2.0.0",
25
26
  "httpx>=0.27",
26
27
  "pydantic-settings>=2.0.0",
@@ -50,6 +51,7 @@ dev = [
50
51
  "pytest-randomly>=4.0.1",
51
52
  "mkdocs-material>=9.5",
52
53
  "mkdocstrings[python]>=0.27",
54
+ "commitizen>=4.0",
53
55
  ]
54
56
 
55
57
  [tool.uv.build-backend]
@@ -66,6 +68,7 @@ ignore = [
66
68
  "D104", # Missing docstring in public package
67
69
  "D105", # Missing docstring in magic method
68
70
  "D107", # Missing docstring in __init__
71
+ "UP045", # Optional[X] vs X | None - Pydantic needs Optional in Annotated params
69
72
  ]
70
73
 
71
74
  [tool.ruff.lint.per-file-ignores]
@@ -102,3 +105,12 @@ pythonPlatform = "Linux"
102
105
  venvPath = "."
103
106
  venv = ".venv"
104
107
  typeCheckingMode = "standard"
108
+
109
+ [tool.commitizen]
110
+ name = "cz_conventional_commits"
111
+ version_provider = "pep621"
112
+ tag_format = "v$version"
113
+ update_changelog_on_bump = true
114
+ changelog_file = "CHANGELOG.md"
115
+ changelog_incremental = true
116
+ bump_message = "bump: version $current_version → $new_version"
@@ -0,0 +1,70 @@
1
+ # pcp_mcp Core Package
2
+
3
+ ## OVERVIEW
4
+
5
+ Core MCP server package. Entry point, HTTP client, configuration, models, error handling.
6
+
7
+ ## STRUCTURE
8
+
9
+ ```
10
+ pcp_mcp/
11
+ ├── __init__.py # CLI entry (main() with argparse)
12
+ ├── server.py # FastMCP setup, lifespan context
13
+ ├── client.py # PCPClient async httpx wrapper
14
+ ├── config.py # PCPMCPSettings (Pydantic)
15
+ ├── models.py # Response models (SystemSnapshot, ProcessInfo, etc.)
16
+ ├── errors.py # Exception → ToolError mapping
17
+ ├── context.py # get_client(), get_settings() helpers
18
+ ├── middleware.py # Request caching middleware
19
+ ├── icons.py # System assessment icons (emoji mappings)
20
+ ├── tools/ # MCP tools (see tools/AGENTS.md)
21
+ ├── resources/ # MCP resources (health.py, catalog.py)
22
+ ├── utils/ # Extractors, builders
23
+ └── prompts/ # LLM system prompts
24
+ ```
25
+
26
+ ## KEY PATTERNS
27
+
28
+ ### Server Lifespan
29
+ ```python
30
+ @asynccontextmanager
31
+ async def lifespan(mcp: FastMCP) -> AsyncIterator[dict]:
32
+ async with PCPClient(...) as client:
33
+ yield {"client": client, "settings": settings}
34
+ ```
35
+ Tools access via `ctx.request_context.lifespan_context["client"]`.
36
+
37
+ ### Client Rate Calculation
38
+ `fetch_with_rates()` takes two samples, calculates per-second rates for counters.
39
+ Handles counter wrap-around (reset to 0) gracefully.
40
+
41
+ ### Error Mapping
42
+ ```python
43
+ try:
44
+ result = await client.fetch(names)
45
+ except Exception as e:
46
+ raise handle_pcp_error(e, "fetching metrics") from e
47
+ ```
48
+
49
+ ### Configuration
50
+ Pydantic settings with `env_prefix="PCP_"`. Computed properties: `base_url`, `auth`.
51
+
52
+ ## WHERE TO LOOK
53
+
54
+ | Task | File | Notes |
55
+ |------|------|-------|
56
+ | Add CLI flag | `__init__.py` | argparse in `main()` |
57
+ | Change transport | `__init__.py` | `server.run(transport=...)` |
58
+ | Add env var | `config.py` | Add field with `Field(default=...)` |
59
+ | New response type | `models.py` | Inherit `BaseModel` |
60
+ | Map new exception | `errors.py` | Add case to `handle_pcp_error()` |
61
+ | Access client in tool | `context.py` | Use `get_client(ctx)` |
62
+ | Add caching | `middleware.py` | Request caching layer |
63
+ | System icons | `icons.py` | Assessment emoji mappings |
64
+
65
+ ## ANTI-PATTERNS
66
+
67
+ - **NEVER** call `client.fetch()` for counter metrics expecting rates
68
+ - **NEVER** use client outside `async with` context
69
+ - **NEVER** log credentials from settings
70
+ - **ALWAYS** use `handle_pcp_error()` for exception wrapping
@@ -17,9 +17,13 @@ Environment Variables:
17
17
  PCP_PORT pmproxy port (default: 44322)
18
18
  PCP_TARGET_HOST Target pmcd host to monitor (default: localhost)
19
19
  PCP_USE_TLS Use HTTPS for pmproxy connection (default: false)
20
+ PCP_TLS_VERIFY Verify TLS certificates (default: true)
21
+ PCP_TLS_CA_BUNDLE Path to custom CA bundle for TLS (optional)
20
22
  PCP_TIMEOUT Request timeout in seconds (default: 30)
21
23
  PCP_USERNAME HTTP basic auth user (optional)
22
24
  PCP_PASSWORD HTTP basic auth password (optional)
25
+ PCP_ALLOWED_HOSTS Comma-separated hostspecs allowed via host parameter (optional)
26
+ If not set, only target_host is allowed. Use '*' for any host.
23
27
 
24
28
  Examples:
25
29
  # Monitor localhost (default)
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import sys
7
+ from typing import TYPE_CHECKING
7
8
 
8
9
  if sys.version_info >= (3, 11):
9
10
  from typing import Self
@@ -12,6 +13,12 @@ else:
12
13
 
13
14
  import httpx
14
15
 
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Callable, Coroutine
18
+ from typing import Any
19
+
20
+ ProgressCallback = Callable[[float, float, str], Coroutine[Any, Any, None]]
21
+
15
22
 
16
23
  class PCPClient:
17
24
  """Async client for pmproxy REST API.
@@ -24,6 +31,7 @@ class PCPClient:
24
31
  target_host: Which pmcd host to connect to (passed as hostspec).
25
32
  auth: Optional HTTP basic auth tuple (username, password).
26
33
  timeout: Request timeout in seconds.
34
+ verify: TLS verification (True, False, or path to CA bundle).
27
35
  """
28
36
 
29
37
  def __init__(
@@ -32,12 +40,14 @@ class PCPClient:
32
40
  target_host: str = "localhost",
33
41
  auth: tuple[str, str] | None = None,
34
42
  timeout: float = 30.0,
43
+ verify: bool | str = True,
35
44
  ) -> None:
36
45
  """Initialize the PCP client."""
37
46
  self._base_url = base_url
38
47
  self._target_host = target_host
39
48
  self._auth = auth
40
49
  self._timeout = timeout
50
+ self._verify = verify
41
51
  self._client: httpx.AsyncClient | None = None
42
52
  self._context_id: int | None = None
43
53
 
@@ -47,6 +57,7 @@ class PCPClient:
47
57
  base_url=self._base_url,
48
58
  auth=self._auth,
49
59
  timeout=self._timeout,
60
+ verify=self._verify,
50
61
  )
51
62
  resp = await self._client.get(
52
63
  "/pmapi/context",
@@ -185,6 +196,7 @@ class PCPClient:
185
196
  metric_names: list[str],
186
197
  counter_metrics: set[str],
187
198
  sample_interval: float = 1.0,
199
+ progress_callback: ProgressCallback | None = None,
188
200
  ) -> dict[str, dict]:
189
201
  """Fetch metrics, calculating rates for counters.
190
202
 
@@ -196,15 +208,31 @@ class PCPClient:
196
208
  metric_names: List of PCP metric names to fetch.
197
209
  counter_metrics: Set of metric names that are counters.
198
210
  sample_interval: Seconds between samples for rate calculation.
211
+ progress_callback: Optional async callback for progress updates.
212
+ Called with (current, total, message) during long operations.
199
213
 
200
214
  Returns:
201
215
  Dict mapping metric name to {value, instances} where value/instances
202
216
  contain the rate (for counters) or instant value (for gauges).
203
217
  """
218
+ if progress_callback:
219
+ await progress_callback(0, 100, "Collecting first sample...")
220
+
204
221
  t1 = await self.fetch(metric_names)
222
+
223
+ if progress_callback:
224
+ await progress_callback(20, 100, f"Waiting {sample_interval}s for rate calculation...")
225
+
205
226
  await asyncio.sleep(sample_interval)
227
+
228
+ if progress_callback:
229
+ await progress_callback(70, 100, "Collecting second sample...")
230
+
206
231
  t2 = await self.fetch(metric_names)
207
232
 
233
+ if progress_callback:
234
+ await progress_callback(90, 100, "Computing rates...")
235
+
208
236
  ts1 = t1.get("timestamp", 0.0)
209
237
  ts2 = t2.get("timestamp", 0.0)
210
238
  if isinstance(ts1, dict):
@@ -0,0 +1,107 @@
1
+ """Configuration for the PCP MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field, computed_field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class PCPMCPSettings(BaseSettings):
10
+ """Configuration for the PCP MCP server.
11
+
12
+ Attributes:
13
+ host: pmproxy host.
14
+ port: pmproxy port.
15
+ use_tls: Use HTTPS for pmproxy connection.
16
+ timeout: Request timeout in seconds.
17
+ target_host: Target pmcd host to monitor (can be remote hostname).
18
+ username: HTTP basic auth user.
19
+ password: HTTP basic auth password.
20
+ """
21
+
22
+ model_config = SettingsConfigDict(
23
+ env_file=".env",
24
+ env_prefix="PCP_",
25
+ extra="ignore",
26
+ )
27
+
28
+ host: str = Field(default="localhost", description="pmproxy host")
29
+ port: int = Field(default=44322, description="pmproxy port")
30
+ use_tls: bool = Field(default=False, description="Use HTTPS for pmproxy connection")
31
+ tls_verify: bool = Field(
32
+ default=True,
33
+ description="Verify TLS certificates when use_tls is enabled",
34
+ )
35
+ tls_ca_bundle: str | None = Field(
36
+ default=None,
37
+ description="Path to custom CA bundle for TLS verification",
38
+ )
39
+ timeout: float = Field(default=30.0, description="Request timeout in seconds")
40
+ target_host: str = Field(
41
+ default="localhost",
42
+ description="Target pmcd host to monitor (can be remote hostname)",
43
+ )
44
+ username: str | None = Field(default=None, description="HTTP basic auth user")
45
+ password: str | None = Field(default=None, description="HTTP basic auth password")
46
+ allowed_hosts: list[str] | None = Field(
47
+ default=None,
48
+ description=(
49
+ "Allowlist of hostspecs that can be queried via the host parameter. "
50
+ "If None, only the configured target_host is allowed (default). "
51
+ "Set to ['*'] to allow any host (use with caution)."
52
+ ),
53
+ )
54
+
55
+ @computed_field
56
+ @property
57
+ def base_url(self) -> str:
58
+ """URL for connecting to pmproxy."""
59
+ scheme = "https" if self.use_tls else "http"
60
+ return f"{scheme}://{self.host}:{self.port}"
61
+
62
+ @computed_field
63
+ @property
64
+ def auth(self) -> tuple[str, str] | None:
65
+ """Auth tuple for httpx, or None if no auth configured."""
66
+ if self.username and self.password:
67
+ return (self.username, self.password)
68
+ return None
69
+
70
+ @computed_field
71
+ @property
72
+ def verify(self) -> bool | str:
73
+ """TLS verification setting for httpx.
74
+
75
+ Returns:
76
+ False if verification disabled, path to CA bundle if specified,
77
+ or True for default system verification.
78
+ """
79
+ if not self.tls_verify:
80
+ return False
81
+ if self.tls_ca_bundle:
82
+ return self.tls_ca_bundle
83
+ return True
84
+
85
+ def is_host_allowed(self, host: str) -> bool:
86
+ """Check if a host is allowed by the allowlist.
87
+
88
+ Args:
89
+ host: The hostspec to validate.
90
+
91
+ Returns:
92
+ True if the host is allowed, False otherwise.
93
+ """
94
+ # Always allow the configured target_host
95
+ if host == self.target_host:
96
+ return True
97
+
98
+ # If no allowlist configured, only target_host is allowed
99
+ if self.allowed_hosts is None:
100
+ return False
101
+
102
+ # Wildcard allows everything
103
+ if "*" in self.allowed_hosts:
104
+ return True
105
+
106
+ # Check exact match in allowlist
107
+ return host in self.allowed_hosts
@@ -0,0 +1,104 @@
1
+ """Context helpers for safe lifespan context access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from typing import TYPE_CHECKING
8
+
9
+ from fastmcp import Context
10
+ from fastmcp.exceptions import ToolError
11
+
12
+ from pcp_mcp.client import PCPClient
13
+
14
+ if TYPE_CHECKING:
15
+ from pcp_mcp.config import PCPMCPSettings
16
+
17
+
18
+ def _validate_context(ctx: Context) -> None:
19
+ """Validate context has lifespan_context available.
20
+
21
+ Args:
22
+ ctx: MCP context.
23
+
24
+ Raises:
25
+ ToolError: If context is not available.
26
+ """
27
+ if ctx.request_context is None or ctx.request_context.lifespan_context is None:
28
+ raise ToolError("Server context not available")
29
+
30
+
31
+ def get_client(ctx: Context) -> PCPClient:
32
+ """Get PCPClient from context.
33
+
34
+ Args:
35
+ ctx: MCP context.
36
+
37
+ Returns:
38
+ The PCPClient instance.
39
+
40
+ Raises:
41
+ ToolError: If context is not available.
42
+ """
43
+ _validate_context(ctx)
44
+ assert ctx.request_context is not None
45
+ assert ctx.request_context.lifespan_context is not None
46
+ return ctx.request_context.lifespan_context["client"]
47
+
48
+
49
+ def get_settings(ctx: Context) -> PCPMCPSettings:
50
+ """Get settings from context.
51
+
52
+ Args:
53
+ ctx: MCP context.
54
+
55
+ Returns:
56
+ The PCPMCPSettings instance.
57
+
58
+ Raises:
59
+ ToolError: If context is not available.
60
+ """
61
+ _validate_context(ctx)
62
+ assert ctx.request_context is not None
63
+ assert ctx.request_context.lifespan_context is not None
64
+ return ctx.request_context.lifespan_context["settings"]
65
+
66
+
67
+ @asynccontextmanager
68
+ async def get_client_for_host(ctx: Context, host: str | None = None) -> AsyncIterator[PCPClient]:
69
+ """Get a PCPClient for the specified host.
70
+
71
+ If host is None or matches the configured target_host, yields the existing
72
+ lifespan client. Otherwise, creates a new ad-hoc client for the specified
73
+ hostspec and cleans it up on exit.
74
+
75
+ Args:
76
+ ctx: MCP context.
77
+ host: Target pmcd hostspec to query. None uses the default.
78
+
79
+ Yields:
80
+ PCPClient connected to the specified host.
81
+
82
+ Raises:
83
+ ToolError: If context is not available, host is not allowed, or host is unreachable.
84
+ """
85
+ settings = get_settings(ctx)
86
+
87
+ if host is None or host == settings.target_host:
88
+ yield get_client(ctx)
89
+ return
90
+
91
+ if not settings.is_host_allowed(host):
92
+ raise ToolError(
93
+ f"Host '{host}' is not in the allowed hosts list. "
94
+ f"Configure PCP_ALLOWED_HOSTS to permit additional hosts."
95
+ )
96
+
97
+ async with PCPClient(
98
+ base_url=settings.base_url,
99
+ target_host=host,
100
+ auth=settings.auth,
101
+ timeout=settings.timeout,
102
+ verify=settings.verify,
103
+ ) as client:
104
+ yield client
@@ -0,0 +1,31 @@
1
+ """Centralized icons and tags for MCP components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mcp.types import Icon
6
+
7
+ ICON_METRICS = Icon(src="data:,📊", mimeType="text/plain")
8
+ ICON_SEARCH = Icon(src="data:,🔍", mimeType="text/plain")
9
+ ICON_INFO = Icon(src="data:,📋", mimeType="text/plain")
10
+ ICON_SYSTEM = Icon(src="data:,💻", mimeType="text/plain")
11
+ ICON_PROCESS = Icon(src="data:,⚙️", mimeType="text/plain")
12
+ ICON_HEALTH = Icon(src="data:,💚", mimeType="text/plain")
13
+ ICON_CATALOG = Icon(src="data:,📚", mimeType="text/plain")
14
+ ICON_NAMESPACE = Icon(src="data:,🗂️", mimeType="text/plain")
15
+ ICON_DIAGNOSE = Icon(src="data:,🔬", mimeType="text/plain")
16
+ ICON_CPU = Icon(src="data:,🖥️", mimeType="text/plain")
17
+ ICON_MEMORY = Icon(src="data:,🧠", mimeType="text/plain")
18
+ ICON_DISK = Icon(src="data:,💾", mimeType="text/plain")
19
+ ICON_NETWORK = Icon(src="data:,🌐", mimeType="text/plain")
20
+
21
+ TAGS_METRICS = {"metrics", "pcp"}
22
+ TAGS_SYSTEM = {"system", "monitoring", "performance"}
23
+ TAGS_PROCESS = {"processes", "monitoring"}
24
+ TAGS_HEALTH = {"health", "status", "summary"}
25
+ TAGS_CATALOG = {"catalog", "reference", "documentation"}
26
+ TAGS_DISCOVERY = {"discovery", "namespace", "exploration"}
27
+ TAGS_CPU = {"cpu", "troubleshooting", "performance"}
28
+ TAGS_MEMORY = {"memory", "troubleshooting", "performance"}
29
+ TAGS_DISK = {"disk", "io", "troubleshooting", "performance"}
30
+ TAGS_NETWORK = {"network", "troubleshooting", "performance"}
31
+ TAGS_DIAGNOSE = {"diagnosis", "troubleshooting", "workflow"}