@evantahler/mcpx 0.15.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 (106) hide show
  1. package/.claude/settings.local.json +18 -0
  2. package/.claude/skills/mcpx.md +165 -0
  3. package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
  4. package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
  5. package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
  6. package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
  7. package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
  8. package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
  9. package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
  10. package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
  11. package/.claude/worktrees/elastic-jennings/README.md +487 -0
  12. package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
  13. package/.claude/worktrees/elastic-jennings/install.sh +55 -0
  14. package/.claude/worktrees/elastic-jennings/package.json +56 -0
  15. package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
  16. package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
  17. package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
  18. package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
  19. package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
  20. package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
  21. package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
  22. package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
  23. package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
  24. package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
  25. package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
  26. package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
  27. package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
  28. package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
  29. package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
  30. package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
  31. package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
  32. package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
  33. package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
  34. package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
  35. package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
  36. package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
  37. package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
  38. package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
  39. package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
  40. package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
  41. package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
  42. package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
  43. package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
  44. package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
  45. package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
  46. package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
  47. package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
  48. package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
  49. package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
  50. package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
  51. package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
  52. package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
  53. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
  54. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
  55. package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
  56. package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
  57. package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
  58. package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
  59. package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
  60. package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
  61. package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
  62. package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
  63. package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
  64. package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
  65. package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
  66. package/.cursor/rules/mcpx.mdc +165 -0
  67. package/LICENSE +21 -0
  68. package/README.md +627 -0
  69. package/package.json +58 -0
  70. package/src/cli.ts +72 -0
  71. package/src/client/browser.ts +24 -0
  72. package/src/client/debug-fetch.ts +81 -0
  73. package/src/client/elicitation.ts +368 -0
  74. package/src/client/http.ts +25 -0
  75. package/src/client/manager.ts +566 -0
  76. package/src/client/oauth.ts +314 -0
  77. package/src/client/sse.ts +17 -0
  78. package/src/client/stdio.ts +12 -0
  79. package/src/client/trace.ts +184 -0
  80. package/src/commands/add.ts +179 -0
  81. package/src/commands/auth.ts +114 -0
  82. package/src/commands/exec.ts +156 -0
  83. package/src/commands/index.ts +62 -0
  84. package/src/commands/info.ts +63 -0
  85. package/src/commands/list.ts +64 -0
  86. package/src/commands/ping.ts +69 -0
  87. package/src/commands/prompt.ts +60 -0
  88. package/src/commands/remove.ts +67 -0
  89. package/src/commands/resource.ts +46 -0
  90. package/src/commands/search.ts +49 -0
  91. package/src/commands/servers.ts +66 -0
  92. package/src/commands/skill.ts +112 -0
  93. package/src/commands/task.ts +82 -0
  94. package/src/config/env.ts +41 -0
  95. package/src/config/loader.ts +156 -0
  96. package/src/config/schemas.ts +152 -0
  97. package/src/context.ts +62 -0
  98. package/src/lib/input.ts +36 -0
  99. package/src/output/formatter.ts +884 -0
  100. package/src/output/logger.ts +173 -0
  101. package/src/search/index.ts +69 -0
  102. package/src/search/indexer.ts +92 -0
  103. package/src/search/keyword.ts +86 -0
  104. package/src/search/semantic.ts +75 -0
  105. package/src/search/staleness.ts +8 -0
  106. package/src/validation/schema.ts +103 -0
@@ -0,0 +1,314 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import {
3
+ auth,
4
+ discoverOAuthServerInfo,
5
+ refreshAuthorization,
6
+ } from "@modelcontextprotocol/sdk/client/auth.js";
7
+ import type {
8
+ OAuthClientMetadata,
9
+ OAuthClientInformationMixed,
10
+ OAuthTokens,
11
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
12
+ import type { AuthFile } from "../config/schemas.ts";
13
+ import { saveAuth } from "../config/loader.ts";
14
+ import type { FormatOptions } from "../output/formatter.ts";
15
+ import { logger } from "../output/logger.ts";
16
+ import { openBrowser } from "./browser.ts";
17
+
18
+ export class McpOAuthProvider implements OAuthClientProvider {
19
+ private serverName: string;
20
+ private configDir: string;
21
+ private auth: AuthFile;
22
+ private _codeVerifier?: string;
23
+ private _callbackPort = 0;
24
+
25
+ constructor(opts: { serverName: string; configDir: string; auth: AuthFile }) {
26
+ this.serverName = opts.serverName;
27
+ this.configDir = opts.configDir;
28
+ this.auth = opts.auth;
29
+ }
30
+
31
+ get redirectUrl(): string {
32
+ return `http://127.0.0.1:${this._callbackPort}/callback`;
33
+ }
34
+
35
+ get clientMetadata(): OAuthClientMetadata {
36
+ return {
37
+ redirect_uris: [`http://127.0.0.1:${this._callbackPort}/callback`],
38
+ grant_types: ["authorization_code", "refresh_token"],
39
+ response_types: ["code"],
40
+ client_name: "mcpx",
41
+ token_endpoint_auth_method: "none",
42
+ };
43
+ }
44
+
45
+ clientInformation(): OAuthClientInformationMixed | undefined {
46
+ const entry = this.auth[this.serverName];
47
+ // During an active auth flow, return client_info even if incomplete.
48
+ // For normal usage (transport), the manager checks isComplete() separately.
49
+ return entry?.client_info;
50
+ }
51
+
52
+ async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
53
+ if (!this.auth[this.serverName]) {
54
+ this.auth[this.serverName] = { tokens: {} as OAuthTokens };
55
+ }
56
+ this.auth[this.serverName]!.client_info = info;
57
+ await saveAuth(this.configDir, this.auth);
58
+ }
59
+
60
+ tokens(): OAuthTokens | undefined {
61
+ return this.auth[this.serverName]?.tokens;
62
+ }
63
+
64
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
65
+ if (!this.auth[this.serverName]) {
66
+ this.auth[this.serverName] = { tokens };
67
+ } else {
68
+ this.auth[this.serverName]!.tokens = tokens;
69
+ }
70
+
71
+ // Compute expires_at from expires_in
72
+ if (tokens.expires_in) {
73
+ const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
74
+ this.auth[this.serverName]!.expires_at = expiresAt.toISOString();
75
+ }
76
+
77
+ // Mark auth as complete — tokens have been successfully obtained
78
+ this.auth[this.serverName]!.complete = true;
79
+
80
+ await saveAuth(this.configDir, this.auth);
81
+ }
82
+
83
+ async redirectToAuthorization(url: URL): Promise<void> {
84
+ const urlStr = url.toString();
85
+ logger.info(urlStr);
86
+ await openBrowser(urlStr);
87
+ }
88
+
89
+ async saveCodeVerifier(v: string): Promise<void> {
90
+ this._codeVerifier = v;
91
+ }
92
+
93
+ codeVerifier(): string {
94
+ if (!this._codeVerifier) {
95
+ throw new Error("Code verifier not set");
96
+ }
97
+ return this._codeVerifier;
98
+ }
99
+
100
+ async invalidateCredentials(
101
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
102
+ ): Promise<void> {
103
+ const entry = this.auth[this.serverName];
104
+ if (!entry) return;
105
+
106
+ switch (scope) {
107
+ case "all":
108
+ delete this.auth[this.serverName];
109
+ break;
110
+ case "client":
111
+ delete entry.client_info;
112
+ break;
113
+ case "tokens":
114
+ delete this.auth[this.serverName];
115
+ // Re-create entry without tokens but keep client_info
116
+ if (entry.client_info) {
117
+ this.auth[this.serverName] = {
118
+ tokens: {} as OAuthTokens,
119
+ client_info: entry.client_info,
120
+ };
121
+ }
122
+ break;
123
+ case "verifier":
124
+ this._codeVerifier = undefined;
125
+ return; // No need to persist
126
+ case "discovery":
127
+ return; // Nothing to clear locally
128
+ }
129
+
130
+ await saveAuth(this.configDir, this.auth);
131
+ }
132
+
133
+ /** Whether the auth flow completed successfully (tokens were obtained) */
134
+ isComplete(): boolean {
135
+ return !!this.auth[this.serverName]?.complete;
136
+ }
137
+
138
+ /** Clear any incomplete auth state from a previously cancelled flow */
139
+ async clearIncomplete(): Promise<void> {
140
+ const entry = this.auth[this.serverName];
141
+ if (entry && !entry.complete) {
142
+ delete this.auth[this.serverName];
143
+ await saveAuth(this.configDir, this.auth);
144
+ }
145
+ }
146
+
147
+ setCallbackPort(port: number): void {
148
+ this._callbackPort = port;
149
+ }
150
+
151
+ isExpired(): boolean {
152
+ const entry = this.auth[this.serverName];
153
+ if (!entry?.expires_at) return false;
154
+ return new Date(entry.expires_at) <= new Date();
155
+ }
156
+
157
+ hasRefreshToken(): boolean {
158
+ const tokens = this.auth[this.serverName]?.tokens;
159
+ return !!tokens?.refresh_token;
160
+ }
161
+
162
+ async refreshIfNeeded(serverUrl: string): Promise<void> {
163
+ if (!this.isExpired()) return;
164
+
165
+ if (!this.hasRefreshToken()) {
166
+ throw new Error(
167
+ `Token expired for "${this.serverName}" and no refresh token available. Run: mcpx auth ${this.serverName}`,
168
+ );
169
+ }
170
+
171
+ const clientInfo = this.clientInformation();
172
+ if (!clientInfo) {
173
+ throw new Error(
174
+ `No client information for "${this.serverName}". Run: mcpx auth ${this.serverName}`,
175
+ );
176
+ }
177
+
178
+ const tokens = await refreshAuthorization(serverUrl, {
179
+ clientInformation: clientInfo,
180
+ refreshToken: this.auth[this.serverName]!.tokens.refresh_token!,
181
+ });
182
+
183
+ await this.saveTokens(tokens);
184
+
185
+ logger.info(`Token refreshed for "${this.serverName}"`);
186
+ }
187
+ }
188
+
189
+ /** Start a local callback server to receive the OAuth authorization code */
190
+ export function startCallbackServer(): {
191
+ server: ReturnType<typeof Bun.serve>;
192
+ authCodePromise: Promise<string>;
193
+ } {
194
+ let resolveCode: (code: string) => void;
195
+ let rejectCode: (err: Error) => void;
196
+
197
+ const authCodePromise = new Promise<string>((resolve, reject) => {
198
+ resolveCode = resolve;
199
+ rejectCode = reject;
200
+ });
201
+
202
+ const server = Bun.serve({
203
+ port: 0,
204
+ fetch(req) {
205
+ const url = new URL(req.url);
206
+ if (url.pathname !== "/callback") {
207
+ return new Response("Not found", { status: 404 });
208
+ }
209
+
210
+ const error = url.searchParams.get("error");
211
+ if (error) {
212
+ const desc = url.searchParams.get("error_description") || error;
213
+ rejectCode!(new Error(`OAuth error: ${desc}`));
214
+ return new Response(
215
+ "<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>",
216
+ { headers: { "Content-Type": "text/html" } },
217
+ );
218
+ }
219
+
220
+ const code = url.searchParams.get("code");
221
+ if (!code) {
222
+ rejectCode!(new Error("No authorization code received"));
223
+ return new Response(
224
+ "<html><body><h1>Error</h1><p>No authorization code received.</p></body></html>",
225
+ { headers: { "Content-Type": "text/html" } },
226
+ );
227
+ }
228
+
229
+ resolveCode!(code);
230
+ return new Response(
231
+ "<html><body><h1>Authenticated!</h1><p>You can close this window.</p></body></html>",
232
+ { headers: { "Content-Type": "text/html" } },
233
+ );
234
+ },
235
+ });
236
+
237
+ return { server, authCodePromise };
238
+ }
239
+
240
+ /** Resolve the canonical resource URL for an HTTP MCP server.
241
+ * Some servers advertise a canonical URL in their OAuth protected resource metadata
242
+ * that may differ from the URL provided by the user (e.g. hf.co → huggingface.co).
243
+ * Returns the canonical URL if found, or the original URL otherwise. */
244
+ export async function resolveResourceUrl(serverUrl: string): Promise<string> {
245
+ try {
246
+ const info = await discoverOAuthServerInfo(serverUrl);
247
+ const canonical = info.resourceMetadata?.resource;
248
+ if (canonical && canonical !== serverUrl) {
249
+ // Preserve the path/query/hash from the original URL — the canonical URL
250
+ // from OAuth resource metadata identifies the origin (scheme + host + port),
251
+ // not the endpoint path (e.g. /mcp).
252
+ const orig = new URL(serverUrl);
253
+ const canon = new URL(canonical);
254
+ canon.pathname = orig.pathname;
255
+ canon.search = orig.search;
256
+ canon.hash = orig.hash;
257
+ const merged = canon.toString();
258
+ return merged === serverUrl ? serverUrl : merged;
259
+ }
260
+ } catch {
261
+ // OAuth discovery not available — use original URL
262
+ }
263
+ return serverUrl;
264
+ }
265
+
266
+ /** Probe for OAuth support and run the auth flow if the server supports it.
267
+ * Returns true if auth ran, false if server doesn't support OAuth (silent skip). */
268
+ export async function tryOAuthIfSupported(
269
+ serverName: string,
270
+ serverUrl: string,
271
+ configDir: string,
272
+ auth: AuthFile,
273
+ formatOptions: FormatOptions,
274
+ ): Promise<boolean> {
275
+ let oauthSupported: boolean;
276
+ try {
277
+ const info = await discoverOAuthServerInfo(serverUrl);
278
+ oauthSupported = info.authorizationServerMetadata !== undefined;
279
+ } catch {
280
+ return false;
281
+ }
282
+
283
+ if (!oauthSupported) return false;
284
+
285
+ const provider = new McpOAuthProvider({ serverName, configDir, auth });
286
+ const spinner = logger.startSpinner(`Authenticating with "${serverName}"…`, formatOptions);
287
+ try {
288
+ await runOAuthFlow(serverUrl, provider);
289
+ spinner.success(`Authenticated with "${serverName}"`);
290
+ return true;
291
+ } catch (err) {
292
+ spinner.error(`Authentication failed: ${err instanceof Error ? err.message : err}`);
293
+ throw err;
294
+ }
295
+ }
296
+
297
+ /** Run a full OAuth authorization flow for an HTTP MCP server */
298
+ export async function runOAuthFlow(serverUrl: string, provider: McpOAuthProvider): Promise<void> {
299
+ // Clear any leftover state from a previously cancelled auth flow
300
+ await provider.clearIncomplete();
301
+
302
+ const { server, authCodePromise } = startCallbackServer();
303
+ try {
304
+ provider.setCallbackPort(server.port);
305
+
306
+ const result = await auth(provider, { serverUrl });
307
+ if (result === "REDIRECT") {
308
+ const code = await authCodePromise;
309
+ await auth(provider, { serverUrl, authorizationCode: code });
310
+ }
311
+ } finally {
312
+ server.stop();
313
+ }
314
+ }
@@ -0,0 +1,17 @@
1
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
2
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
3
+ import type { HttpServerConfig } from "../config/schemas.ts";
4
+ import { createDebugFetch } from "./debug-fetch.ts";
5
+
6
+ export function createSseTransport(
7
+ config: HttpServerConfig,
8
+ authProvider?: OAuthClientProvider,
9
+ verbose = false,
10
+ showSecrets = false,
11
+ ): SSEClientTransport {
12
+ return new SSEClientTransport(new URL(config.url), {
13
+ authProvider,
14
+ requestInit: config.headers ? { headers: config.headers } : undefined,
15
+ fetch: verbose ? createDebugFetch(showSecrets) : undefined,
16
+ });
17
+ }
@@ -0,0 +1,12 @@
1
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
+ import type { StdioServerConfig } from "../config/schemas.ts";
3
+
4
+ export function createStdioTransport(config: StdioServerConfig): StdioClientTransport {
5
+ return new StdioClientTransport({
6
+ command: config.command,
7
+ args: config.args,
8
+ env: config.env ? { ...process.env, ...config.env } : undefined,
9
+ cwd: config.cwd,
10
+ stderr: "pipe",
11
+ });
12
+ }
@@ -0,0 +1,184 @@
1
+ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
2
+ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
3
+ import { cyan, dim, green, red, yellow } from "ansis";
4
+ import { logger } from "../output/logger.ts";
5
+
6
+ export interface TraceOptions {
7
+ json: boolean;
8
+ serverName: string;
9
+ }
10
+
11
+ interface PendingRequest {
12
+ method: string;
13
+ sentAt: number;
14
+ }
15
+
16
+ /**
17
+ * Wrap a transport with JSON-RPC message tracing.
18
+ * Logs all outgoing/incoming messages to stderr.
19
+ * Uses a Proxy so all other transport properties pass through transparently.
20
+ */
21
+ export function wrapTransportWithTrace(transport: Transport, options: TraceOptions): Transport {
22
+ const pending = new Map<string | number, PendingRequest>();
23
+ const isTTY = process.stderr.isTTY ?? false;
24
+
25
+ let clientOnMessage: ((message: JSONRPCMessage, extra?: unknown) => void) | undefined;
26
+
27
+ return new Proxy(transport, {
28
+ get(target, prop, receiver) {
29
+ if (prop === "send") {
30
+ return async (message: JSONRPCMessage) => {
31
+ logOutgoing(message, pending, options, isTTY);
32
+ return target.send(message);
33
+ };
34
+ }
35
+ if (prop === "onmessage") {
36
+ return clientOnMessage;
37
+ }
38
+ return Reflect.get(target, prop, receiver);
39
+ },
40
+ set(target, prop, value) {
41
+ if (prop === "onmessage") {
42
+ clientOnMessage = value;
43
+ target.onmessage = (message: JSONRPCMessage, extra?: unknown) => {
44
+ logIncoming(message, pending, options, isTTY);
45
+ clientOnMessage?.(message, extra);
46
+ };
47
+ return true;
48
+ }
49
+ return Reflect.set(target, prop, value);
50
+ },
51
+ });
52
+ }
53
+
54
+ function logOutgoing(
55
+ message: JSONRPCMessage,
56
+ pending: Map<string | number, PendingRequest>,
57
+ options: TraceOptions,
58
+ isTTY: boolean,
59
+ ): void {
60
+ // Track pending requests for timing (needed in both modes)
61
+ if ("id" in message && "method" in message) {
62
+ const m = message as { id: string | number; method: string };
63
+ pending.set(m.id, { method: m.method, sentAt: performance.now() });
64
+ }
65
+
66
+ if (options.json) {
67
+ logger.writeRaw(
68
+ JSON.stringify({ trace: "outgoing", server: options.serverName, message }) + "\n",
69
+ );
70
+ return;
71
+ }
72
+
73
+ if ("id" in message && "method" in message) {
74
+ const m = message as { id: string | number; method: string; params?: unknown };
75
+ const arrow = isTTY ? cyan("→") : "→";
76
+ const detail = summarizeParams(m.method, m.params);
77
+ const detailStr = detail ? ` ${detail}` : "";
78
+ logger.writeRaw(`${arrow} ${dim(`${m.method} (id: ${m.id})${detailStr}`)}\n`);
79
+ } else if ("method" in message) {
80
+ const m = message as { method: string };
81
+ const arrow = isTTY ? cyan("→") : "→";
82
+ logger.writeRaw(`${arrow} ${dim(m.method)}\n`);
83
+ }
84
+ }
85
+
86
+ function logIncoming(
87
+ message: JSONRPCMessage,
88
+ pending: Map<string | number, PendingRequest>,
89
+ options: TraceOptions,
90
+ isTTY: boolean,
91
+ ): void {
92
+ if ("id" in message && !("method" in message)) {
93
+ // Response to a request
94
+ const m = message as { id: string | number; result?: unknown; error?: unknown };
95
+ const req = pending.get(m.id);
96
+ pending.delete(m.id);
97
+ const elapsed = req ? Math.round(performance.now() - req.sentAt) : undefined;
98
+ const method = req?.method ?? "unknown";
99
+
100
+ if (options.json) {
101
+ logger.writeRaw(
102
+ JSON.stringify({
103
+ trace: "incoming",
104
+ server: options.serverName,
105
+ message,
106
+ ...(elapsed !== undefined && { elapsed_ms: elapsed }),
107
+ request_method: method,
108
+ }) + "\n",
109
+ );
110
+ return;
111
+ }
112
+
113
+ const isError = m.error !== undefined;
114
+ const arrow = isTTY ? (isError ? red("←") : green("←")) : "←";
115
+ const timing = elapsed !== undefined ? ` [${elapsed}ms]` : "";
116
+ const summary = summarizeResult(method, m.result);
117
+ const summaryStr = summary ? ` — ${summary}` : "";
118
+ logger.writeRaw(`${arrow} ${dim(`${method} (id: ${m.id})${timing}${summaryStr}`)}\n`);
119
+ } else if ("method" in message) {
120
+ // Notification (incoming)
121
+ const m = message as { method: string; params?: unknown };
122
+
123
+ if (options.json) {
124
+ logger.writeRaw(
125
+ JSON.stringify({ trace: "incoming", server: options.serverName, message }) + "\n",
126
+ );
127
+ return;
128
+ }
129
+
130
+ const arrow = isTTY ? yellow("←") : "←";
131
+ const params = m.params ? ` ${JSON.stringify(m.params)}` : "";
132
+ logger.writeRaw(`${arrow} ${dim(`${m.method}${params}`)}\n`);
133
+ }
134
+ }
135
+
136
+ function summarizeParams(method: string, params: unknown): string | undefined {
137
+ if (!params || typeof params !== "object") return undefined;
138
+ const p = params as Record<string, unknown>;
139
+
140
+ switch (method) {
141
+ case "tools/call": {
142
+ const name = p.name as string | undefined;
143
+ const args = p.arguments;
144
+ if (!name) return undefined;
145
+ const argsStr = args ? ` ${JSON.stringify(args)}` : "";
146
+ return `${name}${argsStr}`;
147
+ }
148
+ case "resources/read":
149
+ return p.uri ? String(p.uri) : undefined;
150
+ case "prompts/get":
151
+ return p.name ? String(p.name) : undefined;
152
+ default:
153
+ return undefined;
154
+ }
155
+ }
156
+
157
+ function summarizeResult(method: string, result: unknown): string | undefined {
158
+ if (!result || typeof result !== "object") return undefined;
159
+ const r = result as Record<string, unknown>;
160
+
161
+ switch (method) {
162
+ case "tools/list":
163
+ return Array.isArray(r.tools) ? `${r.tools.length} tools` : undefined;
164
+ case "resources/list":
165
+ return Array.isArray(r.resources) ? `${r.resources.length} resources` : undefined;
166
+ case "resources/templates/list":
167
+ return Array.isArray(r.resourceTemplates)
168
+ ? `${r.resourceTemplates.length} templates`
169
+ : undefined;
170
+ case "prompts/list":
171
+ return Array.isArray(r.prompts) ? `${r.prompts.length} prompts` : undefined;
172
+ case "initialize": {
173
+ const info = r.serverInfo as { name?: string; version?: string } | undefined;
174
+ if (info?.name) return info.version ? `${info.name} v${info.version}` : info.name;
175
+ return undefined;
176
+ }
177
+ case "tools/call":
178
+ return r.isError ? "error" : "ok";
179
+ case "ping":
180
+ return "pong";
181
+ default:
182
+ return "ok";
183
+ }
184
+ }