k8stools 2.0.2__tar.gz → 2.0.3__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 (39) hide show
  1. {k8stools-2.0.2 → k8stools-2.0.3}/CLAUDE.md +41 -1
  2. {k8stools-2.0.2 → k8stools-2.0.3}/PKG-INFO +1 -1
  3. {k8stools-2.0.2 → k8stools-2.0.3}/pyproject.toml +1 -1
  4. k8stools-2.0.3/redaction-bug.md +25 -0
  5. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/__init__.py +1 -1
  6. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/k8s_tools.py +54 -19
  7. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mock_state.py +10 -1
  8. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/redaction.py +92 -11
  9. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_capture.py +5 -1
  10. k8stools-2.0.3/tests/test_duration_format.py +182 -0
  11. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_redaction.py +122 -0
  12. k8stools-2.0.3/time-bug-report.md +159 -0
  13. {k8stools-2.0.2 → k8stools-2.0.3}/uv.lock +1 -1
  14. {k8stools-2.0.2 → k8stools-2.0.3}/.claude/settings.local.json +0 -0
  15. {k8stools-2.0.2 → k8stools-2.0.3}/.github/workflows/unit-tests.yml +0 -0
  16. {k8stools-2.0.2 → k8stools-2.0.3}/.gitignore +0 -0
  17. {k8stools-2.0.2 → k8stools-2.0.3}/.python-version +0 -0
  18. {k8stools-2.0.2 → k8stools-2.0.3}/.vscode/mcp.json +0 -0
  19. {k8stools-2.0.2 → k8stools-2.0.3}/.vscode/settings.json +0 -0
  20. {k8stools-2.0.2 → k8stools-2.0.3}/LICENSE +0 -0
  21. {k8stools-2.0.2 → k8stools-2.0.3}/README.md +0 -0
  22. {k8stools-2.0.2 → k8stools-2.0.3}/chat-with-cluster.png +0 -0
  23. {k8stools-2.0.2 → k8stools-2.0.3}/designs/mock-state-capture.md +0 -0
  24. {k8stools-2.0.2 → k8stools-2.0.3}/docs/ROADMAP.md +0 -0
  25. {k8stools-2.0.2 → k8stools-2.0.3}/envrc.template +0 -0
  26. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/capture.py +0 -0
  27. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/fixtures/otel-demo.json +0 -0
  28. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mcp_client.py +0 -0
  29. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mcp_server.py +0 -0
  30. {k8stools-2.0.2 → k8stools-2.0.3}/src/k8stools/mock_tools.py +0 -0
  31. {k8stools-2.0.2 → k8stools-2.0.3}/tests/conftest.py +0 -0
  32. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_k8s_tools.py +0 -0
  33. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_k8s_tools_realk8s.py +0 -0
  34. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mcp_client.py +0 -0
  35. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mock_state.py +0 -0
  36. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_mock_tools.py +0 -0
  37. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_new_tools.py +0 -0
  38. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_replicaset_tool.py +0 -0
  39. {k8stools-2.0.2 → k8stools-2.0.3}/tests/test_version.py +0 -0
@@ -60,7 +60,22 @@ Tools in `k8s_tools.py` follow three patterns:
60
60
  2. **API-typed** — Pydantic models that match K8s client types with lesser-used fields omitted (e.g. `get_pod_container_statuses`)
61
61
  3. **Raw dict** — `to_dict()` on the K8s client object; return type is `dict[str, Any]` with fields documented in the docstring (e.g. `get_pod_spec`)
62
62
 
63
- Use `datetime.timedelta` for age/duration fields. Use snake_case field names. Include `pod_name`/`namespace` in container-level models for context.
63
+ Use the `Duration` type (`k8s_tools.Duration`) for age/duration fields, not a bare
64
+ `datetime.timedelta`. It is a `timedelta` that normalizes to whole, non-negative
65
+ seconds on validation. Pydantic describes a `timedelta` as
66
+ `{"type": "string", "format": "duration"}` but serializes a microsecond-bearing one
67
+ as `"P2DT5M27.978616S"`; JSON Schema's `duration` format points at RFC 3339
68
+ Appendix A, whose grammar allows neither a decimal point nor a sign. An MCP client
69
+ that validates structured output against the declared schema (ajv does; Python's
70
+ `jsonschema` skips `duration` unless the optional `isoduration` extra is installed)
71
+ therefore rejected the whole call, on every row of every list tool. Since every age
72
+ is `now() - <k8s timestamp>` and Kubernetes timestamps are whole-second, the
73
+ fractional part was never information — just the microseconds of that one `now()`.
74
+ This is the same rule `MockState`'s replay clock already applies, for the same
75
+ reason. `tests/test_duration_format.py` checks each model's serialized output
76
+ against the format its own schema declares.
77
+
78
+ Use snake_case field names. Include `pod_name`/`namespace` in container-level models for context.
64
79
 
65
80
  All tools are collected in the `TOOLS` list in `k8s_tools.py` for agent/MCP registration.
66
81
 
@@ -163,6 +178,31 @@ keys) and values under keys/env-var names matching
163
178
  separators and camelCase, so `apiKey` matches and `VALKEY_ADDR` does not),
164
179
  replacing them with a visible `[REDACTED]` marker.
165
180
 
181
+ Redaction is **span-granular, with two deliberate exceptions.** A matched token
182
+ (AWS key, JWT) is replaced where it sits, leaving the surrounding text readable.
183
+ The exceptions:
184
+
185
+ - **PEM blocks go whole.** The pattern matches only the `-----BEGIN ... PRIVATE
186
+ KEY-----` header, and the secret is the base64 body *after* it — substituting
187
+ just the matched span would black out the header and publish the key. Hence the
188
+ `_TOKEN_SHAPE_RES` / `_CONTAINMENT_SHAPE_RES` split: never move a pattern into
189
+ the former unless its match is the entire secret.
190
+ - **A string that parses as a JSON object/array is recursed into** and
191
+ re-serialized, rather than treated as one opaque value. Config-as-JSON under a
192
+ single ConfigMap key is common, and opacity failed in both directions: one
193
+ incidental token match blacked out an entire non-secret config file, while a real
194
+ `{"password": ...}` inside a blob was invisible to the name rule and passed
195
+ through verbatim. Nothing is re-serialized when nothing matched, so clean values
196
+ keep their exact formatting.
197
+
198
+ The `key` exemption below applies *inside* these blobs too. Lifting it there
199
+ looked obviously right — those names are the author's, not Kubernetes schema —
200
+ and re-measuring said otherwise: on the 38-pod cluster it added 12 redactions,
201
+ every one `tags[].key: "instance"` in a Grafana dashboard ConfigMap, and no
202
+ secrets. Structural `key` fields dominate inside config documents just as they do
203
+ in the API. With the exemption kept, this whole change is redaction-neutral on
204
+ that cluster: same 6 redactions, same paths, same values as before it.
205
+
166
206
  A field named *exactly* `key` is exempt — in the Kubernetes API that is always
167
207
  structural (taint, map entry, label selector, projected-volume item), never a
168
208
  credential. Before that exemption a real 38-pod capture produced 139 redactions of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: k8stools
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Tools and an MCP server for building Kubernetes agents
5
5
  Project-URL: Homepage, https://github.com/BenedatLLC/k8stools
6
6
  Author: Jeff Fischer
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "k8stools"
4
- version = "2.0.2"
4
+ version = "2.0.3"
5
5
  description = "Tools and an MCP server for building Kubernetes agents"
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.12"
@@ -0,0 +1,25 @@
1
+ # get_configmap / ConfigMap.data redaction: a single value-shape match anywhere in a JSON-serialized ConfigMap value blacks out the entire value, not just the match
2
+ ## Repro:
3
+ any ConfigMap whose data value is a JSON-encoded blob (common pattern: config-as-JSON-string under one key) where some substring incidentally matches the JWT/AKIA/PEM regexes.
4
+
5
+ ## Impact:
6
+ legitimate, non-secret operational config (in our case: warm-runtime pool target count) becomes fully unreadable via the MCP tool, forcing a workaround (grep-ing cronjob logs instead).
7
+
8
+ ## Suggested fixes (in rough order of effort):
9
+ * When a string value looks like JSON (json.loads succeeds), recurse redaction into the parsed structure and re-serialize, instead of treating it as one opaque string.
10
+ * At minimum, redact only the matched span within the string (rx.sub(REDACTED, s)) rather than replacing the whole value when a partial match is found — this alone would have kept the rest of warm-runtimes.json visible.
11
+ * Note in output when a value is a "detected JSON string with an internal match" vs. a flat credential-shaped value, so callers can tell which failure mode they hit.
12
+
13
+ ## What we saw
14
+ Reading the warm-runtimes-config ConfigMap in openhands-cloud-c3:
15
+ ```
16
+ {
17
+ "name": "warm-runtimes-config",
18
+ "namespace": "openhands-cloud-c3",
19
+ "data": {
20
+ "warm-runtimes.json": "[REDACTED]"
21
+ },
22
+ "binary_data_keys": []
23
+ }
24
+ ```
25
+ The whole value came back redacted, even though this ConfigMap is just warm-pool sizing config (count, runtime spec names) — nothing secret. We had to fall back to the reconciler's own log line (Missing N runtimes for v1, creating N warm runtimes...) to get the real number (5).
@@ -78,4 +78,4 @@ the built-in OTel Demo snapshot by default, or your own via load_mock_state() or
78
78
  the server's --state-file option.
79
79
  """
80
80
 
81
- __version__ = "2.0.2"
81
+ __version__ = "2.0.3"
@@ -16,9 +16,9 @@ import sys
16
16
  import os
17
17
  import logging
18
18
  import datetime
19
- from typing import Optional, Union, Literal, Any
19
+ from typing import Optional, Union, Literal, Any, Annotated
20
20
 
21
- from pydantic import BaseModel, Field
21
+ from pydantic import BaseModel, Field, AfterValidator
22
22
  import yaml
23
23
 
24
24
  from kubernetes import client, config
@@ -85,12 +85,47 @@ def _get_batch_v1_api_client() -> client.BatchV1Api:
85
85
  raise K8sConfigError(f"Unexpected error: {e}") from e
86
86
 
87
87
 
88
+ def _to_whole_seconds(td: datetime.timedelta) -> datetime.timedelta:
89
+ """Truncate a duration to whole seconds, flooring at zero.
90
+
91
+ Both adjustments keep the value inside the grammar our own JSON Schema
92
+ declares for it (see :data:`Duration`).
93
+
94
+ Truncation: every age here is ``now() - <k8s timestamp>``, and Kubernetes
95
+ timestamps are whole-second, so the sub-second part of an age is nothing but
96
+ the microseconds of the ``now()`` that happened to compute it - noise, and
97
+ identical across every row of a single response. This matches the rule
98
+ ``MockState``'s replay clock already follows for the same reason.
99
+
100
+ Flooring: a cluster whose clock is ahead of ours yields a small negative age,
101
+ which pydantic renders as ``-PT5S``. A duration has no sign in this grammar,
102
+ so clamp it; at this granularity "just created" is the honest reading anyway.
103
+ """
104
+ return datetime.timedelta(seconds=max(int(td.total_seconds()), 0))
105
+
106
+
107
+ #: A ``timedelta`` field on a response model.
108
+ #:
109
+ #: Pydantic's default JSON Schema for ``timedelta`` is
110
+ #: ``{"type": "string", "format": "duration"}``, but its default *serialization*
111
+ #: emits fractional seconds (``"P2DT5M27.978616S"``) whenever the value carries
112
+ #: microseconds. JSON Schema's ``duration`` format references RFC 3339 Appendix A,
113
+ #: whose grammar admits only integer components - so pydantic's own schema and its
114
+ #: own serializer disagree, and an MCP client that validates structured output
115
+ #: against the declared schema (ajv does; Python's ``jsonschema`` skips ``duration``
116
+ #: unless the optional ``isoduration`` extra is installed) rejects the tool call
117
+ #: outright, on every row, before the caller sees any data.
118
+ #:
119
+ #: Normalizing the value rather than the schema keeps ``format: "duration"``
120
+ #: accurate and keeps direct Python callers' fields real ``timedelta`` objects.
121
+ Duration = Annotated[datetime.timedelta, AfterValidator(_to_whole_seconds)]
122
+
88
123
 
89
124
  class NamespaceSummary(BaseModel):
90
125
  """Summary information about a namespace, like returned by `kubectl get namespace`"""
91
126
  name: str
92
127
  status: str
93
- age: datetime.timedelta
128
+ age: Duration
94
129
 
95
130
 
96
131
  def get_namespaces() -> list[NamespaceSummary]:
@@ -142,7 +177,7 @@ class NodeSummary(BaseModel):
142
177
  name: str
143
178
  status: str
144
179
  roles: list[str]
145
- age: datetime.timedelta
180
+ age: Duration
146
181
  version: str
147
182
  internal_ip: Optional[str] = None
148
183
  external_ip: Optional[str] = None
@@ -347,8 +382,8 @@ class PodSummary(BaseModel):
347
382
  total_containers: int
348
383
  ready_containers: int
349
384
  restarts: int
350
- last_restart: Optional[datetime.timedelta]
351
- age: datetime.timedelta
385
+ last_restart: Optional[Duration]
386
+ age: Duration
352
387
  ip: Optional[str] = None
353
388
  node: Optional[str] = None
354
389
 
@@ -501,7 +536,7 @@ def _format_timedelta(td: Optional[datetime.timedelta]) -> str:
501
536
 
502
537
  class EventSummary(BaseModel):
503
538
  """This is the representation of a Kubernetes Event"""
504
- last_seen: Optional[datetime.timedelta] # Time since event occurred
539
+ last_seen: Optional[Duration] # Time since event occurred
505
540
  type: str
506
541
  reason: str
507
542
  object: str
@@ -942,7 +977,7 @@ class DeploymentSummary(BaseModel):
942
977
  ready_replicas: int
943
978
  up_to_date_relicas: int
944
979
  available_replicas: int
945
- age: datetime.timedelta
980
+ age: Duration
946
981
 
947
982
  def get_deployment_summaries(namespace: Optional[str] = None) -> list[DeploymentSummary]:
948
983
  """
@@ -1047,7 +1082,7 @@ class ReplicaSetSummary(BaseModel):
1047
1082
  current_replicas: int
1048
1083
  ready_replicas: int
1049
1084
  images: list[str]
1050
- age: datetime.timedelta
1085
+ age: Duration
1051
1086
 
1052
1087
 
1053
1088
  def get_replicaset_summaries(namespace: Optional[str] = None,
@@ -1227,7 +1262,7 @@ class ServiceSummary(BaseModel):
1227
1262
  cluster_ip: Optional[str] = None
1228
1263
  external_ip: Optional[str] = None
1229
1264
  ports: list[PortInfo]
1230
- age: datetime.timedelta
1265
+ age: Duration
1231
1266
  selector: dict[str, str] = Field(default_factory=dict)
1232
1267
  labels: dict[str, str] = Field(default_factory=dict)
1233
1268
  annotations: dict[str, str] = Field(default_factory=dict)
@@ -1378,7 +1413,7 @@ class ConfigMapSummary(BaseModel):
1378
1413
  namespace: str
1379
1414
  key_count: int
1380
1415
  data_size: int # total bytes across all values (data + binary_data)
1381
- age: datetime.timedelta
1416
+ age: Duration
1382
1417
 
1383
1418
 
1384
1419
  def _configmap_key_count_and_size(config_map) -> tuple[int, int]:
@@ -1548,7 +1583,7 @@ class StatefulSetSummary(BaseModel):
1548
1583
  current_replicas: int
1549
1584
  update_strategy: str
1550
1585
  service_name: Optional[str] = None
1551
- age: datetime.timedelta
1586
+ age: Duration
1552
1587
 
1553
1588
 
1554
1589
  def get_statefulset_summaries(namespace: Optional[str] = None) -> list[StatefulSetSummary]:
@@ -1679,9 +1714,9 @@ class CronJobSummary(BaseModel):
1679
1714
  schedule: str
1680
1715
  suspend: bool
1681
1716
  active: int
1682
- last_schedule_time: Optional[datetime.timedelta] = None
1683
- last_successful_time: Optional[datetime.timedelta] = None
1684
- age: datetime.timedelta
1717
+ last_schedule_time: Optional[Duration] = None
1718
+ last_successful_time: Optional[Duration] = None
1719
+ age: Duration
1685
1720
  containers: list[ContainerTemplateSummary] = Field(default_factory=list)
1686
1721
 
1687
1722
 
@@ -1791,10 +1826,10 @@ class JobSummary(BaseModel):
1791
1826
  active: int
1792
1827
  succeeded: int
1793
1828
  failed: int
1794
- start_time: Optional[datetime.timedelta] = None
1795
- completion_time: Optional[datetime.timedelta] = None
1829
+ start_time: Optional[Duration] = None
1830
+ completion_time: Optional[Duration] = None
1796
1831
  conditions: list[str] = Field(default_factory=list)
1797
- age: datetime.timedelta
1832
+ age: Duration
1798
1833
  containers: list[ContainerTemplateSummary] = Field(default_factory=list)
1799
1834
 
1800
1835
 
@@ -2041,7 +2076,7 @@ class PVCSummary(BaseModel):
2041
2076
  access_modes: list[str] = Field(default_factory=list)
2042
2077
  storage_class: Optional[str] = None
2043
2078
  mounted_by: list[str] = Field(default_factory=list)
2044
- age: datetime.timedelta
2079
+ age: Duration
2045
2080
 
2046
2081
 
2047
2082
  def get_pvc_summaries(namespace: Optional[str] = None) -> list[PVCSummary]:
@@ -76,9 +76,18 @@ class CaptureFormatError(Exception):
76
76
  # ---------------------------------------------------------------------------
77
77
 
78
78
  def _annotation_contains(annotation: Any, target: type) -> bool:
79
- """True if ``target`` appears anywhere in ``annotation`` (unwrapping Optional/Union)."""
79
+ """True if ``target`` appears anywhere in ``annotation``.
80
+
81
+ Unwraps ``Optional``/``Union`` and ``Annotated``. The latter matters because
82
+ ``k8s_tools.Duration`` is an annotated ``timedelta``: pydantic strips the
83
+ metadata from a bare field's ``annotation`` but keeps it inside an
84
+ ``Optional[...]``, so without this an optional duration would silently encode
85
+ under its plain name instead of ``<field>_seconds`` and fail to round-trip.
86
+ """
80
87
  if annotation is target:
81
88
  return True
89
+ if hasattr(annotation, "__metadata__"): # Annotated[X, ...] -> X
90
+ return _annotation_contains(typing.get_args(annotation)[0], target)
82
91
  origin = typing.get_origin(annotation)
83
92
  if origin is Union or origin is types.UnionType:
84
93
  return any(_annotation_contains(arg, target) for arg in typing.get_args(annotation))
@@ -29,7 +29,9 @@ Two independent signals trigger a redaction:
29
29
 
30
30
  1. **Value shape** — the string looks like a credential regardless of where it
31
31
  sits: AWS access keys (``AKIA...``), bearer/JWT tokens (``eyJ...``), or PEM
32
- private-key blocks.
32
+ private-key blocks. A self-contained token is replaced where it sits, leaving
33
+ the rest of the surrounding text readable; a PEM header stands for a secret
34
+ that continues past the match, so that value goes in full.
33
35
  2. **Key / field name** — the enclosing map key, model field, or env-var name
34
36
  contains one of ``key``, ``secret``, ``token``, ``password``, ``passwd``,
35
37
  ``credential``, ``cred`` **as a whole word**. Names are split on separators and
@@ -48,6 +50,14 @@ Two independent signals trigger a redaction:
48
50
  nothing. The exemption does not apply to env-var names, where a variable a user
49
51
  named ``KEY`` plausibly does hold one.
50
52
 
53
+ A string value that is itself a JSON object or array — config-as-JSON under a
54
+ single ConfigMap key is a common pattern — is parsed and redacted entry by entry
55
+ rather than treated as one opaque blob, then re-serialized (which normalizes its
56
+ whitespace). Both rules apply inside, so an incidental token match no longer
57
+ blacks out a whole config file and a ``{"password": ...}`` buried in one no longer
58
+ escapes the name rule. Inside such a blob the ``key`` exemption is lifted: those
59
+ names are the author's, not Kubernetes schema.
60
+
51
61
  Expect occasional false positives on high-entropy-but-non-secret values. The
52
62
  visible marker and the opt-out make that tolerable. This is a defense-in-depth
53
63
  aid, not a guarantee — callers that need strong guarantees should still scrub on
@@ -60,10 +70,11 @@ redaction layer) get raw, un-redacted return values and can call
60
70
 
61
71
  import copy
62
72
  import functools
73
+ import json
63
74
  import logging
64
75
  import os
65
76
  import re
66
- from typing import Any
77
+ from typing import Any, Optional
67
78
 
68
79
  from pydantic import BaseModel
69
80
 
@@ -127,17 +138,91 @@ def _field_name_is_sensitive(name: str) -> bool:
127
138
  return False
128
139
  return _name_is_sensitive(name)
129
140
 
130
- #: Value-shape patterns — a string value matching any of these is redacted
131
- #: regardless of the key it lives under.
132
- _VALUE_SHAPE_RES = (
141
+ #: Value-shape patterns whose match *is* the whole secret. Because the match is
142
+ #: self-contained, only the matched span needs to go — the surrounding text is
143
+ #: ordinary config and stays readable.
144
+ _TOKEN_SHAPE_RES = (
133
145
  re.compile(r"\bAKIA[0-9A-Z]{16}\b"), # AWS access key id
134
146
  re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), # JWT / bearer token
147
+ )
148
+
149
+ #: Value-shape patterns that merely *mark* a secret whose sensitive part lies
150
+ #: outside the match. A PEM header is the whole of the match but none of the
151
+ #: secret - the key material is the base64 body that follows - so a value matching
152
+ #: one of these is replaced in full. Substituting only the matched span here would
153
+ #: black out the `-----BEGIN RSA PRIVATE KEY-----` line and publish the key.
154
+ _CONTAINMENT_SHAPE_RES = (
135
155
  re.compile(r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----"), # PEM private key block
136
156
  )
137
157
 
138
158
 
139
159
  def _value_is_secret_shaped(s: str) -> bool:
140
- return any(rx.search(s) for rx in _VALUE_SHAPE_RES)
160
+ """Whether ``s`` matches any value-shape pattern, anywhere in the string."""
161
+ return any(
162
+ rx.search(s) for rx in (*_TOKEN_SHAPE_RES, *_CONTAINMENT_SHAPE_RES)
163
+ )
164
+
165
+
166
+ def _redact_json_string(s: str) -> Optional[tuple[str, int]]:
167
+ """Redact inside a string that is itself a JSON document.
168
+
169
+ Storing config-as-JSON under a single ConfigMap key is a common pattern, and
170
+ it defeats a redactor that treats every string as one opaque value in *both*
171
+ directions: one incidental token match blacks out an entire config file, while
172
+ a real ``{"password": "..."}`` inside the blob is invisible to the key-name
173
+ rule and passes through verbatim. Parsing, recursing, and re-serializing fixes
174
+ both — a match redacts its own entry and nothing else.
175
+
176
+ Returns ``None`` when ``s`` is not a JSON object/array, or when recursion found
177
+ nothing, so the caller falls through and the original text is left byte-for-byte
178
+ intact. Re-serialization otherwise normalizes whitespace, which is why it only
179
+ happens when there is actually something to hide.
180
+ """
181
+ stripped = s.strip()
182
+ # Cheap gate: only objects and arrays are worth parsing. It also keeps bare
183
+ # scalars ("5", "true") from round-tripping through json.dumps.
184
+ if not stripped or stripped[0] not in "{[":
185
+ return None
186
+ try:
187
+ parsed = json.loads(stripped)
188
+ except (ValueError, RecursionError):
189
+ return None
190
+ if not isinstance(parsed, (dict, list)):
191
+ return None
192
+ # The `key` exemption applies in here too. Lifting it looked right - these are
193
+ # the author's field names, not Kubernetes schema - but measured against a real
194
+ # cluster it added 12 redactions, all of them `tags[].key: "instance"` in a
195
+ # Grafana dashboard ConfigMap, and no secrets. Structural `key` fields dominate
196
+ # inside config documents exactly as they do in the API. A credential-shaped
197
+ # value under a `key` field is still caught by the value-shape rules.
198
+ redacted, count = _redact(parsed)
199
+ if not count:
200
+ return None
201
+ return json.dumps(redacted, indent=2 if "\n" in s else None), count
202
+
203
+
204
+ def _redact_string(value: str, key_is_sensitive: bool) -> tuple[str, int]:
205
+ """Redact a single string value. See :func:`_redact` for the parameters."""
206
+ if value == REDACTED:
207
+ return value, 0
208
+ if key_is_sensitive:
209
+ return REDACTED, 1
210
+
211
+ # Before treating the string as opaque text, see whether it is structured. A
212
+ # PEM block nested inside a JSON blob is handled by this recursion too: it
213
+ # arrives back here as a leaf and hits the containment rule below.
214
+ nested = _redact_json_string(value)
215
+ if nested is not None:
216
+ return nested
217
+
218
+ if any(rx.search(value) for rx in _CONTAINMENT_SHAPE_RES):
219
+ return REDACTED, 1
220
+
221
+ count = 0
222
+ for rx in _TOKEN_SHAPE_RES:
223
+ value, n = rx.subn(REDACTED, value)
224
+ count += n
225
+ return value, count
141
226
 
142
227
 
143
228
  def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
@@ -147,11 +232,7 @@ def _redact(value: Any, key_is_sensitive: bool = False) -> tuple[Any, int]:
147
232
  strings are immutable so the parent assigns the replacement.
148
233
  """
149
234
  if isinstance(value, str):
150
- if value == REDACTED:
151
- return value, 0
152
- if key_is_sensitive or _value_is_secret_shaped(value):
153
- return REDACTED, 1
154
- return value, 0
235
+ return _redact_string(value, key_is_sensitive)
155
236
 
156
237
  if isinstance(value, BaseModel):
157
238
  count = 0
@@ -288,10 +288,14 @@ def test_a_containers_name_cannot_redact_its_own_logs(monkeypatch):
288
288
  assert pod["previous_logs"]["valkey-cart"] == log
289
289
 
290
290
  # A secret-shaped value inside a log is still caught, as on the live server.
291
+ # Only the token's own span goes: a log line is mostly diagnostics, and the
292
+ # surrounding text is the part an agent is reading the log for.
291
293
  monkeypatch.setattr(k8s_tools, "get_logs_for_pod_and_container",
292
294
  lambda *a, **kw: f"token={_FakeCluster.JWT}")
293
295
  state = capture_state(redact=True)
294
- assert state["pods"][0]["logs"]["valkey-cart"] == "[REDACTED]"
296
+ captured_log = state["pods"][0]["logs"]["valkey-cart"]
297
+ assert _FakeCluster.JWT not in captured_log
298
+ assert captured_log == "token=[REDACTED]"
295
299
 
296
300
 
297
301
  def test_pod_labels_are_not_subject_to_the_key_name_heuristic(monkeypatch):
@@ -0,0 +1,182 @@
1
+ """Tests that `timedelta` fields serialize to values their own schema accepts.
2
+
3
+ Pydantic describes a `timedelta` field as `{"type": "string", "format": "duration"}`
4
+ but serializes one carrying microseconds as `"P2DT5M27.978616S"`. JSON Schema's
5
+ `duration` format references RFC 3339 Appendix A, whose grammar admits only whole
6
+ numbers - so the default schema and the default serializer contradict each other,
7
+ and an MCP client that validates structured output against the declared schema
8
+ (ajv does) rejects the call outright, on every row, before the caller sees data.
9
+
10
+ Every age here is `now() - <k8s timestamp>`; Kubernetes timestamps are whole-second,
11
+ so the fractional part is always just the microseconds of that `now()` - which is
12
+ why this reproduced on essentially every row of every list tool against a live
13
+ cluster, while the largely whole-second mock fixture mostly hid it.
14
+
15
+ `k8s_tools.Duration` normalizes the value so the declared format stays truthful.
16
+ """
17
+
18
+ import datetime
19
+ import inspect
20
+ import re
21
+ import typing
22
+
23
+ import pytest
24
+ from pydantic import BaseModel
25
+
26
+ from k8stools import k8s_tools, mock_tools
27
+
28
+
29
+ #: RFC 3339 Appendix A `duration`, the grammar JSON Schema's `format: "duration"`
30
+ #: points at. Transcribed from ajv-formats, which is what the reporting client used.
31
+ #: Note there is no decimal point anywhere in it, and no sign.
32
+ RFC3339_DURATION_RE = re.compile(
33
+ r"^P(?!$)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|(\d+W)?)$"
34
+ )
35
+
36
+ #: A duration with a sub-second component, as every live-cluster age has.
37
+ FRACTIONAL = datetime.timedelta(days=2, minutes=5, seconds=27, microseconds=978616)
38
+
39
+
40
+ def _duration_fields(model: type[BaseModel]) -> list[str]:
41
+ """Field names whose *serialization* schema declares `format: "duration"`.
42
+
43
+ Read off the generated schema rather than the annotation, so the test asserts
44
+ against exactly what an MCP client is handed and told to validate against.
45
+ """
46
+ schema = model.model_json_schema(mode="serialization")
47
+ names = []
48
+ for name, prop in schema.get("properties", {}).items():
49
+ branches = [prop, *prop.get("anyOf", [])]
50
+ if any(b.get("format") == "duration" for b in branches):
51
+ names.append(name)
52
+ return names
53
+
54
+
55
+ def _models_with_durations() -> list[type[BaseModel]]:
56
+ return [
57
+ obj for obj in vars(k8s_tools).values()
58
+ if inspect.isclass(obj) and issubclass(obj, BaseModel)
59
+ and obj is not BaseModel and _duration_fields(obj)
60
+ ]
61
+
62
+
63
+ def _accepts_none(annotation) -> bool:
64
+ return type(None) in typing.get_args(annotation)
65
+
66
+
67
+ def _placeholder(annotation):
68
+ """A minimal value satisfying `annotation`, for the fields we aren't testing."""
69
+ if _accepts_none(annotation):
70
+ return None
71
+ origin = typing.get_origin(annotation) or annotation
72
+ return {str: "x", int: 0, bool: False, float: 0.0, list: [], dict: {}}.get(origin)
73
+
74
+
75
+ def _build(model: type[BaseModel], value, overrides=None) -> BaseModel:
76
+ """Instantiate `model`, putting `value` in every duration field.
77
+
78
+ `overrides` replaces individual fields afterwards, for the cases where one
79
+ duration field needs a different value than the rest.
80
+ """
81
+ durations = _duration_fields(model)
82
+ kwargs = {}
83
+ for name, field in model.model_fields.items():
84
+ if name in durations:
85
+ kwargs[name] = value
86
+ elif field.is_required():
87
+ kwargs[name] = _placeholder(field.annotation)
88
+ kwargs.update(overrides or {})
89
+ return model(**kwargs)
90
+
91
+
92
+ # --- the normalization itself -----------------------------------------------
93
+
94
+ @pytest.mark.parametrize("given,expected_seconds", [
95
+ (datetime.timedelta(seconds=27, microseconds=978616), 27),
96
+ (datetime.timedelta(microseconds=1), 0),
97
+ (datetime.timedelta(0), 0),
98
+ (datetime.timedelta(days=8), 8 * 86400),
99
+ # A cluster clock ahead of ours yields a negative age, which pydantic renders
100
+ # as "-PT5S" - no more valid under this grammar than a fractional one.
101
+ (datetime.timedelta(seconds=-5), 0),
102
+ (datetime.timedelta(days=-2), 0),
103
+ ])
104
+ def test_durations_are_truncated_to_whole_nonnegative_seconds(given, expected_seconds):
105
+ assert k8s_tools._to_whole_seconds(given) == datetime.timedelta(seconds=expected_seconds)
106
+
107
+
108
+ def test_field_keeps_a_real_timedelta_for_direct_python_callers():
109
+ """Normalizing the value, not the type: `.age` is still a timedelta."""
110
+ ns = k8s_tools.NamespaceSummary(name="d", status="Active", age=FRACTIONAL)
111
+ assert isinstance(ns.age, datetime.timedelta)
112
+ assert ns.age == datetime.timedelta(days=2, minutes=5, seconds=27)
113
+
114
+
115
+ def test_the_declared_schema_still_says_duration():
116
+ """The fix must not paper over the problem by dropping the format annotation."""
117
+ prop = k8s_tools.NamespaceSummary.model_json_schema()["properties"]["age"]
118
+ assert prop == {"title": "Age", "type": "string", "format": "duration"}
119
+
120
+
121
+ # --- every model, against its own declared schema ---------------------------
122
+
123
+ def test_there_are_duration_fields_to_check():
124
+ """Guard against the sweeps below silently degrading to no-ops."""
125
+ models = _models_with_durations()
126
+ assert len(models) >= 10
127
+ assert k8s_tools.PodSummary in models
128
+
129
+
130
+ @pytest.mark.parametrize("model", _models_with_durations(), ids=lambda m: m.__name__)
131
+ def test_fractional_input_serializes_within_the_declared_format(model):
132
+ dumped = _build(model, FRACTIONAL).model_dump(mode="json")
133
+ for name in _duration_fields(model):
134
+ assert RFC3339_DURATION_RE.match(dumped[name]), \
135
+ f"{model.__name__}.{name} = {dumped[name]!r} violates format: duration"
136
+
137
+
138
+ @pytest.mark.parametrize("model", _models_with_durations(), ids=lambda m: m.__name__)
139
+ def test_optional_duration_fields_still_accept_none(model):
140
+ """Normalization must not turn an absent duration into PT0S."""
141
+ optional = [n for n in _duration_fields(model)
142
+ if _accepts_none(model.model_fields[n].annotation)]
143
+ if not optional:
144
+ pytest.skip(f"{model.__name__} has no optional duration field")
145
+ dumped = _build(model, FRACTIONAL, {n: None for n in optional}).model_dump(mode="json")
146
+ for name in optional:
147
+ assert dumped[name] is None
148
+
149
+
150
+ # --- end to end, over the actual tool surface -------------------------------
151
+
152
+ @pytest.mark.parametrize("tool", mock_tools.TOOLS, ids=lambda f: f.__name__)
153
+ def test_every_tool_emits_conformant_durations(tool):
154
+ """The end-to-end shape of the bug report: call the tool, validate every
155
+ duration in its structured output against the format its schema declares."""
156
+ mock_tools.load_mock_state()
157
+ params = inspect.signature(tool).parameters
158
+ if any(p.default is inspect.Parameter.empty for p in params.values()):
159
+ pytest.skip(f"{tool.__name__} needs arguments")
160
+ result = tool()
161
+
162
+ items = result if isinstance(result, list) else [result]
163
+ for item in items:
164
+ if not isinstance(item, BaseModel):
165
+ continue
166
+ dumped = item.model_dump(mode="json")
167
+ for name in _duration_fields(type(item)):
168
+ if dumped[name] is None:
169
+ continue
170
+ assert RFC3339_DURATION_RE.match(dumped[name]), \
171
+ f"{tool.__name__} -> {type(item).__name__}.{name} = " \
172
+ f"{dumped[name]!r} violates format: duration"
173
+
174
+
175
+ def test_mock_namespaces_were_the_reproducing_case():
176
+ """`get_namespaces` is the no-argument reproducer from the report, and the one
177
+ place the mock fixture did carry fractional seconds (`P1Y55DT1H37M32.49238S`)."""
178
+ mock_tools.load_mock_state()
179
+ ages = [ns.model_dump(mode="json")["age"] for ns in mock_tools.get_namespaces()]
180
+ assert ages, "fixture should have namespaces"
181
+ for age in ages:
182
+ assert RFC3339_DURATION_RE.match(age), f"{age!r} violates format: duration"
@@ -246,3 +246,125 @@ def test_value_shape_still_wins_regardless_of_name():
246
246
  assert out["key"] == REDACTED
247
247
  assert out["hockey"] == REDACTED
248
248
  assert count == 2
249
+
250
+
251
+ # --- granularity: a match should not black out unrelated text ----------------
252
+ #
253
+ # Reported against 2.0.2: a ConfigMap holding config-as-JSON under one key came
254
+ # back as `{"warm-runtimes.json": "[REDACTED]"}` because one substring inside the
255
+ # blob matched a value-shape pattern. The whole file - warm-pool sizing, nothing
256
+ # secret - became unreadable. The same opacity cut the other way too: secrets
257
+ # named inside such a blob were invisible to the key-name rule entirely.
258
+
259
+ _JWT = "eyJhbGciOiJIUzI1Ni.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w"
260
+ _PEM = ("-----BEGIN RSA PRIVATE KEY-----\n"
261
+ "MIIEowIBAAKCAQEAsecretkeymaterial123456\n"
262
+ "-----END RSA PRIVATE KEY-----")
263
+
264
+
265
+ def test_a_token_inside_a_plain_string_redacts_only_its_own_span():
266
+ out, count = redact_object({"note": f"use {_JWT} to authenticate"})
267
+ assert out["note"] == f"use {REDACTED} to authenticate"
268
+ assert count == 1
269
+
270
+
271
+ def test_a_pem_block_is_still_redacted_in_full():
272
+ """The PEM pattern matches only the header; the key material is what follows.
273
+ Substituting just the matched span would black out the BEGIN line and publish
274
+ the key, so a value containing a PEM header goes in its entirety."""
275
+ out, count = redact_object({"blob": f"leading text\n{_PEM}\ntrailing text"})
276
+ assert out["blob"] == REDACTED
277
+ assert "MIIEowIBAAKCAQEAsecretkeymaterial123456" not in str(out)
278
+ assert count == 1
279
+
280
+
281
+ def test_json_configmap_value_keeps_everything_but_the_match():
282
+ """The reported case: one incidental token match must not hide the config."""
283
+ import json
284
+ blob = json.dumps({"target_count": 5, "runtimes": ["v1", "v2"],
285
+ "bootstrap": _JWT}, indent=2)
286
+ out, count = redact_object({
287
+ "name": "warm-runtimes-config", "namespace": "openhands-cloud-c3",
288
+ "data": {"warm-runtimes.json": blob}, "binary_data_keys": []})
289
+ redacted = json.loads(out["data"]["warm-runtimes.json"])
290
+ assert redacted["target_count"] == 5 # the number they needed
291
+ assert redacted["runtimes"] == ["v1", "v2"]
292
+ assert redacted["bootstrap"] == REDACTED
293
+ assert count == 1
294
+
295
+
296
+ def test_secrets_named_inside_a_json_blob_are_found():
297
+ """The under-redaction half: before this, the key-name rule could not see
298
+ into a JSON string, so these passed through verbatim."""
299
+ import json
300
+ blob = json.dumps({"db": {"password": "hunter2", "api_token": "abc"},
301
+ "host": "db.internal"})
302
+ out, count = redact_object({"data": {"app.json": blob}})
303
+ redacted = json.loads(out["data"]["app.json"])
304
+ assert redacted["db"]["password"] == REDACTED
305
+ assert redacted["db"]["api_token"] == REDACTED
306
+ assert redacted["host"] == "db.internal"
307
+ assert count == 2
308
+
309
+
310
+ def test_a_pem_nested_in_a_json_blob_goes_whole_but_takes_nothing_else():
311
+ import json
312
+ blob = json.dumps({"cert": "public-cert-data", "priv": _PEM, "port": 443})
313
+ out, count = redact_object({"data": {"tls.json": blob}})
314
+ redacted = json.loads(out["data"]["tls.json"])
315
+ assert redacted["priv"] == REDACTED
316
+ assert redacted["cert"] == "public-cert-data"
317
+ assert redacted["port"] == 443
318
+ assert count == 1
319
+
320
+
321
+ def test_the_key_exemption_holds_inside_a_json_blob_too():
322
+ """Lifting the exemption in here looked right - these are the author's field
323
+ names, not Kubernetes schema - but measured against a real 38-pod cluster it
324
+ added 12 redactions, every one of them `tags[].key: "instance"` in a Grafana
325
+ dashboard ConfigMap, and not one secret. Structural `key` fields dominate
326
+ inside config documents just as they do in the API."""
327
+ import json
328
+ blob = json.dumps({"targets": [{"tags": [{"key": "instance", "value": "db-1"}]}]})
329
+ out, count = redact_object({"data": {"dash.json": blob}})
330
+ assert out["data"]["dash.json"] == blob # untouched, not even re-serialized
331
+ assert count == 0
332
+
333
+
334
+ def test_a_credential_shaped_value_under_key_is_still_caught_inside_a_blob():
335
+ """What keeps the exemption affordable: the value-shape rules do not care
336
+ what the field is called."""
337
+ import json
338
+ blob = json.dumps({"key": "AKIAIOSFODNN7EXAMPLE"})
339
+ out, count = redact_object({"data": {"cfg.json": blob}})
340
+ assert json.loads(out["data"]["cfg.json"])["key"] == REDACTED
341
+ assert count == 1
342
+
343
+
344
+ def test_a_clean_json_value_is_left_byte_for_byte_alone():
345
+ """No match means no re-serialization, so formatting is never churned."""
346
+ original = '{\n "count": 5,\n "name": "pool"\n}'
347
+ out, count = redact_object({"data": {"cfg.json": original}})
348
+ assert out["data"]["cfg.json"] == original
349
+ assert count == 0
350
+
351
+
352
+ @pytest.mark.parametrize("value", [
353
+ "not json at all", "{broken json", "", " ",
354
+ "5", "true", "null", '"just a string"',
355
+ ])
356
+ def test_non_container_json_values_are_untouched(value):
357
+ """Bare scalars must not round-trip through the JSON path."""
358
+ out, count = redact_object({"data": {"k": value}})
359
+ assert out["data"]["k"] == value
360
+ assert count == 0
361
+
362
+
363
+ def test_a_json_array_blob_is_also_recursed():
364
+ import json
365
+ blob = json.dumps([{"name": "a", "token": "s3kr1t"}, {"name": "b", "port": 80}])
366
+ out, count = redact_object({"data": {"list.json": blob}})
367
+ redacted = json.loads(out["data"]["list.json"])
368
+ assert redacted[0]["token"] == REDACTED
369
+ assert redacted[1]["port"] == 80
370
+ assert count == 1
@@ -0,0 +1,159 @@
1
+ # Bug report: `datetime.timedelta` fields produce values that fail strict `format: "duration"` JSON-Schema validation
2
+
3
+ ## Summary
4
+
5
+ Every k8stools MCP tool whose Pydantic response model includes a `datetime.timedelta`
6
+ field (`age`, `last_seen`) emits a value shape that is rejected by MCP clients that do
7
+ strict JSON-Schema `format: "duration"` validation of structured tool output. When this
8
+ happens, the tool call fails outright with an MCP protocol error — the client never sees
9
+ any data, even though the underlying tool executed successfully and the payload is
10
+ "reasonable" ISO 8601.
11
+
12
+ This is not tied to a particular cluster or tool argument — it reproduces on `get_namespaces()`
13
+ (no arguments) and on every list-summary tool tested.
14
+
15
+ ## Environment
16
+
17
+ - `k8stools` version: **2.0.2** (installed via `uv`/PyPI, unmodified)
18
+ - Invocation: `k8s-mcp-server --transport=streamable-http` (no `--mock`, no `--state-file` —
19
+ live cluster)
20
+ - Python: 3.12.11
21
+ - `mcp` SDK: whatever version `k8stools==2.0.2` pulls in via `uv.lock` (server uses
22
+ `mcp.server.mcpserver.MCPServer` / `Tool.from_function(fn, structured_output=True)`)
23
+ - Client: an MCP client that validates tool call results against the tool's declared
24
+ `outputSchema` (structured content validation), rejecting non-conformant responses before
25
+ the caller ever sees the data.
26
+
27
+ ## Steps to reproduce
28
+
29
+ 1. Start the server: `k8s-mcp-server --transport=streamable-http`
30
+ 2. Call the `get_namespaces` tool (or `get_pod_summaries`, `get_events`, etc.) via an MCP
31
+ client that validates structured output against the tool's JSON output schema.
32
+ 3. Observe the call fail with a schema-validation error instead of returning data.
33
+
34
+ ## Actual error (verbatim, trimmed)
35
+
36
+ ```
37
+ MCP error -32602: Structured content does not match the tool's output schema:
38
+ data/result/0/age must match format "duration",
39
+ data/result/1/age must match format "duration",
40
+ ...
41
+ ```
42
+
43
+ and, for `Optional[timedelta]` fields:
44
+
45
+ ```
46
+ data/result/0/last_seen must match format "duration",
47
+ data/result/0/last_seen must be null,
48
+ data/result/0/last_seen must match a schema in anyOf,
49
+ ...
50
+ ```
51
+
52
+ Every element in the result array fails identically — this is not a per-record data quality
53
+ issue, it's a structural mismatch between every timedelta value and the schema that describes it.
54
+
55
+ ## Root cause
56
+
57
+ `k8s_tools.py` declares timing fields as plain `datetime.timedelta` on `BaseModel` subclasses,
58
+ e.g.:
59
+
60
+ ```464:467:k8stools/k8s_tools.py (site-packages copy)
61
+ class PodSummary(BaseModel):
62
+ """A summary of a pod's status like returned by `kubectl get pods -o wide`"""
63
+ ...
64
+ last_restart: Optional[datetime.timedelta]
65
+ age: datetime.timedelta
66
+ ```
67
+
68
+ ```89:93:k8stools/k8s_tools.py (site-packages copy)
69
+ class NamespaceSummary(BaseModel):
70
+ """Summary information about a namespace, like returned by `kubectl get namespace`"""
71
+ name: str
72
+ status: str
73
+ age: datetime.timedelta
74
+ ```
75
+
76
+ ```504:509:k8stools/k8s_tools.py (site-packages copy)
77
+ class EventSummary(BaseModel):
78
+ """This is the representation of a Kubernetes Event"""
79
+ last_seen: Optional[datetime.timedelta] # Time since event occurred
80
+ type: str
81
+ reason: str
82
+ object: str
83
+ message: str
84
+ ```
85
+
86
+ Pydantic v2's default JSON Schema generation for `datetime.timedelta` emits
87
+ `{"type": "string", "format": "duration"}`, and its default *value* serialization for a
88
+ `timedelta` produces an ISO 8601 duration string that **includes fractional (sub-second)
89
+ seconds when the timedelta has microsecond precision**, e.g. the values actually observed
90
+ over the wire:
91
+
92
+ ```
93
+ "P2DT5M27.978616S"
94
+ "PT10H56M59.978616S"
95
+ "PT50M41.978616S"
96
+ ```
97
+
98
+ These are computed as `now() - <k8s object's creationTimestamp / lastTimestamp>`. Since
99
+ Kubernetes' own timestamps are whole-second (no sub-second component), the fractional part
100
+ of every one of these values is just `now()`'s microseconds — which is why it's identical
101
+ across every row in a single response (all computed from one `now()` call). That's expected
102
+ and not itself a bug; the bug is that this fractional value is legal ISO 8601 but is **not**
103
+ legal per the stricter grammar that JSON Schema's `format: "duration"` keyword references
104
+ (RFC 3339 Appendix A), which most conformant/strict format validators implement. That grammar
105
+ requires each duration component to be a plain (integer) digit sequence — it does not permit a
106
+ decimal point anywhere, including in the seconds component. A validator that enforces this
107
+ strictly will reject `"P2DT5M27.978616S"` even though it is a well-formed, human-readable ISO
108
+ 8601 duration.
109
+
110
+ In short: **pydantic's own default schema (`format: "duration"`) and pydantic's own default
111
+ serialization (fractional-second ISO 8601 strings) are mutually inconsistent** under any
112
+ validator that takes `format: "duration"` seriously. Any MCP client that validates structured
113
+ output strictly against the declared schema will intermittently or consistently break on
114
+ every tool that surfaces an `age` or `last_seen` field — i.e., essentially all of the
115
+ list/summary tools (`get_namespaces`, `get_node_summaries`, `get_pod_summaries`, `get_events`,
116
+ `get_pod_events`, `get_deployment_summaries`, `get_replicaset_summaries`,
117
+ `get_statefulset_summaries`, `get_cronjob_summaries`, `get_job_summaries`, `get_pvc_summaries`,
118
+ `get_service_summaries`, `get_configmap_summaries`).
119
+
120
+ ## Why this seemed to "come and go" in our testing
121
+
122
+ We saw calls to these same tools **succeed** with these exact fractional-second value shapes
123
+ earlier in the same day, then start failing after simply restarting the MCP server process
124
+ (no code/version change on the server side — same `k8stools==2.0.2`, same venv, same
125
+ `k8s-mcp-server` binary, confirmed via `pip`/`uv.lock`/site-packages timestamps). That strongly
126
+ suggests the validation strictness is a property of the **client's** connection/session
127
+ (e.g., whether it negotiates or enables structured-output validation on a given connection),
128
+ not of the server. That's a plausible explanation for the inconsistent symptom, but it doesn't
129
+ change the underlying defect: the server is emitting values that don't conform to its own
130
+ declared schema, and **any** strictly-validating client — now or in the future — will hit this
131
+ 100% of the time on 100% of rows.
132
+
133
+ ## Suggested fixes (any one of these resolves it)
134
+
135
+ 1. **Truncate to whole seconds before serialization.** Drop microseconds from every
136
+ `timedelta` before it's placed on a response model (e.g. `age.total_seconds()` rounded, or
137
+ reconstruct a `timedelta(seconds=int(age.total_seconds()))`). Minute-granularity age/last-seen
138
+ data doesn't need microsecond precision anyway, and this keeps the `format: "duration"`
139
+ schema annotation truthful.
140
+ 2. **Stop declaring `format: "duration"`** on these fields (e.g. represent `age`/`last_seen` as
141
+ a plain `str` computed via the existing `_format_timedelta()` helper already used for CLI
142
+ output, rather than a native `timedelta`). This sidesteps the whole format-conformance
143
+ question and gives every consumer a human-readable string like `"53m46s"` instead.
144
+ 3. **Provide a custom serializer** (`@field_serializer` in Pydantic v2) for these fields that
145
+ emits a strictly RFC 3339 Appendix A–conformant duration string (no fractional component —
146
+ round to the nearest second), while leaving the field type as `timedelta` for typing
147
+ purposes.
148
+
149
+ Option 1 or 3 preserve the current "real" `timedelta`/duration semantics; option 2 is the
150
+ simplest and most robust against any future client strictness, at the cost of losing a
151
+ machine-parseable duration type in the schema.
152
+
153
+ ## Impact
154
+
155
+ Total loss of function for every affected tool when called through a strictly-validating MCP
156
+ client — the call fails outright with no data, rather than degrading gracefully. This blocked
157
+ a live production-incident investigation (watching pod/event state during a load test) because
158
+ every relevant tool (`get_pod_summaries`, `get_events`) was unusable after a routine server
159
+ restart, with no code change on either side.
@@ -544,7 +544,7 @@ wheels = [
544
544
 
545
545
  [[package]]
546
546
  name = "k8stools"
547
- version = "2.0.2"
547
+ version = "2.0.3"
548
548
  source = { editable = "." }
549
549
  dependencies = [
550
550
  { name = "kubernetes" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes