Skip to content

Commit 4bffa62

Browse files
authored
Merge pull request #485 from dr3s/450-template-queries
fix: Resource template query parameters not appended to request URI
2 parents 3772ff3 + cc28a89 commit 4bffa62

File tree

2 files changed

+331
-26
lines changed

2 files changed

+331
-26
lines changed

client/src/components/ResourcesTab.tsx

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ListPane from "./ListPane";
1616
import { useEffect, useState } from "react";
1717
import { useCompletionState } from "@/lib/hooks/useCompletionState";
1818
import JsonView from "./JsonView";
19+
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
1920

2021
const ResourcesTab = ({
2122
resources,
@@ -79,10 +80,7 @@ const ResourcesTab = ({
7980
template: string,
8081
values: Record<string, string>,
8182
): string => {
82-
return template.replace(
83-
/{([^}]+)}/g,
84-
(_, key) => values[key] || `{${key}}`,
85-
);
83+
return new UriTemplate(template).expand(values);
8684
};
8785

8886
const handleTemplateValueChange = async (key: string, value: string) => {
@@ -237,28 +235,27 @@ const ResourcesTab = ({
237235
<p className="text-sm text-gray-600 dark:text-gray-400">
238236
{selectedTemplate.description}
239237
</p>
240-
{selectedTemplate.uriTemplate
241-
.match(/{([^}]+)}/g)
242-
?.map((param) => {
243-
const key = param.slice(1, -1);
244-
return (
245-
<div key={key}>
246-
<Label htmlFor={key}>{key}</Label>
247-
<Combobox
248-
id={key}
249-
placeholder={`Enter ${key}`}
250-
value={templateValues[key] || ""}
251-
onChange={(value) =>
252-
handleTemplateValueChange(key, value)
253-
}
254-
onInputChange={(value) =>
255-
handleTemplateValueChange(key, value)
256-
}
257-
options={completions[key] || []}
258-
/>
259-
</div>
260-
);
261-
})}
238+
{new UriTemplate(
239+
selectedTemplate.uriTemplate,
240+
).variableNames?.map((key) => {
241+
return (
242+
<div key={key}>
243+
<Label htmlFor={key}>{key}</Label>
244+
<Combobox
245+
id={key}
246+
placeholder={`Enter ${key}`}
247+
value={templateValues[key] || ""}
248+
onChange={(value) =>
249+
handleTemplateValueChange(key, value)
250+
}
251+
onInputChange={(value) =>
252+
handleTemplateValueChange(key, value)
253+
}
254+
options={completions[key] || []}
255+
/>
256+
</div>
257+
);
258+
})}
262259
<Button
263260
onClick={handleReadTemplateResource}
264261
disabled={Object.keys(templateValues).length === 0}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import "@testing-library/jest-dom";
3+
import { Tabs } from "@/components/ui/tabs";
4+
import ResourcesTab from "../ResourcesTab";
5+
import { ResourceTemplate, Resource } from "@modelcontextprotocol/sdk/types.js";
6+
7+
// Mock the hooks and components
8+
jest.mock("@/lib/hooks/useCompletionState", () => ({
9+
useCompletionState: () => ({
10+
completions: {},
11+
clearCompletions: jest.fn(),
12+
requestCompletions: jest.fn(),
13+
}),
14+
}));
15+
16+
jest.mock("../JsonView", () => {
17+
return function MockJsonView({ data }: { data: string }) {
18+
return <div data-testid="json-view">{data}</div>;
19+
};
20+
});
21+
22+
jest.mock("@/components/ui/combobox", () => ({
23+
Combobox: ({
24+
id,
25+
value,
26+
onChange,
27+
placeholder,
28+
}: {
29+
id: string;
30+
value: string;
31+
onChange: (value: string) => void;
32+
placeholder: string;
33+
}) => (
34+
<input
35+
id={id}
36+
value={value || ""}
37+
onChange={(e) => onChange(e.target.value)}
38+
placeholder={placeholder}
39+
data-testid={`combobox-${id}`}
40+
/>
41+
),
42+
}));
43+
44+
jest.mock("@/components/ui/label", () => ({
45+
Label: ({
46+
htmlFor,
47+
children,
48+
}: {
49+
htmlFor: string;
50+
children: React.ReactNode;
51+
}) => (
52+
<label htmlFor={htmlFor} data-testid={`label-${htmlFor}`}>
53+
{children}
54+
</label>
55+
),
56+
}));
57+
58+
jest.mock("@/components/ui/button", () => ({
59+
Button: ({
60+
children,
61+
onClick,
62+
disabled,
63+
...props
64+
}: {
65+
children: React.ReactNode;
66+
onClick?: () => void;
67+
disabled?: boolean;
68+
[key: string]: unknown;
69+
}) => (
70+
<button
71+
onClick={onClick}
72+
disabled={disabled}
73+
data-testid="button"
74+
{...props}
75+
>
76+
{children}
77+
</button>
78+
),
79+
}));
80+
81+
describe("ResourcesTab - Template Query Parameters", () => {
82+
const mockListResources = jest.fn();
83+
const mockClearResources = jest.fn();
84+
const mockListResourceTemplates = jest.fn();
85+
const mockClearResourceTemplates = jest.fn();
86+
const mockReadResource = jest.fn();
87+
const mockSetSelectedResource = jest.fn();
88+
const mockHandleCompletion = jest.fn();
89+
const mockSubscribeToResource = jest.fn();
90+
const mockUnsubscribeFromResource = jest.fn();
91+
92+
const mockResourceTemplate: ResourceTemplate = {
93+
name: "Users API",
94+
uriTemplate: "test://users{?name,limit,offset}",
95+
description: "Fetch users with optional filtering and pagination",
96+
};
97+
98+
const mockResource: Resource = {
99+
uri: "test://users?name=john&limit=10&offset=0",
100+
name: "Users Resource",
101+
description: "Expanded users resource",
102+
};
103+
104+
const defaultProps = {
105+
resources: [],
106+
resourceTemplates: [mockResourceTemplate],
107+
listResources: mockListResources,
108+
clearResources: mockClearResources,
109+
listResourceTemplates: mockListResourceTemplates,
110+
clearResourceTemplates: mockClearResourceTemplates,
111+
readResource: mockReadResource,
112+
selectedResource: null,
113+
setSelectedResource: mockSetSelectedResource,
114+
handleCompletion: mockHandleCompletion,
115+
completionsSupported: true,
116+
resourceContent: "",
117+
nextCursor: undefined,
118+
nextTemplateCursor: undefined,
119+
error: null,
120+
resourceSubscriptionsSupported: false,
121+
resourceSubscriptions: new Set<string>(),
122+
subscribeToResource: mockSubscribeToResource,
123+
unsubscribeFromResource: mockUnsubscribeFromResource,
124+
};
125+
126+
const renderResourcesTab = (props = {}) =>
127+
render(
128+
<Tabs defaultValue="resources">
129+
<ResourcesTab {...defaultProps} {...props} />
130+
</Tabs>,
131+
);
132+
133+
beforeEach(() => {
134+
jest.clearAllMocks();
135+
});
136+
137+
it("should parse and display template variables from URI template", () => {
138+
renderResourcesTab();
139+
140+
// Click on the resource template to select it
141+
fireEvent.click(screen.getByText("Users API"));
142+
143+
// Check that input fields are rendered for each template variable
144+
expect(screen.getByTestId("combobox-name")).toBeInTheDocument();
145+
expect(screen.getByTestId("combobox-limit")).toBeInTheDocument();
146+
expect(screen.getByTestId("combobox-offset")).toBeInTheDocument();
147+
});
148+
149+
it("should display template description when template is selected", () => {
150+
renderResourcesTab();
151+
152+
// Click on the resource template to select it
153+
fireEvent.click(screen.getByText("Users API"));
154+
155+
expect(
156+
screen.getByText("Fetch users with optional filtering and pagination"),
157+
).toBeInTheDocument();
158+
});
159+
160+
it("should handle template value changes", () => {
161+
renderResourcesTab();
162+
163+
// Click on the resource template to select it
164+
fireEvent.click(screen.getByText("Users API"));
165+
166+
// Find and fill template value inputs
167+
const nameInput = screen.getByTestId("combobox-name");
168+
const limitInput = screen.getByTestId("combobox-limit");
169+
const offsetInput = screen.getByTestId("combobox-offset");
170+
171+
fireEvent.change(nameInput, { target: { value: "john" } });
172+
fireEvent.change(limitInput, { target: { value: "10" } });
173+
fireEvent.change(offsetInput, { target: { value: "0" } });
174+
175+
expect(nameInput).toHaveValue("john");
176+
expect(limitInput).toHaveValue("10");
177+
expect(offsetInput).toHaveValue("0");
178+
});
179+
180+
it("should expand template and read resource when Read Resource button is clicked", async () => {
181+
renderResourcesTab();
182+
183+
// Click on the resource template to select it
184+
fireEvent.click(screen.getByText("Users API"));
185+
186+
// Fill template values
187+
const nameInput = screen.getByTestId("combobox-name");
188+
const limitInput = screen.getByTestId("combobox-limit");
189+
const offsetInput = screen.getByTestId("combobox-offset");
190+
191+
fireEvent.change(nameInput, { target: { value: "john" } });
192+
fireEvent.change(limitInput, { target: { value: "10" } });
193+
fireEvent.change(offsetInput, { target: { value: "0" } });
194+
195+
// Click Read Resource button
196+
const readResourceButton = screen.getByText("Read Resource");
197+
expect(readResourceButton).not.toBeDisabled();
198+
199+
fireEvent.click(readResourceButton);
200+
201+
// Verify that readResource was called with the expanded URI
202+
expect(mockReadResource).toHaveBeenCalledWith(
203+
"test://users?name=john&limit=10&offset=0",
204+
);
205+
206+
// Verify that setSelectedResource was called with the expanded resource
207+
expect(mockSetSelectedResource).toHaveBeenCalledWith({
208+
uri: "test://users?name=john&limit=10&offset=0",
209+
name: "test://users?name=john&limit=10&offset=0",
210+
});
211+
});
212+
213+
it("should disable Read Resource button when no template values are provided", () => {
214+
renderResourcesTab();
215+
216+
// Click on the resource template to select it
217+
fireEvent.click(screen.getByText("Users API"));
218+
219+
// Read Resource button should be disabled when no values are provided
220+
const readResourceButton = screen.getByText("Read Resource");
221+
expect(readResourceButton).toBeDisabled();
222+
});
223+
224+
it("should handle partial template values correctly", () => {
225+
renderResourcesTab();
226+
227+
// Click on the resource template to select it
228+
fireEvent.click(screen.getByText("Users API"));
229+
230+
// Fill only some template values
231+
const nameInput = screen.getByTestId("combobox-name");
232+
fireEvent.change(nameInput, { target: { value: "john" } });
233+
234+
// Read Resource button should be enabled with partial values
235+
const readResourceButton = screen.getByText("Read Resource");
236+
expect(readResourceButton).not.toBeDisabled();
237+
238+
fireEvent.click(readResourceButton);
239+
240+
// Should expand with only the provided values
241+
expect(mockReadResource).toHaveBeenCalledWith("test://users?name=john");
242+
});
243+
244+
it("should handle special characters in template values", () => {
245+
renderResourcesTab();
246+
247+
// Click on the resource template to select it
248+
fireEvent.click(screen.getByText("Users API"));
249+
250+
// Fill template values with special characters
251+
const nameInput = screen.getByTestId("combobox-name");
252+
fireEvent.change(nameInput, { target: { value: "john doe" } });
253+
254+
fireEvent.click(screen.getByText("Read Resource"));
255+
256+
// Should properly encode special characters
257+
expect(mockReadResource).toHaveBeenCalledWith(
258+
"test://users?name=john%20doe",
259+
);
260+
});
261+
262+
it("should clear template values when switching between templates", () => {
263+
const anotherTemplate: ResourceTemplate = {
264+
name: "Posts API",
265+
uriTemplate: "test://posts{?author,category}",
266+
description: "Fetch posts by author and category",
267+
};
268+
269+
renderResourcesTab({
270+
resourceTemplates: [mockResourceTemplate, anotherTemplate],
271+
});
272+
273+
// Select first template and fill values
274+
fireEvent.click(screen.getByText("Users API"));
275+
const nameInput = screen.getByTestId("combobox-name");
276+
fireEvent.change(nameInput, { target: { value: "john" } });
277+
278+
// Switch to second template
279+
fireEvent.click(screen.getByText("Posts API"));
280+
281+
// Should show new template fields and clear previous values
282+
expect(screen.getByTestId("combobox-author")).toBeInTheDocument();
283+
expect(screen.getByTestId("combobox-category")).toBeInTheDocument();
284+
expect(screen.queryByTestId("combobox-name")).not.toBeInTheDocument();
285+
});
286+
287+
it("should display resource content when a resource is selected", () => {
288+
const resourceContent = '{"users": [{"id": 1, "name": "John"}]}';
289+
290+
renderResourcesTab({
291+
selectedResource: mockResource,
292+
resourceContent: resourceContent,
293+
});
294+
295+
expect(screen.getByTestId("json-view")).toBeInTheDocument();
296+
expect(screen.getByText(resourceContent)).toBeInTheDocument();
297+
});
298+
299+
it("should show alert when no resource or template is selected", () => {
300+
renderResourcesTab();
301+
302+
expect(
303+
screen.getByText(
304+
"Select a resource or template from the list to view its contents",
305+
),
306+
).toBeInTheDocument();
307+
});
308+
});

0 commit comments

Comments
 (0)