@desplega.ai/agent-swarm 1.129.0 → 1.131.0

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 (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
@@ -6,27 +6,36 @@ import {
6
6
  applyMcpOAuthRefresh,
7
7
  consumeMcpOAuthPending,
8
8
  deleteMcpOAuthToken,
9
+ findReusableMcpOAuthClient,
9
10
  getMcpOAuthToken,
10
11
  insertMcpOAuthPending,
12
+ invalidateMcpOAuthClient,
13
+ markMcpOAuthTokenStatus,
11
14
  setMcpServerAuthMethod,
12
15
  upsertMcpOAuthToken,
13
16
  } from "../be/db-queries/mcp-oauth";
14
17
  import { ensureMcpToken } from "../oauth/ensure-mcp-token";
15
18
  import {
16
19
  assertUrlSafe,
20
+ authMethodForStoredClient,
17
21
  buildAuthorizeUrl,
18
22
  computeExpiresAt,
19
23
  discoverAuthorizationServerMetadata,
20
24
  discoverProtectedResourceMetadata,
21
25
  exchangeCodeForTokens,
26
+ isInvalidClientError,
27
+ normalizeTokenEndpointAuthMethod,
22
28
  refreshMcpToken,
23
29
  registerClient,
30
+ resolveAdvertisedTokenEndpointAuthMethod,
24
31
  revokeMcpToken,
32
+ selectDcrTokenEndpointAuthMethod,
25
33
  } from "../oauth/mcp-wrapper";
34
+ import { McpAuthMethodSchema } from "../types";
26
35
  import { getAppUrl, getPublicMcpBaseUrl } from "../utils/constants";
27
36
  import { isEnvFlagEnabled } from "../utils/env-flag";
28
37
  import { route } from "./route-def";
29
- import { json, jsonError } from "./utils";
38
+ import { jsonError } from "./utils";
30
39
 
31
40
  // ─── Helpers ─────────────────────────────────────────────────────────────────
32
41
 
@@ -63,6 +72,7 @@ interface DiscoveryResult {
63
72
  requiresOAuth: boolean;
64
73
  dcrSupported: boolean;
65
74
  bearerMethodsSupported: string[] | null;
75
+ tokenEndpointAuthMethodsSupported: string[] | null;
66
76
  }
67
77
 
68
78
  interface OAuthClientForAuthorize {
@@ -74,12 +84,82 @@ interface OAuthClientForAuthorize {
74
84
  tokenUrl: string;
75
85
  revocationUrl: string | null;
76
86
  scopes: string[];
87
+ tokenEndpointAuthMethod: string;
77
88
  }
78
89
 
90
+ // ─── Response schemas ────────────────────────────────────────────────────────
91
+ // Mirror the `DiscoveryResult` interface above and the handler bodies below —
92
+ // keep in sync by hand since these are local (non-DB-entity) response shapes.
93
+
94
+ const DiscoveryResultSchema = z.object({
95
+ resourceUrl: z.string(),
96
+ authorizationServerIssuer: z.string(),
97
+ authorizeUrl: z.string(),
98
+ tokenUrl: z.string(),
99
+ revocationUrl: z.string().nullable(),
100
+ registrationEndpoint: z.string().nullable(),
101
+ scopes: z.array(z.string()),
102
+ requiresOAuth: z.literal(true),
103
+ dcrSupported: z.boolean(),
104
+ bearerMethodsSupported: z.array(z.string()).nullable(),
105
+ tokenEndpointAuthMethodsSupported: z.array(z.string()).nullable(),
106
+ });
107
+
108
+ const MetadataResponseSchema = z.union([
109
+ z.object({ requiresOAuth: z.literal(false) }),
110
+ DiscoveryResultSchema,
111
+ ]);
112
+
113
+ const McpOAuthStatusResponseSchema = z.object({
114
+ mcpServerId: z.string(),
115
+ authMethod: McpAuthMethodSchema,
116
+ connected: z.boolean(),
117
+ token: z
118
+ .object({
119
+ id: z.string(),
120
+ status: z.enum(["connected", "expired", "error", "revoked"]),
121
+ tokenType: z.string(),
122
+ expiresAt: z.string().nullable(),
123
+ scope: z.string().nullable(),
124
+ lastErrorMessage: z.string().nullable(),
125
+ lastRefreshedAt: z.string().nullable(),
126
+ authorizationServerIssuer: z.string(),
127
+ resourceUrl: z.string(),
128
+ clientSource: z.enum(["dcr", "manual", "preregistered"]),
129
+ hasRefreshToken: z.boolean(),
130
+ createdAt: z.string(),
131
+ updatedAt: z.string(),
132
+ })
133
+ .nullable(),
134
+ });
135
+
136
+ const AuthorizeUrlResponseSchema = z.object({ providerUrl: z.string() });
137
+
138
+ const RefreshResponseSchema = z.object({
139
+ ok: z.literal(true),
140
+ expiresAt: z.string().nullable(),
141
+ scope: z.string().nullable(),
142
+ });
143
+
144
+ const OkResponseSchema = z.object({ ok: z.literal(true) });
145
+
79
146
  function splitScopes(scopes: string | null | undefined): string[] {
80
147
  return scopes?.split(/\s+/).filter(Boolean) ?? [];
81
148
  }
82
149
 
150
+ /**
151
+ * A registered DCR client is scoped to whatever it was registered with.
152
+ * `requested` is covered only if every requested scope is already in
153
+ * `registered` — an empty `registered` set (no scope restriction recorded)
154
+ * does NOT count as "covers everything", since providers that enforce RFC
155
+ * 7591 client scope metadata reject a token/authorize request for a scope
156
+ * the client was never registered with.
157
+ */
158
+ function scopesAreCovered(requested: string[], registered: string[]): boolean {
159
+ const registeredSet = new Set(registered);
160
+ return requested.every((scope) => registeredSet.has(scope));
161
+ }
162
+
83
163
  function manualClientFromToken(token: McpOAuthToken | null): OAuthClientForAuthorize | null {
84
164
  if (!token || token.clientSource !== "manual" || !token.dcrClientId) return null;
85
165
 
@@ -99,6 +179,7 @@ function manualClientFromToken(token: McpOAuthToken | null): OAuthClientForAutho
99
179
  tokenUrl: token.tokenUrl,
100
180
  revocationUrl: token.revocationUrl,
101
181
  scopes: splitScopes(token.scope),
182
+ tokenEndpointAuthMethod: authMethodForStoredClient(token.tokenEndpointAuthMethod),
102
183
  };
103
184
  }
104
185
 
@@ -124,6 +205,7 @@ async function discoverForMcp(resourceUrl: string): Promise<DiscoveryResult | nu
124
205
  requiresOAuth: true,
125
206
  dcrSupported: !!as.registration_endpoint,
126
207
  bearerMethodsSupported: prmd.bearer_methods_supported ?? null,
208
+ tokenEndpointAuthMethodsSupported: as.token_endpoint_auth_methods_supported ?? null,
127
209
  };
128
210
  }
129
211
 
@@ -158,7 +240,10 @@ const metadataRoute = route({
158
240
  auth: { apiKey: true },
159
241
  params: z.object({ mcpServerId: z.string() }),
160
242
  responses: {
161
- 200: { description: "OAuth metadata or { requiresOAuth: false }" },
243
+ 200: {
244
+ description: "OAuth metadata or { requiresOAuth: false }",
245
+ schema: MetadataResponseSchema,
246
+ },
162
247
  400: { description: "MCP has no URL / invalid transport" },
163
248
  404: { description: "MCP server not found" },
164
249
  },
@@ -174,7 +259,10 @@ const statusRoute = route({
174
259
  params: z.object({ mcpServerId: z.string() }),
175
260
  query: z.object({ userId: z.string().optional() }),
176
261
  responses: {
177
- 200: { description: "Token status (never includes the token value itself)" },
262
+ 200: {
263
+ description: "Token status (never includes the token value itself)",
264
+ schema: McpOAuthStatusResponseSchema,
265
+ },
178
266
  404: { description: "MCP server not found" },
179
267
  },
180
268
  });
@@ -216,7 +304,7 @@ const authorizeUrlRoute = route({
216
304
  }),
217
305
  rbac: { permission: "mcp-oauth.authorize.any" },
218
306
  responses: {
219
- 200: { description: "{ providerUrl: string }" },
307
+ 200: { description: "{ providerUrl: string }", schema: AuthorizeUrlResponseSchema },
220
308
  400: { description: "MCP has no URL / does not require OAuth" },
221
309
  404: { description: "MCP server not found" },
222
310
  },
@@ -255,7 +343,7 @@ const refreshRoute = route({
255
343
  })
256
344
  .optional(),
257
345
  responses: {
258
- 200: { description: "Refreshed token" },
346
+ 200: { description: "Refreshed token", schema: RefreshResponseSchema },
259
347
  404: { description: "No token for this MCP server" },
260
348
  500: { description: "Refresh failed" },
261
349
  },
@@ -271,7 +359,7 @@ const disconnectRoute = route({
271
359
  params: z.object({ mcpServerId: z.string() }),
272
360
  query: z.object({ userId: z.string().optional() }),
273
361
  responses: {
274
- 200: { description: "Token revoked/deleted" },
362
+ 200: { description: "Token revoked/deleted", schema: OkResponseSchema },
275
363
  404: { description: "No token for this MCP server" },
276
364
  },
277
365
  });
@@ -292,9 +380,15 @@ const manualClientRoute = route({
292
380
  tokenUrl: z.string().url().optional(),
293
381
  revocationUrl: z.string().url().optional(),
294
382
  scopes: z.array(z.string()).optional(),
383
+ tokenEndpointAuthMethod: z
384
+ .enum(["client_secret_basic", "client_secret_post", "none"])
385
+ .optional(),
295
386
  }),
296
387
  responses: {
297
- 200: { description: "Pending client stored. Call /authorize to start the flow." },
388
+ 200: {
389
+ description: "Pending client stored. Call /authorize to start the flow.",
390
+ schema: OkResponseSchema,
391
+ },
298
392
  400: { description: "Bad input" },
299
393
  404: { description: "MCP server not found" },
300
394
  },
@@ -308,6 +402,42 @@ interface AuthorizeFlowQuery {
308
402
  scopes?: string;
309
403
  }
310
404
 
405
+ // ─── Per-connector+user authorize-flow lock ──────────────────────────────────
406
+ // Two concurrent first `/authorize` requests for the same (mcpServerId,
407
+ // userId) can otherwise both observe "no reusable client" from
408
+ // findReusableMcpOAuthClient, each register a fresh provider client before
409
+ // either reaches insertMcpOAuthPending, and race to persist — the later
410
+ // insert reuses/overwrites the earlier app row while its own pending context
411
+ // still points at the OTHER (now orphaned) client. That leaves duplicate
412
+ // provider registrations and can pair a connected authorization with the
413
+ // wrong client. Serialize the whole check-reusable-through-persist critical
414
+ // section per key so only one call at a time can decide "no reusable client
415
+ // exists, register one".
416
+ const authorizeFlowLocks = new Map<string, Promise<void>>();
417
+
418
+ function authorizeFlowLockKey(mcpServerId: string, userId: string | null): string {
419
+ return `${mcpServerId}::${userId ?? "_"}`;
420
+ }
421
+
422
+ async function withAuthorizeFlowLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
423
+ const prior = authorizeFlowLocks.get(key) ?? Promise.resolve();
424
+ let release!: () => void;
425
+ const mine = new Promise<void>((resolve) => {
426
+ release = resolve;
427
+ });
428
+ const chained = prior.then(() => mine);
429
+ authorizeFlowLocks.set(key, chained);
430
+ await prior;
431
+ try {
432
+ return await fn();
433
+ } finally {
434
+ release();
435
+ if (authorizeFlowLocks.get(key) === chained) {
436
+ authorizeFlowLocks.delete(key);
437
+ }
438
+ }
439
+ }
440
+
311
441
  /**
312
442
  * Use a stored manual client or discover metadata + DCR-register, build the
313
443
  * authorize URL, and persist the pending session. Returns the provider
@@ -321,10 +451,103 @@ async function prepareAuthorizeFlow(
321
451
  q: AuthorizeFlowQuery,
322
452
  ): Promise<string | null> {
323
453
  const userId = q.userId ?? null;
454
+ return withAuthorizeFlowLock(authorizeFlowLockKey(mcpServerId, userId), () =>
455
+ runAuthorizeFlow(res, mcpServerId, server, q, userId),
456
+ );
457
+ }
458
+
459
+ async function runAuthorizeFlow(
460
+ res: ServerResponse,
461
+ mcpServerId: string,
462
+ server: NonNullable<ReturnType<typeof getMcpServerById>>,
463
+ q: AuthorizeFlowQuery,
464
+ userId: string | null,
465
+ ): Promise<string | null> {
324
466
  let client = manualClientFromToken(getMcpOAuthToken(mcpServerId, userId));
467
+ let discovery: DiscoveryResult | null = null;
468
+ let registrationEndpoint: string | null = null;
469
+ // Only set when THIS call freshly registers (or otherwise knows the true
470
+ // registered set) — left undefined on reuse so the previously-recorded
471
+ // value survives (see upsertMcpApp's `registeredScopes` doc).
472
+ let registeredScopes: string[] | undefined;
473
+
474
+ if (!client) {
475
+ // Reuse a previously-registered DCR client for this connector+user
476
+ // instead of hitting the provider's registration endpoint again — either
477
+ // an already-connected client (re-authorizing) or a still-live pending
478
+ // attempt (retrying before it's completed/GC'd). Discovery is still run
479
+ // (cheap metadata GET, not a mutating registration) to get the current
480
+ // authorize/token URLs and to confirm the AS identity hasn't moved.
481
+ const reusable = findReusableMcpOAuthClient(mcpServerId, userId);
482
+ discovery = await discoverForMcp(server.url!);
483
+ if (!discovery) {
484
+ jsonError(res, "MCP server does not require OAuth", 400);
485
+ return null;
486
+ }
487
+ registrationEndpoint = discovery.registrationEndpoint;
488
+
489
+ // A caller-requested scope set that the stored client wasn't registered
490
+ // with must not be silently narrowed to the stored client's scopes —
491
+ // fall through to fresh registration below instead of reusing. With no
492
+ // explicit request, fall back to what would actually be sent
493
+ // (discovery.scopes, same as the else-branch below) so the coverage
494
+ // check below still catches an empty registered set against a
495
+ // non-empty advertised one — see scopesAreCovered's docstring.
496
+ const requestedScopes = q.scopes ? splitScopes(q.scopes) : null;
497
+ const scopesToRequest = requestedScopes ?? discovery.scopes;
498
+
499
+ if (
500
+ reusable &&
501
+ reusable.authorizationServerIssuer === discovery.authorizationServerIssuer &&
502
+ // A stored null means the client was registered before we persisted
503
+ // this field — treat it as unknown rather than as a mismatch, or every
504
+ // pre-existing connector re-registers on each abandoned authorize call.
505
+ (reusable.registrationEndpoint === null ||
506
+ reusable.registrationEndpoint === discovery.registrationEndpoint) &&
507
+ // "" is the legacy/unrecorded sentinel: migration 117 backfilled it for
508
+ // every pre-existing MCP app row, and connects before we persisted
509
+ // pending.redirectUri wrote it too. Treat it as unknown rather than a
510
+ // mismatch, same as a null registrationEndpoint above.
511
+ (reusable.redirectUri === "" || reusable.redirectUri === callbackRedirectUri()) &&
512
+ // Compare against the REGISTERED scope set, not the granted one — a
513
+ // provider that narrows the granted token scope below what the client
514
+ // was registered with (the common case) must not make every later
515
+ // /authorize believe the stored client needs re-registering. A null
516
+ // registeredScopes is a legacy row from before this field existed (see
517
+ // migration 117) and its true registered set is unknown — unlike
518
+ // registrationEndpoint/redirectUri above, "unknown" must NOT be treated
519
+ // as "compatible with anything": we have no evidence the provider ever
520
+ // granted this client any scope beyond none, so require the requested
521
+ // set to be empty (or force the one fresh DCR needed to establish a
522
+ // known set). Do not substitute the granted token scope here — that is
523
+ // the exact bug this series fixed for the known-scope case.
524
+ scopesAreCovered(scopesToRequest, reusable.registeredScopes ?? [])
525
+ ) {
526
+ // We know the true registered set here — record it so a pending row
527
+ // backing THIS reused authorize call doesn't fall back to a literal
528
+ // null if this is the flow that ends up completing. A legacy-unknown
529
+ // reuse only reaches this branch when scopesToRequest was empty, so
530
+ // recording `[]` (rather than leaving it undefined) is accurate: this
531
+ // flow observed no scope requirement, same as a fresh empty-scope DCR.
532
+ registeredScopes = reusable.registeredScopes ?? [];
533
+ client = {
534
+ clientId: reusable.clientId,
535
+ clientSecret: reusable.clientSecret,
536
+ resourceUrl: discovery.resourceUrl,
537
+ authorizationServerIssuer: discovery.authorizationServerIssuer,
538
+ authorizeUrl: discovery.authorizeUrl,
539
+ tokenUrl: discovery.tokenUrl,
540
+ revocationUrl: discovery.revocationUrl,
541
+ scopes: reusable.scopes.length > 0 ? reusable.scopes : discovery.scopes,
542
+ // Reusing the client means reusing how it authenticates. A stored null
543
+ // predates the field, so resolve it the same way every other stored
544
+ // read does rather than falling back to the RFC default.
545
+ tokenEndpointAuthMethod: authMethodForStoredClient(reusable.tokenEndpointAuthMethod),
546
+ };
547
+ }
548
+ }
325
549
 
326
550
  if (!client) {
327
- const discovery = await discoverForMcp(server.url!);
328
551
  if (!discovery) {
329
552
  jsonError(res, "MCP server does not require OAuth", 400);
330
553
  return null;
@@ -340,16 +563,28 @@ async function prepareAuthorizeFlow(
340
563
  }
341
564
 
342
565
  const scopes = q.scopes ? splitScopes(q.scopes) : discovery.scopes;
566
+ // RFC 7591 §2: request the method the AS actually advertises support for
567
+ // (preferring Basic), rather than assuming every provider accepts Basic.
568
+ const requestedAuthMethod = selectDcrTokenEndpointAuthMethod(
569
+ discovery.tokenEndpointAuthMethodsSupported ?? undefined,
570
+ );
343
571
  const dcr = await registerClient(discovery.registrationEndpoint, {
344
572
  client_name: `agent-swarm (${server.name})`,
345
573
  redirect_uris: [callbackRedirectUri()],
346
574
  grant_types: ["authorization_code", "refresh_token"],
347
575
  response_types: ["code"],
348
- token_endpoint_auth_method: "client_secret_basic",
576
+ token_endpoint_auth_method: requestedAuthMethod,
349
577
  application_type: "web",
350
578
  scope: scopes.join(" ") || undefined,
351
579
  });
352
-
580
+ // The AS's response is authoritative when present (it may differ from
581
+ // what we asked for); otherwise trust what we requested. An explicit but
582
+ // unsupported value raises rather than silently downgrading to Basic.
583
+ const registeredAuthMethod = dcr.token_endpoint_auth_method
584
+ ? resolveAdvertisedTokenEndpointAuthMethod(dcr.token_endpoint_auth_method, "returned")
585
+ : requestedAuthMethod;
586
+
587
+ registeredScopes = scopes;
353
588
  client = {
354
589
  clientId: dcr.client_id,
355
590
  clientSecret: dcr.client_secret ?? null,
@@ -359,6 +594,7 @@ async function prepareAuthorizeFlow(
359
594
  tokenUrl: discovery.tokenUrl,
360
595
  revocationUrl: discovery.revocationUrl,
361
596
  scopes,
597
+ tokenEndpointAuthMethod: registeredAuthMethod,
362
598
  };
363
599
  }
364
600
 
@@ -394,12 +630,15 @@ async function prepareAuthorizeFlow(
394
630
  codeVerifier: built.codeVerifier,
395
631
  resourceUrl: client.resourceUrl,
396
632
  authorizationServerIssuer: client.authorizationServerIssuer,
633
+ registrationEndpoint,
397
634
  authorizeUrl: client.authorizeUrl,
398
635
  tokenUrl: client.tokenUrl,
399
636
  revocationUrl: client.revocationUrl,
400
637
  scopes: scopes.join(" "),
638
+ registeredScopes,
401
639
  dcrClientId: client.clientId,
402
640
  dcrClientSecret: client.clientSecret,
641
+ tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
403
642
  redirectUri: callbackRedirectUri(),
404
643
  finalRedirect: q.redirect ?? null,
405
644
  });
@@ -435,6 +674,17 @@ export async function completeMcpOAuthCallback(
435
674
  const dashboardBaseUrl = pending.finalRedirect ?? defaultFinalRedirect(pending.mcpServerId);
436
675
 
437
676
  if (query.error) {
677
+ // A provider that rejects the client at the AUTHORIZE endpoint (rather
678
+ // than the token endpoint) never reaches the exchange try/catch below,
679
+ // so without this the stored client stays "reusable" until GC drops the
680
+ // orphaned pending row — a floor of 10 minutes, not a bound. Same
681
+ // wrong-target guard as the exchange-failure path below.
682
+ if (isInvalidClientError(query.error) || query.error === "unauthorized_client") {
683
+ const connected = getMcpOAuthToken(pending.mcpServerId, pending.userId);
684
+ if (!connected || connected.dcrClientId === pending.dcrClientId) {
685
+ invalidateMcpOAuthClient(pending.mcpServerId, pending.userId);
686
+ }
687
+ }
438
688
  const target = new URL(dashboardBaseUrl);
439
689
  target.searchParams.set("oauth", "error");
440
690
  target.searchParams.set("error", query.error);
@@ -456,6 +706,10 @@ export async function completeMcpOAuthCallback(
456
706
  tokenUrl: pending.tokenUrl,
457
707
  clientId: pending.dcrClientId ?? "",
458
708
  clientSecret: pending.dcrClientSecret ?? undefined,
709
+ // A pending row created before this change carries no method. Its client
710
+ // was registered under the old body-post behavior, so an in-flight
711
+ // callback spanning the deploy must not be upgraded to Basic.
712
+ tokenEndpointAuthMethod: authMethodForStoredClient(pending.tokenEndpointAuthMethod),
459
713
  redirectUri: pending.redirectUri,
460
714
  code: query.code,
461
715
  codeVerifier: pending.codeVerifier,
@@ -476,11 +730,20 @@ export async function completeMcpOAuthCallback(
476
730
  scope: tokens.scope ?? pending.scopes ?? null,
477
731
  resourceUrl: pending.resourceUrl,
478
732
  authorizationServerIssuer: pending.authorizationServerIssuer,
733
+ registrationEndpoint: pending.registrationEndpoint,
479
734
  authorizeUrl: pending.authorizeUrl,
480
735
  tokenUrl: pending.tokenUrl,
481
736
  revocationUrl: pending.revocationUrl,
737
+ redirectUri: pending.redirectUri,
738
+ // undefined (not null) when the pending row doesn't know one — this
739
+ // app row's app may not have been recreated by the orphan-app deletion
740
+ // above, and an explicit null would wipe out an already-correct value.
741
+ registeredScopes: pending.registeredScopes ?? undefined,
482
742
  dcrClientId: pending.dcrClientId,
483
743
  dcrClientSecret: pending.dcrClientSecret,
744
+ // Persist what actually authenticated, not the raw (possibly absent)
745
+ // pending value, so later refreshes reuse the same resolution.
746
+ tokenEndpointAuthMethod: authMethodForStoredClient(pending.tokenEndpointAuthMethod),
484
747
  clientSource,
485
748
  lastRefreshedAt: new Date().toISOString(),
486
749
  });
@@ -495,6 +758,19 @@ export async function completeMcpOAuthCallback(
495
758
  } catch (err) {
496
759
  const message = err instanceof Error ? err.message : String(err);
497
760
  console.error("[mcp-oauth] callback exchange failed:", message);
761
+ if (isInvalidClientError(message)) {
762
+ // The provider rejected the client we stored — invalidate it so the
763
+ // next /authorize call re-registers exactly once instead of retrying
764
+ // with a client the provider has already disowned. Only invalidate the
765
+ // client the provider actually rejected: when this flow used a freshly
766
+ // registered client (AS identity, redirect URI, or scope set moved),
767
+ // the connected app still holds a different, working client_id, and
768
+ // invalidating it would corrupt a working connection.
769
+ const connected = getMcpOAuthToken(pending.mcpServerId, pending.userId);
770
+ if (!connected || connected.dcrClientId === pending.dcrClientId) {
771
+ invalidateMcpOAuthClient(pending.mcpServerId, pending.userId);
772
+ }
773
+ }
498
774
  const target = new URL(dashboardBaseUrl);
499
775
  target.searchParams.set("oauth", "error");
500
776
  target.searchParams.set("error_description", message);
@@ -542,7 +818,7 @@ export async function handleMcpOAuth(
542
818
  const userId = parsed.query.userId ?? null;
543
819
  const token = getMcpOAuthToken(parsed.params.mcpServerId, userId);
544
820
 
545
- json(res, {
821
+ statusRoute.respond(res, 200, {
546
822
  mcpServerId: server.id,
547
823
  authMethod: server.authMethod,
548
824
  connected: !!token && token.status === "connected",
@@ -578,10 +854,10 @@ export async function handleMcpOAuth(
578
854
  try {
579
855
  const result = await discoverForMcp(server.url!);
580
856
  if (!result) {
581
- json(res, { requiresOAuth: false });
857
+ metadataRoute.respond(res, 200, { requiresOAuth: false });
582
858
  return true;
583
859
  }
584
- json(res, result);
860
+ metadataRoute.respond(res, 200, result);
585
861
  } catch (err) {
586
862
  const message = err instanceof Error ? err.message : String(err);
587
863
  jsonError(res, `Metadata discovery failed: ${message}`, 502);
@@ -631,7 +907,7 @@ export async function handleMcpOAuth(
631
907
  parsed.query,
632
908
  );
633
909
  if (!providerUrl) return true;
634
- json(res, { providerUrl });
910
+ authorizeUrlRoute.respond(res, 200, { providerUrl });
635
911
  } catch (err) {
636
912
  const message = err instanceof Error ? err.message : String(err);
637
913
  jsonError(res, `Authorize failed: ${message}`, 502);
@@ -656,6 +932,7 @@ export async function handleMcpOAuth(
656
932
  tokenUrl: existing.tokenUrl,
657
933
  clientId: existing.dcrClientId ?? "",
658
934
  clientSecret: existing.dcrClientSecret ?? undefined,
935
+ tokenEndpointAuthMethod: authMethodForStoredClient(existing.tokenEndpointAuthMethod),
659
936
  refreshToken: existing.refreshToken,
660
937
  resource: existing.resourceUrl,
661
938
  });
@@ -666,13 +943,20 @@ export async function handleMcpOAuth(
666
943
  scope: refreshed.scope ?? null,
667
944
  expectedTokenVersion: existing.tokenVersion,
668
945
  });
669
- json(res, {
946
+ refreshRoute.respond(res, 200, {
670
947
  ok: true,
671
948
  expiresAt: computeExpiresAt(refreshed.expires_in),
672
949
  scope: refreshed.scope ?? existing.scope,
673
950
  });
674
951
  } catch (err) {
675
952
  const message = err instanceof Error ? err.message : String(err);
953
+ if (isInvalidClientError(message)) {
954
+ // The provider has disowned this client — invalidate it so the next
955
+ // /authorize call re-registers exactly once instead of continuing to
956
+ // refresh against a client_id the provider now rejects.
957
+ invalidateMcpOAuthClient(parsed.params.mcpServerId, userId);
958
+ markMcpOAuthTokenStatus(existing.id, "error", message);
959
+ }
676
960
  jsonError(res, `Refresh failed: ${message}`, 500);
677
961
  }
678
962
  return true;
@@ -698,6 +982,7 @@ export async function handleMcpOAuth(
698
982
  tokenTypeHint: "access_token",
699
983
  clientId: token.dcrClientId ?? "",
700
984
  clientSecret: token.dcrClientSecret ?? undefined,
985
+ tokenEndpointAuthMethod: authMethodForStoredClient(token.tokenEndpointAuthMethod),
701
986
  });
702
987
  } catch (err) {
703
988
  console.warn(
@@ -710,7 +995,7 @@ export async function handleMcpOAuth(
710
995
  deleteMcpOAuthToken(parsed.params.mcpServerId, userId);
711
996
  // Flip back to static so resolveSecrets stops trying to inject Bearer.
712
997
  setMcpServerAuthMethod(parsed.params.mcpServerId, "static");
713
- json(res, { ok: true });
998
+ disconnectRoute.respond(res, 200, { ok: true });
714
999
  return true;
715
1000
  }
716
1001
 
@@ -731,6 +1016,14 @@ export async function handleMcpOAuth(
731
1016
  let authorizationServerIssuer = overrides.authorizationServerIssuer ?? null;
732
1017
  let resourceUrl = server.url!;
733
1018
  let scopes = overrides.scopes ?? [];
1019
+ // Only an explicit value from the caller is authoritative here. Unlike
1020
+ // DCR, we never register this client, so the AS's advertised list says
1021
+ // what the SERVER accepts, not which method THIS out-of-band client was
1022
+ // assigned. See the fallback below.
1023
+ let tokenEndpointAuthMethod = overrides.tokenEndpointAuthMethod
1024
+ ? normalizeTokenEndpointAuthMethod(overrides.tokenEndpointAuthMethod)
1025
+ : undefined;
1026
+ let advertisedAuthMethods: string[] | null = null;
734
1027
 
735
1028
  if (!authorizeUrl || !tokenUrl) {
736
1029
  const discovery = await discoverForMcp(server.url!);
@@ -749,6 +1042,27 @@ export async function handleMcpOAuth(
749
1042
  authorizationServerIssuer ?? discovery.authorizationServerIssuer;
750
1043
  resourceUrl = discovery.resourceUrl;
751
1044
  if (scopes.length === 0) scopes = discovery.scopes;
1045
+ advertisedAuthMethods = discovery.tokenEndpointAuthMethodsSupported;
1046
+ }
1047
+
1048
+ if (!tokenEndpointAuthMethod) {
1049
+ // The caller stated no method. A manual client is registered out of
1050
+ // band, so nothing here tells us which method the provider assigned to
1051
+ // it, and the dashboard still submits no method while allowing the
1052
+ // endpoints to be omitted. Inferring from AS metadata would switch a
1053
+ // pre-registered client that works on body-post over to Basic purely
1054
+ // because the server also supports Basic, so keep the legacy default.
1055
+ //
1056
+ // The one exception is an AS that advertises a non-empty list without
1057
+ // body-post: there the legacy default is knowably broken, so defer to
1058
+ // what the server says it accepts.
1059
+ const postUnsupported =
1060
+ advertisedAuthMethods !== null &&
1061
+ advertisedAuthMethods.length > 0 &&
1062
+ !advertisedAuthMethods.includes("client_secret_post");
1063
+ tokenEndpointAuthMethod = postUnsupported
1064
+ ? selectDcrTokenEndpointAuthMethod(advertisedAuthMethods ?? undefined)
1065
+ : authMethodForStoredClient(undefined);
752
1066
  }
753
1067
 
754
1068
  if (!authorizationServerIssuer) {
@@ -775,11 +1089,12 @@ export async function handleMcpOAuth(
775
1089
  revocationUrl,
776
1090
  dcrClientId: overrides.clientId,
777
1091
  dcrClientSecret: overrides.clientSecret ?? null,
1092
+ tokenEndpointAuthMethod,
778
1093
  clientSource: "manual",
779
1094
  status: "error",
780
1095
  lastErrorMessage: "Manual client pre-registered; awaiting authorize flow.",
781
1096
  });
782
- json(res, { ok: true });
1097
+ manualClientRoute.respond(res, 200, { ok: true });
783
1098
  } catch (err) {
784
1099
  const message = err instanceof Error ? err.message : String(err);
785
1100
  jsonError(res, `Manual-client registration failed: ${message}`, 500);