cited-cli 0.3.14__tar.gz → 0.3.15__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 (125) hide show
  1. {cited_cli-0.3.14 → cited_cli-0.3.15}/PKG-INFO +4 -4
  2. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/pyproject.toml +1 -1
  3. cited_cli-0.3.15/packages/core/src/cited_core/__init__.py +1 -0
  4. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/api/client.py +9 -0
  5. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/pyproject.toml +2 -2
  6. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tool_changelog.yaml +51 -0
  7. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/_helpers.py +167 -91
  8. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/tests/test_tools.py +72 -0
  9. {cited_cli-0.3.14 → cited_cli-0.3.15}/pyproject.toml +4 -4
  10. cited_cli-0.3.14/packages/core/src/cited_core/__init__.py +0 -1
  11. {cited_cli-0.3.14 → cited_cli-0.3.15}/.github/workflows/changelog-guard.yml +0 -0
  12. {cited_cli-0.3.14 → cited_cli-0.3.15}/.github/workflows/ci.yml +0 -0
  13. {cited_cli-0.3.14 → cited_cli-0.3.15}/.github/workflows/release.yml +0 -0
  14. {cited_cli-0.3.14 → cited_cli-0.3.15}/.gitignore +0 -0
  15. {cited_cli-0.3.14 → cited_cli-0.3.15}/CLAUDE.md +0 -0
  16. {cited_cli-0.3.14 → cited_cli-0.3.15}/DEMO.md +0 -0
  17. {cited_cli-0.3.14 → cited_cli-0.3.15}/FUTURE.md +0 -0
  18. {cited_cli-0.3.14 → cited_cli-0.3.15}/LICENSE +0 -0
  19. {cited_cli-0.3.14 → cited_cli-0.3.15}/README.md +0 -0
  20. {cited_cli-0.3.14 → cited_cli-0.3.15}/TEST_RUNBOOK.md +0 -0
  21. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/.claude-plugin/marketplace.json +0 -0
  22. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/.claude-plugin/plugin.json +0 -0
  23. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/.mcp.json +0 -0
  24. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/CLAUDE_DESKTOP_SETUP.md +0 -0
  25. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/agents/geo-analyst.md +0 -0
  26. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/commands/audit.md +0 -0
  27. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/commands/recommend.md +0 -0
  28. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/commands/status.md +0 -0
  29. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/settings.json +0 -0
  30. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/skills/business-setup/SKILL.md +0 -0
  31. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/skills/explore/SKILL.md +0 -0
  32. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/skills/geo-audit/SKILL.md +0 -0
  33. {cited_cli-0.3.14 → cited_cli-0.3.15}/cited-plugins/skills/geo-recommend/SKILL.md +0 -0
  34. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/api/__init__.py +0 -0
  35. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/api/endpoints.py +0 -0
  36. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/auth/__init__.py +0 -0
  37. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/auth/oauth_server.py +0 -0
  38. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/auth/store.py +0 -0
  39. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/config/__init__.py +0 -0
  40. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/config/constants.py +0 -0
  41. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/config/manager.py +0 -0
  42. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/core/src/cited_core/errors.py +0 -0
  43. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/README.md +0 -0
  44. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/SKILL.md +0 -0
  45. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/__init__.py +0 -0
  46. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/__main__.py +0 -0
  47. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/auth_provider.py +0 -0
  48. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/context.py +0 -0
  49. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/plan_gating.py +0 -0
  50. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/remote.py +0 -0
  51. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/server.py +0 -0
  52. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tool_manager.py +0 -0
  53. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/__init__.py +0 -0
  54. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/action_plan.py +0 -0
  55. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/agent.py +0 -0
  56. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/analytics.py +0 -0
  57. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/audit.py +0 -0
  58. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/auth.py +0 -0
  59. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/billing.py +0 -0
  60. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/business.py +0 -0
  61. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/changelog.py +0 -0
  62. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/hq.py +0 -0
  63. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/job.py +0 -0
  64. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/recommend.py +0 -0
  65. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/src/cited_mcp/tools/solution.py +0 -0
  66. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/tests/conftest.py +0 -0
  67. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/tests/test_auth_provider.py +0 -0
  68. {cited_cli-0.3.14 → cited_cli-0.3.15}/packages/mcp/tests/test_plan_gating.py +0 -0
  69. {cited_cli-0.3.14 → cited_cli-0.3.15}/scripts/dev-install.sh +0 -0
  70. {cited_cli-0.3.14 → cited_cli-0.3.15}/scripts/mcp_test_harness.py +0 -0
  71. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/__init__.py +0 -0
  72. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/__main__.py +0 -0
  73. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/api/__init__.py +0 -0
  74. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/api/client.py +0 -0
  75. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/api/endpoints.py +0 -0
  76. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/app.py +0 -0
  77. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/auth/__init__.py +0 -0
  78. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/auth/oauth_server.py +0 -0
  79. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/auth/store.py +0 -0
  80. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/__init__.py +0 -0
  81. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/agent.py +0 -0
  82. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/analytics.py +0 -0
  83. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/audit.py +0 -0
  84. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/auth.py +0 -0
  85. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/business.py +0 -0
  86. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/config_cmd.py +0 -0
  87. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/hq.py +0 -0
  88. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/job.py +0 -0
  89. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/mcp_cmd.py +0 -0
  90. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/named_audit.py +0 -0
  91. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/recommend.py +0 -0
  92. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/commands/solution.py +0 -0
  93. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/config/__init__.py +0 -0
  94. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/config/constants.py +0 -0
  95. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/config/manager.py +0 -0
  96. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/__init__.py +0 -0
  97. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/context.py +0 -0
  98. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/server.py +0 -0
  99. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/__init__.py +0 -0
  100. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/_helpers.py +0 -0
  101. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/audit.py +0 -0
  102. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/auth.py +0 -0
  103. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/business.py +0 -0
  104. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/job.py +0 -0
  105. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/recommend.py +0 -0
  106. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/mcp/tools/solution.py +0 -0
  107. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/output/__init__.py +0 -0
  108. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/output/formatter.py +0 -0
  109. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/output/progress.py +0 -0
  110. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/output/tables.py +0 -0
  111. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/utils/__init__.py +0 -0
  112. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/utils/errors.py +0 -0
  113. {cited_cli-0.3.14 → cited_cli-0.3.15}/src/cited_cli/utils/interactive.py +0 -0
  114. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/__init__.py +0 -0
  115. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/conftest.py +0 -0
  116. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_api_client.py +0 -0
  117. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_auth.py +0 -0
  118. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_config.py +0 -0
  119. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_feature_parity.py +0 -0
  120. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_live_pipeline.py +0 -0
  121. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_mcp_server.py +0 -0
  122. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_mcp_tools.py +0 -0
  123. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_output.py +0 -0
  124. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_pipeline.py +0 -0
  125. {cited_cli-0.3.14 → cited_cli-0.3.15}/tests/test_version.py +0 -0
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cited-cli
3
- Version: 0.3.14
3
+ Version: 0.3.15
4
4
  Summary: CLI for the Cited GEO platform
5
5
  Author-email: Cited <support@youcited.com>
6
6
  License-Expression: LicenseRef-Proprietary
7
7
  License-File: LICENSE
8
8
  Requires-Python: >=3.11
9
- Requires-Dist: cited-core>=0.3.14
9
+ Requires-Dist: cited-core>=0.3.15
10
10
  Requires-Dist: rich>=13.0.0
11
11
  Requires-Dist: typer>=0.15.0
12
12
  Provides-Extra: dev
13
- Requires-Dist: cited-mcp>=0.3.14; extra == 'dev'
13
+ Requires-Dist: cited-mcp>=0.3.15; extra == 'dev'
14
14
  Requires-Dist: mypy>=1.10; extra == 'dev'
15
15
  Requires-Dist: pytest>=8.0; extra == 'dev'
16
16
  Requires-Dist: respx>=0.21; extra == 'dev'
17
17
  Requires-Dist: ruff>=0.4; extra == 'dev'
18
18
  Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
19
19
  Provides-Extra: mcp
20
- Requires-Dist: cited-mcp>=0.3.14; extra == 'mcp'
20
+ Requires-Dist: cited-mcp>=0.3.15; extra == 'mcp'
21
21
  Description-Content-Type: text/markdown
22
22
 
23
23
  # Cited CLI
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cited-core"
7
- version = "0.3.14"
7
+ version = "0.3.15"
8
8
  description = "Core library for the Cited GEO platform"
9
9
  license = "LicenseRef-Proprietary"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.15"
@@ -40,6 +40,15 @@ class CitedClient:
40
40
  def close(self) -> None:
41
41
  self._client.close()
42
42
 
43
+ def set_request_id(self, request_id: str | None) -> None:
44
+ """Stamp X-Request-ID on subsequent outgoing requests so the cited
45
+ backend can echo it in its access logs and produce end-to-end traces.
46
+ Pass None to clear."""
47
+ if request_id:
48
+ self._client.headers["X-Request-ID"] = request_id
49
+ else:
50
+ self._client.headers.pop("X-Request-ID", None)
51
+
43
52
  def __enter__(self) -> CitedClient:
44
53
  return self
45
54
 
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cited-mcp"
7
- version = "0.3.14"
7
+ version = "0.3.15"
8
8
  description = "MCP server for the Cited GEO platform"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
11
11
  requires-python = ">=3.11"
12
12
  authors = [{ name = "Cited", email = "support@youcited.com" }]
13
13
  dependencies = [
14
- "cited-core>=0.3.14",
14
+ "cited-core>=0.3.15",
15
15
  "mcp>=1.27.0",
16
16
  "PyJWT>=2.8.0",
17
17
  "PyYAML>=6.0",
@@ -21,6 +21,57 @@
21
21
  # this file.
22
22
 
23
23
  versions:
24
+ - version: "0.3.15"
25
+ released: "2026-05-05"
26
+ # Fingerprint unchanged from 0.3.14 image runtime — pure observability +
27
+ # logging-enrichment release; no tool surface change. (The 0.3.14 entry
28
+ # below pins defa3022aedb, which was the host-venv compute at release
29
+ # prep — differing from the deployed image's 1c4fed943fff. Future
30
+ # entries should pin runtime fingerprints; this collision is benign
31
+ # because there's nothing in the surface for clients to act on.)
32
+ fingerprint: "1c4fed943fff"
33
+ tools_added: []
34
+ tools_changed: []
35
+ tools_removed: []
36
+ behavior_changed:
37
+ - area: structured_logging
38
+ change_summary: |
39
+ Every ``tool_call`` log line now carries:
40
+ - ``server_version`` and ``tools_fingerprint`` (so log entries
41
+ self-identify which build emitted them)
42
+ - ``deployment_id`` from ``CITED_DEPLOYMENT_ID`` env var
43
+ (format ``<env>-<short-sha>-<unix-ts>``, set by the deploy
44
+ script — uniquely identifies a single deploy attempt)
45
+ - ``client_id`` extracted from the OAuth JWT (lets us tell
46
+ Claude Desktop traffic from Claude.ai traffic)
47
+ - ``resources`` map with ID-shaped kwargs (``business_id``,
48
+ ``audit_id``, ``job_id``, ``source_id``, etc.) — enough to
49
+ correlate user activity with backend records, never raw
50
+ content like buyer profiles or audit descriptions.
51
+ On failures the line now also carries ``error_type`` and
52
+ ``status_code`` pulled from the typed-error response, so the
53
+ dashboard can categorise failures without grepping messages.
54
+ - area: end_to_end_tracing
55
+ change_summary: |
56
+ The MCP now stamps ``X-Request-ID`` on every outgoing call to
57
+ the cited backend (matching the ``request_id`` already in the
58
+ tool_call log line). The backend can echo this to its own
59
+ access logs to produce end-to-end traces — when a customer
60
+ reports "buyer_fit_query 500 with request_id abc123" the
61
+ owner can find both MCP-side and backend-side log entries
62
+ for the same request.
63
+ - area: deploy_pipeline
64
+ change_summary: |
65
+ Companion ``deploy-mcp.sh`` improvements (cited-mcp-infra
66
+ repo, commit 5542667): split log groups (prod writes to
67
+ ``/ecs/cited-mcp-prod`` instead of mixing into the dev
68
+ group), real stability check that fails the deploy when ECS
69
+ silently rolls back to a prior task definition (the failure
70
+ mode that hid today's IAM-blocked prod deploy), and
71
+ deployment_id stamping. CloudWatch dashboard ``cited-mcp``
72
+ + saved Insights queries (``cited-mcp/usage/per-user`` etc.)
73
+ + failure-burst alarms also added.
74
+
24
75
  - version: "0.3.14"
25
76
  released: "2026-05-05"
26
77
  fingerprint: "defa3022aedb"
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import functools
4
5
  import hashlib
5
6
  import json
@@ -52,9 +53,7 @@ def _truncate_response(data: Any, max_bytes: int = _MAX_RESPONSE_BYTES) -> Any:
52
53
 
53
54
  if isinstance(data, dict):
54
55
  # Find list-valued fields and iteratively shrink the longest
55
- list_fields = {
56
- k: v for k, v in data.items() if isinstance(v, list) and len(v) > 0
57
- }
56
+ list_fields = {k: v for k, v in data.items() if isinstance(v, list) and len(v) > 0}
58
57
  truncated_fields: list[str] = []
59
58
  attempts = 0
60
59
  while (
@@ -165,9 +164,7 @@ def _auth_check(cited_ctx: CitedContext) -> dict[str, Any] | None:
165
164
  return None
166
165
 
167
166
 
168
- def _resolve_business_id(
169
- cited_ctx: CitedContext, business_id: str | None
170
- ) -> str | None:
167
+ def _resolve_business_id(cited_ctx: CitedContext, business_id: str | None) -> str | None:
171
168
  """Return *business_id* if provided, else the session default."""
172
169
  return business_id or cited_ctx.default_business_id
173
170
 
@@ -241,14 +238,107 @@ def _extract_user(token: str | None) -> str:
241
238
  if not token:
242
239
  return "anonymous"
243
240
  try:
244
- payload = pyjwt.decode(
245
- token, options={"verify_signature": False}
246
- )
241
+ payload = pyjwt.decode(token, options={"verify_signature": False})
247
242
  return str(payload.get("email") or payload.get("sub") or "unknown")
248
243
  except Exception:
249
244
  return "unknown"
250
245
 
251
246
 
247
+ def _extract_client_id(token: str | None) -> str | None:
248
+ """Decode OAuth client_id from a JWT (for connector-mix analytics)."""
249
+ if not token:
250
+ return None
251
+ try:
252
+ payload = pyjwt.decode(token, options={"verify_signature": False})
253
+ cid = payload.get("client_id") or payload.get("aud") or payload.get("azp")
254
+ if isinstance(cid, list):
255
+ cid = cid[0] if cid else None
256
+ return str(cid) if cid else None
257
+ except Exception:
258
+ return None
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Build identity — stamped into every tool_call line for log correlation.
263
+ # Computed once at module import: cheap, deterministic, survives the request.
264
+ # ---------------------------------------------------------------------------
265
+
266
+ _BUILD_IDENTITY: dict[str, str] = {}
267
+
268
+
269
+ def _get_build_identity() -> dict[str, str]:
270
+ """Return cached server_version, tools_fingerprint, deployment_id.
271
+
272
+ Lazy + memoised because compute_tools_fingerprint walks the live server's
273
+ tool registry — fine to do once but expensive per request.
274
+ """
275
+ if _BUILD_IDENTITY:
276
+ return _BUILD_IDENTITY
277
+ try:
278
+ from cited_mcp import __version__ as ver
279
+ from cited_mcp.server import compute_tools_fingerprint
280
+ from cited_mcp.server import mcp as live_mcp
281
+
282
+ _BUILD_IDENTITY["server_version"] = ver
283
+ _BUILD_IDENTITY["tools_fingerprint"] = compute_tools_fingerprint(live_mcp)
284
+ except Exception:
285
+ # Don't crash logging if the import fails — emit blanks instead.
286
+ _BUILD_IDENTITY["server_version"] = "unknown"
287
+ _BUILD_IDENTITY["tools_fingerprint"] = "unknown"
288
+ _BUILD_IDENTITY["deployment_id"] = os.environ.get("CITED_DEPLOYMENT_ID", "local")
289
+ return _BUILD_IDENTITY
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # Resource ID extraction for tool_call logs.
294
+ #
295
+ # We log obvious-shaped IDs (business_id, audit_id, job_id, source_id, etc.)
296
+ # so customer-support and abuse-investigation flows can correlate user
297
+ # activity with platform records. We deliberately do NOT log free-text
298
+ # content (audit descriptions, buyer profiles, etc.) — those can contain
299
+ # sensitive material.
300
+ # ---------------------------------------------------------------------------
301
+
302
+ # Tool kwargs that name a resource we want to log. Match against suffixes:
303
+ # *_id and *_type are the conventions across the surface.
304
+ _RESOURCE_KEYS = {
305
+ "business_id",
306
+ "audit_id",
307
+ "baseline_id",
308
+ "job_id",
309
+ "recommendation_job_id",
310
+ "audit_job_id",
311
+ "named_audit_id",
312
+ "template_id",
313
+ "solution_id",
314
+ "recommendation_id",
315
+ "source_id",
316
+ "source_type",
317
+ "action_id",
318
+ "check_id",
319
+ "question_id",
320
+ }
321
+
322
+ # Maximum length of a logged ID value — avoids the case where a tool author
323
+ # accidentally maps a long string into one of these key names.
324
+ _MAX_RESOURCE_VALUE_LEN = 80
325
+
326
+
327
+ def _extract_resource_ids(kwargs: dict[str, Any]) -> dict[str, str]:
328
+ """Pull ID-shaped kwargs into a flat dict, redacting anything else."""
329
+ out: dict[str, str] = {}
330
+ for key, val in kwargs.items():
331
+ if key not in _RESOURCE_KEYS:
332
+ continue
333
+ if val is None:
334
+ continue
335
+ s = str(val)
336
+ if len(s) > _MAX_RESOURCE_VALUE_LEN:
337
+ continue
338
+ out[key] = s
339
+ return out
340
+
341
+
252
342
  # ---------------------------------------------------------------------------
253
343
  # Per-user tier cache (avoids /auth/me call on every tool invocation)
254
344
  # ---------------------------------------------------------------------------
@@ -294,31 +384,52 @@ def log_tool_call(
294
384
  token = _resolve_token(ctx)
295
385
  rl_key = _rate_limit_key(token)
296
386
  user = _extract_user(token)
387
+ client_id = _extract_client_id(token)
297
388
  request_id = uuid.uuid4().hex[:12]
298
389
  start = time.monotonic()
390
+ build = _get_build_identity()
391
+ resources = _extract_resource_ids(kwargs)
392
+ # Header-propagation context — set inside the try block below so that
393
+ # _get_ctx failures still flow through structured error handling.
394
+ cited_ctx_for_header: CitedContext | None = None
395
+
396
+ def _log(event: str, **extra: Any) -> None:
397
+ payload: dict[str, Any] = {
398
+ "event": event,
399
+ "request_id": request_id,
400
+ "tool": tool_name,
401
+ "user": user,
402
+ "client_id": client_id,
403
+ "server_version": build["server_version"],
404
+ "tools_fingerprint": build["tools_fingerprint"],
405
+ "deployment_id": build["deployment_id"],
406
+ }
407
+ if resources:
408
+ payload["resources"] = resources
409
+ payload.update(extra)
410
+ logger.info(json.dumps(payload, default=str))
299
411
 
300
412
  # Rate limit check
301
413
  if rl_err := _check_rate_limit(rl_key):
302
- logger.info(
303
- json.dumps(
304
- {
305
- "event": "rate_limited",
306
- "request_id": request_id,
307
- "tool": tool_name,
308
- "user": user,
309
- }
310
- )
311
- )
414
+ _log("rate_limited")
312
415
  rl_err["_request_id"] = request_id
313
416
  return rl_err
314
417
 
315
418
  try:
419
+ # Propagate request_id to the cited backend for end-to-end traces.
420
+ cited_ctx_for_header = _get_ctx(ctx)
421
+ cited_ctx_for_header.client.set_request_id(request_id)
422
+
316
423
  # Plan gating check (skip for auth tools — must always be accessible)
317
424
  from cited_mcp.plan_gating import is_tool_allowed, upgrade_message
318
425
 
319
426
  _AUTH_TOOLS = {
320
- "ping", "check_auth_status", "login", "logout",
321
- "get_pricing", "upgrade_plan",
427
+ "ping",
428
+ "check_auth_status",
429
+ "login",
430
+ "logout",
431
+ "get_pricing",
432
+ "upgrade_plan",
322
433
  }
323
434
  if tool_name not in _AUTH_TOOLS and token:
324
435
  cited_ctx = _get_ctx(ctx)
@@ -327,39 +438,31 @@ def log_tool_call(
327
438
  if not is_tool_allowed(tool_name, user_tier):
328
439
  gate_err = upgrade_message(tool_name, user_tier)
329
440
  gate_err["_request_id"] = request_id
330
- logger.info(
331
- json.dumps(
332
- {
333
- "event": "plan_gated",
334
- "request_id": request_id,
335
- "tool": tool_name,
336
- "user": user,
337
- "current_tier": user_tier,
338
- "required_tier": gate_err[
339
- "required_tier"
340
- ],
341
- }
342
- )
441
+ _log(
442
+ "plan_gated",
443
+ current_tier=user_tier,
444
+ required_tier=gate_err["required_tier"],
343
445
  )
344
446
  return gate_err
345
447
 
346
448
  result = await func(ctx, *args, **kwargs)
347
449
  duration_ms = int((time.monotonic() - start) * 1000)
348
- is_error = isinstance(result, dict) and result.get(
349
- "error"
350
- ) is True
351
- logger.info(
352
- json.dumps(
353
- {
354
- "event": "tool_call",
355
- "request_id": request_id,
356
- "tool": tool_name,
357
- "user": user,
358
- "duration_ms": duration_ms,
359
- "success": not is_error,
360
- }
361
- )
362
- )
450
+ is_error = isinstance(result, dict) and result.get("error") is True
451
+ extra: dict[str, Any] = {
452
+ "duration_ms": duration_ms,
453
+ "success": not is_error,
454
+ }
455
+ if is_error and isinstance(result, dict):
456
+ # Pull through the typed error fields the tools already emit
457
+ # so failures are categorisable in the dashboard without
458
+ # re-grepping raw error messages.
459
+ if et := result.get("error_type"):
460
+ extra["error_type"] = et
461
+ if sc := result.get("status_code"):
462
+ extra["status_code"] = sc
463
+ if pr := result.get("payment_required"):
464
+ extra["payment_required"] = pr
465
+ _log("tool_call", **extra)
363
466
  # Inject request_id into response
364
467
  if isinstance(result, dict):
365
468
  result["_request_id"] = request_id
@@ -372,19 +475,7 @@ def log_tool_call(
372
475
 
373
476
  except httpx.TimeoutException:
374
477
  duration_ms = int((time.monotonic() - start) * 1000)
375
- logger.info(
376
- json.dumps(
377
- {
378
- "event": "tool_call",
379
- "request_id": request_id,
380
- "tool": tool_name,
381
- "user": user,
382
- "duration_ms": duration_ms,
383
- "success": False,
384
- "error_type": "timeout",
385
- }
386
- )
387
- )
478
+ _log("tool_call", duration_ms=duration_ms, success=False, error_type="timeout")
388
479
  return {
389
480
  "error": True,
390
481
  "error_type": "upstream_timeout",
@@ -398,19 +489,7 @@ def log_tool_call(
398
489
 
399
490
  except httpx.ConnectError:
400
491
  duration_ms = int((time.monotonic() - start) * 1000)
401
- logger.info(
402
- json.dumps(
403
- {
404
- "event": "tool_call",
405
- "request_id": request_id,
406
- "tool": tool_name,
407
- "user": user,
408
- "duration_ms": duration_ms,
409
- "success": False,
410
- "error_type": "connection_error",
411
- }
412
- )
413
- )
492
+ _log("tool_call", duration_ms=duration_ms, success=False, error_type="connection_error")
414
493
  return {
415
494
  "error": True,
416
495
  "error_type": "connection_error",
@@ -424,29 +503,27 @@ def log_tool_call(
424
503
 
425
504
  except Exception as exc:
426
505
  duration_ms = int((time.monotonic() - start) * 1000)
427
- logger.info(
428
- json.dumps(
429
- {
430
- "event": "tool_call",
431
- "request_id": request_id,
432
- "tool": tool_name,
433
- "user": user,
434
- "duration_ms": duration_ms,
435
- "success": False,
436
- "error_type": type(exc).__name__,
437
- }
438
- )
506
+ _log(
507
+ "tool_call",
508
+ duration_ms=duration_ms,
509
+ success=False,
510
+ error_type=type(exc).__name__,
439
511
  )
440
512
  return {
441
513
  "error": True,
442
514
  "error_type": type(exc).__name__,
443
515
  "message": (
444
- "An unexpected error occurred. "
445
- "This is usually transient — please retry."
516
+ "An unexpected error occurred. This is usually transient — please retry."
446
517
  ),
447
518
  "retriable": True,
448
519
  "_request_id": request_id,
449
520
  }
521
+ finally:
522
+ # Clear the request_id header so the next caller's tool_call
523
+ # doesn't accidentally inherit this one's correlation id.
524
+ if cited_ctx_for_header is not None:
525
+ with contextlib.suppress(Exception):
526
+ cited_ctx_for_header.client.set_request_id(None)
450
527
 
451
528
  return wrapper
452
529
 
@@ -461,8 +538,7 @@ def _api_error_response(e: CitedAPIError) -> dict[str, Any]:
461
538
 
462
539
  for code, substring, hint in _ERROR_HINTS:
463
540
  if e.status_code == code and (
464
- substring is None
465
- or (e.message and substring.lower() in e.message.lower())
541
+ substring is None or (e.message and substring.lower() in e.message.lower())
466
542
  ):
467
543
  response["hint"] = hint
468
544
  break
@@ -1556,6 +1556,78 @@ class TestDecoratorErrorHandling:
1556
1556
  assert "_request_id" in result
1557
1557
 
1558
1558
 
1559
+ class TestEnrichedLogging:
1560
+ """Verify tool_call lines carry server_version, fingerprint, deployment_id,
1561
+ resource IDs, and propagate X-Request-ID to the cited backend."""
1562
+
1563
+ def test_tool_call_log_includes_build_identity_and_resources(self, ctx, caplog):
1564
+ """Successful tool_call log must carry server_version, tools_fingerprint,
1565
+ deployment_id, AND extracted resource IDs from kwargs."""
1566
+ import json
1567
+ import logging
1568
+
1569
+ from cited_mcp.tools._helpers import _BUILD_IDENTITY
1570
+
1571
+ client = ctx.request_context.lifespan_context.client
1572
+ client.get.return_value = {"id": "biz-abc"}
1573
+
1574
+ with caplog.at_level(logging.INFO, logger="cited_mcp.usage"):
1575
+ run(get_business(ctx, business_id="biz-abc"))
1576
+
1577
+ tool_call_lines = [
1578
+ json.loads(r.message)
1579
+ for r in caplog.records
1580
+ if r.name == "cited_mcp.usage" and '"event": "tool_call"' in r.message
1581
+ ]
1582
+ assert tool_call_lines, "no tool_call log line emitted"
1583
+ rec = tool_call_lines[-1]
1584
+ # build identity is stamped
1585
+ assert rec["server_version"] == _BUILD_IDENTITY["server_version"]
1586
+ assert rec["tools_fingerprint"] == _BUILD_IDENTITY["tools_fingerprint"]
1587
+ assert "deployment_id" in rec
1588
+ # the business_id kwarg made it into resources
1589
+ assert rec.get("resources", {}).get("business_id") == "biz-abc"
1590
+
1591
+ def test_tool_call_propagates_request_id_to_client(self, ctx):
1592
+ """The decorator must call set_request_id on the CitedClient so the
1593
+ backend gets X-Request-ID for end-to-end correlation."""
1594
+ client = ctx.request_context.lifespan_context.client
1595
+ client.get.return_value = {"id": "biz-abc"}
1596
+
1597
+ run(get_business(ctx, business_id="biz-abc"))
1598
+
1599
+ # set_request_id should have been called twice: once with the
1600
+ # request_id, once with None to clear in finally.
1601
+ assert client.set_request_id.call_count >= 2
1602
+ # First call carries a non-None 12-char hex request_id
1603
+ first = client.set_request_id.call_args_list[0].args[0]
1604
+ assert isinstance(first, str) and len(first) == 12
1605
+ # Last call clears it
1606
+ assert client.set_request_id.call_args_list[-1].args[0] is None
1607
+
1608
+ def test_tool_call_log_carries_typed_error_on_failure(self, ctx, caplog):
1609
+ """When a tool returns {error: True, error_type: ..., status_code: ...}
1610
+ the tool_call log line should include those fields."""
1611
+ import json
1612
+ import logging
1613
+
1614
+ client = ctx.request_context.lifespan_context.client
1615
+ client.get.side_effect = CitedAPIError(404, "Business not found", "not_found")
1616
+
1617
+ with caplog.at_level(logging.INFO, logger="cited_mcp.usage"):
1618
+ run(get_business(ctx, business_id="missing"))
1619
+
1620
+ tool_call_lines = [
1621
+ json.loads(r.message)
1622
+ for r in caplog.records
1623
+ if r.name == "cited_mcp.usage" and '"event": "tool_call"' in r.message
1624
+ ]
1625
+ assert tool_call_lines
1626
+ rec = tool_call_lines[-1]
1627
+ assert rec["success"] is False
1628
+ assert rec.get("status_code") == 404
1629
+
1630
+
1559
1631
  class TestTransportErrors:
1560
1632
  def test_timeout_returns_error(self, ctx):
1561
1633
  """httpx.TimeoutException should return a structured error, not crash."""
@@ -4,14 +4,14 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cited-cli"
7
- version = "0.3.14"
7
+ version = "0.3.15"
8
8
  description = "CLI for the Cited GEO platform"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
11
11
  requires-python = ">=3.11"
12
12
  authors = [{ name = "Cited", email = "support@youcited.com" }]
13
13
  dependencies = [
14
- "cited-core>=0.3.14",
14
+ "cited-core>=0.3.15",
15
15
  "typer>=0.15.0",
16
16
  "rich>=13.0.0",
17
17
  ]
@@ -20,13 +20,13 @@ dependencies = [
20
20
  cited = "cited_cli.app:main"
21
21
 
22
22
  [project.optional-dependencies]
23
- mcp = ["cited-mcp>=0.3.14"]
23
+ mcp = ["cited-mcp>=0.3.15"]
24
24
  dev = [
25
25
  "pytest>=8.0",
26
26
  "respx>=0.21",
27
27
  "ruff>=0.4",
28
28
  "mypy>=1.10",
29
- "cited-mcp>=0.3.14",
29
+ "cited-mcp>=0.3.15",
30
30
  "types-PyYAML>=6.0",
31
31
  ]
32
32
 
@@ -1 +0,0 @@
1
- __version__ = "0.3.14"
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