(
request: { params: { name: string; arguments?: unknown; task?: { ttl?: number } } },
_extra: unknown,
)
| 3585 | }); |
| 3586 | |
| 3587 | async function dispatchCallTool( |
| 3588 | request: { params: { name: string; arguments?: unknown; task?: { ttl?: number } } }, |
| 3589 | _extra: unknown, |
| 3590 | ): Promise<{ result: object; flushable: boolean }> { |
| 3591 | const { name, arguments: args } = request.params; |
| 3592 | |
| 3593 | // Extract and strip context before passing args to handlers (AdCP requirement: |
| 3594 | // echo caller's context object back unchanged in every response). |
| 3595 | const rawArgs = (args as Record<string, unknown> | undefined) ?? {}; |
| 3596 | const { context: callerContext, ...handlerArgs } = rawArgs; |
| 3597 | |
| 3598 | const handler = HANDLER_MAP[name]; |
| 3599 | |
| 3600 | if (!handler) { |
| 3601 | // Pre-handler validation failures don't touch session state, so flushing |
| 3602 | // is a no-op; leaving flushable=true keeps behaviour consistent for |
| 3603 | // requests whose handlers DO legitimately mutate before failing. |
| 3604 | return { result: adcpError('INVALID_REQUEST', { message: `Unknown tool: ${name}` }, callerContext), flushable: true }; |
| 3605 | } |
| 3606 | |
| 3607 | const requestedVersion = (handlerArgs as { adcp_major_version?: unknown }).adcp_major_version; |
| 3608 | if ( |
| 3609 | requestedVersion !== undefined |
| 3610 | && !(SUPPORTED_MAJOR_VERSIONS as readonly number[]).includes(requestedVersion as number) |
| 3611 | ) { |
| 3612 | return { |
| 3613 | result: adcpError('VERSION_UNSUPPORTED', { |
| 3614 | message: `AdCP major version ${String(requestedVersion)} is not supported`, |
| 3615 | details: { supported_major_versions: SUPPORTED_MAJOR_VERSIONS }, |
| 3616 | field: 'adcp_major_version', |
| 3617 | }, callerContext), |
| 3618 | flushable: true, |
| 3619 | }; |
| 3620 | } |
| 3621 | |
| 3622 | // Check for task-augmented request (explicit `task` field in params). |
| 3623 | // Dry-run requests always return synchronously — there's no reason to |
| 3624 | // async a dry-run operation, and clients expect immediate results. |
| 3625 | const taskField = (request.params as { task?: { ttl?: number } }).task; |
| 3626 | const isDryRun = rawArgs.dry_run === true; |
| 3627 | const isTaskRequest = taskField !== undefined && !isDryRun; |
| 3628 | if (isTaskRequest && !toolSupportsTask(name)) { |
| 3629 | throw new Error(`Tool "${name}" does not support task augmentation`); |
| 3630 | } |
| 3631 | |
| 3632 | // Idempotency enforcement for mutating tools (#2315, #2346). |
| 3633 | // Key presence + format are schema-level requirements; we check them |
| 3634 | // before the handler so a malformed key never touches the cache |
| 3635 | // (prevents key-format-accepting cache misses from leaking timing). |
| 3636 | const authPrincipal = ctx.principal ?? 'anonymous'; |
| 3637 | // Partition the idempotency cache by caller-stated account scope so the |
| 3638 | // shared public sandbox token doesn't pool every buyer into one oracle |
| 3639 | // (security.mdx §"three-state response"). An attacker on the same auth |
| 3640 | // principal using a different account ref can still cross-probe, but |
| 3641 | // callers can already enumerate their own account's keys — so the |
| 3642 | // scoping adds no useful probing surface while closing the cross-caller |
| 3643 | // leak. |
| 3644 | const accountScope = deriveAccountScope(handlerArgs); |
no test coverage detected