@sema-agent/connectors 0.0.0-stage → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,247 @@
1
+ # Changelog: @sema-agent/connectors
2
+
3
+ All notable changes to this package are documented here. Released sections are
4
+ frozen: once a version is published its section is never edited again; new text
5
+ goes into the next version's section.
6
+
7
+ ## 0.2.0 (2026-10-15)
8
+
9
+ ### Added
10
+
11
+ - Handles that are not secrets (CN-038). `<handle>.meta.json` may say
12
+ `"secret": false`; such a handle is delivered to the connector like every
13
+ handle (a per-launch 0600 copy named by `<NAME>_FILE`), but the launcher
14
+ does not withhold its value and the status does not count it. Every other
15
+ handle is a secret, as before. `openFileSecrets().put(name, value,
16
+ { secret: false })` writes it, `SecretHandle.secret` reports it, and a
17
+ later `put` without it makes the handle a secret again.
18
+ - `CONNECTOR_DIR` (CN-038): the launcher gives its connector the absolute path
19
+ of the connector's directory, `<configuration directory>/connectors/<name>`
20
+ (standalone; it may not exist), so a connector no longer infers it from the
21
+ path of a handle copy. A hosted connector, which has no configuration
22
+ directory and no handles in this release, gets a fresh path of its own that
23
+ does not exist. A `CONNECTOR_DIR` set for the launcher never reaches the
24
+ connector.
25
+ - The lock of a connector's directory (CN-038), `.auth.lock`: every write of
26
+ the secrets backend and every launch's listing and copy of the handles hold
27
+ it, so a launch copies a group of writes before or after the group, never in
28
+ between. `openFileSecrets().withLock(fn)` holds it across several writes. A
29
+ taker waits up to 5 seconds for another holder, then refuses (the launcher
30
+ with exit 2 and `connector.internal`); only the holder removes the lock, and
31
+ one left by a process that died is removed by hand. `FileSecrets.directory`
32
+ names the connector's directory.
33
+ - The connector CLI kit (CN-039): `connectorCli` runs a connector package's
34
+ bin (no arguments: the connector's server; `install`, `auth`, `mount`,
35
+ `status` without arguments on the configuration directory of
36
+ `resolveConfigDir`), `cliOutput`, `readStdinLine`, `readStdinJson` (one line
37
+ holding a JSON object with exactly the given keys, each named once, every
38
+ value a string), `mountCommand`, `statusCommand` (the status resource read
39
+ through the launcher within 10 seconds) and `isLoopbackHttpOrHttps`, the
40
+ one rule for an address a connector sends requests to (the rule of a key set
41
+ URL).
42
+ - `writeAll(fd, data)` (CN-042): the one whole write. It writes every byte of
43
+ `data` into the empty file open at `fd` (a write that writes nothing or fails
44
+ ends it with an error), syncs the file and checks that its size is the number
45
+ of bytes, or throws. Every file this package writes goes through it, and a
46
+ connector package writes its own files with it.
47
+
48
+ ### Changed
49
+
50
+ - The status counts secret handles only: a connector whose only handles are
51
+ not secrets is `unauthorized`, and the expiry of a handle that is not a
52
+ secret changes nothing (CN-038).
53
+ - `<handle>.meta.json` must be a JSON object whose only keys are `expiresAt`
54
+ and `secret`, each named once; another key, a key named twice or a `secret`
55
+ that is not `true` or `false` makes the directory refused (another key used
56
+ to be ignored, and an object without `expiresAt` refused) (CN-038).
57
+
58
+ ### Fixed
59
+
60
+ - A write cut short is no longer taken as done (CN-042). A write can stop short
61
+ without an error (a file size limit, a full disk); `mount` used to rename the
62
+ cut-short text over `mcp.json` and report it written, losing the other
63
+ entries, and `put` stored a cut-short value and reported success. Now nothing
64
+ is put in place before it is written whole: `mount` refuses with the new
65
+ reason `write_failed` and `put` throws, the temporary file is removed and the
66
+ old file (or its absence) stays as it was. The lock files and the per-launch
67
+ copies of the handles are written the same way.
68
+ - The scope judge never grants the empty tool name (CN-036): a `tools/call`
69
+ whose name is `""` is refused with `connector.forbidden_scope` also under
70
+ `tool:*` (or a bare `tool:`), and a listed tool whose name is `""` is not
71
+ listed.
72
+
73
+ ## 0.1.0 (2026-10-14)
74
+
75
+ ### Added
76
+
77
+ - Package entry point and the `sema-connector` bin. The entry exports
78
+ `resolveConfigDir`, `openFileSecrets`, `verifyAssertion`, `standaloneResolver`,
79
+ `hostedResolver`, `ASSERTION_HEADER`, `mount` and their types; it exports
80
+ nothing that turns a secret handle into a value.
81
+ - The launcher behind the `sema-connector` bin (CN-003):
82
+ `sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]`.
83
+ Structural guards exit with code 2: a missing `run`, an unknown or repeated
84
+ argument, a missing or unknown `--mode` or `--transport`, standalone with
85
+ anything but stdio, hosted without http, without a `--listen <IPv4 or [IPv6]>:<port>`,
86
+ without `CONNECTOR_ASSERTION_ISSUER` or `CONNECTOR_ASSERTION_AUDIENCE` (CN-008),
87
+ with both key-set overrides, or with a key set URL that is neither `https://`
88
+ nor `http://` on `127.0.0.1` or `localhost`, or that carries credentials,
89
+ standalone without `--config-dir` or with one that is not an absolute path,
90
+ and hosted with `--config-dir` (CN-028). Standalone reads its configuration
91
+ directory from `--config-dir` only, never from an environment variable, so a
92
+ launcher started from a catalog entry with the engine's minimal environment
93
+ finds it (CN-028). A
94
+ connector `<name>` must be lowercase letters and digits in hyphen-separated
95
+ words, so that it can only name `@sema-agent/connector-<name>`. Error messages
96
+ never repeat argument or environment values.
97
+ - Hosted http face (CN-008): hosted mode that passes the guards obtains its key
98
+ set once (inline `CONNECTOR_ASSERTION_JWKS`, `CONNECTOR_ASSERTION_JWKS_URL`, or
99
+ `<issuer>/.well-known/jwks.json`; one GET, no redirect, never refreshed; exit
100
+ 2 when it cannot), resolves its connector (exit 4) and serves the subset of MCP
101
+ Streamable HTTP the MCP SDK client needs on `--listen`: every request needs a
102
+ verifiable assertion in `connector-assertion` (HTTP 401 with
103
+ `connector.bad_assertion` otherwise, a bare name header included); `POST /mcp`
104
+ carries one message answered as one JSON body (notifications and replies 202);
105
+ one session at a time, each with its own connector process, named by
106
+ `mcp-session-id` (400 without it, 404 for another, 409 for a second
107
+ `initialize`); `mcp-protocol-version` must match the version the connector
108
+ chose; `GET /mcp` answers 405 (no stream); `DELETE /mcp` ends the session's
109
+ connector down the teardown ladder and answers 204 once it is gone; requests
110
+ still waiting when a connector ends are answered with `connector.internal`;
111
+ `GET /status` serves the status value under the request's own principal. An
112
+ id stays taken until the connector answers it (409 meanwhile), a message or a
113
+ status read for a connector that has not read its earlier input answers 503,
114
+ and connector
115
+ stderr a stalled log has not taken one step after the connector exited is
116
+ dropped; only a JSON-RPC answer (result or error) ends a request's wait. A stop
117
+ signal ends the open session and exits 0, abandoning output a stalled log has
118
+ not taken one step later. Exit code 3 is gone.
119
+ - An upstream message with a line break inside (a CR other than CRLF framing,
120
+ or an LF in an http body) is refused like an unreadable one: the connector
121
+ could read its parts as other messages than the one judged.
122
+ - The message session judges every upstream message under the principal of the
123
+ request that carried it (the stdio path passes the anonymous principal), and
124
+ a `tools/list` reply or a status read under the principal of the request it
125
+ answers.
126
+ - Resolution of `@sema-agent/connector-<name>` from the launcher's own
127
+ `package.json`; a package that cannot be resolved, or whose `bin` is not a
128
+ single name, exits with code 4 and `connector.not_installed`.
129
+ - MCP stdio path of newline-delimited messages in both directions, with no
130
+ runtime dependencies. The connector's stdout and stderr and the upstream
131
+ input all go through one pump with flow control, so a slow reader holds the
132
+ writer back instead of growing the launcher's memory, whether or not the
133
+ output contains newlines. The connector's stderr passes as it arrives; the
134
+ two message streams are read whole line by line, up to 4 MiB per line,
135
+ through the stages below, and nothing is forwarded unread; messages no stage
136
+ concerns are forwarded byte for byte. From upstream, a line that is not a
137
+ single JSON-RPC message within 4 MiB, that repeats a key in any object, or
138
+ whose id is neither a string nor a number gets one error with `id` null and
139
+ `data.code` `connector.forbidden_scope` (CN-004). From the connector, a
140
+ reply is forwarded only for a request the launcher forwarded and has not seen
141
+ answered; a longer line, a batch or a message with a repeated key is dropped,
142
+ and every pending request gets a `connector.internal` error instead (CN-005),
143
+ so a tool result over 4 MiB arrives as that error; their ids stay taken until
144
+ the connector answers them (a request with one is refused meanwhile), so a
145
+ late reply never answers another request (CN-008). An id is also taken while
146
+ its request is pending, so a request reusing it is refused, not forwarded
147
+ (CN-031). Upstream input is also held
148
+ back while upstream does not read the launcher's own replies. Once a reader has gone
149
+ away, what can no longer be delivered to it is dropped instead of waited for;
150
+ after a stop signal has run the teardown below to its end, undelivered output
151
+ is abandoned. When the connector process ends, every request it left
152
+ unanswered gets a JSON-RPC error with `data.code` `connector.internal` naming
153
+ how it ended. The connector exiting with code 0 on its own exits the launcher
154
+ with code 0. A non-zero exit, a signal or stalled input (below) exits with
155
+ code 5 (with one error with `id` null when no request was pending).
156
+ - The connector never outlives the launcher. It runs as its own process group,
157
+ so helper processes it starts (for example an upstream binary sharing its
158
+ stdio) go down with it. One teardown ladder, one second per step: end the
159
+ connector's input, then `SIGTERM` to the group, then `SIGKILL` to the group.
160
+ The end of upstream input enters the ladder at its first step; `SIGTERM`,
161
+ `SIGINT` or `SIGHUP` sent to the launcher enters it at the second, passing that
162
+ signal on; the connector's own exit enters it at the second for whatever is
163
+ left of its group. The ladder finishes inside an MCP stdio client's own
164
+ shutdown (2 seconds after closing input before `SIGTERM`, 2 seconds after
165
+ `SIGTERM` before `SIGKILL`). The exit code follows the connector process.
166
+ - Stalled input (CN-025): input reaches the connector one piece of at most
167
+ 1 KiB at a time; a connector that takes none of its waiting input for ten
168
+ teardown steps (10 seconds) has stopped reading and enters the ladder at its
169
+ second step, on both transports. Its unanswered requests get
170
+ `connector.internal` naming the stalled input, and the run is abnormal
171
+ (stdio: exit 5). A client that only closes its pipe while input waits behind a
172
+ connector that stopped reading no longer leaves the launcher and the connector
173
+ running.
174
+ - Receipts (CN-031): one `connector-receipt-v1` receipt per `tools/call` that
175
+ names its tool, on both transports, minted by the launcher's session once the
176
+ call is settled and written as the stderr line `sema-connector: receipt <JSON>`:
177
+ `refused` when the launcher refused it, `ok` for a result, `failed` for a
178
+ result with `isError`, a connector error (with its `data.code` only when that
179
+ is a code of the closed set) or a `connector.internal` answer of the launcher.
180
+ It holds the connector and tool names, the start time, the duration, the
181
+ outcome and the error code, nothing of the call's arguments; a tool name that
182
+ carries a secret value is written as `[redacted]`. A connector
183
+ stderr line that would start a line of the launcher's stderr with
184
+ `sema-connector:` gets `connector: ` in front, so such lines are always the
185
+ launcher's own.
186
+ - The connector process gets a fixed environment instead of the launcher's:
187
+ `HOME`, `LOGNAME`, `USER`, `PATH`, `SHELL`, `TERM`, `LANG`, `LC_ALL` and `TZ`
188
+ when present, and `<NAME>_FILE` with the path of each of its secret files
189
+ (CN-005).
190
+ - `resolveConfigDir(env)`, for connector CLI subcommands: the configuration
191
+ directory is `CONFIG_LOCAL_DIR`, which must be an absolute path; not set or
192
+ empty is refused, and no other variable or default is read (CN-028).
193
+ - Secrets (CN-004): `openFileSecrets({ configDir, connector })` lists handles
194
+ (name, connector, source, optional expiry; never the value) and stores values
195
+ atomically. Layout: `<configuration directory>/connectors/<name>/` with mode
196
+ 0700, one 0600 file per handle, optional `<handle>.meta.json` with
197
+ `expiresAt`. Listing refuses wider modes, symbolic links and unreadable
198
+ expiry data; the launcher then exits with code 2 and `connector.internal`
199
+ before the connector starts. The launcher reads each value once, through a
200
+ function the entry does not export, into a per-launch 0600 copy that the
201
+ connector receives and that is removed when the launcher ends, and keeps
202
+ exactly those values out of what it forwards, looking for them also behind
203
+ JSON escapes: a connector stdout line carrying one is withheld, a reply
204
+ becoming a `connector.internal` error, and the connector's stderr is
205
+ scrubbed, also across writes. A line that fails to be judged is not
206
+ forwarded.
207
+ - Principal (CN-004): `verifyAssertion(compactJws, { issuer, audience, keys, now })`
208
+ checks a JWS compact assertion with Node's crypto in the contract's order
209
+ (algorithm allow-list `EdDSA` / `ES256`, key by `kid` with matching key type
210
+ and no other `alg`, signature, presence of every required claim with `iat` a
211
+ non-negative integer and `jti` a non-empty string (CN-029), `iss`, `aud`,
212
+ `exp` with 30 seconds of tolerance, non-empty `sub` and `space`, `scope`
213
+ pattern); every refusal is
214
+ `connector.bad_assertion` with the name of the failed step and nothing of the
215
+ token. `hostedResolver` takes a principal only from a verifiable assertion in
216
+ the `connector-assertion` request header (`ASSERTION_HEADER`: the header
217
+ name is the engine's standard to define; this value is the proposal the
218
+ launcher reads until the engine exports its constant, CN-034; a hosted
219
+ catalog entry names it with the settings schema's `assertionHeader` key,
220
+ CN-020), so a bare name header is
221
+ refused; `standaloneResolver` is anonymous with scope
222
+ `tool:*`.
223
+ - Scope (CN-004): one judge for `tools/list` and `tools/call`: a tool is
224
+ granted by `tool:*` or `tool:<name>` in the principal's scope. Tools outside
225
+ the grant are not listed, and calls to them are answered with JSON-RPC error
226
+ `-32602` and `connector.forbidden_scope` without reaching the connector.
227
+ - Normalize (CN-005): listed tool annotations keep `title` and the hints at
228
+ their MCP default (`destructiveHint: true`, `idempotentHint: false`,
229
+ `openWorldHint: true`); `readOnlyHint`, other hint values and unknown keys
230
+ are removed.
231
+ - Status (CN-005): the launcher serves `sema-connector://<name>/status`, a
232
+ `connector-status-v1` value with state `unauthorized`, `authorized` or
233
+ `expired` from the handles read at start and their expiry, and the tools
234
+ the reading request's principal sees; hosted, also at `GET /status`
235
+ (CN-008). Reads never reach the connector; `resources/list` gains the
236
+ status entry (merged with the connector's own resources, never repeated), and
237
+ `initialize` gains the resources capability when the connector has none.
238
+ - `mount({ name, configDir })` (CN-005, CN-028): writes the standalone entry
239
+ into `<configDir>/config.d/mcp.json`, and the entry's arguments end with
240
+ `--config-dir <configDir>`, the same directory. `configDir` must be an
241
+ absolute path (used normalized); mount reads no environment variable and has
242
+ no default. The same entry again changes nothing, another entry under the
243
+ same name (also one naming another configuration directory, or none) is
244
+ refused as a conflict, every other byte of the file is kept, and the file is
245
+ replaced atomically with its mode kept, under a lock file that excludes
246
+ concurrent mount writers and that only its holder removes. Refusals are
247
+ returned as reasons.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // sema-connector: the launcher executable (CN-003). Command line, exit codes and resolution rule:
3
+ // docs/ARCHITECTURE.md. Implementation: src/launcher.ts, built to dist/launcher.js.
4
+ import { main } from '../dist/launcher.js'
5
+
6
+ process.exit(await main(process.argv.slice(2), process.env))
@@ -0,0 +1,33 @@
1
+ import { type Env } from './config-dir.js';
2
+ export interface CliOutput {
3
+ readonly program: string;
4
+ say(line: string): void;
5
+ complain(line: string): void;
6
+ }
7
+ export declare function cliOutput(name: string): CliOutput;
8
+ export interface ConnectorCommands {
9
+ readonly name: string;
10
+ readonly serve: (env: Env) => void | number | Promise<number>;
11
+ readonly install: (configDir: string) => number | Promise<number>;
12
+ readonly auth: (configDir: string) => number | Promise<number>;
13
+ readonly authReads: string;
14
+ }
15
+ export declare function connectorCli(commands: ConnectorCommands, argv: readonly string[], env: Env): Promise<number | undefined>;
16
+ export type StdinLine = {
17
+ readonly ok: true;
18
+ readonly line: string;
19
+ } | {
20
+ readonly ok: false;
21
+ readonly problem: string;
22
+ };
23
+ export declare function readStdinLine(limit: number): Promise<StdinLine>;
24
+ export type StdinJson = {
25
+ readonly ok: true;
26
+ readonly values: Readonly<Record<string, string>>;
27
+ } | {
28
+ readonly ok: false;
29
+ readonly problem: string;
30
+ };
31
+ export declare function readStdinJson(limit: number, keys: readonly string[]): Promise<StdinJson>;
32
+ export declare function mountCommand(name: string, configDir: string): number;
33
+ export declare function statusCommand(name: string, configDir: string): Promise<number>;
@@ -0,0 +1,223 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveConfigDir } from './config-dir.js';
6
+ import { CONNECTOR_NAME } from './connector-name.js';
7
+ import { hasRepeatedKey } from './json-text.js';
8
+ import { mount } from './mount.js';
9
+ export function cliOutput(name) {
10
+ if (typeof name !== 'string' || !CONNECTOR_NAME.test(name))
11
+ throw new Error('a connector name is lowercase letters and digits in hyphen-separated words');
12
+ const program = `sema-connector-${name}`;
13
+ return {
14
+ program,
15
+ say: (line) => void process.stdout.write(`${line}\n`),
16
+ complain: (line) => void process.stderr.write(`${program}: ${line}\n`),
17
+ };
18
+ }
19
+ const SUBCOMMANDS = ['install', 'auth', 'mount', 'status'];
20
+ export async function connectorCli(commands, argv, env) {
21
+ const { complain, program } = cliOutput(commands.name);
22
+ const [command, ...rest] = argv;
23
+ if (command === undefined) {
24
+ const code = await commands.serve(env);
25
+ return typeof code === 'number' ? code : undefined;
26
+ }
27
+ const usage = `usage: ${program} [${SUBCOMMANDS.join(' | ')}] (no argument: the connector's MCP stdio server; auth reads ${commands.authReads} from standard input)`;
28
+ if (!SUBCOMMANDS.includes(command)) {
29
+ complain(`unknown subcommand\n${usage}`);
30
+ return 2;
31
+ }
32
+ if (rest.length > 0) {
33
+ complain(`${command} takes no arguments${command === 'auth' ? ` (it reads ${commands.authReads} from standard input, never from an argument)` : ''}\n${usage}`);
34
+ return 2;
35
+ }
36
+ const config = resolveConfigDir(env);
37
+ if (!config.ok) {
38
+ complain(config.reason);
39
+ return 2;
40
+ }
41
+ if (command === 'install')
42
+ return commands.install(config.dir);
43
+ if (command === 'auth')
44
+ return commands.auth(config.dir);
45
+ if (command === 'mount')
46
+ return mountCommand(commands.name, config.dir);
47
+ return statusCommand(commands.name, config.dir);
48
+ }
49
+ export async function readStdinLine(limit) {
50
+ if (process.stdin.isTTY)
51
+ return { ok: false, problem: 'standard input is a terminal here; pipe the input in instead (a token typed on a terminal would be shown)' };
52
+ const parts = [];
53
+ let size = 0;
54
+ for await (const chunk of process.stdin) {
55
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
56
+ size += b.length;
57
+ if (size > limit)
58
+ return { ok: false, problem: `standard input holds more than ${limit} bytes; one line is read` };
59
+ parts.push(b);
60
+ }
61
+ const line = Buffer.concat(parts).toString('utf8').replace(/\r?\n$/, '');
62
+ if (/[\r\n]/.test(line))
63
+ return { ok: false, problem: 'standard input must hold exactly one line' };
64
+ return { ok: true, line };
65
+ }
66
+ const listed = (keys) => (keys.length < 2 ? keys.join('') : `${keys.slice(0, -1).join(', ')} and ${keys[keys.length - 1]}`);
67
+ export async function readStdinJson(limit, keys) {
68
+ const read = await readStdinLine(limit);
69
+ if (!read.ok)
70
+ return read;
71
+ const shape = `standard input must be one JSON object with exactly the keys ${listed(keys)}, each a string, on one line`;
72
+ let value;
73
+ try {
74
+ value = JSON.parse(read.line);
75
+ }
76
+ catch {
77
+ return { ok: false, problem: shape };
78
+ }
79
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
80
+ return { ok: false, problem: shape };
81
+ if (hasRepeatedKey(read.line))
82
+ return { ok: false, problem: 'the object names a key more than once' };
83
+ const object = value;
84
+ const own = Object.keys(object);
85
+ if (own.length !== keys.length || !keys.every((k) => own.includes(k)))
86
+ return { ok: false, problem: `the object must have exactly the keys ${listed(keys)}` };
87
+ if (!keys.every((k) => typeof object[k] === 'string'))
88
+ return { ok: false, problem: shape };
89
+ return { ok: true, values: Object.fromEntries(keys.map((k) => [k, object[k]])) };
90
+ }
91
+ export function mountCommand(name, configDir) {
92
+ const { say, complain } = cliOutput(name);
93
+ let r;
94
+ try {
95
+ r = mount({ name, configDir });
96
+ }
97
+ catch (e) {
98
+ complain(`mount failed: ${e instanceof Error ? e.message : 'unknown error'}`);
99
+ return 1;
100
+ }
101
+ if (!r.ok) {
102
+ complain(`mount refused (${r.reason}): ${r.message}`);
103
+ return 1;
104
+ }
105
+ say(r.changed ? `mounted connector ${name} in ${r.file}; restart the engine to apply` : `connector ${name} is already mounted in ${r.file}`);
106
+ return 0;
107
+ }
108
+ const PROTOCOL_VERSION = '2025-11-25';
109
+ const STATUS_TIMEOUT_MS = 10_000;
110
+ const KILL_GRACE_MS = 2_000;
111
+ const SHOWN_LINES = 5;
112
+ const RECEIPT_PREFIX = 'sema-connector: receipt ';
113
+ function ownManifest() {
114
+ const url = new URL('../package.json', import.meta.url);
115
+ const manifest = JSON.parse(readFileSync(url, 'utf8'));
116
+ return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
117
+ }
118
+ function lineReader(stream) {
119
+ const lines = [];
120
+ const waiters = [];
121
+ let rest = '';
122
+ let ended = false;
123
+ stream.setEncoding('utf8');
124
+ stream.on('data', (chunk) => {
125
+ rest += chunk;
126
+ let at;
127
+ while ((at = rest.indexOf('\n')) !== -1) {
128
+ const line = rest.slice(0, at);
129
+ rest = rest.slice(at + 1);
130
+ const waiter = waiters.shift();
131
+ if (waiter)
132
+ waiter(line);
133
+ else
134
+ lines.push(line);
135
+ }
136
+ });
137
+ stream.on('close', () => {
138
+ ended = true;
139
+ for (const waiter of waiters.splice(0))
140
+ waiter(null);
141
+ });
142
+ return () => {
143
+ const line = lines.shift();
144
+ if (line !== undefined)
145
+ return Promise.resolve(line);
146
+ if (ended)
147
+ return Promise.resolve(null);
148
+ return new Promise((resolve) => waiters.push(resolve));
149
+ };
150
+ }
151
+ export async function statusCommand(name, configDir) {
152
+ const { program, say, complain } = cliOutput(name);
153
+ const own = ownManifest();
154
+ const child = spawn(process.execPath, [own.launcher, 'run', name, '--mode', 'standalone', '--transport', 'stdio', '--config-dir', configDir], { stdio: ['pipe', 'pipe', 'pipe'] });
155
+ const exited = new Promise((resolve) => child.on('exit', (code) => resolve(code)));
156
+ const errors = [];
157
+ child.stderr.on('data', (d) => errors.push(d));
158
+ child.stdin.on('error', () => undefined);
159
+ const nextLine = lineReader(child.stdout);
160
+ let timedOut = false;
161
+ const timer = setTimeout(() => {
162
+ timedOut = true;
163
+ child.stdout.destroy();
164
+ child.stderr.destroy();
165
+ child.kill('SIGTERM');
166
+ setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS).unref();
167
+ }, STATUS_TIMEOUT_MS);
168
+ const write = (message) => void child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n');
169
+ const reply = async (id, method, params) => {
170
+ write({ id, method, ...(params === undefined ? {} : { params }) });
171
+ for (;;) {
172
+ const line = await nextLine();
173
+ if (line === null)
174
+ return null;
175
+ try {
176
+ const m = JSON.parse(line);
177
+ if (m?.id === id)
178
+ return m;
179
+ }
180
+ catch { }
181
+ }
182
+ };
183
+ let found = null;
184
+ let problem = null;
185
+ try {
186
+ const init = await reply(1, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
187
+ if (init?.result === undefined)
188
+ problem = 'the launcher did not complete initialize';
189
+ else {
190
+ write({ method: 'notifications/initialized' });
191
+ const list = await reply(2, 'resources/list');
192
+ const entries = list?.result?.resources;
193
+ const uri = Array.isArray(entries) && entries.length === 1 ? entries[0]?.uri : undefined;
194
+ if (typeof uri !== 'string')
195
+ problem = 'the launcher did not list exactly one resource, its status';
196
+ else {
197
+ const read = await reply(3, 'resources/read', { uri });
198
+ const text = read?.result?.contents?.[0]?.text;
199
+ if (typeof text !== 'string')
200
+ problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
201
+ else
202
+ found = JSON.parse(text);
203
+ }
204
+ }
205
+ }
206
+ catch {
207
+ problem = 'the launcher answered with something that is not JSON';
208
+ }
209
+ finally {
210
+ child.stdin.end();
211
+ }
212
+ const code = await exited;
213
+ clearTimeout(timer);
214
+ if (found !== null && !timedOut) {
215
+ say(JSON.stringify(found));
216
+ return 0;
217
+ }
218
+ complain(timedOut ? `the launcher did not answer within ${STATUS_TIMEOUT_MS} ms` : (problem ?? `the launcher exited with code ${String(code)}`));
219
+ const said = Buffer.concat(errors).toString('utf8').split('\n').filter((l) => l.trim() !== '' && !l.startsWith(RECEIPT_PREFIX));
220
+ for (const line of said.slice(-SHOWN_LINES))
221
+ complain(`the launcher said: ${line}`);
222
+ return 1;
223
+ }
@@ -0,0 +1,11 @@
1
+ export type Env = Readonly<Record<string, string | undefined>>;
2
+ export declare const given: (v: string | undefined) => v is string;
3
+ export declare function absoluteDir(value: unknown): string | null;
4
+ export type ConfigDirResolution = {
5
+ readonly ok: true;
6
+ readonly dir: string;
7
+ } | {
8
+ readonly ok: false;
9
+ readonly reason: string;
10
+ };
11
+ export declare function resolveConfigDir(env: Env): ConfigDirResolution;
@@ -0,0 +1,12 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ export const given = (v) => v !== undefined && v !== '';
3
+ export function absoluteDir(value) {
4
+ return typeof value === 'string' && isAbsolute(value) ? resolve(value) : null;
5
+ }
6
+ export function resolveConfigDir(env) {
7
+ const value = env.CONFIG_LOCAL_DIR;
8
+ if (value === undefined || value === '')
9
+ return { ok: false, reason: 'CONFIG_LOCAL_DIR is not set: it names the configuration directory as an absolute path, and there is no default' };
10
+ const dir = absoluteDir(value);
11
+ return dir === null ? { ok: false, reason: 'CONFIG_LOCAL_DIR must be an absolute path' } : { ok: true, dir };
12
+ }
@@ -0,0 +1 @@
1
+ export declare const CONNECTOR_NAME: RegExp;
@@ -0,0 +1 @@
1
+ export const CONNECTOR_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
package/dist/http.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { Principal, PrincipalResolver } from './principal.js';
2
+ import { type JsonRpcId } from './session.js';
3
+ export declare const MCP_PATH = "/mcp";
4
+ export declare const STATUS_PATH = "/status";
5
+ export interface ConnectorLink {
6
+ send(raw: Buffer, principal: Principal): void;
7
+ readStatus(id: JsonRpcId, principal: Principal): void;
8
+ busy(): boolean;
9
+ end(signal?: NodeJS.Signals): void;
10
+ readonly gone: Promise<void>;
11
+ }
12
+ export type StartConnector = (deliver: (line: string) => void) => ConnectorLink;
13
+ export interface HttpFaceOptions {
14
+ readonly host: string;
15
+ readonly port: number;
16
+ readonly resolver: PrincipalResolver;
17
+ readonly start: StartConnector;
18
+ }
19
+ export type HttpFace = {
20
+ readonly ok: true;
21
+ close(signal: NodeJS.Signals): Promise<void>;
22
+ } | {
23
+ readonly ok: false;
24
+ readonly code: string;
25
+ };
26
+ export declare function serveHttp(options: HttpFaceOptions): Promise<HttpFace>;