History

Preserve an explicitly absent OAuth resource instead of gating on discovery metadata

Rhys Sullivan committed 2 months ago

Commit 32c702b1 parent 2d9dcd911 files · +282 −437Browse files

Unified · Split

11 files
  1. .changeset/oauth-resource-explicit-absence.md +17 −0
  2. .changeset/oauth-resource-indicator-capability-gate.md +0 −19
  3. packages/core/sdk/src/executor.ts +8 −2
  4. packages/core/sdk/src/oauth-discovery.test.ts +0 −59
  5. packages/core/sdk/src/oauth-discovery.ts +0 −12
  6. packages/core/sdk/src/oauth-flow.test.ts +157 −0
  7. packages/core/sdk/src/oauth-helpers.test.ts +0 −230
  8. packages/core/sdk/src/oauth-helpers.ts +21 −89
  9. packages/core/sdk/src/oauth-scope-union.test.ts +28 −16
  10. packages/core/sdk/src/oauth-service.ts +21 −7
  11. packages/react/src/components/oauth-client-form.tsx +30 −3
.changeset/oauth-resource-explicit-absence.md+17−0
@@ -0,0 +1,17 @@
1+---
2+"executor": patch
3+"@executor-js/react": patch
4+---
5+
6+**An OAuth app can now be registered without an RFC 8707 resource, and that absence holds on every request**
7+
8+Microsoft Entra v2 rejects any authorization request that carries both a v2 `scope` (such as `https://api.fabric.microsoft.com/.default`) and the RFC 8707 `resource` parameter, failing with `AADSTS9010010` before the consent screen. Executor made that unavoidable for MCP servers behind Entra: registering an app for an MCP integration always derived the MCP endpoint as the resource, the form had no field to change it, and so every request carried the parameter Entra rejects.
9+
10+The register/edit OAuth app form now shows the resource indicator. It is still prefilled for MCP servers — nothing changes for providers that accept the parameter — but it can be cleared, and a cleared value persists as "no resource". A resource-less app then omits `resource` on all four grants alike: the authorization request, the code exchange, token refresh, and client-credentials. Symmetry matters here — sending `resource` on authorize but not on the token request (or the reverse) would bind the two tokens to different audiences.
11+
12+Two adjacent gaps closed with it:
13+
14+- MCP scope discovery no longer depends on the app's resource. It now falls back to the integration's own discovery URL (the MCP endpoint), so clearing the resource does not break connecting.
15+- Token refresh for a first-party OAuth app dropped the app's configured resource, refreshing to a different audience than the original grant. It now sends the same resource the authorization request sent.
16+
17+Apps that keep their resource — the default for every discovered MCP server — behave exactly as before: the parameter is sent on every grant, as the MCP authorization spec expects.
.changeset/oauth-resource-indicator-capability-gate.md+0−19
@@ -1,19 +0,0 @@
1−---
2−"executor": patch
3−---
4−
5−**The RFC 8707 `resource` parameter is now gated on what the authorization server advertises**
6−
7−Executor sent `resource` unconditionally on every authorization, code exchange, refresh, and client-credentials request whenever an OAuth app had a resource configured. Microsoft Entra v2 rejects that: a request carrying both `resource` and a v2 `scope` such as `https://api.fabric.microsoft.com/.default` fails with `AADSTS9010010` before the consent screen, so the Microsoft Fabric Core MCP server could not be connected at all.
8−
9−The rule now applied on all four grants, in full:
10−
11−- **No authorization-server metadata was discovered → send `resource`.** Nothing is known about the server, and the MCP authorization spec expects resource indicators. This is the previous behavior, unchanged, and it covers every manually configured provider.
12−- **Metadata was discovered and advertises `resource_indicators_supported: true` → send `resource`.**
13−- **Metadata was discovered and the flag is absent or `false` → omit `resource`.** RFC 8414 §2 makes an omitted metadata field mean "not advertised", and RFC 8707 §2 makes `resource` optional for clients, so omitting it is conformant.
14−
15−The decision reads only what a server publishes about itself — there is no Microsoft special case and no host allowlist. `resource_indicators_supported` is not in the IANA authorization-server metadata registry (RFC 8707 registered no discovery parameter), but it is the only machine-readable signal a server gives, so discovery now parses it and threads it to the grant helpers.
16−
17−The protected resource is still discovered, validated against the requested endpoint, and retained in the flow state for MCP binding when the parameter itself is withheld. The `executor.oauth.has_resource` span attribute now reports what actually went on the wire rather than what was configured.
18−
19−Providers whose authorization server advertises the capability, and providers reached without any metadata discovery, are unaffected.
packages/core/sdk/src/executor.ts+8−2
@@ -2262,7 +2262,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2261 unmodified lines
22622262 clientSecret: firstParty.clientSecret,
22632263 tokenUrl: firstParty.tokenUrl,
22642264 grant: "authorization_code",
2265− resource: null,
2265+ // RFC 8707: the SAME resource the authorize/exchange path sent
2266+ // (`loadedFirstPartyClient`), so the refreshed token keeps the
2267+ // audience the original grant was bound to. Dropping it here
2268+ // made refresh asymmetric with authorize for first-party apps.
2269+ resource: firstParty.resource ?? null,
22662270 } satisfies RefreshClient;
22672271 }
22682272 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -5202,8 +5206,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2933 unmodified lines
52025206 const oauth = selected?.kind === "oauth" ? selected.oauth : undefined;
52035207 // Declared scopes win. Discover only when the selected method
52045208 // declares none but names a source to discover them from (MCP).
5209+ // The discovery URL rides along so `oauth.start` can discover
5210+ // scopes even for a client whose RFC 8707 resource was cleared.
52055211 if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) {
5206− return { kind: "discover" };
5212+ return { kind: "discover", discoveryUrl: oauth.discoveryUrl };
52075213 }
52085214 return { kind: "scopes", scopes: oauth?.scopes ?? [] };
52095215 }),
packages/core/sdk/src/oauth-discovery.test.ts+0−59
@@ -369,9 +369,6 @@ describe("beginDynamicAuthorization", () => { · 368 unmodified lines
369369 scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"],
370370 response_types_supported: ["code"],
371371 code_challenge_methods_supported: ["S256"],
372− // An MCP-profile server implements RFC 8707, so it advertises it —
373− // that is what earns the `resource` parameter asserted below.
374− resource_indicators_supported: true,
375372 });
376373 }
377374 if (request.url === "/oauth/register") {
@@ -627,7 +624,6 @@ describe("beginDynamicAuthorization", () => { · 249 unmodified lines
627624 registration_endpoint: `${baseUrl}/register`,
628625 response_types_supported: ["code"],
629626 code_challenge_methods_supported: ["S256"],
630− resource_indicators_supported: true,
631627 });
632628 }
633629 if (request.url === "/register") {
@@ -656,61 +652,6 @@ describe("beginDynamicAuthorization", () => { · 22 unmodified lines
656652 ),
657653 );
658654
659− // #1789 — Microsoft Entra v2 publishes authorization-server metadata, does
660− // NOT advertise RFC 8707, and rejects `resource` alongside a v2 `scope` with
661− // AADSTS9010010. Metadata that stays silent on the capability means "not
662− // advertised" (RFC 8414 §2), so the resource indicator is withheld — while
663− // the protected resource itself is still discovered and kept in the state
664− // for MCP binding.
665− it.effect("omits the resource parameter when the AS metadata does not advertise RFC 8707", () =>
666− withOAuthFixture(
667− (request, baseUrl) => {
668− if (request.url === "/.well-known/oauth-protected-resource/v1/mcp/core") {
669− return sendJson({
670− resource: baseUrl,
671− authorization_servers: [baseUrl],
672− scopes_supported: [`${baseUrl}/.default`],
673− });
674− }
675− if (request.url === "/.well-known/oauth-authorization-server") {
676− return sendJson({
677− issuer: baseUrl,
678− authorization_endpoint: `${baseUrl}/oauth2/v2.0/authorize`,
679− token_endpoint: `${baseUrl}/oauth2/v2.0/token`,
680− registration_endpoint: `${baseUrl}/register`,
681− response_types_supported: ["code"],
682− code_challenge_methods_supported: ["S256"],
683− });
684− }
685− if (request.url === "/register") {
686− return sendJson(
687− {
688− client_id: "entra-client",
689− redirect_uris: ["https://app/cb"],
690− token_endpoint_auth_method: "none",
691− },
692− 201,
693− );
694− }
695− return notFound();
696− },
697− ({ baseUrl }) =>
698− Effect.gen(function* () {
699− const result = yield* beginDynamicAuthorization({
700− endpoint: `${baseUrl}/v1/mcp/core`,
701− redirectUrl: "https://app/cb",
702− state: "s",
703− });
704−
705− const authUrl = new URL(result.authorizationUrl);
706− expect(authUrl.searchParams.has("resource")).toBe(false);
707− expect(authUrl.searchParams.get("scope")).toBe(`${baseUrl}/.default`);
708− // Withheld from the wire, still retained for MCP discovery/binding.
709− expect(result.state.resource).toBe(baseUrl);
710− }),
711− ),
712− );
713−
714655 it.effect("includes client_uri in the DCR body", () =>
715656 withOAuthFixture(
716657 (request, baseUrl) => {
packages/core/sdk/src/oauth-discovery.ts+0−12
@@ -82,14 +82,6 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ · 81 unmodified lines
8282 introspection_endpoint: Schema.optional(Schema.String),
8383 userinfo_endpoint: Schema.optional(Schema.String),
8484 id_token_signing_alg_values_supported: Schema.optional(StringArray),
85− /** Whether this server implements RFC 8707 resource indicators. RFC 8707
86− * registered no metadata parameter, so this is a de-facto field rather than
87− * an IANA-registered one — but it is the only machine-readable signal a
88− * server gives, and `shouldSendResourceIndicator` reads it to decide whether
89− * `resource` goes on authorization and token requests. An absent field means
90− * "not advertised" (RFC 8414 §2), NOT "unknown": a server that publishes
91− * metadata and stays silent here gets no `resource`. */
92− resource_indicators_supported: Schema.optional(Schema.Boolean),
9385 /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the
9486 * authorization grant profiles this Resource Authorization Server
9587 * implements. Advertising a profile says only that the server implements
@@ -906,10 +898,6 @@ export const beginDynamicAuthorization = ( · 810 unmodified lines
906898 state: input.state,
907899 codeChallenge,
908900 resource: resourceValue,
909− // The AS metadata was just discovered, so its RFC 8707 capability is
910− // known: send `resource` only if it advertises support. Entra v2 rejects
911− // `resource` alongside a v2 `scope` (AADSTS9010010).
912− authorizationServerMetadata: authServer.metadata,
913901 endpointUrlPolicy: options.endpointUrlPolicy,
914902 });
915903
packages/core/sdk/src/oauth-flow.test.ts+157−0
@@ -2005,3 +2005,160 @@ describe("reactive OAuth refresh on upstream 401", () => { · 2004 unmodified lines
20052005 ),
20062006 );
20072007 });
2008+
2009+// ---------------------------------------------------------------------------
2010+// RFC 8707 resource omission for a resource-less client (#1789)
2011+//
2012+// A client persisted with NO resource sends no `resource` parameter on ANY
2013+// request — authorize, code exchange, refresh, client-credentials. Microsoft
2014+// Entra v2 rejects requests that carry `resource` next to a v2 `scope`
2015+// (AADSTS9010010), and the way out is a client whose resource is absent; that
2016+// absence must hold on every grant, or the token audience diverges between
2017+// authorize and token. The mirror-image assertions — a client WITH a resource
2018+// sends it on authorize + exchange + refresh — live in the tests above.
2019+// ---------------------------------------------------------------------------
2020+describe("resource-less client sends no resource parameter (#1789)", () => {
2021+ it.effect("authorize, code exchange, and refresh all omit `resource`", () =>
2022+ Effect.scoped(
2023+ Effect.gen(function* () {
2024+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2025+ const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2026+ yield* executor.acme.seed();
2027+
2028+ // `resource: null` — explicitly none, not merely unset.
2029+ yield* executor.oauth.createClient({
2030+ owner: "org",
2031+ slug: CLIENT,
2032+ authorizationUrl: server.authorizationEndpoint,
2033+ tokenUrl: server.tokenEndpoint,
2034+ grant: "authorization_code",
2035+ clientId: "test-client",
2036+ clientSecret: "test-secret",
2037+ resource: null,
2038+ });
2039+
2040+ const started = yield* executor.oauth.start({
2041+ owner: "org",
2042+ client: CLIENT,
2043+ clientOwner: "org",
2044+ name: ConnectionName.make("main"),
2045+ integration: INTEG,
2046+ template: TEMPLATE,
2047+ });
2048+ expect(started.status).toBe("redirect");
2049+ if (started.status !== "redirect") return;
2050+ expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false);
2051+
2052+ const callback = yield* server.completeAuthorizationCodeFlow({
2053+ authorizationUrl: started.authorizationUrl,
2054+ });
2055+ yield* executor.oauth.complete({ state: started.state, code: callback.code });
2056+
2057+ // Force expiry so the next execute refreshes.
2058+ yield* Effect.promise(() =>
2059+ config.db.updateMany("connection", {
2060+ where: (b) => b("name", "=", "main"),
2061+ set: { expires_at: Date.now() - 60_000 },
2062+ }),
2063+ );
2064+ const refreshed = (yield* executor.execute(
2065+ ToolAddress.make("tools.acme.org.main.whoami"),
2066+ {},
2067+ )) as { token: string };
2068+ expect(refreshed.token).toMatch(/^at_/);
2069+
2070+ // What the authorization server actually SAW: the authorize request,
2071+ // the code exchange, and the refresh each carried no `resource`.
2072+ const requests = yield* server.requests;
2073+ const authorize = requests.find((r) => r.path === "/authorize" && r.method === "GET");
2074+ expect(authorize).toBeDefined();
2075+ expect(authorize?.query.resource ?? null).toBeNull();
2076+ const exchange = requests.find(
2077+ (r) => r.path === "/token" && r.body.includes("grant_type=authorization_code"),
2078+ );
2079+ expect(exchange).toBeDefined();
2080+ expect(exchange?.body ?? "").not.toContain("resource=");
2081+ const refresh = requests.find(
2082+ (r) => r.path === "/token" && r.body.includes("grant_type=refresh_token"),
2083+ );
2084+ expect(refresh).toBeDefined();
2085+ expect(refresh?.body ?? "").not.toContain("resource=");
2086+ }),
2087+ ),
2088+ );
2089+
2090+ it.effect("client_credentials omits `resource` for a resource-less client", () =>
2091+ Effect.scoped(
2092+ Effect.gen(function* () {
2093+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2094+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2095+ yield* executor.acme.seed();
2096+
2097+ yield* executor.oauth.createClient({
2098+ owner: "org",
2099+ slug: CLIENT,
2100+ authorizationUrl: server.authorizationEndpoint,
2101+ tokenUrl: server.tokenEndpoint,
2102+ grant: "client_credentials",
2103+ clientId: "test-client",
2104+ clientSecret: "test-secret",
2105+ resource: null,
2106+ });
2107+
2108+ const started = yield* executor.oauth.start({
2109+ owner: "org",
2110+ client: CLIENT,
2111+ clientOwner: "org",
2112+ name: ConnectionName.make("cc"),
2113+ integration: INTEG,
2114+ template: TEMPLATE,
2115+ });
2116+ expect(started.status).toBe("connected");
2117+
2118+ const requests = yield* server.requests;
2119+ const grant = requests.find(
2120+ (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"),
2121+ );
2122+ expect(grant).toBeDefined();
2123+ expect(grant?.body ?? "").not.toContain("resource=");
2124+ }),
2125+ ),
2126+ );
2127+
2128+ it.effect("client_credentials sends `resource` when the client has one", () =>
2129+ Effect.scoped(
2130+ Effect.gen(function* () {
2131+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2132+ const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2133+ yield* executor.acme.seed();
2134+
2135+ yield* executor.oauth.createClient({
2136+ owner: "org",
2137+ slug: CLIENT,
2138+ authorizationUrl: server.authorizationEndpoint,
2139+ tokenUrl: server.tokenEndpoint,
2140+ grant: "client_credentials",
2141+ clientId: "test-client",
2142+ clientSecret: "test-secret",
2143+ resource: server.mcpResourceUrl,
2144+ });
2145+
2146+ const started = yield* executor.oauth.start({
2147+ owner: "org",
2148+ client: CLIENT,
2149+ clientOwner: "org",
2150+ name: ConnectionName.make("cc"),
2151+ integration: INTEG,
2152+ template: TEMPLATE,
2153+ });
2154+ expect(started.status).toBe("connected");
2155+
2156+ const requests = yield* server.requests;
2157+ const grant = requests.find(
2158+ (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"),
2159+ );
2160+ expect(grant?.body).toContain(`resource=${encodeURIComponent(server.mcpResourceUrl)}`);
2161+ }),
2162+ ),
2163+ );
2164+});
packages/core/sdk/src/oauth-helpers.test.ts+0−230
@@ -26,10 +26,7 @@ import { · 25 unmodified lines
2626 isUnusableSuccessTokenResponse,
2727 refreshAccessToken,
2828 shouldRefreshToken,
29− shouldSendResourceIndicator,
30− type ResourceIndicatorSupport,
3129 } from "./oauth-helpers";
32−import type { OAuthAuthorizationServerMetadata } from "./oauth-discovery";
3330 import { serveTestHttpApp } from "./testing";
3431
3532 interface TokenCall {
@@ -1426,233 +1423,6 @@ describe("refreshAccessToken", () => { · 1390 unmodified lines
14261423 );
14271424 });
14281425
1429−// ---------------------------------------------------------------------------
1430−// RFC 8707 resource indicators — the AS capability gate (#1789)
1431−//
1432−// THE RULE under test, on every grant that can carry `resource`:
1433−// no metadata → send (unchanged; MCP's default expectation)
1434−// metadata + flag true → send
1435−// metadata + flag absent → omit (RFC 8414 §2: not advertised)
1436−// metadata + flag false → omit
1437−//
1438−// The "metadata + flag absent" row is the bug: Microsoft Entra v2 publishes
1439−// metadata, does not advertise resource indicators, and rejects `resource`
1440−// alongside a v2 `scope` with AADSTS9010010.
1441−// ---------------------------------------------------------------------------
1442−
1443−const RESOURCE = "https://api.example.com/v1/mcp";
1444−const AS_SUPPORTED: ResourceIndicatorSupport = { resource_indicators_supported: true };
1445−const AS_UNSUPPORTED: ResourceIndicatorSupport = { resource_indicators_supported: false };
1446−/** Metadata that exists but never mentions resource indicators — the Entra
1447− * shape. Typed as the whole discovered document, not just the one flag, so
1448− * this fixture stays honest about what a real caller threads through. */
1449−const AS_SILENT: OAuthAuthorizationServerMetadata = {
1450− issuer: "https://login.microsoftonline.com/tenant/v2.0",
1451− authorization_endpoint: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
1452− token_endpoint: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
1453−};
1454−
1455−describe("shouldSendResourceIndicator", () => {
1456− it("sends when no metadata was discovered", () => {
1457− expect(shouldSendResourceIndicator()).toBe(true);
1458− expect(shouldSendResourceIndicator(undefined)).toBe(true);
1459− expect(shouldSendResourceIndicator(null)).toBe(true);
1460− });
1461−
1462− it("sends only when the server advertises support", () => {
1463− expect(shouldSendResourceIndicator(AS_SUPPORTED)).toBe(true);
1464− expect(shouldSendResourceIndicator(AS_UNSUPPORTED)).toBe(false);
1465− expect(shouldSendResourceIndicator(AS_SILENT)).toBe(false);
1466− expect(shouldSendResourceIndicator({})).toBe(false);
1467− });
1468−});
1469−
1470−describe("buildAuthorizationUrl resource-indicator gating", () => {
1471− const baseInput = {
1472− authorizationUrl: "https://example.com/authorize",
1473− clientId: "client-123",
1474− redirectUrl: "https://app.example.com/callback",
1475− scopes: ["https://api.fabric.microsoft.com/.default"] as const,
1476− state: "state-abc",
1477− codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
1478− resource: RESOURCE,
1479− };
1480−
1481− it("sends resource when no authorization-server metadata is known", () => {
1482− const url = new URL(buildAuthorizationUrl(baseInput));
1483− expect(url.searchParams.get("resource")).toBe(RESOURCE);
1484− });
1485−
1486− it("sends resource when the server advertises resource_indicators_supported", () => {
1487− const url = new URL(
1488− buildAuthorizationUrl({ ...baseInput, authorizationServerMetadata: AS_SUPPORTED }),
1489− );
1490− expect(url.searchParams.get("resource")).toBe(RESOURCE);
1491− });
1492−
1493− it("omits resource when the server publishes metadata without the capability", () => {
1494− for (const metadata of [AS_UNSUPPORTED, AS_SILENT]) {
1495− const url = new URL(
1496− buildAuthorizationUrl({ ...baseInput, authorizationServerMetadata: metadata }),
1497− );
1498− expect(url.searchParams.has("resource")).toBe(false);
1499− // The scope the AS DOES accept must survive the veto untouched.
1500− expect(url.searchParams.get("scope")).toBe("https://api.fabric.microsoft.com/.default");
1501− }
1502− });
1503−});
1504−
1505−describe("exchangeAuthorizationCode resource-indicator gating", () => {
1506− const exchange = (
1507− tokenUrl: string,
1508− authorizationServerMetadata?: ResourceIndicatorSupport | null,
1509− ) =>
1510− exchangeAuthorizationCode({
1511− tokenUrl,
1512− clientId: "cid",
1513− redirectUrl: "https://app.example.com/cb",
1514− codeVerifier: "verifier",
1515− code: "abc",
1516− resource: RESOURCE,
1517− authorizationServerMetadata,
1518− });
1519−
1520− it.effect("sends resource when no authorization-server metadata is known", () =>
1521− withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1522− Effect.gen(function* () {
1523− yield* exchange(tokenUrl);
1524− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1525− }),
1526− ),
1527− );
1528−
1529− it.effect("sends resource when the server advertises support", () =>
1530− withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1531− Effect.gen(function* () {
1532− yield* exchange(tokenUrl, AS_SUPPORTED);
1533− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1534− }),
1535− ),
1536− );
1537−
1538− it.effect("omits resource when the server publishes metadata without the capability", () =>
1539− withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1540− Effect.gen(function* () {
1541− yield* exchange(tokenUrl, AS_SILENT);
1542− yield* exchange(tokenUrl, AS_UNSUPPORTED);
1543− const bodies = (yield* calls).map((call) => call.body);
1544− expect(bodies).toHaveLength(2);
1545− for (const body of bodies) {
1546− expect(body.has("resource")).toBe(false);
1547− // The grant itself is untouched — only `resource` is withheld.
1548− expect(body.get("grant_type")).toBe("authorization_code");
1549− expect(body.get("code_verifier")).toBe("verifier");
1550− }
1551− }),
1552− ),
1553− );
1554−});
1555−
1556−describe("exchangeClientCredentials resource-indicator gating", () => {
1557− const exchange = (
1558− tokenUrl: string,
1559− authorizationServerMetadata?: ResourceIndicatorSupport | null,
1560− ) =>
1561− exchangeClientCredentials({
1562− tokenUrl,
1563− clientId: "cid",
1564− clientSecret: "secret",
1565− scopes: ["https://api.fabric.microsoft.com/.default"],
1566− resource: RESOURCE,
1567− authorizationServerMetadata,
1568− });
1569−
1570− it.effect("sends resource when no authorization-server metadata is known", () =>
1571− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1572− Effect.gen(function* () {
1573− yield* exchange(tokenUrl);
1574− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1575− }),
1576− ),
1577− );
1578−
1579− it.effect("sends resource when the server advertises support", () =>
1580− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1581− Effect.gen(function* () {
1582− yield* exchange(tokenUrl, AS_SUPPORTED);
1583− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1584− }),
1585− ),
1586− );
1587−
1588− it.effect("omits resource when the server publishes metadata without the capability", () =>
1589− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1590− Effect.gen(function* () {
1591− yield* exchange(tokenUrl, AS_SILENT);
1592− yield* exchange(tokenUrl, AS_UNSUPPORTED);
1593− const bodies = (yield* calls).map((call) => call.body);
1594− expect(bodies).toHaveLength(2);
1595− for (const body of bodies) {
1596− expect(body.has("resource")).toBe(false);
1597− expect(body.get("grant_type")).toBe("client_credentials");
1598− expect(body.get("scope")).toBe("https://api.fabric.microsoft.com/.default");
1599− }
1600− }),
1601− ),
1602− );
1603−});
1604−
1605−describe("refreshAccessToken resource-indicator gating", () => {
1606− const refresh = (
1607− tokenUrl: string,
1608− authorizationServerMetadata?: ResourceIndicatorSupport | null,
1609− ) =>
1610− refreshAccessToken({
1611− tokenUrl,
1612− clientId: "cid",
1613− refreshToken: "old",
1614− resource: RESOURCE,
1615− authorizationServerMetadata,
1616− });
1617−
1618− it.effect("sends resource when no authorization-server metadata is known", () =>
1619− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1620− Effect.gen(function* () {
1621− yield* refresh(tokenUrl);
1622− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1623− }),
1624− ),
1625− );
1626−
1627− it.effect("sends resource when the server advertises support", () =>
1628− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1629− Effect.gen(function* () {
1630− yield* refresh(tokenUrl, AS_SUPPORTED);
1631− expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1632− }),
1633− ),
1634− );
1635−
1636− // Refresh builds its extra params conditionally and passes `undefined` when
1637− // the set is empty, so the veto must leave a well-formed refresh request
1638− // rather than an empty `additionalParameters` bag.
1639− it.effect("omits resource when the server publishes metadata without the capability", () =>
1640− withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1641− Effect.gen(function* () {
1642− yield* refresh(tokenUrl, AS_SILENT);
1643− yield* refresh(tokenUrl, AS_UNSUPPORTED);
1644− const bodies = (yield* calls).map((call) => call.body);
1645− expect(bodies).toHaveLength(2);
1646− for (const body of bodies) {
1647− expect(body.has("resource")).toBe(false);
1648− expect(body.get("grant_type")).toBe("refresh_token");
1649− expect(body.get("refresh_token")).toBe("old");
1650− }
1651− }),
1652− ),
1653− );
1654−});
1655−
16561426 describe("shouldRefreshToken", () => {
16571427 it("never refreshes when expiresAt is null", () => {
16581428 expect(shouldRefreshToken({ expiresAt: null })).toBe(false);
packages/core/sdk/src/oauth-helpers.ts+21−89
@@ -181,57 +181,6 @@ export const createPkceCodeChallenge = (verifier: string): Promise<string> => · 180 unmodified lines
181181 * and redeemed by `oauth.complete`. */
182182 export const createOAuthState = (): string => oauth.generateRandomState();
183183
184−// ---------------------------------------------------------------------------
185−// RFC 8707 resource indicators — when `resource` may be sent
186−// ---------------------------------------------------------------------------
187−
188−/** The authorization server's discovered RFC 8414 metadata, narrowed to the one
189− * field the resource-indicator decision reads.
190− *
191− * `resource_indicators_supported` is NOT in the IANA "OAuth Authorization
192− * Server Metadata" registry — RFC 8707 registered no discovery parameter at
193− * all. It is the de-facto flag servers that do implement resource indicators
194− * advertise, so it is the only machine-readable signal available. */
195−export type ResourceIndicatorSupport = {
196− readonly resource_indicators_supported?: boolean;
197−};
198−
199−/** Whether the RFC 8707 `resource` parameter may be sent to this authorization
200− * server. THE RULE, in full:
201− *
202− * - No metadata was discovered (`undefined` / `null`) → SEND. Nothing is known
203− * about the server, and MCP Authorization 2025-06-18 tells clients to send
204− * resource indicators. This is the unchanged, pre-existing behavior and it
205− * covers every manually configured provider.
206− * - Metadata was discovered and `resource_indicators_supported` is `true`
207− * → SEND. The server says it implements RFC 8707.
208− * - Metadata was discovered and the flag is absent or `false` → OMIT. Per
209− * RFC 8414 §2 an omitted metadata field means the capability is not
210− * advertised, and RFC 8707 §2 makes `resource` OPTIONAL for clients, so
211− * omitting it is conformant. Sending it anyway is what breaks Microsoft
212− * Entra v2, which rejects `resource` alongside a v2 `scope` with
213− * AADSTS9010010.
214− *
215− * Deliberately NOT a per-provider special case: the decision reads only what
216− * the server advertises about itself. */
217−export const shouldSendResourceIndicator = (
218− authorizationServerMetadata?: ResourceIndicatorSupport | null,
219−): boolean =>
220− authorizationServerMetadata == null
221− ? true
222− : authorizationServerMetadata.resource_indicators_supported === true;
223−
224−/** The `resource` value to actually put on the wire — `undefined` when the
225− * caller has none, or when the authorization server does not advertise
226− * RFC 8707 support. */
227−const resourceParamFor = (input: {
228− readonly resource?: string | null;
229− readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
230−}): string | undefined =>
231− input.resource && shouldSendResourceIndicator(input.authorizationServerMetadata)
232− ? input.resource
233− : undefined;
234−
235184 // ---------------------------------------------------------------------------
236185 // Authorization URL builder
237186 // ---------------------------------------------------------------------------
@@ -247,12 +196,9 @@ export type BuildAuthorizationUrlInput = { · 9 unmodified lines
247196 /** Separator between scopes. RFC 6749 says space; some providers use comma. */
248197 readonly scopeSeparator?: string;
249198 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
250− * Parameter Implementation" asks clients to send this on every authorization
251− * request; `authorizationServerMetadata` can veto it. */
199+ * Parameter Implementation" requires clients to send this on every
200+ * authorization request, regardless of AS support. */
252201 readonly resource?: string;
253− /** The authorization server's discovered metadata. Gates `resource` — see
254− * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
255− readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
256202 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
257203 readonly extraParams?: Readonly<Record<string, string>>;
258204 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
@@ -281,9 +227,8 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 22 unmodified lines
281227 url.searchParams.set("state", input.state);
282228 url.searchParams.set("code_challenge_method", "S256");
283229 url.searchParams.set("code_challenge", input.codeChallenge);
284− const resource = resourceParamFor(input);
285− if (resource) {
286− url.searchParams.set("resource", resource);
230+ if (input.resource) {
231+ url.searchParams.set("resource", input.resource);
287232 }
288233 if (input.extraParams) {
289234 for (const [k, v] of Object.entries(input.extraParams)) {
@@ -1125,13 +1070,10 @@ export type ExchangeAuthorizationCodeInput = { · 835 unmodified lines
11251070 readonly code: string;
11261071 readonly clientAuth?: ClientAuthMethod;
11271072 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1128− /** RFC 8707 Resource Indicator. The MCP Auth spec asks for this on the token
1129− * request when the client knows the resource it intends to call;
1130− * `authorizationServerMetadata` can veto it. */
1073+ /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
1074+ * the token request when the client knows the resource it intends
1075+ * to call. */
11311076 readonly resource?: string;
1132− /** The authorization server's discovered metadata. Gates `resource` — see
1133− * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1134− readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
11351077 readonly timeoutMs?: number;
11361078 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
11371079 readonly fetch?: typeof globalThis.fetch;
@@ -1161,9 +1103,8 @@ export const exchangeAuthorizationCode = ( · 23 unmodified lines
11611103 redirect_uri: input.redirectUrl,
11621104 code_verifier: input.codeVerifier,
11631105 });
1164− const resource = resourceParamFor(input);
1165− if (resource) {
1166− params.set("resource", resource);
1106+ if (input.resource) {
1107+ params.set("resource", input.resource);
11671108 }
11681109 const response = await oauth.genericTokenEndpointRequest(
11691110 as,
@@ -1187,7 +1128,7 @@ export const exchangeAuthorizationCode = ( · 17 unmodified lines
11871128 grantType: "authorization_code",
11881129 tokenUrl: input.tokenUrl,
11891130 clientAuth: input.clientAuth,
1190− hasResource: resourceParamFor(input) !== undefined,
1131+ hasResource: input.resource !== undefined,
11911132 }),
11921133 );
11931134
@@ -1202,13 +1143,9 @@ export type ExchangeClientCredentialsInput = { · 8 unmodified lines
12021143 readonly scopes?: readonly string[];
12031144 readonly scopeSeparator?: string;
12041145 readonly clientAuth?: ClientAuthMethod;
1205− /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 asks for this on
1206− * token requests when the client knows the protected resource;
1207− * `authorizationServerMetadata` can veto it. */
1146+ /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 requires this
1147+ * on token requests when the client knows the protected resource. */
12081148 readonly resource?: string;
1209− /** The authorization server's discovered metadata. Gates `resource` — see
1210− * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1211− readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
12121149 readonly timeoutMs?: number;
12131150 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
12141151 readonly fetch?: typeof globalThis.fetch;
@@ -1229,9 +1166,8 @@ export const exchangeClientCredentials = ( · 14 unmodified lines
12291166 if (input.scopes && input.scopes.length > 0) {
12301167 params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
12311168 }
1232− const resource = resourceParamFor(input);
1233− if (resource) {
1234− params.set("resource", resource);
1169+ if (input.resource) {
1170+ params.set("resource", input.resource);
12351171 }
12361172 const response = await oauth.clientCredentialsGrantRequest(
12371173 as,
@@ -1255,7 +1191,7 @@ export const exchangeClientCredentials = ( · 17 unmodified lines
12551191 grantType: "client_credentials",
12561192 tokenUrl: input.tokenUrl,
12571193 clientAuth: input.clientAuth,
1258− hasResource: resourceParamFor(input) !== undefined,
1194+ hasResource: input.resource !== undefined,
12591195 }),
12601196 );
12611197
@@ -1273,13 +1209,10 @@ export type RefreshAccessTokenInput = { · 11 unmodified lines
12731209 readonly scopeSeparator?: string;
12741210 readonly clientAuth?: ClientAuthMethod;
12751211 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1276− /** RFC 8707 Resource Indicator — the MCP spec asks for this on refresh
1277− * requests so the new access token's audience stays bound to the same
1278− * resource; `authorizationServerMetadata` can veto it. */
1212+ /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
1213+ * refresh requests so the new access token's audience is bound to
1214+ * the same resource. */
12791215 readonly resource?: string;
1280− /** The authorization server's discovered metadata. Gates `resource` — see
1281− * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1282− readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
12831216 readonly timeoutMs?: number;
12841217 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
12851218 readonly fetch?: typeof globalThis.fetch;
@@ -1303,9 +1236,8 @@ export const refreshAccessToken = ( · 17 unmodified lines
13031236 if (input.scopes && input.scopes.length > 0) {
13041237 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
13051238 }
1306− const resource = resourceParamFor(input);
1307− if (resource) {
1308− extraParams.set("resource", resource);
1239+ if (input.resource) {
1240+ extraParams.set("resource", input.resource);
13091241 }
13101242 const additionalParameters =
13111243 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
@@ -1338,7 +1270,7 @@ export const refreshAccessToken = ( · 26 unmodified lines
13381270 grantType: "refresh_token",
13391271 tokenUrl: input.tokenUrl,
13401272 clientAuth: input.clientAuth,
1341− hasResource: resourceParamFor(input) !== undefined,
1273+ hasResource: input.resource !== undefined,
13421274 }),
13431275 );
13441276
packages/core/sdk/src/oauth-scope-union.test.ts+28−16
@@ -38,7 +38,7 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 37 unmodified lines
3838 const makeScopePluginWithId = <const TId extends string>(
3939 id: TId,
4040 config: { readonly scopes: readonly string[] | null },
41− options: { readonly discoversScopes?: boolean } = {},
41+ options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {},
4242 ) =>
4343 definePlugin(() => ({
4444 id,
@@ -62,7 +62,7 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines
6262 kind: "oauth",
6363 template: String(TEMPLATE),
6464 ...(options.discoversScopes
65− ? { oauth: { discoveryUrl: `https://${id}.example/mcp` } }
65+ ? { oauth: { discoveryUrl: options.discoveryUrl ?? `https://${id}.example/mcp` } }
6666 : {}),
6767 },
6868 ];
@@ -495,19 +495,28 @@ describe("oauth.start integration-driven scopes", () => { · 426 unmodified lines
495495 );
496496
497497 it.effect(
498− "(h) for MCP, a client with no resource fails start (discovery cannot run without one)",
498+ "(h) for MCP, a client with no resource still discovers scopes from the integration's discovery URL",
499499 () =>
500500 Effect.scoped(
501501 Effect.gen(function* () {
502+ // #1789 — a user may CLEAR the client's RFC 8707 resource (Entra v2
503+ // rejects the parameter). Scope discovery must not die with it: the
504+ // integration's own discovery URL (the MCP endpoint) is probed
505+ // instead, and the authorize request carries no `resource`.
502506 const server = yield* serveMetadataServer({ prm: { scopesSupported: ["read"] } });
503507 const plugins = [
504508 memoryCredentialsPlugin(),
505− makeMcpScopePlugin({ scopes: null }),
509+ makeScopePluginWithId(
510+ "mcp",
511+ { scopes: null },
512+ { discoversScopes: true, discoveryUrl: server.mcpResourceUrl },
513+ ),
506514 ] as const;
507515 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
508516 yield* executor.mcp.seed();
509517
510− // No `resource` on the client — discovery has nothing to probe.
518+ // No `resource` on the client — the wire parameter is absent by
519+ // choice, while discovery still has the integration's URL.
511520 yield* executor.oauth.createClient({
512521 owner: "org",
513522 slug: CLIENT,
@@ -518,17 +527,20 @@ describe("oauth.start integration-driven scopes", () => { · 4 unmodified lines
518527 clientSecret: "test-secret",
519528 });
520529
521− const exit = yield* Effect.exit(
522− executor.oauth.start({
523− owner: "org",
524− client: CLIENT,
525− clientOwner: "org",
526− name: ConnectionName.make("main"),
527− integration: INTEG,
528− template: TEMPLATE,
529− }),
530− );
531− expect(Exit.isFailure(exit)).toBe(true);
530+ const started = yield* executor.oauth.start({
531+ owner: "org",
532+ client: CLIENT,
533+ clientOwner: "org",
534+ name: ConnectionName.make("main"),
535+ integration: INTEG,
536+ template: TEMPLATE,
537+ });
538+ expect(started.status).toBe("redirect");
539+ if (started.status !== "redirect") return;
540+
541+ expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["read"]);
542+ // The cleared resource stays cleared on the wire.
543+ expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false);
532544 }),
533545 ),
534546 );
packages/core/sdk/src/oauth-service.ts+21−7
@@ -167,10 +167,15 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 166 unmodified lines
167167 * integration declares the scopes to request (`scopes`, possibly empty — an
168168 * empty set requests no scopes), or it declares none and the request scopes
169169 * are discovered from the server's metadata at connect (`discover`, used by
170− * MCP). The two are mutually exclusive by construction. */
170+ * MCP). The two are mutually exclusive by construction.
171+ *
172+ * `discover` carries the integration's own discovery URL (the MCP endpoint)
173+ * so scope discovery does not depend on the CLIENT having a persisted RFC
174+ * 8707 resource: a user may clear the client's resource (Entra v2 rejects
175+ * the parameter, #1789) without losing scope discovery. */
171176 export type OAuthScopePolicy =
172177 | { readonly kind: "scopes"; readonly scopes: readonly string[] }
173− | { readonly kind: "discover" };
178+ | { readonly kind: "discover"; readonly discoveryUrl: string };
174179
175180 /** Everything the OAuth service needs from the executor: fuma access for the
176181 * owned `oauth_client` / `oauth_session` tables, the default credential
@@ -205,10 +210,12 @@ export interface OAuthServiceDeps { · 28 unmodified lines
205210 * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union),
206211 * NOT the scopes frozen on a specific `oauth_client` row. These are
207212 * requested verbatim at connect (`start`); an empty set requests none.
208− * - `{ kind: "discover" }`: the integration declares no scopes, so `start`
209− * discovers the request scopes from the server's RFC 9728 / RFC 8414
210− * metadata. Used by server-targeting integrations (MCP) whose scopes live
211− * on the server rather than in a template.
213+ * - `{ kind: "discover", discoveryUrl }`: the integration declares no
214+ * scopes, so `start` discovers the request scopes from the server's RFC
215+ * 9728 / RFC 8414 metadata. Used by server-targeting integrations (MCP)
216+ * whose scopes live on the server rather than in a template.
217+ * `discoveryUrl` is the integration's protected-resource URL (the MCP
218+ * endpoint), used when the client persists no resource.
212219 */
213220 readonly resolveOAuthScopePolicy: (
214221 integration: IntegrationSlug,
@@ -1453,7 +1460,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1238 unmodified lines
14531460 const requestedScopes =
14541461 scopePolicy.kind === "discover"
14551462 ? yield* (() => {
1456− const discovered = discoverScopesForResource(client.resource).pipe(
1463+ // Scope discovery reads protected-resource metadata. The client's
1464+ // persisted resource is the historical source and stays primary,
1465+ // but it is a WIRE parameter the user may clear (Entra v2 rejects
1466+ // `resource`, #1789) — the integration's own discovery URL then
1467+ // keeps scope discovery working for a resource-less client.
1468+ const discovered = discoverScopesForResource(
1469+ client.resource ?? scopePolicy.discoveryUrl,
1470+ ).pipe(
14571471 Effect.mapError(
14581472 (cause) =>
14591473 new OAuthStartError({
packages/react/src/components/oauth-client-form.tsx+30−3
@@ -224,6 +224,13 @@ export function OAuthClientForm(props: { · 223 unmodified lines
224224 mode: "promiseExit",
225225 });
226226
227+ // Blank means "send no RFC 8707 resource": persist null so every OAuth
228+ // request (authorize, exchange, refresh, client-credentials) omits the
229+ // parameter. Clearing must stick — no endpoint is re-derived over an
230+ // intentional absence (Entra v2 rejects `resource`, #1789).
231+ const normalizedResource =
232+ resource == null || resource.trim().length === 0 ? null : resource.trim();
233+
227234 const canSubmit = canSubmitOAuthClientForm({
228235 submitting,
229236 name,
@@ -250,7 +257,7 @@ export function OAuthClientForm(props: { · 20 unmodified lines
250257 authorizationUrl,
251258 tokenUrl,
252259 issuer: discoveredIssuer,
253− resource,
260+ resource: normalizedResource,
254261 });
255262 const showAppSetup =
256263 appSetup !== undefined && grant === "authorization_code" && !showAutoRegister;
@@ -303,7 +310,7 @@ export function OAuthClientForm(props: { · 46 unmodified lines
303310 registrationEndpoint: registrationEndpoint.trim(),
304311 authorizationUrl: authorizationUrl.trim(),
305312 tokenUrl: tokenUrl.trim(),
306− resource,
313+ resource: normalizedResource,
307314 // DCR sends the integration's declared scopes, or the discovered set when
308315 // none are declared, to the AS at registration (the app stores none).
309316 scopes: [...registrationScopes(declaredScopes, discoveredScopes)],
@@ -337,7 +344,7 @@ export function OAuthClientForm(props: { · 27 unmodified lines
337344 grant,
338345 clientId: clientId.trim(),
339346 clientSecret: clientSecret.trim(),
340− resource,
347+ resource: normalizedResource,
341348 // Editing preserves the app's already-recorded origin (via
342349 // `intentIntegration`, passed verbatim by the caller); a fresh
343350 // registration from an integration's dialog stamps recorded intent.
@@ -613,6 +620,26 @@ export function OAuthClientForm(props: { · 269 unmodified lines
613620 />
614621 </div>
615622
623+ {/* RFC 8707 resource indicator. Prefilled for MCP servers; the field
624+ exists so a user can CLEAR it — some authorization servers
625+ (Microsoft Entra v2) reject requests that carry `resource`, and a
626+ cleared value persists as "no resource" on every OAuth request. */}
627+ <div className="space-y-1.5">
628+ <Label htmlFor="oauth-resource" className="text-xs text-muted-foreground">
629+ Resource indicator
630+ <span className="font-normal text-muted-foreground/70">
631+ optional — leave empty if the server rejects the resource parameter
632+ </span>
633+ </Label>
634+ <Input
635+ id="oauth-resource"
636+ placeholder="https://mcp.example.com/mcp"
637+ value={resource ?? ""}
638+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResource(e.target.value)}
639+ className="font-mono"
640+ />
641+ </div>
642+
616643 {endpointsKnown ? (
617644 <Button
618645 type="button"