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.
@@ -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';
@@ -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` already returned (see
4
- * MCPConnection.resources, populated passively in src/protocol/connect.ts).
5
- * Never calls `resources/read` — that would be real content retrieval, out
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` already returned (see
3
- * MCPConnection.resources, populated passively in src/protocol/connect.ts).
4
- * Never calls `resources/read` — that would be real content retrieval, out
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 (!resources || !Array.isArray(resources)) {
15
- return results;
16
- }
17
- const byUri = new Map();
18
- for (const resource of resources) {
19
- const uri = resource.uri;
20
- if (typeof uri !== 'string' || uri.trim() === '') {
21
- results.push({
22
- checkId: 'quality.resource',
23
- severity: 'error',
24
- message: 'Resource has an empty or invalid "uri" — the MCP spec requires resources to have a URI.',
25
- serverName: connection.server.name,
26
- category: 'schema',
27
- details: { resource },
28
- });
29
- continue;
30
- }
31
- byUri.set(uri, (byUri.get(uri) ?? 0) + 1);
32
- // Per the MCP spec's Resource type (extends BaseMetadata), "name" is
33
- // a required field, not optional.
34
- if (!resource.name || resource.name.trim() === '') {
35
- results.push({
36
- checkId: 'quality.resource',
37
- severity: 'error',
38
- message: `Resource "${uri}" is missing a "name" — required by the MCP spec's Resource type.`,
39
- serverName: connection.server.name,
40
- category: 'schema',
41
- details: { uri },
42
- suggestedFix: { description: `Add a "name" field to the resource at "${uri}".` },
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
- // description is optional per spec — absence is a quality
46
- // recommendation, not a violation.
47
- if (!resource.description || resource.description.trim() === '') {
48
- results.push({
49
- checkId: 'quality.resource',
50
- severity: 'info',
51
- message: `Resource "${uri}" has no description, making it harder for an agent to know when to read it.`,
52
- serverName: connection.server.name,
53
- category: 'quality',
54
- details: { uri },
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
- for (const [uri, count] of byUri) {
69
- if (count > 1) {
70
- results.push({
71
- checkId: 'quality.resource',
72
- severity: 'error',
73
- message: `Resource URI "${uri}" is declared ${count} times resource URIs must be unique.`,
74
- serverName: connection.server.name,
75
- category: 'schema',
76
- details: { uri, duplicateCount: count },
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,2 @@
1
+ import type { Check } from '../types.js';
2
+ export declare const securityHiddenUnicodeTagsCheck: Check;
@@ -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
+ };
@@ -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, nextExpectedId++);
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(validateResponse(await withTimeout(transport.request('tools/list'), timeoutMs, 'tools/list'), nextExpectedId++));
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(validateResponse(await withTimeout(transport.request('resources/list'), timeoutMs, 'resources/list'), nextExpectedId++));
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(validateResponse(await withTimeout(transport.request('prompts/list'), timeoutMs, 'prompts/list'), nextExpectedId++));
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) — these
82
- * never fail the overall connection, since `tools` is the one capability mcp-medic requires. */
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-medic",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "Diagnose broken MCP (Model Context Protocol) server configs before they break your agent silently.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",