@plaited/acp-harness 0.2.5
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/.claude/rules/accuracy.md +43 -0
- package/.claude/rules/bun-apis.md +80 -0
- package/.claude/rules/code-review.md +254 -0
- package/.claude/rules/git-workflow.md +37 -0
- package/.claude/rules/github.md +154 -0
- package/.claude/rules/testing.md +172 -0
- package/.claude/skills/acp-harness/SKILL.md +310 -0
- package/.claude/skills/acp-harness/assets/Dockerfile.acp +25 -0
- package/.claude/skills/acp-harness/assets/docker-compose.acp.yml +19 -0
- package/.claude/skills/acp-harness/references/downstream.md +288 -0
- package/.claude/skills/acp-harness/references/output-formats.md +221 -0
- package/.claude-plugin/marketplace.json +15 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.github/CODEOWNERS +6 -0
- package/.github/workflows/ci.yml +63 -0
- package/.github/workflows/publish.yml +146 -0
- package/.mcp.json +20 -0
- package/CLAUDE.md +92 -0
- package/Dockerfile.test +23 -0
- package/LICENSE +15 -0
- package/README.md +94 -0
- package/bin/cli.ts +670 -0
- package/bin/tests/cli.spec.ts +362 -0
- package/biome.json +96 -0
- package/bun.lock +513 -0
- package/docker-compose.test.yml +21 -0
- package/package.json +57 -0
- package/scripts/bun-test-wrapper.sh +46 -0
- package/src/acp-client.ts +503 -0
- package/src/acp-helpers.ts +121 -0
- package/src/acp-transport.ts +455 -0
- package/src/acp-utils.ts +341 -0
- package/src/acp.constants.ts +56 -0
- package/src/acp.schemas.ts +161 -0
- package/src/acp.ts +27 -0
- package/src/acp.types.ts +28 -0
- package/src/tests/acp-client.spec.ts +205 -0
- package/src/tests/acp-helpers.spec.ts +105 -0
- package/src/tests/acp-integration.docker.ts +214 -0
- package/src/tests/acp-transport.spec.ts +153 -0
- package/src/tests/acp-utils.spec.ts +394 -0
- package/src/tests/fixtures/.claude/settings.local.json +8 -0
- package/src/tests/fixtures/.claude/skills/greeting/SKILL.md +17 -0
- package/src/tests/fixtures/calculator-mcp.ts +215 -0
- package/tsconfig.json +32 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { ContentBlock, PlanEntry, SessionNotification, ToolCall } from '@agentclientprotocol/sdk'
|
|
3
|
+
import {
|
|
4
|
+
createAudioContent,
|
|
5
|
+
createBlobResource,
|
|
6
|
+
createImageContent,
|
|
7
|
+
createResourceLink,
|
|
8
|
+
createTextContent,
|
|
9
|
+
createTextResource,
|
|
10
|
+
extractLatestToolCalls,
|
|
11
|
+
extractPlan,
|
|
12
|
+
extractText,
|
|
13
|
+
extractTextFromUpdates,
|
|
14
|
+
extractToolCalls,
|
|
15
|
+
filterPlanByStatus,
|
|
16
|
+
filterToolCallsByStatus,
|
|
17
|
+
filterToolCallsByTitle,
|
|
18
|
+
getCompletedToolCallsWithContent,
|
|
19
|
+
getPlanProgress,
|
|
20
|
+
hasToolCallErrors,
|
|
21
|
+
} from '../acp-utils.ts'
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Content Block Builders
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
describe('createTextContent', () => {
|
|
28
|
+
test('creates text content block', () => {
|
|
29
|
+
const content = createTextContent('Hello world')
|
|
30
|
+
expect(content.type).toBe('text')
|
|
31
|
+
// Type narrowing to access text property
|
|
32
|
+
if (content.type === 'text') {
|
|
33
|
+
expect(content.text).toBe('Hello world')
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('createImageContent', () => {
|
|
39
|
+
test('creates image content with required fields', () => {
|
|
40
|
+
const content = createImageContent('base64data', 'image/png')
|
|
41
|
+
expect(content.type).toBe('image')
|
|
42
|
+
if (content.type === 'image') {
|
|
43
|
+
expect(content.data).toBe('base64data')
|
|
44
|
+
expect(content.mimeType).toBe('image/png')
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('createAudioContent', () => {
|
|
50
|
+
test('creates audio content block', () => {
|
|
51
|
+
const content = createAudioContent('audiodata', 'audio/wav')
|
|
52
|
+
expect(content.type).toBe('audio')
|
|
53
|
+
if (content.type === 'audio') {
|
|
54
|
+
expect(content.data).toBe('audiodata')
|
|
55
|
+
expect(content.mimeType).toBe('audio/wav')
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('createResourceLink', () => {
|
|
61
|
+
test('creates resource link with uri and name', () => {
|
|
62
|
+
const content = createResourceLink({ uri: 'file:///path/to/file.ts', name: 'file.ts' })
|
|
63
|
+
expect(content.type).toBe('resource_link')
|
|
64
|
+
if (content.type === 'resource_link') {
|
|
65
|
+
expect(content.uri).toBe('file:///path/to/file.ts')
|
|
66
|
+
expect(content.name).toBe('file.ts')
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('includes optional mimeType', () => {
|
|
71
|
+
const content = createResourceLink({ uri: 'file:///path/to/file.ts', name: 'file.ts', mimeType: 'text/typescript' })
|
|
72
|
+
if (content.type === 'resource_link') {
|
|
73
|
+
expect(content.mimeType).toBe('text/typescript')
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('createTextResource', () => {
|
|
79
|
+
test('creates embedded text resource', () => {
|
|
80
|
+
const content = createTextResource({ uri: 'file:///src/main.ts', text: 'const x = 1;' })
|
|
81
|
+
expect(content.type).toBe('resource')
|
|
82
|
+
if (content.type === 'resource') {
|
|
83
|
+
expect(content.resource.uri).toBe('file:///src/main.ts')
|
|
84
|
+
expect('text' in content.resource && content.resource.text).toBe('const x = 1;')
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('includes optional mimeType', () => {
|
|
89
|
+
const content = createTextResource({
|
|
90
|
+
uri: 'file:///src/main.ts',
|
|
91
|
+
text: 'const x = 1;',
|
|
92
|
+
mimeType: 'text/typescript',
|
|
93
|
+
})
|
|
94
|
+
if (content.type === 'resource' && 'text' in content.resource) {
|
|
95
|
+
expect(content.resource.mimeType).toBe('text/typescript')
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('createBlobResource', () => {
|
|
101
|
+
test('creates embedded blob resource', () => {
|
|
102
|
+
const content = createBlobResource({ uri: 'file:///image.png', blob: 'base64blobdata' })
|
|
103
|
+
expect(content.type).toBe('resource')
|
|
104
|
+
if (content.type === 'resource' && 'blob' in content.resource) {
|
|
105
|
+
expect(content.resource.uri).toBe('file:///image.png')
|
|
106
|
+
expect(content.resource.blob).toBe('base64blobdata')
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Content Extraction
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
describe('extractText', () => {
|
|
116
|
+
test('extracts text from text content blocks', () => {
|
|
117
|
+
const content: ContentBlock[] = [
|
|
118
|
+
{ type: 'text', text: 'Hello' },
|
|
119
|
+
{ type: 'text', text: 'World' },
|
|
120
|
+
]
|
|
121
|
+
expect(extractText(content)).toBe('Hello\nWorld')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('ignores non-text content blocks', () => {
|
|
125
|
+
const content: ContentBlock[] = [
|
|
126
|
+
{ type: 'text', text: 'Hello' },
|
|
127
|
+
{ type: 'image', data: 'base64', mimeType: 'image/png' },
|
|
128
|
+
{ type: 'text', text: 'World' },
|
|
129
|
+
]
|
|
130
|
+
expect(extractText(content)).toBe('Hello\nWorld')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('returns empty string for no text blocks', () => {
|
|
134
|
+
const content: ContentBlock[] = [{ type: 'image', data: 'base64', mimeType: 'image/png' }]
|
|
135
|
+
expect(extractText(content)).toBe('')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('handles empty array', () => {
|
|
139
|
+
expect(extractText([])).toBe('')
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('extractTextFromUpdates', () => {
|
|
144
|
+
test('extracts text from agent message chunks', () => {
|
|
145
|
+
const notifications: SessionNotification[] = [
|
|
146
|
+
{
|
|
147
|
+
sessionId: 's1',
|
|
148
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'First' } },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
sessionId: 's1',
|
|
152
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Second' } },
|
|
153
|
+
},
|
|
154
|
+
]
|
|
155
|
+
expect(extractTextFromUpdates(notifications)).toBe('FirstSecond')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test('skips non-text content updates', () => {
|
|
159
|
+
const notifications: SessionNotification[] = [
|
|
160
|
+
{
|
|
161
|
+
sessionId: 's1',
|
|
162
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
sessionId: 's1',
|
|
166
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'pending' },
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
sessionId: 's1',
|
|
170
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'World' } },
|
|
171
|
+
},
|
|
172
|
+
]
|
|
173
|
+
expect(extractTextFromUpdates(notifications)).toBe('HelloWorld')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('extractToolCalls', () => {
|
|
178
|
+
test('extracts all tool calls from notifications', () => {
|
|
179
|
+
const notifications: SessionNotification[] = [
|
|
180
|
+
{
|
|
181
|
+
sessionId: 's1',
|
|
182
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
sessionId: 's1',
|
|
186
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't2', title: 'write_file', status: 'in_progress' },
|
|
187
|
+
},
|
|
188
|
+
]
|
|
189
|
+
const calls = extractToolCalls(notifications)
|
|
190
|
+
expect(calls).toHaveLength(2)
|
|
191
|
+
expect(calls[0]?.title).toBe('read_file')
|
|
192
|
+
expect(calls[1]?.title).toBe('write_file')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('returns empty array when no tool calls', () => {
|
|
196
|
+
const notifications: SessionNotification[] = [
|
|
197
|
+
{
|
|
198
|
+
sessionId: 's1',
|
|
199
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello' } },
|
|
200
|
+
},
|
|
201
|
+
]
|
|
202
|
+
expect(extractToolCalls(notifications)).toEqual([])
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('extractLatestToolCalls', () => {
|
|
207
|
+
test('returns latest state of each tool call', () => {
|
|
208
|
+
const notifications: SessionNotification[] = [
|
|
209
|
+
{
|
|
210
|
+
sessionId: 's1',
|
|
211
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'pending' },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
sessionId: 's1',
|
|
215
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'in_progress' },
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
sessionId: 's1',
|
|
219
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
|
|
220
|
+
},
|
|
221
|
+
]
|
|
222
|
+
const latest = extractLatestToolCalls(notifications)
|
|
223
|
+
expect(latest.size).toBe(1)
|
|
224
|
+
expect(latest.get('t1')?.status).toBe('completed')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('tracks multiple tool calls independently', () => {
|
|
228
|
+
const notifications: SessionNotification[] = [
|
|
229
|
+
{
|
|
230
|
+
sessionId: 's1',
|
|
231
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't1', title: 'read_file', status: 'completed' },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
sessionId: 's1',
|
|
235
|
+
update: { sessionUpdate: 'tool_call', toolCallId: 't2', title: 'write_file', status: 'in_progress' },
|
|
236
|
+
},
|
|
237
|
+
]
|
|
238
|
+
const latest = extractLatestToolCalls(notifications)
|
|
239
|
+
expect(latest.size).toBe(2)
|
|
240
|
+
expect(latest.get('t1')?.status).toBe('completed')
|
|
241
|
+
expect(latest.get('t2')?.status).toBe('in_progress')
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('extractPlan', () => {
|
|
246
|
+
test('returns latest plan from notifications', () => {
|
|
247
|
+
const notifications: SessionNotification[] = [
|
|
248
|
+
{
|
|
249
|
+
sessionId: 's1',
|
|
250
|
+
update: {
|
|
251
|
+
sessionUpdate: 'plan',
|
|
252
|
+
entries: [{ content: 'Step 1', status: 'pending', priority: 'medium' }],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
sessionId: 's1',
|
|
257
|
+
update: {
|
|
258
|
+
sessionUpdate: 'plan',
|
|
259
|
+
entries: [
|
|
260
|
+
{ content: 'Step 1', status: 'completed', priority: 'medium' },
|
|
261
|
+
{ content: 'Step 2', status: 'in_progress', priority: 'medium' },
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
]
|
|
266
|
+
const plan = extractPlan(notifications)
|
|
267
|
+
expect(plan).toHaveLength(2)
|
|
268
|
+
expect(plan?.[0]?.status).toBe('completed')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('returns undefined when no plan in updates', () => {
|
|
272
|
+
const notifications: SessionNotification[] = [
|
|
273
|
+
{
|
|
274
|
+
sessionId: 's1',
|
|
275
|
+
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hi' } },
|
|
276
|
+
},
|
|
277
|
+
]
|
|
278
|
+
expect(extractPlan(notifications)).toBeUndefined()
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// Tool Call Utilities
|
|
284
|
+
// ============================================================================
|
|
285
|
+
|
|
286
|
+
describe('filterToolCallsByStatus', () => {
|
|
287
|
+
const toolCalls: ToolCall[] = [
|
|
288
|
+
{ toolCallId: 't1', title: 'a', status: 'completed' },
|
|
289
|
+
{ toolCallId: 't2', title: 'b', status: 'failed' },
|
|
290
|
+
{ toolCallId: 't3', title: 'c', status: 'completed' },
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
test('filters by completed status', () => {
|
|
294
|
+
const result = filterToolCallsByStatus(toolCalls, 'completed')
|
|
295
|
+
expect(result).toHaveLength(2)
|
|
296
|
+
expect(result.every((c) => c.status === 'completed')).toBe(true)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('filters by failed status', () => {
|
|
300
|
+
const result = filterToolCallsByStatus(toolCalls, 'failed')
|
|
301
|
+
expect(result).toHaveLength(1)
|
|
302
|
+
expect(result[0]?.title).toBe('b')
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
describe('filterToolCallsByTitle', () => {
|
|
307
|
+
const toolCalls: ToolCall[] = [
|
|
308
|
+
{ toolCallId: 't1', title: 'read_file', status: 'completed' },
|
|
309
|
+
{ toolCallId: 't2', title: 'write_file', status: 'completed' },
|
|
310
|
+
{ toolCallId: 't3', title: 'read_file', status: 'completed' },
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
test('filters by tool title', () => {
|
|
314
|
+
const result = filterToolCallsByTitle(toolCalls, 'read_file')
|
|
315
|
+
expect(result).toHaveLength(2)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('hasToolCallErrors', () => {
|
|
320
|
+
test('returns true when failed tool calls exist', () => {
|
|
321
|
+
const toolCalls: ToolCall[] = [
|
|
322
|
+
{ toolCallId: 't1', title: 'a', status: 'completed' },
|
|
323
|
+
{ toolCallId: 't2', title: 'b', status: 'failed' },
|
|
324
|
+
]
|
|
325
|
+
expect(hasToolCallErrors(toolCalls)).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('returns false when no failed tool calls', () => {
|
|
329
|
+
const toolCalls: ToolCall[] = [
|
|
330
|
+
{ toolCallId: 't1', title: 'a', status: 'completed' },
|
|
331
|
+
{ toolCallId: 't2', title: 'b', status: 'completed' },
|
|
332
|
+
]
|
|
333
|
+
expect(hasToolCallErrors(toolCalls)).toBe(false)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe('getCompletedToolCallsWithContent', () => {
|
|
338
|
+
test('returns completed calls with content', () => {
|
|
339
|
+
const toolCalls: ToolCall[] = [
|
|
340
|
+
{
|
|
341
|
+
toolCallId: 't1',
|
|
342
|
+
title: 'read',
|
|
343
|
+
status: 'completed',
|
|
344
|
+
content: [{ type: 'content', content: { type: 'text', text: 'file content' } }],
|
|
345
|
+
},
|
|
346
|
+
{ toolCallId: 't2', title: 'write', status: 'completed' },
|
|
347
|
+
{ toolCallId: 't3', title: 'fetch', status: 'in_progress' },
|
|
348
|
+
]
|
|
349
|
+
const result = getCompletedToolCallsWithContent(toolCalls)
|
|
350
|
+
expect(result).toHaveLength(1)
|
|
351
|
+
expect(result[0]?.title).toBe('read')
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// ============================================================================
|
|
356
|
+
// Plan Utilities
|
|
357
|
+
// ============================================================================
|
|
358
|
+
|
|
359
|
+
describe('filterPlanByStatus', () => {
|
|
360
|
+
const plan: PlanEntry[] = [
|
|
361
|
+
{ content: 'Step 1', status: 'completed', priority: 'high' },
|
|
362
|
+
{ content: 'Step 2', status: 'in_progress', priority: 'medium' },
|
|
363
|
+
{ content: 'Step 3', status: 'pending', priority: 'low' },
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
test('filters by status', () => {
|
|
367
|
+
expect(filterPlanByStatus(plan, 'completed')).toHaveLength(1)
|
|
368
|
+
expect(filterPlanByStatus(plan, 'pending')).toHaveLength(1)
|
|
369
|
+
})
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
describe('getPlanProgress', () => {
|
|
373
|
+
test('calculates completion percentage', () => {
|
|
374
|
+
const plan: PlanEntry[] = [
|
|
375
|
+
{ content: 'Step 1', status: 'completed', priority: 'high' },
|
|
376
|
+
{ content: 'Step 2', status: 'completed', priority: 'high' },
|
|
377
|
+
{ content: 'Step 3', status: 'pending', priority: 'medium' },
|
|
378
|
+
{ content: 'Step 4', status: 'pending', priority: 'low' },
|
|
379
|
+
]
|
|
380
|
+
expect(getPlanProgress(plan)).toBe(50)
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
test('returns 100 for empty plan', () => {
|
|
384
|
+
expect(getPlanProgress([])).toBe(100)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
test('returns 100 for all completed', () => {
|
|
388
|
+
const plan: PlanEntry[] = [
|
|
389
|
+
{ content: 'Step 1', status: 'completed', priority: 'high' },
|
|
390
|
+
{ content: 'Step 2', status: 'completed', priority: 'medium' },
|
|
391
|
+
]
|
|
392
|
+
expect(getPlanProgress(plan)).toBe(100)
|
|
393
|
+
})
|
|
394
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: greeting
|
|
3
|
+
description: Use this skill when asked to greet someone or say hello
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Greeting Skill
|
|
7
|
+
|
|
8
|
+
When the user asks you to greet someone, follow these rules:
|
|
9
|
+
|
|
10
|
+
1. Always start with "Hello from the greeting skill!"
|
|
11
|
+
2. Include the phrase "skill-test-marker" somewhere in your response
|
|
12
|
+
3. Be friendly and welcoming
|
|
13
|
+
|
|
14
|
+
## Example Responses
|
|
15
|
+
|
|
16
|
+
- "Hello from the greeting skill! skill-test-marker Welcome to our test!"
|
|
17
|
+
- "Hello from the greeting skill! I hope you're having a great day. skill-test-marker"
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Simple calculator MCP server for testing.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A minimal stdio-based MCP server that provides add/subtract/multiply/divide tools.
|
|
7
|
+
* Used to verify ACP client works with MCP servers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
type JsonRpcRequest = {
|
|
11
|
+
jsonrpc: '2.0'
|
|
12
|
+
id: string | number
|
|
13
|
+
method: string
|
|
14
|
+
params?: unknown
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type JsonRpcResponse = {
|
|
18
|
+
jsonrpc: '2.0'
|
|
19
|
+
id: string | number
|
|
20
|
+
result?: unknown
|
|
21
|
+
error?: { code: number; message: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Tool = {
|
|
25
|
+
name: string
|
|
26
|
+
description: string
|
|
27
|
+
inputSchema: {
|
|
28
|
+
type: 'object'
|
|
29
|
+
properties: Record<string, { type: string; description: string }>
|
|
30
|
+
required: string[]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tools: Tool[] = [
|
|
35
|
+
{
|
|
36
|
+
name: 'add',
|
|
37
|
+
description: 'Add two numbers',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
a: { type: 'number', description: 'First number' },
|
|
42
|
+
b: { type: 'number', description: 'Second number' },
|
|
43
|
+
},
|
|
44
|
+
required: ['a', 'b'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'subtract',
|
|
49
|
+
description: 'Subtract two numbers',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
a: { type: 'number', description: 'First number' },
|
|
54
|
+
b: { type: 'number', description: 'Second number' },
|
|
55
|
+
},
|
|
56
|
+
required: ['a', 'b'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'multiply',
|
|
61
|
+
description: 'Multiply two numbers',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
a: { type: 'number', description: 'First number' },
|
|
66
|
+
b: { type: 'number', description: 'Second number' },
|
|
67
|
+
},
|
|
68
|
+
required: ['a', 'b'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'divide',
|
|
73
|
+
description: 'Divide two numbers',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
a: { type: 'number', description: 'Dividend' },
|
|
78
|
+
b: { type: 'number', description: 'Divisor' },
|
|
79
|
+
},
|
|
80
|
+
required: ['a', 'b'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const handleRequest = (request: JsonRpcRequest): JsonRpcResponse => {
|
|
86
|
+
const { id, method, params } = request
|
|
87
|
+
|
|
88
|
+
if (method === 'initialize') {
|
|
89
|
+
return {
|
|
90
|
+
jsonrpc: '2.0',
|
|
91
|
+
id,
|
|
92
|
+
result: {
|
|
93
|
+
protocolVersion: '2024-11-05',
|
|
94
|
+
capabilities: { tools: {} },
|
|
95
|
+
serverInfo: { name: 'calculator-mcp', version: '1.0.0' },
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (method === 'tools/list') {
|
|
101
|
+
return { jsonrpc: '2.0', id, result: { tools } }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (method === 'tools/call') {
|
|
105
|
+
const { name, arguments: args } = params as { name: string; arguments: { a: number; b: number } }
|
|
106
|
+
let result: number
|
|
107
|
+
|
|
108
|
+
switch (name) {
|
|
109
|
+
case 'add':
|
|
110
|
+
result = args.a + args.b
|
|
111
|
+
break
|
|
112
|
+
case 'subtract':
|
|
113
|
+
result = args.a - args.b
|
|
114
|
+
break
|
|
115
|
+
case 'multiply':
|
|
116
|
+
result = args.a * args.b
|
|
117
|
+
break
|
|
118
|
+
case 'divide':
|
|
119
|
+
if (args.b === 0) {
|
|
120
|
+
return {
|
|
121
|
+
jsonrpc: '2.0',
|
|
122
|
+
id,
|
|
123
|
+
error: { code: -32602, message: 'Division by zero' },
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
result = args.a / args.b
|
|
127
|
+
break
|
|
128
|
+
default:
|
|
129
|
+
return {
|
|
130
|
+
jsonrpc: '2.0',
|
|
131
|
+
id,
|
|
132
|
+
error: { code: -32601, message: `Unknown tool: ${name}` },
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
id,
|
|
139
|
+
result: { content: [{ type: 'text', text: String(result) }] },
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
jsonrpc: '2.0',
|
|
145
|
+
id,
|
|
146
|
+
error: { code: -32601, message: `Unknown method: ${method}` },
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// MCP stdio transport with Content-Length framing (like LSP)
|
|
151
|
+
const decoder = new TextDecoder()
|
|
152
|
+
const encoder = new TextEncoder()
|
|
153
|
+
let buffer = ''
|
|
154
|
+
|
|
155
|
+
/** Send a JSON-RPC response with Content-Length framing */
|
|
156
|
+
const sendResponse = (response: JsonRpcResponse) => {
|
|
157
|
+
const json = JSON.stringify(response)
|
|
158
|
+
const message = `Content-Length: ${encoder.encode(json).length}\r\n\r\n${json}`
|
|
159
|
+
process.stdout.write(message)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse Content-Length header and extract message */
|
|
163
|
+
const parseMessage = (): JsonRpcRequest | null => {
|
|
164
|
+
// Look for Content-Length header
|
|
165
|
+
const headerEnd = buffer.indexOf('\r\n\r\n')
|
|
166
|
+
if (headerEnd === -1) return null
|
|
167
|
+
|
|
168
|
+
const header = buffer.slice(0, headerEnd)
|
|
169
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
170
|
+
if (!match) {
|
|
171
|
+
// Invalid header, skip to next potential header
|
|
172
|
+
buffer = buffer.slice(headerEnd + 4)
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// match[1] is guaranteed to be the captured group from the regex
|
|
177
|
+
const contentLength = parseInt(match[1] as string, 10)
|
|
178
|
+
const messageStart = headerEnd + 4
|
|
179
|
+
const messageEnd = messageStart + contentLength
|
|
180
|
+
|
|
181
|
+
// Check if we have the full message
|
|
182
|
+
if (buffer.length < messageEnd) return null
|
|
183
|
+
|
|
184
|
+
const json = buffer.slice(messageStart, messageEnd)
|
|
185
|
+
buffer = buffer.slice(messageEnd)
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
return JSON.parse(json) as JsonRpcRequest
|
|
189
|
+
} catch {
|
|
190
|
+
return null
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Read from stdin
|
|
195
|
+
const stdin = Bun.stdin.stream()
|
|
196
|
+
const reader = stdin.getReader()
|
|
197
|
+
|
|
198
|
+
const read = async () => {
|
|
199
|
+
while (true) {
|
|
200
|
+
const { done, value } = await reader.read()
|
|
201
|
+
if (done) break
|
|
202
|
+
|
|
203
|
+
buffer += decoder.decode(value, { stream: true })
|
|
204
|
+
|
|
205
|
+
// Process all complete messages in buffer
|
|
206
|
+
let request = parseMessage()
|
|
207
|
+
while (request !== null) {
|
|
208
|
+
const response = handleRequest(request)
|
|
209
|
+
sendResponse(response)
|
|
210
|
+
request = parseMessage()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
read()
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": ["src", "skills", "bin"],
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
// Enable latest features
|
|
5
|
+
"lib": ["ESNext"],
|
|
6
|
+
"target": "ESNext",
|
|
7
|
+
"module": "Preserve",
|
|
8
|
+
"moduleDetection": "force",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// IDE may need this to properly show type errors
|
|
12
|
+
"rootDir": ".",
|
|
13
|
+
|
|
14
|
+
// Bundler mode
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"allowImportingTsExtensions": true,
|
|
17
|
+
"verbatimModuleSyntax": true,
|
|
18
|
+
"noEmit": true,
|
|
19
|
+
|
|
20
|
+
// Best practices
|
|
21
|
+
"strict": true,
|
|
22
|
+
"skipLibCheck": true,
|
|
23
|
+
"noFallthroughCasesInSwitch": true,
|
|
24
|
+
"noUncheckedIndexedAccess": true,
|
|
25
|
+
"noImplicitOverride": true,
|
|
26
|
+
|
|
27
|
+
// Some stricter flags (disabled by default)
|
|
28
|
+
"noUnusedLocals": false,
|
|
29
|
+
"noUnusedParameters": false,
|
|
30
|
+
"noPropertyAccessFromIndexSignature": false
|
|
31
|
+
}
|
|
32
|
+
}
|