api-mcp-compiler 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {api_mcp_compiler-0.2.0/src/api_mcp_compiler.egg-info → api_mcp_compiler-0.3.0}/PKG-INFO +39 -27
  2. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/README.md +38 -26
  3. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/pyproject.toml +1 -1
  4. api_mcp_compiler-0.3.0/src/api_mcp_compiler/__init__.py +1 -0
  5. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/mcp_server.py +88 -2
  6. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/soap_server.py +51 -6
  7. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0/src/api_mcp_compiler.egg-info}/PKG-INFO +39 -27
  8. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler.egg-info/SOURCES.txt +1 -0
  9. api_mcp_compiler-0.3.0/tests/test_generated_retry.py +263 -0
  10. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_mcp_server.py +9 -4
  11. api_mcp_compiler-0.2.0/src/api_mcp_compiler/__init__.py +0 -1
  12. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/LICENSE +0 -0
  13. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/NOTICE +0 -0
  14. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/setup.cfg +0 -0
  15. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/benchmarks.py +0 -0
  16. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/cli.py +0 -0
  17. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/__init__.py +0 -0
  18. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/composite.py +0 -0
  19. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/credentials.py +0 -0
  20. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/schema.py +0 -0
  21. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/codegen/tools.py +0 -0
  22. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/contracts.py +0 -0
  23. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/__init__.py +0 -0
  24. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/harness.py +0 -0
  25. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/model_driver.py +0 -0
  26. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/oracles.py +0 -0
  27. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/preregistration.py +0 -0
  28. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/restbench.py +0 -0
  29. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/evaluation/state.py +0 -0
  30. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/__init__.py +0 -0
  31. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/coverage.py +0 -0
  32. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/documents.py +0 -0
  33. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/openapi.py +0 -0
  34. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/refs.py +0 -0
  35. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/swagger2.py +0 -0
  36. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/wsdl.py +0 -0
  37. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/ingest/xsd.py +0 -0
  38. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/models.py +0 -0
  39. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/__init__.py +0 -0
  40. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/approval.py +0 -0
  41. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/baseline.py +0 -0
  42. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/overlay.py +0 -0
  43. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/report.py +0 -0
  44. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/planning/semantic.py +0 -0
  45. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/policy/__init__.py +0 -0
  46. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/policy/synthesis.py +0 -0
  47. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/provenance.py +0 -0
  48. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/reporting/__init__.py +0 -0
  49. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/reporting/conversion_report.py +0 -0
  50. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/routes.py +0 -0
  51. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/runtime/__init__.py +0 -0
  52. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/runtime/governance.py +0 -0
  53. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/runtime/mock.py +0 -0
  54. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/api_semantic_ir.schema.json +0 -0
  55. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/eval_corpus.schema.json +0 -0
  56. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/evaluation_run.schema.json +0 -0
  57. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/mcp_tool_surface.schema.json +0 -0
  58. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/policy_manifest.schema.json +0 -0
  59. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/preregistration.schema.json +0 -0
  60. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/tool_overlay.schema.json +0 -0
  61. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler/schemas/tool_plan.schema.json +0 -0
  62. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler.egg-info/dependency_links.txt +0 -0
  63. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler.egg-info/entry_points.txt +0 -0
  64. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler.egg-info/requires.txt +0 -0
  65. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/src/api_mcp_compiler.egg-info/top_level.txt +0 -0
  66. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_baseline_planner.py +0 -0
  67. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_benchmarks.py +0 -0
  68. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_codegen.py +0 -0
  69. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_codegen_schema.py +0 -0
  70. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_contracts.py +0 -0
  71. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_coverage.py +0 -0
  72. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_determinism.py +0 -0
  73. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_evaluation.py +0 -0
  74. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_generated_confirmation.py +0 -0
  75. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_generated_credentials.py +0 -0
  76. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_mock_runtime.py +0 -0
  77. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_openapi.py +0 -0
  78. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_packaging.py +0 -0
  79. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_policy.py +0 -0
  80. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_preregistration.py +0 -0
  81. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_provenance.py +0 -0
  82. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_refs.py +0 -0
  83. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_restbench.py +0 -0
  84. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_review_flow.py +0 -0
  85. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_semantic.py +0 -0
  86. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_soap_server.py +0 -0
  87. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_swagger2.py +0 -0
  88. {api_mcp_compiler-0.2.0 → api_mcp_compiler-0.3.0}/tests/test_wsdl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-mcp-compiler
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Semantic and safe compilation of enterprise APIs into MCP tool surfaces
5
5
  Author-email: Subhadip Mitra <contact@subhadipmitra.com>
6
6
  License-Expression: Apache-2.0
@@ -87,8 +87,9 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
87
87
 
88
88
  ## Contents
89
89
 
90
- - [Status](#status)
91
- - [Install and verify](#install-and-verify)
90
+ - [What it does](#what-it-does)
91
+ - [What is not demonstrated](#what-is-not-demonstrated)
92
+ - [Install](#install)
92
93
  - [Try it](#try-it)
93
94
  - [How it works](#how-it-works)
94
95
  - [Design properties](#design-properties)
@@ -98,22 +99,32 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
98
99
  - [Author](#author)
99
100
  - [Licence](#licence)
100
101
 
101
- ## Status
102
+ ## What it does
102
103
 
103
- Working software, and recorded comparisons that did not find a difference.
104
+ Ingests **OpenAPI 3.x, Swagger 2.0 and WSDL 1.1**, normalizes them into one provider-independent
105
+ intermediate representation, plans a tool surface, derives a policy manifest, and emits a
106
+ runnable MCP server for the approved part: over HTTP for OpenAPI, over SOAP envelopes for WSDL.
107
+ The compiler itself binds to no MCP SDK, never calls a model, and never reaches the network.
104
108
 
105
- The compiler ingests OpenAPI 3.x, Swagger 2.0 and WSDL 1.1, produces an intermediate
106
- representation, plans a tool surface, derives a policy manifest, and generates a
107
- transport-independent surface that runs against a deterministic mock. An evaluation harness
108
- scores a surface against a task corpus using deterministic oracles over real service state,
109
- driven either by a replay of a recorded solution or by a model that sees only the goal and the
110
- tools. It emits a runnable MCP server for the approved part of a surface, over HTTP for
111
- OpenAPI and over SOAP envelopes for WSDL, while the compiler itself binds to no MCP SDK, never
112
- calls a model, and never reaches the network.
109
+ This has been exercised on specifications and services written by other people, which is where
110
+ the interesting defects live:
113
111
 
114
- The central question, whether a semantically planned surface beats one-tool-per-operation on
115
- task success, unsafe-action rate and cost, **has been measured four times and is still
116
- unanswered**.
112
+ | Exercised against | Result |
113
+ |---|---|
114
+ | Spotify Web API, 40 operations (RestBench) | Parsed with no blocking ambiguities; least-privilege scopes derived from a real OAuth2 surface |
115
+ | Swagger 2.0 Petstore, 20 operations | Parsed, 13 tools executable, runnable server emitted |
116
+ | 40 WSDL documents (SAWSDL collection) | Parsed, with what cannot be translated recorded rather than approximated |
117
+ | Two live public SOAP services | Real calls made and answered through generated servers |
118
+
119
+ The governance is the part worth having. Every field carries provenance, judgement is proposed
120
+ rather than applied, write and destructive tools are not emitted in executable form until a
121
+ human approves them by class, and what the compiler cannot demonstrate it records as
122
+ unresolved instead of assuming.
123
+
124
+ ## What is not demonstrated
125
+
126
+ The design thesis, that a semantically planned surface beats one tool per operation on task
127
+ success, unsafe-action rate and cost, **has been measured four times and is still unanswered**.
117
128
 
118
129
  Every comparison was pre-registered: the hypothesis, corpus, arms, model, success definition
119
130
  and significance threshold were fixed in a digested document before the run, and each run
@@ -121,17 +132,18 @@ records that digest. All returned inconclusive, and the nominal direction revers
121
132
  them. Nothing here should be read as evidence that the semantic surface performs better. It is
122
133
  an argued position that has so far survived no test capable of confirming it.
123
134
 
124
- What the runs did establish is mostly about the instrument. Several defects were found only by
125
- pointing the system at a specification and a task set written by other people: a parser that
126
- marked every optional argument required, generated schemas no client would load, a store in
127
- which a read mutated state and a bulk delete removed everything, and oracles that scored the
128
- route an annotator took rather than the outcome a goal asked for. Each is fixed, and the rules
129
- that would have prevented the last class now fail the build.
130
-
131
- The most distinctive claim, that operations should be composed into workflow tools, remains
132
- untested. The rule that proposes composites fires nowhere on the first benchmark API, and
133
- designing a better one after reading that benchmark's solution paths would fit the treatment
134
- to the test set. It needs a benchmark whose tasks have not been read here.
135
+ The most distinctive claim, that operations should be composed into workflow tools, is
136
+ untested rather than unsupported. The rule that proposes composites fires nowhere on the first
137
+ benchmark API, and designing a better one after reading that benchmark's solution paths would
138
+ fit the treatment to the test set. It needs a benchmark whose tasks have not been read here.
139
+
140
+ What the runs did establish is mostly about the instrument, and it was worth the tokens.
141
+ Several defects were found only by pointing the system at a specification and a task set
142
+ written by other people: a parser that marked every optional argument required, generated
143
+ schemas no client would load, a store in which a read mutated state and a bulk delete removed
144
+ everything, and oracles that scored the route an annotator took rather than the outcome a goal
145
+ asked for. Each is fixed, and the rules that would have prevented the last class now fail the
146
+ build.
135
147
 
136
148
  ## Install
137
149
 
@@ -43,8 +43,9 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
43
43
 
44
44
  ## Contents
45
45
 
46
- - [Status](#status)
47
- - [Install and verify](#install-and-verify)
46
+ - [What it does](#what-it-does)
47
+ - [What is not demonstrated](#what-is-not-demonstrated)
48
+ - [Install](#install)
48
49
  - [Try it](#try-it)
49
50
  - [How it works](#how-it-works)
50
51
  - [Design properties](#design-properties)
@@ -54,22 +55,32 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
54
55
  - [Author](#author)
55
56
  - [Licence](#licence)
56
57
 
57
- ## Status
58
+ ## What it does
58
59
 
59
- Working software, and recorded comparisons that did not find a difference.
60
+ Ingests **OpenAPI 3.x, Swagger 2.0 and WSDL 1.1**, normalizes them into one provider-independent
61
+ intermediate representation, plans a tool surface, derives a policy manifest, and emits a
62
+ runnable MCP server for the approved part: over HTTP for OpenAPI, over SOAP envelopes for WSDL.
63
+ The compiler itself binds to no MCP SDK, never calls a model, and never reaches the network.
60
64
 
61
- The compiler ingests OpenAPI 3.x, Swagger 2.0 and WSDL 1.1, produces an intermediate
62
- representation, plans a tool surface, derives a policy manifest, and generates a
63
- transport-independent surface that runs against a deterministic mock. An evaluation harness
64
- scores a surface against a task corpus using deterministic oracles over real service state,
65
- driven either by a replay of a recorded solution or by a model that sees only the goal and the
66
- tools. It emits a runnable MCP server for the approved part of a surface, over HTTP for
67
- OpenAPI and over SOAP envelopes for WSDL, while the compiler itself binds to no MCP SDK, never
68
- calls a model, and never reaches the network.
65
+ This has been exercised on specifications and services written by other people, which is where
66
+ the interesting defects live:
69
67
 
70
- The central question, whether a semantically planned surface beats one-tool-per-operation on
71
- task success, unsafe-action rate and cost, **has been measured four times and is still
72
- unanswered**.
68
+ | Exercised against | Result |
69
+ |---|---|
70
+ | Spotify Web API, 40 operations (RestBench) | Parsed with no blocking ambiguities; least-privilege scopes derived from a real OAuth2 surface |
71
+ | Swagger 2.0 Petstore, 20 operations | Parsed, 13 tools executable, runnable server emitted |
72
+ | 40 WSDL documents (SAWSDL collection) | Parsed, with what cannot be translated recorded rather than approximated |
73
+ | Two live public SOAP services | Real calls made and answered through generated servers |
74
+
75
+ The governance is the part worth having. Every field carries provenance, judgement is proposed
76
+ rather than applied, write and destructive tools are not emitted in executable form until a
77
+ human approves them by class, and what the compiler cannot demonstrate it records as
78
+ unresolved instead of assuming.
79
+
80
+ ## What is not demonstrated
81
+
82
+ The design thesis, that a semantically planned surface beats one tool per operation on task
83
+ success, unsafe-action rate and cost, **has been measured four times and is still unanswered**.
73
84
 
74
85
  Every comparison was pre-registered: the hypothesis, corpus, arms, model, success definition
75
86
  and significance threshold were fixed in a digested document before the run, and each run
@@ -77,17 +88,18 @@ records that digest. All returned inconclusive, and the nominal direction revers
77
88
  them. Nothing here should be read as evidence that the semantic surface performs better. It is
78
89
  an argued position that has so far survived no test capable of confirming it.
79
90
 
80
- What the runs did establish is mostly about the instrument. Several defects were found only by
81
- pointing the system at a specification and a task set written by other people: a parser that
82
- marked every optional argument required, generated schemas no client would load, a store in
83
- which a read mutated state and a bulk delete removed everything, and oracles that scored the
84
- route an annotator took rather than the outcome a goal asked for. Each is fixed, and the rules
85
- that would have prevented the last class now fail the build.
86
-
87
- The most distinctive claim, that operations should be composed into workflow tools, remains
88
- untested. The rule that proposes composites fires nowhere on the first benchmark API, and
89
- designing a better one after reading that benchmark's solution paths would fit the treatment
90
- to the test set. It needs a benchmark whose tasks have not been read here.
91
+ The most distinctive claim, that operations should be composed into workflow tools, is
92
+ untested rather than unsupported. The rule that proposes composites fires nowhere on the first
93
+ benchmark API, and designing a better one after reading that benchmark's solution paths would
94
+ fit the treatment to the test set. It needs a benchmark whose tasks have not been read here.
95
+
96
+ What the runs did establish is mostly about the instrument, and it was worth the tokens.
97
+ Several defects were found only by pointing the system at a specification and a task set
98
+ written by other people: a parser that marked every optional argument required, generated
99
+ schemas no client would load, a store in which a read mutated state and a bulk delete removed
100
+ everything, and oracles that scored the route an annotator took rather than the outcome a goal
101
+ asked for. Each is fixed, and the rules that would have prevented the last class now fail the
102
+ build.
91
103
 
92
104
  ## Install
93
105
 
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  name = "api-mcp-compiler"
7
7
  license = "Apache-2.0"
8
8
  license-files = ["LICENSE", "NOTICE"]
9
- version = "0.2.0"
9
+ version = "0.3.0"
10
10
  description = "Semantic and safe compilation of enterprise APIs into MCP tool surfaces"
11
11
  readme = "README.md"
12
12
  authors = [{name = "Subhadip Mitra", email = "contact@subhadipmitra.com"}]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -33,6 +33,7 @@ from api_mcp_compiler.models import (
33
33
  EmissionStatus,
34
34
  ParameterLocation,
35
35
  PolicyManifest,
36
+ RetryPolicy,
36
37
  RiskClass,
37
38
  ToolDescriptor,
38
39
  ToolSurface,
@@ -108,6 +109,10 @@ def _tool_function(
108
109
  }
109
110
  policy = manifest.policy_for(tool.tool_id) if manifest else None
110
111
  confirm = policy is not None and policy.confirmation is not None
112
+ # Derived governance the server has to act on, not merely carry: a retry policy nothing
113
+ # reads is the same defect as a confirmation nobody expires.
114
+ retry = policy.retry.value if policy else RetryPolicy.NEVER.value
115
+ needs_key = policy.idempotency_key_required if policy else False
111
116
  confirmation_ttl = (
112
117
  policy.confirmation.token_ttl_seconds
113
118
  if policy is not None and policy.confirmation is not None
@@ -130,6 +135,8 @@ async def {tool.name}(arguments: dict[str, Any]) -> dict[str, Any]:
130
135
  bindings={_binding_map(tool)!r},
131
136
  requires_confirmation={confirm!r},
132
137
  confirmation_ttl_seconds={confirmation_ttl!r},
138
+ retry={retry!r},
139
+ idempotency_key_required={needs_key!r},
133
140
  destructive={destructive!r},
134
141
  max_output_bytes={max_bytes!r},
135
142
  redact_fields={redact!r},
@@ -152,9 +159,11 @@ registered them would undo the decision at the last step.
152
159
 
153
160
  from __future__ import annotations
154
161
 
162
+ import asyncio
155
163
  import json
156
164
  import os
157
165
  import time
166
+ import uuid
158
167
  from typing import Any
159
168
 
160
169
  import httpx
@@ -185,6 +194,73 @@ def _digest(tool_name: str, arguments: dict[str, Any]) -> str:
185
194
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
186
195
 
187
196
 
197
+ #: Attempts in total, not retries in addition. Three is a compromise: enough to ride out a
198
+ #: single restart or rate-limit window, few enough that a wedged upstream is reported rather
199
+ #: than hammered.
200
+ _MAX_ATTEMPTS = 3
201
+
202
+ #: Status codes worth sending again. Deliberately excludes 500: a server error may mean the
203
+ #: effect happened and the response was lost, and repeating that is exactly what the retry
204
+ #: policy exists to prevent. 429 and the gateway codes are unambiguous about not having acted.
205
+ _RETRYABLE_STATUS = frozenset({{429, 502, 503, 504}})
206
+
207
+
208
+ def _backoff(attempt: int, response: Any) -> float:
209
+ """How long to wait, preferring what the service asked for over what we guessed."""
210
+ if response is not None:
211
+ requested = getattr(response, "headers", {{}}) or {{}}
212
+ raw = requested.get("Retry-After") or requested.get("retry-after")
213
+ if raw:
214
+ try:
215
+ return max(0.0, float(raw))
216
+ except (TypeError, ValueError):
217
+ pass
218
+ return float(2**attempt) / 2
219
+
220
+
221
+ async def _send(
222
+ client: Any,
223
+ method: str,
224
+ path: str,
225
+ *,
226
+ params: Any,
227
+ headers: dict[str, str],
228
+ json_body: Any,
229
+ retry: str,
230
+ idempotency_key: str | None,
231
+ ) -> Any:
232
+ """Make one request, repeating it only as far as the derived policy allows.
233
+
234
+ `never` means one attempt. It is the policy for an operation whose idempotency could not
235
+ be determined, where a repeat could duplicate an effect nobody asked for twice.
236
+
237
+ An idempotency key is generated per invocation and held across that invocation's retries,
238
+ which is the whole point: a fresh key per attempt would make every retry a new operation,
239
+ and a key derived from the arguments would make two deliberate identical calls collide.
240
+ """
241
+ attempts = 1 if retry == "never" else _MAX_ATTEMPTS
242
+ for attempt in range(attempts):
243
+ sending = dict(headers)
244
+ if idempotency_key is not None:
245
+ sending["Idempotency-Key"] = idempotency_key
246
+ last_error: Exception | None = None
247
+ try:
248
+ response = await client.request(
249
+ method, path, params=params, headers=sending or None, json=json_body
250
+ )
251
+ except Exception as error: # transport failure: nothing was answered, so it is safe
252
+ last_error = error
253
+ if attempt + 1 >= attempts:
254
+ raise
255
+ await asyncio.sleep(_backoff(attempt, None))
256
+ continue
257
+ if response.status_code in _RETRYABLE_STATUS and attempt + 1 < attempts:
258
+ await asyncio.sleep(_backoff(attempt, response))
259
+ continue
260
+ return response
261
+ raise last_error if last_error is not None else RuntimeError("no attempt was made")
262
+
263
+
188
264
  def _credentials(tool_name: str) -> tuple[dict[str, str], dict[str, str]]:
189
265
  """Read the credentials this tool needs and place each where the service declared it.
190
266
 
@@ -263,6 +339,8 @@ async def _invoke(
263
339
  bindings: dict[str, tuple[str, str, str | None]],
264
340
  requires_confirmation: bool,
265
341
  confirmation_ttl_seconds: int,
342
+ retry: str,
343
+ idempotency_key_required: bool,
266
344
  destructive: bool,
267
345
  max_output_bytes: int | None,
268
346
  redact_fields: list[str],
@@ -316,6 +394,7 @@ async def _invoke(
316
394
  carried = dict(arguments)
317
395
  responses: list[Any] = []
318
396
  status_code = 0
397
+ idempotency_key = str(uuid.uuid4()) if idempotency_key_required else None
319
398
 
320
399
  async with httpx.AsyncClient(base_url=BASE_URL, timeout=30) as client:
321
400
  for position, (method, route, operation_id) in enumerate(steps):
@@ -352,8 +431,15 @@ async def _invoke(
352
431
  headers.update(authorization)
353
432
  query.update(authorized_query)
354
433
 
355
- response = await client.request(
356
- method, path, params=query or None, headers=headers or None, json=body
434
+ response = await _send(
435
+ client,
436
+ method,
437
+ path,
438
+ params=query or None,
439
+ headers=headers,
440
+ json_body=body,
441
+ retry=retry,
442
+ idempotency_key=idempotency_key,
357
443
  )
358
444
  status_code = response.status_code
359
445
  try:
@@ -23,6 +23,7 @@ from api_mcp_compiler.models import (
23
23
  OperationIR,
24
24
  ParameterLocation,
25
25
  PolicyManifest,
26
+ RetryPolicy,
26
27
  RiskClass,
27
28
  ToolDescriptor,
28
29
  ToolSurface,
@@ -65,6 +66,7 @@ def _tool_function(ir: ApiSemanticIR, tool: ToolDescriptor, manifest: PolicyMani
65
66
  raise SoapEmissionError(f"{tool.name!r} carries no SOAP binding record")
66
67
  policy = manifest.policy_for(tool.tool_id) if manifest else None
67
68
  confirm = policy is not None and policy.confirmation is not None
69
+ retry = policy.retry.value if policy else RetryPolicy.NEVER.value
68
70
  confirmation_ttl = (
69
71
  policy.confirmation.token_ttl_seconds
70
72
  if policy is not None and policy.confirmation is not None
@@ -96,6 +98,7 @@ async def {tool.name}(arguments: dict[str, Any]) -> dict[str, Any]:
96
98
  schema=_SCHEMAS[{tool.name!r}],
97
99
  requires_confirmation={confirm!r},
98
100
  confirmation_ttl_seconds={confirmation_ttl!r},
101
+ retry={retry!r},
99
102
  destructive={(tool.risk is RiskClass.DESTRUCTIVE)!r},
100
103
  max_output_bytes={(policy.output.max_bytes if policy else None)!r},
101
104
  redact_fields={sorted(policy.output.redact_fields) if policy else []!r},
@@ -117,6 +120,7 @@ produces a request the service rejects while looking like it worked.
117
120
 
118
121
  from __future__ import annotations
119
122
 
123
+ import asyncio
120
124
  import json
121
125
  import os
122
126
  import time
@@ -153,6 +157,51 @@ def _digest(tool_name: str, arguments: dict[str, Any]) -> str:
153
157
  return hashlib.sha256(payload.encode("utf-8")).hexdigest()
154
158
 
155
159
 
160
+ #: Attempts in total, not retries in addition.
161
+ _MAX_ATTEMPTS = 3
162
+
163
+ #: Transport-level codes worth sending again. A SOAP fault arrives as 500 and is an answer,
164
+ #: not a failure to answer, so 500 is never retried here.
165
+ _RETRYABLE_STATUS = frozenset({{429, 502, 503, 504}})
166
+
167
+
168
+ def _backoff(attempt: int, response: Any) -> float:
169
+ """How long to wait, preferring what the service asked for over what we guessed."""
170
+ if response is not None:
171
+ requested = getattr(response, "headers", {{}}) or {{}}
172
+ raw = requested.get("Retry-After") or requested.get("retry-after")
173
+ if raw:
174
+ try:
175
+ return max(0.0, float(raw))
176
+ except (TypeError, ValueError):
177
+ pass
178
+ return float(2**attempt) / 2
179
+
180
+
181
+ async def _post(client: Any, envelope: str, headers: dict[str, str], query: Any, retry: str) -> Any:
182
+ """Post the envelope, repeating it only as far as the derived policy allows.
183
+
184
+ No idempotency key is sent. WSDL declares nothing equivalent, so a key here would be a
185
+ header invented by this compiler that no service was built to honour, and a retry of a
186
+ non-idempotent SOAP operation is exactly what `never` is for.
187
+ """
188
+ for attempt in range(1 if retry == "never" else _MAX_ATTEMPTS):
189
+ try:
190
+ response = await client.post(
191
+ ENDPOINT, content=envelope.encode("utf-8"), headers=headers, params=query or None
192
+ )
193
+ except Exception: # nothing was answered, so nothing was performed twice
194
+ if attempt + 1 >= (1 if retry == "never" else _MAX_ATTEMPTS):
195
+ raise
196
+ await asyncio.sleep(_backoff(attempt, None))
197
+ continue
198
+ if response.status_code in _RETRYABLE_STATUS and attempt + 1 < _MAX_ATTEMPTS:
199
+ await asyncio.sleep(_backoff(attempt, response))
200
+ continue
201
+ return response
202
+ raise RuntimeError("no attempt was made")
203
+
204
+
156
205
  def _credentials(tool_name: str) -> tuple[dict[str, str], dict[str, str]]:
157
206
  """Read the credentials this tool needs and place each where the service declared it.
158
207
 
@@ -252,6 +301,7 @@ async def _invoke(
252
301
  schema: dict[str, Any],
253
302
  requires_confirmation: bool,
254
303
  confirmation_ttl_seconds: int,
304
+ retry: str,
255
305
  destructive: bool,
256
306
  max_output_bytes: int | None,
257
307
  redact_fields: list[str],
@@ -330,12 +380,7 @@ async def _invoke(
330
380
  headers.update(authorization)
331
381
 
332
382
  async with httpx.AsyncClient(timeout=30) as client:
333
- response = await client.post(
334
- ENDPOINT,
335
- content=envelope.encode("utf-8"),
336
- headers=headers,
337
- params=query or None,
338
- )
383
+ response = await _post(client, envelope, headers, query, retry)
339
384
 
340
385
  try:
341
386
  root = ElementTree.fromstring(response.text)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: api-mcp-compiler
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Semantic and safe compilation of enterprise APIs into MCP tool surfaces
5
5
  Author-email: Subhadip Mitra <contact@subhadipmitra.com>
6
6
  License-Expression: Apache-2.0
@@ -87,8 +87,9 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
87
87
 
88
88
  ## Contents
89
89
 
90
- - [Status](#status)
91
- - [Install and verify](#install-and-verify)
90
+ - [What it does](#what-it-does)
91
+ - [What is not demonstrated](#what-is-not-demonstrated)
92
+ - [Install](#install)
92
93
  - [Try it](#try-it)
93
94
  - [How it works](#how-it-works)
94
95
  - [Design properties](#design-properties)
@@ -98,22 +99,32 @@ specification -> ingestion -> API Semantic IR -> planner -> tool plan
98
99
  - [Author](#author)
99
100
  - [Licence](#licence)
100
101
 
101
- ## Status
102
+ ## What it does
102
103
 
103
- Working software, and recorded comparisons that did not find a difference.
104
+ Ingests **OpenAPI 3.x, Swagger 2.0 and WSDL 1.1**, normalizes them into one provider-independent
105
+ intermediate representation, plans a tool surface, derives a policy manifest, and emits a
106
+ runnable MCP server for the approved part: over HTTP for OpenAPI, over SOAP envelopes for WSDL.
107
+ The compiler itself binds to no MCP SDK, never calls a model, and never reaches the network.
104
108
 
105
- The compiler ingests OpenAPI 3.x, Swagger 2.0 and WSDL 1.1, produces an intermediate
106
- representation, plans a tool surface, derives a policy manifest, and generates a
107
- transport-independent surface that runs against a deterministic mock. An evaluation harness
108
- scores a surface against a task corpus using deterministic oracles over real service state,
109
- driven either by a replay of a recorded solution or by a model that sees only the goal and the
110
- tools. It emits a runnable MCP server for the approved part of a surface, over HTTP for
111
- OpenAPI and over SOAP envelopes for WSDL, while the compiler itself binds to no MCP SDK, never
112
- calls a model, and never reaches the network.
109
+ This has been exercised on specifications and services written by other people, which is where
110
+ the interesting defects live:
113
111
 
114
- The central question, whether a semantically planned surface beats one-tool-per-operation on
115
- task success, unsafe-action rate and cost, **has been measured four times and is still
116
- unanswered**.
112
+ | Exercised against | Result |
113
+ |---|---|
114
+ | Spotify Web API, 40 operations (RestBench) | Parsed with no blocking ambiguities; least-privilege scopes derived from a real OAuth2 surface |
115
+ | Swagger 2.0 Petstore, 20 operations | Parsed, 13 tools executable, runnable server emitted |
116
+ | 40 WSDL documents (SAWSDL collection) | Parsed, with what cannot be translated recorded rather than approximated |
117
+ | Two live public SOAP services | Real calls made and answered through generated servers |
118
+
119
+ The governance is the part worth having. Every field carries provenance, judgement is proposed
120
+ rather than applied, write and destructive tools are not emitted in executable form until a
121
+ human approves them by class, and what the compiler cannot demonstrate it records as
122
+ unresolved instead of assuming.
123
+
124
+ ## What is not demonstrated
125
+
126
+ The design thesis, that a semantically planned surface beats one tool per operation on task
127
+ success, unsafe-action rate and cost, **has been measured four times and is still unanswered**.
117
128
 
118
129
  Every comparison was pre-registered: the hypothesis, corpus, arms, model, success definition
119
130
  and significance threshold were fixed in a digested document before the run, and each run
@@ -121,17 +132,18 @@ records that digest. All returned inconclusive, and the nominal direction revers
121
132
  them. Nothing here should be read as evidence that the semantic surface performs better. It is
122
133
  an argued position that has so far survived no test capable of confirming it.
123
134
 
124
- What the runs did establish is mostly about the instrument. Several defects were found only by
125
- pointing the system at a specification and a task set written by other people: a parser that
126
- marked every optional argument required, generated schemas no client would load, a store in
127
- which a read mutated state and a bulk delete removed everything, and oracles that scored the
128
- route an annotator took rather than the outcome a goal asked for. Each is fixed, and the rules
129
- that would have prevented the last class now fail the build.
130
-
131
- The most distinctive claim, that operations should be composed into workflow tools, remains
132
- untested. The rule that proposes composites fires nowhere on the first benchmark API, and
133
- designing a better one after reading that benchmark's solution paths would fit the treatment
134
- to the test set. It needs a benchmark whose tasks have not been read here.
135
+ The most distinctive claim, that operations should be composed into workflow tools, is
136
+ untested rather than unsupported. The rule that proposes composites fires nowhere on the first
137
+ benchmark API, and designing a better one after reading that benchmark's solution paths would
138
+ fit the treatment to the test set. It needs a benchmark whose tasks have not been read here.
139
+
140
+ What the runs did establish is mostly about the instrument, and it was worth the tokens.
141
+ Several defects were found only by pointing the system at a specification and a task set
142
+ written by other people: a parser that marked every optional argument required, generated
143
+ schemas no client would load, a store in which a read mutated state and a bulk delete removed
144
+ everything, and oracles that scored the route an annotator took rather than the outcome a goal
145
+ asked for. Each is fixed, and the rules that would have prevented the last class now fail the
146
+ build.
135
147
 
136
148
  ## Install
137
149
 
@@ -68,6 +68,7 @@ tests/test_determinism.py
68
68
  tests/test_evaluation.py
69
69
  tests/test_generated_confirmation.py
70
70
  tests/test_generated_credentials.py
71
+ tests/test_generated_retry.py
71
72
  tests/test_mcp_server.py
72
73
  tests/test_mock_runtime.py
73
74
  tests/test_openapi.py
@@ -0,0 +1,263 @@
1
+ """Tests that a generated server retries exactly as far as the derived policy allows.
2
+
3
+ The manifest has always computed a retry policy and an idempotency requirement per tool, and
4
+ the generated server read neither: a `never` policy retried nothing because nothing retried
5
+ at all, which looks like compliance and is absence. This is the third value in a row that was
6
+ derived, written into the artifact, and acted on by nobody, after the confirmation time to
7
+ live and the credential placement.
8
+
9
+ As with those, the tests here run the emitted module and watch what it does to the wire.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import sys
16
+ import types
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import pytest
21
+
22
+ from api_mcp_compiler.codegen.mcp_server import emit_server
23
+ from api_mcp_compiler.codegen.tools import generate_surface
24
+ from api_mcp_compiler.ingest.openapi import parse_openapi
25
+ from api_mcp_compiler.models import RiskClass
26
+ from api_mcp_compiler.planning.approval import approve
27
+ from api_mcp_compiler.planning.semantic import plan_semantic
28
+ from api_mcp_compiler.policy.synthesis import synthesize_policy
29
+
30
+ #: `put` is idempotent by RFC 9110 and `post` is not, which is what makes the two tools below
31
+ #: land on different retry policies without anything in the test saying so.
32
+ SERVICE = """openapi: 3.0.3
33
+ info: {title: Retry Service, version: 1.0.0}
34
+ servers: [{url: https://retry.example.invalid}]
35
+ # A write with no declared authentication cannot be shown to be governed, so the emission
36
+ # gate refuses it and there is nothing to test. That refusal is the subject of its own tests.
37
+ security: [{guard: []}]
38
+ paths:
39
+ /widgets/{id}:
40
+ put:
41
+ operationId: replaceWidget
42
+ summary: Replace a widget
43
+ parameters: [{in: path, name: id, required: true, schema: {type: string}}]
44
+ responses: {'200': {description: ok, content: {application/json: {schema: {type: object}}}}}
45
+ /widgets:
46
+ post:
47
+ operationId: createWidget
48
+ summary: Create a widget
49
+ responses: {'201': {description: made, content: {application/json: {schema: {type: object}}}}}
50
+ components:
51
+ securitySchemes:
52
+ guard: {type: apiKey, in: header, name: X-Retry-Key}
53
+ """
54
+
55
+
56
+ class _Reply:
57
+ def __init__(self, status: int, headers: dict[str, str] | None = None) -> None:
58
+ self.status_code = status
59
+ self.headers = headers or {}
60
+
61
+ def json(self) -> dict[str, str]:
62
+ return {"id": "w-1"}
63
+
64
+ @property
65
+ def text(self) -> str:
66
+ return "{}"
67
+
68
+
69
+ class _Client:
70
+ """Answers with a scripted sequence, recording every attempt."""
71
+
72
+ def __init__(self, script: list[Any], seen: list[dict[str, Any]]) -> None:
73
+ self.script = script
74
+ self.seen = seen
75
+
76
+ async def __aenter__(self) -> _Client:
77
+ return self
78
+
79
+ async def __aexit__(self, *_: object) -> None:
80
+ return None
81
+
82
+ async def request(
83
+ self, method: str, path: str, params: Any = None, headers: Any = None, json: Any = None
84
+ ) -> Any:
85
+ self.seen.append({"method": method, "path": path, "headers": dict(headers or {})})
86
+ answer = self.script.pop(0) if self.script else _Reply(200)
87
+ if isinstance(answer, Exception):
88
+ raise answer
89
+ return answer
90
+
91
+
92
+ def _load(
93
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, script: list[Any]
94
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], list[float]]:
95
+ """Emit the server, stub the SDK and the clock, and return what it did."""
96
+ spec = tmp_path / "service.yaml"
97
+ spec.write_text(SERVICE, encoding="utf-8")
98
+ ir = parse_openapi(spec)
99
+ plan = plan_semantic(ir)
100
+ overlay = approve(plan, overlay=None, risk=RiskClass.WRITE, group=None, names=[]).overlay
101
+ approved = plan_semantic(ir, overlay)
102
+ manifest = synthesize_policy(ir, approved)
103
+ source = emit_server(ir, generate_surface(ir, approved, manifest), manifest).source
104
+
105
+ seen: list[dict[str, Any]] = []
106
+ fastmcp = types.ModuleType("mcp.server.fastmcp")
107
+ fastmcp.FastMCP = lambda *_, **__: types.SimpleNamespace( # type: ignore[attr-defined]
108
+ tool=lambda **_kw: (lambda function: function),
109
+ resource=lambda *_a, **_kw: (lambda function: function),
110
+ )
111
+ server = types.ModuleType("mcp.server")
112
+ server.fastmcp = fastmcp # type: ignore[attr-defined]
113
+ package = types.ModuleType("mcp")
114
+ package.server = server # type: ignore[attr-defined]
115
+ httpx = types.ModuleType("httpx")
116
+ httpx.AsyncClient = lambda **_: _Client(script, seen) # type: ignore[attr-defined]
117
+ for name, module in {
118
+ "mcp": package, "mcp.server": server, "mcp.server.fastmcp": fastmcp, "httpx": httpx
119
+ }.items():
120
+ monkeypatch.setitem(sys.modules, name, module)
121
+
122
+ namespace: dict[str, Any] = {"__name__": "generated_server"}
123
+ exec(compile(source, "<generated server>", "exec"), namespace)
124
+
125
+ # Waiting is the one part that must not actually happen in a test, but how long it would
126
+ # have waited is worth asserting, so it is recorded rather than discarded.
127
+ waits: list[float] = []
128
+
129
+ async def _instant(delay: float) -> None:
130
+ waits.append(delay)
131
+
132
+ namespace["asyncio"] = types.SimpleNamespace(sleep=_instant)
133
+ return namespace, seen, waits
134
+
135
+
136
+ def _replace(namespace: dict[str, Any]) -> Any:
137
+ """The idempotent tool, whose derived policy is `safe`."""
138
+ return asyncio.run(namespace["replace_widget"]({"id": "w-1"}))
139
+
140
+
141
+ def _create(namespace: dict[str, Any]) -> Any:
142
+ """The non-idempotent tool, whose derived policy needs an idempotency key."""
143
+ return asyncio.run(namespace["create_widget"]({}))
144
+
145
+
146
+ def test_a_retryable_status_is_sent_again(
147
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
148
+ ) -> None:
149
+ namespace, seen, waits = _load(tmp_path, monkeypatch, [_Reply(503), _Reply(200)])
150
+
151
+ _replace(namespace)
152
+
153
+ assert len(seen) == 2
154
+ assert waits == [0.5]
155
+
156
+
157
+ def test_a_client_error_is_not_sent_again(
158
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
159
+ ) -> None:
160
+ """Repeating a request the service rejected changes nothing except the load."""
161
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(404), _Reply(200)])
162
+
163
+ _replace(namespace)
164
+
165
+ assert len(seen) == 1
166
+
167
+
168
+ def test_a_server_error_is_not_sent_again(
169
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
170
+ ) -> None:
171
+ """500 may mean the effect happened and the answer was lost, which a retry duplicates."""
172
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(500), _Reply(200)])
173
+
174
+ _replace(namespace)
175
+
176
+ assert len(seen) == 1
177
+
178
+
179
+ def test_attempts_are_bounded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
180
+ """A wedged upstream is reported, not hammered."""
181
+ namespace, seen, waits = _load(
182
+ tmp_path, monkeypatch, [_Reply(503), _Reply(503), _Reply(503), _Reply(200)]
183
+ )
184
+
185
+ _replace(namespace)
186
+
187
+ assert len(seen) == 3
188
+ assert waits == [0.5, 1.0]
189
+
190
+
191
+ def test_the_service_decides_how_long_to_wait(
192
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
193
+ ) -> None:
194
+ """A rate limit that names its window is more useful than any backoff curve."""
195
+ namespace, _, waits = _load(
196
+ tmp_path, monkeypatch, [_Reply(429, {"Retry-After": "7"}), _Reply(200)]
197
+ )
198
+
199
+ _replace(namespace)
200
+
201
+ assert waits == [7.0]
202
+
203
+
204
+ def test_a_transport_failure_is_retried(
205
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
206
+ ) -> None:
207
+ """Nothing was answered, so nothing was performed twice."""
208
+ namespace, seen, _ = _load(
209
+ tmp_path, monkeypatch, [ConnectionError("reset"), _Reply(200)]
210
+ )
211
+
212
+ _replace(namespace)
213
+
214
+ assert len(seen) == 2
215
+
216
+
217
+ def test_a_non_idempotent_tool_carries_a_key_that_survives_the_retry(
218
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
219
+ ) -> None:
220
+ """A fresh key per attempt would make every retry a new operation."""
221
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(503), _Reply(201)])
222
+
223
+ _create(namespace)
224
+
225
+ keys = [attempt["headers"].get("Idempotency-Key") for attempt in seen]
226
+ assert len(seen) == 2
227
+ assert keys[0] and keys[0] == keys[1]
228
+
229
+
230
+ def test_two_invocations_do_not_share_a_key(
231
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
232
+ ) -> None:
233
+ """A key derived from the arguments would make two deliberate identical calls collide."""
234
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(201), _Reply(201)])
235
+
236
+ _create(namespace)
237
+ _create(namespace)
238
+
239
+ first, second = (attempt["headers"]["Idempotency-Key"] for attempt in seen)
240
+ assert first != second
241
+
242
+
243
+ def test_an_idempotent_tool_sends_no_key(
244
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
245
+ ) -> None:
246
+ """The header is for the case that needs it, not decoration on every request."""
247
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(200)])
248
+
249
+ _replace(namespace)
250
+
251
+ assert "Idempotency-Key" not in seen[0]["headers"]
252
+
253
+
254
+ def test_invalid_arguments_reach_no_request_at_all(
255
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
256
+ ) -> None:
257
+ """Validating after calling has already had the effect it was checking for."""
258
+ namespace, seen, _ = _load(tmp_path, monkeypatch, [_Reply(200)])
259
+
260
+ outcome = asyncio.run(namespace["replace_widget"]({"id": 17}))
261
+
262
+ assert outcome["error"] == "invalid_arguments"
263
+ assert seen == []
@@ -94,11 +94,16 @@ def test_no_credential_is_written_into_the_generated_file() -> None:
94
94
 
95
95
 
96
96
  def test_arguments_are_validated_before_the_call_is_made() -> None:
97
- """A surface that validates after calling has already had the effect it was checking."""
97
+ """A surface that validates after calling has already had the effect it was checking.
98
+
99
+ Asserted on the ordering inside `_invoke`, which is where the decision lives. The request
100
+ itself is made from a helper defined further up the file, so comparing raw positions
101
+ would compare the wrong two things. `test_generated_retry.py` checks the same property
102
+ by running the module and finding no request was made at all.
103
+ """
98
104
  _, _, emitted = _emit()
99
- validate_at = emitted.source.index("iter_errors(arguments)")
100
- call_at = emitted.source.index("await client.request(")
101
- assert validate_at < call_at
105
+ body = emitted.source[emitted.source.index("async def _invoke("):]
106
+ assert body.index("iter_errors(arguments)") < body.index("await _send(")
102
107
 
103
108
 
104
109
  def test_a_surface_with_nothing_executable_refuses_to_emit() -> None:
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"