@xpufx/paseo-x-comms 0.3.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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/client/attribution.ts +7 -0
  4. package/client/conversations.ts +229 -0
  5. package/client/main.tsx +885 -0
  6. package/client/peer-label.ts +98 -0
  7. package/client/peer-status.tsx +201 -0
  8. package/client/settings-prototype.tsx +395 -0
  9. package/client/tool-call.ts +115 -0
  10. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  11. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  12. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  13. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  14. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  15. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  16. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  17. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  18. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  19. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  20. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  21. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  22. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  23. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  24. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  25. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  26. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  27. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  28. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  29. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  30. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  31. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  33. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  34. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  35. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  36. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  37. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  38. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  39. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  40. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  41. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  42. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  43. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  44. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  45. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  47. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  50. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  51. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  52. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  53. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  54. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  55. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  56. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  57. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  58. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  59. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  60. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  61. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  62. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  63. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  64. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  65. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  66. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  67. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  68. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  69. package/client/via-x-comms.tsx +15 -0
  70. package/client/x-comms-conversation.tsx +449 -0
  71. package/client/x-comms-panel.tsx +6 -0
  72. package/client/x-comms-pill.tsx +193 -0
  73. package/client/x-comms-timeline.tsx +176 -0
  74. package/client/x-comms-tool-call.tsx +85 -0
  75. package/docs/mesh.md +115 -0
  76. package/mcp/LICENSE +202 -0
  77. package/mcp/README.md +287 -0
  78. package/mcp/mcp-config.example.json +8 -0
  79. package/mcp/package-lock.json +1186 -0
  80. package/mcp/package.json +43 -0
  81. package/mcp/paseo-cross-daemon-comms.example.json +5 -0
  82. package/mcp/paseo-x-comms.bundled.mjs +36964 -0
  83. package/mcp/paseo-x-comms.mjs +630 -0
  84. package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
  85. package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
  86. package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
  87. package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
  88. package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
  89. package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
  90. package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
  91. package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
  92. package/mcp/test/fixtures/fake-paseo.mjs +92 -0
  93. package/mcp/test/register-ts-hooks.mjs +6 -0
  94. package/mcp/test/resolve-ts-hooks.mjs +46 -0
  95. package/package.json +58 -0
  96. package/paseo-plugin.json +7 -0
  97. package/server/conversations-snapshot.ts +249 -0
  98. package/server/handlers.ts +1081 -0
  99. package/server/injection.ts +152 -0
  100. package/server/local-send.ts +153 -0
  101. package/server/mcp-client.ts +47 -0
  102. package/server/outbox.ts +233 -0
  103. package/server/peer-channel.ts +151 -0
  104. package/server/peer-status.ts +233 -0
  105. package/server/presence.ts +204 -0
  106. package/server/registry.ts +215 -0
  107. package/server/relay-status.ts +9 -0
  108. package/server/server-status.ts +138 -0
  109. package/server/settings.ts +81 -0
  110. package/server/snapshot.ts +176 -0
  111. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  112. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  113. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  114. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  115. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  116. package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
  117. package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
  118. package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
  119. package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
  120. package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
  121. package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
  122. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  123. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  124. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  125. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  126. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  127. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  128. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  129. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  130. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  131. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  132. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  133. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  134. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  135. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  136. package/shared/conversations-snapshot.ts +39 -0
  137. package/shared/envelope.ts +92 -0
  138. package/shared/outbox.ts +21 -0
  139. package/shared/registry.ts +366 -0
  140. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  141. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  142. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  143. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  144. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  145. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  146. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  147. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  148. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  149. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  150. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  151. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  152. package/shared/version.ts +2 -0
@@ -0,0 +1,210 @@
1
+ import { redactSecrets } from "./redact";
2
+ import { resolvePluginVersion } from "./version";
3
+
4
+ export type LogLevel = "debug" | "info" | "warn" | "error";
5
+
6
+ const LEVEL_SEVERITY: Record<LogLevel, number> = {
7
+ debug: 10,
8
+ info: 20,
9
+ warn: 30,
10
+ error: 40,
11
+ };
12
+
13
+ function isLogLevel(value: unknown): value is LogLevel {
14
+ return (
15
+ value === "debug" || value === "info" || value === "warn" || value === "error"
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Resolves the minimum log level from the environment. Precedence:
21
+ * `PASEO_PLUGIN_LOG_LEVEL` > `PASEO_LOG_LEVEL` > `PASEO_DEBUG=1` (debug).
22
+ * Returns undefined when nothing is set so callers can apply their default.
23
+ */
24
+ export function resolveMinLevelFromEnv(
25
+ env: NodeJS.ProcessEnv = process.env,
26
+ ): LogLevel | undefined {
27
+ const raw = env.PASEO_PLUGIN_LOG_LEVEL ?? env.PASEO_LOG_LEVEL;
28
+ if (typeof raw === "string" && isLogLevel(raw.trim().toLowerCase())) {
29
+ return raw.trim().toLowerCase() as LogLevel;
30
+ }
31
+ const debugFlag = env.PASEO_DEBUG ?? env.PASEO_PLUGIN_DEBUG;
32
+ if (
33
+ typeof debugFlag === "string" &&
34
+ ["1", "true", "yes", "debug"].includes(debugFlag.trim().toLowerCase())
35
+ ) {
36
+ return "debug";
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * True when running in production. Retained for callers that need a
43
+ * production check; note the default log level no longer keys off this.
44
+ */
45
+ export function isProductionEnv(env: NodeJS.ProcessEnv = process.env): boolean {
46
+ return (env.NODE_ENV ?? "").trim().toLowerCase() === "production";
47
+ }
48
+
49
+ /**
50
+ * True only when `NODE_ENV` is explicitly a development value
51
+ * (`development`/`dev`). Unset, empty, `production`, and anything else
52
+ * (e.g. `test`) all count as quiet, so a shipped plugin defaults to info
53
+ * without any env var set.
54
+ */
55
+ export function isDevelopmentEnv(env: NodeJS.ProcessEnv = process.env): boolean {
56
+ const nodeEnv = (env.NODE_ENV ?? "").trim().toLowerCase();
57
+ return nodeEnv === "development" || nodeEnv === "dev";
58
+ }
59
+
60
+ /**
61
+ * Default rule (documented for operators):
62
+ * explicit `PASEO_PLUGIN_LOG_LEVEL`/`PASEO_LOG_LEVEL`/`PASEO_DEBUG` always wins;
63
+ * otherwise quiet (`info`) — including when no env var is set at all, so a
64
+ * shipped plugin never emits debug logs by default. Debug only when `NODE_ENV`
65
+ * is explicitly a development value (`development`/`dev`).
66
+ */
67
+ export function resolveDefaultMinLevel(
68
+ env: NodeJS.ProcessEnv = process.env,
69
+ ): LogLevel {
70
+ return resolveMinLevelFromEnv(env) ?? (isDevelopmentEnv(env) ? "debug" : "info");
71
+ }
72
+
73
+ export interface PluginLoggerOptions {
74
+ /**
75
+ * Version of the plugin.
76
+ * If omitted, automatically resolves from `package.json` (augmented by git tag/hash).
77
+ */
78
+ version?: string;
79
+
80
+ /**
81
+ * Whether to emit a formatted startup banner on initialization.
82
+ * Defaults to `true`.
83
+ */
84
+ banner?: boolean;
85
+
86
+ /**
87
+ * Subsystem or module tag within the plugin (e.g. "poller", "mcp-client").
88
+ */
89
+ subsystem?: string;
90
+
91
+ /**
92
+ * Minimum log level to print. Defaults to resolveDefaultMinLevel():
93
+ * info unless NODE_ENV is explicitly development (or an explicit level is set).
94
+ */
95
+ minLevel?: LogLevel;
96
+
97
+ /**
98
+ * Optional custom metadata key-values to include in the startup banner.
99
+ */
100
+ meta?: Record<string, string | number | boolean>;
101
+ }
102
+
103
+ export interface PluginLogger {
104
+ debug(message: string, data?: unknown): void;
105
+ info(message: string, data?: unknown): void;
106
+ warn(message: string, data?: unknown): void;
107
+ error(message: string, data?: unknown): void;
108
+ /** Surfaces a caught/suppressed error at debug level so it reaches the plugin log. */
109
+ suppressed(context: string, error: unknown): void;
110
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger;
111
+ }
112
+
113
+ function formatData(data: unknown): string {
114
+ if (data === undefined) return "";
115
+ if (data instanceof Error) {
116
+ return `error="${data.message}"${data.stack ? `\n${data.stack}` : ""}`;
117
+ }
118
+
119
+ const sanitized = redactSecrets(data);
120
+ if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
121
+ const pairs = Object.entries(sanitized as Record<string, unknown>).map(
122
+ ([k, v]) => `${k}=${typeof v === "object" ? JSON.stringify(v) : String(v)}`,
123
+ );
124
+ return pairs.join(" ");
125
+ }
126
+
127
+ return typeof sanitized === "string" ? sanitized : JSON.stringify(sanitized);
128
+ }
129
+
130
+ /**
131
+ * Creates a structured logger for Paseo plugins.
132
+ * By default, displays the plugin name and version in startup logs and tags each line
133
+ * for Paseo's log stream without fragmented multi-line JSON.
134
+ *
135
+ * If `options.version` is omitted, it automatically resolves the version from `package.json`
136
+ * augmented with git metadata.
137
+ */
138
+ export function createPluginLogger(
139
+ pluginId: string,
140
+ options: PluginLoggerOptions = {},
141
+ ): PluginLogger {
142
+ const resolvedVer = options.version ?? resolvePluginVersion({ fallback: "" });
143
+ const {
144
+ banner = true,
145
+ subsystem,
146
+ minLevel = resolveDefaultMinLevel(),
147
+ meta = {},
148
+ } = options;
149
+
150
+ const minSeverity = LEVEL_SEVERITY[minLevel];
151
+
152
+ // Prefix format: "[name vX.Y.Z]" or "[name]" or "[name vX.Y.Z:subsystem]"
153
+ const versionTag = resolvedVer ? ` v${resolvedVer}` : "";
154
+ const subTag = subsystem ? `:${subsystem}` : "";
155
+ const baseTag = `[${pluginId}${versionTag}${subTag}]`;
156
+
157
+ if (banner) {
158
+ const bannerDetails = [
159
+ `pid ${process.pid}`,
160
+ `node ${process.version}`,
161
+ ...Object.entries(meta).map(([k, v]) => `${k} ${v}`),
162
+ ].join(", ");
163
+
164
+ // Emit startup banner directly to stdout
165
+ console.log(`${baseTag} Initializing plugin (${bannerDetails})`);
166
+ }
167
+
168
+ function emit(level: LogLevel, message: string, data?: unknown) {
169
+ if (LEVEL_SEVERITY[level] < minSeverity) return;
170
+
171
+ const levelTag = `[${level.toUpperCase()}]`;
172
+ const formattedData = formatData(data);
173
+ const line = formattedData
174
+ ? `${baseTag} ${levelTag} ${message} ${formattedData}`
175
+ : `${baseTag} ${levelTag} ${message}`;
176
+
177
+ if (level === "error" || level === "warn") {
178
+ console.error(line);
179
+ } else {
180
+ console.log(line);
181
+ }
182
+ }
183
+
184
+ return {
185
+ debug(message: string, data?: unknown) {
186
+ emit("debug", message, data);
187
+ },
188
+ info(message: string, data?: unknown) {
189
+ emit("info", message, data);
190
+ },
191
+ warn(message: string, data?: unknown) {
192
+ emit("warn", message, data);
193
+ },
194
+ error(message: string, data?: unknown) {
195
+ emit("error", message, data);
196
+ },
197
+ suppressed(context: string, error: unknown) {
198
+ const detail = error instanceof Error ? error.message : String(error);
199
+ emit("debug", `${context}: ${detail}`, error);
200
+ },
201
+ child(subsystemOrOptions: string | Partial<PluginLoggerOptions>): PluginLogger {
202
+ const childOptions: PluginLoggerOptions =
203
+ typeof subsystemOrOptions === "string"
204
+ ? { ...options, version: resolvedVer, banner: false, subsystem: subsystemOrOptions }
205
+ : { ...options, version: resolvedVer, banner: false, ...subsystemOrOptions };
206
+
207
+ return createPluginLogger(pluginId, childOptions);
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,349 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+ import readline from "node:readline";
3
+ import crypto from "node:crypto";
4
+ import { StderrRingBuffer } from "./ring-buffer";
5
+ import { killProcessTree } from "./process-killer";
6
+ import { McpHttpClient, type McpHttpOptions } from "./http-client";
7
+ import type {
8
+ McpClientOptions,
9
+ McpPingOptions,
10
+ McpPingResult,
11
+ McpToolCallResult,
12
+ McpToolInfo,
13
+ } from "./types";
14
+
15
+ interface PendingRequest {
16
+ resolve: (res: any) => void;
17
+ reject: (err: Error) => void;
18
+ timer: NodeJS.Timeout;
19
+ }
20
+
21
+ export class McpClient {
22
+ private child: ChildProcess | null = null;
23
+ private stderrBuffer = new StderrRingBuffer(30);
24
+ private pendingRequests = new Map<string | number, PendingRequest>();
25
+ private initialized = false;
26
+ private _serverInfo?: { name: string; version?: string };
27
+ private _instructions?: string;
28
+ private _capabilities?: Record<string, unknown>;
29
+ private _protocolVersion?: string;
30
+ private initPromise?: Promise<void>;
31
+
32
+ /**
33
+ * Server metadata returned during MCP initialize handshake.
34
+ */
35
+ get serverInfo(): { name: string; version?: string } | undefined {
36
+ return this._serverInfo;
37
+ }
38
+
39
+ /**
40
+ * Optional server instructions / system prompt guidance returned during MCP initialize handshake.
41
+ */
42
+ get instructions(): string | undefined {
43
+ return this._instructions;
44
+ }
45
+
46
+ /**
47
+ * Declared server capabilities returned during initialize handshake.
48
+ */
49
+ get capabilities(): Record<string, unknown> | undefined {
50
+ return this._capabilities;
51
+ }
52
+
53
+ /**
54
+ * Protocol version negotiated during initialize handshake.
55
+ */
56
+ get protocolVersion(): string | undefined {
57
+ return this._protocolVersion;
58
+ }
59
+
60
+ private constructor(
61
+ private readonly command: string,
62
+ private readonly args: string[] = [],
63
+ private readonly env?: Record<string, string>,
64
+ private readonly options: McpClientOptions = {},
65
+ ) {}
66
+
67
+ /**
68
+ * Creates an MCP client talking to a local process over stdio.
69
+ */
70
+ static forStdio(
71
+ command: string,
72
+ args: string[] = [],
73
+ env?: Record<string, string>,
74
+ options?: McpClientOptions,
75
+ ): McpClient {
76
+ return new McpClient(command, args, env, options);
77
+ }
78
+
79
+ /**
80
+ * Creates an MCP client connecting to an HTTP or Server-Sent Events (SSE) MCP server.
81
+ */
82
+ static forHttp(url: string, options?: McpHttpOptions): McpHttpClient {
83
+ return new McpHttpClient(url, options);
84
+ }
85
+
86
+ private startProcess(): Promise<void> {
87
+ if (this.child && !this.child.killed) return Promise.resolve();
88
+
89
+ return new Promise((resolve, reject) => {
90
+ try {
91
+ this.child = spawn(this.command, this.args, {
92
+ env: {
93
+ ...process.env,
94
+ ...this.env,
95
+ },
96
+ shell: false,
97
+ detached: process.platform !== "win32",
98
+ });
99
+
100
+ this.child.on("error", (err: any) => {
101
+ const isNotFound = err.code === "ENOENT" || err.code === "EACCES";
102
+ const formattedErr = isNotFound
103
+ ? new Error(`Executable "${this.command}" not found in PATH or cannot be executed (${err.code}).`)
104
+ : err;
105
+ this.rejectAllPending(formattedErr);
106
+ reject(formattedErr);
107
+ });
108
+
109
+ this.child.stderr?.on("data", (chunk: Buffer | string) => {
110
+ this.stderrBuffer.push(chunk.toString());
111
+ });
112
+
113
+ if (this.child.stdout) {
114
+ const rl = readline.createInterface({
115
+ input: this.child.stdout,
116
+ crlfDelay: Infinity,
117
+ });
118
+
119
+ rl.on("line", (line) => {
120
+ const trimmed = line.trim();
121
+ if (!trimmed || !trimmed.startsWith("{")) {
122
+ // Ignore non-JSON output (e.g. startup banners or debug logs)
123
+ return;
124
+ }
125
+ try {
126
+ const msg = JSON.parse(trimmed);
127
+ this.handleMessage(msg);
128
+ } catch {
129
+ // Ignore JSON parse errors from malformed stdout noise
130
+ }
131
+ });
132
+ }
133
+
134
+ this.child.on("close", (code, signal) => {
135
+ const stderr = this.stderrBuffer.getRecentText();
136
+ const reason = stderr
137
+ ? `Process exited with code ${code ?? signal}:\n${stderr}`
138
+ : `Process exited unexpectedly with code ${code ?? signal}.`;
139
+ this.rejectAllPending(new Error(reason));
140
+ });
141
+
142
+ resolve();
143
+ } catch (err: any) {
144
+ const isNotFound = err?.code === "ENOENT" || err?.code === "EACCES";
145
+ const formattedErr = isNotFound
146
+ ? new Error(`Executable "${this.command}" not found in PATH or cannot be executed (${err?.code}).`)
147
+ : err;
148
+ reject(formattedErr);
149
+ }
150
+ });
151
+ }
152
+
153
+ private handleMessage(msg: any): void {
154
+ if (!msg || typeof msg !== "object") return;
155
+
156
+ // Handle responses to requests
157
+ if (msg.id !== undefined && msg.id !== null) {
158
+ const pending = this.pendingRequests.get(msg.id);
159
+ if (pending) {
160
+ clearTimeout(pending.timer);
161
+ this.pendingRequests.delete(msg.id);
162
+
163
+ if (msg.error) {
164
+ const err = new Error(msg.error.message || `JSON-RPC error ${msg.error.code}`);
165
+ (err as any).code = msg.error.code;
166
+ (err as any).data = msg.error.data;
167
+ pending.reject(err);
168
+ } else {
169
+ pending.resolve(msg.result);
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ private rejectAllPending(err: Error): void {
176
+ for (const [id, pending] of this.pendingRequests.entries()) {
177
+ clearTimeout(pending.timer);
178
+ pending.reject(err);
179
+ }
180
+ this.pendingRequests.clear();
181
+ }
182
+
183
+ private async sendRequest<T = any>(
184
+ method: string,
185
+ params?: any,
186
+ timeoutMs?: number,
187
+ ): Promise<T> {
188
+ await this.startProcess();
189
+
190
+ if (!this.child || !this.child.stdin || this.child.killed) {
191
+ throw new Error(`Process "${this.command}" is not running.`);
192
+ }
193
+
194
+ const id = crypto.randomUUID();
195
+ const timeout = timeoutMs ?? this.options.timeoutMs ?? 10000;
196
+
197
+ const payload = JSON.stringify({
198
+ jsonrpc: "2.0",
199
+ id,
200
+ method,
201
+ ...(params !== undefined ? { params } : {}),
202
+ });
203
+
204
+ return new Promise<T>((resolve, reject) => {
205
+ const timer = setTimeout(() => {
206
+ this.pendingRequests.delete(id);
207
+ const stderr = this.stderrBuffer.getRecentText();
208
+ const msg = stderr
209
+ ? `Request "${method}" timed out after ${timeout}ms. Stderr output:\n${stderr}`
210
+ : `Request "${method}" timed out after ${timeout}ms.`;
211
+ reject(new Error(msg));
212
+ }, timeout);
213
+
214
+ this.pendingRequests.set(id, { resolve, reject, timer });
215
+
216
+ this.child!.stdin!.write(payload + "\n", (err) => {
217
+ if (err) {
218
+ clearTimeout(timer);
219
+ this.pendingRequests.delete(id);
220
+ reject(err);
221
+ }
222
+ });
223
+ });
224
+ }
225
+
226
+ private sendNotification(method: string, params?: any): void {
227
+ if (!this.child || !this.child.stdin || this.child.killed) return;
228
+ const payload = JSON.stringify({
229
+ jsonrpc: "2.0",
230
+ method,
231
+ ...(params !== undefined ? { params } : {}),
232
+ });
233
+ this.child.stdin.write(payload + "\n");
234
+ }
235
+
236
+ /**
237
+ * Performs the MCP initialize handshake and sends the initialized notification.
238
+ */
239
+ async initialize(): Promise<void> {
240
+ if (this.initialized) return;
241
+ if (this.initPromise) return this.initPromise;
242
+
243
+ this.initPromise = (async () => {
244
+ const result = await this.sendRequest("initialize", {
245
+ protocolVersion: "2024-11-05",
246
+ capabilities: {
247
+ tools: {},
248
+ },
249
+ clientInfo: this.options.clientInfo ?? {
250
+ name: "paseo-plugin",
251
+ version: "1.0.0",
252
+ },
253
+ });
254
+
255
+ this._serverInfo = result?.serverInfo;
256
+ this._instructions = result?.instructions;
257
+ this._capabilities = result?.capabilities;
258
+ this._protocolVersion = result?.protocolVersion;
259
+ this.sendNotification("notifications/initialized");
260
+ this.initialized = true;
261
+ })();
262
+
263
+ return this.initPromise;
264
+ }
265
+
266
+ /**
267
+ * Checks the health and responsiveness of the MCP server.
268
+ * If protocol ping fails with -32601 (Method not found), seamlessly falls back
269
+ * to handshake validation.
270
+ */
271
+ async ping(options: McpPingOptions = {}): Promise<McpPingResult> {
272
+ const start = Date.now();
273
+ const timeout = options.timeoutMs ?? this.options.timeoutMs ?? 10000;
274
+
275
+ try {
276
+ await this.initialize();
277
+
278
+ if (options.mode === "tools") {
279
+ await this.listTools();
280
+ } else {
281
+ try {
282
+ await this.sendRequest("ping", {}, timeout);
283
+ } catch (err: any) {
284
+ // Method not found (-32601) is common on minimalist servers; fallback to handshake
285
+ if (err.code !== -32601) {
286
+ throw err;
287
+ }
288
+ }
289
+ }
290
+
291
+ return {
292
+ healthy: true,
293
+ latencyMs: Date.now() - start,
294
+ serverInfo: this._serverInfo,
295
+ instructions: this._instructions,
296
+ stderr: this.stderrBuffer.getRecentText(),
297
+ };
298
+ } catch (err: any) {
299
+ return {
300
+ healthy: false,
301
+ latencyMs: Date.now() - start,
302
+ error: err.message,
303
+ stderr: this.stderrBuffer.getRecentText(),
304
+ };
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Enumerates available tools on the MCP server.
310
+ */
311
+ async listTools(): Promise<McpToolInfo[]> {
312
+ await this.initialize();
313
+ const result = await this.sendRequest("tools/list", {});
314
+ return (result?.tools as McpToolInfo[]) ?? [];
315
+ }
316
+
317
+ /**
318
+ * Calls a tool by name with the given arguments.
319
+ */
320
+ async callTool(name: string, args: Record<string, unknown> = {}): Promise<McpToolCallResult> {
321
+ await this.initialize();
322
+ const result = await this.sendRequest("tools/call", {
323
+ name,
324
+ arguments: args,
325
+ });
326
+ return result as McpToolCallResult;
327
+ }
328
+
329
+ /**
330
+ * Retrieves the recent stderr lines captured in the ring buffer.
331
+ */
332
+ getStderr(): string {
333
+ return this.stderrBuffer.getRecentText();
334
+ }
335
+
336
+ /**
337
+ * Cleanly closes the client, killing the child process and its process tree.
338
+ */
339
+ async close(): Promise<void> {
340
+ this.rejectAllPending(new Error("Client was closed."));
341
+ if (this.child) {
342
+ const child = this.child;
343
+ this.child = null;
344
+ await killProcessTree(child);
345
+ }
346
+ this.initialized = false;
347
+ this.initPromise = undefined;
348
+ }
349
+ }