@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.
- package/README.md +2 -2
- package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
- package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
- package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
- package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
- package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
- package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
- package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
- package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
- package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
- package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
- package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
- package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
- package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
- package/dist/cli-3fj60etf.js +153 -0
- package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
- package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
- package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
- package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
- package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
- package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
- package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
- package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
- package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
- package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
- package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
- package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
- package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
- package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
- package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
- package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
- package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
- package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
- package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
- package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
- package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
- package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
- package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
- package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
- package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
- package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
- package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
- package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
- package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
- package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
- package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
- package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
- package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
- package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
- package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
- package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
- package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
- package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
- package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
- package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
- package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
- package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
- package/dist/cli-zsspvvm2.js +490 -0
- package/dist/cli.js +27 -25
- package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
- package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
- package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
- package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
- package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
- package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
- package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
- package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
- package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
- package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
- package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
- package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
- package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
- package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
- package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
- package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
- package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
- package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
- package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
- package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
- package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
- package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
- package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
- package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
- package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
- package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
- package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
- package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
- package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
- package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
- package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
- package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
- package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
- package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
- package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
- package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
- package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
- package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
- package/openapi.json +34039 -11036
- package/package.json +8 -6
- package/src/apps/definition.ts +368 -26
- package/src/apps/row-store.ts +66 -10
- package/src/apps/schema-migrate.ts +95 -1
- package/src/apps/store.ts +13 -4
- package/src/apps/sync-run-as.ts +18 -0
- package/src/apps/sync.ts +934 -0
- package/src/apps/version.ts +16 -2
- package/src/assets/key.ts +8 -1
- package/src/be/asset-key-audit.ts +3 -1
- package/src/be/db-queries/mcp-oauth.ts +297 -24
- package/src/be/db.ts +42 -1
- package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
- package/src/be/scripts/db.ts +5 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
- package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
- package/src/be/seed-scripts/index.ts +18 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/http/active-sessions.ts +26 -18
- package/src/http/agents.ts +94 -27
- package/src/http/api-keys.ts +91 -17
- package/src/http/approval-requests.ts +87 -10
- package/src/http/apps.ts +361 -53
- package/src/http/assets.ts +165 -31
- package/src/http/budgets.ts +7 -5
- package/src/http/codex-oauth-keep-warm.ts +21 -3
- package/src/http/config.ts +46 -16
- package/src/http/context.ts +33 -6
- package/src/http/ecosystem.ts +20 -6
- package/src/http/events.ts +24 -9
- package/src/http/favorites.ts +20 -6
- package/src/http/fs.ts +60 -17
- package/src/http/heartbeat.ts +18 -4
- package/src/http/inbox-state.ts +12 -6
- package/src/http/integrations.ts +25 -5
- package/src/http/kv.ts +36 -15
- package/src/http/mcp-bridge.ts +5 -1
- package/src/http/mcp-oauth.ts +332 -17
- package/src/http/mcp-servers.ts +56 -18
- package/src/http/memory.ts +282 -29
- package/src/http/metrics.ts +80 -17
- package/src/http/oauth-callback.ts +10 -4
- package/src/http/oauth-generic.ts +5 -1
- package/src/http/oauth-locks.ts +6 -3
- package/src/http/openapi.ts +57 -9
- package/src/http/page-proxy.ts +25 -5
- package/src/http/pages-public.ts +37 -20
- package/src/http/pages.ts +93 -28
- package/src/http/poll.ts +89 -5
- package/src/http/pricing.ts +9 -6
- package/src/http/prompt-templates.ts +85 -21
- package/src/http/route-def.ts +67 -4
- package/src/http/schedules.ts +63 -14
- package/src/http/script-connection-proxy.ts +30 -4
- package/src/http/script-connections.ts +393 -43
- package/src/http/script-runs.ts +73 -19
- package/src/http/scripts.ts +257 -39
- package/src/http/session-data.ts +87 -14
- package/src/http/sessions.ts +80 -5
- package/src/http/skills.ts +115 -35
- package/src/http/stats.ts +94 -13
- package/src/http/task-templates.ts +6 -4
- package/src/http/tasks.ts +230 -61
- package/src/http/trackers/jira.ts +90 -30
- package/src/http/trackers/linear.ts +46 -12
- package/src/http/users.ts +162 -25
- package/src/http/utils.ts +12 -2
- package/src/http/webhooks.ts +30 -16
- package/src/http/workflow-events.ts +18 -5
- package/src/http/workflows.ts +128 -39
- package/src/http/x.ts +37 -6
- package/src/oauth/ensure-mcp-token.ts +16 -1
- package/src/oauth/mcp-wrapper.ts +281 -26
- package/src/providers/pi-mono-adapter.ts +1 -1
- package/src/scripts-runtime/credential-broker/README.md +1 -1
- package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/stdlib/fetch.ts +6 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tests/apps-rbac.test.ts +110 -0
- package/src/tests/apps-spike2.test.ts +4 -1
- package/src/tests/apps-sync-engine.test.ts +2540 -0
- package/src/tests/apps-sync.test.ts +1627 -0
- package/src/tests/asset-key-api.test.ts +48 -2
- package/src/tests/asset-key-invariants.test.ts +92 -0
- package/src/tests/asset-key-migration.test.ts +174 -8
- package/src/tests/asset-key-normalization.test.ts +2 -0
- package/src/tests/credential-broker.test.ts +27 -2
- package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
- package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
- package/src/tests/mcp-oauth-queries.test.ts +153 -0
- package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
- package/src/tests/memory-http-recall-gating.test.ts +2 -0
- package/src/tests/openapi-response-contract.test.ts +138 -0
- package/src/tests/rbac-admission-e2e.test.ts +68 -0
- package/src/tests/script-connections.test.ts +3 -0
- package/src/tests/scripts-runtime-fetch.test.ts +121 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/swarm-tool-result-gate.test.ts +32 -0
- package/src/tests/task-working-dir.test.ts +8 -4
- package/src/tests/whoami-route.test.ts +127 -0
- package/src/tests/workflow-engine-v2.test.ts +80 -7
- package/src/tests/workflow-swarm-script.test.ts +104 -0
- package/src/tools/app-get.ts +10 -2
- package/src/tools/app-rollback.ts +1 -0
- package/src/tools/app-sync.ts +112 -0
- package/src/tools/script-common.ts +20 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/utils.ts +11 -0
- package/src/tools/workflows/create-workflow.ts +4 -1
- package/src/types.ts +1394 -1206
- package/src/utils/template.ts +8 -1
- package/src/utils/zod-openapi.ts +11 -0
- package/src/workflows/engine.ts +27 -4
- package/src/workflows/executors/swarm-script.ts +28 -0
- package/src/workflows/retry-poller.ts +19 -2
- package/templates/skills/apps/content.md +94 -5
- package/templates/skills/asset-namespaces/content.md +2 -2
- package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
- package/templates/skills/scheduled-task-resilience/config.json +14 -0
- package/templates/skills/scheduled-task-resilience/content.md +97 -0
- package/dist/cli-7wmybrt5.js +0 -115
- package/dist/cli-d91pvmq1.js +0 -14
package/src/http/mcp-oauth.ts
CHANGED
|
@@ -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 {
|
|
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: {
|
|
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: {
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
857
|
+
metadataRoute.respond(res, 200, { requiresOAuth: false });
|
|
582
858
|
return true;
|
|
583
859
|
}
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|