Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/bb-plugin-agent-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ bb plugin install git:https://github.com/patleeman/bb-plugins.git --subdirectory
- **CLI:** `bb agent-plugins list --json` · `bb agent-plugins outdated --json` · `bb agent-plugins update <id>` · `bb agent-plugins show <id> --json` · `bb agent-plugins tools --json` · `bb agent-plugins tools --query "send message" --server slack` · `bb agent-plugins describe <opaqueId>` · `bb agent-plugins call <opaqueId> '{"text":"hello"}'`
- **Agents:** the bridge exposes `agent_plugins_list_tools`, `agent_plugins_describe_tool`, and `agent_plugins_call`. `agent_plugins_list_tools` searches the catalog (`query`, `serverId`, `limit`, `offset`) and returns compact entries without input schemas, plus a tool count per server, so large servers don't flood the context. Agents fetch schemas for the tools they pick with `agent_plugins_describe_tool`, then call them by opaque ID.

Tool discovery applies `serverId` before loading catalogs; scoped results include counts for that scope only. Unscoped discovery loads at most four server catalogs concurrently per request and preserves catalog order. Describe/call resolve IDs against their owning server, reusing the existing per-server cache, refresh notifications, five-minute TTL, and five-second error backoff. Unrelated failed servers are not retried by targeted requests. After a gateway restart, an ID with an ambiguous readable server prefix requires scoped discovery again; full IDs are always checked against the current catalog before execution.

The MCP client delegates authentication to the official SDK, including protected-resource/authorization-server discovery, dynamic client registration, PKCE, refresh, `WWW-Authenticate`, insufficient-scope step-up, and OAuth callback completion. Tokens and PKCE state are stored in a BB secret setting; they are never placed in the database snapshot or frontend payload.

For OAuth-protected MCP servers such as Fastmail, the registered redirect uses `http://localhost` on BB's port. If BB runs on another machine, sign in with your normal browser, then copy the **full** localhost URL from its address bar after the connection fails and paste it into the matching server's **Finish sign-in** field in BB. The URL contains a short-lived, one-use authorization code: paste it only into BB, never into chat or logs. BB verifies the callback URL, state and PKCE grant before exchanging the code. If the browser runs on the BB host or you have a port forward, the callback completes normally without pasting. Fastmail's MCP dynamic registration does not accept arbitrary public HTTPS redirect URIs; setting `BB_APP_URL` does not change this callback.
Expand Down
8 changes: 4 additions & 4 deletions packages/bb-plugin-agent-plugins/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,15 +559,15 @@ export default async function plugin(bb: BbPluginApi) {
limit: z.number().int().min(1).max(MAX_TOOL_SEARCH_LIMIT).optional().describe(`Max tools to return (default ${DEFAULT_TOOL_SEARCH_LIMIT}).`),
offset: z.number().int().min(0).optional().describe("Skip this many matches, for paging."),
}).strict(),
async execute(input) { return JSON.stringify(searchTools(await gateway.listTools(), input), null, 2); },
async execute(input) { return JSON.stringify(searchTools(await gateway.listTools(input.serverId), input), null, 2); },
});
bb.agents.registerTool({
name: "agent_plugins_describe_tool",
description: "Get full definitions, including input schemas, for Agent Plugin MCP tools by opaque ID.",
instructions: "Pass opaqueIds returned by agent_plugins_list_tools; read inputSchema before calling agent_plugins_call.",
presentation: { label: { pending: "Describing Agent Plugins tools", completed: "Tools described" } },
parameters: z.object({ opaqueIds: z.array(z.string().min(1)).min(1).max(MAX_DESCRIBE_TOOLS) }).strict(),
async execute(input) { return JSON.stringify(describeTools(await gateway.listTools(), input.opaqueIds), null, 2); },
async execute(input) { return JSON.stringify(describeTools(await gateway.resolveTools(input.opaqueIds), input.opaqueIds), null, 2); },
});
bb.agents.registerTool({
name: "agent_plugins_call",
Expand Down Expand Up @@ -1270,7 +1270,7 @@ export default async function plugin(bb: BbPluginApi) {
};
const query = flag("--query"); const serverId = flag("--server");
const limit = flag("--limit"); const offset = flag("--offset");
const tools = await gateway.listTools();
const tools = await gateway.listTools(serverId);
if (query === undefined && serverId === undefined && limit === undefined && offset === undefined) {
return { exitCode: 0, stdout: (asJson ? JSON.stringify({ tools }, null, 2) : tools.map((t: CatalogTool) => `${t.opaqueId} — ${t.description} [${t.serverType}]`).join("\n")) + "\n" };
}
Expand All @@ -1288,7 +1288,7 @@ export default async function plugin(bb: BbPluginApi) {
if (cmd === "describe") {
const ids = rest.filter((arg) => arg !== "--json");
if (ids.length === 0) return { exitCode: 2, stderr: "Usage: bb agent-plugins describe <opaqueId>… [--json]\n" };
const result = describeTools(await gateway.listTools(), ids);
const result = describeTools(await gateway.resolveTools(ids), ids);
const text = [
...result.tools.map((t) => `${t.opaqueId}\n${t.description}\ninputSchema: ${JSON.stringify(t.inputSchema)}`),
...result.notFound.map((id) => `Not found: ${id}`),
Expand Down
72 changes: 72 additions & 0 deletions packages/bb-plugin-agent-plugins/src/bridge-scoped.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import plugin from "../server.js";
import { McpGateway } from "./gateway.js";
import type { CatalogTool } from "./types.js";

const hosts: Array<ReturnType<typeof createFakePluginHost>> = [];
const tool: CatalogTool = {
opaqueId: "fixture__healthy__echo_0123456789", pluginId: "fixture", pluginName: "Fixture",
serverId: "healthy", serverType: "stdio", name: "echo", description: "Echo a value.",
inputSchema: { type: "object", properties: { value: { type: "string" } } }, status: "ready",
};

async function createHost() {
const host = createFakePluginHost({ pluginId: "agent-plugins" });
hosts.push(host);
await plugin(host.bb);
return host.harness.behavior;
}

afterEach(async () => {
await Promise.all(hosts.splice(0).map((host) => host.harness.lifecycle.dispose()));
vi.restoreAllMocks();
});

describe("scoped static bridge contracts", () => {
it("passes the server filter to discovery before searching; CLI uses the same scope", async () => {
const list = vi.spyOn(McpGateway.prototype, "listTools").mockResolvedValue([tool]);
const host = await createHost();
const result = JSON.parse(String(await host.callAgentTool("agent_plugins_list_tools", { serverId: "healthy", query: "echo", limit: 1, offset: 0 })));
expect(list).toHaveBeenCalledExactlyOnceWith("healthy");
expect(result).toMatchObject({ total: 1, offset: 0, tools: [{ opaqueId: tool.opaqueId }], servers: [{ pluginName: "Fixture", serverId: "healthy", tools: 1 }] });
expect(result.tools[0]).not.toHaveProperty("inputSchema");
list.mockClear();
const cli = await host.runCli(["tools", "--server", "healthy", "--json"]);
expect(cli.exitCode).toBe(0);
expect(list).toHaveBeenCalledExactlyOnceWith("healthy");
expect(JSON.parse(cli.stdout!)).toEqual(result);
});

it("describes requested IDs without invoking global discovery, preserving notFound and deduplication", async () => {
const list = vi.spyOn(McpGateway.prototype, "listTools");
const resolve = vi.spyOn(McpGateway.prototype, "resolveTools").mockResolvedValue([tool]);
const host = await createHost();
const ids = [tool.opaqueId, "missing", tool.opaqueId];
const result = JSON.parse(String(await host.callAgentTool("agent_plugins_describe_tool", { opaqueIds: ids })));
expect(resolve).toHaveBeenCalledExactlyOnceWith(ids);
expect(list).not.toHaveBeenCalled();
expect(result).toEqual({ tools: [tool], notFound: ["missing"] });
resolve.mockClear();
const cli = await host.runCli(["describe", ...ids, "--json"]);
expect(cli.exitCode).toBe(1);
expect(resolve).toHaveBeenCalledExactlyOnceWith(ids);
expect(JSON.parse(cli.stdout!)).toEqual(result);
expect(list).not.toHaveBeenCalled();
});

it("retains strict input schemas, call defaults, cancellation and the error envelope", async () => {
const list = vi.spyOn(McpGateway.prototype, "listTools");
const call = vi.spyOn(McpGateway.prototype, "call").mockRejectedValue(new Error("fixture call error"));
const host = await createHost();
await expect(host.callAgentTool("agent_plugins_list_tools", { serverId: "healthy", unknown: true })).rejects.toThrow();
await expect(host.callAgentTool("agent_plugins_list_tools", { limit: 201 })).rejects.toThrow();
await expect(host.callAgentTool("agent_plugins_describe_tool", { opaqueIds: [] })).rejects.toThrow();
await expect(host.callAgentTool("agent_plugins_describe_tool", { opaqueIds: Array(21).fill("id") })).rejects.toThrow();
expect(list).not.toHaveBeenCalled();
const signal = new AbortController().signal;
const result = JSON.parse(String(await host.callAgentTool("agent_plugins_call", { opaqueId: tool.opaqueId }, { signal })));
expect(call).toHaveBeenCalledExactlyOnceWith(tool.opaqueId, {}, signal);
expect(result).toEqual({ isError: true, error: "fixture call error" });
});
});
Loading