@aspectlylabs/mikka 0.0.0-stage → 0.0.1
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 +662 -0
- package/README.md +63 -2
- package/agents/native-container.mjs +191 -0
- package/agents/native-egress.mjs +51 -0
- package/cli.js +15433 -0
- package/client.js +3783 -0
- package/computer/driver.mjs +278 -0
- package/computer/proxy.mjs +123 -0
- package/coordinator.js +317 -0
- package/env.js +53 -0
- package/fixtures/ask.jsonl +5 -0
- package/fixtures/blocked-question.jsonl +2 -0
- package/fixtures/check-pass.jsonl +3 -0
- package/fixtures/crash.jsonl +6 -0
- package/fixtures/failure.jsonl +8 -0
- package/fixtures/long-running.jsonl +67 -0
- package/fixtures/plan.jsonl +10 -0
- package/fixtures/quota.jsonl +6 -0
- package/fixtures/run-tests.jsonl +8 -0
- package/fixtures/secret-output.jsonl +11 -0
- package/fixtures/self-verify.jsonl +5 -0
- package/fixtures/stall.jsonl +7 -0
- package/fixtures/success-edit.jsonl +19 -0
- package/fixtures/turn-ended.jsonl +18 -0
- package/fixtures/verify-fails.jsonl +7 -0
- package/http-link.js +4496 -0
- package/log.js +62 -0
- package/login.js +3883 -0
- package/main.js +14348 -0
- package/mikka-mcp.mjs +410 -0
- package/mikka.js +15433 -0
- package/package.json +46 -4
- package/paths.js +108 -0
- package/sandbox.js +133 -0
package/mikka-mcp.mjs
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mikka's MCP server for agent CLIs: the explicit hand-in tools a real
|
|
3
|
+
// harness calls instead of just ending its turn (`submit`, `ask_human`,
|
|
4
|
+
// `report_blocked`). It runs inside the agent's sandbox as a stdio child of
|
|
5
|
+
// the CLI, with no dependencies, and records the call in the handoff file
|
|
6
|
+
// (in the worktree's git dir, writable from the sandbox). The daemon reads
|
|
7
|
+
// that file when the turn ends; the file, not the CLI's event stream, is the
|
|
8
|
+
// one source of truth for every harness.
|
|
9
|
+
//
|
|
10
|
+
// node mikka-mcp.mjs --handoff <file> [--captures <dir> --capture-root <dir>...]
|
|
11
|
+
//
|
|
12
|
+
// `submit_capture` copies a screenshot or recording the agent made into the
|
|
13
|
+
// captures dir; the daemon checks it again and uploads it to the run.
|
|
14
|
+
//
|
|
15
|
+
// Protocol: MCP over stdio, newline-delimited JSON-RPC 2.0.
|
|
16
|
+
import { randomUUID } from "node:crypto"
|
|
17
|
+
import { closeSync, fstatSync, openSync, readSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync, constants } from "node:fs"
|
|
18
|
+
import { join, sep } from "node:path"
|
|
19
|
+
import { createInterface } from "node:readline"
|
|
20
|
+
import { copyCaptureFd } from "./capture-copy.mjs"
|
|
21
|
+
|
|
22
|
+
const handoffIndex = process.argv.indexOf("--handoff")
|
|
23
|
+
const handoffFile = handoffIndex > 0 ? process.argv[handoffIndex + 1] : null
|
|
24
|
+
if (!handoffFile) {
|
|
25
|
+
process.stderr.write("mikka-mcp: --handoff <file> is required\n")
|
|
26
|
+
process.exit(2)
|
|
27
|
+
}
|
|
28
|
+
const flagValues = (flag) => process.argv.flatMap((arg, i) => (arg === flag && process.argv[i + 1] ? [process.argv[i + 1]] : []))
|
|
29
|
+
const capturesDir = flagValues("--captures")[0] ?? null
|
|
30
|
+
const captureRoots = flagValues("--capture-root").flatMap((root) => {
|
|
31
|
+
try { return [realpathSync(root)] } catch { return [] }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const stringArray = { type: "array", items: { type: "string" } }
|
|
35
|
+
|
|
36
|
+
// A `plan` run hands in a spec and the split it proposes instead of code
|
|
37
|
+
// (ADR 0047). The shapes are `planSpec` / `planSplit` in
|
|
38
|
+
// packages/protocol/src/plan.ts; the daemon checks them again on the way out.
|
|
39
|
+
const SPEC_SCHEMA = {
|
|
40
|
+
type: "object",
|
|
41
|
+
description: "The product-level spec. Required on a planning run.",
|
|
42
|
+
properties: {
|
|
43
|
+
title: { type: "string" },
|
|
44
|
+
summary: { type: "string", description: "One paragraph, for the card." },
|
|
45
|
+
sections: {
|
|
46
|
+
type: "array",
|
|
47
|
+
items: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
heading: { type: "string" },
|
|
51
|
+
items: stringArray,
|
|
52
|
+
},
|
|
53
|
+
required: ["heading", "items"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["title", "sections"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SPLIT_SCHEMA = {
|
|
61
|
+
type: "object",
|
|
62
|
+
description:
|
|
63
|
+
"The issues the spec splits into. Required on a planning run. Every issue needs at least one acceptance criterion; nothing is created until a person approves the split.",
|
|
64
|
+
properties: {
|
|
65
|
+
summary: { type: "string" },
|
|
66
|
+
issues: {
|
|
67
|
+
type: "array",
|
|
68
|
+
items: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
key: {
|
|
72
|
+
type: "string",
|
|
73
|
+
description: "Local id; parentKey and blockedBy name these.",
|
|
74
|
+
},
|
|
75
|
+
title: { type: "string" },
|
|
76
|
+
goal: { type: "string" },
|
|
77
|
+
parentKey: { type: "string", description: "Grouping only." },
|
|
78
|
+
blockedBy: {
|
|
79
|
+
...stringArray,
|
|
80
|
+
description: "Keys that must merge first.",
|
|
81
|
+
},
|
|
82
|
+
criteria: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: {
|
|
85
|
+
type: "object",
|
|
86
|
+
properties: {
|
|
87
|
+
body: { type: "string" },
|
|
88
|
+
checkCommand: {
|
|
89
|
+
type: "string",
|
|
90
|
+
description: "Omit when a person checks it.",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
required: ["body"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
verifyCommand: { type: "string" },
|
|
97
|
+
predictedFiles: stringArray,
|
|
98
|
+
sizeEstimate: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
files: { type: "number" },
|
|
102
|
+
lines: { type: "number" },
|
|
103
|
+
},
|
|
104
|
+
required: ["files", "lines"],
|
|
105
|
+
},
|
|
106
|
+
priority: {
|
|
107
|
+
type: "string",
|
|
108
|
+
enum: ["none", "urgent", "high", "medium", "low"],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ["key", "title", "criteria"],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
required: ["issues"],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const TOOLS = [
|
|
119
|
+
{
|
|
120
|
+
name: "submit",
|
|
121
|
+
description:
|
|
122
|
+
"Hand in your finished work. Call this once, as your last action, when the task is done and you checked it. Mikka then runs the verify command, commits your changes and pushes the branch. Do not commit or push yourself. On a planning run, hand in the spec and the split you propose instead of code.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
summary: {
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "What you did, in a few sentences.",
|
|
129
|
+
},
|
|
130
|
+
nextStep: {
|
|
131
|
+
type: "string",
|
|
132
|
+
description: "What should happen next, if anything.",
|
|
133
|
+
},
|
|
134
|
+
openQuestions: {
|
|
135
|
+
...stringArray,
|
|
136
|
+
description: "Questions a human should look at.",
|
|
137
|
+
},
|
|
138
|
+
spec: SPEC_SCHEMA,
|
|
139
|
+
split: SPLIT_SCHEMA,
|
|
140
|
+
criteria: {
|
|
141
|
+
type: "array",
|
|
142
|
+
description:
|
|
143
|
+
"One entry per acceptance criterion (AC-n) the issue lists, in order. On a read-only check, an entry with no evidence counts as not passed, so report only what you actually saw.",
|
|
144
|
+
items: {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {
|
|
147
|
+
criterionId: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "The criterion's id, as given in your brief.",
|
|
150
|
+
},
|
|
151
|
+
passed: { type: "boolean" },
|
|
152
|
+
evidence: {
|
|
153
|
+
type: "string",
|
|
154
|
+
description:
|
|
155
|
+
"What you saw: the command and its output, or the file and line.",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
required: ["criterionId", "passed"],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
required: ["summary"],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "request_help",
|
|
167
|
+
description:
|
|
168
|
+
"Stop and request missing task context from the Orchestrator. It first checks the issue and existing facts; only genuinely unresolved input is escalated to the person. Work is preserved for the original session.",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: "object",
|
|
171
|
+
properties: {
|
|
172
|
+
question: { type: "string" },
|
|
173
|
+
context: { type: "string" },
|
|
174
|
+
summary: { type: "string" },
|
|
175
|
+
},
|
|
176
|
+
required: ["question"],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "ask_human",
|
|
181
|
+
description:
|
|
182
|
+
"Stop and ask a human a question you cannot answer yourself (a product decision, missing access). Your work so far is kept; the run continues after the answer.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
question: { type: "string" },
|
|
187
|
+
kind: { type: "string", enum: ["context", "decision", "permission", "external"] },
|
|
188
|
+
summary: { type: "string", description: "Where you are so far." },
|
|
189
|
+
},
|
|
190
|
+
required: ["question"],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "report_blocked",
|
|
195
|
+
description:
|
|
196
|
+
"Stop because something outside the code blocks you (quota, a service down, a permission you lack).",
|
|
197
|
+
inputSchema: {
|
|
198
|
+
type: "object",
|
|
199
|
+
properties: {
|
|
200
|
+
blockedOn: {
|
|
201
|
+
type: "string",
|
|
202
|
+
description: "The external condition, e.g. quota_exhausted.",
|
|
203
|
+
},
|
|
204
|
+
summary: { type: "string" },
|
|
205
|
+
},
|
|
206
|
+
required: ["blockedOn"],
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
// The same limits as @mikka/protocol/capture; this file has no dependencies.
|
|
212
|
+
const CAPTURE_LIMITS = { png: 10_000_000, jpg: 10_000_000, webp: 10_000_000, mp4: 100_000_000, webm: 100_000_000 }
|
|
213
|
+
const MAX_QUEUED_CAPTURES = 20
|
|
214
|
+
const CAPTURE_TOOL = {
|
|
215
|
+
name: "submit_capture",
|
|
216
|
+
description:
|
|
217
|
+
"Attach a screenshot or screen recording you made (for example with Playwright) to this thread as evidence for the person. PNG, JPEG or WebP up to 10 MB; MP4 or WebM up to 100 MB. Pass a file inside the worktree or your temp directory.",
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
path: { type: "string", description: "The file's path." },
|
|
222
|
+
title: { type: "string", description: "What it shows, for the person." },
|
|
223
|
+
},
|
|
224
|
+
required: ["path", "title"],
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sniff(head) {
|
|
229
|
+
const ascii = (at, text) => [...text].every((c, i) => head[at + i] === c.charCodeAt(0))
|
|
230
|
+
if (head.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => head[i] === b)) return "png"
|
|
231
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return "jpg"
|
|
232
|
+
if (head.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) return "webp"
|
|
233
|
+
if (head.length >= 12 && ascii(4, "ftyp")) return "mp4"
|
|
234
|
+
if (head.length >= 4 && head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3 && Buffer.from(head).toString("latin1").includes("webm")) return "webm"
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Copies the file into the captures dir after the checks the daemon repeats. */
|
|
239
|
+
function queueCapture(args) {
|
|
240
|
+
if (!capturesDir) throw new Error("Captures are not available in this run.")
|
|
241
|
+
const title = typeof args.title === "string" ? args.title.replace(/\s+/g, " ").trim().slice(0, 200) : ""
|
|
242
|
+
if (!title) throw new Error("A title is required.")
|
|
243
|
+
if (typeof args.path !== "string" || !args.path) throw new Error("A path is required.")
|
|
244
|
+
const real = realpathSync(args.path)
|
|
245
|
+
if (!captureRoots.some((root) => real === root || real.startsWith(root + sep)))
|
|
246
|
+
throw new Error("The file must be inside the worktree or your temp directory.")
|
|
247
|
+
const fd = openSync(real, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
248
|
+
let ext, id
|
|
249
|
+
try {
|
|
250
|
+
const stat = fstatSync(fd)
|
|
251
|
+
if (!stat.isFile()) throw new Error("Not a regular file.")
|
|
252
|
+
const head = Buffer.alloc(64)
|
|
253
|
+
ext = sniff(head.subarray(0, readSync(fd, head, 0, 64, 0)))
|
|
254
|
+
if (!ext) throw new Error("Not a PNG, JPEG or WebP image, or an MP4 or WebM video.")
|
|
255
|
+
if (stat.size === 0 || stat.size > CAPTURE_LIMITS[ext]) throw new Error(`At most ${CAPTURE_LIMITS[ext]} bytes for a .${ext} file.`)
|
|
256
|
+
if (readdirSync(capturesDir).filter((name) => name.endsWith(".json")).length >= MAX_QUEUED_CAPTURES)
|
|
257
|
+
throw new Error("Too many captures waiting to upload; try again shortly.")
|
|
258
|
+
id = randomUUID()
|
|
259
|
+
copyCaptureFd(fd, join(capturesDir, `${id}.${ext}`), stat.size)
|
|
260
|
+
} finally {
|
|
261
|
+
closeSync(fd)
|
|
262
|
+
}
|
|
263
|
+
// The metadata lands last: the daemon only takes files that have one.
|
|
264
|
+
const meta = join(capturesDir, `${id}.json`)
|
|
265
|
+
writeFileSync(`${meta}.tmp`, JSON.stringify({ title, at: new Date().toISOString() }))
|
|
266
|
+
renameSync(`${meta}.tmp`, meta)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const KINDS = { submit: "submit", request_help: "ask", ask_human: "ask", report_blocked: "blocked" }
|
|
270
|
+
|
|
271
|
+
function record(kind, args, toolName) {
|
|
272
|
+
// A planning hand-in carries the spec and the split it proposes. The id is
|
|
273
|
+
// generated here, once, so the cards the daemon puts in the conversation
|
|
274
|
+
// and the row the server stores name the same spec.
|
|
275
|
+
const plan =
|
|
276
|
+
kind === "submit" &&
|
|
277
|
+
typeof args.spec === "object" &&
|
|
278
|
+
args.spec !== null &&
|
|
279
|
+
typeof args.split === "object" &&
|
|
280
|
+
args.split !== null
|
|
281
|
+
? { specId: randomUUID(), spec: args.spec, split: args.split }
|
|
282
|
+
: null
|
|
283
|
+
const questionText = String(
|
|
284
|
+
kind === "blocked"
|
|
285
|
+
? (args.blockedOn ?? "External condition")
|
|
286
|
+
: (args.question ?? "Missing task input"),
|
|
287
|
+
)
|
|
288
|
+
.trim()
|
|
289
|
+
.slice(0, 4000)
|
|
290
|
+
const questionKind =
|
|
291
|
+
kind === "blocked"
|
|
292
|
+
? "external"
|
|
293
|
+
: toolName === "request_help"
|
|
294
|
+
? "context"
|
|
295
|
+
: args.kind === "permission"
|
|
296
|
+
? "permission"
|
|
297
|
+
: "decision"
|
|
298
|
+
let questionId = randomUUID()
|
|
299
|
+
try {
|
|
300
|
+
const existing = JSON.parse(readFileSync(handoffFile, "utf8"))
|
|
301
|
+
if (
|
|
302
|
+
existing.kind === kind &&
|
|
303
|
+
existing.question?.question === questionText &&
|
|
304
|
+
existing.question.kind === questionKind
|
|
305
|
+
)
|
|
306
|
+
questionId = existing.question.questionId
|
|
307
|
+
} catch {
|
|
308
|
+
/* First durable hand-in; a retry reads the same id above. */
|
|
309
|
+
}
|
|
310
|
+
const handoff = {
|
|
311
|
+
kind,
|
|
312
|
+
...(kind === "ask" || kind === "blocked"
|
|
313
|
+
? {
|
|
314
|
+
question: {
|
|
315
|
+
questionId,
|
|
316
|
+
kind: questionKind,
|
|
317
|
+
question: questionText,
|
|
318
|
+
context: String(args.context ?? args.summary ?? "").slice(0, 4000),
|
|
319
|
+
options: [],
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
: {}),
|
|
323
|
+
summary: typeof args.summary === "string" ? args.summary : "",
|
|
324
|
+
nextStep: typeof args.nextStep === "string" ? args.nextStep : null,
|
|
325
|
+
openQuestions: Array.isArray(args.openQuestions)
|
|
326
|
+
? args.openQuestions.filter((q) => typeof q === "string")
|
|
327
|
+
: typeof args.question === "string"
|
|
328
|
+
? [args.question]
|
|
329
|
+
: [],
|
|
330
|
+
...(typeof args.blockedOn === "string"
|
|
331
|
+
? { blockedOn: args.blockedOn }
|
|
332
|
+
: {}),
|
|
333
|
+
...(plan ? { plan } : {}),
|
|
334
|
+
// Kept as sent; the daemon validates and trims each entry.
|
|
335
|
+
...(Array.isArray(args.criteria) ? { criteria: args.criteria } : {}),
|
|
336
|
+
at: new Date().toISOString(),
|
|
337
|
+
}
|
|
338
|
+
const tmp = `${handoffFile}.${process.pid}.tmp`
|
|
339
|
+
writeFileSync(tmp, `${JSON.stringify(handoff, null, 2)}\n`)
|
|
340
|
+
renameSync(tmp, handoffFile)
|
|
341
|
+
return plan !== null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function reply(id, result) {
|
|
345
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function fail(id, code, message) {
|
|
349
|
+
process.stdout.write(
|
|
350
|
+
`${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`,
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function handle(message) {
|
|
355
|
+
const { id, method, params } = message
|
|
356
|
+
if (id === undefined || id === null) return // notifications
|
|
357
|
+
switch (method) {
|
|
358
|
+
case "initialize":
|
|
359
|
+
return reply(id, {
|
|
360
|
+
protocolVersion: params?.protocolVersion ?? "2025-06-18",
|
|
361
|
+
capabilities: { tools: {} },
|
|
362
|
+
serverInfo: { name: "mikka", version: "0.0.1" },
|
|
363
|
+
})
|
|
364
|
+
case "ping":
|
|
365
|
+
return reply(id, {})
|
|
366
|
+
case "tools/list":
|
|
367
|
+
return reply(id, { tools: capturesDir ? [...TOOLS, CAPTURE_TOOL] : TOOLS })
|
|
368
|
+
case "tools/call": {
|
|
369
|
+
if (capturesDir && params?.name === CAPTURE_TOOL.name) {
|
|
370
|
+
try {
|
|
371
|
+
queueCapture(params.arguments ?? {})
|
|
372
|
+
} catch (error) {
|
|
373
|
+
return reply(id, { isError: true, content: [{ type: "text", text: `Not attached: ${error instanceof Error ? error.message : error}` }] })
|
|
374
|
+
}
|
|
375
|
+
return reply(id, { content: [{ type: "text", text: "Attached to this thread. Keep working; it uploads in the background." }] })
|
|
376
|
+
}
|
|
377
|
+
const kind = KINDS[params?.name]
|
|
378
|
+
if (!kind) return fail(id, -32602, `Unknown tool: ${params?.name}`)
|
|
379
|
+
let planned
|
|
380
|
+
try {
|
|
381
|
+
planned = record(kind, params.arguments ?? {}, params.name)
|
|
382
|
+
} catch (error) {
|
|
383
|
+
return reply(id, {
|
|
384
|
+
isError: true,
|
|
385
|
+
content: [{ type: "text", text: `Could not record: ${error}` }],
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
const text =
|
|
389
|
+
kind !== "submit"
|
|
390
|
+
? "Recorded. End your turn now."
|
|
391
|
+
: planned
|
|
392
|
+
? "Handed in. End your turn now; a person approves the spec, then the split."
|
|
393
|
+
: "Handed in. End your turn now; Mikka verifies, commits and pushes."
|
|
394
|
+
return reply(id, { content: [{ type: "text", text }] })
|
|
395
|
+
}
|
|
396
|
+
default:
|
|
397
|
+
return fail(id, -32601, `Method not found: ${method}`)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
402
|
+
if (!line.trim()) return
|
|
403
|
+
let message
|
|
404
|
+
try {
|
|
405
|
+
message = JSON.parse(line)
|
|
406
|
+
} catch {
|
|
407
|
+
return fail(null, -32700, "Parse error")
|
|
408
|
+
}
|
|
409
|
+
handle(message)
|
|
410
|
+
})
|