@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.
@@ -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, JSON.stringify(checkpoint), 'utf8')
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 raw
115
+ let checkpoint
97
116
  try {
98
- raw = fs.readFileSync(file, 'utf8')
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
  }
@@ -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
- const frame = currentFrames().find(f => 'PARALLEL' in f.handlers)
221
- return frame ? frame.handlers.PARALLEL.handle : null
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
  /*
@@ -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
+ }
@@ -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'
@@ -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({ tools = {} } = {}) {
11
- this.tools = new Map(Object.entries(tools))
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 || typeof message !== 'object') {
16
- return {
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: null,
19
- error: {
20
- code: -32600,
21
- message: 'Invalid Request'
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 (message.method !== 'tools/call') {
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: message.id ?? null,
30
- error: {
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
- const toolArgs = message.params?.arguments ?? {}
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
- if (!tool || typeof tool !== 'function') {
42
- return {
43
- jsonrpc: '2.0',
44
- id: message.id ?? null,
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: message.id ?? null,
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: message.id ?? null,
63
- error: {
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
- const message = JSON.parse(line)
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