Skip to content

Add gated tool live invocation and snippets - #79

Merged
vishu-bh merged 3 commits into
mainfrom
feat/6318-tool-live-invoke-snippets
Aug 26, 2026
Merged

Add gated tool live invocation and snippets#79
vishu-bh merged 3 commits into
mainfrom
feat/6318-tool-live-invoke-snippets

Conversation

@gandhipratik203

@gandhipratik203 gandhipratik203 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Summary

In simple terms: PR #53 added the Try-it shell, and PR #55 made preview results readable. This PR adds the guarded path for calling a real tool from that same surface, plus copyable MCP tools/call snippets.

It adds UI support for:

  • live invocation through the BFF-backed MCP JSON-RPC /rpc route
  • tools.execute and servers.use RBAC gating before any annotation-based live invoke decision
  • immediate live invoke for readOnlyHint: true tools
  • destructive confirmation for local destructiveHint: true tools, with destructiveHint taking priority over conflicting readOnlyHint
  • unavailable states for federated or untagged tools that are not explicitly read-only
  • MCP cancellation notifications for active live requests when the user cancels, switches tools, resets, unmounts, supersedes, or hits the timeout
  • live result rendering through the existing tool result renderer
  • JSON-RPC snippets for curl, JSON-RPC, Python, and TypeScript using a real gateway placeholder
  • timeout and Cancel request behavior backed by MCP notifications/cancelled

Before / After

Before PR #79
=============

Tool details drawer
  |
  v
Try it tab
  |
  +-- Arguments
  +-- Headers
  +-- Preview
        |
        v
      Dry-run response only


After PR #79
============

Tool details drawer
  |
  v
Try it tab
  |
  +-- Arguments
  +-- Headers
  +-- MCP 2025-11-25 snippets
  |     |
  |     +-- curl
  |     +-- JSON-RPC
  |     +-- Python
  |     `-- TypeScript
  |
  +-- Preview
  |     `-- Dry-run response
  |
  `-- Live invoke
        |
        +-- tools.execute + servers.use checks
        +-- readOnlyHint: true -> invoke
        +-- local destructiveHint: true -> confirm -> invoke
        +-- federated / untagged non-read-only -> unavailable
        +-- Cancel request / timeout -> notifications/cancelled for active request id
        |
        v
      Live JSON-RPC result / JSON-RPC error

Context

Notes

  • The browser calls api.post("/rpc", ...); the API client resolves that to same-origin /api/rpc, and the BFF proxies upstream.
  • The live invoke payload is MCP JSON-RPC tools/call with params.name = tool.name and params.arguments = args.
  • The payload intentionally does not send server_id; backend name resolution uses DbTool.name for this route shape.
  • JSON-RPC error bodies are treated as failures even when HTTP status is 200, including permission errors like -32003.
  • Live snippets target MCPGATEWAY_URL/rpc with Authorization: Bearer $MCPGATEWAY_BEARER_TOKEN, not the browser-only /api/rpc URL.
  • The MCP snippet badge is pinned to 2025-11-25, which is supported by the backend. No unsupported 2026-07-28 variant is emitted.
  • Cancel request sends a best-effort /rpc notifications/cancelled message for the active JSON-RPC id before aborting the browser request. The REST /v1/cancellation/cancel endpoint is admin-scoped today, so this stays frontend-only and uses the backend's existing owner-authorized MCP path.
  • Live invoke requires both tools.execute and servers.use; each missing permission has a distinct disabled state.
  • Live invoke rides the existing VITE_ENABLE_TOOL_PREVIEW Try-it feature flag.
  • Passthrough headers use the existing headers editor and forwardable-header filter. Denied headers such as Authorization, Cookie, Forwarded, X-Forwarded-*, and X-Real-IP are not forwarded from this surface.

Tests

  • npm run lint
  • npm run format:check
  • npm run generate
  • ./node_modules/.bin/tsc -b
  • npm run test
  • npm run build
  • HEADLESS=1 node tool-live-invoke-manual.mjs
  • PLAYWRIGHT_SKIP_WEBSERVER=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 npm run e2e -- e2e/tools.spec.ts -g "live invokes|confirms destructive|tools.execute|servers.use|federated|cancellation"
  • git diff --check

Manual verification

Manual test steps

Setup

git checkout feat/6318-tool-live-invoke-snippets
npm ci                 # if node_modules is missing
npm run generate       # if src/generated/ is missing

Save the mock script from the next collapsible at the repo root as tool-live-invoke-manual.mjs.

Two terminals:

# terminal A - dev server with the temporary tool-preview flag enabled
VITE_ENABLE_TOOL_PREVIEW=true npm run dev

# terminal B - opens the mocked browser
node tool-live-invoke-manual.mjs

Terminal B opens a Chrome for Testing window with /auth/session, /api/rbac/my/permissions, /api/tools, /api/gateways, and /api/rpc mocked. Ctrl-C in terminal B to close. Do everything in that window, in the tab it opens.

Optional RBAC-denied modes:

PERMISSIONS=NO_EXECUTE node tool-live-invoke-manual.mjs
PERMISSIONS=NO_SERVERS_USE node tool-live-invoke-manual.mjs

Steps

1. Open More options for live-lab -> View details.
Expect: the details drawer opens with Try it selected.

2. Confirm all tool chips are visible: live_readonly_search, live_jsonrpc_error, live_slow_tool, local_destructive_cleanup, federated_destructive_blocked, and local_untagged_blocked.

3. Select live_readonly_search. Fill query with cloudflare and limit with 5. Add header X-Tenant-Id with value team-a, then click Live invoke.
Expect: Live invoke 200 and Live result for live_readonly_search. Terminal B should show /api/rpc with method: "tools/call", params.name: "live_readonly_search", no server_id, and x-tenant-id: team-a.

4. Inspect the snippet tabs.
Expect: curl, JSON-RPC, Python, and TypeScript tabs render with an MCP 2025-11-25 badge. Snippets target MCPGATEWAY_URL/rpc, not browser-only /api/rpc.

5. Select live_jsonrpc_error and click Live invoke.
Expect: Live invoke failed -32003 and Mock JSON-RPC permission denied from body, even though the mocked HTTP status is 200.

6. Select local_destructive_cleanup and click Live invoke.
Expect: an Invoke destructive tool confirmation. Clicking Cancel sends no /api/rpc request; clicking Invoke tool sends exactly one request and renders Destructive invoke confirmed and completed.

7. Select federated_destructive_blocked.
Expect: Live invoke is not offered for federated tools without readOnlyHint. and the live invoke button is disabled.

8. Select local_untagged_blocked.
Expect: Live invoke is not offered until the tool declares readOnlyHint or destructiveHint. and the live invoke button is disabled.

9. Select live_slow_tool and click Live invoke.
Expect: Invoking... plus Cancel request. Click Cancel request; terminal B should show a second /api/rpc request with method: "notifications/cancelled" and params.requestId matching the original tools/call id.

10. Stop Terminal B and rerun with PERMISSIONS=NO_EXECUTE node tool-live-invoke-manual.mjs. Open the drawer again and select a read-only tool.
Expect: Live invoke requires tools.execute. and the live invoke button is disabled.

11. Stop Terminal B and rerun with PERMISSIONS=NO_SERVERS_USE node tool-live-invoke-manual.mjs. Open the drawer again and select a read-only tool.
Expect: Live invoke requires servers.use. and the live invoke button is disabled.

Teardown

Ctrl-C both terminals. If :5173 is stuck:

lsof -ti:5173 | xargs kill
Mock script (tool-live-invoke-manual.mjs)

Save at the repo root. Requires @playwright/test, already a dev dependency; run npx playwright install chromium if the browser is missing.

// Manual UI testing for contextforge-web-ui#79 - gated tool live invocation.
//
//   VITE_ENABLE_TOOL_PREVIEW=true npm run dev  # terminal A, Vite on :5173
//   node tool-live-invoke-manual.mjs            # terminal B
//
// Optional RBAC-denied modes:
//
//   PERMISSIONS=NO_EXECUTE node tool-live-invoke-manual.mjs
//   PERMISSIONS=NO_SERVERS_USE node tool-live-invoke-manual.mjs
//
// Ctrl-C in terminal B to close the headed browser.
//
// This mocks the backend endpoints needed by /app/tools, including /api/rpc.
// It verifies frontend behavior only: tools/call payload shape, passthrough
// headers, JSON-RPC body errors, read-only gate, destructive confirmation,
// federated unavailable gate, untagged unavailable gate, and MCP cancellation.

import { chromium } from "@playwright/test";

const BASE = process.env.BASE_URL ?? "http://localhost:5173";
const HEADED = !process.env.HEADLESS;
const PERMISSIONS =
  process.env.PERMISSIONS === "NO_EXECUTE"
    ? ["tools.read", "servers.use"]
    : process.env.PERMISSIONS === "NO_SERVERS_USE"
      ? ["tools.read", "tools.execute"]
      : ["*"];
const PERMISSIONS_MODE = process.env.PERMISSIONS ?? "full access";

const USER = {
  email: "test@example.com",
  full_name: "Test User",
  is_admin: true,
  is_active: true,
  auth_provider: "local",
  email_verified: true,
  password_change_required: false,
};

function makeTool(id, overrides = {}) {
  return {
    id: `tool-${id}`,
    name: id,
    originalName: id,
    description: `Manual live invoke test tool: ${id}`,
    originalDescription: `Manual live invoke test tool: ${id}`,
    title: id,
    displayName: id,
    gatewayId: "gw-live-lab",
    gatewaySlug: "live-lab",
    customName: id,
    customNameSlug: id,
    enabled: true,
    reachable: true,
    deprecated: false,
    executionCount: 0,
    tags: [],
    integrationType: "mcp",
    requestType: "http",
    url: "https://live.example/mcp",
    headers: {},
    inputSchema: { type: "object", properties: {} },
    outputSchema: { type: "object" },
    annotations: {},
    jsonpathFilter: null,
    auth: null,
    visibility: "team",
    createdAt: "2026-04-10T10:00:00Z",
    updatedAt: "2026-04-10T10:00:00Z",
    ...overrides,
  };
}

const TOOLS = [
  makeTool("live_readonly_search", {
    description: "Read-only live invoke: sends args and allowed passthrough headers to /api/rpc.",
    annotations: { readOnlyHint: true },
    inputSchema: {
      type: "object",
      required: ["query"],
      properties: {
        query: { type: "string", description: "Search query" },
        limit: { type: "integer", description: "Maximum results" },
      },
    },
  }),
  makeTool("live_jsonrpc_error", {
    description: "Read-only tool that returns a JSON-RPC error body with HTTP 200.",
    annotations: { readOnlyHint: true },
  }),
  makeTool("live_slow_tool", {
    description: "Read-only tool that stays pending long enough to check Cancel request.",
    annotations: { readOnlyHint: true },
  }),
  makeTool("local_destructive_cleanup", {
    description: "Local destructive tool: opens a confirmation before /api/rpc is called.",
    gatewayId: null,
    annotations: { destructiveHint: true },
  }),
  makeTool("federated_destructive_blocked", {
    description: "Federated non-read-only tool: unavailable pending approval policy.",
    annotations: { destructiveHint: true },
  }),
  makeTool("local_untagged_blocked", {
    description: "Local untagged tool: unavailable until annotations declare intent.",
    gatewayId: null,
    annotations: {},
  }),
];

const GATEWAY_RESPONSE = {
  gateways: [
    {
      id: "gw-live-lab",
      name: "live-lab",
      url: "https://live.example/mcp",
      description: "Mocked MCP tools for manual live-invoke verification",
    },
  ],
  nextCursor: null,
};

function json(body, status = 200) {
  return {
    status,
    contentType: "application/json",
    body: JSON.stringify(body),
  };
}

function fallbackApiBody(pathname) {
  if (pathname.startsWith("/api/resources")) return [];
  if (pathname.startsWith("/api/prompts")) return [];
  if (pathname.startsWith("/api/servers")) return [];
  if (pathname.startsWith("/api/gateways")) return { gateways: [], nextCursor: null };
  if (pathname.startsWith("/api/tools")) return [];
  return {};
}

function interestingHeaders(headers) {
  return Object.fromEntries(
    Object.entries(headers).filter(([name]) =>
      ["x-csrf-token", "x-tenant-id", "x-api-key", "authorization"].includes(name.toLowerCase()),
    ),
  );
}

function toolResult(text, extra = {}) {
  return {
    content: [{ type: "text", text, mimeType: "text/plain" }],
    structured_output: extra,
  };
}

const browser = await chromium.launch({ headless: !HEADED });
const context = await browser.newContext({ viewport: { width: 1512, height: 950 } });
const page = await context.newPage();

page.on("console", (message) => {
  if (["error", "warning"].includes(message.type())) {
    console.log(`browser ${message.type()}: ${message.text()}`);
  }
});
page.on("pageerror", (error) => {
  console.log(`browser pageerror: ${error.message}`);
});

await page.route("**/*", (route) => {
  const pathname = new URL(route.request().url()).pathname;
  if (pathname.startsWith("/api/")) return route.fulfill(json(fallbackApiBody(pathname)));
  return route.fallback();
});

await page.route("**/auth/session", (route) =>
  route.fulfill(
    json({
      authenticated: true,
      user: USER,
      csrfToken: "mock-csrf-token",
    }),
  ),
);

await page.route("**/api/rbac/my/permissions**", (route) => route.fulfill(json(PERMISSIONS)));
await page.route("**/api/tools?*", (route) => route.fulfill(json(TOOLS)));
await page.route("**/api/gateways?*", (route) => route.fulfill(json(GATEWAY_RESPONSE)));

const slowReleaseById = new Map();

await page.route("**/api/rpc", async (route) => {
  const request = route.request();
  const body = request.postDataJSON();
  const name = body?.params?.name;
  const args = body?.params?.arguments ?? {};
  const headers = request.headers();

  console.log("\n/api/rpc request body:");
  console.log(JSON.stringify(body, null, 2));
  console.log("/api/rpc interesting headers:");
  console.log(JSON.stringify(interestingHeaders(headers), null, 2));

  if (body?.method === "notifications/cancelled") {
    const requestId = String(body?.params?.requestId ?? "");
    slowReleaseById.get(requestId)?.();
    slowReleaseById.delete(requestId);
    return route.fulfill(
      json({
        jsonrpc: "2.0",
        id: body.id,
        result: {},
      }),
    );
  }

  if (name === "live_jsonrpc_error") {
    return route.fulfill(
      json({
        jsonrpc: "2.0",
        id: body.id,
        error: {
          code: -32003,
          message: "Mock JSON-RPC permission denied from body",
          data: { method: "tools/call" },
        },
      }),
    );
  }

  if (name === "live_slow_tool") {
    const requestId = String(body.id);
    await new Promise((resolve) => {
      slowReleaseById.set(requestId, resolve);
      setTimeout(resolve, 90_000);
    });
    slowReleaseById.delete(requestId);
    try {
      return await route.fulfill(
        json({
          jsonrpc: "2.0",
          id: body.id,
          result: toolResult("Slow tool eventually completed after cancellation was requested."),
        }),
      );
    } catch {
      return undefined;
    }
  }

  if (name === "local_destructive_cleanup") {
    return route.fulfill(
      json({
        jsonrpc: "2.0",
        id: body.id,
        result: toolResult("Destructive invoke confirmed and completed.", {
          answeredBy: "local manual tool",
        }),
      }),
    );
  }

  return route.fulfill(
    json({
      jsonrpc: "2.0",
      id: body.id,
      result: toolResult(`Live result for ${name}`, {
        receivedArguments: args,
        tenantHeader: headers["x-tenant-id"] ?? null,
      }),
    }),
  );
});

await page.addInitScript(() => {
  sessionStorage.setItem("mcpgateway_token", "placeholder-token");
});

await page.goto(`${BASE}/app/tools`, { waitUntil: "networkidle" });

const cardCount = await page.getByRole("button", { name: "More options for live-lab" }).count();
console.log(`tools card: ${cardCount ? "ok" : "MISSING"}`);
console.log(`permissions mode: ${PERMISSIONS_MODE}`);

if (!HEADED) {
  await browser.close();
} else {
  console.log(`
Browser open. Try:

  1. Open "More options for live-lab" -> "View details".
     Expect the details drawer to open with "Try it" selected.

  2. Select live_readonly_search.
     Fill query="cloudflare" and limit="5".
     Add header X-Tenant-Id=team-a.
     Click "Live invoke".
     Expect "Live invoke 200" and "Live result for live_readonly_search".
     Terminal should show /api/rpc with method "tools/call", params.name
     "live_readonly_search", no server_id, and x-tenant-id "team-a".

  3. Inspect snippet tabs.
     Expect curl, JSON-RPC, Python, TypeScript and MCP 2025-11-25.
     Snippets should target MCPGATEWAY_URL/rpc, not /api/rpc.

  4. Select live_jsonrpc_error and click "Live invoke".
     Expect "Live invoke failed -32003" even though the mocked HTTP status is 200.

  5. Select local_destructive_cleanup and click "Live invoke".
     Expect "Invoke destructive tool" confirmation.
     Click "Cancel" first: terminal should show no /api/rpc request.
     Click again, then "Invoke tool": terminal should show one /api/rpc request.

  6. Select federated_destructive_blocked.
     Expect "Live invoke is not offered for federated tools without readOnlyHint."
     The live invoke button should be disabled.

  7. Select local_untagged_blocked.
     Expect "Live invoke is not offered until the tool declares readOnlyHint or destructiveHint."
     The live invoke button should be disabled.

  8. Select live_slow_tool and click "Live invoke".
     Expect "Invoking..." plus "Cancel request".
     Click "Cancel request"; terminal should show a second /api/rpc request with
     method "notifications/cancelled" and params.requestId matching the original
     tools/call id.

  9. Optional RBAC denial:
     Ctrl-C this script and rerun:
       PERMISSIONS=NO_EXECUTE node tool-live-invoke-manual.mjs
     Open the drawer again.
     Expect "Live invoke requires tools.execute." for read-only tools.

 10. Optional servers.use denial:
     Ctrl-C this script and rerun:
       PERMISSIONS=NO_SERVERS_USE node tool-live-invoke-manual.mjs
     Open the drawer again.
     Expect "Live invoke requires servers.use." for read-only tools.

Ctrl-C to close.
`);
  await new Promise(() => {});
}
Manual test results

Latest verification was run against this PR head. Browser launch required running Playwright outside the sandbox in this local environment for the manual smoke check.

# Check Expected Result
1 Script syntax node --check tool-live-invoke-manual.mjs exits cleanly Pass
2 Mock browser smoke HEADLESS=1 node tool-live-invoke-manual.mjs logs tools card: ok and permissions mode: full access Pass
3 Snippets Snippet tabs render and target MCPGATEWAY_URL/rpc with MCP 2025-11-25 Pass
4 JSON-RPC body error live_jsonrpc_error renders Live invoke failed -32003 even though the mock returns HTTP 200 Pass
5 MCP cancellation Clicking Cancel request sends a second /api/rpc request with method: "notifications/cancelled", reason "user", and params.requestId matching the original tools/call id Pass
6 Focused Playwright live invoke cases Read-only invoke, passthrough headers, destructive confirmation, MCP cancellation, tools.execute denial, servers.use denial, and federated gate pass Pass: 6 passed
7 Full unit suite npm run test completes Pass: 190 files, 3150 passed, 1 skipped
8 Build npm run build completes Pass
9 Static checks lint, format, generated client, TypeScript build, and git diff --check complete Pass

Focused command used for live-invoke browser coverage:

PLAYWRIGHT_SKIP_WEBSERVER=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 npm run e2e -- e2e/tools.spec.ts -g "live invokes|confirms destructive|tools.execute|servers.use|federated|cancellation"

Scope of this verification: the manual script is mock-backed. It covers frontend behavior for MCP JSON-RPC payload construction, passthrough headers, snippets, JSON-RPC error bodies, destructive confirmation, annotation gates, RBAC gating, and MCP cancellation request emission. It does not verify execution against a real upstream tool.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
@gandhipratik203
gandhipratik203 marked this pull request as ready for review August 25, 2026 12:32
@gandhipratik203 gandhipratik203 self-assigned this Aug 26, 2026
Comment thread src/components/tools/ToolLiveInvokeResult.tsx Outdated
Comment thread src/components/tools/buildToolSnippets.test.ts
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach is good, few things to check before merging

Comment thread src/hooks/useToolInvoke.ts
Comment thread src/components/tools/ToolLiveInvokeGate.tsx Outdated
Comment thread src/components/tools/ToolLiveInvokeGate.tsx
Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>

@vishu-bh vishu-bh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the comments!!

LGTM 🐎

@vishu-bh
vishu-bh merged commit 75a5d75 into main Aug 26, 2026
5 checks passed
@gcgoncalves
gcgoncalves deleted the feat/6318-tool-live-invoke-snippets branch August 26, 2026 15:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[UI-REWRITE]: Add gated tool live invocation and spec-aware snippets

3 participants