Skip to content
Merged
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
116 changes: 108 additions & 8 deletions e2e/resources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,98 @@ test.describe("Resources page", () => {
await expect(panel).not.toBeVisible();
});

test.describe("Try it preview", () => {
test("renders a preview in the details panel Try it tab", async ({ page }) => {
await page.route("**/resources?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([RESOURCE_A1]),
});
});

let requestedUrl: string | null = null;
await page.route("**/v1/resources/test/**", async (route) => {
requestedUrl = route.request().url();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
content: { mimeType: "text/plain", text: "hello from document-txt" },
}),
});
});

await page.goto(APP.RESOURCES);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

// Try it is the default tab, so Preview is available without switching tabs.
const panel = page.getByRole("region", { name: /Resources for github-server/i });
await expect(panel).toBeVisible();
await panel.getByRole("button", { name: "Preview" }).click();

await expect.poll(() => requestedUrl).not.toBeNull();
expect(requestedUrl).toContain(`/v1/resources/test/${encodeURI(RESOURCE_A1.uri)}`);

await expect(panel).toContainText("200 OK");
await expect(panel).toContainText("hello from document-txt");
});

test("disables Preview until every uriTemplate placeholder is filled, then sends the resolved uri", async ({
page,
}) => {
const TEMPLATED = makeResource("repo-contents", "github-server", {
uri: "github://repos/{owner}/{repo}",
uriTemplate: "github://repos/{owner}/{repo}",
});

await page.route("**/resources?*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([TEMPLATED]),
});
});

let requestedUrl: string | null = null;
await page.route("**/v1/resources/test/**", async (route) => {
requestedUrl = route.request().url();
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ content: { mimeType: "text/plain", text: "readme contents" } }),
});
});

await page.goto(APP.RESOURCES);
await page.waitForLoadState("networkidle");

await page.getByRole("button", { name: "More options for github-server" }).click();
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Resources for github-server/i });
const previewButton = panel.getByRole("button", { name: "Preview" });
await expect(previewButton).toBeDisabled();

await panel.getByLabel(/owner/).fill("ibm");
await expect(previewButton).toBeDisabled();

await panel.getByLabel(/repo/).fill("mcp-context-forge");
await expect(previewButton).toBeEnabled();

await previewButton.click();

await expect.poll(() => requestedUrl).not.toBeNull();
expect(requestedUrl).toContain(
`/v1/resources/test/${encodeURI("github://repos/ibm/mcp-context-forge")}`,
);
await expect(panel).toContainText("readme contents");
});
});

test.describe("Delete resource", () => {
test("cancel in confirm dialog keeps resource visible", async ({ page }) => {
await page.route("**/resources?*", async (route) => {
Expand All @@ -283,7 +375,8 @@ test.describe("Resources page", () => {
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Resources for github-server/i });
await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for document-txt" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();

const dialog = page.getByRole("dialog", { name: "Delete resource" });
Expand Down Expand Up @@ -329,7 +422,8 @@ test.describe("Resources page", () => {
await expect(panel).toBeVisible();
await expect(panel.getByText("document-txt").first()).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for document-txt" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();

const dialog = page.getByRole("dialog", { name: "Delete resource" });
Expand Down Expand Up @@ -376,7 +470,8 @@ test.describe("Resources page", () => {
const panel = page.getByRole("region", { name: /Resources for github-server/i });
await expect(panel).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for document-txt" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();

const dialog = page.getByRole("dialog", { name: "Delete resource" });
Expand Down Expand Up @@ -420,7 +515,8 @@ test.describe("Resources page", () => {
const panel = page.getByRole("region", { name: /Resources for solo-gateway/i });
await expect(panel).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for solo_resource" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();

await page
Expand Down Expand Up @@ -469,7 +565,8 @@ test.describe("Resources page", () => {
await expect(panel.getByText("alpha_resource").first()).toBeVisible();
await expect(panel.getByText("beta_resource").first()).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for alpha_resource" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page
.getByRole("dialog", { name: "Delete resource" })
Expand Down Expand Up @@ -517,7 +614,8 @@ test.describe("Resources page", () => {
const panel = page.getByRole("region", { name: /Resources for lone-gateway/i });
await expect(panel).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: "More options for lone_resource" }).click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page
.getByRole("dialog", { name: "Delete resource" })
Expand Down Expand Up @@ -572,7 +670,8 @@ test.describe("Resources page", () => {
const panel = page.getByRole("region", { name: /Resources for github-server/i });
await expect(panel).toBeVisible();

await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: `More options for ${RESOURCE_A1.name}` }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();

await expect(page.getByRole("heading", { name: "Edit resource" })).toBeVisible();
Expand Down Expand Up @@ -626,7 +725,8 @@ test.describe("Resources page", () => {
await page.getByRole("menuitem", { name: "View details" }).click();

const panel = page.getByRole("region", { name: /Resources for github-server/i });
await panel.getByRole("button", { name: "More options" }).first().click();
await panel.getByRole("tab", { name: "Definition" }).click();
await panel.getByRole("button", { name: `More options for ${RESOURCE_A1.name}` }).click();
await page.getByRole("menuitem", { name: "Edit" }).click();

await expect(page.getByLabel(/Content/)).toHaveValue("original content");
Expand Down
7 changes: 7 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ export const api = {
return request<T>(path, { method: "GET", headers, signal, ...opts });
},

getWithMeta<T>(
path: string,
opts?: Omit<RequestOptions, "method" | "body">,
): Promise<ResponseWithMeta<T>> {
return requestWithMeta<T>(path, { method: "GET", ...opts });
},

post<T>(
path: string,
body?: unknown,
Expand Down
44 changes: 44 additions & 0 deletions src/api/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,50 @@ describe("resourcesApi", () => {
});
});

describe("test", () => {
it("GETs /v1/resources/test/:uri (slashes preserved) and returns content + status", async () => {
mockFetch.mockResolvedValueOnce(
okJson({ content: { mimeType: "text/plain", text: "hello" } }),
);

const result = await resourcesApi.test("file:///tmp/a.txt");

expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/v1/resources/test/file:///tmp/a.txt"),
expect.objectContaining({ method: "GET" }),
);
expect(result).toEqual({ content: { mimeType: "text/plain", text: "hello" }, status: 200 });
});

it("percent-encodes ? and # so the uri survives as a full path segment", async () => {
mockFetch.mockResolvedValueOnce(
okJson({ content: { mimeType: "text/plain", text: "hello" } }),
);

await resourcesApi.test("file:///a?b#c");

const requestedUrl = String(mockFetch.mock.calls[0][0]);
expect(requestedUrl).toContain("/v1/resources/test/file:///a%3Fb%23c");
// Unescaped, `?`/`#` would truncate the path here instead of reaching the backend.
expect(requestedUrl).not.toContain("/v1/resources/test/file:///a?b#c");
});

it("throws synchronously for an empty URI", () => {
expect(() => resourcesApi.test("")).toThrow("Invalid resource URI");
});

it("throws ApiError on a non-2xx response", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ detail: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
}),
);

await expect(resourcesApi.test("resource://missing")).rejects.toThrow("HTTP 404");
});
});

describe("validateResourceId (via delete)", () => {
it("rejects an empty id", () => {
expect(() => resourcesApi.delete("")).toThrow(/^Invalid resource ID$/);
Expand Down
62 changes: 62 additions & 0 deletions src/api/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,45 @@ function validateResourceId(id: string): string {
return id;
}

/**
* Loose shape of the `content` payload returned by {@link resourcesApi.test}.
*
* The backend returns `Dict[str, Any]` (see `test_resource_by_uri` in
* `mcpgateway/main.py`) — depending on the read path this is either a
* `ResourceContent`/`ResourceContents` model dump (`mimeType`, `text`,
* `blob`) or, for some template/direct-fetch paths, a raw dict with
* snake_case keys (`mime_type`). Callers should read through a helper that
* checks both key spellings (see `ResourcePreviewResult.normalizeMimeType`)
* rather than indexing a single key directly.
*/
export interface ResourceTestContent {
uri?: string;
mimeType?: string | null;
mime_type?: string | null;
text?: string | null;
blob?: string | null;
size?: number | null;
[key: string]: unknown;
}

export interface ResourceTestResult {
content: ResourceTestContent;
status: number;
}

/**
* `encodeURI` deliberately leaves `/` and `:` raw (see {@link resourcesApi.test}),
* but it also leaves `?` and `#` raw — and those two are structural to a URL:
* unescaped, `?` starts a query string and `#` starts a fragment (fragments
* never even leave the browser), silently truncating the path the backend
* receives. Percent-encode just those two so a uri containing them still
* reaches the `:path` route intact; the backend's path converter decodes
* `%3F`/`%23` back to the literal chars like any other percent-escape.
*/
function encodeResourceTestUri(uri: string): string {
return encodeURI(uri).replace(/[?#]/g, (ch) => (ch === "?" ? "%3F" : "%23"));
}

export const resourcesApi = {
/**
* Create a new resource
Expand All @@ -37,6 +76,29 @@ export const resourcesApi = {
return api.post("/resources", data);
},

/**
* Read a resource's resolved content by URI, without going through the
* cached `GET /resources/{id}` path. Backs the Resources "Try it" preview.
*
* Mirrors `GET /v1/resources/test/{resource_uri:path}` (`mcpgateway/main.py`).
* The URI is passed through `encodeURI` (not
* `encodeURIComponent`) so `/` and `:` in the URI survive as path
* separators rather than being percent-escaped — the backend's `:path`
* converter expects the raw URI, matching how `resources/read` addresses
* resources on the MCP wire.
*/
test: (uri: string, options: { signal?: AbortSignal } = {}): Promise<ResourceTestResult> => {
if (!uri || typeof uri !== "string") {
throw new Error("Invalid resource URI");
}
return api
.getWithMeta<{ content: ResourceTestContent }>(
`/v1/resources/test/${encodeResourceTestUri(uri)}`,
{ signal: options.signal },
)
.then(({ data, status }) => ({ content: data.content, status }));
},

/**
* Update a resource
*/
Expand Down
6 changes: 4 additions & 2 deletions src/components/prompts/PromptDefinitionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export function PromptDefinitionTable({
const intl = useIntl();

return (
<Table className="min-w-full border-separate border-spacing-y-1.5">
<Table className="min-w-full table-fixed border-separate border-spacing-y-1.5">
<TableHeader>
<TableRow className="border-none hover:bg-transparent">
<TableHead className="h-9 w-[30%] border-b border-border px-4 py-2.5 text-xs font-medium">
Expand Down Expand Up @@ -82,7 +82,9 @@ export function PromptDefinitionTable({
className="cursor-pointer border-0 bg-neutral-50 hover:bg-neutral-100 data-[state=selected]:bg-neutral-200 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset dark:bg-neutral-800/50 dark:hover:bg-neutral-700/60 dark:data-[state=selected]:bg-neutral-700 [&>td:first-child]:rounded-l-lg [&>td:last-child]:rounded-r-lg"
>
<TableCell className="px-4 py-3 text-sm text-foreground">
<span className="line-clamp-1">{prompt.displayName || prompt.name}</span>
<span className="block truncate" title={prompt.displayName || prompt.name}>
{prompt.displayName || prompt.name}
</span>
</TableCell>

<TableCell className="px-4 py-3">
Expand Down
Loading
Loading