Skip to content

[UI-REWRITE]: Add virtual server drawer Try-it UI - #90

Draft
gandhipratik203 wants to merge 1 commit into
mainfrom
issue-6417-virtual-server-tool-try-it
Draft

[UI-REWRITE]: Add virtual server drawer Try-it UI#90
gandhipratik203 wants to merge 1 commit into
mainfrom
issue-6417-virtual-server-tool-try-it

Conversation

@gandhipratik203

@gandhipratik203 gandhipratik203 commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Refs IBM/mcp-context-forge#6417

Summary

  • Add VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=false and gate the new virtual-server Try-it UI behind it.
  • Extend live invoke plumbing to include optional server_id for virtual-server scoped tools/call.
  • Reuse the existing tool argument form, headers editor, live gate, snippets, and result renderer in live-only mode.
  • Keep associated tool fallback for Components only; Try-it uses fetched /servers/:id/tools.

Notes

Virtual-server Try-it is implemented behind VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=false by default. Production behavior remains unchanged until the flag is enabled.

The frontend currently invokes tool.name as the available qualified tool address and follows the existing direct live-invoke cancellation behavior; both are subject to backend confirmation in IBM/mcp-context-forge#6416.

Verification

  • npm test
  • npm run lint
  • npm run build
  • focused flag-enabled virtual-server Try-it e2e
  • flag-off existing virtual-server drawer e2e

Manual verification

Manual test steps

Setup

git checkout issue-6417-virtual-server-tool-try-it
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 virtual-server-try-it-manual.mjs.

Two terminals:

# terminal A - dev server with the virtual-server Try-it flag enabled before Vite starts
VITE_ENABLE_TOOL_PREVIEW=true VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true npm run dev

# terminal B - opens the mocked browser
node virtual-server-try-it-manual.mjs

If Vite chooses another port, pass it to the script:

BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs

Optional modes:

PERMISSIONS=NO_EXECUTE BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs
PERMISSIONS=NO_SERVERS_USE BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs
TOOLS=EMPTY BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs

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

Steps

1. Open Actions for testVS -> View details.
Expect: the details drawer opens with Components selected.

2. Click Try it.
Expect: Live tool call appears, no Preview button appears, and snippets include server_id.

3. 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, Requested through testVS, Answered by github-mcp, and Scoped result for github.search_issues.

4. Inspect Terminal B.
Expect: /api/rpc logs method: "tools/call", params.name: "github.search_issues", params.server_id: "76c7b637dafc4d7197f14817ddffeda9", arguments { "query": "cloudflare", "limit": 5 }, and x-tenant-id: "team-a".

5. Stop Terminal B and rerun with PERMISSIONS=NO_EXECUTE BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs. Open the drawer and click Try it.
Expect: Live invoke requires tools.execute. and the live invoke button is disabled.

6. Stop Terminal B and rerun with PERMISSIONS=NO_SERVERS_USE BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs. Open the drawer and click Try it.
Expect: Live invoke requires servers.use. and the live invoke button is disabled.

7. Stop Terminal B and rerun with TOOLS=EMPTY BASE_URL=http://localhost:5175 node virtual-server-try-it-manual.mjs. Open the drawer and click Try it.
Expect: the Try-it empty state renders from fetched /servers/:id/tools; it does not fall back to associatedToolIds.

Teardown

Ctrl-C both terminals.

Mock script (virtual-server-try-it-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 virtual-server scoped Try-it.
//
// Terminal A:
//   VITE_ENABLE_TOOL_PREVIEW=true VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true npm run dev
//
// Terminal B:
//   node virtual-server-try-it-manual.mjs
//
// Optional modes:
//   PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs
//   PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs
//   TOOLS=EMPTY node virtual-server-try-it-manual.mjs
//
// Ctrl-C in terminal B to close the headed browser.

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"
    ? ["servers.read", "servers.use"]
    : process.env.PERMISSIONS === "NO_SERVERS_USE"
      ? ["servers.read", "tools.execute"]
      : ["*"];
const PERMISSIONS_MODE = process.env.PERMISSIONS ?? "full access";

const SERVER_ID = "76c7b637dafc4d7197f14817ddffeda9"; // pragma: allowlist secret

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,
};

const VIRTUAL_SERVER = {
  id: SERVER_ID,
  name: "testVS",
  description: "Virtual server endpoint: developer tooling server exposing repository workflows.",
  icon: "",
  createdAt: "2026-04-28T15:41:31.233166",
  updatedAt: "2026-04-28T15:41:31.233168",
  enabled: true,
  associatedTools: ["Get Repo Issues", "Create New Issue"],
  associatedToolIds: ["GITHUB_GET_REPO_ISSUES", "GITHUB_CREATE_ISSUE"],
  associatedResources: ["github://repo/{owner}/{repo}"],
  associatedPrompts: ["summarize_pull_request"],
  associatedA2aAgents: [],
  metrics: null,
  tags: [{ id: "tag-development", label: "development" }],
  createdBy: "admin@example.com",
  createdFromIp: "127.0.0.1",
  createdVia: "ui",
  createdUserAgent: "Mozilla/5.0",
  modifiedBy: null,
  modifiedFromIp: null,
  modifiedVia: null,
  modifiedUserAgent: null,
  importBatchId: null,
  federationSource: null,
  version: 1,
  teamId: "0a9b06bd22974fe386dcacb18548ed61", // pragma: allowlist secret
  team: "Platform Administrator's Team",
  ownerEmail: "admin@example.com",
  visibility: "public",
  oauthEnabled: false,
  oauthConfig: null,
};

const MCP_SERVER = {
  id: "mcp-gateway-1",
  name: "github-mcp",
  url: "http://localhost:9000",
  transport: "SSE",
  enabled: true,
  reachable: true,
  visibility: "public",
  tool_count: 1,
  resource_count: 1,
  prompt_count: 1,
  created_at: "2026-04-28T15:41:31.233166",
  updated_at: "2026-04-28T15:41:31.233168",
};

function makeTool(overrides = {}) {
  return {
    id: "tool-search",
    name: "github.search_issues",
    originalName: "search_issues",
    description: "Search repository issues through the selected virtual server.",
    originalDescription: "Search repository issues through the selected virtual server.",
    title: "Search issues",
    displayName: "Search issues",
    gatewayId: "mcp-gateway-1",
    gatewaySlug: "github-mcp",
    customName: "",
    customNameSlug: "search_issues",
    enabled: true,
    reachable: true,
    deprecated: false,
    executionCount: 0,
    tags: [],
    integrationType: "MCP",
    requestType: "http",
    url: "https://example.com/mcp",
    headers: {},
    annotations: { readOnlyHint: true },
    jsonpathFilter: null,
    auth: null,
    version: 1,
    visibility: "team",
    createdAt: "2026-04-10T10:00:00Z",
    updatedAt: "2026-04-10T10:00:00Z",
    inputSchema: {
      type: "object",
      required: ["query"],
      properties: {
        query: { type: "string", description: "Search query" },
        limit: { type: "integer", description: "Maximum results" },
      },
    },
    outputSchema: { type: "object" },
    ...overrides,
  };
}

const TOOLS = process.env.TOOLS === "EMPTY" ? [] : [makeTool()];

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

function fallbackApiBody(pathname) {
  if (pathname.startsWith("/api/resources")) return { resources: [] };
  if (pathname.startsWith("/api/prompts")) return { prompts: [] };
  if (pathname.startsWith("/api/tools")) return { tools: [] };
  if (pathname.startsWith("/api/gateways")) return { gateways: [], nextCursor: null };
  if (pathname.startsWith("/api/servers")) return { servers: [] };
  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 {
    target: { kind: "federated", gateway_name: "github-mcp" },
    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/servers?*", (route) =>
  route.fulfill(json({ servers: [VIRTUAL_SERVER] })),
);
await page.route(`**/api/servers/${SERVER_ID}`, (route) => route.fulfill(json(VIRTUAL_SERVER)));
await page.route(`**/api/servers/${SERVER_ID}/tools?*`, (route) =>
  route.fulfill(json({ tools: TOOLS })),
);
await page.route(`**/api/servers/${SERVER_ID}/resources?*`, (route) =>
  route.fulfill(json({ resources: [] })),
);
await page.route(`**/api/servers/${SERVER_ID}/prompts?*`, (route) =>
  route.fulfill(json({ prompts: [] })),
);
await page.route("**/api/gateways?*", (route) =>
  route.fulfill(json({ gateways: [MCP_SERVER], nextCursor: null })),
);

await page.route("**/api/rpc", async (route) => {
  const request = route.request();
  const body = request.postDataJSON();
  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));

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

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

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

const cardCount = await page.getByRole("button", { name: "Actions for testVS" }).count();
console.log(`virtual server actions: ${cardCount ? "ok" : "MISSING"}`);
console.log(`permissions mode: ${PERMISSIONS_MODE}`);
console.log(`tools mode: ${process.env.TOOLS ?? "attached tool"}`);

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

  1. Open "Actions for testVS" -> "View details".
     Expect the details drawer to open with "Components" selected.

  2. Click "Try it".
     Expect "Live tool call" and no "Preview" button.

  3. Fill query="cloudflare" and limit="5".
     Add header X-Tenant-Id=team-a.
     Click "Live invoke".
     Expect "Live invoke 200", "Requested through testVS",
     "Answered by github-mcp", and "Scoped result for github.search_issues".

  4. Terminal should show /api/rpc with:
       method "tools/call"
       params.name "github.search_issues"
       params.server_id "${SERVER_ID}"
       params.arguments query="cloudflare", limit=5
       x-tenant-id "team-a"

  5. Optional RBAC denial:
       PERMISSIONS=NO_EXECUTE node virtual-server-try-it-manual.mjs
     Expect "Live invoke requires tools.execute."

  6. Optional servers.use denial:
       PERMISSIONS=NO_SERVERS_USE node virtual-server-try-it-manual.mjs
     Expect "Live invoke requires servers.use."

  7. Optional empty fetched tools:
       TOOLS=EMPTY node virtual-server-try-it-manual.mjs
     Expect the Try-it empty state instead of fallback associated tool IDs.

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

Latest local manual verification was run against this PR branch on August 30, 2026, using the mock script above and Vite served from the PR checkout with VITE_ENABLE_VIRTUAL_SERVER_TOOL_TRY_IT=true.

# Check Expected Result
1 Script syntax node --check virtual-server-try-it-manual.mjs exits cleanly Pass
2 Mock browser smoke Script opens /app/gateways and logs virtual server actions: ok Pass
3 Flag-gated drawer UI Drawer shows Components and Try it when the flag is enabled Pass
4 Live-only Try-it Try-it shows Live tool call and no Preview button Pass
5 Scoped invoke payload /api/rpc sends method: "tools/call", params.name: "github.search_issues", params.server_id: "76c7b637dafc4d7197f14817ddffeda9", and arguments { "query": "cloudflare", "limit": 5 } Pass
6 Passthrough headers /api/rpc receives x-tenant-id: "team-a" Pass
7 Result context UI renders Live invoke 200, Requested through testVS, Answered by github-mcp, and the mock result Pass

Captured /api/rpc payload from the passing run:

{
  "jsonrpc": "2.0",
  "id": "tool-live-1788096311653",
  "method": "tools/call",
  "params": {
    "name": "github.search_issues",
    "server_id": "76c7b637dafc4d7197f14817ddffeda9",
    "arguments": {
      "query": "cloudflare",
      "limit": 5
    }
  }
}

Captured forwarded headers:

{
  "x-csrf-token": "mock-csrf-token",
  "x-tenant-id": "team-a"
}

Scope of this verification: the manual script is mock-backed. It covers frontend behavior for the virtual-server Try-it tab, fetched attached tools, scoped MCP JSON-RPC payload construction, snippets containing server_id, passthrough headers, RBAC-gated live invoke states, and rendering of virtual-server/backing-gateway result context. It does not verify execution against a real upstream tool.

Signed-off-by: Pratik Gandhi <gandhipratik203@gmail.com>
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.

1 participant