@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 +247 -0
- package/bin/sema-connector.mjs +6 -0
- package/dist/cli-kit.d.ts +33 -0
- package/dist/cli-kit.js +223 -0
- package/dist/config-dir.d.ts +11 -0
- package/dist/config-dir.js +12 -0
- package/dist/connector-name.d.ts +1 -0
- package/dist/connector-name.js +1 -0
- package/dist/http.d.ts +26 -0
- package/dist/http.js +264 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/json-text.d.ts +11 -0
- package/dist/json-text.js +93 -0
- package/dist/jwks.d.ts +19 -0
- package/dist/jwks.js +81 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +585 -0
- package/dist/lock-file.d.ts +1 -0
- package/dist/lock-file.js +34 -0
- package/dist/mount.d.ts +23 -0
- package/dist/mount.js +160 -0
- package/dist/normalize.d.ts +1 -0
- package/dist/normalize.js +19 -0
- package/dist/principal.d.ts +50 -0
- package/dist/principal.js +127 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +121 -0
- package/dist/scope.d.ts +1 -0
- package/dist/scope.js +5 -0
- package/dist/secrets.d.ts +49 -0
- package/dist/secrets.js +264 -0
- package/dist/session.d.ts +59 -0
- package/dist/session.js +256 -0
- package/dist/status.d.ts +12 -0
- package/dist/status.js +21 -0
- package/dist/write-all.d.ts +1 -0
- package/dist/write-all.js +15 -0
- package/package.json +37 -4
- package/README.md +0 -3
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>;
|
package/dist/cli-kit.js
ADDED
|
@@ -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>;
|