mcp-medic 1.2.4 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/checks/index.d.ts +1 -0
- package/dist/checks/index.js +3 -0
- package/dist/checks/quality-resources.d.ts +4 -4
- package/dist/checks/quality-resources.js +121 -66
- package/dist/checks/security-hidden-unicode-tags.d.ts +2 -0
- package/dist/checks/security-hidden-unicode-tags.js +106 -0
- package/dist/protocol/connect.js +88 -4
- package/dist/report.js +5 -0
- package/dist/types.d.ts +25 -2
- package/package.json +1 -1
package/dist/checks/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
|
6
6
|
export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
7
7
|
export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
8
8
|
export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
9
|
+
export { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
|
|
9
10
|
export { qualityToolNamesCheck } from './quality-tool-names.js';
|
|
10
11
|
export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
|
|
11
12
|
export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
|
package/dist/checks/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
|
6
6
|
export { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
7
7
|
export { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
8
8
|
export { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
9
|
+
export { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
|
|
9
10
|
export { qualityToolNamesCheck } from './quality-tool-names.js';
|
|
10
11
|
export { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
|
|
11
12
|
export { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
|
|
@@ -22,6 +23,7 @@ import { sampleCallSimulationCheck } from './sample-call-simulation.js';
|
|
|
22
23
|
import { securityUntrustedRemoteCheck } from './security-untrusted-remote.js';
|
|
23
24
|
import { securityOverbroadPermissionsCheck } from './security-overbroad-permissions.js';
|
|
24
25
|
import { securityPromptInjectionRiskCheck } from './security-prompt-injection-risk.js';
|
|
26
|
+
import { securityHiddenUnicodeTagsCheck } from './security-hidden-unicode-tags.js';
|
|
25
27
|
import { qualityToolNamesCheck } from './quality-tool-names.js';
|
|
26
28
|
import { qualityToolDescriptionsCheck } from './quality-tool-descriptions.js';
|
|
27
29
|
import { qualityToolOutputSchemaCheck } from './quality-tool-output-schema.js';
|
|
@@ -40,6 +42,7 @@ export const allChecks = [
|
|
|
40
42
|
securityUntrustedRemoteCheck,
|
|
41
43
|
securityOverbroadPermissionsCheck,
|
|
42
44
|
securityPromptInjectionRiskCheck,
|
|
45
|
+
securityHiddenUnicodeTagsCheck,
|
|
43
46
|
qualityToolNamesCheck,
|
|
44
47
|
qualityToolDescriptionsCheck,
|
|
45
48
|
qualityToolOutputSchemaCheck,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Check } from '../types.js';
|
|
2
2
|
/**
|
|
3
|
-
* Inspects only what `resources/list`
|
|
4
|
-
* MCPConnection.resources, populated
|
|
5
|
-
* Never calls `resources/read` —
|
|
6
|
-
* of scope for a passive `check`.
|
|
3
|
+
* Inspects only what `resources/list` and `resources/templates/list` already
|
|
4
|
+
* returned (see MCPConnection.resources/resourceTemplates, populated
|
|
5
|
+
* passively in src/protocol/connect.ts). Never calls `resources/read` —
|
|
6
|
+
* that would be real content retrieval, out of scope for a passive `check`.
|
|
7
7
|
*/
|
|
8
8
|
export declare const qualityResourcesCheck: Check;
|
|
@@ -1,80 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Inspects only what `resources/list`
|
|
3
|
-
* MCPConnection.resources, populated
|
|
4
|
-
* Never calls `resources/read` —
|
|
5
|
-
* of scope for a passive `check`.
|
|
2
|
+
* Inspects only what `resources/list` and `resources/templates/list` already
|
|
3
|
+
* returned (see MCPConnection.resources/resourceTemplates, populated
|
|
4
|
+
* passively in src/protocol/connect.ts). Never calls `resources/read` —
|
|
5
|
+
* that would be real content retrieval, out of scope for a passive `check`.
|
|
6
6
|
*/
|
|
7
7
|
export const qualityResourcesCheck = {
|
|
8
8
|
id: 'quality.resource',
|
|
9
|
-
description: 'Flags duplicate/empty resource URIs, missing required names, and other resources/list quality issues.',
|
|
9
|
+
description: 'Flags duplicate/empty resource(-template) URIs, missing required names, and other resources/list quality issues.',
|
|
10
10
|
run(connection) {
|
|
11
11
|
const results = [];
|
|
12
12
|
try {
|
|
13
13
|
const resources = connection.resources;
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
14
|
+
if (Array.isArray(resources)) {
|
|
15
|
+
const byUri = new Map();
|
|
16
|
+
for (const resource of resources) {
|
|
17
|
+
const uri = resource.uri;
|
|
18
|
+
if (typeof uri !== 'string' || uri.trim() === '') {
|
|
19
|
+
results.push({
|
|
20
|
+
checkId: 'quality.resource',
|
|
21
|
+
severity: 'error',
|
|
22
|
+
message: 'Resource has an empty or invalid "uri" — the MCP spec requires resources to have a URI.',
|
|
23
|
+
serverName: connection.server.name,
|
|
24
|
+
category: 'schema',
|
|
25
|
+
details: { resource },
|
|
26
|
+
});
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
byUri.set(uri, (byUri.get(uri) ?? 0) + 1);
|
|
30
|
+
// Per the MCP spec's Resource type (extends BaseMetadata), "name" is
|
|
31
|
+
// a required field, not optional.
|
|
32
|
+
if (!resource.name || resource.name.trim() === '') {
|
|
33
|
+
results.push({
|
|
34
|
+
checkId: 'quality.resource',
|
|
35
|
+
severity: 'error',
|
|
36
|
+
message: `Resource "${uri}" is missing a "name" — required by the MCP spec's Resource type.`,
|
|
37
|
+
serverName: connection.server.name,
|
|
38
|
+
category: 'schema',
|
|
39
|
+
details: { uri },
|
|
40
|
+
suggestedFix: { description: `Add a "name" field to the resource at "${uri}".` },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// description is optional per spec — absence is a quality
|
|
44
|
+
// recommendation, not a violation.
|
|
45
|
+
if (!resource.description || resource.description.trim() === '') {
|
|
46
|
+
results.push({
|
|
47
|
+
checkId: 'quality.resource',
|
|
48
|
+
severity: 'info',
|
|
49
|
+
message: `Resource "${uri}" has no description, making it harder for an agent to know when to read it.`,
|
|
50
|
+
serverName: connection.server.name,
|
|
51
|
+
category: 'quality',
|
|
52
|
+
details: { uri },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (resource.size !== undefined && (typeof resource.size !== 'number' || resource.size < 0)) {
|
|
56
|
+
results.push({
|
|
57
|
+
checkId: 'quality.resource',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: `Resource "${uri}" declares an invalid "size" (${JSON.stringify(resource.size)}) — size must be a non-negative number.`,
|
|
60
|
+
serverName: connection.server.name,
|
|
61
|
+
category: 'schema',
|
|
62
|
+
details: { uri, size: resource.size },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
44
65
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (resource.size !== undefined && (typeof resource.size !== 'number' || resource.size < 0)) {
|
|
58
|
-
results.push({
|
|
59
|
-
checkId: 'quality.resource',
|
|
60
|
-
severity: 'warning',
|
|
61
|
-
message: `Resource "${uri}" declares an invalid "size" (${JSON.stringify(resource.size)}) — size must be a non-negative number.`,
|
|
62
|
-
serverName: connection.server.name,
|
|
63
|
-
category: 'schema',
|
|
64
|
-
details: { uri, size: resource.size },
|
|
65
|
-
});
|
|
66
|
+
for (const [uri, count] of byUri) {
|
|
67
|
+
if (count > 1) {
|
|
68
|
+
results.push({
|
|
69
|
+
checkId: 'quality.resource',
|
|
70
|
+
severity: 'error',
|
|
71
|
+
message: `Resource URI "${uri}" is declared ${count} times — resource URIs must be unique.`,
|
|
72
|
+
serverName: connection.server.name,
|
|
73
|
+
category: 'schema',
|
|
74
|
+
details: { uri, duplicateCount: count },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
66
77
|
}
|
|
67
78
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
const templates = connection.resourceTemplates;
|
|
80
|
+
if (Array.isArray(templates)) {
|
|
81
|
+
const byUriTemplate = new Map();
|
|
82
|
+
for (const template of templates) {
|
|
83
|
+
const uriTemplate = template.uriTemplate;
|
|
84
|
+
if (typeof uriTemplate !== 'string' || uriTemplate.trim() === '') {
|
|
85
|
+
results.push({
|
|
86
|
+
checkId: 'quality.resource',
|
|
87
|
+
severity: 'error',
|
|
88
|
+
message: 'Resource template has an empty or invalid "uriTemplate" — the MCP spec requires resource templates to have one.',
|
|
89
|
+
serverName: connection.server.name,
|
|
90
|
+
category: 'schema',
|
|
91
|
+
details: { template },
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
byUriTemplate.set(uriTemplate, (byUriTemplate.get(uriTemplate) ?? 0) + 1);
|
|
96
|
+
// Per the MCP spec's ResourceTemplate type (extends BaseMetadata),
|
|
97
|
+
// "name" is a required field, not optional.
|
|
98
|
+
if (!template.name || template.name.trim() === '') {
|
|
99
|
+
results.push({
|
|
100
|
+
checkId: 'quality.resource',
|
|
101
|
+
severity: 'error',
|
|
102
|
+
message: `Resource template "${uriTemplate}" is missing a "name" — required by the MCP spec's ResourceTemplate type.`,
|
|
103
|
+
serverName: connection.server.name,
|
|
104
|
+
category: 'schema',
|
|
105
|
+
details: { uriTemplate },
|
|
106
|
+
suggestedFix: { description: `Add a "name" field to the resource template "${uriTemplate}".` },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
// description is optional per spec — absence is a quality
|
|
110
|
+
// recommendation, not a violation.
|
|
111
|
+
if (!template.description || template.description.trim() === '') {
|
|
112
|
+
results.push({
|
|
113
|
+
checkId: 'quality.resource',
|
|
114
|
+
severity: 'info',
|
|
115
|
+
message: `Resource template "${uriTemplate}" has no description, making it harder for an agent to know when to use it.`,
|
|
116
|
+
serverName: connection.server.name,
|
|
117
|
+
category: 'quality',
|
|
118
|
+
details: { uriTemplate },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [uriTemplate, count] of byUriTemplate) {
|
|
123
|
+
if (count > 1) {
|
|
124
|
+
results.push({
|
|
125
|
+
checkId: 'quality.resource',
|
|
126
|
+
severity: 'error',
|
|
127
|
+
message: `Resource template "${uriTemplate}" is declared ${count} times — resource template URIs must be unique.`,
|
|
128
|
+
serverName: connection.server.name,
|
|
129
|
+
category: 'schema',
|
|
130
|
+
details: { uriTemplate, duplicateCount: count },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
78
133
|
}
|
|
79
134
|
}
|
|
80
135
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unicode "Tags" block (U+E0000–U+E007F): originally drafted for invisible
|
|
3
|
+
* language tagging, deprecated, and now documented in the wild as a way to
|
|
4
|
+
* hide prompt-injection payloads inside MCP tool/resource/prompt metadata —
|
|
5
|
+
* the characters render as nothing in virtually every UI, but many LLM
|
|
6
|
+
* tokenizers still see and act on them. See e.g. "Unicode TAG-Block
|
|
7
|
+
* Concealment of Tool-Metadata Payloads in the Model Context Protocol"
|
|
8
|
+
* (arXiv:2607.05744).
|
|
9
|
+
*
|
|
10
|
+
* Unlike security.prompt-injection-risk's keyword matching (which only
|
|
11
|
+
* catches instructions written in plain, visible text), this check flags
|
|
12
|
+
* the mere PRESENCE of any codepoint in this block — there is no legitimate
|
|
13
|
+
* reason for it to appear in tool/resource/prompt metadata a human is
|
|
14
|
+
* meant to review, so this is treated as an error, not a heuristic
|
|
15
|
+
* judgment call.
|
|
16
|
+
*/
|
|
17
|
+
const HIDDEN_TAG_CHARACTERS = /[\u{E0000}-\u{E007F}]/u;
|
|
18
|
+
function checkField(text, fieldLabel) {
|
|
19
|
+
if (typeof text !== 'string' || !HIDDEN_TAG_CHARACTERS.test(text))
|
|
20
|
+
return undefined;
|
|
21
|
+
const visible = text.replace(HIDDEN_TAG_CHARACTERS, '');
|
|
22
|
+
return { snippet: `${fieldLabel}: "${visible}" (+ ${[...text].filter((c) => HIDDEN_TAG_CHARACTERS.test(c)).length} hidden tag character(s))` };
|
|
23
|
+
}
|
|
24
|
+
export const securityHiddenUnicodeTagsCheck = {
|
|
25
|
+
id: 'security.hidden-unicode-tags',
|
|
26
|
+
description: 'Flags tool/resource/prompt names or descriptions containing invisible Unicode "Tag" characters (U+E0000-U+E007F) — a known technique for concealing prompt-injection payloads from human review.',
|
|
27
|
+
run(connection) {
|
|
28
|
+
const results = [];
|
|
29
|
+
try {
|
|
30
|
+
for (const tool of connection.tools ?? []) {
|
|
31
|
+
for (const [field, value] of [
|
|
32
|
+
['name', tool.name],
|
|
33
|
+
['title', tool.title],
|
|
34
|
+
['description', tool.description],
|
|
35
|
+
]) {
|
|
36
|
+
const found = checkField(value, field);
|
|
37
|
+
if (found) {
|
|
38
|
+
results.push({
|
|
39
|
+
checkId: 'security.hidden-unicode-tags',
|
|
40
|
+
severity: 'error',
|
|
41
|
+
message: `Tool "${tool.name}" ${field} contains hidden Unicode tag characters — likely a concealed prompt-injection payload. ${found.snippet}`,
|
|
42
|
+
serverName: connection.server.name,
|
|
43
|
+
toolName: tool.name,
|
|
44
|
+
category: 'security',
|
|
45
|
+
confidence: 'high',
|
|
46
|
+
details: { field },
|
|
47
|
+
suggestedFix: { description: `Remove the hidden Unicode tag characters from this tool's ${field}, or treat this server as untrusted.` },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for (const resource of connection.resources ?? []) {
|
|
53
|
+
for (const [field, value] of [
|
|
54
|
+
['name', resource.name],
|
|
55
|
+
['title', resource.title],
|
|
56
|
+
['description', resource.description],
|
|
57
|
+
]) {
|
|
58
|
+
const found = checkField(value, field);
|
|
59
|
+
if (found) {
|
|
60
|
+
results.push({
|
|
61
|
+
checkId: 'security.hidden-unicode-tags',
|
|
62
|
+
severity: 'error',
|
|
63
|
+
message: `Resource "${resource.uri}" ${field} contains hidden Unicode tag characters — likely a concealed prompt-injection payload. ${found.snippet}`,
|
|
64
|
+
serverName: connection.server.name,
|
|
65
|
+
category: 'security',
|
|
66
|
+
confidence: 'high',
|
|
67
|
+
details: { field, uri: resource.uri },
|
|
68
|
+
suggestedFix: { description: `Remove the hidden Unicode tag characters from this resource's ${field}, or treat this server as untrusted.` },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const prompt of connection.prompts ?? []) {
|
|
74
|
+
for (const [field, value] of [
|
|
75
|
+
['name', prompt.name],
|
|
76
|
+
['title', prompt.title],
|
|
77
|
+
['description', prompt.description],
|
|
78
|
+
]) {
|
|
79
|
+
const found = checkField(value, field);
|
|
80
|
+
if (found) {
|
|
81
|
+
results.push({
|
|
82
|
+
checkId: 'security.hidden-unicode-tags',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
message: `Prompt "${prompt.name}" ${field} contains hidden Unicode tag characters — likely a concealed prompt-injection payload. ${found.snippet}`,
|
|
85
|
+
serverName: connection.server.name,
|
|
86
|
+
category: 'security',
|
|
87
|
+
confidence: 'high',
|
|
88
|
+
details: { field },
|
|
89
|
+
suggestedFix: { description: `Remove the hidden Unicode tag characters from this prompt's ${field}, or treat this server as untrusted.` },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
results.push({
|
|
97
|
+
checkId: 'security.hidden-unicode-tags',
|
|
98
|
+
severity: 'error',
|
|
99
|
+
message: `check failed internally: ${err instanceof Error ? err.message : String(err)}`,
|
|
100
|
+
serverName: connection.server.name,
|
|
101
|
+
category: 'security',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
},
|
|
106
|
+
};
|
package/dist/protocol/connect.js
CHANGED
|
@@ -16,6 +16,13 @@ const CLIENT_INFO = { name: 'mcp-medic', version: readOwnVersion() };
|
|
|
16
16
|
function messageOf(error) {
|
|
17
17
|
return error instanceof Error ? error.message : String(error);
|
|
18
18
|
}
|
|
19
|
+
/** JSON-RPC -32601 ("Method not found"): many servers that declare the
|
|
20
|
+
* `resources` capability (for concrete resources/list) simply don't
|
|
21
|
+
* implement the optional `resources/templates/list` RPC — that's spec-
|
|
22
|
+
* compliant, not a capability error, so it must never be reported as one. */
|
|
23
|
+
function isMethodNotFound(error) {
|
|
24
|
+
return messageOf(error).includes('(-32601)');
|
|
25
|
+
}
|
|
19
26
|
function logVerbose(options, message) {
|
|
20
27
|
if (options?.onLog) {
|
|
21
28
|
options.onLog(message);
|
|
@@ -55,6 +62,41 @@ function withTimeout(promise, timeoutMs, label) {
|
|
|
55
62
|
});
|
|
56
63
|
});
|
|
57
64
|
}
|
|
65
|
+
/** Defends against a misbehaving/malicious server that never stops returning
|
|
66
|
+
* a `nextCursor`, which would otherwise hang a passive `check` forever. No
|
|
67
|
+
* real MCP server should need anywhere near this many pages for a single
|
|
68
|
+
* `list` call. */
|
|
69
|
+
const MAX_PAGINATION_PAGES = 1000;
|
|
70
|
+
/**
|
|
71
|
+
* MCP's `tools/list`/`resources/list`/`resources/templates/list`/`prompts/list`
|
|
72
|
+
* all extend `PaginatedRequest`/`PaginatedResult` (optional `cursor` param,
|
|
73
|
+
* optional `nextCursor` in the response) — present since the client's
|
|
74
|
+
* earliest supported protocol version, not something new in 2026-07-28.
|
|
75
|
+
* A server with a large catalog can legitimately split it across pages;
|
|
76
|
+
* without this, mcp-medic would silently see only page 1 and under-report
|
|
77
|
+
* (or mis-score) everything after it. Fetches every page with the same
|
|
78
|
+
* request/response validation as a single call, then hands the merged
|
|
79
|
+
* `{ [arrayKey]: allItems }` to the existing per-item `normalize*` function
|
|
80
|
+
* unchanged.
|
|
81
|
+
*/
|
|
82
|
+
async function requestAllPages(transport, method, arrayKey, timeoutMs, nextId) {
|
|
83
|
+
const merged = [];
|
|
84
|
+
let cursor;
|
|
85
|
+
for (let page = 0; page < MAX_PAGINATION_PAGES; page++) {
|
|
86
|
+
const id = nextId();
|
|
87
|
+
const params = cursor !== undefined ? { cursor } : undefined;
|
|
88
|
+
const result = validateResponse(await withTimeout(transport.request(method, params), timeoutMs, method), id);
|
|
89
|
+
if (!Array.isArray(result[arrayKey])) {
|
|
90
|
+
throw new Error(`${method} response has no ${arrayKey} array`);
|
|
91
|
+
}
|
|
92
|
+
merged.push(...result[arrayKey]);
|
|
93
|
+
cursor = typeof result.nextCursor === 'string' ? result.nextCursor : undefined;
|
|
94
|
+
if (cursor === undefined) {
|
|
95
|
+
return { [arrayKey]: merged };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`${method} did not terminate pagination after ${MAX_PAGINATION_PAGES} pages`);
|
|
99
|
+
}
|
|
58
100
|
function validateResponse(response, expectedId) {
|
|
59
101
|
if (!response || response.jsonrpc !== '2.0' || response.id !== expectedId) {
|
|
60
102
|
throw new Error('invalid JSON-RPC response');
|
|
@@ -120,6 +162,26 @@ function normalizeResources(result) {
|
|
|
120
162
|
};
|
|
121
163
|
});
|
|
122
164
|
}
|
|
165
|
+
function normalizeResourceTemplates(result) {
|
|
166
|
+
if (!Array.isArray(result.resourceTemplates)) {
|
|
167
|
+
throw new Error('resources/templates/list response has no resourceTemplates array');
|
|
168
|
+
}
|
|
169
|
+
return result.resourceTemplates.map((template, index) => {
|
|
170
|
+
if (!template ||
|
|
171
|
+
typeof template !== 'object' ||
|
|
172
|
+
typeof template.uriTemplate !== 'string') {
|
|
173
|
+
throw new Error(`resources/templates/list returned an invalid template at index ${index}`);
|
|
174
|
+
}
|
|
175
|
+
const value = template;
|
|
176
|
+
return {
|
|
177
|
+
uriTemplate: value.uriTemplate,
|
|
178
|
+
...(typeof value.name === 'string' ? { name: value.name } : {}),
|
|
179
|
+
...(typeof value.title === 'string' ? { title: value.title } : {}),
|
|
180
|
+
...(typeof value.description === 'string' ? { description: value.description } : {}),
|
|
181
|
+
...(typeof value.mimeType === 'string' ? { mimeType: value.mimeType } : {}),
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
123
185
|
function normalizePromptArgument(value) {
|
|
124
186
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
125
187
|
return {};
|
|
@@ -488,12 +550,20 @@ export async function connect(config, timeoutMs, options) {
|
|
|
488
550
|
let nextExpectedId = 1;
|
|
489
551
|
let initialize;
|
|
490
552
|
try {
|
|
553
|
+
// The id must be captured *before* the request settles, not as a
|
|
554
|
+
// trailing call argument evaluated after an `await` — if the awaited
|
|
555
|
+
// call throws (timeout, transport error), a trailing `nextExpectedId++`
|
|
556
|
+
// never runs at all, desyncing this counter from the ids the
|
|
557
|
+
// transport actually assigned to later requests. See the
|
|
558
|
+
// resources/templates/list addition in DECISIONS.md for how this
|
|
559
|
+
// surfaced.
|
|
560
|
+
const initializeId = nextExpectedId++;
|
|
491
561
|
const response = await withTimeout(transport.request('initialize', {
|
|
492
562
|
protocolVersion: requestedVersion,
|
|
493
563
|
capabilities: {},
|
|
494
564
|
clientInfo: CLIENT_INFO,
|
|
495
565
|
}), timeoutMs, 'initialize handshake');
|
|
496
|
-
initialize = validateResponse(response,
|
|
566
|
+
initialize = validateResponse(response, initializeId);
|
|
497
567
|
if (!initialize.capabilities || typeof initialize.capabilities !== 'object') {
|
|
498
568
|
throw new Error('initialize response has no capabilities object');
|
|
499
569
|
}
|
|
@@ -529,7 +599,7 @@ export async function connect(config, timeoutMs, options) {
|
|
|
529
599
|
}
|
|
530
600
|
let tools;
|
|
531
601
|
try {
|
|
532
|
-
tools = normalizeTools(
|
|
602
|
+
tools = normalizeTools(await requestAllPages(transport, 'tools/list', 'tools', timeoutMs, () => nextExpectedId++));
|
|
533
603
|
}
|
|
534
604
|
catch (error) {
|
|
535
605
|
return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'list-tools', messageOf(error), error, { protocolVersion, serverInfo });
|
|
@@ -542,19 +612,32 @@ export async function connect(config, timeoutMs, options) {
|
|
|
542
612
|
// capability mcp-medic requires, so a broken resources/prompts listing
|
|
543
613
|
// is reported alongside a still-successful connection.
|
|
544
614
|
let resources;
|
|
615
|
+
let resourceTemplates;
|
|
545
616
|
let prompts;
|
|
546
617
|
const capabilityErrors = {};
|
|
547
618
|
if (capabilities.resources && typeof capabilities.resources === 'object') {
|
|
548
619
|
try {
|
|
549
|
-
resources = normalizeResources(
|
|
620
|
+
resources = normalizeResources(await requestAllPages(transport, 'resources/list', 'resources', timeoutMs, () => nextExpectedId++));
|
|
550
621
|
}
|
|
551
622
|
catch (error) {
|
|
552
623
|
capabilityErrors.resources = messageOf(error);
|
|
553
624
|
}
|
|
625
|
+
// resources/templates/list is a distinct RPC governed by the same
|
|
626
|
+
// capability flag, but genuinely optional in practice — most servers
|
|
627
|
+
// that only expose concrete resources never implement it, and a
|
|
628
|
+
// "method not found" response for it is spec-compliant, not a defect.
|
|
629
|
+
try {
|
|
630
|
+
resourceTemplates = normalizeResourceTemplates(await requestAllPages(transport, 'resources/templates/list', 'resourceTemplates', timeoutMs, () => nextExpectedId++));
|
|
631
|
+
}
|
|
632
|
+
catch (error) {
|
|
633
|
+
if (!isMethodNotFound(error)) {
|
|
634
|
+
capabilityErrors.resourceTemplates = messageOf(error);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
554
637
|
}
|
|
555
638
|
if (capabilities.prompts && typeof capabilities.prompts === 'object') {
|
|
556
639
|
try {
|
|
557
|
-
prompts = normalizePrompts(
|
|
640
|
+
prompts = normalizePrompts(await requestAllPages(transport, 'prompts/list', 'prompts', timeoutMs, () => nextExpectedId++));
|
|
558
641
|
}
|
|
559
642
|
catch (error) {
|
|
560
643
|
capabilityErrors.prompts = messageOf(error);
|
|
@@ -566,6 +649,7 @@ export async function connect(config, timeoutMs, options) {
|
|
|
566
649
|
capabilities,
|
|
567
650
|
tools,
|
|
568
651
|
...(resources !== undefined ? { resources } : {}),
|
|
652
|
+
...(resourceTemplates !== undefined ? { resourceTemplates } : {}),
|
|
569
653
|
...(prompts !== undefined ? { prompts } : {}),
|
|
570
654
|
...(Object.keys(capabilityErrors).length > 0 ? { capabilityErrors } : {}),
|
|
571
655
|
protocolVersion,
|
package/dist/report.js
CHANGED
|
@@ -58,6 +58,8 @@ export function formatReportHuman(report, options = {}) {
|
|
|
58
58
|
const parts = [`${conn.tools.length} tool(s)`];
|
|
59
59
|
if (conn.resources)
|
|
60
60
|
parts.push(`${conn.resources.length} resource(s)`);
|
|
61
|
+
if (conn.resourceTemplates)
|
|
62
|
+
parts.push(`${conn.resourceTemplates.length} resource template(s)`);
|
|
61
63
|
if (conn.prompts)
|
|
62
64
|
parts.push(`${conn.prompts.length} prompt(s)`);
|
|
63
65
|
lines.push(` Capabilities: ${parts.join(', ')}`);
|
|
@@ -65,6 +67,9 @@ export function formatReportHuman(report, options = {}) {
|
|
|
65
67
|
if (conn.capabilityErrors?.resources) {
|
|
66
68
|
lines.push(` resources/list: ${conn.capabilityErrors.resources}`);
|
|
67
69
|
}
|
|
70
|
+
if (conn.capabilityErrors?.resourceTemplates) {
|
|
71
|
+
lines.push(` resources/templates/list: ${conn.capabilityErrors.resourceTemplates}`);
|
|
72
|
+
}
|
|
68
73
|
if (conn.capabilityErrors?.prompts) {
|
|
69
74
|
lines.push(` prompts/list: ${conn.capabilityErrors.prompts}`);
|
|
70
75
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -43,6 +43,21 @@ export interface MCPResourceDefinition {
|
|
|
43
43
|
mimeType?: string;
|
|
44
44
|
size?: number;
|
|
45
45
|
}
|
|
46
|
+
/** MCP's `ResourceTemplate` (spec 2025-06-18+): a URI template (RFC 6570) a
|
|
47
|
+
* client can fill in to construct concrete resource URIs — a distinct RPC
|
|
48
|
+
* (`resources/templates/list`) from `resources/list`'s concrete instances,
|
|
49
|
+
* but governed by the same `capabilities.resources` flag. Common for
|
|
50
|
+
* servers that expose parameterized resources (e.g. `file://{path}`)
|
|
51
|
+
* rather than (or in addition to) a fixed list. */
|
|
52
|
+
export interface MCPResourceTemplate {
|
|
53
|
+
uriTemplate: string;
|
|
54
|
+
/** Required by the spec's `BaseMetadata` — kept optional here for the
|
|
55
|
+
* same reason as `MCPResourceDefinition.name`. */
|
|
56
|
+
name?: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
mimeType?: string;
|
|
60
|
+
}
|
|
46
61
|
export interface MCPPromptArgument {
|
|
47
62
|
/** Required by the MCP spec's `PromptArgument` (extends `BaseMetadata`) —
|
|
48
63
|
* kept optional here for the same reason as `MCPResourceDefinition.name`. */
|
|
@@ -76,12 +91,20 @@ export interface MCPConnection {
|
|
|
76
91
|
tools?: MCPToolDefinition[];
|
|
77
92
|
/** Populated only if the server's `initialize` response declared a `resources` capability. */
|
|
78
93
|
resources?: MCPResourceDefinition[];
|
|
94
|
+
/** Populated only if the server's `initialize` response declared a `resources` capability
|
|
95
|
+
* (same flag as `resources` — the spec has no separate templates sub-capability) AND the
|
|
96
|
+
* server actually returned any templates. `resources/templates/list` is optional in
|
|
97
|
+
* practice: many servers only expose concrete resources, so an empty/absent result here
|
|
98
|
+
* is normal, not an error. */
|
|
99
|
+
resourceTemplates?: MCPResourceTemplate[];
|
|
79
100
|
/** Populated only if the server's `initialize` response declared a `prompts` capability. */
|
|
80
101
|
prompts?: MCPPromptDefinition[];
|
|
81
|
-
/** Best-effort failures from optional capability inspection (resources/prompts
|
|
82
|
-
* never fail the overall connection, since `tools` is the one
|
|
102
|
+
/** Best-effort failures from optional capability inspection (resources/prompts/resource
|
|
103
|
+
* templates) — these never fail the overall connection, since `tools` is the one
|
|
104
|
+
* capability mcp-medic requires. */
|
|
83
105
|
capabilityErrors?: {
|
|
84
106
|
resources?: string;
|
|
107
|
+
resourceTemplates?: string;
|
|
85
108
|
prompts?: string;
|
|
86
109
|
};
|
|
87
110
|
/** Set once an `initialize` response was received, even if negotiation was incompatible or a later stage failed. */
|