From 2f50a4ba0eecc2774b4ac6544b10149452b96835 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 1/6] fix: Resource template query parameters not appended to request URI #450 --- client/src/components/ResourcesTab.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 35742799..1293a0f8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -16,6 +16,7 @@ import ListPane from "./ListPane"; import { useEffect, useState } from "react"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; import JsonView from "./JsonView"; +import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js"; const ResourcesTab = ({ resources, @@ -79,10 +80,10 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - return template.replace( - /{([^}]+)}/g, - (_, key) => values[key] || `{${key}}`, - ); + console.log("Expanding template:", template, "with values:", values); + const result = new UriTemplate(template).expand(values); + console.log("Filled template:", result); + return result; }; const handleTemplateValueChange = async (key: string, value: string) => { @@ -240,7 +241,8 @@ const ResourcesTab = ({ {selectedTemplate.uriTemplate .match(/{([^}]+)}/g) ?.map((param) => { - const key = param.slice(1, -1); + // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name + const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); return (
From f1650d06e3d3a835ecdafe65c8e7ce19f129c317 Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 2/6] use UriTemplate variable names --- client/src/components/ResourcesTab.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 1293a0f8..dd40d1b8 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -238,11 +238,8 @@ const ResourcesTab = ({

{selectedTemplate.description}

- {selectedTemplate.uriTemplate - .match(/{([^}]+)}/g) - ?.map((param) => { - // Remove leading operator characters (?, &, /, #, ;, +, .) from variable name - const key = param.slice(1, -1).replace(/^[?&/#;+.]/, ""); + {new UriTemplate(selectedTemplate.uriTemplate).variableNames + ?.map((key) => { return (
From 07f3e4593b8bb2ffc9cee99fb7813a08ca85f27b Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 3/6] remove logs --- client/src/components/ResourcesTab.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index dd40d1b8..8795e46b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -80,10 +80,7 @@ const ResourcesTab = ({ template: string, values: Record, ): string => { - console.log("Expanding template:", template, "with values:", values); - const result = new UriTemplate(template).expand(values); - console.log("Filled template:", result); - return result; + return new UriTemplate(template).expand(values); }; const handleTemplateValueChange = async (key: string, value: string) => { From 26f744866aefde5acaec7a2da6314b8c915ec9be Mon Sep 17 00:00:00 2001 From: dr3s Date: Tue, 3 Jun 2025 13:58:33 -0400 Subject: [PATCH 4/6] formatting --- client/src/components/ResourcesTab.tsx | 41 +++++++++++++------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index 8795e46b..8ec3f91b 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -235,26 +235,27 @@ const ResourcesTab = ({

{selectedTemplate.description}

- {new UriTemplate(selectedTemplate.uriTemplate).variableNames - ?.map((key) => { - return ( -
- - - handleTemplateValueChange(key, value) - } - onInputChange={(value) => - handleTemplateValueChange(key, value) - } - options={completions[key] || []} - /> -
- ); - })} + {new UriTemplate( + selectedTemplate.uriTemplate, + ).variableNames?.map((key) => { + return ( +
+ + + handleTemplateValueChange(key, value) + } + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} + /> +
+ ); + })} + ), +})); + +describe("ResourcesTab - Template Query Parameters", () => { + const mockListResources = jest.fn(); + const mockClearResources = jest.fn(); + const mockListResourceTemplates = jest.fn(); + const mockClearResourceTemplates = jest.fn(); + const mockReadResource = jest.fn(); + const mockSetSelectedResource = jest.fn(); + const mockHandleCompletion = jest.fn(); + const mockSubscribeToResource = jest.fn(); + const mockUnsubscribeFromResource = jest.fn(); + + const mockResourceTemplate: ResourceTemplate = { + name: "Users API", + uriTemplate: "test://users{?name,limit,offset}", + description: "Fetch users with optional filtering and pagination", + }; + + const mockResource: Resource = { + uri: "test://users?name=john&limit=10&offset=0", + name: "Users Resource", + description: "Expanded users resource", + }; + + const defaultProps = { + resources: [], + resourceTemplates: [mockResourceTemplate], + listResources: mockListResources, + clearResources: mockClearResources, + listResourceTemplates: mockListResourceTemplates, + clearResourceTemplates: mockClearResourceTemplates, + readResource: mockReadResource, + selectedResource: null, + setSelectedResource: mockSetSelectedResource, + handleCompletion: mockHandleCompletion, + completionsSupported: true, + resourceContent: "", + nextCursor: undefined, + nextTemplateCursor: undefined, + error: null, + resourceSubscriptionsSupported: false, + resourceSubscriptions: new Set(), + subscribeToResource: mockSubscribeToResource, + unsubscribeFromResource: mockUnsubscribeFromResource, + }; + + const renderResourcesTab = (props = {}) => + render( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse and display template variables from URI template", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Check that input fields are rendered for each template variable + expect(screen.getByTestId("combobox-name")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-limit")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-offset")).toBeInTheDocument(); + }); + + it("should display template description when template is selected", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + expect(screen.getByText("Fetch users with optional filtering and pagination")).toBeInTheDocument(); + }); + + it("should handle template value changes", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Find and fill template value inputs + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + expect(nameInput).toHaveValue("john"); + expect(limitInput).toHaveValue("10"); + expect(offsetInput).toHaveValue("0"); + }); + + it("should expand template and read resource when Read Resource button is clicked", async () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values + const nameInput = screen.getByTestId("combobox-name"); + const limitInput = screen.getByTestId("combobox-limit"); + const offsetInput = screen.getByTestId("combobox-offset"); + + fireEvent.change(nameInput, { target: { value: "john" } }); + fireEvent.change(limitInput, { target: { value: "10" } }); + fireEvent.change(offsetInput, { target: { value: "0" } }); + + // Click Read Resource button + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Verify that readResource was called with the expanded URI + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john&limit=10&offset=0"); + + // Verify that setSelectedResource was called with the expanded resource + expect(mockSetSelectedResource).toHaveBeenCalledWith({ + uri: "test://users?name=john&limit=10&offset=0", + name: "test://users?name=john&limit=10&offset=0" + }); + }); + + it("should disable Read Resource button when no template values are provided", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Read Resource button should be disabled when no values are provided + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).toBeDisabled(); + }); + + it("should handle partial template values correctly", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill only some template values + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Read Resource button should be enabled with partial values + const readResourceButton = screen.getByText("Read Resource"); + expect(readResourceButton).not.toBeDisabled(); + + fireEvent.click(readResourceButton); + + // Should expand with only the provided values + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john"); + }); + + it("should handle special characters in template values", () => { + renderResourcesTab(); + + // Click on the resource template to select it + fireEvent.click(screen.getByText("Users API")); + + // Fill template values with special characters + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john doe" } }); + + fireEvent.click(screen.getByText("Read Resource")); + + // Should properly encode special characters + expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john%20doe"); + }); + + it("should clear template values when switching between templates", () => { + const anotherTemplate: ResourceTemplate = { + name: "Posts API", + uriTemplate: "test://posts{?author,category}", + description: "Fetch posts by author and category", + }; + + renderResourcesTab({ + resourceTemplates: [mockResourceTemplate, anotherTemplate] + }); + + // Select first template and fill values + fireEvent.click(screen.getByText("Users API")); + const nameInput = screen.getByTestId("combobox-name"); + fireEvent.change(nameInput, { target: { value: "john" } }); + + // Switch to second template + fireEvent.click(screen.getByText("Posts API")); + + // Should show new template fields and clear previous values + expect(screen.getByTestId("combobox-author")).toBeInTheDocument(); + expect(screen.getByTestId("combobox-category")).toBeInTheDocument(); + expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument(); + }); + + it("should display resource content when a resource is selected", () => { + const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; + + renderResourcesTab({ + selectedResource: mockResource, + resourceContent: resourceContent + }); + + expect(screen.getByTestId("json-view")).toBeInTheDocument(); + expect(screen.getByText(resourceContent)).toBeInTheDocument(); + }); + + it("should show alert when no resource or template is selected", () => { + renderResourcesTab(); + + expect(screen.getByText("Select a resource or template from the list to view its contents")).toBeInTheDocument(); + }); +}); From 9508cc46271b5965accda35e98c3a93f5cc4de96 Mon Sep 17 00:00:00 2001 From: dr3s Date: Wed, 4 Jun 2025 14:44:15 -0400 Subject: [PATCH 6/6] pretty --- .../__tests__/ResourcesTab.test.tsx | 51 ++++++++++++++----- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/client/src/components/__tests__/ResourcesTab.test.tsx b/client/src/components/__tests__/ResourcesTab.test.tsx index dd12cced..d564b02c 100644 --- a/client/src/components/__tests__/ResourcesTab.test.tsx +++ b/client/src/components/__tests__/ResourcesTab.test.tsx @@ -20,7 +20,12 @@ jest.mock("../JsonView", () => { }); jest.mock("@/components/ui/combobox", () => ({ - Combobox: ({ id, value, onChange, placeholder }: { + Combobox: ({ + id, + value, + onChange, + placeholder, + }: { id: string; value: string; onChange: (value: string) => void; @@ -37,7 +42,10 @@ jest.mock("@/components/ui/combobox", () => ({ })); jest.mock("@/components/ui/label", () => ({ - Label: ({ htmlFor, children }: { + Label: ({ + htmlFor, + children, + }: { htmlFor: string; children: React.ReactNode; }) => ( @@ -48,7 +56,12 @@ jest.mock("@/components/ui/label", () => ({ })); jest.mock("@/components/ui/button", () => ({ - Button: ({ children, onClick, disabled, ...props }: { + Button: ({ + children, + onClick, + disabled, + ...props + }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; @@ -139,7 +152,9 @@ describe("ResourcesTab - Template Query Parameters", () => { // Click on the resource template to select it fireEvent.click(screen.getByText("Users API")); - expect(screen.getByText("Fetch users with optional filtering and pagination")).toBeInTheDocument(); + expect( + screen.getByText("Fetch users with optional filtering and pagination"), + ).toBeInTheDocument(); }); it("should handle template value changes", () => { @@ -180,16 +195,18 @@ describe("ResourcesTab - Template Query Parameters", () => { // Click Read Resource button const readResourceButton = screen.getByText("Read Resource"); expect(readResourceButton).not.toBeDisabled(); - + fireEvent.click(readResourceButton); // Verify that readResource was called with the expanded URI - expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john&limit=10&offset=0"); - + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john&limit=10&offset=0", + ); + // Verify that setSelectedResource was called with the expanded resource expect(mockSetSelectedResource).toHaveBeenCalledWith({ uri: "test://users?name=john&limit=10&offset=0", - name: "test://users?name=john&limit=10&offset=0" + name: "test://users?name=john&limit=10&offset=0", }); }); @@ -217,7 +234,7 @@ describe("ResourcesTab - Template Query Parameters", () => { // Read Resource button should be enabled with partial values const readResourceButton = screen.getByText("Read Resource"); expect(readResourceButton).not.toBeDisabled(); - + fireEvent.click(readResourceButton); // Should expand with only the provided values @@ -237,7 +254,9 @@ describe("ResourcesTab - Template Query Parameters", () => { fireEvent.click(screen.getByText("Read Resource")); // Should properly encode special characters - expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john%20doe"); + expect(mockReadResource).toHaveBeenCalledWith( + "test://users?name=john%20doe", + ); }); it("should clear template values when switching between templates", () => { @@ -248,7 +267,7 @@ describe("ResourcesTab - Template Query Parameters", () => { }; renderResourcesTab({ - resourceTemplates: [mockResourceTemplate, anotherTemplate] + resourceTemplates: [mockResourceTemplate, anotherTemplate], }); // Select first template and fill values @@ -267,10 +286,10 @@ describe("ResourcesTab - Template Query Parameters", () => { it("should display resource content when a resource is selected", () => { const resourceContent = '{"users": [{"id": 1, "name": "John"}]}'; - + renderResourcesTab({ selectedResource: mockResource, - resourceContent: resourceContent + resourceContent: resourceContent, }); expect(screen.getByTestId("json-view")).toBeInTheDocument(); @@ -280,6 +299,10 @@ describe("ResourcesTab - Template Query Parameters", () => { it("should show alert when no resource or template is selected", () => { renderResourcesTab(); - expect(screen.getByText("Select a resource or template from the list to view its contents")).toBeInTheDocument(); + expect( + screen.getByText( + "Select a resource or template from the list to view its contents", + ), + ).toBeInTheDocument(); }); });