@llm4ts/shell 2.44.1 → 2.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Cli.d.ts +3 -7
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +20 -4
- package/dist/Cli.js.map +1 -1
- package/dist/FlowLaunch.d.ts +2 -16
- package/dist/FlowLaunch.d.ts.map +1 -1
- package/dist/FlowLaunch.js +20 -66
- package/dist/FlowLaunch.js.map +1 -1
- package/dist/FlowProcess.d.ts +67 -0
- package/dist/FlowProcess.d.ts.map +1 -0
- package/dist/FlowProcess.js +175 -0
- package/dist/FlowProcess.js.map +1 -0
- package/dist/Menu.d.ts +1 -1
- package/dist/Menu.d.ts.map +1 -1
- package/dist/OperatorActTools.d.ts +170 -0
- package/dist/OperatorActTools.d.ts.map +1 -0
- package/dist/OperatorActTools.js +332 -0
- package/dist/OperatorActTools.js.map +1 -0
- package/dist/OperatorConfig.d.ts +44 -0
- package/dist/OperatorConfig.d.ts.map +1 -0
- package/dist/OperatorConfig.js +88 -0
- package/dist/OperatorConfig.js.map +1 -0
- package/dist/OperatorNode.d.ts +45 -0
- package/dist/OperatorNode.d.ts.map +1 -0
- package/dist/OperatorNode.js +103 -0
- package/dist/OperatorNode.js.map +1 -0
- package/dist/OperatorRuns.d.ts +76 -0
- package/dist/OperatorRuns.d.ts.map +1 -0
- package/dist/OperatorRuns.js +146 -0
- package/dist/OperatorRuns.js.map +1 -0
- package/dist/OperatorServer.d.ts +160 -0
- package/dist/OperatorServer.d.ts.map +1 -0
- package/dist/OperatorServer.js +123 -0
- package/dist/OperatorServer.js.map +1 -0
- package/dist/OperatorTools.d.ts +558 -0
- package/dist/OperatorTools.d.ts.map +1 -0
- package/dist/OperatorTools.js +398 -0
- package/dist/OperatorTools.js.map +1 -0
- package/dist/Refine.d.ts +2 -2
- package/dist/Refine.d.ts.map +1 -1
- package/dist/Refine.js +3 -5
- package/dist/Refine.js.map +1 -1
- package/dist/RunSupervisor.d.ts +51 -0
- package/dist/RunSupervisor.d.ts.map +1 -0
- package/dist/RunSupervisor.js +282 -0
- package/dist/RunSupervisor.js.map +1 -0
- package/dist/ShellErrors.d.ts +9 -0
- package/dist/ShellErrors.d.ts.map +1 -0
- package/dist/ShellErrors.js +7 -0
- package/dist/ShellErrors.js.map +1 -0
- package/flows/lib/epic-stories.js +83 -30
- package/flows/port-guide.js +4 -1
- package/kits/j2ee-nextjs/packs/j2ee-nextjs-spa/pack.md +1 -0
- package/kits/j2ee-nextjs/packs/jsp-nextjs/pack.md +1 -0
- package/kits/port/packs/scala-ts/pack.md +1 -0
- package/kits/soap-ace/fixtures/demo-bank-soap/RUNBOOK.md +5 -2
- package/kits/soap-ace/flows/soap-epic.js +1 -2
- package/package.json +13 -4
- package/src/Cli.ts +64 -3
- package/src/FlowLaunch.ts +20 -66
- package/src/FlowProcess.ts +239 -0
- package/src/OperatorActTools.ts +407 -0
- package/src/OperatorConfig.ts +148 -0
- package/src/OperatorNode.ts +147 -0
- package/src/OperatorRuns.ts +270 -0
- package/src/OperatorServer.ts +192 -0
- package/src/OperatorTools.ts +610 -0
- package/src/Refine.ts +4 -7
- package/src/RunSupervisor.ts +403 -0
- package/src/ShellErrors.ts +6 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Option from "effect/Option"
|
|
4
|
+
import * as Schema from "effect/Schema"
|
|
5
|
+
import { EpicRun, epicRunsPath } from "@llm4ts/flow/EpicRuns"
|
|
6
|
+
import { describeFlowError } from "@llm4ts/flow/FlowError"
|
|
7
|
+
import { TraceLine } from "@llm4ts/flow/FlowRecorder"
|
|
8
|
+
import {
|
|
9
|
+
OperatorRun,
|
|
10
|
+
ReadFailed,
|
|
11
|
+
UnknownEpic,
|
|
12
|
+
UnknownRun,
|
|
13
|
+
type OperatorRunState
|
|
14
|
+
} from "@llm4ts/flow/OperatorSchemas"
|
|
15
|
+
import type { PlainFileStoreShape } from "@llm4ts/flow/Persistence"
|
|
16
|
+
import { readTrace } from "@llm4ts/flow/Replay"
|
|
17
|
+
import { TraceUnreadable } from "@llm4ts/runner/RunReaders"
|
|
18
|
+
import type { OperatorConfig } from "./OperatorConfig.ts"
|
|
19
|
+
import type { RunSupervisorShape } from "./RunSupervisor.ts"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where the operator finds a run: the supervisor's registry first, then each
|
|
23
|
+
* served repository's epic `runs.jsonl` files and `.llm4ts/trace-*.jsonl`
|
|
24
|
+
* traces. A client never names a path; epics and runs are ids found on disk.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** What run lookup reads; the operator's full dependency record extends it. */
|
|
28
|
+
export interface RunLookupDeps {
|
|
29
|
+
readonly config: OperatorConfig
|
|
30
|
+
readonly files: PlainFileStoreShape
|
|
31
|
+
/** Absolute trace paths under one `.llm4ts/` directory. */
|
|
32
|
+
readonly listTraces: (directory: string) => ReadonlyArray<string>
|
|
33
|
+
/** Folder names under `.llm4ts/epics/`. */
|
|
34
|
+
readonly listEpics: (directory: string) => ReadonlyArray<string>
|
|
35
|
+
readonly supervisor: RunSupervisorShape
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Repo {
|
|
39
|
+
readonly id: string
|
|
40
|
+
readonly path: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const stateRoot = (repo: Repo): string => join(repo.path, ".llm4ts")
|
|
44
|
+
export const epicsDir = (repo: Repo): string => join(stateRoot(repo), "epics")
|
|
45
|
+
export const transcriptsDir = (repo: Repo, runId: string): string =>
|
|
46
|
+
join(stateRoot(repo), "transcripts", runId)
|
|
47
|
+
|
|
48
|
+
export const readFailed = (error: unknown): ReadFailed =>
|
|
49
|
+
ReadFailed.make({ message: describeFlowError(error) })
|
|
50
|
+
|
|
51
|
+
/** The epic's state folder, only for an id found on disk, so no client text becomes a path. */
|
|
52
|
+
export const epicDirOf = (
|
|
53
|
+
deps: RunLookupDeps,
|
|
54
|
+
repo: Repo,
|
|
55
|
+
epic: string
|
|
56
|
+
): Effect.Effect<string, UnknownEpic> =>
|
|
57
|
+
deps.listEpics(epicsDir(repo)).includes(epic)
|
|
58
|
+
? Effect.succeed(join(epicsDir(repo), epic))
|
|
59
|
+
: Effect.fail(UnknownEpic.make({ message: `unknown epic '${epic}' in ${repo.id}` }))
|
|
60
|
+
|
|
61
|
+
const decodeEpicRun = Schema.decodeUnknownOption(Schema.fromJsonString(EpicRun))
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An epic's runs, oldest first. A missing file is no runs and a torn last line
|
|
65
|
+
* (a write in progress) is skipped, but any other line that does not decode
|
|
66
|
+
* fails `ReadFailed` naming the file.
|
|
67
|
+
*/
|
|
68
|
+
export const readEpicRunsStrict = (
|
|
69
|
+
files: PlainFileStoreShape,
|
|
70
|
+
stateDir: string
|
|
71
|
+
): Effect.Effect<ReadonlyArray<EpicRun>, ReadFailed> => {
|
|
72
|
+
const path = epicRunsPath(stateDir)
|
|
73
|
+
return files.read(path).pipe(
|
|
74
|
+
Effect.mapError(readFailed),
|
|
75
|
+
Effect.flatMap((contents) => {
|
|
76
|
+
const rows = (contents ?? "").split("\n").filter((row) => row.trim().length > 0)
|
|
77
|
+
const runs: Array<EpicRun> = []
|
|
78
|
+
for (const [index, row] of rows.entries()) {
|
|
79
|
+
const decoded = Option.getOrUndefined(decodeEpicRun(row))
|
|
80
|
+
if (decoded !== undefined) {
|
|
81
|
+
runs.push(decoded)
|
|
82
|
+
} else if (index < rows.length - 1) {
|
|
83
|
+
return Effect.fail(
|
|
84
|
+
ReadFailed.make({ message: `malformed ${path}:${index + 1}: not an epic run record` })
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Effect.succeed(runs)
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface EpicRunEntry {
|
|
94
|
+
readonly epic: string
|
|
95
|
+
readonly dir: string
|
|
96
|
+
readonly run: EpicRun
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const epicRunsOf = (
|
|
100
|
+
deps: RunLookupDeps,
|
|
101
|
+
repo: Repo
|
|
102
|
+
): Effect.Effect<ReadonlyArray<EpicRunEntry>, ReadFailed> =>
|
|
103
|
+
Effect.forEach(deps.listEpics(epicsDir(repo)), (epic) => {
|
|
104
|
+
const dir = join(epicsDir(repo), epic)
|
|
105
|
+
return readEpicRunsStrict(deps.files, dir).pipe(
|
|
106
|
+
Effect.map((runs) => runs.map((run) => ({ epic, dir, run })))
|
|
107
|
+
)
|
|
108
|
+
}).pipe(Effect.map((all) => all.flat()))
|
|
109
|
+
|
|
110
|
+
const decodeTraceLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceLine))
|
|
111
|
+
|
|
112
|
+
export interface TraceHead {
|
|
113
|
+
readonly runId: string
|
|
114
|
+
readonly startedAt: number
|
|
115
|
+
readonly ended: boolean
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The run a trace belongs to and whether it ended, from its first and last lines. */
|
|
119
|
+
export const traceHead = (
|
|
120
|
+
files: PlainFileStoreShape,
|
|
121
|
+
path: string
|
|
122
|
+
): Effect.Effect<TraceHead | undefined> =>
|
|
123
|
+
files.read(path).pipe(
|
|
124
|
+
Effect.catch(() => Effect.succeed(undefined)),
|
|
125
|
+
Effect.map((contents) => {
|
|
126
|
+
const rows = (contents ?? "").split(/\r?\n/u).filter((row) => row.trim().length > 0)
|
|
127
|
+
const first = Option.getOrUndefined(decodeTraceLine(rows[0] ?? ""))
|
|
128
|
+
if (first === undefined) return undefined
|
|
129
|
+
const last = Option.getOrUndefined(decodeTraceLine(rows[rows.length - 1] ?? ""))
|
|
130
|
+
return { runId: first.runId, startedAt: first.timestamp, ended: last?.kind === "RunEnded" }
|
|
131
|
+
})
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
export const traceState = (head: TraceHead | undefined): OperatorRunState =>
|
|
135
|
+
head === undefined ? "lost" : head.ended ? "exited" : "running"
|
|
136
|
+
|
|
137
|
+
export interface ResolvedRun {
|
|
138
|
+
readonly runId: string
|
|
139
|
+
readonly repo: Repo
|
|
140
|
+
readonly flow: string
|
|
141
|
+
readonly state: OperatorRunState
|
|
142
|
+
readonly tracePath: string
|
|
143
|
+
readonly supervised?: OperatorRun
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** A run id through the supervisor first, then each repository's epic runs and traces. */
|
|
147
|
+
export const resolveRun = (
|
|
148
|
+
deps: RunLookupDeps,
|
|
149
|
+
runId: string
|
|
150
|
+
): Effect.Effect<ResolvedRun, UnknownRun | ReadFailed> =>
|
|
151
|
+
Effect.gen(function* () {
|
|
152
|
+
const supervised = yield* deps.supervisor.get(runId)
|
|
153
|
+
if (supervised !== undefined) {
|
|
154
|
+
const repo = deps.config.repos.find((candidate) => candidate.id === supervised.repo)
|
|
155
|
+
if (repo === undefined) {
|
|
156
|
+
return yield* UnknownRun.make({
|
|
157
|
+
message: `run ${runId} belongs to repo '${supervised.repo}', which this server does not serve`
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
if (supervised.tracePath === undefined) {
|
|
161
|
+
return yield* UnknownRun.make({ message: `run ${runId} has no trace yet` })
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
runId,
|
|
165
|
+
repo,
|
|
166
|
+
flow: supervised.flow,
|
|
167
|
+
state: supervised.state,
|
|
168
|
+
tracePath: supervised.tracePath,
|
|
169
|
+
supervised
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const repo of deps.config.repos) {
|
|
173
|
+
for (const entry of yield* epicRunsOf(deps, repo)) {
|
|
174
|
+
if (entry.run.runId === runId) {
|
|
175
|
+
const head = yield* traceHead(deps.files, entry.run.tracePath)
|
|
176
|
+
return {
|
|
177
|
+
runId,
|
|
178
|
+
repo,
|
|
179
|
+
flow: "?",
|
|
180
|
+
state: traceState(head),
|
|
181
|
+
tracePath: entry.run.tracePath
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const path of deps.listTraces(stateRoot(repo))) {
|
|
186
|
+
const head = yield* traceHead(deps.files, path)
|
|
187
|
+
if (head?.runId === runId) {
|
|
188
|
+
return { runId, repo, flow: "?", state: traceState(head), tracePath: path }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return yield* UnknownRun.make({ message: `unknown run '${runId}'` })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/** The epic a run worked on: the epic whose `runs.jsonl` lists it. */
|
|
196
|
+
export const epicOfRun = (
|
|
197
|
+
deps: RunLookupDeps,
|
|
198
|
+
run: ResolvedRun
|
|
199
|
+
): Effect.Effect<EpicRunEntry | undefined, ReadFailed> =>
|
|
200
|
+
Effect.map(epicRunsOf(deps, run.repo), (entries) =>
|
|
201
|
+
entries.find((entry) => entry.run.runId === run.runId || entry.run.tracePath === run.tracePath)
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
/** Runs found on disk (epic run lists and traces) that the supervisor does not hold. */
|
|
205
|
+
export const discoveredRuns = (
|
|
206
|
+
deps: RunLookupDeps,
|
|
207
|
+
repo: Repo,
|
|
208
|
+
known: ReadonlySet<string>
|
|
209
|
+
): Effect.Effect<ReadonlyArray<OperatorRun>, ReadFailed> =>
|
|
210
|
+
Effect.gen(function* () {
|
|
211
|
+
const found = new Map<string, OperatorRun>()
|
|
212
|
+
const add = (runId: string, tracePath: string, startedAt: number, state: OperatorRunState) => {
|
|
213
|
+
if (known.has(runId) || found.has(runId)) return
|
|
214
|
+
found.set(
|
|
215
|
+
runId,
|
|
216
|
+
new OperatorRun({
|
|
217
|
+
runId,
|
|
218
|
+
repo: repo.id,
|
|
219
|
+
flow: "?",
|
|
220
|
+
args: [],
|
|
221
|
+
knobs: {},
|
|
222
|
+
startedAt,
|
|
223
|
+
tracePath,
|
|
224
|
+
logDir: "",
|
|
225
|
+
state
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
for (const entry of yield* epicRunsOf(deps, repo)) {
|
|
230
|
+
const head = yield* traceHead(deps.files, entry.run.tracePath)
|
|
231
|
+
add(entry.run.runId, entry.run.tracePath, entry.run.startedAt, traceState(head))
|
|
232
|
+
}
|
|
233
|
+
for (const path of deps.listTraces(stateRoot(repo))) {
|
|
234
|
+
const head = yield* traceHead(deps.files, path)
|
|
235
|
+
if (head !== undefined) add(head.runId, path, head.startedAt, traceState(head))
|
|
236
|
+
}
|
|
237
|
+
return [...found.values()]
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* A run's trace lines. A supervised run whose trace file does not exist yet
|
|
242
|
+
* (the flow has not written its first line) reads as an empty trace; any other
|
|
243
|
+
* missing or unreadable trace fails `TraceUnreadable`.
|
|
244
|
+
*/
|
|
245
|
+
export const runTraceLines = (
|
|
246
|
+
files: PlainFileStoreShape,
|
|
247
|
+
run: ResolvedRun
|
|
248
|
+
): Effect.Effect<ReadonlyArray<TraceLine>, TraceUnreadable> =>
|
|
249
|
+
Effect.gen(function* () {
|
|
250
|
+
if (yield* tracePending(files, run)) return []
|
|
251
|
+
return yield* readTrace(files, run.tracePath).pipe(
|
|
252
|
+
Effect.mapError((error) =>
|
|
253
|
+
TraceUnreadable.make({ path: run.tracePath, message: describeFlowError(error) })
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
/** Whether the run is supervised and its flow has not written a trace yet. */
|
|
259
|
+
export const tracePending = (
|
|
260
|
+
files: PlainFileStoreShape,
|
|
261
|
+
run: ResolvedRun
|
|
262
|
+
): Effect.Effect<boolean, TraceUnreadable> =>
|
|
263
|
+
run.supervised === undefined
|
|
264
|
+
? Effect.succeed(false)
|
|
265
|
+
: files.read(run.tracePath).pipe(
|
|
266
|
+
Effect.map((contents) => contents === undefined),
|
|
267
|
+
Effect.mapError((error) =>
|
|
268
|
+
TraceUnreadable.make({ path: run.tracePath, message: describeFlowError(error) })
|
|
269
|
+
)
|
|
270
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto"
|
|
2
|
+
import { createServer } from "node:http"
|
|
3
|
+
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
|
|
4
|
+
import * as NodeStdio from "@effect/platform-node/NodeStdio"
|
|
5
|
+
import type * as Cause from "effect/Cause"
|
|
6
|
+
import * as Clock from "effect/Clock"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Layer from "effect/Layer"
|
|
9
|
+
import * as Logger from "effect/Logger"
|
|
10
|
+
import * as Option from "effect/Option"
|
|
11
|
+
import * as Schema from "effect/Schema"
|
|
12
|
+
import * as McpProtocol from "effect/ai/McpProtocol"
|
|
13
|
+
import * as McpServer from "effect/ai/McpServer"
|
|
14
|
+
import * as Toolkit from "effect/ai/Toolkit"
|
|
15
|
+
import * as HttpRouter from "effect/http/HttpRouter"
|
|
16
|
+
import type { ServeError } from "effect/http/HttpServerError"
|
|
17
|
+
import * as HttpServerRequest from "effect/http/HttpServerRequest"
|
|
18
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
19
|
+
import {
|
|
20
|
+
actHandlers,
|
|
21
|
+
actToolkit,
|
|
22
|
+
approveTool,
|
|
23
|
+
retroStartTool,
|
|
24
|
+
startRunTool,
|
|
25
|
+
stopRunTool,
|
|
26
|
+
waitHandlers,
|
|
27
|
+
waitToolkit
|
|
28
|
+
} from "./OperatorActTools.ts"
|
|
29
|
+
import type { OperatorConfig } from "./OperatorConfig.ts"
|
|
30
|
+
import { readHandlers, readToolkit, type OperatorDeps } from "./OperatorTools.ts"
|
|
31
|
+
import { packageVersion } from "./Package.ts"
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The operator MCP server (`llm4ts mcp`, ADR 0041): the read, wait and allowed
|
|
35
|
+
* act tools served over stdio, or over HTTP at `/mcp` behind a bearer token.
|
|
36
|
+
* A tool that the configuration does not allow is never registered, so it is
|
|
37
|
+
* absent from `tools/list` rather than refused when called.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export const serverName = "llm4ts"
|
|
41
|
+
export const serverPath = "/mcp"
|
|
42
|
+
|
|
43
|
+
type HttpTransport = Extract<OperatorConfig["transport"], { readonly _tag: "http" }>
|
|
44
|
+
export type HttpOperatorConfig = OperatorConfig & { readonly transport: HttpTransport }
|
|
45
|
+
|
|
46
|
+
const serverInfo = {
|
|
47
|
+
name: serverName,
|
|
48
|
+
version: packageVersion,
|
|
49
|
+
protocols: [McpProtocol.v2025_06_18]
|
|
50
|
+
} as const
|
|
51
|
+
|
|
52
|
+
const runToolkit = Toolkit.make(startRunTool, stopRunTool, retroStartTool)
|
|
53
|
+
const approveToolkit = Toolkit.make(approveTool)
|
|
54
|
+
|
|
55
|
+
/** The act tools a configuration allows: `--allow-run` and `--allow-approve` each add their own. */
|
|
56
|
+
export const actToolkitFor = (config: OperatorConfig) =>
|
|
57
|
+
config.allowRun
|
|
58
|
+
? config.allowApprove
|
|
59
|
+
? actToolkit
|
|
60
|
+
: runToolkit
|
|
61
|
+
: config.allowApprove
|
|
62
|
+
? approveToolkit
|
|
63
|
+
: Toolkit.empty
|
|
64
|
+
|
|
65
|
+
const actLayer = (config: OperatorConfig, deps: OperatorDeps): Layer.Layer<never> => {
|
|
66
|
+
const act = actHandlers(deps)
|
|
67
|
+
if (config.allowRun && config.allowApprove) {
|
|
68
|
+
return McpServer.toolkit(actToolkit).pipe(Layer.provide(actToolkit.toLayer(act)))
|
|
69
|
+
}
|
|
70
|
+
if (config.allowRun) {
|
|
71
|
+
const handlers = runToolkit.of({
|
|
72
|
+
start_run: act.start_run,
|
|
73
|
+
stop_run: act.stop_run,
|
|
74
|
+
retro_start: act.retro_start
|
|
75
|
+
})
|
|
76
|
+
return McpServer.toolkit(runToolkit).pipe(Layer.provide(runToolkit.toLayer(handlers)))
|
|
77
|
+
}
|
|
78
|
+
if (config.allowApprove) {
|
|
79
|
+
const handlers = approveToolkit.of({ approve: act.approve })
|
|
80
|
+
return McpServer.toolkit(approveToolkit).pipe(Layer.provide(approveToolkit.toLayer(handlers)))
|
|
81
|
+
}
|
|
82
|
+
return Layer.empty
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every tool this configuration serves, registered on the server it is provided. */
|
|
86
|
+
export const toolLayer = (
|
|
87
|
+
config: OperatorConfig,
|
|
88
|
+
deps: OperatorDeps
|
|
89
|
+
): Layer.Layer<never, never, McpServer.McpServer> =>
|
|
90
|
+
Layer.mergeAll(
|
|
91
|
+
McpServer.toolkit(readToolkit).pipe(Layer.provide(readToolkit.toLayer(readHandlers(deps)))),
|
|
92
|
+
McpServer.toolkit(waitToolkit).pipe(Layer.provide(waitToolkit.toLayer(waitHandlers(deps)))),
|
|
93
|
+
actLayer(config, deps)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const unauthorized = HttpServerResponse.empty({ status: 401 })
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Lets a request through only with `Authorization: Bearer <token>`, compared in
|
|
100
|
+
* constant time; anything else is a bare 401. The token is never logged.
|
|
101
|
+
*/
|
|
102
|
+
export const bearerMiddleware = (token: string): Layer.Layer<never> => {
|
|
103
|
+
const expected = Buffer.from(`Bearer ${token}`, "utf8")
|
|
104
|
+
const matches = (header: string | undefined): boolean => {
|
|
105
|
+
if (header === undefined) return false
|
|
106
|
+
const actual = Buffer.from(header, "utf8")
|
|
107
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
|
108
|
+
}
|
|
109
|
+
return HttpRouter.middleware((httpEffect) =>
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
112
|
+
return matches(request.headers.authorization) ? yield* httpEffect : unauthorized
|
|
113
|
+
})
|
|
114
|
+
).layer
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The HTTP application: the tools on an MCP endpoint at `/mcp`, behind the bearer token. */
|
|
118
|
+
export const httpAppLayer = (
|
|
119
|
+
config: HttpOperatorConfig,
|
|
120
|
+
deps: OperatorDeps
|
|
121
|
+
): Layer.Layer<never, Cause.IllegalArgumentError, HttpRouter.HttpRouter> =>
|
|
122
|
+
toolLayer(config, deps).pipe(
|
|
123
|
+
Layer.provide(McpServer.layerHttp({ ...serverInfo, path: serverPath })),
|
|
124
|
+
Layer.provide(bearerMiddleware(config.transport.token))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const RpcCall = Schema.Struct({
|
|
128
|
+
method: Schema.String,
|
|
129
|
+
params: Schema.optional(Schema.Struct({ name: Schema.optional(Schema.String) }))
|
|
130
|
+
})
|
|
131
|
+
const decodeRpcCall = Schema.decodeUnknownOption(Schema.fromJsonString(RpcCall))
|
|
132
|
+
|
|
133
|
+
/** What a request asked for: its JSON-RPC method and, for `tools/call`, the tool. Never its arguments. */
|
|
134
|
+
const requestLabel = (body: string): string =>
|
|
135
|
+
Option.match(decodeRpcCall(body), {
|
|
136
|
+
onNone: () => "request",
|
|
137
|
+
onSome: (call) =>
|
|
138
|
+
call.method === "tools/call" && call.params?.name !== undefined
|
|
139
|
+
? `tools/call ${call.params.name}`
|
|
140
|
+
: call.method
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One log line per HTTP request: method, tool, duration and status. The body
|
|
145
|
+
* is read only after the endpoint has read it (the read is cached), and never
|
|
146
|
+
* for a request the token check turned away.
|
|
147
|
+
*/
|
|
148
|
+
const requestLog = HttpRouter.middleware(
|
|
149
|
+
(httpEffect) =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
152
|
+
const startedAt = yield* Clock.currentTimeMillis
|
|
153
|
+
const response = yield* httpEffect
|
|
154
|
+
const elapsed = (yield* Clock.currentTimeMillis) - startedAt
|
|
155
|
+
const label =
|
|
156
|
+
response.status === 401 || request.method !== "POST"
|
|
157
|
+
? request.method
|
|
158
|
+
: yield* request.text.pipe(
|
|
159
|
+
Effect.map(requestLabel),
|
|
160
|
+
Effect.orElseSucceed(() => "request")
|
|
161
|
+
)
|
|
162
|
+
yield* Effect.logInfo(`mcp ${label} ${elapsed}ms status=${response.status}`)
|
|
163
|
+
return response
|
|
164
|
+
}),
|
|
165
|
+
{ global: true }
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
/** Serves the operator tools on the configured transport until interrupted. */
|
|
169
|
+
export const runOperatorServer = (
|
|
170
|
+
config: OperatorConfig,
|
|
171
|
+
deps: OperatorDeps
|
|
172
|
+
): Effect.Effect<never, ServeError | Cause.IllegalArgumentError> => {
|
|
173
|
+
const transport = config.transport
|
|
174
|
+
if (transport._tag === "stdio") {
|
|
175
|
+
// stdout carries the protocol: every log line goes to stderr.
|
|
176
|
+
return Layer.launch(
|
|
177
|
+
toolLayer(config, deps).pipe(
|
|
178
|
+
Layer.provide(McpServer.layerStdio(serverInfo)),
|
|
179
|
+
Layer.provide(NodeStdio.layer)
|
|
180
|
+
)
|
|
181
|
+
).pipe(Effect.provideService(Logger.LogToStderr, true))
|
|
182
|
+
}
|
|
183
|
+
return Layer.launch(
|
|
184
|
+
HttpRouter.serve(Layer.merge(httpAppLayer({ ...config, transport }, deps), requestLog), {
|
|
185
|
+
disableLogger: true
|
|
186
|
+
}).pipe(
|
|
187
|
+
Layer.provide(
|
|
188
|
+
NodeHttpServer.layer(createServer, { host: transport.host, port: transport.port })
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
}
|