From 84e9839f7e48897691882d1e7beceff28a0b1d05 Mon Sep 17 00:00:00 2001 From: MarioCadenas Date: Wed, 7 Oct 2026 12:42:57 +0200 Subject: [PATCH] feat: add actionable hint when an OBO agent tool lacks its scope Plugin-toolkit and MCP agent tools run on behalf of the user. In an app scaffolded as service-principal with no user_api_scopes, the user's token lacks the resource scope and the platform rejects the call with a raw "does not have required scopes: sql" message that never says how to fix it. Rewrap that specific failure into an actionable hint naming the plugin, the missing scope, and user_api_scopes in databricks.yml, keeping the original error as the cause. Only the OBO sources (toolkit, mcp) are rewrapped; function tools run as the service principal and are left untouched. Co-authored-by: Isaac Signed-off-by: MarioCadenas --- .../agents/tests/dispatch-tool-call.test.ts | 121 ++++++++++++++++++ .../src/plugins/agents/tool-dispatch.ts | 47 ++++++- 2 files changed, 165 insertions(+), 3 deletions(-) diff --git a/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts index 9fa979843..5fa1c3d7e 100644 --- a/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts +++ b/packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts @@ -424,6 +424,127 @@ describe("dispatchToolCall — toolkit timeout plumbing", () => { }); }); +describe("dispatchToolCall — missing-OBO-scope hint", () => { + /** + * Plugin-toolkit (and MCP) agent tools run on behalf of the user. In an + * app scaffolded as service-principal with no `user_api_scopes`, the user + * token lacks the resource scope and the platform rejects the call with a + * raw "does not have required scopes: sql" message. `dispatchToolCall` + * rewraps that into an actionable hint naming the plugin, the scope, and + * `user_api_scopes` — without silently falling back to the SP. + */ + const scopeError = () => + new Error( + "Statement failed: Provided OAuth token does not have required scopes: sql [ReqId: abc-123]", + ); + + const toolkitIndex = () => + new Map([ + [ + "analytics.query", + { + source: "toolkit", + pluginName: "analytics", + localName: "query", + def: { + name: "analytics.query", + description: "sql", + parameters: { type: "object" }, + }, + }, + ], + ]); + + test("rewraps a toolkit scope rejection with an actionable user_api_scopes hint", async () => { + const plugin = new AgentsPlugin({}); + const { runState } = makeRunState(plugin); + + const mock = createTestPluginContext({ + analytics: { + query: () => Promise.reject(scopeError()), + }, + }); + await mock.attach(plugin); + + const caught = (await callDispatch(plugin, { + runState, + toolIndex: toolkitIndex(), + name: "analytics.query", + args: { sql: "SELECT 1" }, + }).catch((e: unknown) => e)) as Error; + + expect(caught).toBeInstanceOf(Error); + expect(caught.message).toContain("analytics.query"); + expect(caught.message).toContain("'analytics' plugin"); + expect(caught.message).toContain("on behalf of the user"); + expect(caught.message).toContain("required scope(s): sql"); + expect(caught.message).toContain("user_api_scopes"); + // The original error is preserved as the cause for operators / debugging. + expect((caught as Error).cause).toBeInstanceOf(Error); + expect(((caught as Error).cause as Error).message).toContain( + "does not have required scopes: sql", + ); + // The actionable message is also what the non-streaming response records. + expect(runState.toolErrors).toHaveLength(1); + expect(runState.toolErrors[0].tool).toBe("analytics.query"); + expect(runState.toolErrors[0].error).toContain("user_api_scopes"); + }); + + test("leaves a non-scope toolkit error untouched", async () => { + const plugin = new AgentsPlugin({}); + const { runState } = makeRunState(plugin); + + const mock = createTestPluginContext({ + analytics: { + query: () => Promise.reject(new Error("syntax error at line 1")), + }, + }); + await mock.attach(plugin); + + await expect( + callDispatch(plugin, { + runState, + toolIndex: toolkitIndex(), + name: "analytics.query", + args: { sql: "SELEKT 1" }, + }), + ).rejects.toThrow(/^syntax error at line 1$/); + expect(runState.toolErrors[0].error).not.toContain("user_api_scopes"); + }); + + test("does NOT rewrap a function (service-principal) tool, even with a scope-shaped message", async () => { + // Function tools execute as the service principal, so a user_api_scopes + // hint would be wrong. Only the OBO sources (toolkit, mcp) are rewrapped. + const plugin = new AgentsPlugin({}); + const { runState } = makeRunState(plugin); + + const toolIndex = new Map([ + [ + "boom", + { + source: "function", + def: { + name: "boom", + description: "throws", + parameters: { type: "object" }, + }, + functionTool: { execute: vi.fn().mockRejectedValue(scopeError()) }, + }, + ], + ]); + + const caught = (await callDispatch(plugin, { + runState, + toolIndex, + name: "boom", + args: {}, + }).catch((e: unknown) => e)) as Error; + + expect(caught.message).toContain("does not have required scopes: sql"); + expect(caught.message).not.toContain("user_api_scopes"); + }); +}); + describe("runSubAgent — sub-agent event forwarding", () => { /** * The smart-dashboard `query` agent delegates to `dashboard_pilot`, which diff --git a/packages/appkit/src/plugins/agents/tool-dispatch.ts b/packages/appkit/src/plugins/agents/tool-dispatch.ts index 73deb94ef..5e34fb7b3 100644 --- a/packages/appkit/src/plugins/agents/tool-dispatch.ts +++ b/packages/appkit/src/plugins/agents/tool-dispatch.ts @@ -152,20 +152,61 @@ export async function dispatchToolCall( ); } catch (caught) { const err = normalizeIdentityError(caught); - const error = err instanceof Error ? err.message : String(err); + // Surface an actionable hint for the common OBO failure — a plugin-toolkit + // or MCP tool whose forwarded user token lacks the resource's scope — while + // still logging the original error with its full stack for operators. + const toThrow = describeMissingOboScope(entry, name, err) ?? err; logger.error( "Tool '%s' failed (request %s): %O", name, runState.requestId, err, ); - runState.toolErrors.push({ tool: name, error }); - throw err; + runState.toolErrors.push({ + tool: name, + error: toThrow instanceof Error ? toThrow.message : String(toThrow), + }); + throw toThrow; } return normalizeToolResult(toolResult); } +/** + * Plugin-toolkit and MCP agent tools execute on behalf of the user, so they + * use the user's forwarded OAuth token. When that token lacks the scope the + * resource needs, the platform rejects the call with a raw + * "...does not have required scopes: " message that never says how to fix + * it — the app simply never requested that scope. Turn it into an actionable + * hint pointing at `user_api_scopes`. Returns `undefined` for any other error, + * and for service-principal tool sources (`function`, `skill`, `subagent`), + * leaving those untouched. + */ +function describeMissingOboScope( + entry: ResolvedToolEntry, + name: string, + err: unknown, +): Error | undefined { + if (entry.source !== "toolkit" && entry.source !== "mcp") return undefined; + const message = err instanceof Error ? err.message : String(err); + // Platform wording: "...does not have required scopes: sql" — optionally a + // comma-separated list, optionally trailed by a "[ReqId: ...]" suffix. + const match = message.match(/required scopes?:\s*([^[\]]+)/i); + if (!match) return undefined; + const scopes = match[1].trim().replace(/\s+/g, " "); + const origin = + entry.source === "toolkit" + ? `from the '${entry.pluginName}' plugin` + : "backed by an MCP server"; + return new Error( + `Agent tool '${name}' (${origin}) runs on behalf of the user, but the ` + + `user's token is missing the required scope(s): ${scopes}. Add ${scopes} ` + + `to user_api_scopes in your app's databricks.yml and redeploy so the ` + + `Databricks Apps proxy forwards a token with that scope.`, + { cause: err }, + ); +} + /** * Executes a resolved tool entry by source. Unknown sources fall through to * `undefined` (the tool index only holds these six).