History

Classify scope-insufficient 403s as oauth_scope_insufficient (#1385)

A 403 whose body or WWW-Authenticate challenge names a scope shortfall (RFC 6750 insufficient_scope, Google ACCESS_TOKEN_SCOPE_INSUFFICIENT) cannot be fixed by re-running the same OAuth grant, but all three protocol plugins collapsed it into connection_rejected, whose recovery block tells the agent to call oauth.start and retry: an infinite loop through identical consent screens. Add an oauth_scope_insufficient failure code with its own recovery guidance (reconnect with broader access; deliberately no startOAuthTool hint), a shared detector in the core SDK, and detection at the three collapse sites: openapi (body + headers), graphql (body), mcp (the transport error message, the only place the 403 body survives). A 403 without a recognised scope signal keeps the existing classification. Fixes #1381

Rhys Sullivan committed 4 months ago

Commit eabb67a1 parent d90d8be18 files · +1594 −38Browse files

Unified · Split

18 files
  1. e2e/scenarios/oauth-scope-insufficient.test.ts +324 −0
  2. packages/core/sdk/src/auth-tool-failure.ts +32 −14
  3. packages/core/sdk/src/index.ts +5 −0
  4. packages/core/sdk/src/insufficient-scope.test.ts +377 −0
  5. packages/core/sdk/src/insufficient-scope.ts +274 −0
  6. packages/plugins/graphql/src/sdk/invoke.ts +2 −0
  7. packages/plugins/graphql/src/sdk/plugin.test.ts +134 −0
  8. packages/plugins/graphql/src/sdk/plugin.ts +41 −16
  9. packages/plugins/graphql/src/sdk/types.ts +6 −0
  10. packages/plugins/mcp/src/sdk/connection.ts +40 −2
  11. packages/plugins/mcp/src/sdk/errors.ts +19 −0
  12. packages/plugins/mcp/src/sdk/http-status.test.ts +54 −0
  13. packages/plugins/mcp/src/sdk/http-status.ts +21 −0
  14. packages/plugins/mcp/src/sdk/invoke.ts +15 −1
  15. packages/plugins/mcp/src/sdk/plugin.test.ts +105 −3
  16. packages/plugins/mcp/src/sdk/plugin.ts +19 −2
  17. packages/plugins/openapi/src/sdk/backing.ts +23 −0
  18. packages/plugins/openapi/src/sdk/upstream-failures.test.ts +103 −0
e2e/scenarios/oauth-scope-insufficient.test.ts+324−0
@@ -0,0 +1,324 @@
1// Cross-target: a scope-insufficient upstream 403 is a distinct, actionable
2// failure — not a re-authenticate loop. When a connection's OAuth grant does
3// not cover the scope an operation requires, the upstream rejects the call
4// with a scope signal (Google's ACCESS_TOKEN_SCOPE_INSUFFICIENT, RFC 6750's
5// insufficient_scope). Re-running the same grant returns the identical 403,
6// so the tool failure must say so: code `oauth_scope_insufficient`, guidance
7// to reconnect with broader access, and NO `oauth.start` recovery hint (an
8// agent following one would loop through identical consent forever).
9//
10// The journey: an OpenAPI integration with two operations under one OAuth
11// method; the connection completes a real authorization-code flow granting
12// only `mail.read`. Calling the `files.read` operation dispatches upstream
13// (catalog projection by scope is #1384, tracked separately), which answers
14// with a Google-shaped scope-insufficient 403 — and the failure the sandbox
15// sees carries the new code, while the ordinary revoked-token 403 stays
16// `connection_rejected`.
17import { randomBytes } from "node:crypto";
18import { createServer } from "node:http";
19
20import { expect } from "@effect/vitest";
21import { Effect } from "effect";
22import { composePluginApi } from "@executor-js/api/server";
23import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24import {
25 AuthTemplateSlug,
26 ConnectionName,
27 IntegrationSlug,
28 OAuthClientSlug,
29} from "@executor-js/sdk/shared";
30import { serveOAuthTestServer } from "@executor-js/sdk/testing";
31
32import { scenario } from "../src/scenario";
33import { Api, Target } from "../src/services";
34
35const api = composePluginApi([openApiHttpPlugin()] as const);
36
37const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
38
39/** Upstream on 127.0.0.1: `GET /inbox` is 200 for any bearer; `GET /files`
40 * answers a Google-shaped scope-insufficient 403; `GET /admin` answers an
41 * ordinary 403 with no scope signal. */
42const serveScopedUpstream = () =>
43 Effect.acquireRelease(
44 Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
45 const server = createServer((request, response) => {
46 const path = request.url ?? "";
47 if (request.method === "GET" && path.startsWith("/inbox")) {
48 response.writeHead(200, { "content-type": "application/json" });
49 response.end(JSON.stringify({ messages: [] }));
50 return;
51 }
52 if (request.method === "GET" && path.startsWith("/files")) {
53 response.writeHead(403, { "content-type": "application/json" });
54 response.end(
55 JSON.stringify({
56 error: {
57 code: 403,
58 message: "Request had insufficient authentication scopes.",
59 status: "PERMISSION_DENIED",
60 details: [
61 {
62 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
63 reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT",
64 domain: "googleapis.com",
65 metadata: { service: "drive.googleapis.com" },
66 },
67 ],
68 },
69 }),
70 );
71 return;
72 }
73 if (request.method === "GET" && path.startsWith("/admin")) {
74 response.writeHead(403, { "content-type": "application/json" });
75 response.end(
76 JSON.stringify({ error: { status: "PERMISSION_DENIED", message: "not allowed" } }),
77 );
78 return;
79 }
80 response.writeHead(404, { "content-type": "application/json" });
81 response.end(JSON.stringify({ error: "not_found" }));
82 });
83 server.listen(0, "127.0.0.1", () => {
84 const address = server.address();
85 const port = typeof address === "object" && address ? address.port : 0;
86 resume(
87 Effect.succeed({
88 url: `http://127.0.0.1:${port}`,
89 close: () => {
90 server.close();
91 server.closeAllConnections();
92 },
93 }),
94 );
95 });
96 }),
97 (server) => Effect.sync(server.close),
98 );
99
100const spec = (
101 baseUrl: string,
102 oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
103): string =>
104 JSON.stringify({
105 openapi: "3.0.3",
106 info: { title: "Scoped API", version: "1.0.0" },
107 servers: [{ url: baseUrl }],
108 paths: {
109 "/inbox": {
110 get: {
111 operationId: "readInbox",
112 summary: "List inbox messages",
113 security: [{ oauth: ["mail.read"] }],
114 responses: { "200": { description: "messages" } },
115 },
116 },
117 "/files": {
118 get: {
119 operationId: "listFiles",
120 summary: "List drive files",
121 security: [{ oauth: ["files.read"] }],
122 responses: { "200": { description: "files" } },
123 },
124 },
125 "/admin": {
126 get: {
127 operationId: "adminAction",
128 summary: "An admin-only action",
129 security: [{ oauth: ["mail.read"] }],
130 responses: { "200": { description: "ok" } },
131 },
132 },
133 },
134 components: {
135 securitySchemes: {
136 oauth: {
137 type: "oauth2",
138 flows: {
139 authorizationCode: {
140 authorizationUrl: oauth.authorizationEndpoint,
141 tokenUrl: oauth.tokenEndpoint,
142 scopes: {
143 "mail.read": "Read mail",
144 "files.read": "Read files",
145 },
146 },
147 },
148 },
149 },
150 },
151 });
152
153const invokeByAddressCode = (address: string, args: unknown) => `
154const segments = ${JSON.stringify(address)}.split(".").slice(1);
155let node = tools;
156for (const segment of segments) node = node[segment];
157const result = await node(${JSON.stringify(args)});
158return JSON.stringify(result);
159`;
160
161type ToolEnvelope = {
162 readonly ok: boolean;
163 readonly error?: {
164 readonly code?: string;
165 readonly message?: string;
166 readonly details?: { readonly recovery?: Record<string, string> };
167 };
168};
169
170scenario(
171 "Auth failures · a scope-insufficient 403 tells the agent to reconnect with broader access, not to re-authenticate",
172 {},
173 Effect.scoped(
174 Effect.gen(function* () {
175 const target = yield* Target;
176 const { client: makeClient } = yield* Api;
177 const identity = yield* target.newIdentity();
178 const client = yield* makeClient(api, identity);
179 const upstream = yield* serveScopedUpstream();
180 const oauth = yield* serveOAuthTestServer({ scopes: ["mail.read", "files.read"] });
181 const slug = unique("scopeins");
182 const clientSlug = OAuthClientSlug.make(unique("scopeinsc"));
183
184 yield* Effect.ensuring(
185 Effect.gen(function* () {
186 // The integration's OAuth method requests only mail.read — the
187 // whole grant the connection will hold.
188 yield* client.openapi.addSpec({
189 payload: {
190 spec: { kind: "blob", value: spec(upstream.url, oauth) },
191 slug,
192 baseUrl: upstream.url,
193 authenticationTemplate: [
194 {
195 slug: "oauth",
196 kind: "oauth2",
197 authorizationUrl: oauth.authorizationEndpoint,
198 tokenUrl: oauth.tokenEndpoint,
199 scopes: ["mail.read"],
200 },
201 ],
202 },
203 });
204 yield* client.oauth.createClient({
205 payload: {
206 owner: "org",
207 slug: clientSlug,
208 grant: "authorization_code",
209 authorizationUrl: oauth.authorizationEndpoint,
210 tokenUrl: oauth.tokenEndpoint,
211 clientId: "test-client",
212 clientSecret: "test-secret",
213 originIntegration: IntegrationSlug.make(slug),
214 },
215 });
216
217 const started = yield* client.oauth.start({
218 payload: {
219 client: clientSlug,
220 clientOwner: "org",
221 owner: "org",
222 name: ConnectionName.make("main"),
223 integration: IntegrationSlug.make(slug),
224 template: AuthTemplateSlug.make("oauth"),
225 },
226 });
227 expect(started.status, "oauth.start redirects to the authorization server").toBe(
228 "redirect",
229 );
230 if (started.status !== "redirect") return yield* Effect.die("no redirect");
231
232 // Drive the test IdP's consent by hand (authorize → login → code).
233 const code = yield* Effect.promise(async () => {
234 const authorize = await fetch(started.authorizationUrl, { redirect: "manual" });
235 const loginUrl = authorize.headers.get("location");
236 if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
237 const login = await fetch(loginUrl, {
238 method: "POST",
239 headers: {
240 authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
241 },
242 redirect: "manual",
243 });
244 const callbackUrl = login.headers.get("location");
245 if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
246 const minted = new URL(callbackUrl).searchParams.get("code");
247 if (!minted) throw new Error("callback carried no authorization code");
248 return minted;
249 });
250 yield* client.oauth.complete({ payload: { state: started.state, code } });
251
252 const tools = yield* client.tools.list({ query: {} });
253 const addresses = tools
254 .filter((tool) => String(tool.integration) === slug)
255 .map((tool) => String(tool.address));
256 const addressOf = (suffix: string) => {
257 const found = addresses.find((addr) => addr.endsWith(suffix));
258 expect(found, `the ${suffix} tool is in the catalog`).toBeDefined();
259 return found!;
260 };
261
262 const invoke = (address: string) =>
263 Effect.gen(function* () {
264 const executed = yield* client.executions.execute({
265 payload: { code: invokeByAddressCode(address, {}), autoApprove: true },
266 });
267 expect(executed.status, "the sandbox execution completed").toBe("completed");
268 return JSON.parse(executed.text) as ToolEnvelope;
269 });
270
271 // THE guarantee: the scope-insufficient 403 carries its own code
272 // and reconnect guidance — and no oauth.start hint, because
273 // re-running the identical grant cannot satisfy the scope.
274 const scopeFailure = yield* invoke(addressOf("listFiles"));
275 expect(scopeFailure.ok, "the out-of-scope call failed").toBe(false);
276 expect(
277 scopeFailure.error?.code,
278 "the failure names the scope shortfall, not a rejected credential",
279 ).toBe("oauth_scope_insufficient");
280 expect(
281 scopeFailure.error?.message ?? "",
282 "the message says re-authenticating will not help",
283 ).toContain("Re-authenticating with the same grant");
284 expect(
285 scopeFailure.error?.details?.recovery?.startOAuthTool,
286 "no oauth.start recovery hint",
287 ).toBeUndefined();
288 expect(
289 scopeFailure.error?.details?.recovery?.scopeInstructions ?? "",
290 "the recovery tells the agent to reconnect with broader access",
291 ).toContain("broader access");
292
293 // An ordinary 403 with no scope signal keeps the existing
294 // classification — the new code is additive, not a re-label.
295 const plainForbidden = yield* invoke(addressOf("adminAction"));
296 expect(plainForbidden.ok, "the plain-403 call failed").toBe(false);
297 expect(
298 plainForbidden.error?.code,
299 "a 403 without a scope signal stays connection_rejected",
300 ).toBe("connection_rejected");
301
302 // And the in-scope operation works, proving the connection is fine.
303 const inScope = yield* invoke(addressOf("readInbox"));
304 expect(inScope.ok, "the in-scope call succeeds with the same token").toBe(true);
305 }),
306 Effect.gen(function* () {
307 yield* client.connections
308 .remove({
309 params: {
310 owner: "org",
311 integration: IntegrationSlug.make(slug),
312 name: ConnectionName.make("main"),
313 },
314 })
315 .pipe(Effect.ignore);
316 yield* client.oauth
317 .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
318 .pipe(Effect.ignore);
319 yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
320 }),
321 );
322 }),
323 ),
324);
packages/core/sdk/src/auth-tool-failure.ts+32−14
@@ -5,7 +5,8 @@ export type AuthToolFailureCode = · 4 unmodified lines
5 | "connection_rejected"5 | "connection_rejected"
6 | "oauth_connection_missing"6 | "oauth_connection_missing"
7 | "oauth_refresh_failed"7 | "oauth_refresh_failed"
8 | "oauth_reauth_required";8 | "oauth_reauth_required"
9 | "oauth_scope_insufficient";
9 10
10export type AuthToolFailureInput = {11export type AuthToolFailureInput = {
11 readonly code: AuthToolFailureCode;12 readonly code: AuthToolFailureCode;
@@ -37,18 +38,35 @@ export type AuthToolFailureInput = { · 25 unmodified lines
37// creates the bound connection in one step); OAuth credentials are minted by38// creates the bound connection in one step); OAuth credentials are minted by
38// the OAuth start flow. These strings are read by the agent resolving the39// the OAuth start flow. These strings are read by the agent resolving the
39// failure, so they must name tools that actually exist on the executor.40// failure, so they must name tools that actually exist on the executor.
40const authRecovery = (input?: AuthToolFailureInput["recovery"]) => ({41const authRecovery = (code: AuthToolFailureCode, input?: AuthToolFailureInput["recovery"]) => {
41 createConnectionTool: "executor.coreTools.connections.createHandoff",42 // A scope-insufficient rejection cannot be fixed by re-running the same
42 startOAuthTool: "executor.coreTools.oauth.start",43 // grant, so this branch deliberately omits startOAuthTool/oauthInstructions:
43 listConnectionsTool: "executor.coreTools.connections.list",44 // an agent following the hints would loop through an identical consent and
44 ...(input?.configureIntegrationTool45 // land on the identical 403. The connection must be reconnected with a
45 ? { configureIntegrationTool: input.configureIntegrationTool }46 // broader scope, which is a user decision, not a retryable tool call.
46 : {}),47 if (code === "oauth_scope_insufficient") {
47 connectionInstructions:48 return {
48 "For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.",49 listConnectionsTool: "executor.coreTools.connections.list",
49 oauthInstructions:50 ...(input?.configureIntegrationTool
50 "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.",51 ? { configureIntegrationTool: input.configureIntegrationTool }
51});52 : {}),
53 scopeInstructions:
54 "The connection's OAuth grant does not cover the scope this operation requires; re-authenticating with the same grant will return the same error. Tell the user which operation was denied and ask them to reconnect the integration with broader access (or use a connection that already has it). Call listConnectionsTool to see the available connections and their scopes.",
55 };
56 }
57 return {
58 createConnectionTool: "executor.coreTools.connections.createHandoff",
59 startOAuthTool: "executor.coreTools.oauth.start",
60 listConnectionsTool: "executor.coreTools.connections.list",
61 ...(input?.configureIntegrationTool
62 ? { configureIntegrationTool: input.configureIntegrationTool }
63 : {}),
64 connectionInstructions:
65 "For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.",
66 oauthInstructions:
67 "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.",
68 };
69};
52 70
53export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolResult<T> => {71export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolResult<T> => {
54 const error: ToolError = {72 const error: ToolError = {
@@ -61,7 +79,7 @@ export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolRes · 6 unmodified lines
61 ...(input.integration ? { integration: input.integration } : {}),79 ...(input.integration ? { integration: input.integration } : {}),
62 ...(input.credential ? { credential: input.credential } : {}),80 ...(input.credential ? { credential: input.credential } : {}),
63 ...(input.upstream ? { upstream: input.upstream } : {}),81 ...(input.upstream ? { upstream: input.upstream } : {}),
64 recovery: authRecovery(input.recovery),82 recovery: authRecovery(input.code, input.recovery),
65 },83 },
66 };84 };
67 return ToolResult.fail(error);85 return ToolResult.fail(error);
packages/core/sdk/src/index.ts+5−0
@@ -407,3 +407,8 @@ export { · 406 unmodified lines
407 type AuthToolFailureCode,407 type AuthToolFailureCode,
408 type AuthToolFailureInput,408 type AuthToolFailureInput,
409} from "./auth-tool-failure";409} from "./auth-tool-failure";
410export {
411 detectInsufficientScope,
412 insufficientScopeFromEmbeddedJson,
413 type InsufficientScopeDetection,
414} from "./insufficient-scope";
packages/core/sdk/src/insufficient-scope.test.ts+377−0
@@ -0,0 +1,377 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { authToolFailure } from "./auth-tool-failure";
4import { detectInsufficientScope } from "./insufficient-scope";
5
6describe("detectInsufficientScope", () => {
7 it("detects Google's ErrorInfo reason nested in an error body", () => {
8 const body = {
9 error: {
10 code: 403,
11 message: "Request had insufficient authentication scopes.",
12 status: "PERMISSION_DENIED",
13 details: [
14 {
15 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
16 reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT",
17 domain: "googleapis.com",
18 metadata: { service: "drive.googleapis.com" },
19 },
20 ],
21 },
22 };
23 expect(detectInsufficientScope({ body })).toEqual({ requiredScopes: [] });
24 });
25
26 it("detects the RFC 6750 error body", () => {
27 expect(detectInsufficientScope({ body: { error: "insufficient_scope" } })).toEqual({
28 requiredScopes: [],
29 });
30 });
31
32 it("reads required scopes from a WWW-Authenticate challenge", () => {
33 expect(
34 detectInsufficientScope({
35 body: null,
36 headers: {
37 "www-authenticate":
38 'Bearer realm="example", error="insufficient_scope", scope="files.read files.meta"',
39 },
40 }),
41 ).toEqual({ requiredScopes: ["files.read", "files.meta"] });
42 });
43
44 it("detects the signal inside a JSON text body", () => {
45 expect(detectInsufficientScope({ body: '{"error":"insufficient_scope"}' })).toEqual({
46 requiredScopes: [],
47 });
48 });
49
50 it("never classifies non-JSON text, even when it embeds example JSON", () => {
51 expect(
52 detectInsufficientScope({
53 body: "Proxy note: error=insufficient_scope was returned upstream",
54 }),
55 ).toBeNull();
56 expect(
57 detectInsufficientScope({
58 body: 'The docs show {"error":"insufficient_scope"} as an example response',
59 }),
60 ).toBeNull();
61 });
62
63 it("ignores quoted challenge parameter values that embed the error token", () => {
64 expect(
65 detectInsufficientScope({
66 headers: {
67 "www-authenticate":
68 'Bearer error_description="Example: error=insufficient_scope for missing grants"',
69 },
70 }),
71 ).toBeNull();
72 });
73
74 it("ignores prose that merely mentions the tokens", () => {
75 expect(
76 detectInsufficientScope({
77 body: {
78 error: {
79 message:
80 "If the token lacks access you may see insufficient_scope or ACCESS_TOKEN_SCOPE_INSUFFICIENT in provider docs",
81 },
82 },
83 }),
84 ).toBeNull();
85 expect(
86 detectInsufficientScope({
87 body: "Consult the OAuth guide about insufficient_scope errors",
88 }),
89 ).toBeNull();
90 });
91
92 it("ignores the tokens under other field names and inside data lists", () => {
93 expect(
94 detectInsufficientScope({
95 body: { supportedErrors: ["invalid_token", "insufficient_scope"] },
96 }),
97 ).toBeNull();
98 expect(detectInsufficientScope({ body: { code: "insufficient_scope" } })).toBeNull();
99 });
100
101 it("ignores look-alike challenge parameters and values", () => {
102 expect(
103 detectInsufficientScope({
104 headers: { "www-authenticate": 'Bearer x-error="insufficient_scope"' },
105 }),
106 ).toBeNull();
107 expect(
108 detectInsufficientScope({
109 headers: { "www-authenticate": 'Bearer error="insufficient_scope_extra"' },
110 }),
111 ).toBeNull();
112 });
113
114 it("consumes quoted-pairs, so an escaped quote cannot fabricate a parameter", () => {
115 expect(
116 detectInsufficientScope({
117 headers: {
118 "www-authenticate": 'Bearer error_description="Example: \\"error=insufficient_scope"',
119 },
120 }),
121 ).toBeNull();
122 });
123
124 it("never discovers a scheme inside another challenge's quoted value", () => {
125 expect(
126 detectInsufficientScope({
127 headers: {
128 "www-authenticate":
129 'Basic error_description="Proxy saw Bearer error=insufficient_scope upstream"',
130 },
131 }),
132 ).toBeNull();
133 expect(
134 detectInsufficientScope({
135 headers: {
136 "www-authenticate":
137 'Digest realm="x", qop="auth Bearer error=insufficient_scope", nonce="n"',
138 },
139 }),
140 ).toBeNull();
141 // A Basic challenge followed by a harmless real Bearer challenge: the
142 // Bearer params are read, and they do not include the Basic params.
143 expect(
144 detectInsufficientScope({
145 headers: {
146 "www-authenticate": 'Basic error=insufficient_scope, Bearer realm="api"',
147 },
148 }),
149 ).toBeNull();
150 });
151
152 it("only reads the Bearer scheme's parameters", () => {
153 expect(
154 detectInsufficientScope({
155 headers: { "www-authenticate": 'Basic realm="example", error=insufficient_scope' },
156 }),
157 ).toBeNull();
158 // Multi-challenge: params after the NEXT scheme are not Bearer's.
159 expect(
160 detectInsufficientScope({
161 headers: {
162 "www-authenticate": 'Bearer realm="api", Basic error=insufficient_scope',
163 },
164 }),
165 ).toBeNull();
166 });
167
168 it("evaluates repeated Bearer challenges independently", () => {
169 // The second Bearer challenge carries the signal; the first must not
170 // shadow it (params are per challenge, not first-wins across the header).
171 expect(
172 detectInsufficientScope({
173 headers: {
174 "www-authenticate":
175 "Bearer error=invalid_token, Basic realm=x, Bearer error=insufficient_scope",
176 },
177 }),
178 ).toEqual({ requiredScopes: [] });
179 // And a scope attr from an unrelated Bearer challenge does not leak into
180 // the one that matched.
181 expect(
182 detectInsufficientScope({
183 headers: {
184 "www-authenticate": 'Bearer scope="other.scope", Bearer error=insufficient_scope',
185 },
186 }),
187 ).toEqual({ requiredScopes: [] });
188 });
189
190 it("recognizes a new scheme only at a comma boundary", () => {
191 // Comma-less run-ons are malformed and must not fabricate a Bearer
192 // challenge out of a bare word.
193 expect(
194 detectInsufficientScope({
195 headers: { "www-authenticate": "Basic realm=x Bearer error=insufficient_scope" },
196 }),
197 ).toBeNull();
198 expect(
199 detectInsufficientScope({
200 headers: {
201 "www-authenticate": "Basic error_description=Proxy Bearer error=insufficient_scope",
202 },
203 }),
204 ).toBeNull();
205 });
206
207 it("steps over a token68 blob to a later legitimate Bearer challenge", () => {
208 expect(
209 detectInsufficientScope({
210 headers: {
211 "www-authenticate": "Negotiate abc/def==, Bearer error=insufficient_scope",
212 },
213 }),
214 ).toEqual({ requiredScopes: [] });
215 });
216
217 it("never classifies malformed headers", () => {
218 // Unterminated quoted-string.
219 expect(
220 detectInsufficientScope({
221 headers: { "www-authenticate": 'Bearer error="insufficient_scope' },
222 }),
223 ).toBeNull();
224 // Quoted value run into the next token with no separator.
225 expect(
226 detectInsufficientScope({
227 headers: { "www-authenticate": 'Basic realm="x"Bearer error=insufficient_scope' },
228 }),
229 ).toBeNull();
230 });
231
232 it("accepts BWS around the auth-param equals sign", () => {
233 for (const header of [
234 "Bearer error =insufficient_scope",
235 "Bearer error= insufficient_scope",
236 'Bearer error = "insufficient_scope"',
237 ]) {
238 expect(detectInsufficientScope({ headers: { "www-authenticate": header } })).toEqual({
239 requiredScopes: [],
240 });
241 }
242 });
243
244 it("rejects params attached to token68 or scheme-only challenges", () => {
245 for (const header of [
246 "Bearer a=, error=insufficient_scope",
247 "Bearer abc, error=insufficient_scope",
248 "Bearer, error=insufficient_scope",
249 'Bearer realm="x" error=insufficient_scope',
250 ]) {
251 expect(
252 detectInsufficientScope({ headers: { "www-authenticate": header } }),
253 header,
254 ).toBeNull();
255 }
256 });
257
258 it("accepts full HTTP token characters in schemes and param names", () => {
259 for (const header of [
260 "Bearer foo!=bar, error=insufficient_scope",
261 "Bearer x#=bar, error=insufficient_scope",
262 "Bearer x|=bar, error=insufficient_scope",
263 "Foo! realm=x, Bearer error=insufficient_scope",
264 ]) {
265 expect(detectInsufficientScope({ headers: { "www-authenticate": header } }), header).toEqual({
266 requiredScopes: [],
267 });
268 }
269 });
270
271 it("rejects empty values, non-token values, and duplicate param names", () => {
272 for (const header of [
273 "Bearer realm =, error=insufficient_scope",
274 "Bearer realm=;, error=insufficient_scope",
275 "Bearer error=insufficient_scope, error=invalid_token",
276 // `/` and `=` are token68-only; schemes, param names, and unquoted
277 // values are HTTP tokens.
278 "Bearer foo/bar=baz, error=insufficient_scope",
279 "Foo/Bar realm=x, Bearer error=insufficient_scope",
280 "Bearer realm=foo/bar, error=insufficient_scope",
281 "Bearer realm=foo=bar, error=insufficient_scope",
282 // `!` is token-only, not token68.
283 "Bearer abc!==, error=insufficient_scope",
284 ]) {
285 expect(
286 detectInsufficientScope({ headers: { "www-authenticate": header } }),
287 header,
288 ).toBeNull();
289 }
290 });
291
292 it("tolerates real-provider header quirks without weakening the signal params", () => {
293 // Stripe (live snapshot): unquoted resource_metadata URL.
294 expect(
295 detectInsufficientScope({
296 headers: {
297 "www-authenticate":
298 "Bearer resource_metadata=https://mcp.stripe.com/.well-known/oauth-protected-resource, error=insufficient_scope",
299 },
300 }),
301 ).toEqual({ requiredScopes: [] });
302 // Sentry (live snapshot): duplicated resource_metadata param.
303 expect(
304 detectInsufficientScope({
305 headers: {
306 "www-authenticate":
307 'Bearer realm="OAuth", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource", error="insufficient_scope", resource_metadata="https://mcp.sentry.dev/.well-known/oauth-protected-resource"',
308 },
309 }),
310 ).toEqual({ requiredScopes: [] });
311 // But a duplicated SIGNAL param still fails closed.
312 expect(
313 detectInsufficientScope({
314 headers: {
315 "www-authenticate": "Bearer error=insufficient_scope, error=invalid_token",
316 },
317 }),
318 ).toBeNull();
319 });
320
321 it("accepts comma-separated params within one Bearer challenge", () => {
322 expect(
323 detectInsufficientScope({
324 headers: {
325 "www-authenticate": 'Bearer realm="api", error="insufficient_scope", scope="a.b"',
326 },
327 }),
328 ).toEqual({ requiredScopes: ["a.b"] });
329 });
330
331 it("accepts an unquoted RFC 6750 challenge value", () => {
332 expect(
333 detectInsufficientScope({
334 headers: { "www-authenticate": "Bearer error=insufficient_scope" },
335 }),
336 ).toEqual({ requiredScopes: [] });
337 });
338
339 it("returns null for an ordinary 403 body", () => {
340 expect(
341 detectInsufficientScope({
342 body: { error: { status: "PERMISSION_DENIED", message: "Caller lacks permission" } },
343 headers: { "www-authenticate": 'Bearer realm="example", error="invalid_token"' },
344 }),
345 ).toBeNull();
346 });
347
348 it("returns null for empty input", () => {
349 expect(detectInsufficientScope({})).toBeNull();
350 });
351});
352
353describe("authToolFailure recovery for oauth_scope_insufficient", () => {
354 const recoveryOf = (result: ReturnType<typeof authToolFailure>) => {
355 const details = (result as { error: { details: { recovery: Record<string, string> } } }).error
356 .details;
357 return details.recovery;
358 };
359
360 it("omits the oauth.start hint, which would re-run the identical grant", () => {
361 const recovery = recoveryOf(
362 authToolFailure({ code: "oauth_scope_insufficient", message: "scope shortfall" }),
363 );
364 expect(recovery.startOAuthTool).toBeUndefined();
365 expect(recovery.oauthInstructions).toBeUndefined();
366 expect(recovery.scopeInstructions).toContain("does not cover the scope");
367 expect(recovery.listConnectionsTool).toBe("executor.coreTools.connections.list");
368 });
369
370 it("keeps the full recovery block for connection_rejected", () => {
371 const recovery = recoveryOf(
372 authToolFailure({ code: "connection_rejected", message: "rejected" }),
373 );
374 expect(recovery.startOAuthTool).toBe("executor.coreTools.oauth.start");
375 expect(recovery.oauthInstructions).toBeDefined();
376 });
377});
packages/core/sdk/src/insufficient-scope.ts+274−0
@@ -0,0 +1,274 @@
1// Detecting a scope-insufficient upstream rejection. A 403 that means "this
2// grant does not cover that operation" is unfixable by re-running the same
3// OAuth flow, so it must not be labelled connection_rejected (whose recovery
4// tells the agent to re-authenticate). Providers signal it three ways:
5//
6// - RFC 6750: a `WWW-Authenticate: Bearer error="insufficient_scope"
7// scope="..."` challenge header (the `scope` attribute names what the
8// request needed).
9// - Google (google.rpc.ErrorInfo): a JSON body whose error details carry
10// `reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT"`.
11// - Generic OAuth JSON: `{ "error": "insufficient_scope" }` (RFC 6750 §3.1
12// as a body, which some providers emit instead of the header).
13//
14// Detection is deliberately strict — a false positive strips the
15// re-authenticate recovery from a 403 that re-auth WOULD fix:
16//
17// - Challenge headers are PARSED into parameters (quoted values consumed
18// whole), so `error=insufficient_scope` inside another parameter's
19// quoted value never counts; only the `error` parameter's own exact
20// value does.
21// - Structured bodies match only exact `error` / `reason` field values.
22// - Text bodies are JSON-parsed first and then inspected structurally;
23// non-JSON text never classifies (prose mentioning the tokens misses).
24//
25// A miss is benign: the failure stays on the existing classification.
26
27export type InsufficientScopeDetection = {
28 /** Scopes the upstream named as required, when it named any (RFC 6750's
29 * `scope` attribute). Empty when the provider only signalled the class of
30 * failure (Google's ErrorInfo does not carry the missing scope). */
31 readonly requiredScopes: readonly string[];
32};
33
34const MAX_DEPTH = 8;
35
36const isRecord = (value: unknown): value is Record<string, unknown> =>
37 typeof value === "object" && value !== null && !Array.isArray(value);
38
39/** Parser for the whole WWW-Authenticate header per RFC 7235 §2.1: a
40 * comma-separated #list of challenges, each
41 * `scheme [ 1*SP ( token68 / #auth-param ) ]`. Implemented as an explicit
42 * per-challenge state machine so params can never attach across challenge
43 * boundaries or to a token68 credential:
44 *
45 * - "scheme": just read a scheme; accepts a token68 OR a first auth-param
46 * (space-separated, no comma).
47 * - "params": accepts further auth-params ONLY after a comma.
48 * - "token68": accepts nothing; any trailing param is malformed.
49 *
50 * Auth-params allow BWS around `=` (RFC 7230). Quoted-strings consume
51 * quoted-pairs whole and must end at a separator. ANY malformed shape —
52 * scheme-less params, space-separated param runs, params after token68,
53 * stray quotes/bytes — returns null and never classifies: a miss is benign,
54 * a false positive strips a valid recovery path. */
55type Challenge = { readonly scheme: string; readonly params: Map<string, string> };
56
57// HTTP `token` alphabet (RFC 7230 §3.2.6) — schemes and auth-param names.
58const TOKEN_RE = /[A-Za-z0-9!#$%&'*+.^_`|~-]/;
59// token68 alphabet (RFC 7235 §2.1), padding `=` handled separately.
60const TOKEN68_RE = /[A-Za-z0-9._~+/-]/;
61// Superset used by the word reader; each use site validates against the
62// context-specific alphabet after reading.
63const WORD_RE = /[A-Za-z0-9!#$%&'*+.^_`|~/-]/;
64
65const isToken = (word: string): boolean => [...word].every((ch) => TOKEN_RE.test(ch));
66// Unquoted URL values some providers emit (scheme://host/path?query): URI
67// characters per RFC 3986, no whitespace/comma/quotes.
68const isUrlish = (word: string): boolean => /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s,"]+$/.test(word);
69const isToken68 = (word: string): boolean => [...word].every((ch) => TOKEN68_RE.test(ch));
70
71const parseChallenges = (header: string): readonly Challenge[] | null => {
72 const len = header.length;
73 const challenges: Challenge[] = [];
74 let current: Challenge | null = null;
75 let state: "boundary" | "scheme" | "token68" | "params" = "boundary";
76 let sawComma = true; // header start counts as a list boundary
77 let i = 0;
78
79 const readWord = (): string => {
80 const start = i;
81 while (i < len && WORD_RE.test(header[i]!)) i += 1;
82 return header.slice(start, i);
83 };
84 // Returns null on an unterminated quote or a quote run into the next token.
85 const readQuoted = (): string | null => {
86 let value = "";
87 i += 1; // opening quote
88 while (i < len) {
89 const ch = header[i]!;
90 if (ch === '"') {
91 i += 1;
92 return i >= len || /[\s,]/.test(header[i]!) ? value : null;
93 }
94 if (ch === "\\" && i + 1 < len) {
95 value += header[i + 1];
96 i += 2;
97 continue;
98 }
99 value += ch;
100 i += 1;
101 }
102 return null; // unterminated
103 };
104
105 while (i < len) {
106 while (i < len && /\s/.test(header[i]!)) i += 1;
107 if (i >= len) break;
108 if (header[i] === ",") {
109 sawComma = true;
110 i += 1;
111 continue;
112 }
113 if (!WORD_RE.test(header[i]!)) return null; // stray quote/byte: malformed
114 const word = readWord();
115 // Look ahead through BWS for `=` to classify the word.
116 let j = i;
117 while (j < len && /[ \t]/.test(header[j]!)) j += 1;
118 const isPaddingRun = (() => {
119 // An `=`-run directly on the word (no BWS) that is followed (after
120 // optional whitespace) by a comma or the end of input is token68
121 // padding. An `=` followed by a value — even across BWS — is an
122 // auth-param (RFC 7230 allows BWS around `=`).
123 if (header[i] !== "=") return false;
124 let k = i;
125 while (k < len && header[k] === "=") k += 1;
126 while (k < len && /[ \t]/.test(header[k]!)) k += 1;
127 return k >= len || header[k] === ",";
128 })();
129
130 if (isPaddingRun) {
131 // token68 with padding — only legal directly after a scheme.
132 if (state !== "scheme" || sawComma) return null;
133 if (!isToken68(word)) return null;
134 while (i < len && header[i] === "=") i += 1;
135 state = "token68";
136 sawComma = false;
137 continue;
138 }
139
140 if (header[j] === "=") {
141 // auth-param: `word BWS = BWS value`.
142 if (!isToken(word)) return null; // param name must be an HTTP token
143 if (current === null) return null; // scheme-less param
144 if (state === "token68") return null; // params after token68
145 if (state === "scheme" && sawComma) return null; // "Bearer, a=b"
146 if (state === "params" && !sawComma) return null; // space-separated run
147 i = j + 1;
148 while (i < len && /[ \t]/.test(header[i]!)) i += 1;
149 let value: string;
150 if (header[i] === '"') {
151 const quoted = readQuoted();
152 if (quoted === null) return null;
153 value = quoted;
154 } else {
155 const start = i;
156 while (i < len && !/[\s,]/.test(header[i]!)) i += 1;
157 value = header.slice(start, i);
158 // An unquoted value must be an HTTP token (`realm =,` / `realm=;`
159 // are malformed) — EXCEPT that real providers emit unquoted URLs for
160 // resource_metadata (observed live: Stripe), so URL-safe characters
161 // are tolerated there. The signal params (`error`, `scope`) stay
162 // token-strict.
163 if (value.length === 0) return null;
164 const lowerName = word.toLowerCase();
165 if (!isToken(value) && !(lowerName === "resource_metadata" && isUrlish(value))) {
166 return null;
167 }
168 }
169 // Duplicate SIGNAL params (`error`, `scope`) within one challenge mean
170 // a header playing games — never classify. Other duplicates are
171 // tolerated first-wins: real providers emit them (observed live:
172 // Sentry duplicates resource_metadata).
173 const key = word.toLowerCase();
174 if (current.params.has(key)) {
175 if (key === "error" || key === "scope") return null;
176 } else {
177 current.params.set(key, value);
178 }
179 state = "params";
180 sawComma = false;
181 continue;
182 }
183
184 // Bare word: a new challenge's scheme at a list boundary, a token68
185 // directly after a scheme, malformed anywhere else.
186 if (sawComma) {
187 if (!isToken(word)) return null; // a scheme must be an HTTP token
188 current = { scheme: word.toLowerCase(), params: new Map() };
189 challenges.push(current);
190 state = "scheme";
191 sawComma = false;
192 continue;
193 }
194 if (state === "scheme") {
195 if (!isToken68(word)) return null;
196 state = "token68";
197 continue;
198 }
199 return null;
200 }
201
202 return challenges;
203};
204
205const detectFromChallenge = (header: string): InsufficientScopeDetection | null => {
206 const challenges = parseChallenges(header);
207 if (challenges === null) return null;
208 for (const challenge of challenges) {
209 if (challenge.scheme !== "bearer") continue;
210 if (challenge.params.get("error") !== "insufficient_scope") continue;
211 const scope = challenge.params.get("scope");
212 return { requiredScopes: scope ? scope.split(/\s+/).filter(Boolean) : [] };
213 }
214 return null;
215};
216
217const detectFromStructured = (body: unknown, depth: number): boolean => {
218 if (depth > MAX_DEPTH) return false;
219 if (Array.isArray(body)) {
220 return body.some((item) => detectFromStructured(item, depth + 1));
221 }
222 if (!isRecord(body)) return false;
223 if (body.error === "insufficient_scope") return true;
224 if (body.reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT") return true;
225 // Recurse into records/arrays only — nested strings are data (scope lists,
226 // docs prose), never the error envelope itself.
227 return Object.values(body).some(
228 (value) => (isRecord(value) || Array.isArray(value)) && detectFromStructured(value, depth + 1),
229 );
230};
231
232const parseJsonSafe = (text: string): unknown => {
233 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying an untrusted upstream error body; a parse failure just means "not a JSON body", never an error path
234 try {
235 // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body classification; the parsed value is only structurally inspected, never decoded into domain types
236 return JSON.parse(text) as unknown;
237 } catch {
238 return undefined;
239 }
240};
241
242const detectFromBody = (body: unknown): boolean => {
243 if (typeof body === "string") {
244 const parsed = parseJsonSafe(body);
245 return parsed !== undefined && detectFromStructured(parsed, 0);
246 }
247 return detectFromStructured(body, 0);
248};
249
250/** Strict detection over a text fragment whose JSON body survives only as a
251 * suffix of a transport error message (the MCP SDK embeds the failed POST's
252 * body after a fixed prefix). The JSON is isolated from the first `{` or `[`
253 * and parsed; prose without a parseable JSON envelope never classifies. */
254export const insufficientScopeFromEmbeddedJson = (text: string): boolean => {
255 const start = text.search(/[{[]/);
256 if (start < 0) return false;
257 const parsed = parseJsonSafe(text.slice(start));
258 return parsed !== undefined && detectFromStructured(parsed, 0);
259};
260
261/** Inspect an upstream 401/403's body and headers for a scope-insufficiency
262 * signal. Returns `null` when nothing matches, so callers fall through to
263 * their existing classification. */
264export const detectInsufficientScope = (input: {
265 readonly body?: unknown;
266 readonly headers?: Record<string, string>;
267}): InsufficientScopeDetection | null => {
268 for (const [name, value] of Object.entries(input.headers ?? {})) {
269 if (name.toLowerCase() !== "www-authenticate") continue;
270 const detected = detectFromChallenge(value);
271 if (detected) return detected;
272 }
273 return detectFromBody(input.body) ? { requiredScopes: [] } : null;
274};
packages/plugins/graphql/src/sdk/invoke.ts+2−0
@@ -139,6 +139,8 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( · 138 unmodified lines
139 status,139 status,
140 data: gqlBody?.data ?? null,140 data: gqlBody?.data ?? null,
141 errors: hasErrors ? gqlBody!.errors : null,141 errors: hasErrors ? gqlBody!.errors : null,
142 body,
143 headers: { ...response.headers },
142 });144 });
143});145});
144 146
packages/plugins/graphql/src/sdk/plugin.test.ts+134−0
@@ -480,6 +480,140 @@ describe("graphqlPlugin real protocol server", () => { · 479 unmodified lines
480 }),480 }),
481 );481 );
482 482
483 it.effect("classifies a 401 as connection_rejected even when the body is GraphQL-shaped", () =>
484 Effect.gen(function* () {
485 // An auth gateway may answer with a GraphQL-style errors array AND a
486 // 401 status. The transport status is authoritative: the failure must
487 // reach the agent as a credential problem, not as graphql_errors.
488 const server = yield* serveTestHttpApp((request) =>
489 Effect.gen(function* () {
490 const webRequest = yield* HttpServerRequest.toWeb(request);
491 const body = yield* Effect.promise(() => webRequest.text());
492 if (body.includes("__schema")) {
493 return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
494 }
495 return HttpServerResponse.jsonUnsafe(
496 { errors: [{ message: "Not authenticated" }] },
497 { status: 401 },
498 );
499 }),
500 );
501 const executor = yield* makeExecutor();
502
503 yield* executor.graphql.addIntegration({
504 endpoint: server.url("/graphql"),
505 slug: "auth_wall_graph",
506 });
507 yield* createOrgConnection(executor, {
508 integration: "auth_wall_graph",
509 name: "main",
510 template: "none",
511 value: "unused",
512 });
513
514 const result = yield* executor.execute(toolAddr("auth_wall_graph", "main", "query.hello"), {
515 name: "Ada",
516 });
517
518 expect(result).toMatchObject({
519 ok: false,
520 error: { code: "connection_rejected", status: 401 },
521 });
522 }),
523 );
524
525 it.effect(
526 "classifies a scope-insufficient 403 (OAuth error body) as oauth_scope_insufficient",
527 () =>
528 Effect.gen(function* () {
529 // The 403 body is an OAuth error object, not GraphQL-shaped at all —
530 // exactly what an auth gateway in front of a GraphQL API returns.
531 const server = yield* serveTestHttpApp((request) =>
532 Effect.gen(function* () {
533 const webRequest = yield* HttpServerRequest.toWeb(request);
534 const body = yield* Effect.promise(() => webRequest.text());
535 if (body.includes("__schema")) {
536 return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
537 }
538 return HttpServerResponse.jsonUnsafe(
539 { error: "insufficient_scope", error_description: "needs repo scope" },
540 { status: 403 },
541 );
542 }),
543 );
544 const executor = yield* makeExecutor();
545
546 yield* executor.graphql.addIntegration({
547 endpoint: server.url("/graphql"),
548 slug: "scope_graph",
549 });
550 yield* createOrgConnection(executor, {
551 integration: "scope_graph",
552 name: "main",
553 template: "none",
554 value: "unused",
555 });
556
557 const result = yield* executor.execute(toolAddr("scope_graph", "main", "query.hello"), {
558 name: "Ada",
559 });
560
561 expect(result).toMatchObject({
562 ok: false,
563 error: { code: "oauth_scope_insufficient", status: 403 },
564 });
565 const recovery = (result as { error: { details: { recovery: Record<string, string> } } })
566 .error.details.recovery;
567 expect(
568 recovery.startOAuthTool,
569 "no oauth.start hint: re-running the identical grant cannot satisfy the scope",
570 ).toBeUndefined();
571 }),
572 );
573
574 it.effect(
575 "classifies a scope-insufficient 403 challenge header as oauth_scope_insufficient",
576 () =>
577 Effect.gen(function* () {
578 const server = yield* serveTestHttpApp((request) =>
579 Effect.gen(function* () {
580 const webRequest = yield* HttpServerRequest.toWeb(request);
581 const body = yield* Effect.promise(() => webRequest.text());
582 if (body.includes("__schema")) {
583 return HttpServerResponse.jsonUnsafe({ data: introspectionResult });
584 }
585 return HttpServerResponse.text("forbidden", {
586 status: 403,
587 headers: {
588 "www-authenticate": 'Bearer realm="api", error="insufficient_scope"',
589 },
590 });
591 }),
592 );
593 const executor = yield* makeExecutor();
594
595 yield* executor.graphql.addIntegration({
596 endpoint: server.url("/graphql"),
597 slug: "scope_hdr_graph",
598 });
599 yield* createOrgConnection(executor, {
600 integration: "scope_hdr_graph",
601 name: "main",
602 template: "none",
603 value: "unused",
604 });
605
606 const result = yield* executor.execute(toolAddr("scope_hdr_graph", "main", "query.hello"), {
607 name: "Ada",
608 });
609
610 expect(result).toMatchObject({
611 ok: false,
612 error: { code: "oauth_scope_insufficient", status: 403 },
613 });
614 }),
615 );
616
483 it.effect("invokes OAuth-backed integrations with a rendered bearer token", () =>617 it.effect("invokes OAuth-backed integrations with a rendered bearer token", () =>
484 Effect.gen(function* () {618 Effect.gen(function* () {
485 const server = yield* serveGraphqlTestServer({619 const server = yield* serveGraphqlTestServer({
packages/plugins/graphql/src/sdk/plugin.ts+41−16
@@ -4,6 +4,7 @@ import { HttpClient } from "effect/unstable/http"; · 3 unmodified lines
4 4
5import {5import {
6 authToolFailure,6 authToolFailure,
7 detectInsufficientScope,
7 AuthTemplateSlug,8 AuthTemplateSlug,
8 definePlugin,9 definePlugin,
9 IntegrationAlreadyExistsError,10 IntegrationAlreadyExistsError,
@@ -1134,6 +1135,46 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 1124 unmodified lines
1134 httpClientLayer,1135 httpClientLayer,
1135 );1136 );
1136 1137
1138 // An HTTP auth wall is classified BEFORE the GraphQL-errors branch: a
1139 // 401/403 body is usually not GraphQL-shaped at all (an auth
1140 // gateway's OAuth error object), and even when it is, the transport
1141 // status is the authoritative signal — labelling it graphql_errors
1142 // would hide the credential problem from the agent entirely.
1143 if (result.status === 401 || result.status === 403) {
1144 // A scope-insufficient 403 is not fixable by re-authenticating
1145 // the same grant; give it its own code so the agent stops looping
1146 // through identical consent flows. Detection reads the RAW body and
1147 // response headers (RFC 6750 WWW-Authenticate challenge), not the
1148 // GraphQL projection.
1149 const insufficientScope =
1150 result.status === 403
1151 ? detectInsufficientScope({ body: result.body, headers: result.headers })
1152 : null;
1153 if (insufficientScope) {
1154 return authToolFailure({
1155 code: "oauth_scope_insufficient",
1156 status: result.status,
1157 message: `The connection for GraphQL integration "${integration}" is authorized, but its grant does not cover the scope this operation requires. Re-authenticating with the same grant will return the same error; reconnect with broader access.`,
1158 integration: { id: integration, scope: credential.owner },
1159 credential: { kind: "oauth", label: "Upstream authorization" },
1160 upstream: {
1161 status: result.status,
1162 details: { body: result.body },
1163 },
1164 });
1165 }
1166 return authToolFailure({
1167 code: "connection_rejected",
1168 status: result.status,
1169 message: `Upstream rejected credentials for GraphQL integration "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection before retrying this tool.`,
1170 integration: { id: integration, scope: credential.owner },
1171 credential: { kind: "upstream", label: "Upstream authorization" },
1172 upstream: {
1173 status: result.status,
1174 details: { body: result.body },
1175 },
1176 });
1177 }
1137 const errors = decodeGraphqlErrors(result.errors);1178 const errors = decodeGraphqlErrors(result.errors);
1138 if (errors !== undefined && errors.length > 0) {1179 if (errors !== undefined && errors.length > 0) {
1139 const firstMessage = extractGraphqlErrorMessage(errors);1180 const firstMessage = extractGraphqlErrorMessage(errors);
@@ -1144,22 +1185,6 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { · 4 unmodified lines
1144 });1185 });
1145 }1186 }
1146 if (result.status < 200 || result.status >= 300) {1187 if (result.status < 200 || result.status >= 300) {
1147 if (result.status === 401 || result.status === 403) {
1148 return authToolFailure({
1149 code: "connection_rejected",
1150 status: result.status,
1151 message: `Upstream rejected credentials for GraphQL integration "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection before retrying this tool.`,
1152 integration: { id: integration, scope: credential.owner },
1153 credential: { kind: "upstream", label: "Upstream authorization" },
1154 upstream: {
1155 status: result.status,
1156 details: {
1157 data: result.data,
1158 errors: result.errors,
1159 },
1160 },
1161 });
1162 }
1163 return ToolResult.fail({1188 return ToolResult.fail({
1164 code: "graphql_http_error",1189 code: "graphql_http_error",
1165 status: result.status,1190 status: result.status,
packages/plugins/graphql/src/sdk/types.ts+6−0
@@ -210,5 +210,11 @@ export const InvocationResult = Schema.Struct({ · 209 unmodified lines
210 status: Schema.Number,210 status: Schema.Number,
211 data: Schema.NullOr(Schema.Unknown),211 data: Schema.NullOr(Schema.Unknown),
212 errors: Schema.NullOr(Schema.Unknown),212 errors: Schema.NullOr(Schema.Unknown),
213 /** The raw response body (parsed JSON or text) and response headers: a
214 * non-2xx response is usually NOT GraphQL-shaped (an auth gateway's OAuth
215 * error object, an HTML page), so 401/403 classification needs what
216 * `data`/`errors` cannot carry. */
217 body: Schema.optional(Schema.Unknown),
218 headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
213});219});
214export type InvocationResult = typeof InvocationResult.Type;220export type InvocationResult = typeof InvocationResult.Type;
packages/plugins/mcp/src/sdk/connection.ts+40−2
@@ -16,8 +16,13 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"; · 15 unmodified lines
16// stdio branch of `createMcpConnector`.16// stdio branch of `createMcpConnector`.
17 17
18import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";18import type { McpRemoteIntegrationConfig, McpStdioIntegrationConfig } from "./types";
19import { McpConnectionError, McpOAuthReauthorizationRequired } from "./errors";19import {
20 McpConnectionError,
21 McpInsufficientScopeError,
22 McpOAuthReauthorizationRequired,
23} from "./errors";
20import { httpStatusFromCause } from "./http-status";24import { httpStatusFromCause } from "./http-status";
25import { detectInsufficientScope } from "@executor-js/sdk/core";
21 26
22// ---------------------------------------------------------------------------27// ---------------------------------------------------------------------------
23// Connection type28// Connection type
@@ -143,7 +148,30 @@ const fetchFromHttpClientLayer = ( · 119 unmodified lines
143 headers: responseHeaders,148 headers: responseHeaders,
144 });149 });
145 }).pipe(Effect.provide(httpClientLayer));150 }).pipe(Effect.provide(httpClientLayer));
146 const promise = Effect.runPromise(effect);151 // A 403 carrying an RFC 6750 insufficient_scope challenge is intercepted
152 // HERE, below the SDK: with an authProvider the SDK would consume the
153 // challenge and re-run auth ("upscoping"), which our static-token
154 // provider can only answer by demanding reauthorization — misclassifying
155 // an unfixable scope shortfall as oauth_reauth_required. Thrown as the
156 // tagged error from the fetch adapter (a true runtime edge: the SDK
157 // consumes promise rejections) so it reaches the invoke/connect catch
158 // sites verbatim.
159 const promise = Effect.runPromise(effect).then((response) => {
160 if (response.status === 403) {
161 const challenge = response.headers.get("www-authenticate");
162 if (
163 challenge !== null &&
164 detectInsufficientScope({ headers: { "www-authenticate": challenge } }) !== null
165 ) {
166 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Fetch-compatible adapter can only signal through a rejected promise
167 throw new McpInsufficientScopeError({
168 message:
169 "MCP server rejected the call: the OAuth grant does not cover the required scope",
170 });
171 }
172 }
173 return response;
174 });
147 if (!init?.signal) return promise;175 if (!init?.signal) return promise;
148 // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics176 // oxlint-disable-next-line executor/no-promise-reject -- boundary: Fetch-compatible adapter mirrors abort rejection semantics
149 if (init.signal.aborted) return Promise.reject(abortError(init.signal));177 if (init.signal.aborted) return Promise.reject(abortError(init.signal));
@@ -186,6 +214,16 @@ const connectionFailure = ( · 36 unmodified lines
186 if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) {214 if (Predicate.isTagged(cause, "McpOAuthReauthorizationRequired")) {
187 return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" });215 return new McpOAuthReauthorizationRequired({ message: "MCP OAuth re-authorization required" });
188 }216 }
217 if (Predicate.isTagged(cause, "McpInsufficientScopeError")) {
218 // Surfaced as a connection error with the 403 status; the invoke/connect
219 // catch sites detect the tag and classify as oauth_scope_insufficient.
220 return new McpConnectionError({
221 transport,
222 message: `${message} (HTTP 403: insufficient scope)`,
223 httpStatus: 403,
224 insufficientScope: true,
225 });
226 }
189 // Carry the handshake HTTP status structurally (and in the message for227 // Carry the handshake HTTP status structurally (and in the message for
190 // humans) so the liveness health check can classify a rejected credential228 // humans) so the liveness health check can classify a rejected credential
191 // as expired rather than a generic connection failure.229 // as expired rather than a generic connection failure.
packages/plugins/mcp/src/sdk/errors.ts+19−0
@@ -12,6 +12,10 @@ export class McpConnectionError extends Schema.TaggedErrorClass<McpConnectionErr · 11 unmodified lines
12 * transport surfaced one. Structural, so the liveness classifier and the12 * transport surfaced one. Structural, so the liveness classifier and the
13 * auto-transport fallback never string-match the message. */13 * auto-transport fallback never string-match the message. */
14 httpStatus: Schema.optional(Schema.Number),14 httpStatus: Schema.optional(Schema.Number),
15 /** The 403 carried an RFC 6750 insufficient_scope challenge (set by the
16 * connection layer when the fetch adapter intercepted one during the
17 * handshake). */
18 insufficientScope: Schema.optional(Schema.Boolean),
15 },19 },
16 { httpApiStatus: 400 },20 { httpApiStatus: 400 },
17) {}21) {}
@@ -37,6 +41,11 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ · 19 unmodified lines
37 /** The server rejected the call as an unknown tool (protocol error), which41 /** The server rejected the call as an unknown tool (protocol error), which
38 * means the persisted catalog has drifted from the server's live tool set. */42 * means the persisted catalog has drifted from the server's live tool set. */
39 readonly unknownTool?: boolean;43 readonly unknownTool?: boolean;
44 /** A 403 whose body named a scope shortfall (RFC 6750 insufficient_scope /
45 * Google's ACCESS_TOKEN_SCOPE_INSUFFICIENT): re-authenticating the same
46 * grant cannot fix it, so the failure must not be labelled
47 * connection_rejected. */
48 readonly insufficientScope?: boolean;
40}> {}49}> {}
41 50
42export class McpOAuthReauthorizationRequired extends Data.TaggedError(51export class McpOAuthReauthorizationRequired extends Data.TaggedError(
@@ -45,6 +54,16 @@ export class McpOAuthReauthorizationRequired extends Data.TaggedError( · 2 unmodified lines
45 readonly message: string;54 readonly message: string;
46}> {}55}> {}
47 56
57/** Thrown by the fetch adapter when a 403 carries an RFC 6750
58 * `error="insufficient_scope"` challenge. Raised BELOW the MCP SDK on
59 * purpose: with an authProvider present the SDK would otherwise consume the
60 * challenge and re-run auth ("upscoping"), which our static-token provider
61 * can only answer by demanding reauthorization — the exact loop the
62 * oauth_scope_insufficient classification exists to break. */
63export class McpInsufficientScopeError extends Data.TaggedError("McpInsufficientScopeError")<{
64 readonly message: string;
65}> {}
66
48export class McpOAuthError extends Schema.TaggedErrorClass<McpOAuthError>()(67export class McpOAuthError extends Schema.TaggedErrorClass<McpOAuthError>()(
49 "McpOAuthError",68 "McpOAuthError",
50 {69 {
packages/plugins/mcp/src/sdk/http-status.test.ts+54−0
@@ -0,0 +1,54 @@
1import { describe, expect, it } from "@effect/vitest";
2
3// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
4import { insufficientScopeFromCause } from "./http-status";
5
6// The MCP SDK surfaces a 403 two ways, and both must classify:
7// - no authProvider: the transport throws with the response body embedded
8// in the message ("Error POSTing to endpoint: <body>");
9// - with an authProvider (the production OAuth path): the StreamableHTTP
10// transport consumes the insufficient_scope challenge itself, retries
11// with the broader scope, and only when THAT fails throws the fixed
12// "Server returned 403 after trying upscoping" message.
13describe("insufficientScopeFromCause", () => {
14 it("detects the OAuth error body embedded in a transport message", () => {
15 expect(
16 insufficientScopeFromCause(
17 new Error(
18 'Error POSTing to endpoint: {"error":"insufficient_scope","error_description":"needs files.read"}',
19 ),
20 ),
21 ).toBe(true);
22 });
23
24 it("detects a Google ErrorInfo body embedded in a transport message", () => {
25 expect(
26 insufficientScopeFromCause(
27 new Error(
28 'Error POSTing to endpoint: {"error":{"details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',
29 ),
30 ),
31 ).toBe(true);
32 });
33
34 it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
35 expect(
36 insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
37 ).toBe(true);
38 });
39
40 it("ignores prose that merely mentions the tokens", () => {
41 expect(
42 insufficientScopeFromCause(
43 new Error(
44 "Error POSTing to endpoint: see the OAuth docs about insufficient_scope handling",
45 ),
46 ),
47 ).toBe(false);
48 });
49
50 it("ignores non-error causes", () => {
51 expect(insufficientScopeFromCause(undefined)).toBe(false);
52 expect(insufficientScopeFromCause("insufficient_scope")).toBe(false);
53 });
54});
packages/plugins/mcp/src/sdk/http-status.ts+21−0
@@ -8,6 +8,7 @@ · 7 unmodified lines
8 8
9import { Option, Schema } from "effect";9import { Option, Schema } from "effect";
10 10
11import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
11import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";12import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
12 13
13const SsePostErrorCause = Schema.Struct({ message: Schema.String });14const SsePostErrorCause = Schema.Struct({ message: Schema.String });
@@ -34,3 +35,23 @@ const statusFromStreamableHttpError = (cause: unknown): number | undefined => { · 20 unmodified lines
34 35
35export const httpStatusFromCause = (cause: unknown): number | undefined =>36export const httpStatusFromCause = (cause: unknown): number | undefined =>
36 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);37 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
38
39// The SDK embeds the upstream response text in the transport error message
40// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
41// survives for connections without an authProvider. For OAuth connections the
42// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:
43// it re-runs auth requesting the broader scope, and only when that upscoped
44// retry still 403s does it throw — with the fixed message matched below
45// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on
46// SDK bumps). Both paths mean the same thing: the grant does not cover the
47// operation, and re-running the identical flow cannot help. Strict matching
48// (exact serialized field forms via the shared core detector, or the SDK's
49// exact upscoping message) — a miss stays on the generic auth path.
50const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;
51
52export const insufficientScopeFromCause = (cause: unknown): boolean =>
53 Option.match(decodeSsePostErrorCause(cause), {
54 onNone: () => false,
55 onSome: ({ message }) =>
56 insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),
57 });
packages/plugins/mcp/src/sdk/invoke.ts+15−1
@@ -30,7 +30,7 @@ import { · 29 unmodified lines
30 30
31import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";31import { McpConnectionError, McpInvocationError, McpOAuthReauthorizationRequired } from "./errors";
32import type { McpConnection, McpConnector } from "./connection";32import type { McpConnection, McpConnector } from "./connection";
33import { httpStatusFromCause } from "./http-status";33import { httpStatusFromCause, insufficientScopeFromCause } from "./http-status";
34 34
35// ---------------------------------------------------------------------------35// ---------------------------------------------------------------------------
36// Helpers36// Helpers
@@ -172,12 +172,26 @@ const useConnection = ( · 135 unmodified lines
172 message: "MCP OAuth re-authorization required",172 message: "MCP OAuth re-authorization required",
173 });173 });
174 }174 }
175 // Raised by the fetch adapter when the 403 carried an RFC 6750
176 // insufficient_scope challenge (the authProvider path, where the SDK
177 // would otherwise consume the challenge before we could see it).
178 if (Predicate.isTagged(cause, "McpInsufficientScopeError")) {
179 return new McpInvocationError({
180 toolName,
181 message: `MCP tool call failed for ${toolName}`,
182 status: 403,
183 insufficientScope: true,
184 });
185 }
175 const status = httpStatusFromCause(cause);186 const status = httpStatusFromCause(cause);
176 return new McpInvocationError({187 return new McpInvocationError({
177 toolName,188 toolName,
178 message: `MCP tool call failed for ${toolName}`,189 message: `MCP tool call failed for ${toolName}`,
179 ...(status === undefined ? {} : { status }),190 ...(status === undefined ? {} : { status }),
180 ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),191 ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}),
192 ...(status === 403 && insufficientScopeFromCause(cause)
193 ? { insufficientScope: true }
194 : {}),
181 });195 });
182 },196 },
183 }).pipe(197 }).pipe(
packages/plugins/mcp/src/sdk/plugin.test.ts+105−3
@@ -119,7 +119,14 @@ const jsonRpcErrorCallTool = · 118 unmodified lines
119 error: { code, message: "application-level do-not-leak" },119 error: { code, message: "application-level do-not-leak" },
120 });120 });
121 121
122const seedCallToolExecutor = (input: { slug: string; callTool: CallToolResponder }) =>122const seedCallToolExecutor = (input: {
123 slug: string;
124 callTool: CallToolResponder;
125 /** Seed an oauth2-templated connection with a static access token, so the
126 * transport gets a real authProvider — the production OAuth path where the
127 * SDK's own challenge handling would otherwise swallow scope signals. */
128 oauth?: boolean;
129}) =>
123 Effect.acquireRelease(130 Effect.acquireRelease(
124 Effect.gen(function* () {131 Effect.gen(function* () {
125 const server = yield* serveCallToolServer(input.callTool);132 const server = yield* serveCallToolServer(input.callTool);
@@ -133,13 +140,14 @@ const seedCallToolExecutor = (input: { slug: string; callTool: CallToolResponder · 7 unmodified lines
133 endpoint: server.url("/mcp"),140 endpoint: server.url("/mcp"),
134 slug: input.slug,141 slug: input.slug,
135 remoteTransport: "streamable-http",142 remoteTransport: "streamable-http",
143 ...(input.oauth ? { auth: { kind: "oauth2" as const } } : {}),
136 });144 });
137 yield* executor.connections.create({145 yield* executor.connections.create({
138 owner: "org",146 owner: "org",
139 name: ConnectionName.make("main"),147 name: ConnectionName.make("main"),
140 integration: IntegrationSlug.make(input.slug),148 integration: IntegrationSlug.make(input.slug),
141 template: TEMPLATE,149 template: input.oauth ? AuthTemplateSlug.make("oauth2") : TEMPLATE,
142 value: "",150 value: input.oauth ? "static-access-token" : "",
143 });151 });
144 152
145 return {153 return {
@@ -643,6 +651,100 @@ describe("mcpPlugin", () => { · 497 unmodified lines
643 );651 );
644 }652 }
645 653
654 it.effect(
655 "classifies a scope-insufficient 403 as oauth_scope_insufficient, not connection_rejected",
656 () =>
657 Effect.scoped(
658 Effect.gen(function* () {
659 const slug = "call_status_scope";
660 const { executor, toolAddress } = yield* seedCallToolExecutor({
661 slug,
662 // RFC 6750 insufficient_scope in the response body: re-running the
663 // same grant cannot fix this, so the failure must not carry the
664 // re-authenticate recovery (whose oauth.start hint would loop).
665 callTool: () =>
666 HttpServerResponse.text(
667 '{"error":"insufficient_scope","error_description":"do-not-leak: needs files.read"}',
668 { status: 403 },
669 ),
670 });
671
672 const result = yield* executor.execute(toolAddress, {}, { onElicitation: "accept-all" });
673
674 expect(result).toMatchObject({
675 ok: false,
676 error: {
677 code: "oauth_scope_insufficient",
678 status: 403,
679 retryable: false,
680 details: {
681 category: "authentication",
682 integration: { id: slug },
683 credential: { kind: "oauth", label: "main" },
684 upstream: { status: 403 },
685 },
686 },
687 });
688
689 const failure = result as {
690 readonly ok: false;
691 readonly error: {
692 readonly message: string;
693 readonly details: { readonly recovery: Record<string, string> };
694 };
695 };
696 expect(failure.error.message).not.toContain("do-not-leak");
697 expect(
698 failure.error.details.recovery.startOAuthTool,
699 "no oauth.start hint: re-running the identical grant cannot satisfy the scope",
700 ).toBeUndefined();
701 expect(failure.error.details.recovery.scopeInstructions).toBeDefined();
702 }),
703 ),
704 );
705
706 it.effect(
707 "classifies a challenge-header scope 403 on an OAuth connection as oauth_scope_insufficient",
708 () =>
709 Effect.scoped(
710 Effect.gen(function* () {
711 // The production path: an oauth2-templated connection gives the
712 // transport an authProvider, and the MCP SDK reacts to an RFC 6750
713 // insufficient_scope challenge by re-running auth itself
714 // ("upscoping") — which our static-token provider can only answer by
715 // demanding reauthorization. The fetch adapter intercepts the
716 // challenge below the SDK, so the failure classifies as the scope
717 // shortfall it is instead of oauth_reauth_required.
718 const slug = "call_status_oauth_scope";
719 const { executor, toolAddress } = yield* seedCallToolExecutor({
720 slug,
721 oauth: true,
722 callTool: () =>
723 HttpServerResponse.text("do-not-leak: forbidden", {
724 status: 403,
725 headers: {
726 "www-authenticate":
727 'Bearer realm="mcp", error="insufficient_scope", scope="files.read"',
728 },
729 }),
730 });
731
732 const result = yield* executor.execute(toolAddress, {}, { onElicitation: "accept-all" });
733
734 expect(result).toMatchObject({
735 ok: false,
736 error: {
737 code: "oauth_scope_insufficient",
738 status: 403,
739 details: { category: "authentication" },
740 },
741 });
742 const failure = result as { readonly error: { readonly message: string } };
743 expect(failure.error.message).not.toContain("do-not-leak");
744 }),
745 ),
746 );
747
646 it.effect("does not classify non-auth tools/call HTTP failures as auth failures", () =>748 it.effect("does not classify non-auth tools/call HTTP failures as auth failures", () =>
647 Effect.scoped(749 Effect.scoped(
648 Effect.gen(function* () {750 Effect.gen(function* () {
packages/plugins/mcp/src/sdk/plugin.ts+19−2
@@ -292,8 +292,22 @@ const mcpInvocationAuthFailure = (input: { · 291 unmodified lines
292 readonly status: 401 | 403;292 readonly status: 401 | 403;
293 readonly integration: string;293 readonly integration: string;
294 readonly connection: string;294 readonly connection: string;
295}) =>295 readonly insufficientScope?: boolean;
296 authToolFailure({296}) => {
297 // A 403 that named a scope shortfall is unfixable by re-running the same
298 // grant, so it carries its own code (and recovery without the oauth.start
299 // hint) instead of the re-authenticate loop.
300 if (input.status === 403 && input.insufficientScope === true) {
301 return authToolFailure({
302 code: "oauth_scope_insufficient",
303 message: `MCP server rejected connection "${input.connection}" with HTTP 403: the connection's OAuth grant does not cover the scope this tool requires. Re-authenticating with the same grant will return the same error; reconnect with broader access.`,
304 integration: { id: input.integration },
305 credential: { kind: "oauth", label: input.connection },
306 status: input.status,
307 upstream: { status: input.status },
308 });
309 }
310 return authToolFailure({
297 code: "connection_rejected",311 code: "connection_rejected",
298 message:312 message:
299 input.status === 403313 input.status === 403
@@ -304,6 +318,7 @@ const mcpInvocationAuthFailure = (input: { · 4 unmodified lines
304 status: input.status,318 status: input.status,
305 upstream: { status: input.status },319 upstream: { status: input.status },
306 });320 });
321};
307 322
308const mcpInvocationOAuthReauthFailure = (input: {323const mcpInvocationOAuthReauthFailure = (input: {
309 readonly integration: string;324 readonly integration: string;
@@ -1328,6 +1343,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1018 unmodified lines
1328 status: error.httpStatus,1343 status: error.httpStatus,
1329 integration: String(credential.integration),1344 integration: String(credential.integration),
1330 connection: String(credential.connection),1345 connection: String(credential.connection),
1346 ...(error.insufficientScope === true ? { insufficientScope: true } : {}),
1331 }),1347 }),
1332 );1348 );
1333 }1349 }
@@ -1347,6 +1363,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 13 unmodified lines
1347 status: error.status,1363 status: error.status,
1348 integration: String(credential.integration),1364 integration: String(credential.integration),
1349 connection: String(credential.connection),1365 connection: String(credential.connection),
1366 ...(error.insufficientScope === true ? { insufficientScope: true } : {}),
1350 }),1367 }),
1351 );1368 );
1352 }1369 }
packages/plugins/openapi/src/sdk/backing.ts+23−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 ToolResult,8 ToolResult,
9 authToolFailure,9 authToolFailure,
10 classifyHttpStatus,10 classifyHttpStatus,
11 detectInsufficientScope,
11 sortHealthCheckCandidatesByIdentity,12 sortHealthCheckCandidatesByIdentity,
12 extractIdentity,13 extractIdentity,
13 extractResponseFields,14 extractResponseFields,
@@ -722,6 +723,28 @@ export const invokeOpenApiBackedTool = (input: { · 708 unmodified lines
722 const ok = result.status >= 200 && result.status < 300;723 const ok = result.status >= 200 && result.status < 300;
723 if (!ok) {724 if (!ok) {
724 if (result.status === 401 || result.status === 403) {725 if (result.status === 401 || result.status === 403) {
726 // A 403 naming a scope shortfall (RFC 6750 insufficient_scope,
727 // Google's ACCESS_TOKEN_SCOPE_INSUFFICIENT) cannot be fixed by
728 // re-running the same grant, so it gets its own code and recovery
729 // guidance instead of the re-authenticate loop.
730 const insufficientScope =
731 result.status === 403
732 ? detectInsufficientScope({ body: result.error, headers: result.headers })
733 : null;
734 if (insufficientScope) {
735 const required = insufficientScope.requiredScopes;
736 return openApiAuthToolFailure({
737 code: "oauth_scope_insufficient",
738 status: result.status,
739 message: `The connection "${input.credential.connection}" for "${integration}" is authorized, but its grant does not cover the scope this operation requires${required.length > 0 ? ` (${required.join(" ")})` : ""}. Re-authenticating with the same grant will return the same error; reconnect with broader access.`,
740 owner: input.credential.owner,
741 integration,
742 connection: String(input.credential.connection),
743 credentialKind: "oauth",
744 credentialLabel: "Upstream authorization",
745 details: result.error,
746 });
747 }
725 return openApiAuthToolFailure({748 return openApiAuthToolFailure({
726 code: "connection_rejected",749 code: "connection_rejected",
727 status: result.status,750 status: result.status,
packages/plugins/openapi/src/sdk/upstream-failures.test.ts+103−0
@@ -219,6 +219,109 @@ describe("OpenAPI upstream failure modes", () => { · 218 unmodified lines
219 }),219 }),
220 );220 );
221 221
222 // A 403 that names a scope shortfall is unfixable by re-running the same
223 // grant: it must NOT be connection_rejected (whose recovery tells the agent
224 // to oauth.start the identical grant and loop on the identical 403).
225 it.effect(
226 "scope-insufficient 403 (Google ErrorInfo) is classified as oauth_scope_insufficient",
227 () =>
228 Effect.gen(function* () {
229 const server = yield* startScriptedServer(() => ({
230 status: 403,
231 headers: { "content-type": "application/json" },
232 body: JSON.stringify({
233 error: {
234 code: 403,
235 message: "Request had insufficient authentication scopes.",
236 status: "PERMISSION_DENIED",
237 details: [
238 {
239 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
240 reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT",
241 domain: "googleapis.com",
242 metadata: { service: "drive.googleapis.com" },
243 },
244 ],
245 },
246 }),
247 }));
248 const { executor, address } = yield* buildExecutorForOpenApiServer(server);
249
250 const result = yield* executor.execute(address, {});
251
252 expect(result).toMatchObject({
253 ok: false,
254 error: {
255 code: "oauth_scope_insufficient",
256 status: 403,
257 message: expect.stringContaining("does not cover the scope"),
258 details: {
259 category: "authentication",
260 upstream: { status: 403 },
261 },
262 },
263 });
264 const recovery = (
265 result as {
266 error: { details: { recovery: Record<string, string> } };
267 }
268 ).error.details.recovery;
269 expect(
270 recovery.startOAuthTool,
271 "no oauth.start hint: re-running the identical grant cannot satisfy the scope",
272 ).toBeUndefined();
273 expect(recovery.scopeInstructions).toBeDefined();
274 }),
275 );
276
277 it.effect(
278 "scope-insufficient 403 (RFC 6750 WWW-Authenticate challenge) is classified as oauth_scope_insufficient",
279 () =>
280 Effect.gen(function* () {
281 const server = yield* startScriptedServer(() => ({
282 status: 403,
283 headers: {
284 "content-type": "application/json",
285 "www-authenticate":
286 'Bearer realm="api", error="insufficient_scope", scope="files.read"',
287 },
288 body: '{"message":"forbidden"}',
289 }));
290 const { executor, address } = yield* buildExecutorForOpenApiServer(server);
291
292 const result = yield* executor.execute(address, {});
293
294 expect(result).toMatchObject({
295 ok: false,
296 error: {
297 code: "oauth_scope_insufficient",
298 status: 403,
299 // The challenge names the missing scope; the message carries it so
300 // the agent can tell the user exactly what to grant.
301 message: expect.stringContaining("files.read"),
302 },
303 });
304 }),
305 );
306
307 it.effect("ordinary 403 without a scope signal stays connection_rejected", () =>
308 Effect.gen(function* () {
309 const server = yield* startScriptedServer(() => ({
310 status: 403,
311 headers: { "content-type": "application/json" },
312 body: '{"error":{"status":"PERMISSION_DENIED","message":"Caller lacks permission"}}',
313 }));
314 const { executor, address } = yield* buildExecutorForOpenApiServer(server);
315
316 const result = yield* executor.execute(address, {});
317
318 expect(result).toMatchObject({
319 ok: false,
320 error: { code: "connection_rejected", status: 403 },
321 });
322 }),
323 );
324
222 it.effect("upstream returns malformed JSON despite Content-Type: application/json", () =>325 it.effect("upstream returns malformed JSON despite Content-Type: application/json", () =>
223 Effect.gen(function* () {326 Effect.gen(function* () {
224 const server = yield* startScriptedServer(() => ({327 const server = yield* startScriptedServer(() => ({