@panagos/jojoscript 0.9.3 → 0.10.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/README.md CHANGED
@@ -1,13 +1,12 @@
1
- # JojoScript 0.9.3
1
+ # JojoScript 0.10.1
2
2
 
3
- JojoScript is a small JavaScript-compatible language and runtime for
4
- expressive, composable pipelines. It adds lightweight syntax for declarations,
5
- functions, pattern matching, type annotations, and pipelines while preserving
6
- ordinary JavaScript. Its compiler uses a lightweight lexer and source
7
- transformations rather than a full AST, keeping the toolchain compact. The
8
- project also provides lazy and async collection operators, checkpointing,
9
- retries, metrics, pipeline graphing, CLI workflows, effect handlers, MCP
10
- support, and VS Code editor support.
3
+ JojoScript is a lightweight language and runtime for data pipelines and agent
4
+ workflows, built on JavaScript.
5
+
6
+ It combines expressive pipeline syntax, lazy data processing, resumable
7
+ execution and effect handling with MCP tool serving and A2A agent
8
+ communication. It compiles to ordinary JavaScript and keeps the familiar
9
+ JavaScript ecosystem accessible, without requiring a heavyweight framework.
11
10
 
12
11
  See [CHANGELOG.md](CHANGELOG.md) for release history and
13
12
  [example/](example/) for a complete runnable application that exercises
@@ -592,6 +591,51 @@ declared itself `deterministic`, and the declaring effect's `sideEffecting`/
592
591
  `replayable` metadata (from `effect name(params)`'s options, or defaults —
593
592
  `sideEffecting: true`, `replayable: false`).
594
593
 
594
+ ### Effect authorization
595
+
596
+ Applications can place a capability policy around a `handle` block with
597
+ `withAuthorization(policy, fn)`. The policy receives the declared effect
598
+ name, a shallow copy of its arguments, declared effect metadata, and the
599
+ current execution context. It must explicitly return `true` or
600
+ `{ allowed: true }` for the handler to run.
601
+
602
+ ```js
603
+ import { withAuthorization } from "@panagos/jojoscript/runtime"
604
+
605
+ const result = await withAuthorization(
606
+ invocation => invocation.name === "readTender"
607
+ ? true
608
+ : { allowed: false, reason: "Capability is not granted" },
609
+ () => withHandlers({
610
+ readTender: id => ({ id }),
611
+ approveOffer: offer => approve(offer)
612
+ }, () => program())
613
+ )
614
+ ```
615
+
616
+ Authorization is opt-in; programs that do not use `withAuthorization` retain
617
+ their existing unrestricted effect behavior. When enabled, every active
618
+ authorization scope must allow each resolved effect invocation before its
619
+ handler starts. This means an inner `handle` block cannot bypass an outer
620
+ policy. A missing, malformed, denied, or failed policy decision throws
621
+ `EffectAuthorizationError`; denial is non-retryable, so `retry()` does not
622
+ repeat it. An unhandled effect still throws `UnhandledEffectError` before any
623
+ authorization policy runs.
624
+
625
+ Authorization is evaluated before normal handlers, recording wrappers, and
626
+ replay wrappers. Replay therefore does not bypass a policy, and a checkpoint
627
+ resume only avoids authorization when it restores a completed checkpoint
628
+ without invoking an upstream effect at all. Policies do not record arguments
629
+ or decisions automatically, and this mechanism is not a sandbox: ordinary
630
+ JavaScript and operations that do not pass through a declared effect are
631
+ outside its enforcement boundary. Do not derive permission from model or
632
+ remote-agent output; make policy decisions in trusted application code.
633
+
634
+ See
635
+ [example/src/effects/effect-authorization.jojo](example/src/effects/effect-authorization.jojo)
636
+ for a procurement workflow that permits reads and supplier lookup while
637
+ blocking invitations and offer approval before their handlers run.
638
+
595
639
  ### Effects and checkpoint/resume
596
640
 
597
641
  Consider:
@@ -629,6 +673,13 @@ actually safe. See
629
673
  [example/src/effects/checkpoint-resume.jojo](example/src/effects/checkpoint-resume.jojo)
630
674
  and `test/effects-checkpoint.test.js` for both cases exercised end to end.
631
675
 
676
+ File-backed checkpoint records are validated when they are loaded: their
677
+ pipeline ID and checkpoint ID must match the requested checkpoint, `items`
678
+ must be an array, and a present `itemCount` must match the saved item count.
679
+ Malformed records fail clearly rather than resuming with untrusted data. See
680
+ [example/src/effects/checkpoint-integrity.jojo](example/src/effects/checkpoint-integrity.jojo)
681
+ for a compiled pipeline test of this behavior.
682
+
632
683
  ### Record/replay
633
684
 
634
685
  A handler map can be wrapped to record every call (arguments + result) to
@@ -749,18 +800,121 @@ JavaScript functions.
749
800
  import { createMcpServer, runMcpStdioServer } from "jojoscript/runtime"
750
801
 
751
802
  const server = createMcpServer({
803
+ name: "example-server",
804
+ version: "1.0.0",
752
805
  tools: {
753
- echo: args => ({ content: [{ type: "text", text: args.value }] })
806
+ echo: {
807
+ description: "Returns the supplied value.",
808
+ inputSchema: {
809
+ type: "object",
810
+ properties: {
811
+ value: { type: "string" }
812
+ },
813
+ required: ["value"],
814
+ additionalProperties: false
815
+ },
816
+ handler: args => ({
817
+ content: [{ type: "text", text: args.value }]
818
+ })
819
+ }
754
820
  }
755
821
  })
756
822
 
757
823
  await runMcpStdioServer(server)
758
824
  ```
759
825
 
760
- The server accepts `tools/call` requests and returns JSON-RPC responses on
761
- standard output. It is intentionally minimal: supporting tool registration,
762
- request dispatch, structured errors, and the stdin/stdout transport without any
763
- external NPM packages.
826
+ The server supports the `initialize` handshake (protocol version
827
+ `2025-06-18`), `notifications/initialized`, `ping`, `tools/list`, and
828
+ `tools/call` over newline-delimited JSON-RPC on standard input/output. Clients
829
+ must initialize before making requests. Tools declare an `inputSchema`; calls
830
+ that do not conform are rejected before the handler runs. Handlers return a
831
+ `CallToolResult` object with a `content` array and may return
832
+ `structuredContent` when their tool declares an `outputSchema`. Thrown handler
833
+ errors are returned as `CallToolResult` values with `isError: true`.
834
+
835
+ It is intentionally minimal: supporting tool registration, schema validation,
836
+ request dispatch, structured errors, and the stdin/stdout transport without
837
+ external NPM packages. It remains an MCP-style runtime until its supported
838
+ protocol surface has been verified against MCP conformance tests and real
839
+ clients.
840
+
841
+ ### A2A client
842
+
843
+ JojoScript provides a dependency-free A2A 1.0 JSON-RPC client. It discovers a
844
+ remote agent through `/.well-known/agent-card.json`, selects its advertised
845
+ JSON-RPC interface, sends messages, and retrieves/polls task results.
846
+
847
+ ```js
848
+ import { createA2AClient } from "jojoscript/runtime"
849
+
850
+ const agent = createA2AClient("https://agent.example")
851
+ await agent.discover()
852
+
853
+ const task = await agent.sendMessage({
854
+ role: "ROLE_USER",
855
+ parts: [{ kind: "data", data: { record: { id: "item-1" } } }],
856
+ metadata: { requestId: "request-1" }
857
+ })
858
+ const result = await agent.waitForTask(task, {
859
+ pollIntervalMs: 500,
860
+ maxPollIntervalMs: 5000,
861
+ timeoutMs: 30000
862
+ })
863
+ ```
864
+
865
+ `sendMessage`, `getTask`, and `cancelTask` map to the A2A `SendMessage`,
866
+ `GetTask`, and `CancelTask` operations. Messages, task IDs, context IDs,
867
+ metadata, typed parts, and artifacts are returned unchanged. `waitForTask`
868
+ accepts either a task ID or an in-progress task, then returns the final task
869
+ for every terminal state—including failed, rejected, canceled,
870
+ input-required, and authentication-required tasks—so callers must inspect the
871
+ state before treating it as successful.
872
+
873
+ Waiting uses a five-minute default timeout, configurable polling and bounded
874
+ backoff (`pollIntervalMs`, `maxPollIntervalMs`, and `backoffMultiplier`), and
875
+ an optional `AbortSignal`. It rejects with an `A2AError` whose `code` clearly
876
+ identifies timeouts, cancellation, network/HTTP failures, invalid discovery or
877
+ JSON-RPC responses, and remote-agent errors. Pass `bearerToken`, custom
878
+ headers, or an alternate `fetch` function in the client options. Discovery
879
+ validates advertised JSON-RPC endpoints and only permits credential-free HTTP
880
+ or HTTPS URLs.
881
+
882
+ JojoScript intentionally provides a lightweight A2A client rather than an
883
+ A2A SDK or agent server. Its protocol behavior is covered by local fixtures;
884
+ real-agent interoperability remains a separate integration concern.
885
+
886
+ ### Resumable MCP-to-A2A enrichment
887
+
888
+ [example/src/application/mcp-a2a-resumable-enrichment.jojo](example/src/application/mcp-a2a-resumable-enrichment.jojo)
889
+ combines the MCP server, A2A client, asynchronous pipelines, and checkpoints
890
+ in one tool. `enrich_records` accepts a batch of `{ id, title }` records,
891
+ delegates enrichment to an A2A agent, checkpoints a completed batch, and
892
+ returns both text content and structured records to the MCP client.
893
+
894
+ ```js
895
+ import {
896
+ FileCheckpointStore,
897
+ runMcpStdioServer
898
+ } from "jojoscript/runtime"
899
+ import { createEnrichmentServer } from "./application/mcp-a2a-resumable-enrichment.js"
900
+
901
+ const server = createEnrichmentServer({
902
+ agentUrl: process.env.A2A_AGENT_URL,
903
+ checkpointStore: new FileCheckpointStore(".jojo-checkpoints")
904
+ })
905
+
906
+ await runMcpStdioServer(server)
907
+ ```
908
+
909
+ Use a stable `jobId` for retries of the same batch. The checkpoint is saved
910
+ only after every record has been enriched, so retrying a completed batch uses
911
+ the saved records without calling the A2A agent again. This is **at-least-once
912
+ delegation**, not exactly-once: if an A2A agent completes a record and the
913
+ process fails before the enclosing batch checkpoint commits, the retry invokes
914
+ that agent again. Remote agents should therefore use idempotency keys derived
915
+ from the record and job ID when duplicate work is unsafe. The matching
916
+ integration test demonstrates both the duplicate-before-commit case and the
917
+ no-repeat-after-commit case.
764
918
 
765
919
  ## Example
766
920
 
@@ -777,6 +931,10 @@ running the same program with multiple handlers, deterministic testing,
777
931
  `parallel()` as an effect, effect visibility in the execution plan, and the
778
932
  checkpoint/resume interaction.
779
933
 
934
+ [example/src/application/a2a-enrichment.jojo](example/src/application/a2a-enrichment.jojo)
935
+ demonstrates enriching a record through A2A discovery, message dispatch, and
936
+ task polling against a local test agent.
937
+
780
938
  ```bash
781
939
  cd example
782
940
  npm install
@@ -849,4 +1007,3 @@ resulting `.vsix` locally.
849
1007
  mentioned as a long-term direction (`IO`, `HTTP`, `FILE`, `DATABASE`,
850
1008
  `SLEEP`, `TIME`, `RANDOM`, `CHECKPOINT`) are not implemented — user-defined
851
1009
  `effect` declarations cover the same need today.
852
-
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@panagos/jojoscript",
3
- "version": "0.9.3",
4
- "description": "A tiny JavaScript-compatible language layer with Jojo syntax and pipelines.",
3
+ "version": "0.10.1",
4
+ "description": "JojoScript is a lightweight language and runtime for data pipelines and agent workflows, built on JavaScript.",
5
5
  "license": "Unlicense",
6
6
  "type": "module",
7
7
  "bin": {
@@ -0,0 +1,322 @@
1
+ const TERMINAL_TASK_STATES = new Set([
2
+ 'TASK_STATE_COMPLETED',
3
+ 'TASK_STATE_FAILED',
4
+ 'TASK_STATE_CANCELED',
5
+ 'TASK_STATE_REJECTED',
6
+ 'TASK_STATE_INPUT_REQUIRED',
7
+ 'TASK_STATE_AUTH_REQUIRED'
8
+ ])
9
+
10
+ function isObject(value) {
11
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
12
+ }
13
+
14
+ function taskIdFrom(task) {
15
+ const id = typeof task === 'string' ? task : task?.id
16
+ if (typeof id !== 'string' || !id) {
17
+ throw new TypeError('An A2A task id is required')
18
+ }
19
+ return id
20
+ }
21
+
22
+ function abortError(reason) {
23
+ if (reason instanceof A2AError) return reason
24
+ return new A2AError('A2A operation was aborted', {
25
+ code: 'ABORTED',
26
+ cause: reason
27
+ })
28
+ }
29
+
30
+ function throwIfAborted(signal) {
31
+ if (signal?.aborted) throw abortError(signal.reason)
32
+ }
33
+
34
+ function waitForDelay(delayMs, signal) {
35
+ throwIfAborted(signal)
36
+
37
+ return new Promise((resolve, reject) => {
38
+ const timer = setTimeout(done, delayMs)
39
+ function done() {
40
+ signal?.removeEventListener('abort', onAbort)
41
+ resolve()
42
+ }
43
+ function onAbort() {
44
+ clearTimeout(timer)
45
+ signal.removeEventListener('abort', onAbort)
46
+ reject(abortError(signal.reason))
47
+ }
48
+ signal?.addEventListener('abort', onAbort, { once: true })
49
+ })
50
+ }
51
+
52
+ function createWaitSignal(signal, timeoutMs, taskId) {
53
+ const controller = new AbortController()
54
+ const abort = () => controller.abort(signal.reason)
55
+ const timer = setTimeout(() => {
56
+ controller.abort(new A2AError(`Timed out waiting for A2A task "${taskId}"`, {
57
+ code: 'TIMEOUT'
58
+ }))
59
+ }, timeoutMs)
60
+
61
+ signal?.addEventListener('abort', abort, { once: true })
62
+ if (signal?.aborted) abort()
63
+
64
+ return {
65
+ signal: controller.signal,
66
+ cleanup() {
67
+ clearTimeout(timer)
68
+ signal?.removeEventListener('abort', abort)
69
+ }
70
+ }
71
+ }
72
+
73
+ export class A2AError extends Error {
74
+ constructor(message, options = {}) {
75
+ super(message, options.cause === undefined ? undefined : { cause: options.cause })
76
+ this.name = 'A2AError'
77
+ this.code = options.code
78
+ this.data = options.data
79
+ this.status = options.status
80
+ this.remoteCode = options.remoteCode
81
+ }
82
+ }
83
+
84
+ export class A2AClient {
85
+ constructor(agentUrl, options = {}) {
86
+ if (typeof agentUrl !== 'string' || !agentUrl) {
87
+ throw new TypeError('An A2A agent URL is required')
88
+ }
89
+ if (options.headers !== undefined && !isObject(options.headers)) {
90
+ throw new TypeError('A2A headers must be an object')
91
+ }
92
+ if (options.bearerToken !== undefined &&
93
+ (typeof options.bearerToken !== 'string' || !options.bearerToken)) {
94
+ throw new TypeError('A2A bearerToken must be a non-empty string')
95
+ }
96
+
97
+ this.agentUrl = new URL(agentUrl)
98
+ this.fetch = options.fetch ?? globalThis.fetch
99
+ this.headers = {
100
+ ...(options.bearerToken ? { authorization: `Bearer ${options.bearerToken}` } : {}),
101
+ ...(options.headers ?? {})
102
+ }
103
+ this.agentCard = null
104
+ this.endpoint = null
105
+ this.requestId = 0
106
+
107
+ if (typeof this.fetch !== 'function') {
108
+ throw new TypeError('A fetch implementation is required')
109
+ }
110
+ }
111
+
112
+ async discover(options = {}) {
113
+ throwIfAborted(options.signal)
114
+
115
+ const cardUrl = new URL('/.well-known/agent-card.json', this.agentUrl)
116
+ const response = await this.#fetch(cardUrl.href, {
117
+ method: 'GET',
118
+ headers: this.headers,
119
+ signal: options.signal
120
+ }, 'Agent Card request')
121
+ if (!response.ok) {
122
+ throw new A2AError(`A2A Agent Card request failed with HTTP ${response.status}`, {
123
+ code: 'DISCOVERY_HTTP_ERROR',
124
+ status: response.status
125
+ })
126
+ }
127
+
128
+ const card = await this.#readJson(response, 'A2A Agent Card', 'INVALID_AGENT_CARD')
129
+ throwIfAborted(options.signal)
130
+ if (!isObject(card)) {
131
+ throw new A2AError('A2A Agent Card response must be a JSON object', {
132
+ code: 'INVALID_AGENT_CARD'
133
+ })
134
+ }
135
+
136
+ const jsonRpcInterface = card.supportedInterfaces?.find(
137
+ item => isObject(item) && item.protocolBinding === 'JSONRPC' && typeof item.url === 'string'
138
+ )
139
+ const endpoint = jsonRpcInterface?.url
140
+ ?? (card.preferredTransport === 'JSONRPC' ? card.url : null)
141
+
142
+ if (typeof endpoint !== 'string' || !endpoint) {
143
+ throw new A2AError('A2A Agent Card does not advertise a JSON-RPC interface', {
144
+ code: 'UNSUPPORTED_INTERFACE'
145
+ })
146
+ }
147
+
148
+ let endpointUrl
149
+ try {
150
+ endpointUrl = new URL(endpoint, cardUrl)
151
+ } catch (error) {
152
+ throw new A2AError('A2A Agent Card contains an invalid JSON-RPC endpoint', {
153
+ code: 'INVALID_AGENT_CARD',
154
+ cause: error
155
+ })
156
+ }
157
+ if (!['http:', 'https:'].includes(endpointUrl.protocol) ||
158
+ endpointUrl.username || endpointUrl.password) {
159
+ throw new A2AError('A2A Agent Card contains an unsafe JSON-RPC endpoint', {
160
+ code: 'INVALID_AGENT_CARD'
161
+ })
162
+ }
163
+
164
+ this.agentCard = card
165
+ this.endpoint = endpointUrl
166
+ return card
167
+ }
168
+
169
+ async sendMessage(message, configuration, options = {}) {
170
+ if (!isObject(message)) {
171
+ throw new TypeError('A2A message must be an object')
172
+ }
173
+
174
+ const params = { message }
175
+ if (configuration !== undefined) params.configuration = configuration
176
+ return this.#request('SendMessage', params, options)
177
+ }
178
+
179
+ async getTask(task, options = {}) {
180
+ const params = typeof task === 'string' ? { id: task } : task
181
+ taskIdFrom(params)
182
+ return this.#request('GetTask', params, options)
183
+ }
184
+
185
+ async cancelTask(task, options = {}) {
186
+ const id = taskIdFrom(task)
187
+ return this.#request('CancelTask', { id }, options)
188
+ }
189
+
190
+ async waitForTask(taskOrId, options = {}) {
191
+ const taskId = taskIdFrom(taskOrId)
192
+ const pollIntervalMs = options.pollIntervalMs ?? 1000
193
+ const maxPollIntervalMs = options.maxPollIntervalMs ?? 30000
194
+ const backoffMultiplier = options.backoffMultiplier ?? 1.5
195
+ const timeoutMs = options.timeoutMs ?? 300000
196
+
197
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs < 0) {
198
+ throw new TypeError('pollIntervalMs must be a non-negative number')
199
+ }
200
+ if (!Number.isFinite(maxPollIntervalMs) || maxPollIntervalMs < pollIntervalMs) {
201
+ throw new TypeError('maxPollIntervalMs must be at least pollIntervalMs')
202
+ }
203
+ if (!Number.isFinite(backoffMultiplier) || backoffMultiplier < 1) {
204
+ throw new TypeError('backoffMultiplier must be at least 1')
205
+ }
206
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
207
+ throw new TypeError('timeoutMs must be a positive number')
208
+ }
209
+
210
+ const waiting = createWaitSignal(options.signal, timeoutMs, taskId)
211
+ try {
212
+ let task = typeof taskOrId === 'string'
213
+ ? await this.getTask(taskId, { signal: waiting.signal })
214
+ : taskOrId
215
+ let intervalMs = pollIntervalMs
216
+
217
+ while (!TERMINAL_TASK_STATES.has(task?.status?.state)) {
218
+ if (!isObject(task) || !isObject(task.status) || typeof task.status.state !== 'string') {
219
+ throw new A2AError(`A2A task "${taskId}" has an invalid status`, {
220
+ code: 'INVALID_TASK'
221
+ })
222
+ }
223
+
224
+ await waitForDelay(intervalMs, waiting.signal)
225
+ task = await this.getTask(taskId, { signal: waiting.signal })
226
+ intervalMs = Math.min(maxPollIntervalMs, intervalMs * backoffMultiplier)
227
+ }
228
+
229
+ return task
230
+ } finally {
231
+ waiting.cleanup()
232
+ }
233
+ }
234
+
235
+ async #fetch(url, options, description) {
236
+ try {
237
+ const response = await this.fetch(url, options)
238
+ throwIfAborted(options.signal)
239
+ return response
240
+ } catch (error) {
241
+ if (options.signal?.aborted) throw abortError(options.signal.reason)
242
+ if (error instanceof A2AError) throw error
243
+ throw new A2AError(`A2A ${description} failed`, {
244
+ code: 'NETWORK_ERROR',
245
+ cause: error
246
+ })
247
+ }
248
+ }
249
+
250
+ async #readJson(response, description, code) {
251
+ try {
252
+ return await response.json()
253
+ } catch (error) {
254
+ throw new A2AError(`${description} response is not valid JSON`, {
255
+ code,
256
+ cause: error
257
+ })
258
+ }
259
+ }
260
+
261
+ async #request(method, params, options = {}) {
262
+ if (!this.endpoint) {
263
+ throw new A2AError('Call discover() before making A2A requests', {
264
+ code: 'NOT_DISCOVERED'
265
+ })
266
+ }
267
+ throwIfAborted(options.signal)
268
+
269
+ const requestId = ++this.requestId
270
+ const response = await this.#fetch(this.endpoint.href, {
271
+ method: 'POST',
272
+ headers: {
273
+ 'content-type': 'application/json',
274
+ ...this.headers
275
+ },
276
+ body: JSON.stringify({
277
+ jsonrpc: '2.0',
278
+ id: requestId,
279
+ method,
280
+ params
281
+ }),
282
+ signal: options.signal
283
+ }, `${method} request`)
284
+ if (!response.ok) {
285
+ throw new A2AError(`A2A ${method} request failed with HTTP ${response.status}`, {
286
+ code: 'HTTP_ERROR',
287
+ status: response.status
288
+ })
289
+ }
290
+
291
+ const payload = await this.#readJson(response, `A2A ${method}`, 'INVALID_RPC_RESPONSE')
292
+ throwIfAborted(options.signal)
293
+ if (
294
+ !isObject(payload)
295
+ || payload.jsonrpc !== '2.0'
296
+ || payload.id !== requestId
297
+ || (!Object.hasOwn(payload, 'result') && !Object.hasOwn(payload, 'error'))
298
+ ) {
299
+ throw new A2AError(`Invalid JSON-RPC response for A2A method "${method}"`, {
300
+ code: 'INVALID_RPC_RESPONSE'
301
+ })
302
+ }
303
+ if (payload.error) {
304
+ if (!isObject(payload.error)) {
305
+ throw new A2AError(`Invalid JSON-RPC error for A2A method "${method}"`, {
306
+ code: 'INVALID_RPC_RESPONSE'
307
+ })
308
+ }
309
+ throw new A2AError(payload.error.message ?? 'A2A JSON-RPC request failed', {
310
+ code: 'REMOTE_ERROR',
311
+ remoteCode: payload.error.code,
312
+ data: payload.error.data
313
+ })
314
+ }
315
+
316
+ return payload.result
317
+ }
318
+ }
319
+
320
+ export function createA2AClient(agentUrl, options = {}) {
321
+ return new A2AClient(agentUrl, options)
322
+ }
@@ -29,6 +29,23 @@ function key(pipelineId, checkpointId) {
29
29
  return `${pipelineId}::${checkpointId}`
30
30
  }
31
31
 
32
+ function validateCheckpoint(checkpoint, pipelineId, checkpointId) {
33
+ const invalid = reason => {
34
+ throw new Error(`Invalid checkpoint "${checkpointId}": ${reason}`)
35
+ }
36
+
37
+ if (!checkpoint || typeof checkpoint !== 'object' || Array.isArray(checkpoint)) {
38
+ invalid('expected an object')
39
+ }
40
+ if (checkpoint.pipelineId !== pipelineId) invalid('pipelineId does not match')
41
+ if (checkpoint.checkpointId !== checkpointId) invalid('checkpointId does not match')
42
+ if (!Array.isArray(checkpoint.items)) invalid('items must be an array')
43
+ if (checkpoint.itemCount !== undefined &&
44
+ (!Number.isInteger(checkpoint.itemCount) || checkpoint.itemCount !== checkpoint.items.length)) {
45
+ invalid('itemCount does not match items')
46
+ }
47
+ }
48
+
32
49
  /*
33
50
  * In-memory store. Useful for tests and for short-lived processes where
34
51
  * durability across restarts is not required.
@@ -93,13 +110,15 @@ export class FileCheckpointStore extends CheckpointStore {
93
110
  const file = this.filePath(pipelineId, checkpointId)
94
111
  if (!fs.existsSync(file)) throw new Error(`Checkpoint not found: ${checkpointId}`)
95
112
 
96
- let raw
113
+ let checkpoint
97
114
  try {
98
- raw = fs.readFileSync(file, 'utf8')
99
- return JSON.parse(raw)
115
+ checkpoint = JSON.parse(fs.readFileSync(file, 'utf8'))
100
116
  } catch (error) {
101
117
  throw new Error(`Corrupted checkpoint "${checkpointId}": ${error.message}`)
102
118
  }
119
+
120
+ validateCheckpoint(checkpoint, pipelineId, checkpointId)
121
+ return checkpoint
103
122
  }
104
123
 
105
124
  async exists(pipelineId, checkpointId) {
@@ -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,7 @@ 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()
26
28
 
27
29
  /*
28
30
  * name -> { name, params, sideEffecting, replayable, builtin? }.
@@ -57,6 +59,17 @@ export class RecursiveEffectError extends Error {
57
59
  }
58
60
  }
59
61
 
62
+ export class EffectAuthorizationError extends Error {
63
+ constructor(name, reason, options = {}) {
64
+ const prefix = options.failed ? 'Effect authorization failed' : 'Effect authorization denied'
65
+ super(`${prefix} for "${name}": ${reason}`, { cause: options.cause })
66
+ this.name = 'EffectAuthorizationError'
67
+ this.effectName = name
68
+ this.reason = reason
69
+ this.retryable = false
70
+ }
71
+ }
72
+
60
73
  /*
61
74
  * Declares a named effect and returns the callable used to *perform* it.
62
75
  * `options.sideEffecting` (default true) and `options.replayable` (default
@@ -109,6 +122,10 @@ function currentFrames() {
109
122
  return handlerStorage.getStore() ?? []
110
123
  }
111
124
 
125
+ function currentAuthorizationPolicies() {
126
+ return authorizationStorage.getStore() ?? []
127
+ }
128
+
112
129
  /*
113
130
  * Installs `handlers` (an object of `name -> handlerFn | { handle,
114
131
  * deterministic }`) for the dynamic extent of `fn`, then restores whatever
@@ -141,6 +158,51 @@ export function withHandlers(handlers, fn) {
141
158
  return handlerStorage.run(frames, fn)
142
159
  }
143
160
 
161
+ /*
162
+ * Installs a capability policy for the dynamic extent of `fn`. Nested
163
+ * policies compose restrictively: every active policy must explicitly allow
164
+ * an invocation, so an inner handler or policy cannot bypass an outer
165
+ * boundary.
166
+ */
167
+ export function withAuthorization(policy, fn) {
168
+ if (typeof policy !== 'function') {
169
+ throw new TypeError('withAuthorization requires an authorization function')
170
+ }
171
+
172
+ return authorizationStorage.run([...currentAuthorizationPolicies(), policy], fn)
173
+ }
174
+
175
+ async function authorizeEffect(name, args) {
176
+ const policies = currentAuthorizationPolicies()
177
+ if (policies.length === 0) return
178
+
179
+ const invocation = {
180
+ name,
181
+ args: [...args],
182
+ effect: effectRegistry.get(name) ?? null,
183
+ context: getCurrentContext() ?? null
184
+ }
185
+
186
+ for (const policy of policies) {
187
+ let decision
188
+ try {
189
+ decision = await policy(invocation)
190
+ } catch (error) {
191
+ throw new EffectAuthorizationError(name, `authorization callback failed: ${error?.message ?? error}`, {
192
+ failed: true,
193
+ cause: error
194
+ })
195
+ }
196
+
197
+ if (decision === true || decision?.allowed === true) continue
198
+
199
+ const reason = typeof decision?.reason === 'string'
200
+ ? decision.reason
201
+ : 'policy did not explicitly allow this invocation'
202
+ throw new EffectAuthorizationError(name, reason)
203
+ }
204
+ }
205
+
144
206
  /*
145
207
  * Resolves and invokes the effect named `name` against the innermost
146
208
  * handler that declares it. Always returns a Promise (even for a
@@ -159,6 +221,8 @@ export async function performEffect(name, args) {
159
221
  throw new UnhandledEffectError(name)
160
222
  }
161
223
 
224
+ await authorizeEffect(name, args)
225
+
162
226
  const entry = frame.handlers[name]
163
227
  const nextInFlight = new Set(inFlight)
164
228
  nextInFlight.add(name)
@@ -51,6 +51,7 @@ export {
51
51
  export {
52
52
  defineEffect,
53
53
  withHandlers,
54
+ withAuthorization,
54
55
  performEffect,
55
56
  describeEffect,
56
57
  listEffects,
@@ -60,7 +61,8 @@ export {
60
61
  replayHandlers,
61
62
  UnhandledEffectError,
62
63
  EffectHandlerError,
63
- RecursiveEffectError
64
+ RecursiveEffectError,
65
+ EffectAuthorizationError
64
66
  } from './effects.js'
65
67
  export {
66
68
  EffectLog,
@@ -72,4 +74,8 @@ export {
72
74
  createMcpServer,
73
75
  runMcpStdioServer
74
76
  } from './mcp.js'
75
-
77
+ export {
78
+ A2AClient,
79
+ A2AError,
80
+ createA2AClient
81
+ } 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 = '0.10.1',
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