@longshot-harness/longshot 0.0.0-stage → 0.1.0-beta.2
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.
- package/LICENSE.md +201 -0
- package/README.md +44 -2
- package/bin/longshot.js +82 -0
- package/package.json +34 -6
- package/sidecars/claude/events.mjs +331 -0
- package/sidecars/claude/history.mjs +70 -0
- package/sidecars/claude/main.mjs +84 -0
- package/sidecars/claude/mcpserver.mjs +88 -0
- package/sidecars/claude/protocol.mjs +222 -0
- package/sidecars/claude/session.mjs +561 -0
- package/sidecars/claude/sidecar.mjs +207 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Agent SDK message stream → sidecar wire events.
|
|
3
|
+
*
|
|
4
|
+
* TS equivalent of events.py: one frozen table, transcribed here, plus the
|
|
5
|
+
* policy for a message or content-block type the table does not name.
|
|
6
|
+
* Dispatch is by msg.type / block.type, not class name (sdk-mapping.md §6).
|
|
7
|
+
* Only built-in Node.js modules are imported.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Public building block
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** {kind:'event', event:name, data: data ?? {}} */
|
|
17
|
+
export function event(name, data) {
|
|
18
|
+
return { kind: 'event', event: name, data: data ?? {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// IGNORED: SDK message types deliberately not mapped, each with a reason.
|
|
23
|
+
// Keys are msg.type values. The same completeness-by-inspection purpose as
|
|
24
|
+
// events.py: every intentional drop is named here, not buried in a comment.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export const IGNORED = {
|
|
27
|
+
rate_limit_event:
|
|
28
|
+
'rate-limit status transitions are deliberately dropped, not uninteresting: ' +
|
|
29
|
+
'the frozen wire catalog has no event kind meaning "rate limit", which makes this a named ' +
|
|
30
|
+
'candidate for the next protocol version',
|
|
31
|
+
conversation_reset:
|
|
32
|
+
"a mid-session transcript discard zeroes the SDK's running " +
|
|
33
|
+
'total_cost_usd, but nothing in this sidecar or the adapter accumulates cost, and ' +
|
|
34
|
+
'session.history is served per request from getSessionMessages() rather than a cache, so ' +
|
|
35
|
+
'there is no total to snapshot and no cache to invalidate',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Content block types that never produce a frame on their own (D14).
|
|
39
|
+
export const IGNORED_CONTENT_BLOCKS = {
|
|
40
|
+
thinking:
|
|
41
|
+
'thinking is already emitted via stream_event thinking_delta; re-emitting ' +
|
|
42
|
+
'it here would duplicate it (I11), and unlike an assistant text block there is ' +
|
|
43
|
+
'no boundary to draw: nothing downstream renders a thinking block as its own message',
|
|
44
|
+
redacted_thinking:
|
|
45
|
+
'redacted_thinking blocks carry no displayable text; logging them as ' +
|
|
46
|
+
'unknown on every extended-thinking response would be noise in the 4 KB stderr Go keeps',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Structural stream event envelope types: framing only, never content.
|
|
50
|
+
const _IGNORED_STREAM_EVENT_TYPES = new Set([
|
|
51
|
+
'message_start',
|
|
52
|
+
'content_block_start',
|
|
53
|
+
'content_block_stop',
|
|
54
|
+
'message_delta',
|
|
55
|
+
'message_stop',
|
|
56
|
+
'ping',
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// StreamState
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** The turn-tracking state one session's persistent read loop owns. */
|
|
64
|
+
export class StreamState {
|
|
65
|
+
/** @param {string} sessionId */
|
|
66
|
+
constructor(sessionId) {
|
|
67
|
+
this.sessionId = sessionId;
|
|
68
|
+
/** @type {string|null} */
|
|
69
|
+
this.currentTurnId = null;
|
|
70
|
+
/** @type {Map<string,string>} tool_use_id → tool_name */
|
|
71
|
+
this.toolNames = new Map();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// session.ready
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The session.ready frame, built once the session is open and ready.
|
|
81
|
+
* @param {string} sessionId
|
|
82
|
+
* @param {string} nativeId
|
|
83
|
+
* @param {string} model empty string when operator pinned none
|
|
84
|
+
* @returns {object}
|
|
85
|
+
*/
|
|
86
|
+
export function sessionReady(sessionId, nativeId, model) {
|
|
87
|
+
return event('session.ready', {
|
|
88
|
+
session_id: sessionId,
|
|
89
|
+
native_id: nativeId,
|
|
90
|
+
model,
|
|
91
|
+
at: new Date().toISOString(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// beginTurn
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mint a turn id and return [turn.started], or [] when a turn is already
|
|
101
|
+
* active. The only place a turn_id is minted (I11).
|
|
102
|
+
* @param {StreamState} state
|
|
103
|
+
* @param {string} [carrier]
|
|
104
|
+
* @returns {object[]}
|
|
105
|
+
*/
|
|
106
|
+
export function beginTurn(state, carrier = '') {
|
|
107
|
+
if (state.currentTurnId !== null) return [];
|
|
108
|
+
state.currentTurnId = randomUUID();
|
|
109
|
+
const data = { session_id: state.sessionId, turn_id: state.currentTurnId };
|
|
110
|
+
if (carrier) data.carrier = carrier;
|
|
111
|
+
data.at = new Date().toISOString();
|
|
112
|
+
return [event('turn.started', data)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// createUnknownLog
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns a (what, name) → void logger that writes exactly one line per
|
|
121
|
+
* (what, name) pair per instance (D14: one line per name per process).
|
|
122
|
+
* @param {(line: string) => void} write
|
|
123
|
+
* @returns {(what: string, name: string) => void}
|
|
124
|
+
*/
|
|
125
|
+
export function createUnknownLog(write) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
return function logUnknown(what, name) {
|
|
128
|
+
const key = `${what}\0${name}`;
|
|
129
|
+
if (seen.has(key)) return;
|
|
130
|
+
seen.add(key);
|
|
131
|
+
write(
|
|
132
|
+
`claude sidecar: unknown SDK ${what} type ${JSON.stringify(name)}; no event emitted\n`
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Internal helpers
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
function _now() {
|
|
142
|
+
return new Date().toISOString();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _mapSystem(_msg, _state, _log) {
|
|
146
|
+
// No SystemMessage subtype emits a frame (events.py:163-174).
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _stringifyToolResult(content) {
|
|
151
|
+
if (content == null) return '';
|
|
152
|
+
if (typeof content === 'string') return content;
|
|
153
|
+
if (Array.isArray(content)) {
|
|
154
|
+
return content
|
|
155
|
+
.map((part) => {
|
|
156
|
+
if (part && typeof part === 'object' && 'text' in part) return String(part.text);
|
|
157
|
+
return String(part);
|
|
158
|
+
})
|
|
159
|
+
.join('');
|
|
160
|
+
}
|
|
161
|
+
return String(content);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _mapToolResultBlock(block, state) {
|
|
165
|
+
const toolUseId = block.tool_use_id ?? '';
|
|
166
|
+
const toolName = state.toolNames.get(toolUseId) ?? '';
|
|
167
|
+
state.toolNames.delete(toolUseId);
|
|
168
|
+
return [
|
|
169
|
+
event('tool.completed', {
|
|
170
|
+
session_id: state.sessionId,
|
|
171
|
+
turn_id: state.currentTurnId ?? '',
|
|
172
|
+
tool_id: toolUseId,
|
|
173
|
+
tool_name: toolName,
|
|
174
|
+
text: _stringifyToolResult(block.content),
|
|
175
|
+
at: _now(),
|
|
176
|
+
}),
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _mapUser(msg, state, logUnknown) {
|
|
181
|
+
const content = msg.message?.content;
|
|
182
|
+
if (typeof content === 'string') {
|
|
183
|
+
return beginTurn(state);
|
|
184
|
+
}
|
|
185
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
186
|
+
const frames = [];
|
|
187
|
+
let sawText = false;
|
|
188
|
+
for (const block of blocks) {
|
|
189
|
+
const bt = block.type;
|
|
190
|
+
if (bt === 'tool_result') {
|
|
191
|
+
frames.push(..._mapToolResultBlock(block, state));
|
|
192
|
+
} else if (bt === 'text') {
|
|
193
|
+
sawText = true;
|
|
194
|
+
} else {
|
|
195
|
+
logUnknown('content block', String(bt));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (sawText) {
|
|
199
|
+
return [...beginTurn(state), ...frames];
|
|
200
|
+
}
|
|
201
|
+
return frames;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _mapAssistant(msg, state, logUnknown) {
|
|
205
|
+
const blocks = Array.isArray(msg.message?.content) ? msg.message.content : [];
|
|
206
|
+
const frames = [];
|
|
207
|
+
for (const block of blocks) {
|
|
208
|
+
const bt = block.type;
|
|
209
|
+
if (bt === 'text') {
|
|
210
|
+
frames.push(
|
|
211
|
+
event('message.completed', {
|
|
212
|
+
session_id: state.sessionId,
|
|
213
|
+
turn_id: state.currentTurnId ?? '',
|
|
214
|
+
text: block.text ?? '',
|
|
215
|
+
at: _now(),
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
} else if (bt === 'tool_use') {
|
|
219
|
+
const toolId = block.id ?? '';
|
|
220
|
+
const toolName = block.name ?? '';
|
|
221
|
+
state.toolNames.set(toolId, toolName);
|
|
222
|
+
frames.push(
|
|
223
|
+
event('tool.started', {
|
|
224
|
+
session_id: state.sessionId,
|
|
225
|
+
turn_id: state.currentTurnId ?? '',
|
|
226
|
+
tool_id: toolId,
|
|
227
|
+
tool_name: toolName,
|
|
228
|
+
at: _now(),
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
} else if (bt in IGNORED_CONTENT_BLOCKS) {
|
|
232
|
+
// deliberate no-op (thinking, redacted_thinking)
|
|
233
|
+
} else {
|
|
234
|
+
logUnknown('content block', String(bt));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return frames;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _mapStreamEvent(msg, state, logUnknown) {
|
|
241
|
+
const ev = msg.event ?? {};
|
|
242
|
+
const eventType = ev.type;
|
|
243
|
+
if (eventType !== 'content_block_delta') {
|
|
244
|
+
if (!_IGNORED_STREAM_EVENT_TYPES.has(eventType)) {
|
|
245
|
+
logUnknown('StreamEvent', String(eventType));
|
|
246
|
+
}
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
const delta = ev.delta ?? {};
|
|
250
|
+
const deltaType = delta.type;
|
|
251
|
+
if (deltaType === 'text_delta') {
|
|
252
|
+
return [
|
|
253
|
+
event('message.delta', {
|
|
254
|
+
session_id: state.sessionId,
|
|
255
|
+
turn_id: state.currentTurnId ?? '',
|
|
256
|
+
delta: delta.text ?? '',
|
|
257
|
+
at: _now(),
|
|
258
|
+
}),
|
|
259
|
+
];
|
|
260
|
+
}
|
|
261
|
+
if (deltaType === 'thinking_delta') {
|
|
262
|
+
return [
|
|
263
|
+
event('thinking.delta', {
|
|
264
|
+
session_id: state.sessionId,
|
|
265
|
+
turn_id: state.currentTurnId ?? '',
|
|
266
|
+
delta: delta.thinking ?? '',
|
|
267
|
+
at: _now(),
|
|
268
|
+
}),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
// Other delta types (input_json_delta, etc.) carry no text: drop silently.
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function _mapResult(msg, state) {
|
|
276
|
+
const turnId = state.currentTurnId ?? '';
|
|
277
|
+
const frames = [];
|
|
278
|
+
if (msg.is_error) {
|
|
279
|
+
const text =
|
|
280
|
+
msg.result || msg.subtype || 'the turn ended with an error';
|
|
281
|
+
frames.push(
|
|
282
|
+
event('error', {
|
|
283
|
+
session_id: state.sessionId,
|
|
284
|
+
turn_id: turnId,
|
|
285
|
+
message: text,
|
|
286
|
+
will_retry: false,
|
|
287
|
+
at: _now(),
|
|
288
|
+
})
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
frames.push(
|
|
292
|
+
event('turn.completed', {
|
|
293
|
+
session_id: state.sessionId,
|
|
294
|
+
turn_id: turnId,
|
|
295
|
+
at: _now(),
|
|
296
|
+
})
|
|
297
|
+
);
|
|
298
|
+
// ResultMessage is the one and only turn boundary.
|
|
299
|
+
state.currentTurnId = null;
|
|
300
|
+
return frames;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// Dispatch table and mapMessage
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const _DISPATCH = {
|
|
308
|
+
system: _mapSystem,
|
|
309
|
+
user: _mapUser,
|
|
310
|
+
assistant: _mapAssistant,
|
|
311
|
+
stream_event: _mapStreamEvent,
|
|
312
|
+
result: _mapResult,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Maps one SDK message to zero, one, or more wire event frames.
|
|
317
|
+
* Never throws on an unknown type.
|
|
318
|
+
* @param {object} message
|
|
319
|
+
* @param {StreamState} state
|
|
320
|
+
* @param {(what: string, name: string) => void} logUnknown
|
|
321
|
+
* @returns {object[]}
|
|
322
|
+
*/
|
|
323
|
+
export function mapMessage(message, state, logUnknown) {
|
|
324
|
+
const type = message.type;
|
|
325
|
+
const handler = _DISPATCH[type];
|
|
326
|
+
if (!handler) {
|
|
327
|
+
if (!(type in IGNORED)) logUnknown('message', String(type));
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
return handler(message, state, logUnknown);
|
|
331
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Agent SDK persisted SessionMessage → session.history wire items.
|
|
3
|
+
*
|
|
4
|
+
* TS equivalent of history.py, built against the documented SessionMessage
|
|
5
|
+
* form (sdk.d.ts:6664: type, uuid, message — raw Messages API object) rather
|
|
6
|
+
* than repeating the Python deffect of dispatching on class names (D15).
|
|
7
|
+
* Only built-in Node.js modules are imported (none needed here).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function _serializeBlock(block) {
|
|
11
|
+
const bt = block.type;
|
|
12
|
+
if (bt === 'text') {
|
|
13
|
+
return { type: 'text', text: block.text ?? '' };
|
|
14
|
+
}
|
|
15
|
+
if (bt === 'tool_use') {
|
|
16
|
+
return {
|
|
17
|
+
type: 'tool_use',
|
|
18
|
+
id: block.id ?? '',
|
|
19
|
+
name: block.name ?? '',
|
|
20
|
+
input: block.input ?? {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (bt === 'tool_result') {
|
|
24
|
+
return {
|
|
25
|
+
type: 'tool_result',
|
|
26
|
+
tool_use_id: block.tool_use_id ?? '',
|
|
27
|
+
content: block.content,
|
|
28
|
+
is_error: Boolean(block.is_error),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (bt === 'thinking') {
|
|
32
|
+
return { type: 'thinking', text: block.thinking ?? '' };
|
|
33
|
+
}
|
|
34
|
+
// Unknown block: carry through by its type field (history.go treats
|
|
35
|
+
// unrecognised blocks as no-item rather than a silent loss).
|
|
36
|
+
return { type: bt };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _serializeContent(content) {
|
|
40
|
+
if (typeof content === 'string') return content;
|
|
41
|
+
if (Array.isArray(content)) return content.map(_serializeBlock);
|
|
42
|
+
return content;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Serializes one SessionMessage into the raw item dict session.history
|
|
47
|
+
* carries on the wire (docs/claude-sidecar.md, "session.history").
|
|
48
|
+
* Never throws on an unknown type.
|
|
49
|
+
* @param {{ type: string, uuid?: string, message?: { content?: unknown } }} m
|
|
50
|
+
* @returns {object}
|
|
51
|
+
*/
|
|
52
|
+
export function serializeMessage(m) {
|
|
53
|
+
const uuid = m.uuid ?? '';
|
|
54
|
+
switch (m.type) {
|
|
55
|
+
case 'user':
|
|
56
|
+
case 'assistant':
|
|
57
|
+
return {
|
|
58
|
+
type: m.type,
|
|
59
|
+
uuid,
|
|
60
|
+
message: {
|
|
61
|
+
role: m.type,
|
|
62
|
+
content: _serializeContent(m.message?.content),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
case 'system':
|
|
66
|
+
return { type: 'system', uuid };
|
|
67
|
+
default:
|
|
68
|
+
return { type: m.type };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Entry point for the Claude sidecar process (D12).
|
|
2
|
+
//
|
|
3
|
+
// Static imports are only node:* built-ins; the SDK and sidecar.mjs are loaded
|
|
4
|
+
// dynamically so their imports cannot reach process.stdout before step 1 runs.
|
|
5
|
+
//
|
|
6
|
+
// 1. Capture real stdout for frames; redirect process.stdout.write to stderr so
|
|
7
|
+
// that an accidental console.log anywhere in the process goes to diagnostics
|
|
8
|
+
// instead of the frame stream (main.py:21-23).
|
|
9
|
+
// 2. Reject Node < 18 with a readable message and exit code 3.
|
|
10
|
+
// 3. Resolve the SDK; read its package.json for the two version strings (D05).
|
|
11
|
+
// ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND → exit code 3; any other load
|
|
12
|
+
// error is a sidecar defect → re-throw (exit code 1 via uncaught rejection).
|
|
13
|
+
// 4. Run the sidecar loop.
|
|
14
|
+
// 5. Flush the real stdout before exiting so no frame is truncated.
|
|
15
|
+
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join, dirname } from 'node:path';
|
|
19
|
+
|
|
20
|
+
// ---- Step 1 ----------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const frames = process.stdout.write.bind(process.stdout);
|
|
23
|
+
process.stdout.write = process.stderr.write.bind(process.stderr);
|
|
24
|
+
|
|
25
|
+
// ---- Step 2 ----------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const [nodeMajor] = process.versions.node.split('.').map(Number);
|
|
28
|
+
if (nodeMajor < 18) {
|
|
29
|
+
process.stderr.write(
|
|
30
|
+
`claude sidecar: Node.js ${process.versions.node} is too old; ` +
|
|
31
|
+
'the sidecar needs Node.js 18 or newer\n'
|
|
32
|
+
);
|
|
33
|
+
process.exit(3);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- Step 3 ----------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
let sdkMod, run, meta;
|
|
39
|
+
try {
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const entry = require.resolve('@anthropic-ai/claude-agent-sdk');
|
|
42
|
+
try {
|
|
43
|
+
meta = JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8'));
|
|
44
|
+
} catch {
|
|
45
|
+
meta = {};
|
|
46
|
+
}
|
|
47
|
+
sdkMod = await import('@anthropic-ai/claude-agent-sdk');
|
|
48
|
+
({ run } = await import('./sidecar.mjs'));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const code = err?.code;
|
|
51
|
+
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
'claude sidecar: @anthropic-ai/claude-agent-sdk cannot be loaded; ' +
|
|
54
|
+
'run npm ci in sidecars/claude (source checkout) or reinstall ' +
|
|
55
|
+
'@longshot-harness/longshot; ' +
|
|
56
|
+
`import failed with: ${err.name}: ${err.message}\n`
|
|
57
|
+
);
|
|
58
|
+
process.exit(3);
|
|
59
|
+
}
|
|
60
|
+
throw err; // sidecar syntax error or other defect → code 1
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- Step 4 ----------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const sdk = {
|
|
66
|
+
query: sdkMod.query,
|
|
67
|
+
getSessionMessages: sdkMod.getSessionMessages,
|
|
68
|
+
version: meta.version || 'unknown',
|
|
69
|
+
claudeCodeVersion: meta.claudeCodeVersion || 'unknown',
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const code = await run({
|
|
73
|
+
sdk,
|
|
74
|
+
input: process.stdin,
|
|
75
|
+
output: { write: frames },
|
|
76
|
+
errput: process.stderr,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ---- Step 5 ----------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// Flush the real stdout before exit: write() is async on some platforms and
|
|
82
|
+
// the frame stream must be drained before the process terminates.
|
|
83
|
+
await new Promise((r) => frames('', r));
|
|
84
|
+
process.exit(code);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the in-process "longshot" MCP server from the tool definitions
|
|
3
|
+
* session.start carries, using raw JSON Schema handlers (D06).
|
|
4
|
+
*
|
|
5
|
+
* McpServer.server.setRequestHandler bypasses the zod-only McpServer.tool()
|
|
6
|
+
* path, letting Go's raw JSON Schema pass through unmodified. The SDK only
|
|
7
|
+
* calls instance.connect(transport) on the returned McpServer (D06, context.md
|
|
8
|
+
* §9.4), so passing the McpServer as-is satisfies McpSdkServerConfigWithInstance.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
12
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
|
|
14
|
+
import { SIDECAR_VERSION } from './protocol.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Prefix Claude adds to every tool name when the MCP server is named "longshot".
|
|
18
|
+
* A definition arrives as "mcp__longshot__<tool>" and registers on the server
|
|
19
|
+
* under "<tool>" alone; the prefix is re-attached for the forwarding call.
|
|
20
|
+
*/
|
|
21
|
+
export const MCP_PREFIX = 'mcp__longshot__';
|
|
22
|
+
|
|
23
|
+
/** Strip the prefix to get the name the MCP server registers the tool under. */
|
|
24
|
+
function bareName(fullName) {
|
|
25
|
+
return fullName.startsWith(MCP_PREFIX) ? fullName.slice(MCP_PREFIX.length) : fullName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the in-process "longshot" MCP server.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array<{name: string, description: string, input_schema: object}>} definitions
|
|
32
|
+
* Tool definitions from Go (session.start params).
|
|
33
|
+
* @param {function(fullName: string, args: object): Promise<{content, is_error}>} callTool
|
|
34
|
+
* Forwards to Go via tool.call; rejection is mapped to isError: true per
|
|
35
|
+
* protocol.py:204-217 so the SDK delivers a readable error to the model.
|
|
36
|
+
*
|
|
37
|
+
* @returns {{ type: 'sdk', name: 'longshot', instance: McpServer }}
|
|
38
|
+
* Ready for use as Options.mcpServers.longshot.
|
|
39
|
+
*/
|
|
40
|
+
export function buildLongshotServer(definitions, callTool) {
|
|
41
|
+
const mcp = new McpServer(
|
|
42
|
+
{ name: 'longshot', version: SIDECAR_VERSION },
|
|
43
|
+
{ capabilities: { tools: {} } },
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Build a map from bare name → full name for the call handler.
|
|
47
|
+
// If a definition's name does not carry the prefix, fall back to
|
|
48
|
+
// MCP_PREFIX + bareName (unknown name still goes to Go's Dispatcher).
|
|
49
|
+
const fullNameOf = new Map(
|
|
50
|
+
definitions.map(d => [bareName(d.name), d.name]),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// tools/list: pass raw input_schema as inputSchema without copy or validation.
|
|
54
|
+
// The low-level Server.setRequestHandler does not call zodToJsonSchema, so
|
|
55
|
+
// plain JSON Schema objects are accepted here (D06).
|
|
56
|
+
mcp.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
57
|
+
tools: definitions.map(d => ({
|
|
58
|
+
name: bareName(d.name),
|
|
59
|
+
description: d.description,
|
|
60
|
+
inputSchema: d.input_schema,
|
|
61
|
+
})),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
// tools/call: forward to callTool exactly once; map the result to MCP shape.
|
|
65
|
+
mcp.server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
66
|
+
const bare = req.params.name;
|
|
67
|
+
const full = fullNameOf.get(bare) ?? (MCP_PREFIX + bare);
|
|
68
|
+
const args = req.params.arguments ?? {};
|
|
69
|
+
|
|
70
|
+
let content, isError;
|
|
71
|
+
try {
|
|
72
|
+
const result = await callTool(full, args);
|
|
73
|
+
const raw = result.content;
|
|
74
|
+
const text = typeof raw === 'string' ? raw : JSON.stringify(raw ?? '');
|
|
75
|
+
content = [{ type: 'text', text }];
|
|
76
|
+
isError = Boolean(result.is_error);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
// Rejected callTool (e.g. failPending) → readable error result,
|
|
79
|
+
// no MCP protocol error (protocol.py:204-217).
|
|
80
|
+
content = [{ type: 'text', text: err.message }];
|
|
81
|
+
isError = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { content, isError };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { type: 'sdk', name: 'longshot', instance: mcp };
|
|
88
|
+
}
|