Skip to content

Commit 374f7dd

Browse files
committed
fix: drop top-level anyOf from webhook tool input schemas
webhooks_verify_signature, webhooks_parse_payload, and webhooks_triage_event wrapped their object schema in a top-level anyOf to express "payload or payload_base64". The Anthropic API rejects top-level oneOf/anyOf/allOf in a tool input_schema even with type:object, so every sub-agent (Explore, Plan, teammates) failed with HTTP 400 since 2.4.0. The either/or constraint is already enforced at runtime in the handlers, so the schema hint was redundant. Also strip top-level composition centrally in ToolMetadataPolicy as a safety net, and add a regression test forbidding it across all tool schemas.
1 parent d6d141c commit 374f7dd

3 files changed

Lines changed: 101 additions & 58 deletions

File tree

Sources/asc-mcp/Helpers/ToolMetadataPolicy.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,21 @@ enum ToolMetadataPolicy {
101101
}
102102

103103
private static func normalizedInputSchema(for tool: Tool) -> Value {
104-
guard case .object(var schema) = tool.inputSchema,
105-
case .object(let properties)? = schema["properties"],
106-
properties.isEmpty else {
104+
guard case .object(var schema) = tool.inputSchema else {
107105
return tool.inputSchema
108106
}
109107

110-
if schema["additionalProperties"] == nil {
108+
// The Anthropic API rejects top-level oneOf/anyOf/allOf in a tool
109+
// input_schema, even when `type: object` is present. Strip them here as a
110+
// safety net so a single malformed tool definition can never again break
111+
// every sub-agent request; the real "either/or" constraints are enforced
112+
// at runtime inside the tool handlers.
113+
schema["anyOf"] = nil
114+
schema["oneOf"] = nil
115+
schema["allOf"] = nil
116+
117+
if case .object(let properties)? = schema["properties"], properties.isEmpty,
118+
schema["additionalProperties"] == nil {
111119
schema["additionalProperties"] = .bool(false)
112120
}
113121
return .object(schema)

Sources/asc-mcp/Workers/WebhooksWorker/WebhooksWorker+ToolDefinitions.swift

Lines changed: 33 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -129,62 +129,53 @@ extension WebhooksWorker {
129129
func verifySignatureTool() -> Tool {
130130
Tool(
131131
name: "webhooks_verify_signature",
132-
description: "Verify an App Store Connect webhook x-apple-signature HMAC against the exact raw request body. This is local and does not call Apple.",
133-
inputSchema: schemaRequiringAnyOf(
134-
baseSchema(
135-
properties: [
136-
"secret": stringSchema("Webhook secret configured in App Store Connect"),
137-
"signature": stringSchema("x-apple-signature header value, for example hmacsha256=<hex>"),
138-
"payload": stringSchema("Exact raw UTF-8 request body received by your webhook endpoint"),
139-
"payload_base64": stringSchema("Base64-encoded exact raw request body bytes; use this when byte-for-byte preservation matters")
140-
],
141-
required: ["secret", "signature"]
142-
),
143-
alternatives: [["payload"], ["payload_base64"]]
132+
description: "Verify an App Store Connect webhook x-apple-signature HMAC against the exact raw request body. This is local and does not call Apple. Provide the body via either `payload` (raw UTF-8) or `payload_base64`.",
133+
inputSchema: baseSchema(
134+
properties: [
135+
"secret": stringSchema("Webhook secret configured in App Store Connect"),
136+
"signature": stringSchema("x-apple-signature header value, for example hmacsha256=<hex>"),
137+
"payload": stringSchema("Exact raw UTF-8 request body received by your webhook endpoint"),
138+
"payload_base64": stringSchema("Base64-encoded exact raw request body bytes; use this when byte-for-byte preservation matters")
139+
],
140+
required: ["secret", "signature"]
144141
)
145142
)
146143
}
147144

148145
func parsePayloadTool() -> Tool {
149146
Tool(
150147
name: "webhooks_parse_payload",
151-
description: "Parse and normalize a raw App Store Connect webhook payload, including nested event payload JSON when Apple sends it as a string. This is local and read-only.",
152-
inputSchema: schemaRequiringAnyOf(
153-
baseSchema(
154-
properties: [
155-
"payload": stringSchema("Exact raw UTF-8 request body received by your webhook endpoint"),
156-
"payload_base64": stringSchema("Base64-encoded exact raw request body bytes"),
157-
"secret": stringSchema("Optional webhook secret used to verify the signature while parsing"),
158-
"signature": stringSchema("Optional x-apple-signature header value used with secret")
159-
],
160-
required: []
161-
),
162-
alternatives: [["payload"], ["payload_base64"]]
148+
description: "Parse and normalize a raw App Store Connect webhook payload, including nested event payload JSON when Apple sends it as a string. This is local and read-only. Provide the body via either `payload` (raw UTF-8) or `payload_base64`.",
149+
inputSchema: baseSchema(
150+
properties: [
151+
"payload": stringSchema("Exact raw UTF-8 request body received by your webhook endpoint"),
152+
"payload_base64": stringSchema("Base64-encoded exact raw request body bytes"),
153+
"secret": stringSchema("Optional webhook secret used to verify the signature while parsing"),
154+
"signature": stringSchema("Optional x-apple-signature header value used with secret")
155+
],
156+
required: []
163157
)
164158
)
165159
}
166160

167161
func triageEventTool() -> Tool {
168162
Tool(
169163
name: "webhooks_triage_event",
170-
description: "Turn a webhook event or failed delivery context into an actionable MCP triage plan with recommended read-only lookup tools.",
171-
inputSchema: schemaRequiringAnyOf(
172-
baseSchema(
173-
properties: [
174-
"payload": stringSchema("Optional exact raw UTF-8 webhook request body"),
175-
"payload_base64": stringSchema("Optional base64-encoded exact raw request body bytes"),
176-
"event_type": enumSchema("Webhook event type when raw payload is not available", values: ASCWebhookEventTypes.all),
177-
"resource_type": stringSchema("Optional affected App Store Connect resource type from the webhook payload"),
178-
"resource_id": stringSchema("Optional affected App Store Connect resource ID from the webhook payload"),
179-
"delivery_id": stringSchema("Optional webhook delivery ID for redelivery recommendations"),
180-
"webhook_id": stringSchema("Optional webhook configuration ID for ping/delivery recommendations"),
181-
"delivery_state": enumSchema("Optional delivery state from webhooks_list_deliveries", values: ["SUCCEEDED", "FAILED", "PENDING"]),
182-
"http_status_code": integerSchema("Optional receiver HTTP status code from the delivery response"),
183-
"error_message": stringSchema("Optional delivery error message")
184-
],
185-
required: []
186-
),
187-
alternatives: [["event_type"], ["payload"], ["payload_base64"]]
164+
description: "Turn a webhook event or failed delivery context into an actionable MCP triage plan with recommended read-only lookup tools. Provide at least one of: `event_type`, `payload`, or `payload_base64`.",
165+
inputSchema: baseSchema(
166+
properties: [
167+
"payload": stringSchema("Optional exact raw UTF-8 webhook request body"),
168+
"payload_base64": stringSchema("Optional base64-encoded exact raw request body bytes"),
169+
"event_type": enumSchema("Webhook event type when raw payload is not available", values: ASCWebhookEventTypes.all),
170+
"resource_type": stringSchema("Optional affected App Store Connect resource type from the webhook payload"),
171+
"resource_id": stringSchema("Optional affected App Store Connect resource ID from the webhook payload"),
172+
"delivery_id": stringSchema("Optional webhook delivery ID for redelivery recommendations"),
173+
"webhook_id": stringSchema("Optional webhook configuration ID for ping/delivery recommendations"),
174+
"delivery_state": enumSchema("Optional delivery state from webhooks_list_deliveries", values: ["SUCCEEDED", "FAILED", "PENDING"]),
175+
"http_status_code": integerSchema("Optional receiver HTTP status code from the delivery response"),
176+
"error_message": stringSchema("Optional delivery error message")
177+
],
178+
required: []
188179
)
189180
)
190181
}
@@ -198,18 +189,6 @@ extension WebhooksWorker {
198189
])
199190
}
200191

201-
private func schemaRequiringAnyOf(_ schema: Value, alternatives: [[String]]) -> Value {
202-
guard case .object(var object) = schema else {
203-
return schema
204-
}
205-
object["anyOf"] = .array(alternatives.map { requiredFields in
206-
.object([
207-
"required": .array(requiredFields.map(Value.string))
208-
])
209-
})
210-
return .object(object)
211-
}
212-
213192
private func stringSchema(_ description: String) -> Value {
214193
.object([
215194
"type": .string("string"),

Tests/ASCMCPTests/Workers/WorkerToolDefinitionsTests.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,62 @@ struct WorkerToolDefinitionsTests {
771771
}
772772
}
773773

774+
@Test("No tool input schema uses top-level oneOf/anyOf/allOf (Anthropic API constraint)")
775+
func noTopLevelSchemaComposition() async throws {
776+
let client = try await TestFactory.makeHTTPClient()
777+
let rawTools: [Tool] = await {
778+
var tools: [Tool] = []
779+
tools += await AppsWorker(client: client).getTools()
780+
tools += await AccessibilityWorker(httpClient: client).getTools()
781+
tools += await WebhooksWorker(httpClient: client).getTools()
782+
tools += await BuildsWorker(httpClient: client).getTools()
783+
tools += await BuildProcessingWorker(httpClient: client).getTools()
784+
tools += await BuildBetaDetailsWorker(httpClient: client).getTools()
785+
tools += await AppLifecycleWorker(httpClient: client).getTools()
786+
tools += await ReviewsWorker(httpClient: client).getTools()
787+
tools += await BetaGroupsWorker(httpClient: client).getTools()
788+
tools += await BetaFeedbackWorker(httpClient: client).getTools()
789+
tools += await InAppPurchasesWorker(httpClient: client, uploadService: UploadService()).getTools()
790+
tools += await ProvisioningWorker(httpClient: client).getTools()
791+
tools += await BetaTestersWorker(httpClient: client).getTools()
792+
tools += await AppInfoWorker(httpClient: client).getTools()
793+
tools += await PricingWorker(httpClient: client).getTools()
794+
tools += await UsersWorker(httpClient: client).getTools()
795+
tools += await AppEventsWorker(httpClient: client).getTools()
796+
tools += await AnalyticsWorker(httpClient: client).getTools()
797+
tools += await SubscriptionsWorker(httpClient: client, uploadService: UploadService()).getTools()
798+
tools += await OfferCodesWorker(httpClient: client).getTools()
799+
tools += await WinBackOffersWorker(httpClient: client).getTools()
800+
tools += await IntroductoryOffersWorker(httpClient: client).getTools()
801+
tools += await PromotionalOffersWorker(httpClient: client).getTools()
802+
tools += await SandboxTestersWorker(httpClient: client).getTools()
803+
tools += await BetaAppWorker(httpClient: client).getTools()
804+
tools += await PreReleaseVersionsWorker(httpClient: client).getTools()
805+
tools += await BetaLicenseAgreementsWorker(httpClient: client).getTools()
806+
tools += await ScreenshotsWorker(httpClient: client, uploadService: UploadService()).getTools()
807+
tools += await CustomProductPagesWorker(httpClient: client).getTools()
808+
tools += await ProductPageOptimizationWorker(httpClient: client).getTools()
809+
tools += await PromotedPurchasesWorker(httpClient: client, uploadService: UploadService()).getTools()
810+
tools += await MetricsWorker(httpClient: client).getTools()
811+
tools += await ReviewAttachmentsWorker(httpClient: client, uploadService: UploadService()).getTools()
812+
return tools
813+
}()
814+
815+
// Validate the schema that is actually sent to the API (post-policy),
816+
// so this also covers the central strip in ToolMetadataPolicy.
817+
let allTools = rawTools.map { ToolMetadataPolicy.apply(to: $0) }
818+
for tool in allTools {
819+
guard case .object(let schema) = tool.inputSchema else {
820+
Issue.record("Tool '\(tool.name)' input schema is not a JSON object")
821+
continue
822+
}
823+
#expect(schema["type"]?.stringValue == "object", "Tool '\(tool.name)' root schema type must be object")
824+
#expect(schema["anyOf"] == nil, "Tool '\(tool.name)' has top-level anyOf (Anthropic API rejects it)")
825+
#expect(schema["oneOf"] == nil, "Tool '\(tool.name)' has top-level oneOf (Anthropic API rejects it)")
826+
#expect(schema["allOf"] == nil, "Tool '\(tool.name)' has top-level allOf (Anthropic API rejects it)")
827+
}
828+
}
829+
774830
// MARK: - ReviewAttachmentsWorker (4 tools)
775831

776832
@Test("ReviewAttachmentsWorker returns 4 tools with correct names")

0 commit comments

Comments
 (0)