Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions packages/appkit/src/plugins/agents/tests/dispatch-tool-call.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>([
[
"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<string, unknown>([
[
"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
Expand Down
47 changes: 44 additions & 3 deletions packages/appkit/src/plugins/agents/tool-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <x>" 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).
Expand Down
Loading