Skip to content

Commit 09563dd

Browse files
authored
feat: activate resource completions (#107)
* mcpSdk, wrapper for mods, adds for generating resource permutations * resources, pf.getResources, fix for encoding URIs * resource.helpers, completion helper for filtering params * resources, uri templates, activate completion for most resources * server, expand resource typing, activate completions * e2e, uri param checks for resources
1 parent c2b7176 commit 09563dd

26 files changed

+1352
-162
lines changed

src/__tests__/__snapshots__/resource.patternFlyComponentsIndex.test.ts.snap

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ exports[`patternFlyComponentsIndexResource should have a consistent return struc
55
"config": true,
66
"handler": [Function],
77
"name": "patternfly-components-index",
8-
"uri": "patternfly://components/index",
8+
"uri": ResourceTemplate {
9+
"_callbacks": {
10+
"complete": {
11+
"category": [Function],
12+
"version": [Function],
13+
},
14+
"list": [Function],
15+
},
16+
"_uriTemplate": UriTemplate {
17+
"parts": [
18+
"patternfly://components/index",
19+
{
20+
"exploded": false,
21+
"name": "version",
22+
"names": [
23+
"version",
24+
"category",
25+
],
26+
"operator": "?",
27+
},
28+
],
29+
"template": "patternfly://components/index{?version,category}",
30+
},
31+
},
932
}
1033
`;

src/__tests__/__snapshots__/resource.patternFlyDocsIndex.test.ts.snap

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@ exports[`patternFlyDocsIndexResource should have a consistent return structure:
55
"config": true,
66
"handler": [Function],
77
"name": "patternfly-docs-index",
8-
"uri": "patternfly://docs/index",
8+
"uri": ResourceTemplate {
9+
"_callbacks": {
10+
"complete": {
11+
"category": [Function],
12+
"section": [Function],
13+
"version": [Function],
14+
},
15+
"list": [Function],
16+
},
17+
"_uriTemplate": UriTemplate {
18+
"parts": [
19+
"patternfly://docs/index",
20+
{
21+
"exploded": false,
22+
"name": "version",
23+
"names": [
24+
"version",
25+
"category",
26+
"section",
27+
],
28+
"operator": "?",
29+
},
30+
],
31+
"template": "patternfly://docs/index{?version,category,section}",
32+
},
33+
},
934
}
1035
`;

src/__tests__/__snapshots__/resource.patternFlyDocsTemplate.test.ts.snap

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ exports[`patternFlyDocsTemplateResource should have a consistent return structur
77
"name": "patternfly-docs-template",
88
"uri": ResourceTemplate {
99
"_callbacks": {
10+
"complete": {
11+
"category": [Function],
12+
"name": [Function],
13+
"section": [Function],
14+
"version": [Function],
15+
},
1016
"list": undefined,
1117
},
1218
"_uriTemplate": UriTemplate {
@@ -20,8 +26,18 @@ exports[`patternFlyDocsTemplateResource should have a consistent return structur
2026
],
2127
"operator": "",
2228
},
29+
{
30+
"exploded": false,
31+
"name": "version",
32+
"names": [
33+
"version",
34+
"category",
35+
"section",
36+
],
37+
"operator": "?",
38+
},
2339
],
24-
"template": "patternfly://docs/{name}",
40+
"template": "patternfly://docs/{name}{?version,category,section}",
2541
},
2642
},
2743
}

src/__tests__/__snapshots__/resource.patternFlySchemasIndex.test.ts.snap

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ exports[`patternFlySchemasIndexResource should have a consistent return structur
55
"config": true,
66
"handler": [Function],
77
"name": "patternfly-schemas-index",
8-
"uri": "patternfly://schemas/index",
8+
"uri": ResourceTemplate {
9+
"_callbacks": {
10+
"complete": {
11+
"category": [Function],
12+
"version": [Function],
13+
},
14+
"list": [Function],
15+
},
16+
"_uriTemplate": UriTemplate {
17+
"parts": [
18+
"patternfly://schemas/index",
19+
{
20+
"exploded": false,
21+
"name": "version",
22+
"names": [
23+
"version",
24+
"category",
25+
],
26+
"operator": "?",
27+
},
28+
],
29+
"template": "patternfly://schemas/index{?version,category}",
30+
},
31+
},
932
}
1033
`;

src/__tests__/__snapshots__/resource.patternFlySchemasTemplate.test.ts.snap

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ exports[`patternFlySchemasTemplateResource should have a consistent return struc
77
"name": "patternfly-schemas-template",
88
"uri": ResourceTemplate {
99
"_callbacks": {
10+
"complete": {
11+
"category": [Function],
12+
"name": [Function],
13+
"version": [Function],
14+
},
1015
"list": undefined,
1116
},
1217
"_uriTemplate": UriTemplate {
@@ -20,8 +25,17 @@ exports[`patternFlySchemasTemplateResource should have a consistent return struc
2025
],
2126
"operator": "",
2227
},
28+
{
29+
"exploded": false,
30+
"name": "version",
31+
"names": [
32+
"version",
33+
"category",
34+
],
35+
"operator": "?",
36+
},
2337
],
24-
"template": "patternfly://schemas/{name}",
38+
"template": "patternfly://schemas/{name}{?version,category}",
2539
},
2640
},
2741
}

src/__tests__/__snapshots__/server.test.ts.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ exports[`runServer should attempt to run server, create transport, connect, and
158158
},
159159
{
160160
"capabilities": {
161+
"completions": {},
161162
"resources": {},
162163
"tools": {},
163164
},
@@ -221,6 +222,7 @@ exports[`runServer should attempt to run server, disable SIGINT handler: diagnos
221222
},
222223
{
223224
"capabilities": {
225+
"completions": {},
224226
"resources": {},
225227
"tools": {},
226228
},
@@ -279,6 +281,7 @@ exports[`runServer should attempt to run server, enable SIGINT handler explicitl
279281
},
280282
{
281283
"capabilities": {
284+
"completions": {},
282285
"resources": {},
283286
"tools": {},
284287
},
@@ -351,6 +354,7 @@ exports[`runServer should attempt to run server, register a tool: diagnostics 1`
351354
},
352355
{
353356
"capabilities": {
357+
"completions": {},
354358
"resources": {},
355359
"tools": {},
356360
},
@@ -434,6 +438,7 @@ exports[`runServer should attempt to run server, register multiple tools: diagno
434438
},
435439
{
436440
"capabilities": {
441+
"completions": {},
437442
"resources": {},
438443
"tools": {},
439444
},
@@ -500,6 +505,7 @@ exports[`runServer should attempt to run server, use custom options: diagnostics
500505
},
501506
{
502507
"capabilities": {
508+
"completions": {},
503509
"resources": {},
504510
"tools": {},
505511
},
@@ -572,6 +578,7 @@ exports[`runServer should attempt to run server, use default tools, http: diagno
572578
},
573579
{
574580
"capabilities": {
581+
"completions": {},
575582
"resources": {},
576583
"tools": {},
577584
},
@@ -648,6 +655,7 @@ exports[`runServer should attempt to run server, use default tools, stdio: diagn
648655
},
649656
{
650657
"capabilities": {
658+
"completions": {},
651659
"resources": {},
652660
"tools": {},
653661
},

src/__tests__/mcpSdk.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import { UriTemplate } from '@modelcontextprotocol/sdk/shared/uriTemplate.js';
3+
import { registerResource } from '../mcpSdk';
4+
5+
describe('registerResource', () => {
6+
// If this test starts to fail, indicating it is matching partial queries, then the next step is to
7+
// investigate `UriTemplate` and determine if our `registerResource` implementation is still necessary.
8+
it('should demonstrate that MCP SDK UriTemplate requires all variables for query templates', () => {
9+
const templateStr = 'patternfly://test{?name,version}';
10+
const template = new UriTemplate(templateStr);
11+
12+
// 1. Full match works
13+
const fullUri = 'patternfly://test?name=button&version=v6';
14+
15+
expect(template.match(fullUri)).toEqual({ name: 'button', version: 'v6' });
16+
17+
// 2. Partial match (missing version) FAILS in the MCP SDK UriTemplate implementation
18+
// This is the "limitation" that registerResource works around by registering 'patternfly://test{?name}'
19+
const partialUri = 'patternfly://test?name=button';
20+
21+
expect(template.match(partialUri)).toBeNull();
22+
23+
// 3. Base URI match (no params) FAILS
24+
const baseUri = 'patternfly://test';
25+
26+
expect(template.match(baseUri)).toBeNull();
27+
});
28+
29+
it('should register a callback that receives a static uri for resources', async () => {
30+
const mockServer = {
31+
registerResource: jest.fn()
32+
};
33+
const name = 'test-resource';
34+
const uriStatic = 'patternfly://test/index';
35+
const config = { title: 'Test', description: 'Test', mimeType: 'text/markdown' as const };
36+
const callback = jest.fn().mockImplementation((passedUri: URL) => ({
37+
contents: [{
38+
uri: passedUri?.toString(),
39+
mimeType: 'text/markdown',
40+
text: ''
41+
}]
42+
}));
43+
44+
registerResource(
45+
mockServer as any,
46+
name,
47+
uriStatic,
48+
config,
49+
callback as any,
50+
undefined
51+
);
52+
53+
const registeredCallback = mockServer.registerResource.mock.calls[0][3];
54+
const uri = new URL('patternfly://test/index');
55+
const result = await registeredCallback(uri, {});
56+
57+
expect(result.contents[0].uri).toBe(uri.toString());
58+
});
59+
60+
it('should register a callback that receives a template uri that handles variables', async () => {
61+
const mockServer = {
62+
registerResource: jest.fn()
63+
};
64+
const name = 'test-resource';
65+
const uriTemplate = new ResourceTemplate('patternfly://test/{name}', { list: undefined });
66+
const config = { title: 'Test', description: 'Test', mimeType: 'text/markdown' as const };
67+
const callback = jest.fn().mockImplementation((passedUri: URL, variables: any) => ({
68+
contents: [{
69+
uri: passedUri?.toString(),
70+
mimeType: 'text/markdown',
71+
text: `name=${variables?.name || ''}`
72+
}]
73+
}));
74+
75+
registerResource(
76+
mockServer as any,
77+
name,
78+
uriTemplate,
79+
config,
80+
callback as any,
81+
undefined
82+
);
83+
84+
const registeredCallback = mockServer.registerResource.mock.calls[0][3];
85+
const uri = new URL('patternfly://test/button');
86+
const variables = { name: 'button', version: 'v6' };
87+
const result = await registeredCallback(uri, variables);
88+
89+
expect(result.contents[0].uri).toBe(uri.toString());
90+
expect(result.contents[0].text).toBe('name=button');
91+
expect(callback).toHaveBeenCalledWith(uri, variables);
92+
});
93+
94+
it('should register incremental permutations for query templates', () => {
95+
const mockServer = { registerResource: jest.fn() };
96+
const name = 'test-resource';
97+
const uriTemplate = new ResourceTemplate('patternfly://test{?name,version,category}', { list: undefined });
98+
const config = { title: 'Test', description: 'Test', mimeType: 'text/markdown' as const };
99+
const callback = jest.fn();
100+
101+
registerResource(mockServer as any, name, uriTemplate, config, callback, {} as any);
102+
103+
// Expected registrations in order (Reverse order for SDK matching):
104+
// 1. Original: patternfly://test{?name,version,category}
105+
// 2. Incremental: patternfly://test{?name,version}
106+
// 3. Incremental: patternfly://test{?name}
107+
// 4. Base: patternfly://test
108+
expect(mockServer.registerResource).toHaveBeenCalledTimes(4);
109+
110+
const calls: any[] = mockServer.registerResource.mock.calls.map(call => ({ name: call[0], template: call[1].uriTemplate.template }));
111+
112+
expect(calls[0].template).toBe('patternfly://test{?name,version,category}');
113+
expect(calls[1].template).toBe('patternfly://test{?name,version}');
114+
expect(calls[2].template).toBe('patternfly://test{?name}');
115+
expect(calls[3].template).toBe('patternfly://test');
116+
117+
// Verify the original name
118+
expect(calls[0].name).toBe(name);
119+
120+
// Verify name incrementation
121+
expect(calls[1].name).toBe('test-resource-name-version');
122+
expect(calls[2].name).toBe('test-resource-name');
123+
expect(calls[3].name).toBe('test-resource-empty');
124+
});
125+
126+
it('should register all parameter permutations for query templates, when enabled', () => {
127+
const mockServer = { registerResource: jest.fn() };
128+
const name = 'test-resource';
129+
const uriTemplate = new ResourceTemplate('patternfly://test{?a,b}', { list: undefined });
130+
const config = { title: 'Test', description: 'Test', mimeType: 'text/markdown' as const };
131+
const callback = jest.fn();
132+
133+
registerResource(mockServer as any, name, uriTemplate, config, callback, {
134+
registerAllSearchCombinations: true
135+
} as any);
136+
137+
// Expected combinations for {a, b}:
138+
// Original: {a, b}
139+
// Others: {b}, {a}, {} (empty)
140+
// Total: 4
141+
expect(mockServer.registerResource).toHaveBeenCalledTimes(4);
142+
143+
const calls: any[] = mockServer.registerResource.mock.calls.map(call => call[1].uriTemplate.template);
144+
145+
// Permutation sequence could shift, just do a check for "contains"
146+
expect(calls).toContain('patternfly://test{?a,b}');
147+
expect(calls).toContain('patternfly://test{?b}');
148+
expect(calls).toContain('patternfly://test{?a}');
149+
expect(calls).toContain('patternfly://test');
150+
});
151+
});

0 commit comments

Comments
 (0)