docpull 5.0.0__tar.gz → 5.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 (157) hide show
  1. {docpull-5.0.0/src/docpull.egg-info → docpull-5.0.1}/PKG-INFO +1 -1
  2. {docpull-5.0.0 → docpull-5.0.1}/pyproject.toml +1 -1
  3. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/__init__.py +1 -1
  4. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/mcp/server.py +281 -71
  5. {docpull-5.0.0 → docpull-5.0.1/src/docpull.egg-info}/PKG-INFO +1 -1
  6. {docpull-5.0.0 → docpull-5.0.1}/tests/test_mcp_server.py +65 -0
  7. {docpull-5.0.0 → docpull-5.0.1}/LICENSE +0 -0
  8. {docpull-5.0.0 → docpull-5.0.1}/README.md +0 -0
  9. {docpull-5.0.0 → docpull-5.0.1}/setup.cfg +0 -0
  10. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/__main__.py +0 -0
  11. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/accounting.py +0 -0
  12. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/auth_cli.py +0 -0
  13. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/benchmark.py +0 -0
  14. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/cache/__init__.py +0 -0
  15. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/cache/frontier.py +0 -0
  16. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/cache/manager.py +0 -0
  17. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/cache/streaming_dedup.py +0 -0
  18. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/cli.py +0 -0
  19. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/__init__.py +0 -0
  20. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/chunking.py +0 -0
  21. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/extractor.py +0 -0
  22. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/filings.py +0 -0
  23. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/markdown.py +0 -0
  24. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/protocols.py +0 -0
  25. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/special_cases.py +0 -0
  26. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/conversion/trafilatura_extractor.py +0 -0
  27. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/core/__init__.py +0 -0
  28. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/core/fetcher.py +0 -0
  29. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/__init__.py +0 -0
  30. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/_fetch.py +0 -0
  31. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/composite.py +0 -0
  32. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/contracts.py +0 -0
  33. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/crawler.py +0 -0
  34. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/filters.py +0 -0
  35. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/link_extractors/__init__.py +0 -0
  36. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/link_extractors/enhanced.py +0 -0
  37. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/link_extractors/protocols.py +0 -0
  38. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/link_extractors/static.py +0 -0
  39. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/protocols.py +0 -0
  40. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery/sitemap.py +0 -0
  41. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/discovery_cli.py +0 -0
  42. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/doctor.py +0 -0
  43. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/evidence_pack.py +0 -0
  44. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/exports.py +0 -0
  45. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/fixtures/__init__.py +0 -0
  46. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/fixtures/parallel-search-extract.json +0 -0
  47. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/graph.py +0 -0
  48. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/http/__init__.py +0 -0
  49. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/http/client.py +0 -0
  50. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/http/protocols.py +0 -0
  51. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/http/rate_limiter.py +0 -0
  52. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/judge.py +0 -0
  53. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/local_workflows.py +0 -0
  54. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/mcp/__init__.py +0 -0
  55. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/mcp/sources.py +0 -0
  56. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/mcp/tools.py +0 -0
  57. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/metadata_extractor.py +0 -0
  58. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/__init__.py +0 -0
  59. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/config.py +0 -0
  60. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/document.py +0 -0
  61. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/events.py +0 -0
  62. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/profiles.py +0 -0
  63. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/models/run.py +0 -0
  64. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/monitor.py +0 -0
  65. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pack_reader.py +0 -0
  66. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pack_tools.py +0 -0
  67. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/parallel_workflows.py +0 -0
  68. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/parity.py +0 -0
  69. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/parity_cli.py +0 -0
  70. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/passk.py +0 -0
  71. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/__init__.py +0 -0
  72. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/base.py +0 -0
  73. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/manifest.py +0 -0
  74. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/__init__.py +0 -0
  75. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/chunk.py +0 -0
  76. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/convert.py +0 -0
  77. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/dedup.py +0 -0
  78. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/fetch.py +0 -0
  79. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/metadata.py +0 -0
  80. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/render.py +0 -0
  81. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/save.py +0 -0
  82. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/save_json.py +0 -0
  83. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/save_ndjson.py +0 -0
  84. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/save_okf.py +0 -0
  85. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/save_sqlite.py +0 -0
  86. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/pipeline/steps/validate.py +0 -0
  87. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/policy.py +0 -0
  88. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/policy_cli.py +0 -0
  89. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/provider_adapters.py +0 -0
  90. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/provider_capabilities.py +0 -0
  91. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/provider_cli.py +0 -0
  92. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/provider_keys.py +0 -0
  93. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/provider_probes.py +0 -0
  94. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/py.typed +0 -0
  95. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/rendering.py +0 -0
  96. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/scraper.py +0 -0
  97. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/security/__init__.py +0 -0
  98. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/security/download_policy.py +0 -0
  99. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/security/robots.py +0 -0
  100. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/security/url_validator.py +0 -0
  101. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/server.py +0 -0
  102. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/skill_export.py +0 -0
  103. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/source_scoring.py +0 -0
  104. {docpull-5.0.0 → docpull-5.0.1}/src/docpull/time_utils.py +0 -0
  105. {docpull-5.0.0 → docpull-5.0.1}/src/docpull.egg-info/SOURCES.txt +0 -0
  106. {docpull-5.0.0 → docpull-5.0.1}/src/docpull.egg-info/dependency_links.txt +0 -0
  107. {docpull-5.0.0 → docpull-5.0.1}/src/docpull.egg-info/entry_points.txt +0 -0
  108. {docpull-5.0.0 → docpull-5.0.1}/src/docpull.egg-info/requires.txt +0 -0
  109. {docpull-5.0.0 → docpull-5.0.1}/src/docpull.egg-info/top_level.txt +0 -0
  110. {docpull-5.0.0 → docpull-5.0.1}/tests/test_accounting.py +0 -0
  111. {docpull-5.0.0 → docpull-5.0.1}/tests/test_auth_cli.py +0 -0
  112. {docpull-5.0.0 → docpull-5.0.1}/tests/test_benchmark.py +0 -0
  113. {docpull-5.0.0 → docpull-5.0.1}/tests/test_cache_conditional_get.py +0 -0
  114. {docpull-5.0.0 → docpull-5.0.1}/tests/test_chunking.py +0 -0
  115. {docpull-5.0.0 → docpull-5.0.1}/tests/test_ci_policy.py +0 -0
  116. {docpull-5.0.0 → docpull-5.0.1}/tests/test_cli.py +0 -0
  117. {docpull-5.0.0 → docpull-5.0.1}/tests/test_conversion.py +0 -0
  118. {docpull-5.0.0 → docpull-5.0.1}/tests/test_convert_step_new.py +0 -0
  119. {docpull-5.0.0 → docpull-5.0.1}/tests/test_discovery.py +0 -0
  120. {docpull-5.0.0 → docpull-5.0.1}/tests/test_discovery_contracts.py +0 -0
  121. {docpull-5.0.0 → docpull-5.0.1}/tests/test_doctor.py +0 -0
  122. {docpull-5.0.0 → docpull-5.0.1}/tests/test_document_record.py +0 -0
  123. {docpull-5.0.0 → docpull-5.0.1}/tests/test_evidence_pack.py +0 -0
  124. {docpull-5.0.0 → docpull-5.0.1}/tests/test_exports.py +0 -0
  125. {docpull-5.0.0 → docpull-5.0.1}/tests/test_frontier_resume.py +0 -0
  126. {docpull-5.0.0 → docpull-5.0.1}/tests/test_graph.py +0 -0
  127. {docpull-5.0.0 → docpull-5.0.1}/tests/test_integration.py +0 -0
  128. {docpull-5.0.0 → docpull-5.0.1}/tests/test_judge.py +0 -0
  129. {docpull-5.0.0 → docpull-5.0.1}/tests/test_link_extractors.py +0 -0
  130. {docpull-5.0.0 → docpull-5.0.1}/tests/test_local_workflows.py +0 -0
  131. {docpull-5.0.0 → docpull-5.0.1}/tests/test_mcp_tools.py +0 -0
  132. {docpull-5.0.0 → docpull-5.0.1}/tests/test_metadata_extractor.py +0 -0
  133. {docpull-5.0.0 → docpull-5.0.1}/tests/test_monitor.py +0 -0
  134. {docpull-5.0.0 → docpull-5.0.1}/tests/test_naming.py +0 -0
  135. {docpull-5.0.0 → docpull-5.0.1}/tests/test_outputs_e2e.py +0 -0
  136. {docpull-5.0.0 → docpull-5.0.1}/tests/test_pack_server.py +0 -0
  137. {docpull-5.0.0 → docpull-5.0.1}/tests/test_pack_tools.py +0 -0
  138. {docpull-5.0.0 → docpull-5.0.1}/tests/test_parallel_workflows.py +0 -0
  139. {docpull-5.0.0 → docpull-5.0.1}/tests/test_parity_workflows.py +0 -0
  140. {docpull-5.0.0 → docpull-5.0.1}/tests/test_passk.py +0 -0
  141. {docpull-5.0.0 → docpull-5.0.1}/tests/test_pipeline.py +0 -0
  142. {docpull-5.0.0 → docpull-5.0.1}/tests/test_policy.py +0 -0
  143. {docpull-5.0.0 → docpull-5.0.1}/tests/test_policy_cli.py +0 -0
  144. {docpull-5.0.0 → docpull-5.0.1}/tests/test_provider_adapters.py +0 -0
  145. {docpull-5.0.0 → docpull-5.0.1}/tests/test_provider_cli.py +0 -0
  146. {docpull-5.0.0 → docpull-5.0.1}/tests/test_provider_keys.py +0 -0
  147. {docpull-5.0.0 → docpull-5.0.1}/tests/test_provider_probes.py +0 -0
  148. {docpull-5.0.0 → docpull-5.0.1}/tests/test_rate_limiter.py +0 -0
  149. {docpull-5.0.0 → docpull-5.0.1}/tests/test_real_site_regressions.py +0 -0
  150. {docpull-5.0.0 → docpull-5.0.1}/tests/test_rendering.py +0 -0
  151. {docpull-5.0.0 → docpull-5.0.1}/tests/test_save_ndjson.py +0 -0
  152. {docpull-5.0.0 → docpull-5.0.1}/tests/test_save_sqlite.py +0 -0
  153. {docpull-5.0.0 → docpull-5.0.1}/tests/test_security_hardening.py +0 -0
  154. {docpull-5.0.0 → docpull-5.0.1}/tests/test_special_cases.py +0 -0
  155. {docpull-5.0.0 → docpull-5.0.1}/tests/test_surface_contract.py +0 -0
  156. {docpull-5.0.0 → docpull-5.0.1}/tests/test_time_utils.py +0 -0
  157. {docpull-5.0.0 → docpull-5.0.1}/tests/test_trafilatura_extractor.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docpull
3
- Version: 5.0.0
3
+ Version: 5.0.1
4
4
  Summary: Convert public web pages into clean Markdown for AI agents and RAG
5
5
  Author-email: Zachary Roth <support@raintree.technology>
6
6
  Maintainer-email: Raintree Technology <support@raintree.technology>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "docpull"
7
- version = "5.0.0"
7
+ version = "5.0.1"
8
8
  dynamic = []
9
9
  description = "Convert public web pages into clean Markdown for AI agents and RAG"
10
10
  readme = {file = "README.md", content-type = "text/markdown"}
@@ -14,7 +14,7 @@ Usage:
14
14
  print(event)
15
15
  """
16
16
 
17
- __version__ = "5.0.0"
17
+ __version__ = "5.0.1"
18
18
 
19
19
  from .cache import CacheManager, StreamingDeduplicator
20
20
  from .conversion.chunking import Chunk, TokenCounter, chunk_markdown
@@ -48,6 +48,7 @@ from __future__ import annotations
48
48
 
49
49
  import argparse
50
50
  import asyncio
51
+ import json
51
52
  import logging
52
53
  import sys
53
54
  from collections.abc import Awaitable, Callable
@@ -55,9 +56,14 @@ from pathlib import Path
55
56
  from typing import Any, Literal, cast
56
57
 
57
58
  from ..accounting import (
59
+ ACCOUNTING_ARTIFACT,
60
+ RunAccounting,
61
+ blocked_action,
58
62
  budget_block_payload,
63
+ default_route_steps,
59
64
  effective_budget_limit,
60
- enforce_paid_budget,
65
+ maybe_write_run_accounting,
66
+ paid_action_blocked,
61
67
  )
62
68
  from ..discovery import (
63
69
  CandidateSourceRecord,
@@ -136,6 +142,42 @@ SERVER_INSTRUCTIONS = (
136
142
  # Tools that return free-form Markdown (fetch_url) intentionally omit a
137
143
  # schema; the rest expose structured payloads alongside the rendered text.
138
144
 
145
+ _ACCOUNTING_OUTPUT_SCHEMA = {
146
+ "type": "object",
147
+ "description": (
148
+ "Non-secret run accounting receipt. Mirrors run.accounting.json when an artifact was written."
149
+ ),
150
+ "properties": {
151
+ "schema_version": {"type": "integer"},
152
+ "generated_at": {"type": "string"},
153
+ "budget_limit_usd": {"type": ["number", "null"]},
154
+ "estimated_paid_cost_usd": {"type": "number"},
155
+ "actual_paid_cost_usd": {"type": ["number", "null"]},
156
+ "paid_request_count": {"type": "integer"},
157
+ "local_browser_seconds": {"type": "number"},
158
+ "http_request_count": {"type": "integer"},
159
+ "cache_hit_count": {"type": "integer"},
160
+ "blocked_actions": {"type": "array", "items": {"type": "object"}},
161
+ "route_steps": {"type": "array", "items": {"type": "object"}},
162
+ "command": {"type": "string"},
163
+ "metadata": {"type": "object"},
164
+ "artifact_path": {"type": "string"},
165
+ },
166
+ "required": [
167
+ "schema_version",
168
+ "generated_at",
169
+ "budget_limit_usd",
170
+ "estimated_paid_cost_usd",
171
+ "actual_paid_cost_usd",
172
+ "paid_request_count",
173
+ "local_browser_seconds",
174
+ "http_request_count",
175
+ "cache_hit_count",
176
+ "blocked_actions",
177
+ "route_steps",
178
+ ],
179
+ }
180
+
139
181
  _LIST_SOURCES_OUTPUT_SCHEMA = {
140
182
  "type": "object",
141
183
  "properties": {
@@ -274,6 +316,7 @@ _PARALLEL_PACK_OUTPUT_SCHEMA = {
274
316
  "output_dir": {"type": "string"},
275
317
  "dry_run": {"type": "boolean"},
276
318
  "estimated_cost_usd": {"type": "number"},
319
+ "accounting": _ACCOUNTING_OUTPUT_SCHEMA,
277
320
  },
278
321
  "required": ["workflow", "output_dir"],
279
322
  }
@@ -470,6 +513,7 @@ _ANSWER_PACK_OUTPUT_SCHEMA = {
470
513
  "search": {"type": "object"},
471
514
  "brief": {"type": "object"},
472
515
  "artifacts": {"type": "object"},
516
+ "accounting": _ACCOUNTING_OUTPUT_SCHEMA,
473
517
  },
474
518
  "required": ["question", "answer", "search", "artifacts"],
475
519
  }
@@ -494,8 +538,14 @@ _RENDER_URL_OUTPUT_SCHEMA = {
494
538
  "sidecar_path": {"type": "string"},
495
539
  "html_bytes": {"type": "integer"},
496
540
  "html_sha256": {"type": "string"},
541
+ "output_dir": {"type": "string"},
542
+ "dry_run": {"type": "boolean"},
543
+ "blocked_by_budget": {"type": "boolean"},
544
+ "budget_limit_usd": {"type": ["number", "null"]},
545
+ "blocked_action": {"type": "object"},
546
+ "accounting": _ACCOUNTING_OUTPUT_SCHEMA,
497
547
  },
498
- "required": ["url", "backend", "html_path", "sidecar_path", "html_bytes", "html_sha256"],
548
+ "required": ["url", "backend"],
499
549
  }
500
550
 
501
551
  _DISCOVER_SOURCES_OUTPUT_SCHEMA = {
@@ -615,6 +665,43 @@ def _policy_arg(arguments: dict[str, Any]) -> PolicyConfig:
615
665
  return PolicyConfig.from_file(policy_path) if policy_path else PolicyConfig()
616
666
 
617
667
 
668
+ def _read_accounting_payload(path: Path) -> dict[str, Any] | None:
669
+ try:
670
+ payload = json.loads(path.read_text(encoding="utf-8"))
671
+ except (OSError, json.JSONDecodeError):
672
+ return None
673
+ if not isinstance(payload, dict):
674
+ return None
675
+ payload["artifact_path"] = str(path)
676
+ return payload
677
+
678
+
679
+ def _mcp_accounting_payload(
680
+ accounting: RunAccounting,
681
+ *,
682
+ output_dir: Path | None = None,
683
+ budget_limit_usd: float | None,
684
+ paid_capable: bool,
685
+ ) -> dict[str, Any]:
686
+ path = None
687
+ if output_dir is not None:
688
+ path = maybe_write_run_accounting(
689
+ output_dir,
690
+ budget_limit_usd=budget_limit_usd,
691
+ paid_capable=paid_capable,
692
+ accounting=accounting,
693
+ )
694
+ if path is not None:
695
+ artifact_payload = _read_accounting_payload(path)
696
+ if artifact_payload is not None:
697
+ return artifact_payload
698
+ return accounting.to_dict()
699
+
700
+
701
+ def _pack_accounting_payload(pack_dir: Path) -> dict[str, Any] | None:
702
+ return _read_accounting_payload(pack_dir / ACCOUNTING_ARTIFACT)
703
+
704
+
618
705
  async def _dispatch_tool(
619
706
  name: str,
620
707
  arguments: dict[str, Any],
@@ -669,6 +756,26 @@ async def _dispatch_tool(
669
756
  cloud_agent_browser_binary = arguments.get("cloud_agent_browser_binary", "agent-browser")
670
757
  if not isinstance(cloud_agent_browser_binary, str):
671
758
  raise ValueError("'cloud_agent_browser_binary' must be a string")
759
+ output_dir = _path_arg(arguments, "output_dir", "rendered")
760
+ estimated = 0.0
761
+ paid_capable_render = backend in {"vercel-sandbox", "e2b-sandbox"}
762
+ accounting = RunAccounting(
763
+ budget_limit_usd=budget_limit,
764
+ estimated_paid_cost_usd=estimated,
765
+ route_steps=default_route_steps(
766
+ include_local_render=True,
767
+ include_cloud=paid_capable_render,
768
+ budget_limit_usd=budget_limit,
769
+ ),
770
+ command="mcp render_url",
771
+ metadata={
772
+ "mcp_tool": "render_url",
773
+ "url": url,
774
+ "runtime": runtime,
775
+ "backend": backend,
776
+ },
777
+ )
778
+ render_blocked = False
672
779
  if backend in {"vercel-sandbox", "e2b-sandbox"}:
673
780
  from ..models.config import RenderConfig
674
781
  from ..rendering import estimate_cloud_render_cost_usd
@@ -682,40 +789,77 @@ async def _dispatch_tool(
682
789
  timeout_seconds=float(timeout_arg),
683
790
  ),
684
791
  )
685
- enforce_paid_budget(
686
- f"render:{backend}",
792
+ accounting.estimated_paid_cost_usd = estimated
793
+ if paid_action_blocked(budget_limit, estimated_cost_usd=estimated):
794
+ render_blocked = True
795
+ accounting.blocked_actions.append(
796
+ blocked_action(
797
+ f"render:{backend}",
798
+ budget_limit_usd=budget_limit,
799
+ estimated_cost_usd=estimated,
800
+ provider=backend,
801
+ )
802
+ )
803
+ accounting_payload = _mcp_accounting_payload(
804
+ accounting,
805
+ output_dir=output_dir,
806
+ budget_limit_usd=budget_limit,
807
+ paid_capable=True,
808
+ )
809
+ result = ToolResult(
810
+ f"Render blocked by budget before {backend}.",
811
+ data={
812
+ "url": url,
813
+ "backend": backend,
814
+ "output_dir": str(output_dir),
815
+ "dry_run": False,
816
+ **budget_block_payload(
817
+ f"render:{backend}",
818
+ budget_limit_usd=budget_limit,
819
+ estimated_cost_usd=estimated,
820
+ provider=backend,
821
+ ),
822
+ "accounting": accounting_payload,
823
+ },
824
+ )
825
+ else:
826
+ accounting.paid_request_count = 1
827
+ if not render_blocked:
828
+ artifact = await render_url_to_directory(
829
+ url,
830
+ output_dir,
831
+ config={
832
+ "mode": "agent-browser",
833
+ "backend": backend,
834
+ "allowed_domains": _string_list_arg(arguments, "allowed_domains"),
835
+ "timeout_seconds": float(timeout_arg),
836
+ "wait_for": wait_for,
837
+ "cloud_agent_browser_install": cloud_agent_browser_install,
838
+ "cloud_result_transport": cloud_result_transport,
839
+ "cloud_max_estimated_cost_usd": cloud_max_estimated_cost,
840
+ "cloud_agent_browser_binary": cloud_agent_browser_binary,
841
+ "e2b_template": template,
842
+ },
843
+ )
844
+ accounting_payload = _mcp_accounting_payload(
845
+ accounting,
846
+ output_dir=output_dir,
687
847
  budget_limit_usd=budget_limit,
688
- estimated_cost_usd=estimated,
689
- provider=backend,
848
+ paid_capable=paid_capable_render,
849
+ )
850
+ payload = {
851
+ "url": artifact.page.url,
852
+ "backend": artifact.page.backend,
853
+ "html_path": str(artifact.html_path),
854
+ "sidecar_path": str(artifact.sidecar_path),
855
+ "html_bytes": artifact.page.html_bytes,
856
+ "html_sha256": artifact.page.html_sha256,
857
+ "accounting": accounting_payload,
858
+ }
859
+ result = ToolResult(
860
+ f"Rendered {artifact.page.html_bytes} bytes: {artifact.html_path}",
861
+ data=payload,
690
862
  )
691
- artifact = await render_url_to_directory(
692
- url,
693
- _path_arg(arguments, "output_dir", "rendered"),
694
- config={
695
- "mode": "agent-browser",
696
- "backend": backend,
697
- "allowed_domains": _string_list_arg(arguments, "allowed_domains"),
698
- "timeout_seconds": float(timeout_arg),
699
- "wait_for": wait_for,
700
- "cloud_agent_browser_install": cloud_agent_browser_install,
701
- "cloud_result_transport": cloud_result_transport,
702
- "cloud_max_estimated_cost_usd": cloud_max_estimated_cost,
703
- "cloud_agent_browser_binary": cloud_agent_browser_binary,
704
- "e2b_template": template,
705
- },
706
- )
707
- payload = {
708
- "url": artifact.page.url,
709
- "backend": artifact.page.backend,
710
- "html_path": str(artifact.html_path),
711
- "sidecar_path": str(artifact.sidecar_path),
712
- "html_bytes": artifact.page.html_bytes,
713
- "html_sha256": artifact.page.html_sha256,
714
- }
715
- result = ToolResult(
716
- f"Rendered {artifact.page.html_bytes} bytes: {artifact.html_path}",
717
- data=payload,
718
- )
719
863
 
720
864
  elif name == "ensure_docs":
721
865
  source = _require_str(arguments, "source")
@@ -794,6 +938,7 @@ async def _dispatch_tool(
794
938
  max_estimated_cost,
795
939
  float(budget_arg) if budget_arg is not None else None,
796
940
  )
941
+ output_dir = _path_arg(arguments, "output_dir", "packs/parallel-context-pack")
797
942
  request_options = _build_request_options(
798
943
  source_policy=source_policy,
799
944
  fetch_policy=fetch_policy,
@@ -808,6 +953,32 @@ async def _dispatch_tool(
808
953
  else None,
809
954
  full_content=True,
810
955
  )
956
+ blocked_by_budget = paid_action_blocked(budget_limit, estimated_cost_usd=estimated_cost)
957
+ accounting = RunAccounting(
958
+ budget_limit_usd=budget_limit,
959
+ estimated_paid_cost_usd=estimated_cost,
960
+ route_steps=default_route_steps(
961
+ include_provider=True,
962
+ budget_limit_usd=budget_limit,
963
+ ),
964
+ command="mcp parallel_context_pack",
965
+ metadata={
966
+ "mcp_tool": "parallel_context_pack",
967
+ "objective": objective,
968
+ "query_count": len(queries),
969
+ "extract_limit": extract_limit,
970
+ "max_search_results": max_search_results,
971
+ },
972
+ )
973
+ if blocked_by_budget:
974
+ accounting.blocked_actions.append(
975
+ blocked_action(
976
+ "parallel:context-pack",
977
+ budget_limit_usd=budget_limit,
978
+ estimated_cost_usd=estimated_cost,
979
+ provider="parallel",
980
+ )
981
+ )
811
982
  if bool(arguments.get("dry_run", False)):
812
983
  blocked = (
813
984
  budget_block_payload(
@@ -816,60 +987,95 @@ async def _dispatch_tool(
816
987
  estimated_cost_usd=estimated_cost,
817
988
  provider="parallel",
818
989
  )
819
- if budget_limit is not None and budget_limit <= 0
990
+ if blocked_by_budget
820
991
  else {}
821
992
  )
993
+ accounting_payload = _mcp_accounting_payload(
994
+ accounting,
995
+ output_dir=None,
996
+ budget_limit_usd=budget_limit,
997
+ paid_capable=True,
998
+ )
822
999
  result = ToolResult(
823
1000
  "Parallel context pack dry run.",
824
1001
  data={
825
1002
  "workflow": "context-pack",
826
- "output_dir": str(_path_arg(arguments, "output_dir", "packs/parallel-context-pack")),
1003
+ "output_dir": str(output_dir),
827
1004
  "dry_run": True,
828
1005
  "estimated_cost_usd": estimated_cost,
829
1006
  "budget_limit_usd": budget_limit,
830
1007
  "request_options": request_options,
831
1008
  **blocked,
1009
+ "accounting": accounting_payload,
832
1010
  },
833
1011
  )
834
1012
  else:
835
- enforce_paid_budget(
836
- "parallel:context-pack",
837
- budget_limit_usd=budget_limit,
838
- estimated_cost_usd=estimated_cost,
839
- provider="parallel",
840
- )
841
- if estimated_cost > max_estimated_cost:
1013
+ if blocked_by_budget:
1014
+ accounting_payload = _mcp_accounting_payload(
1015
+ accounting,
1016
+ output_dir=output_dir,
1017
+ budget_limit_usd=budget_limit,
1018
+ paid_capable=True,
1019
+ )
1020
+ result = ToolResult(
1021
+ "Parallel context pack blocked by budget before provider call.",
1022
+ data={
1023
+ "workflow": "context-pack",
1024
+ "output_dir": str(output_dir),
1025
+ "dry_run": False,
1026
+ "estimated_cost_usd": estimated_cost,
1027
+ "budget_limit_usd": budget_limit,
1028
+ "request_options": request_options,
1029
+ **budget_block_payload(
1030
+ "parallel:context-pack",
1031
+ budget_limit_usd=budget_limit,
1032
+ estimated_cost_usd=estimated_cost,
1033
+ provider="parallel",
1034
+ ),
1035
+ "accounting": accounting_payload,
1036
+ },
1037
+ )
1038
+ elif estimated_cost > max_estimated_cost:
842
1039
  raise ValueError(
843
1040
  f"Estimated Parallel cost ${estimated_cost:.3f} exceeds max_estimated_cost "
844
1041
  f"${max_estimated_cost:.3f}"
845
1042
  )
846
- output_dir = _path_arg(arguments, "output_dir", "packs/parallel-context-pack")
847
- mode_arg = arguments.get("mode")
848
- mode = mode_arg if isinstance(mode_arg, str) else "advanced"
849
- pack_path = await asyncio.to_thread(
850
- run_live_context_pack,
851
- objective=objective,
852
- queries=queries,
853
- output_dir=output_dir,
854
- mode=mode,
855
- extract_limit=extract_limit,
856
- max_tokens_per_file=DEFAULT_MAX_TOKENS,
857
- source_policy=source_policy,
858
- max_search_results=max_search_results,
859
- client_model=arguments.get("client_model")
860
- if isinstance(arguments.get("client_model"), str)
861
- else None,
862
- estimated_cost_usd=estimated_cost,
863
- )
864
- result = ToolResult(
865
- f"Wrote Parallel context pack: {pack_path}",
866
- data={
867
- "workflow": "context-pack",
868
- "output_dir": str(pack_path),
869
- "dry_run": False,
870
- "estimated_cost_usd": estimated_cost,
871
- },
872
- )
1043
+ else:
1044
+ mode_arg = arguments.get("mode")
1045
+ mode = mode_arg if isinstance(mode_arg, str) else "advanced"
1046
+ accounting.paid_request_count = 1
1047
+ pack_path = await asyncio.to_thread(
1048
+ run_live_context_pack,
1049
+ objective=objective,
1050
+ queries=queries,
1051
+ output_dir=output_dir,
1052
+ mode=mode,
1053
+ extract_limit=extract_limit,
1054
+ max_tokens_per_file=DEFAULT_MAX_TOKENS,
1055
+ source_policy=source_policy,
1056
+ max_search_results=max_search_results,
1057
+ client_model=arguments.get("client_model")
1058
+ if isinstance(arguments.get("client_model"), str)
1059
+ else None,
1060
+ estimated_cost_usd=estimated_cost,
1061
+ )
1062
+ accounting.metadata["output_dir"] = str(pack_path)
1063
+ accounting_payload = _mcp_accounting_payload(
1064
+ accounting,
1065
+ output_dir=pack_path,
1066
+ budget_limit_usd=budget_limit,
1067
+ paid_capable=True,
1068
+ )
1069
+ result = ToolResult(
1070
+ f"Wrote Parallel context pack: {pack_path}",
1071
+ data={
1072
+ "workflow": "context-pack",
1073
+ "output_dir": str(pack_path),
1074
+ "dry_run": False,
1075
+ "estimated_cost_usd": estimated_cost,
1076
+ "accounting": accounting_payload,
1077
+ },
1078
+ )
873
1079
 
874
1080
  elif name == "parallel_api_pack":
875
1081
  source = _require_str(arguments, "source")
@@ -1135,13 +1341,17 @@ async def _dispatch_tool(
1135
1341
  )
1136
1342
 
1137
1343
  elif name == "answer_pack":
1344
+ answer_pack_dir = _path_arg(arguments, "pack_dir")
1138
1345
  answer_payload: dict[str, Any] = await asyncio.to_thread(
1139
1346
  answer_pack,
1140
- _path_arg(arguments, "pack_dir"),
1347
+ answer_pack_dir,
1141
1348
  _require_str(arguments, "question"),
1142
1349
  required_domains=_string_list_arg(arguments, "required_domains"),
1143
1350
  limit=_coerce_int(arguments.get("limit"), name="limit", default=8),
1144
1351
  )
1352
+ existing_accounting = _pack_accounting_payload(answer_pack_dir)
1353
+ if existing_accounting is not None:
1354
+ answer_payload["accounting"] = existing_accounting
1145
1355
  answer_raw = answer_payload.get("answer")
1146
1356
  answer_data: dict[str, Any] = answer_raw if isinstance(answer_raw, dict) else {}
1147
1357
  result = ToolResult(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docpull
3
- Version: 5.0.0
3
+ Version: 5.0.1
4
4
  Summary: Convert public web pages into clean Markdown for AI agents and RAG
5
5
  Author-email: Zachary Roth <support@raintree.technology>
6
6
  Maintainer-email: Raintree Technology <support@raintree.technology>
@@ -17,6 +17,7 @@ pytest.importorskip("mcp.client.stdio")
17
17
 
18
18
  from mcp.client.stdio import stdio_client
19
19
 
20
+ from docpull.accounting import RunAccounting, write_run_accounting
20
21
  from docpull.mcp import server as mcp_server
21
22
  from mcp import ClientSession, StdioServerParameters
22
23
  from tests.pack_fixtures import write_context_pack
@@ -155,6 +156,38 @@ async def test_mcp_dispatch_tool_handles_success_and_validation_errors(tmp_path)
155
156
  assert blocked_parallel.is_error is False
156
157
  assert blocked_parallel.data is not None
157
158
  assert blocked_parallel.data["blocked_by_budget"] is True
159
+ assert blocked_parallel.data["accounting"]["budget_limit_usd"] == 0
160
+ assert blocked_parallel.data["accounting"]["blocked_actions"][0]["provider"] == "parallel"
161
+
162
+ blocked_live_parallel = await mcp_server._dispatch_tool(
163
+ "parallel_context_pack",
164
+ {
165
+ "objective": "Parallel docs",
166
+ "budget": 0,
167
+ "output_dir": str(tmp_path / "blocked-parallel"),
168
+ },
169
+ )
170
+ assert blocked_live_parallel.is_error is False
171
+ assert blocked_live_parallel.data is not None
172
+ assert blocked_live_parallel.data["blocked_by_budget"] is True
173
+ parallel_accounting_path = tmp_path / "blocked-parallel" / "run.accounting.json"
174
+ assert blocked_live_parallel.data["accounting"]["artifact_path"] == str(parallel_accounting_path)
175
+ assert parallel_accounting_path.exists()
176
+
177
+ blocked_cloud_render = await mcp_server._dispatch_tool(
178
+ "render_url",
179
+ {
180
+ "url": "https://example.com",
181
+ "runtime": "vercel",
182
+ "budget": 0,
183
+ "output_dir": str(tmp_path / "blocked-render"),
184
+ },
185
+ )
186
+ assert blocked_cloud_render.is_error is False
187
+ assert blocked_cloud_render.data is not None
188
+ assert blocked_cloud_render.data["blocked_by_budget"] is True
189
+ assert blocked_cloud_render.data["accounting"]["blocked_actions"][0]["provider"] == "vercel-sandbox"
190
+ assert (tmp_path / "blocked-render" / "run.accounting.json").exists()
158
191
 
159
192
  unknown = await mcp_server._dispatch_tool("not_a_tool", {})
160
193
  assert unknown.is_error is True
@@ -173,6 +206,22 @@ async def test_mcp_dispatch_tool_handles_success_and_validation_errors(tmp_path)
173
206
  assert research.data["workflow"] == "research-pack"
174
207
  assert (tmp_path / "research" / "research.result.json").exists()
175
208
 
209
+ write_run_accounting(
210
+ pack_dir,
211
+ RunAccounting(
212
+ budget_limit_usd=0,
213
+ command="test context-pack",
214
+ ),
215
+ )
216
+ answer = await mcp_server._dispatch_tool(
217
+ "answer_pack",
218
+ {"pack_dir": str(pack_dir), "question": "What does Parallel Search return?"},
219
+ )
220
+ assert answer.is_error is False
221
+ assert answer.data is not None
222
+ assert answer.data["accounting"]["budget_limit_usd"] == 0
223
+ assert answer.data["accounting"]["artifact_path"] == str(pack_dir / "run.accounting.json")
224
+
176
225
 
177
226
  @pytest.mark.asyncio
178
227
  async def test_stdio_server_lists_and_calls_tools(tmp_path):
@@ -269,6 +318,22 @@ async def test_stdio_server_lists_and_calls_tools(tmp_path):
269
318
  assert dry_run.structuredContent is not None
270
319
  assert dry_run.structuredContent["dry_run"] is True
271
320
 
321
+ blocked_render = await session.call_tool(
322
+ "render_url",
323
+ {
324
+ "url": "https://example.com",
325
+ "runtime": "vercel",
326
+ "budget": 0,
327
+ "output_dir": str(tmp_path / "stdio-blocked-render"),
328
+ },
329
+ )
330
+ assert blocked_render.isError is False
331
+ assert blocked_render.structuredContent is not None
332
+ assert blocked_render.structuredContent["blocked_by_budget"] is True
333
+ assert blocked_render.structuredContent["accounting"]["blocked_actions"][0]["provider"] == (
334
+ "vercel-sandbox"
335
+ )
336
+
272
337
  prepared = await session.call_tool(
273
338
  "pack_prepare",
274
339
  {
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
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
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
File without changes