@modelprofile.com/mcp-crossharness 5.0.0 → 5.2.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.
@@ -1,24 +1,25 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
- import type * as interfaces from './interfaces.js';
4
3
  import { ConnectionRegistry } from './classes.connectionregistry.js';
5
4
  import { DispatchRegistry } from './classes.dispatchregistry.js';
6
-
7
- const harnessNameSchema = plugins.z.enum(['claudecode', 'opencode', 'codex']);
5
+ import { CrossHarnessMcpToolRegistrar } from './classes.mcptoolregistrar.js';
8
6
 
9
7
  export class CrossHarnessMcpServer {
8
+ public readonly registrar: CrossHarnessMcpToolRegistrar;
10
9
  public readonly connections: ConnectionRegistry;
11
10
  public readonly dispatches: DispatchRegistry;
12
11
  private activeServer: InstanceType<typeof plugins.McpServer> | undefined;
12
+ private activeTransport: plugins.Transport | undefined;
13
+ private transportCloseNeedsRetry = false;
13
14
  private stdinEndHandler: (() => void) | undefined;
14
15
  private stdinEndSource: NodeJS.ReadableStream | undefined;
15
16
  private closePromise: Promise<void> | undefined;
16
- private resourceShutdownPromise: Promise<void> | undefined;
17
17
  private startCalled = false;
18
18
 
19
19
  constructor(connectionsArg = new ConnectionRegistry(), dispatchesArg = new DispatchRegistry()) {
20
- this.connections = connectionsArg;
21
- this.dispatches = dispatchesArg;
20
+ this.registrar = new CrossHarnessMcpToolRegistrar(connectionsArg, dispatchesArg);
21
+ this.connections = this.registrar.connections;
22
+ this.dispatches = this.registrar.dispatches;
22
23
  }
23
24
 
24
25
  public async start(
@@ -31,294 +32,93 @@ export class CrossHarnessMcpServer {
31
32
  name: 'mcp-crossharness',
32
33
  version: commitinfo.version,
33
34
  });
34
-
35
- server.registerTool(
36
- 'connect_harness',
37
- {
38
- title: 'Connect to harness server',
39
- description:
40
- 'Establish an explicit connection to an allowlisted harness web server. The exact origin must be listed in CROSSHARNESS_ALLOWED_SERVER_URLS. OpenCode uses http(s), Codex uses ws(s), and Claude Code is rejected because it exposes no URL-addressable session server. Returns a connectionId required by every connection-scoped operation; list_connections remains global.',
41
- inputSchema: {
42
- harness: harnessNameSchema,
43
- serverUrl: plugins.z.string().max(2048).url().describe('Allowlisted harness server origin'),
44
- directory: plugins.z
45
- .string()
46
- .max(4096)
47
- .startsWith('/')
48
- .describe('Absolute project directory on the harness server'),
49
- },
50
- },
51
- async (args, extra) =>
52
- this.wrapToolCall(async () => {
53
- const info = await this.connections.connect(
54
- args.harness as interfaces.THarnessName,
55
- args.serverUrl,
56
- args.directory,
57
- extra.signal,
58
- );
59
- return this.describeConnection(info);
60
- }),
61
- );
62
-
63
- server.registerTool(
64
- 'disconnect_harness',
65
- {
66
- title: 'Disconnect harness server',
67
- description:
68
- 'Close and remove an established harness connection. In-flight requests fail; async dispatch records remain inspectable with check_reply.',
69
- inputSchema: {
70
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
71
- },
72
- },
73
- async (args, extra) =>
74
- this.wrapToolCall(async () =>
75
- this.describeConnection(await this.connections.disconnect(args.connectionId)),
76
- ),
77
- );
78
-
79
- server.registerTool(
80
- 'list_connections',
81
- {
82
- title: 'List harness connections',
83
- description: 'List explicit harness web-server connections established in this MCP process.',
84
- inputSchema: {},
85
- },
86
- async () =>
87
- this.wrapToolCall(async () => {
88
- const connections = this.connections.list();
89
- return connections.length > 0
90
- ? connections.map((connection) => this.describeConnection(connection)).join('\n\n')
91
- : 'No harness server connections established.';
92
- }),
93
- );
94
-
95
- server.registerTool(
96
- 'connection_status',
97
- {
98
- title: 'Check harness connection',
99
- description: 'Run the protocol health check for one explicit harness server connection.',
100
- inputSchema: {
101
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
102
- },
103
- },
104
- async (args) =>
105
- this.wrapToolCall(async () => {
106
- const status = await this.connections.get(args.connectionId).getStatus();
107
- return `${this.describeConnection(status)}\nhealthy: ${status.healthy ? 'yes' : 'no'}\ndetail: ${status.detail}`;
108
- }),
109
- );
110
-
111
- server.registerTool(
112
- 'list_chats',
113
- {
114
- title: 'List harness chats',
115
- description:
116
- 'List chats from the server and directory bound to an explicit connection. No local session stores are read.',
117
- inputSchema: {
118
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
119
- limit: plugins.z.number().int().min(1).max(100).optional(),
120
- },
121
- },
122
- async (args) =>
123
- this.wrapToolCall(async () => {
124
- const chats = await this.connections.get(args.connectionId).listChats(args.limit ?? 10);
125
- if (chats.length === 0) return 'No chats found for this connection.';
126
- return [
127
- 'harness\tchatId\tupdatedAt\tactivity\ttitle',
128
- ...chats.map(
129
- (chat) =>
130
- `${chat.harness}\t${chat.chatId}\t${chat.updatedAt}\t${chat.isActive ? 'active' : 'idle'}\t${chat.title.replace(/\s+/g, ' ').slice(0, 120)}`,
131
- ),
132
- ].join('\n');
133
- }),
134
- );
135
-
136
- server.registerTool(
137
- 'read_chat',
138
- {
139
- title: 'Read harness chat',
140
- description:
141
- 'Read a chat through the harness web server bound to an explicit connection.',
142
- inputSchema: {
143
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
144
- chatId: plugins.z.string().min(1).max(1024),
145
- limit: plugins.z.number().int().min(1).max(500).optional(),
146
- maxChars: plugins.z.number().int().min(200).max(50000).optional(),
147
- },
148
- },
149
- async (args) =>
150
- this.wrapToolCall(async () => {
151
- const transcript = await this.connections
152
- .get(args.connectionId)
153
- .readChat(args.chatId, args.limit ?? 20, args.maxChars ?? 2000);
154
- const header = [
155
- `harness: ${transcript.harness}`,
156
- `chatId: ${transcript.chatId}`,
157
- `title: ${transcript.title}`,
158
- `directory: ${transcript.directory}`,
159
- transcript.totalMessages === undefined
160
- ? `messages: showing ${transcript.messages.length}; server total unavailable`
161
- : `messages: showing ${transcript.messages.length} of ${transcript.totalMessages}`,
162
- ].join('\n');
163
- const body = transcript.messages
164
- .map(
165
- (message) =>
166
- `-- [${message.role}]${message.timestamp ? ` ${message.timestamp}` : ''}\n${message.text}`,
167
- )
168
- .join('\n');
169
- return `${header}\n\n${body}`;
170
- }),
171
- );
172
-
173
- server.registerTool(
174
- 'send_message',
175
- {
176
- title: 'Send message through harness server',
177
- description:
178
- 'Send a message only through the harness web server bound to an explicit connection and wait for the reply. There is no CLI, process, local-store, discovery, or retry fallback.',
179
- inputSchema: {
180
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
181
- chatId: plugins.z.string().min(1).max(1024),
182
- message: plugins.z.string().min(1).max(1024 * 1024),
183
- timeoutSeconds: plugins.z.number().int().min(1).max(3600).optional(),
184
- },
185
- },
186
- async (args, extra) =>
187
- this.wrapToolCall(async () => {
188
- const result = await this.connections
189
- .get(args.connectionId)
190
- .sendMessage(args.chatId, args.message, args.timeoutSeconds ?? 300, extra.signal);
191
- return `${result.reply}\n\n[crossharness transport: ${result.via}]`;
192
- }),
193
- );
194
-
195
- server.registerTool(
196
- 'send_message_async',
197
- {
198
- title: 'Send message through harness server asynchronously',
199
- description:
200
- 'Start a server-only send and return a dispatchId immediately. Both this connectionId and the dispatchId are required by check_reply.',
201
- inputSchema: {
202
- connectionId: plugins.z.string().max(128).describe('Connection id returned by connect_harness'),
203
- chatId: plugins.z.string().min(1).max(1024),
204
- message: plugins.z.string().min(1).max(1024 * 1024),
205
- timeoutSeconds: plugins.z.number().int().min(1).max(3600).optional(),
206
- },
207
- },
208
- async (args) =>
209
- this.wrapToolCall(async () => {
210
- const connection = this.connections.get(args.connectionId);
211
- const record = this.dispatches.track(
212
- args.connectionId,
213
- connection.harness,
214
- args.chatId,
215
- () => connection.sendMessage(args.chatId, args.message, args.timeoutSeconds ?? 900),
216
- );
217
- return [
218
- `connectionId: ${record.connectionId}`,
219
- `dispatchId: ${record.dispatchId}`,
220
- `harness: ${record.harness}`,
221
- `chatId: ${record.chatId}`,
222
- `startedAt: ${record.startedAt}`,
223
- ].join('\n');
224
- }),
225
- );
226
-
227
- server.registerTool(
228
- 'check_reply',
229
- {
230
- title: 'Check async server send',
231
- description:
232
- 'Check an asynchronous send. The connectionId must own the dispatch. Records remain inspectable after disconnect.',
233
- inputSchema: {
234
- connectionId: plugins.z.string().max(128).describe('Connection id used for send_message_async'),
235
- dispatchId: plugins.z.string().max(128).describe('Dispatch id returned by send_message_async'),
236
- waitSeconds: plugins.z.number().int().min(0).max(60).optional(),
237
- },
238
- },
239
- async (args, extra) =>
240
- this.wrapToolCall(async () => {
241
- const record = await this.dispatches.waitFor(
242
- args.connectionId,
243
- args.dispatchId,
244
- (args.waitSeconds ?? 0) * 1000,
245
- extra.signal,
246
- );
247
- if (!record) throw new Error('unknown dispatchId for this connectionId');
248
- const header = [
249
- `connectionId: ${record.connectionId}`,
250
- `dispatchId: ${record.dispatchId}`,
251
- `status: ${record.status}`,
252
- `harness: ${record.harness}`,
253
- `chatId: ${record.chatId}`,
254
- `startedAt: ${record.startedAt}`,
255
- ...(record.completedAt ? [`completedAt: ${record.completedAt}`] : []),
256
- ].join('\n');
257
- if (record.status === 'working') return `${header}\n\nStill working.`;
258
- if (record.status === 'failed') return `${header}\n\nError: ${record.error}`;
259
- return `${header}\n\n${record.reply}\n\n[crossharness transport: ${record.via}]`;
260
- }),
261
- );
35
+ this.registrar.register(server);
262
36
 
263
37
  this.activeServer = server;
38
+ this.activeTransport = transportArg;
264
39
  this.stdinEndHandler = () => this.triggerClose();
265
40
  this.stdinEndSource = stdinArg;
266
41
  this.stdinEndSource.once('end', this.stdinEndHandler);
267
42
  this.stdinEndSource.once('close', this.stdinEndHandler);
268
43
  this.stdinEndSource.once('error', this.stdinEndHandler);
269
- server.server.onclose = () => {
270
- this.clearTransportReferences();
271
- void this.ensureResourceShutdown().catch(() => undefined);
272
- };
44
+ server.server.onclose = () => this.triggerClose();
273
45
  try {
274
46
  await server.connect(transportArg);
275
47
  } catch (error) {
276
- this.clearTransportReferences();
277
- await transportArg.close().catch(() => {});
278
- await this.ensureResourceShutdown();
48
+ let cleanupError: unknown;
49
+ try {
50
+ await this.close();
51
+ } catch (errorArg) {
52
+ cleanupError = errorArg;
53
+ }
54
+ if (cleanupError !== undefined) {
55
+ const cleanupErrors = cleanupError instanceof AggregateError
56
+ ? cleanupError.errors
57
+ : [cleanupError];
58
+ throw new AggregateError(
59
+ [error, ...cleanupErrors],
60
+ 'Crossharness MCP startup and cleanup failed.',
61
+ );
62
+ }
279
63
  throw error;
280
64
  }
281
65
  }
282
66
 
283
67
  public async close(): Promise<void> {
284
68
  if (this.closePromise) return this.closePromise;
285
- this.closePromise = (async () => {
69
+ let finalPromise!: Promise<void>;
70
+ const closeOperation = Promise.resolve().then(async () => {
71
+ const errors: unknown[] = [];
286
72
  const server = this.activeServer;
287
- let closeError: unknown;
73
+ const transport = this.activeTransport;
74
+ let transportClosed = transport === undefined;
288
75
  try {
289
- if (server) await server.close();
76
+ if (transport) {
77
+ if (this.transportCloseNeedsRetry || !server) {
78
+ await transport.close();
79
+ } else {
80
+ await server.close();
81
+ }
82
+ }
83
+ transportClosed = true;
84
+ this.transportCloseNeedsRetry = false;
290
85
  } catch (error) {
291
- closeError = error;
292
- } finally {
86
+ this.transportCloseNeedsRetry = true;
87
+ errors.push(error);
88
+ }
89
+ if (
90
+ transportClosed
91
+ && this.activeServer === server
92
+ && this.activeTransport === transport
93
+ ) {
293
94
  this.clearTransportReferences();
294
- await this.ensureResourceShutdown();
295
95
  }
296
- if (closeError) throw closeError;
297
- })().finally(() => {
298
- this.closePromise = undefined;
96
+ try {
97
+ await this.registrar.close();
98
+ } catch (error) {
99
+ errors.push(error);
100
+ }
101
+ if (errors.length === 1) {
102
+ throw errors[0];
103
+ }
104
+ if (errors.length > 1) {
105
+ throw new AggregateError(
106
+ errors,
107
+ 'Crossharness MCP transport and tool-resource shutdown failed.',
108
+ );
109
+ }
110
+ });
111
+ finalPromise = closeOperation.finally(() => {
112
+ if (this.closePromise === finalPromise) this.closePromise = undefined;
299
113
  });
300
- return this.closePromise;
114
+ this.closePromise = finalPromise;
115
+ return finalPromise;
301
116
  }
302
117
 
303
118
  private triggerClose(): void {
304
119
  void this.close().catch(() => {});
305
120
  }
306
121
 
307
- private ensureResourceShutdown(): Promise<void> {
308
- if (this.resourceShutdownPromise) return this.resourceShutdownPromise;
309
- let shutdownPromise!: Promise<void>;
310
- shutdownPromise = this.connections.closeAll().finally(() => {
311
- this.dispatches.dispose();
312
- }).catch((errorArg) => {
313
- if (this.resourceShutdownPromise === shutdownPromise) {
314
- this.resourceShutdownPromise = undefined;
315
- }
316
- throw errorArg;
317
- });
318
- this.resourceShutdownPromise = shutdownPromise;
319
- return shutdownPromise;
320
- }
321
-
322
122
  private clearTransportReferences(): void {
323
123
  if (this.stdinEndHandler) {
324
124
  this.stdinEndSource?.off('end', this.stdinEndHandler);
@@ -328,36 +128,6 @@ export class CrossHarnessMcpServer {
328
128
  this.stdinEndHandler = undefined;
329
129
  this.stdinEndSource = undefined;
330
130
  this.activeServer = undefined;
331
- }
332
-
333
- private describeConnection(infoArg: interfaces.IConnectionInfo): string {
334
- return [
335
- `connectionId: ${infoArg.connectionId}`,
336
- `harness: ${infoArg.harness}`,
337
- `serverUrl: ${infoArg.serverUrl}`,
338
- `directory: ${infoArg.directory}`,
339
- `state: ${infoArg.state}`,
340
- `connectedAt: ${infoArg.connectedAt}`,
341
- ...(infoArg.serverVersion ? [`serverVersion: ${infoArg.serverVersion}`] : []),
342
- ].join('\n');
343
- }
344
-
345
- private async wrapToolCall(
346
- handlerArg: () => Promise<string>,
347
- ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
348
- try {
349
- const text = await handlerArg();
350
- return { content: [{ type: 'text', text: text.slice(0, 100000) }] };
351
- } catch (error) {
352
- return {
353
- content: [
354
- {
355
- type: 'text',
356
- text: `Error: ${error instanceof Error ? error.message : String(error)}`.slice(0, 100000),
357
- },
358
- ],
359
- isError: true,
360
- };
361
- }
131
+ this.activeTransport = undefined;
362
132
  }
363
133
  }