@panagos/jojoscript 0.9.1 → 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 +190 -10
- package/package.json +2 -2
- package/src/pipeline-parser.js +8 -0
- package/src/runtime/a2a.js +322 -0
- package/src/runtime/checkpoint-store.js +22 -3
- package/src/runtime/effects.js +64 -0
- package/src/runtime/index.js +13 -2
- package/src/runtime/mcp.js +295 -0
package/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
# JojoScript 0.
|
|
1
|
+
# JojoScript 0.10.1
|
|
2
2
|
|
|
3
|
-
JojoScript is a
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
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
|
|
@@ -739,6 +790,132 @@ never through `|>`) and also uses pipeline syntax with overlapping names,
|
|
|
739
790
|
the compiler merges the auto-imported names into the existing import
|
|
740
791
|
instead of emitting a second, colliding import declaration.
|
|
741
792
|
|
|
793
|
+
### MCP support
|
|
794
|
+
|
|
795
|
+
JojoScript includes a small MCP-style JSON-RPC runtime for Node.js. It does
|
|
796
|
+
not add MCP-specific language syntax; tool implementations remain ordinary
|
|
797
|
+
JavaScript functions.
|
|
798
|
+
|
|
799
|
+
```js
|
|
800
|
+
import { createMcpServer, runMcpStdioServer } from "jojoscript/runtime"
|
|
801
|
+
|
|
802
|
+
const server = createMcpServer({
|
|
803
|
+
name: "example-server",
|
|
804
|
+
version: "1.0.0",
|
|
805
|
+
tools: {
|
|
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
|
+
}
|
|
820
|
+
}
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
await runMcpStdioServer(server)
|
|
824
|
+
```
|
|
825
|
+
|
|
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.
|
|
918
|
+
|
|
742
919
|
## Example
|
|
743
920
|
|
|
744
921
|
See [example/](example/). It is a complete runnable project covering every
|
|
@@ -754,6 +931,10 @@ running the same program with multiple handlers, deterministic testing,
|
|
|
754
931
|
`parallel()` as an effect, effect visibility in the execution plan, and the
|
|
755
932
|
checkpoint/resume interaction.
|
|
756
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
|
+
|
|
757
938
|
```bash
|
|
758
939
|
cd example
|
|
759
940
|
npm install
|
|
@@ -826,4 +1007,3 @@ resulting `.vsix` locally.
|
|
|
826
1007
|
mentioned as a long-term direction (`IO`, `HTTP`, `FILE`, `DATABASE`,
|
|
827
1008
|
`SLEEP`, `TIME`, `RANDOM`, `CHECKPOINT`) are not implemented — user-defined
|
|
828
1009
|
`effect` declarations cover the same need today.
|
|
829
|
-
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panagos/jojoscript",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
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": {
|
package/src/pipeline-parser.js
CHANGED
|
@@ -327,6 +327,14 @@ export function emitPipeline(pipeline, options = {}) {
|
|
|
327
327
|
continue
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
if (stage.name === 'pass') {
|
|
331
|
+
if (stage.args.length === 0) continue
|
|
332
|
+
if (stage.args.length === 1 && stage.args[0] === '_') {
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
throw new SyntaxError('the `pass` pipeline stage takes no arguments or a single `_` placeholder')
|
|
336
|
+
}
|
|
337
|
+
|
|
330
338
|
if (stage.name.startsWith('.')) {
|
|
331
339
|
const args = stage.args.slice()
|
|
332
340
|
const placeholderIndex = args.indexOf('_')
|
|
@@ -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
|
|
113
|
+
let checkpoint
|
|
97
114
|
try {
|
|
98
|
-
|
|
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) {
|
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,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)
|
package/src/runtime/index.js
CHANGED
|
@@ -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,11 +61,21 @@ 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,
|
|
67
69
|
MemoryEffectLog,
|
|
68
70
|
FileEffectLog
|
|
69
71
|
} from './effect-log.js'
|
|
70
|
-
|
|
72
|
+
export {
|
|
73
|
+
McpServer,
|
|
74
|
+
createMcpServer,
|
|
75
|
+
runMcpStdioServer
|
|
76
|
+
} from './mcp.js'
|
|
77
|
+
export {
|
|
78
|
+
A2AClient,
|
|
79
|
+
A2AError,
|
|
80
|
+
createA2AClient
|
|
81
|
+
} from './a2a.js'
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Minimal MCP-style JSON-RPC transport for Node.js. The server is protocol-
|
|
3
|
+
* focused rather than introducing MCP-specific JojoScript syntax; tool
|
|
4
|
+
* implementations remain ordinary JavaScript functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createInterface } from 'node:readline'
|
|
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
|
+
|
|
158
|
+
export class McpServer {
|
|
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
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async handleRequest(message) {
|
|
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({
|
|
204
|
+
jsonrpc: '2.0',
|
|
205
|
+
id,
|
|
206
|
+
result: {
|
|
207
|
+
protocolVersion: this.protocolVersion,
|
|
208
|
+
capabilities: {
|
|
209
|
+
tools: { listChanged: false }
|
|
210
|
+
},
|
|
211
|
+
serverInfo: this.serverInfo
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
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') {
|
|
224
|
+
return {
|
|
225
|
+
jsonrpc: '2.0',
|
|
226
|
+
id,
|
|
227
|
+
result: { tools: [...this.tools.values()].map(toolDefinition) }
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (message.method !== 'tools/call') {
|
|
232
|
+
return jsonRpcError(id, -32601, 'Method not found')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const toolName = message.params?.name
|
|
236
|
+
if (typeof toolName !== 'string') {
|
|
237
|
+
return jsonRpcError(id, -32602, 'Tool name is required')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tool = this.tools.get(toolName)
|
|
241
|
+
if (!tool) return jsonRpcError(id, -32602, `Unknown tool: ${toolName}`)
|
|
242
|
+
|
|
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}`)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const result = await tool.handler(toolArgs)
|
|
251
|
+
const resultError = validateToolResult(result, tool)
|
|
252
|
+
return {
|
|
253
|
+
jsonrpc: '2.0',
|
|
254
|
+
id,
|
|
255
|
+
result: resultError ? toolError(new Error(resultError)) : result
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
return {
|
|
259
|
+
jsonrpc: '2.0',
|
|
260
|
+
id,
|
|
261
|
+
result: toolError(error)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function createMcpServer(options = {}) {
|
|
268
|
+
return new McpServer(options)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function runMcpStdioServer(server, options = {}) {
|
|
272
|
+
const input = options.input ?? process.stdin
|
|
273
|
+
const output = options.output ?? process.stdout
|
|
274
|
+
|
|
275
|
+
const lines = Array.isArray(input)
|
|
276
|
+
? input.values()
|
|
277
|
+
: createInterface({ input, crlfDelay: Infinity })
|
|
278
|
+
|
|
279
|
+
for await (const line of lines) {
|
|
280
|
+
if (!line.trim()) continue
|
|
281
|
+
|
|
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
|
+
|
|
290
|
+
const response = await server.handleRequest(message)
|
|
291
|
+
if (response) output.write(`${JSON.stringify(response)}\n`)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return 0
|
|
295
|
+
}
|