mcp-medic 1.0.0

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/action.yml +57 -0
  4. package/dist/checks/index.d.ts +6 -0
  5. package/dist/checks/index.js +17 -0
  6. package/dist/checks/malformed-schema.d.ts +2 -0
  7. package/dist/checks/malformed-schema.js +63 -0
  8. package/dist/checks/missing-description.d.ts +2 -0
  9. package/dist/checks/missing-description.js +71 -0
  10. package/dist/checks/missing-required-fields.d.ts +2 -0
  11. package/dist/checks/missing-required-fields.js +55 -0
  12. package/dist/checks/sample-call-simulation.d.ts +2 -0
  13. package/dist/checks/sample-call-simulation.js +239 -0
  14. package/dist/checks/type-mismatch.d.ts +2 -0
  15. package/dist/checks/type-mismatch.js +154 -0
  16. package/dist/cli.d.ts +20 -0
  17. package/dist/cli.js +457 -0
  18. package/dist/config-loader.d.ts +6 -0
  19. package/dist/config-loader.js +77 -0
  20. package/dist/conformance.d.ts +10 -0
  21. package/dist/conformance.js +112 -0
  22. package/dist/discovery.d.ts +9 -0
  23. package/dist/discovery.js +76 -0
  24. package/dist/extension/index.d.ts +79 -0
  25. package/dist/extension/index.js +125 -0
  26. package/dist/fleet.d.ts +48 -0
  27. package/dist/fleet.js +153 -0
  28. package/dist/index.d.ts +20 -0
  29. package/dist/index.js +13 -0
  30. package/dist/junit.d.ts +10 -0
  31. package/dist/junit.js +87 -0
  32. package/dist/orchestrator.d.ts +6 -0
  33. package/dist/orchestrator.js +60 -0
  34. package/dist/policy.d.ts +16 -0
  35. package/dist/policy.js +143 -0
  36. package/dist/protocol/connect.d.ts +2 -0
  37. package/dist/protocol/connect.js +417 -0
  38. package/dist/protocol/index.d.ts +3 -0
  39. package/dist/protocol/index.js +6 -0
  40. package/dist/registry.d.ts +16 -0
  41. package/dist/registry.js +87 -0
  42. package/dist/report.d.ts +7 -0
  43. package/dist/report.js +30 -0
  44. package/dist/types.d.ts +70 -0
  45. package/dist/types.js +4 -0
  46. package/dist/watch.d.ts +12 -0
  47. package/dist/watch.js +85 -0
  48. package/package.json +56 -0
@@ -0,0 +1,60 @@
1
+ async function connectStub(config) {
2
+ return {
3
+ server: config,
4
+ status: 'failed',
5
+ error: {
6
+ stage: 'spawn',
7
+ message: 'protocol layer not yet implemented (src/protocol/) — see .agent-room/STATUS.md',
8
+ },
9
+ };
10
+ }
11
+ let connectImpl = (config, _timeoutMs) => connectStub(config);
12
+ /** Allows the protocol layer to register its real implementation without
13
+ * this file needing to import it directly (keeps orchestrator decoupled
14
+ * from protocol internals per CONTRACT.md module boundaries). */
15
+ export function registerConnectImpl(impl) {
16
+ connectImpl = impl;
17
+ }
18
+ export async function runChecks(config, options = {}) {
19
+ const timeoutMs = options.timeoutMs ?? 5000;
20
+ const checks = options.checks ?? [];
21
+ const connections = [];
22
+ const diagnostics = [];
23
+ for (const server of config.servers) {
24
+ const connection = await connectImpl(server, timeoutMs, options);
25
+ connections.push(connection);
26
+ if (connection.status !== 'connected') {
27
+ continue; // checks require a live connection; connection failure is its own signal in the report
28
+ }
29
+ for (const check of checks) {
30
+ try {
31
+ const results = await check.run(connection);
32
+ diagnostics.push(...results);
33
+ }
34
+ catch (err) {
35
+ // Non-negotiable per CONTRACT.md: a throwing check must not crash
36
+ // the run. This catch is a safety net on top of each check's own
37
+ // required internal handling.
38
+ diagnostics.push({
39
+ checkId: check.id,
40
+ severity: 'error',
41
+ message: `check threw unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
42
+ serverName: connection.server.name,
43
+ });
44
+ }
45
+ }
46
+ }
47
+ const summary = {
48
+ servers: config.servers.length,
49
+ connected: connections.filter((c) => c.status === 'connected').length,
50
+ failed: connections.filter((c) => c.status !== 'connected').length,
51
+ errors: diagnostics.filter((d) => d.severity === 'error').length,
52
+ warnings: diagnostics.filter((d) => d.severity === 'warning').length,
53
+ };
54
+ return {
55
+ configSource: config.sourcePath,
56
+ connections,
57
+ diagnostics,
58
+ summary,
59
+ };
60
+ }
@@ -0,0 +1,16 @@
1
+ import type { Check, TransportType } from './types.js';
2
+ export interface MCPDoctorPolicy {
3
+ bannedTransports?: TransportType[];
4
+ allowedDomains?: string[];
5
+ minDescriptionLength?: number;
6
+ requireToolDescriptions?: boolean;
7
+ }
8
+ /**
9
+ * Loads a policy from a file path, or auto-discovers .mcp-doctor-policy.json in cwd.
10
+ */
11
+ export declare function loadPolicy(policyPath?: string, cwd?: string): MCPDoctorPolicy | undefined;
12
+ /**
13
+ * Creates composable Check instances from an organization policy.
14
+ * Policy checks seamlessly integrate with the standard Check interface.
15
+ */
16
+ export declare function createPolicyChecks(policy: MCPDoctorPolicy): Check[];
package/dist/policy.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ /**
4
+ * Loads a policy from a file path, or auto-discovers .mcp-doctor-policy.json in cwd.
5
+ */
6
+ export function loadPolicy(policyPath, cwd = process.cwd()) {
7
+ const targetPath = policyPath ? resolve(policyPath) : resolve(cwd, '.mcp-doctor-policy.json');
8
+ if (!existsSync(targetPath)) {
9
+ return undefined;
10
+ }
11
+ try {
12
+ const raw = readFileSync(targetPath, 'utf-8');
13
+ const parsed = JSON.parse(raw);
14
+ return parsed;
15
+ }
16
+ catch (err) {
17
+ throw new Error(`Failed to parse policy file at ${targetPath}: ${err instanceof Error ? err.message : String(err)}`);
18
+ }
19
+ }
20
+ /**
21
+ * Creates composable Check instances from an organization policy.
22
+ * Policy checks seamlessly integrate with the standard Check interface.
23
+ */
24
+ export function createPolicyChecks(policy) {
25
+ const checks = [];
26
+ // 1. Banned transport check
27
+ if (policy.bannedTransports && policy.bannedTransports.length > 0) {
28
+ const banned = new Set(policy.bannedTransports);
29
+ checks.push({
30
+ id: 'policy.banned-transport',
31
+ description: 'Enforces organizational policies on allowed connection transports.',
32
+ run(connection) {
33
+ const results = [];
34
+ try {
35
+ if (banned.has(connection.server.transport)) {
36
+ results.push({
37
+ checkId: 'policy.banned-transport',
38
+ severity: 'error',
39
+ message: `Transport "${connection.server.transport}" is banned by organizational policy.`,
40
+ serverName: connection.server.name,
41
+ details: {
42
+ transport: connection.server.transport,
43
+ bannedTransports: Array.from(banned),
44
+ },
45
+ suggestedFix: {
46
+ description: `Migrate server "${connection.server.name}" to an allowed transport.`,
47
+ },
48
+ });
49
+ }
50
+ }
51
+ catch (err) {
52
+ results.push({
53
+ checkId: 'policy.banned-transport',
54
+ severity: 'error',
55
+ message: `policy check failed internally: ${err instanceof Error ? err.message : String(err)}`,
56
+ serverName: connection.server.name,
57
+ });
58
+ }
59
+ return results;
60
+ },
61
+ });
62
+ }
63
+ // 2. Allowed domain allowlist check
64
+ if (policy.allowedDomains && policy.allowedDomains.length > 0) {
65
+ const allowed = policy.allowedDomains.map((d) => d.toLowerCase());
66
+ checks.push({
67
+ id: 'policy.domain-allowlist',
68
+ description: 'Ensures remote SSE/HTTP servers connect only to approved domains.',
69
+ run(connection) {
70
+ const results = [];
71
+ try {
72
+ if (connection.server.url) {
73
+ const parsedUrl = new URL(connection.server.url);
74
+ const hostname = parsedUrl.hostname.toLowerCase();
75
+ const isAllowed = allowed.some((dom) => hostname === dom || hostname.endsWith(`.${dom}`));
76
+ if (!isAllowed) {
77
+ results.push({
78
+ checkId: 'policy.domain-allowlist',
79
+ severity: 'error',
80
+ message: `Server URL domain "${hostname}" is not in organizational allowlist [${allowed.join(', ')}].`,
81
+ serverName: connection.server.name,
82
+ details: { hostname, allowedDomains: allowed, url: connection.server.url },
83
+ suggestedFix: {
84
+ description: `Configure server "${connection.server.name}" to use an approved domain or update .mcp-doctor-policy.json.`,
85
+ },
86
+ });
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ results.push({
92
+ checkId: 'policy.domain-allowlist',
93
+ severity: 'error',
94
+ message: `policy check failed internally: ${err instanceof Error ? err.message : String(err)}`,
95
+ serverName: connection.server.name,
96
+ });
97
+ }
98
+ return results;
99
+ },
100
+ });
101
+ }
102
+ // 3. Minimum description length check
103
+ if (typeof policy.minDescriptionLength === 'number' && policy.minDescriptionLength > 0) {
104
+ const minLen = policy.minDescriptionLength;
105
+ checks.push({
106
+ id: 'policy.description-length',
107
+ description: `Enforces minimum tool description length of ${minLen} characters.`,
108
+ run(connection) {
109
+ const results = [];
110
+ try {
111
+ if (!connection.tools)
112
+ return results;
113
+ for (const tool of connection.tools) {
114
+ const desc = tool.description?.trim() || '';
115
+ if (desc.length < minLen) {
116
+ results.push({
117
+ checkId: 'policy.description-length',
118
+ severity: 'warning',
119
+ message: `Tool "${tool.name}" description is too brief (${desc.length} chars, policy requires minimum ${minLen}).`,
120
+ serverName: connection.server.name,
121
+ toolName: tool.name,
122
+ details: { currentLength: desc.length, requiredLength: minLen },
123
+ suggestedFix: {
124
+ description: `Expand tool "${tool.name}" description to at least ${minLen} characters to assist model tool selection.`,
125
+ },
126
+ });
127
+ }
128
+ }
129
+ }
130
+ catch (err) {
131
+ results.push({
132
+ checkId: 'policy.description-length',
133
+ severity: 'error',
134
+ message: `policy check failed internally: ${err instanceof Error ? err.message : String(err)}`,
135
+ serverName: connection.server.name,
136
+ });
137
+ }
138
+ return results;
139
+ },
140
+ });
141
+ }
142
+ return checks;
143
+ }
@@ -0,0 +1,2 @@
1
+ import type { MCPConnection, MCPServerConfig, RunOptions } from '../types.js';
2
+ export declare function connect(config: MCPServerConfig, timeoutMs: number, options?: RunOptions): Promise<MCPConnection>;
@@ -0,0 +1,417 @@
1
+ import { spawn } from 'node:child_process';
2
+ const PROTOCOL_VERSION = '2024-11-05';
3
+ const CLIENT_INFO = { name: 'mcp-doctor', version: '0.0.1' };
4
+ function messageOf(error) {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+ function logVerbose(options, message) {
8
+ if (options?.onLog) {
9
+ options.onLog(message);
10
+ }
11
+ else if (options?.verbose) {
12
+ console.error(`[debug] ${message}`);
13
+ }
14
+ }
15
+ function failed(config, status, stage, message, raw) {
16
+ return { server: config, status, error: { stage, message, ...(raw === undefined ? {} : { raw }) } };
17
+ }
18
+ function withTimeout(promise, timeoutMs, label) {
19
+ return new Promise((resolve, reject) => {
20
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
21
+ promise.then((value) => {
22
+ clearTimeout(timer);
23
+ resolve(value);
24
+ }, (error) => {
25
+ clearTimeout(timer);
26
+ reject(error);
27
+ });
28
+ });
29
+ }
30
+ function validateResponse(response, expectedId) {
31
+ if (!response || response.jsonrpc !== '2.0' || response.id !== expectedId) {
32
+ throw new Error('invalid JSON-RPC response');
33
+ }
34
+ if (response.error) {
35
+ throw new Error(`MCP request failed (${response.error.code}): ${response.error.message}`);
36
+ }
37
+ if (!response.result || typeof response.result !== 'object') {
38
+ throw new Error('JSON-RPC response has no result');
39
+ }
40
+ return response.result;
41
+ }
42
+ function normalizeTools(result) {
43
+ if (!Array.isArray(result.tools))
44
+ throw new Error('tools/list response has no tools array');
45
+ return result.tools.map((tool, index) => {
46
+ if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string') {
47
+ throw new Error(`tools/list returned an invalid tool at index ${index}`);
48
+ }
49
+ const value = tool;
50
+ return {
51
+ name: value.name,
52
+ ...(typeof value.description === 'string' ? { description: value.description } : {}),
53
+ inputSchema: value.inputSchema,
54
+ };
55
+ });
56
+ }
57
+ async function refreshTokenIfNeeded(config, options) {
58
+ const headers = { ...(config.headers ?? {}) };
59
+ if (!config.tokenRefreshUrl) {
60
+ return headers;
61
+ }
62
+ try {
63
+ logVerbose(options, `Refreshing OAuth token from ${config.tokenRefreshUrl}...`);
64
+ const res = await fetch(config.tokenRefreshUrl, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json', ...(config.headers ?? {}) },
67
+ body: JSON.stringify(config.tokenRefreshBody ?? {}),
68
+ });
69
+ if (!res.ok) {
70
+ throw new Error(`Token refresh failed with status ${res.status}`);
71
+ }
72
+ const data = (await res.json());
73
+ const token = (typeof data.access_token === 'string' && data.access_token) ||
74
+ (typeof data.token === 'string' && data.token);
75
+ if (token) {
76
+ headers['Authorization'] = `Bearer ${token}`;
77
+ logVerbose(options, 'Token refreshed successfully');
78
+ }
79
+ }
80
+ catch (err) {
81
+ logVerbose(options, `Token refresh warning: ${messageOf(err)}`);
82
+ }
83
+ return headers;
84
+ }
85
+ class StdioTransport {
86
+ options;
87
+ process;
88
+ nextId = 1;
89
+ pending = new Map();
90
+ buffer = '';
91
+ closed = false;
92
+ exitError;
93
+ constructor(config, options) {
94
+ this.options = options;
95
+ if (!config.command)
96
+ throw new Error('stdio transport requires command');
97
+ this.process = spawn(config.command, config.args ?? [], {
98
+ env: { ...process.env, ...(config.env ?? {}) },
99
+ stdio: ['pipe', 'pipe', 'pipe'],
100
+ });
101
+ this.exitError = new Promise((_, reject) => {
102
+ this.process.once('error', (error) => reject(new Error(`failed to start MCP server: ${error.message}`)));
103
+ this.process.once('exit', (code, signal) => {
104
+ if (!this.closed)
105
+ reject(new Error(`MCP server exited before responding (code=${code ?? 'unknown'}, signal=${signal ?? 'none'})`));
106
+ });
107
+ });
108
+ this.process.stdout.setEncoding('utf8');
109
+ this.process.stdout.on('data', (chunk) => this.consume(chunk));
110
+ }
111
+ consume(chunk) {
112
+ this.buffer += chunk;
113
+ let newline = this.buffer.indexOf('\n');
114
+ while (newline >= 0) {
115
+ const line = this.buffer.slice(0, newline).trim();
116
+ this.buffer = this.buffer.slice(newline + 1);
117
+ newline = this.buffer.indexOf('\n');
118
+ if (!line)
119
+ continue;
120
+ try {
121
+ logVerbose(this.options, `<-- stdio: ${line}`);
122
+ const parsed = JSON.parse(line);
123
+ if (typeof parsed.id === 'number') {
124
+ const waiter = this.pending.get(parsed.id);
125
+ if (waiter) {
126
+ this.pending.delete(parsed.id);
127
+ waiter.resolve(parsed);
128
+ }
129
+ }
130
+ }
131
+ catch {
132
+ // Ignore server log noise or malformed notifications; the request timeout reports the failure.
133
+ }
134
+ }
135
+ }
136
+ request(method, params) {
137
+ const id = this.nextId++;
138
+ const payload = JSON.stringify({ jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }) +
139
+ '\n';
140
+ logVerbose(this.options, `--> stdio: ${payload.trim()}`);
141
+ return Promise.race([
142
+ new Promise((resolve, reject) => {
143
+ this.pending.set(id, { resolve, reject });
144
+ this.process.stdin.write(payload, (error) => {
145
+ if (error) {
146
+ this.pending.delete(id);
147
+ reject(error);
148
+ }
149
+ });
150
+ }),
151
+ this.exitError,
152
+ ]);
153
+ }
154
+ notify(method, params) {
155
+ return new Promise((resolve, reject) => {
156
+ const payload = JSON.stringify({ jsonrpc: '2.0', method, ...(params === undefined ? {} : { params }) }) +
157
+ '\n';
158
+ logVerbose(this.options, `--> stdio (notify): ${payload.trim()}`);
159
+ this.process.stdin.write(payload, (error) => (error ? reject(error) : resolve()));
160
+ });
161
+ }
162
+ async close() {
163
+ this.closed = true;
164
+ for (const waiter of this.pending.values())
165
+ waiter.reject(new Error('MCP server connection closed'));
166
+ this.pending.clear();
167
+ if (!this.process.killed) {
168
+ this.process.kill();
169
+ await new Promise((resolve) => this.process.once('close', () => resolve()));
170
+ }
171
+ }
172
+ }
173
+ async function httpJson(url, headers, body, options) {
174
+ const bodyText = JSON.stringify(body);
175
+ logVerbose(options, `--> HTTP POST ${url}: ${bodyText}`);
176
+ const response = await fetch(url, {
177
+ method: 'POST',
178
+ headers: {
179
+ Accept: 'application/json, text/event-stream',
180
+ 'Content-Type': 'application/json',
181
+ ...headers,
182
+ },
183
+ body: bodyText,
184
+ });
185
+ if (!response.ok)
186
+ throw new Error(`MCP HTTP request failed with status ${response.status}`);
187
+ const text = await response.text();
188
+ logVerbose(options, `<-- HTTP ${response.status}: ${text}`);
189
+ const data = text.trim().startsWith('data:')
190
+ ? text
191
+ .split(/\r?\n/)
192
+ .find((line) => line.startsWith('data:'))
193
+ ?.slice(5)
194
+ .trim()
195
+ : text;
196
+ if (!data)
197
+ throw new Error('MCP HTTP response was empty');
198
+ return JSON.parse(data);
199
+ }
200
+ class HttpTransport {
201
+ config;
202
+ options;
203
+ nextId = 1;
204
+ headers = {};
205
+ constructor(config, options) {
206
+ this.config = config;
207
+ this.options = options;
208
+ if (!config.url)
209
+ throw new Error(`${config.transport} transport requires url`);
210
+ }
211
+ async getHeaders() {
212
+ if (Object.keys(this.headers).length === 0) {
213
+ this.headers = await refreshTokenIfNeeded(this.config, this.options);
214
+ }
215
+ return this.headers;
216
+ }
217
+ async request(method, params) {
218
+ const id = this.nextId++;
219
+ const headers = await this.getHeaders();
220
+ return httpJson(this.config.url, headers, { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }, this.options);
221
+ }
222
+ async notify(method, params) {
223
+ const headers = await this.getHeaders();
224
+ const bodyText = JSON.stringify({
225
+ jsonrpc: '2.0',
226
+ method,
227
+ ...(params === undefined ? {} : { params }),
228
+ });
229
+ logVerbose(this.options, `--> HTTP POST (notify) ${this.config.url}: ${bodyText}`);
230
+ const response = await fetch(this.config.url, {
231
+ method: 'POST',
232
+ headers: {
233
+ Accept: 'application/json, text/event-stream',
234
+ 'Content-Type': 'application/json',
235
+ ...headers,
236
+ },
237
+ body: bodyText,
238
+ });
239
+ if (!response.ok)
240
+ throw new Error(`MCP HTTP notification failed with status ${response.status}`);
241
+ }
242
+ async close() { }
243
+ }
244
+ class SseTransport {
245
+ config;
246
+ options;
247
+ nextId = 1;
248
+ endpointPromise;
249
+ headers = {};
250
+ constructor(config, options) {
251
+ this.config = config;
252
+ this.options = options;
253
+ if (!config.url)
254
+ throw new Error('sse transport requires url');
255
+ }
256
+ async getHeaders() {
257
+ if (Object.keys(this.headers).length === 0) {
258
+ this.headers = await refreshTokenIfNeeded(this.config, this.options);
259
+ }
260
+ return this.headers;
261
+ }
262
+ async endpointWithRetry(maxRetries = 2) {
263
+ let lastError;
264
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
265
+ try {
266
+ return await this.fetchEndpoint();
267
+ }
268
+ catch (err) {
269
+ lastError = err;
270
+ logVerbose(this.options, `SSE connection attempt ${attempt} failed: ${messageOf(err)}`);
271
+ if (attempt < maxRetries) {
272
+ await new Promise((r) => setTimeout(r, 100 * attempt));
273
+ }
274
+ }
275
+ }
276
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
277
+ }
278
+ async fetchEndpoint() {
279
+ const headers = await this.getHeaders();
280
+ logVerbose(this.options, `--> SSE connecting to ${this.config.url}...`);
281
+ const response = await fetch(this.config.url, {
282
+ headers: { Accept: 'text/event-stream', ...headers },
283
+ });
284
+ if (!response.ok || !response.body)
285
+ throw new Error(`MCP SSE connection failed with status ${response.status}`);
286
+ const reader = response.body.getReader();
287
+ const decoder = new TextDecoder();
288
+ let buffer = '';
289
+ try {
290
+ while (true) {
291
+ const chunk = await reader.read();
292
+ if (chunk.done)
293
+ break;
294
+ buffer += decoder.decode(chunk.value, { stream: true });
295
+ logVerbose(this.options, `<-- SSE stream chunk: ${buffer}`);
296
+ const event = buffer.match(/(?:^|\r?\n)\r?\n([\s\S]*?)(?:\r?\n\r?\n|$)/);
297
+ if (!event)
298
+ continue;
299
+ buffer = buffer.slice((event.index ?? 0) + event[0].length);
300
+ const data = event[1]
301
+ .split(/\r?\n/)
302
+ .filter((line) => line.startsWith('data:'))
303
+ .map((line) => line.slice(5).trim())
304
+ .join('\n');
305
+ if (data) {
306
+ const endpointUrl = new URL(data, this.config.url).toString();
307
+ logVerbose(this.options, `SSE discovered endpoint: ${endpointUrl}`);
308
+ return endpointUrl;
309
+ }
310
+ }
311
+ }
312
+ finally {
313
+ await reader.cancel();
314
+ }
315
+ throw new Error('MCP SSE stream ended before endpoint event');
316
+ }
317
+ async endpoint() {
318
+ if (!this.endpointPromise) {
319
+ this.endpointPromise = this.endpointWithRetry();
320
+ }
321
+ return this.endpointPromise;
322
+ }
323
+ async request(method, params) {
324
+ const id = this.nextId++;
325
+ const headers = await this.getHeaders();
326
+ const targetEndpoint = await this.endpoint();
327
+ return httpJson(targetEndpoint, headers, { jsonrpc: '2.0', id, method, ...(params === undefined ? {} : { params }) }, this.options);
328
+ }
329
+ async notify(method, params) {
330
+ const headers = await this.getHeaders();
331
+ const endpoint = await this.endpoint();
332
+ const bodyText = JSON.stringify({
333
+ jsonrpc: '2.0',
334
+ method,
335
+ ...(params === undefined ? {} : { params }),
336
+ });
337
+ logVerbose(this.options, `--> SSE POST (notify) ${endpoint}: ${bodyText}`);
338
+ const response = await fetch(endpoint, {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json', ...headers },
341
+ body: bodyText,
342
+ });
343
+ if (!response.ok)
344
+ throw new Error(`MCP SSE notification failed with status ${response.status}`);
345
+ }
346
+ async close() { }
347
+ }
348
+ export async function connect(config, timeoutMs, options) {
349
+ const started = Date.now();
350
+ let transport;
351
+ try {
352
+ if (config.transport === 'stdio') {
353
+ try {
354
+ transport = new StdioTransport(config, options);
355
+ }
356
+ catch (error) {
357
+ return failed(config, 'failed', 'spawn', messageOf(error), error);
358
+ }
359
+ }
360
+ else if (config.transport === 'sse' || config.transport === 'http') {
361
+ try {
362
+ transport =
363
+ config.transport === 'sse'
364
+ ? new SseTransport(config, options)
365
+ : new HttpTransport(config, options);
366
+ }
367
+ catch (error) {
368
+ return failed(config, 'failed', 'spawn', messageOf(error), error);
369
+ }
370
+ }
371
+ else {
372
+ return failed(config, 'failed', 'spawn', `unsupported transport: ${String(config.transport)}`);
373
+ }
374
+ let initialize;
375
+ try {
376
+ const response = await withTimeout(transport.request('initialize', {
377
+ protocolVersion: PROTOCOL_VERSION,
378
+ capabilities: {},
379
+ clientInfo: CLIENT_INFO,
380
+ }), timeoutMs, 'initialize handshake');
381
+ initialize = validateResponse(response, 1);
382
+ if (!initialize.capabilities || typeof initialize.capabilities !== 'object') {
383
+ throw new Error('initialize response has no capabilities object');
384
+ }
385
+ }
386
+ catch (error) {
387
+ const timedOut = messageOf(error).includes('timed out');
388
+ const message = messageOf(error);
389
+ return failed(config, timedOut ? 'timeout' : 'failed', message.startsWith('failed to start') ? 'spawn' : 'handshake', message, error);
390
+ }
391
+ try {
392
+ await withTimeout(transport.notify('notifications/initialized'), timeoutMs, 'initialized notification');
393
+ }
394
+ catch (error) {
395
+ return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'capability-negotiation', messageOf(error), error);
396
+ }
397
+ try {
398
+ const tools = normalizeTools(validateResponse(await withTimeout(transport.request('tools/list'), timeoutMs, 'tools/list'), 2));
399
+ return {
400
+ server: config,
401
+ status: 'connected',
402
+ capabilities: initialize.capabilities,
403
+ tools,
404
+ latencyMs: Date.now() - started,
405
+ };
406
+ }
407
+ catch (error) {
408
+ return failed(config, messageOf(error).includes('timed out') ? 'timeout' : 'failed', 'list-tools', messageOf(error), error);
409
+ }
410
+ }
411
+ catch (error) {
412
+ return failed(config, 'failed', 'handshake', messageOf(error), error);
413
+ }
414
+ finally {
415
+ await transport?.close().catch(() => undefined);
416
+ }
417
+ }
@@ -0,0 +1,3 @@
1
+ import { connect } from './connect.js';
2
+ export declare function registerProtocol(): void;
3
+ export { connect };
@@ -0,0 +1,6 @@
1
+ import { registerConnectImpl } from '../orchestrator.js';
2
+ import { connect } from './connect.js';
3
+ export function registerProtocol() {
4
+ registerConnectImpl(connect);
5
+ }
6
+ export { connect };
@@ -0,0 +1,16 @@
1
+ import type { MCPServerConfig } from './types.js';
2
+ export interface RegistryResolveOptions {
3
+ timeoutMs?: number;
4
+ fetchFn?: typeof fetch;
5
+ }
6
+ /**
7
+ * Resolves a published MCP server descriptor or registry identifier into an MCPServerConfig.
8
+ *
9
+ * Supported formats:
10
+ * - Direct HTTP/SSE URL: "https://mcp.example.com/sse" -> sse/http config
11
+ * - NPM/Npx package: "npm:@modelcontextprotocol/server-memory" or "@modelcontextprotocol/server-memory" -> stdio npx
12
+ * - PyPI/Uvx package: "pypi:mcp-server-git" or "uvx:mcp-server-git" -> stdio uvx
13
+ * - Registry URL returning JSON server config: "https://registry.example.com/servers/my-tool.json"
14
+ * - Smithery / Glama server ID: "smithery:username/server-name"
15
+ */
16
+ export declare function resolveRegistryServer(registryId: string, options?: RegistryResolveOptions): Promise<MCPServerConfig>;