@panagos/jojoscript 0.9.3 → 1.0.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.
- package/README.md +848 -25
- package/bin/jojo.js +257 -23
- package/package.json +2 -2
- package/src/check.js +253 -0
- package/src/compiler.js +36 -37
- package/src/contracts.js +124 -0
- package/src/describe.js +63 -0
- package/src/effect-parser.js +4 -2
- package/src/formatter.js +105 -0
- package/src/index.js +31 -0
- package/src/match-parser.js +212 -14
- package/src/named-pipelines.js +87 -0
- package/src/parse.js +124 -0
- package/src/pipeline-explain.js +132 -0
- package/src/pipeline-ir.js +9 -7
- package/src/pipeline-parser.js +24 -2
- package/src/pipeline-plan.js +203 -0
- package/src/purity.js +101 -0
- package/src/runtime/a2a.js +322 -0
- package/src/runtime/checkpoint-store.js +26 -5
- package/src/runtime/checkpoint.js +3 -2
- package/src/runtime/effects.js +136 -2
- package/src/runtime/errors.js +14 -0
- package/src/runtime/index.js +15 -3
- package/src/runtime/mcp.js +237 -35
- package/src/runtime/serialization.js +97 -0
- package/src/runtime/tagged-unions.js +29 -0
- package/src/simulate.js +156 -0
- package/src/type-declarations.js +83 -0
- package/src/why.js +146 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
-
import path from 'node:path'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { toJson, fromJson } from './serialization.js'
|
|
3
5
|
|
|
4
6
|
/*
|
|
5
7
|
* Storage interface for checkpoints. Deliberately storage-agnostic: the
|
|
@@ -29,6 +31,23 @@ function key(pipelineId, checkpointId) {
|
|
|
29
31
|
return `${pipelineId}::${checkpointId}`
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
function validateCheckpoint(checkpoint, pipelineId, checkpointId) {
|
|
35
|
+
const invalid = reason => {
|
|
36
|
+
throw new Error(`Invalid checkpoint "${checkpointId}": ${reason}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!checkpoint || typeof checkpoint !== 'object' || Array.isArray(checkpoint)) {
|
|
40
|
+
invalid('expected an object')
|
|
41
|
+
}
|
|
42
|
+
if (checkpoint.pipelineId !== pipelineId) invalid('pipelineId does not match')
|
|
43
|
+
if (checkpoint.checkpointId !== checkpointId) invalid('checkpointId does not match')
|
|
44
|
+
if (!Array.isArray(checkpoint.items)) invalid('items must be an array')
|
|
45
|
+
if (checkpoint.itemCount !== undefined &&
|
|
46
|
+
(!Number.isInteger(checkpoint.itemCount) || checkpoint.itemCount !== checkpoint.items.length)) {
|
|
47
|
+
invalid('itemCount does not match items')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
/*
|
|
33
52
|
* In-memory store. Useful for tests and for short-lived processes where
|
|
34
53
|
* durability across restarts is not required.
|
|
@@ -85,7 +104,7 @@ export class FileCheckpointStore extends CheckpointStore {
|
|
|
85
104
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
86
105
|
|
|
87
106
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
88
|
-
fs.writeFileSync(tmp,
|
|
107
|
+
fs.writeFileSync(tmp, toJson(checkpoint), 'utf8')
|
|
89
108
|
fs.renameSync(tmp, file)
|
|
90
109
|
}
|
|
91
110
|
|
|
@@ -93,13 +112,15 @@ export class FileCheckpointStore extends CheckpointStore {
|
|
|
93
112
|
const file = this.filePath(pipelineId, checkpointId)
|
|
94
113
|
if (!fs.existsSync(file)) throw new Error(`Checkpoint not found: ${checkpointId}`)
|
|
95
114
|
|
|
96
|
-
let
|
|
115
|
+
let checkpoint
|
|
97
116
|
try {
|
|
98
|
-
|
|
99
|
-
return JSON.parse(raw)
|
|
117
|
+
checkpoint = fromJson(fs.readFileSync(file, 'utf8'))
|
|
100
118
|
} catch (error) {
|
|
101
119
|
throw new Error(`Corrupted checkpoint "${checkpointId}": ${error.message}`)
|
|
102
120
|
}
|
|
121
|
+
|
|
122
|
+
validateCheckpoint(checkpoint, pipelineId, checkpointId)
|
|
123
|
+
return checkpoint
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
async exists(pipelineId, checkpointId) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
1
|
+
import { getCurrentContext, getDefaultContext } from './execution-context.js'
|
|
2
|
+
import { now } from './effects.js'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* A pipeline checkpoint. `items` flow through unchanged; the operator's job
|
|
@@ -64,6 +65,6 @@ export async function* checkpoint(items, name, meta = {}) {
|
|
|
64
65
|
checkpointId: name,
|
|
65
66
|
items: buffer,
|
|
66
67
|
itemCount: buffer.length,
|
|
67
|
-
createdAt: new Date().toISOString()
|
|
68
|
+
createdAt: new Date(now()).toISOString()
|
|
68
69
|
})
|
|
69
70
|
}
|
package/src/runtime/effects.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { getCurrentContext } from './execution-context.js'
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
* Algebraic effects / effect handlers runtime.
|
|
@@ -23,6 +24,8 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
23
24
|
|
|
24
25
|
const handlerStorage = new AsyncLocalStorage()
|
|
25
26
|
const invocationStorage = new AsyncLocalStorage()
|
|
27
|
+
const authorizationStorage = new AsyncLocalStorage()
|
|
28
|
+
const deterministicStorage = new AsyncLocalStorage()
|
|
26
29
|
|
|
27
30
|
/*
|
|
28
31
|
* name -> { name, params, sideEffecting, replayable, builtin? }.
|
|
@@ -57,6 +60,26 @@ export class RecursiveEffectError extends Error {
|
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
export class EffectAuthorizationError extends Error {
|
|
64
|
+
constructor(name, reason, options = {}) {
|
|
65
|
+
const prefix = options.failed ? 'Effect authorization failed' : 'Effect authorization denied'
|
|
66
|
+
super(`${prefix} for "${name}": ${reason}`, { cause: options.cause })
|
|
67
|
+
this.name = 'EffectAuthorizationError'
|
|
68
|
+
this.effectName = name
|
|
69
|
+
this.reason = reason
|
|
70
|
+
this.retryable = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class NondeterministicEffectError extends Error {
|
|
75
|
+
constructor(name) {
|
|
76
|
+
super(`Effect "${name}" has no deterministic handler; inside deterministic mode use { handle, deterministic: true } or replayHandlers`)
|
|
77
|
+
this.name = 'NondeterministicEffectError'
|
|
78
|
+
this.effectName = name
|
|
79
|
+
this.retryable = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
/*
|
|
61
84
|
* Declares a named effect and returns the callable used to *perform* it.
|
|
62
85
|
* `options.sideEffecting` (default true) and `options.replayable` (default
|
|
@@ -109,6 +132,10 @@ function currentFrames() {
|
|
|
109
132
|
return handlerStorage.getStore() ?? []
|
|
110
133
|
}
|
|
111
134
|
|
|
135
|
+
function currentAuthorizationPolicies() {
|
|
136
|
+
return authorizationStorage.getStore() ?? []
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
/*
|
|
113
140
|
* Installs `handlers` (an object of `name -> handlerFn | { handle,
|
|
114
141
|
* deterministic }`) for the dynamic extent of `fn`, then restores whatever
|
|
@@ -141,6 +168,51 @@ export function withHandlers(handlers, fn) {
|
|
|
141
168
|
return handlerStorage.run(frames, fn)
|
|
142
169
|
}
|
|
143
170
|
|
|
171
|
+
/*
|
|
172
|
+
* Installs a capability policy for the dynamic extent of `fn`. Nested
|
|
173
|
+
* policies compose restrictively: every active policy must explicitly allow
|
|
174
|
+
* an invocation, so an inner handler or policy cannot bypass an outer
|
|
175
|
+
* boundary.
|
|
176
|
+
*/
|
|
177
|
+
export function withAuthorization(policy, fn) {
|
|
178
|
+
if (typeof policy !== 'function') {
|
|
179
|
+
throw new TypeError('withAuthorization requires an authorization function')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return authorizationStorage.run([...currentAuthorizationPolicies(), policy], fn)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function authorizeEffect(name, args) {
|
|
186
|
+
const policies = currentAuthorizationPolicies()
|
|
187
|
+
if (policies.length === 0) return
|
|
188
|
+
|
|
189
|
+
const invocation = {
|
|
190
|
+
name,
|
|
191
|
+
args: [...args],
|
|
192
|
+
effect: effectRegistry.get(name) ?? null,
|
|
193
|
+
context: getCurrentContext() ?? null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
for (const policy of policies) {
|
|
197
|
+
let decision
|
|
198
|
+
try {
|
|
199
|
+
decision = await policy(invocation)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new EffectAuthorizationError(name, `authorization callback failed: ${error?.message ?? error}`, {
|
|
202
|
+
failed: true,
|
|
203
|
+
cause: error
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (decision === true || decision?.allowed === true) continue
|
|
208
|
+
|
|
209
|
+
const reason = typeof decision?.reason === 'string'
|
|
210
|
+
? decision.reason
|
|
211
|
+
: 'policy did not explicitly allow this invocation'
|
|
212
|
+
throw new EffectAuthorizationError(name, reason)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
144
216
|
/*
|
|
145
217
|
* Resolves and invokes the effect named `name` against the innermost
|
|
146
218
|
* handler that declares it. Always returns a Promise (even for a
|
|
@@ -160,6 +232,12 @@ export async function performEffect(name, args) {
|
|
|
160
232
|
}
|
|
161
233
|
|
|
162
234
|
const entry = frame.handlers[name]
|
|
235
|
+
if (deterministicStorage.getStore() && entry.deterministic !== true) {
|
|
236
|
+
throw new NondeterministicEffectError(name)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await authorizeEffect(name, args)
|
|
240
|
+
|
|
163
241
|
const nextInFlight = new Set(inFlight)
|
|
164
242
|
nextInFlight.add(name)
|
|
165
243
|
|
|
@@ -216,9 +294,65 @@ effectRegistry.set('PARALLEL', {
|
|
|
216
294
|
builtin: true
|
|
217
295
|
})
|
|
218
296
|
|
|
297
|
+
for (const [name, params] of [['TIME', []], ['RANDOM', []]]) {
|
|
298
|
+
effectRegistry.set(name, { name, params, sideEffecting: false, replayable: false, builtin: true })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function builtinHandler(name) {
|
|
302
|
+
const frame = currentFrames().find(f => name in f.handlers)
|
|
303
|
+
return frame ? frame.handlers[name].handle : null
|
|
304
|
+
}
|
|
305
|
+
|
|
219
306
|
export function getParallelHandler() {
|
|
220
|
-
|
|
221
|
-
|
|
307
|
+
return builtinHandler('PARALLEL')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* `now()` and `random()` are built-in capabilities like `PARALLEL`: they are
|
|
312
|
+
* synchronous, fall back to `Date.now()` / `Math.random()`, and can be
|
|
313
|
+
* replaced with `handle { TIME: ..., RANDOM: ... }` or `deterministic()`.
|
|
314
|
+
*/
|
|
315
|
+
export function now() {
|
|
316
|
+
const handler = builtinHandler('TIME')
|
|
317
|
+
return handler ? handler() : Date.now()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function random() {
|
|
321
|
+
const handler = builtinHandler('RANDOM')
|
|
322
|
+
return handler ? handler() : Math.random()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// mulberry32: a small, well-distributed seeded generator.
|
|
326
|
+
function seededRandom(seed) {
|
|
327
|
+
let state = seed >>> 0
|
|
328
|
+
return () => {
|
|
329
|
+
state = (state + 0x6D2B79F5) >>> 0
|
|
330
|
+
let t = state
|
|
331
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
332
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
333
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function* runSequentially({ items, fn }) {
|
|
338
|
+
for await (const item of items) yield await fn(item)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/*
|
|
342
|
+
* Runs `fn` so that the same inputs always produce the same outputs: a seeded
|
|
343
|
+
* `random()`, a clock that starts at `startTime` and advances 1ms per read,
|
|
344
|
+
* sequential `parallel()`, and only effects whose handler is declared
|
|
345
|
+
* deterministic (including replay handlers).
|
|
346
|
+
*/
|
|
347
|
+
export function deterministic(fn, options = {}) {
|
|
348
|
+
let clock = options.startTime ?? 0
|
|
349
|
+
const handlers = {
|
|
350
|
+
TIME: { handle: () => clock++, deterministic: true },
|
|
351
|
+
RANDOM: { handle: seededRandom(options.seed ?? 1), deterministic: true },
|
|
352
|
+
PARALLEL: { handle: runSequentially, deterministic: true }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return deterministicStorage.run(true, () => withHandlers(handlers, fn))
|
|
222
356
|
}
|
|
223
357
|
|
|
224
358
|
/*
|
package/src/runtime/errors.js
CHANGED
|
@@ -27,3 +27,17 @@ export class PipelineError extends Error {
|
|
|
27
27
|
this.timestamp = details.timestamp ?? new Date().toISOString()
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* A broken `requires`/`ensures` clause is a programming error, so retrying
|
|
33
|
+
* the same call can never succeed.
|
|
34
|
+
*/
|
|
35
|
+
export class ContractError extends NonRetryableError {
|
|
36
|
+
constructor(functionName, kind, condition) {
|
|
37
|
+
super(`Contract violated: ${functionName} ${kind} ${condition}`)
|
|
38
|
+
this.name = 'ContractError'
|
|
39
|
+
this.functionName = functionName
|
|
40
|
+
this.kind = kind
|
|
41
|
+
this.condition = condition
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/runtime/index.js
CHANGED
|
@@ -31,10 +31,12 @@ export {
|
|
|
31
31
|
} from './collections.js'
|
|
32
32
|
|
|
33
33
|
export { checkpoint } from './checkpoint.js'
|
|
34
|
+
export { toJson, fromJson } from './serialization.js'
|
|
35
|
+
export { defineType } from './tagged-unions.js'
|
|
34
36
|
export { inspect, formatInspectReport } from './inspect.js'
|
|
35
37
|
export { retry } from './retry.js'
|
|
36
38
|
export { traceNode } from './trace.js'
|
|
37
|
-
export { PipelineError, NonRetryableError } from './errors.js'
|
|
39
|
+
export { PipelineError, NonRetryableError, ContractError } from './errors.js'
|
|
38
40
|
export { Metrics, throughputOf } from './metrics.js'
|
|
39
41
|
export {
|
|
40
42
|
CheckpointStore,
|
|
@@ -51,6 +53,7 @@ export {
|
|
|
51
53
|
export {
|
|
52
54
|
defineEffect,
|
|
53
55
|
withHandlers,
|
|
56
|
+
withAuthorization,
|
|
54
57
|
performEffect,
|
|
55
58
|
describeEffect,
|
|
56
59
|
listEffects,
|
|
@@ -60,7 +63,12 @@ export {
|
|
|
60
63
|
replayHandlers,
|
|
61
64
|
UnhandledEffectError,
|
|
62
65
|
EffectHandlerError,
|
|
63
|
-
RecursiveEffectError
|
|
66
|
+
RecursiveEffectError,
|
|
67
|
+
EffectAuthorizationError,
|
|
68
|
+
NondeterministicEffectError,
|
|
69
|
+
deterministic,
|
|
70
|
+
now,
|
|
71
|
+
random
|
|
64
72
|
} from './effects.js'
|
|
65
73
|
export {
|
|
66
74
|
EffectLog,
|
|
@@ -72,4 +80,8 @@ export {
|
|
|
72
80
|
createMcpServer,
|
|
73
81
|
runMcpStdioServer
|
|
74
82
|
} from './mcp.js'
|
|
75
|
-
|
|
83
|
+
export {
|
|
84
|
+
A2AClient,
|
|
85
|
+
A2AError,
|
|
86
|
+
createA2AClient
|
|
87
|
+
} from './a2a.js'
|
package/src/runtime/mcp.js
CHANGED
|
@@ -6,64 +6,259 @@
|
|
|
6
6
|
|
|
7
7
|
import { createInterface } from 'node:readline'
|
|
8
8
|
|
|
9
|
+
export const MCP_PROTOCOL_VERSION = '2025-06-18'
|
|
10
|
+
|
|
11
|
+
function isObject(value) {
|
|
12
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function jsonRpcError(id, code, message) {
|
|
16
|
+
return {
|
|
17
|
+
jsonrpc: '2.0',
|
|
18
|
+
id,
|
|
19
|
+
error: { code, message }
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function validateSchema(value, schema, path = 'value') {
|
|
24
|
+
if (!isObject(schema)) return null
|
|
25
|
+
|
|
26
|
+
if (schema.enum && !schema.enum.some(item => JSON.stringify(item) === JSON.stringify(value))) {
|
|
27
|
+
return `${path} must be one of the allowed values`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (schema.type === 'object') {
|
|
31
|
+
if (!isObject(value)) return `${path} must be an object`
|
|
32
|
+
|
|
33
|
+
for (const name of schema.required ?? []) {
|
|
34
|
+
if (!Object.hasOwn(value, name)) return `${name} is required`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const properties = schema.properties ?? {}
|
|
38
|
+
for (const [name, propertyValue] of Object.entries(value)) {
|
|
39
|
+
if (!Object.hasOwn(properties, name)) {
|
|
40
|
+
if (schema.additionalProperties === false) {
|
|
41
|
+
return `${path}.${name} is not allowed`
|
|
42
|
+
}
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const error = validateSchema(propertyValue, properties[name], `${path}.${name}`)
|
|
47
|
+
if (error) return error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (schema.type === 'array') {
|
|
52
|
+
if (!Array.isArray(value)) return `${path} must be an array`
|
|
53
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
54
|
+
return `${path} must contain at least ${schema.minItems} items`
|
|
55
|
+
}
|
|
56
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
57
|
+
return `${path} must contain at most ${schema.maxItems} items`
|
|
58
|
+
}
|
|
59
|
+
if (schema.items) {
|
|
60
|
+
for (const [index, item] of value.entries()) {
|
|
61
|
+
const error = validateSchema(item, schema.items, `${path}[${index}]`)
|
|
62
|
+
if (error) return error
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (schema.type === 'string') {
|
|
68
|
+
if (typeof value !== 'string') return `${path} must be a string`
|
|
69
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
70
|
+
return `${path} must be at least ${schema.minLength} characters`
|
|
71
|
+
}
|
|
72
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength) {
|
|
73
|
+
return `${path} must be at most ${schema.maxLength} characters`
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (schema.type === 'number' && (typeof value !== 'number' || !Number.isFinite(value))) {
|
|
78
|
+
return `${path} must be a number`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (schema.type === 'integer' && (!Number.isInteger(value))) {
|
|
82
|
+
return `${path} must be an integer`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (schema.type === 'boolean' && typeof value !== 'boolean') {
|
|
86
|
+
return `${path} must be a boolean`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (schema.type === 'null' && value !== null) return `${path} must be null`
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeTool(name, tool) {
|
|
95
|
+
if (typeof tool === 'function') {
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
inputSchema: { type: 'object', additionalProperties: false },
|
|
99
|
+
handler: tool
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!isObject(tool) || typeof tool.handler !== 'function' || !isObject(tool.inputSchema)) {
|
|
104
|
+
throw new TypeError(`Tool "${name}" must define an inputSchema and handler`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
description: tool.description,
|
|
110
|
+
inputSchema: tool.inputSchema,
|
|
111
|
+
outputSchema: tool.outputSchema,
|
|
112
|
+
handler: tool.handler
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function toolDefinition(tool) {
|
|
117
|
+
const definition = {
|
|
118
|
+
name: tool.name,
|
|
119
|
+
inputSchema: tool.inputSchema
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (typeof tool.description === 'string') definition.description = tool.description
|
|
123
|
+
if (isObject(tool.outputSchema)) definition.outputSchema = tool.outputSchema
|
|
124
|
+
|
|
125
|
+
return definition
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toolError(error) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{
|
|
131
|
+
type: 'text',
|
|
132
|
+
text: error instanceof Error ? error.message : String(error)
|
|
133
|
+
}],
|
|
134
|
+
isError: true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function validateToolResult(result, tool) {
|
|
139
|
+
if (!isObject(result) || !Array.isArray(result.content)) {
|
|
140
|
+
return 'Tool results must contain a content array'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.content.some(content => !isObject(content) || typeof content.type !== 'string')) {
|
|
144
|
+
return 'Tool result content items must have a type'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (result.isError !== undefined && typeof result.isError !== 'boolean') {
|
|
148
|
+
return 'Tool result isError must be a boolean'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (result.structuredContent !== undefined && tool.outputSchema) {
|
|
152
|
+
return validateSchema(result.structuredContent, tool.outputSchema, 'structuredContent')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
9
158
|
export class McpServer {
|
|
10
|
-
constructor({
|
|
11
|
-
|
|
159
|
+
constructor({
|
|
160
|
+
tools = {},
|
|
161
|
+
name = 'JojoScript',
|
|
162
|
+
version = '1.0.0',
|
|
163
|
+
protocolVersion = MCP_PROTOCOL_VERSION
|
|
164
|
+
} = {}) {
|
|
165
|
+
this.tools = new Map(
|
|
166
|
+
Object.entries(tools).map(([toolName, tool]) => [toolName, normalizeTool(toolName, tool)])
|
|
167
|
+
)
|
|
168
|
+
this.serverInfo = { name, version }
|
|
169
|
+
this.protocolVersion = protocolVersion
|
|
170
|
+
this.initialized = false
|
|
12
171
|
}
|
|
13
172
|
|
|
14
173
|
async handleRequest(message) {
|
|
15
|
-
if (!message ||
|
|
16
|
-
|
|
174
|
+
if (!isObject(message) || message.jsonrpc !== '2.0' ||
|
|
175
|
+
typeof message.method !== 'string' || message.method.length === 0 ||
|
|
176
|
+
(message.params !== undefined && !isObject(message.params))) {
|
|
177
|
+
return jsonRpcError(null, -32600, 'Invalid Request')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const isNotification = !Object.hasOwn(message, 'id')
|
|
181
|
+
const id = isNotification ? null : message.id
|
|
182
|
+
|
|
183
|
+
if (!isNotification && id !== null && typeof id !== 'string' &&
|
|
184
|
+
(typeof id !== 'number' || !Number.isFinite(id))) {
|
|
185
|
+
return jsonRpcError(null, -32600, 'Invalid Request')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const respond = response => isNotification ? undefined : response
|
|
189
|
+
|
|
190
|
+
if (message.method === 'initialize') {
|
|
191
|
+
if (isNotification) return undefined
|
|
192
|
+
if (this.initialized) return respond(jsonRpcError(id, -32600, 'Server is already initialized'))
|
|
193
|
+
|
|
194
|
+
const requestedVersion = message.params?.protocolVersion
|
|
195
|
+
if (typeof requestedVersion !== 'string') {
|
|
196
|
+
return respond(jsonRpcError(id, -32602, 'protocolVersion is required'))
|
|
197
|
+
}
|
|
198
|
+
if (requestedVersion !== this.protocolVersion) {
|
|
199
|
+
return respond(jsonRpcError(id, -32602, `Unsupported protocol version: ${requestedVersion}`))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.initialized = true
|
|
203
|
+
return respond({
|
|
17
204
|
jsonrpc: '2.0',
|
|
18
|
-
id
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
205
|
+
id,
|
|
206
|
+
result: {
|
|
207
|
+
protocolVersion: this.protocolVersion,
|
|
208
|
+
capabilities: {
|
|
209
|
+
tools: { listChanged: false }
|
|
210
|
+
},
|
|
211
|
+
serverInfo: this.serverInfo
|
|
22
212
|
}
|
|
23
|
-
}
|
|
213
|
+
})
|
|
24
214
|
}
|
|
25
215
|
|
|
26
|
-
if (
|
|
216
|
+
if (isNotification) return undefined
|
|
217
|
+
if (!this.initialized) return jsonRpcError(id, -32600, 'Server is not initialized')
|
|
218
|
+
|
|
219
|
+
if (message.method === 'ping') {
|
|
220
|
+
return { jsonrpc: '2.0', id, result: {} }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (message.method === 'tools/list') {
|
|
27
224
|
return {
|
|
28
225
|
jsonrpc: '2.0',
|
|
29
|
-
id
|
|
30
|
-
|
|
31
|
-
code: -32601,
|
|
32
|
-
message: 'Method not found'
|
|
33
|
-
}
|
|
226
|
+
id,
|
|
227
|
+
result: { tools: [...this.tools.values()].map(toolDefinition) }
|
|
34
228
|
}
|
|
35
229
|
}
|
|
36
230
|
|
|
231
|
+
if (message.method !== 'tools/call') {
|
|
232
|
+
return jsonRpcError(id, -32601, 'Method not found')
|
|
233
|
+
}
|
|
234
|
+
|
|
37
235
|
const toolName = message.params?.name
|
|
38
|
-
|
|
236
|
+
if (typeof toolName !== 'string') {
|
|
237
|
+
return jsonRpcError(id, -32602, 'Tool name is required')
|
|
238
|
+
}
|
|
239
|
+
|
|
39
240
|
const tool = this.tools.get(toolName)
|
|
241
|
+
if (!tool) return jsonRpcError(id, -32602, `Unknown tool: ${toolName}`)
|
|
40
242
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
error: {
|
|
46
|
-
code: -32602,
|
|
47
|
-
message: `Unknown tool: ${toolName}`
|
|
48
|
-
}
|
|
49
|
-
}
|
|
243
|
+
const toolArgs = message.params.arguments ?? {}
|
|
244
|
+
const validationError = validateSchema(toolArgs, tool.inputSchema, 'arguments')
|
|
245
|
+
if (validationError) {
|
|
246
|
+
return jsonRpcError(id, -32602, `Invalid tool arguments: ${validationError}`)
|
|
50
247
|
}
|
|
51
248
|
|
|
52
249
|
try {
|
|
53
|
-
const result = await tool(toolArgs)
|
|
250
|
+
const result = await tool.handler(toolArgs)
|
|
251
|
+
const resultError = validateToolResult(result, tool)
|
|
54
252
|
return {
|
|
55
253
|
jsonrpc: '2.0',
|
|
56
|
-
id
|
|
57
|
-
result
|
|
254
|
+
id,
|
|
255
|
+
result: resultError ? toolError(new Error(resultError)) : result
|
|
58
256
|
}
|
|
59
257
|
} catch (error) {
|
|
60
258
|
return {
|
|
61
259
|
jsonrpc: '2.0',
|
|
62
|
-
id
|
|
63
|
-
|
|
64
|
-
code: -32603,
|
|
65
|
-
message: error instanceof Error ? error.message : String(error)
|
|
66
|
-
}
|
|
260
|
+
id,
|
|
261
|
+
result: toolError(error)
|
|
67
262
|
}
|
|
68
263
|
}
|
|
69
264
|
}
|
|
@@ -84,9 +279,16 @@ export async function runMcpStdioServer(server, options = {}) {
|
|
|
84
279
|
for await (const line of lines) {
|
|
85
280
|
if (!line.trim()) continue
|
|
86
281
|
|
|
87
|
-
|
|
282
|
+
let message
|
|
283
|
+
try {
|
|
284
|
+
message = JSON.parse(line)
|
|
285
|
+
} catch {
|
|
286
|
+
output.write(`${JSON.stringify(jsonRpcError(null, -32700, 'Parse error'))}\n`)
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
|
|
88
290
|
const response = await server.handleRequest(message)
|
|
89
|
-
output.write(`${JSON.stringify(response)}\n`)
|
|
291
|
+
if (response) output.write(`${JSON.stringify(response)}\n`)
|
|
90
292
|
}
|
|
91
293
|
|
|
92
294
|
return 0
|