@sema-agent/connectors 0.0.0-stage → 0.1.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 +181 -0
- package/bin/sema-connector.mjs +6 -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 +8 -0
- package/dist/index.js +4 -0
- package/dist/json-text.d.ts +11 -0
- package/dist/json-text.js +93 -0
- package/dist/jwks.d.ts +18 -0
- package/dist/jwks.js +78 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +585 -0
- package/dist/mount.d.ts +23 -0
- package/dist/mount.js +175 -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 +3 -0
- package/dist/secrets.d.ts +34 -0
- package/dist/secrets.js +163 -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 +19 -0
- package/package.json +37 -4
- package/README.md +0 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
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.1.0 (2026-10-14)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Package entry point and the `sema-connector` bin. The entry exports
|
|
12
|
+
`resolveConfigDir`, `openFileSecrets`, `verifyAssertion`, `standaloneResolver`,
|
|
13
|
+
`hostedResolver`, `ASSERTION_HEADER`, `mount` and their types; it exports
|
|
14
|
+
nothing that turns a secret handle into a value.
|
|
15
|
+
- The launcher behind the `sema-connector` bin (CN-003):
|
|
16
|
+
`sema-connector run <name> --mode standalone|hosted --transport stdio|http [--listen <ip>:<port>] [--config-dir <absolute path>]`.
|
|
17
|
+
Structural guards exit with code 2: a missing `run`, an unknown or repeated
|
|
18
|
+
argument, a missing or unknown `--mode` or `--transport`, standalone with
|
|
19
|
+
anything but stdio, hosted without http, without a `--listen <IPv4 or [IPv6]>:<port>`,
|
|
20
|
+
without `CONNECTOR_ASSERTION_ISSUER` or `CONNECTOR_ASSERTION_AUDIENCE` (CN-008),
|
|
21
|
+
with both key-set overrides, or with a key set URL that is neither `https://`
|
|
22
|
+
nor `http://` on `127.0.0.1` or `localhost`, or that carries credentials,
|
|
23
|
+
standalone without `--config-dir` or with one that is not an absolute path,
|
|
24
|
+
and hosted with `--config-dir` (CN-028). Standalone reads its configuration
|
|
25
|
+
directory from `--config-dir` only, never from an environment variable, so a
|
|
26
|
+
launcher started from a catalog entry with the engine's minimal environment
|
|
27
|
+
finds it (CN-028). A
|
|
28
|
+
connector `<name>` must be lowercase letters and digits in hyphen-separated
|
|
29
|
+
words, so that it can only name `@sema-agent/connector-<name>`. Error messages
|
|
30
|
+
never repeat argument or environment values.
|
|
31
|
+
- Hosted http face (CN-008): hosted mode that passes the guards obtains its key
|
|
32
|
+
set once (inline `CONNECTOR_ASSERTION_JWKS`, `CONNECTOR_ASSERTION_JWKS_URL`, or
|
|
33
|
+
`<issuer>/.well-known/jwks.json`; one GET, no redirect, never refreshed; exit
|
|
34
|
+
2 when it cannot), resolves its connector (exit 4) and serves the subset of MCP
|
|
35
|
+
Streamable HTTP the MCP SDK client needs on `--listen`: every request needs a
|
|
36
|
+
verifiable assertion in `connector-assertion` (HTTP 401 with
|
|
37
|
+
`connector.bad_assertion` otherwise, a bare name header included); `POST /mcp`
|
|
38
|
+
carries one message answered as one JSON body (notifications and replies 202);
|
|
39
|
+
one session at a time, each with its own connector process, named by
|
|
40
|
+
`mcp-session-id` (400 without it, 404 for another, 409 for a second
|
|
41
|
+
`initialize`); `mcp-protocol-version` must match the version the connector
|
|
42
|
+
chose; `GET /mcp` answers 405 (no stream); `DELETE /mcp` ends the session's
|
|
43
|
+
connector down the teardown ladder and answers 204 once it is gone; requests
|
|
44
|
+
still waiting when a connector ends are answered with `connector.internal`;
|
|
45
|
+
`GET /status` serves the status value under the request's own principal. An
|
|
46
|
+
id stays taken until the connector answers it (409 meanwhile), a message or a
|
|
47
|
+
status read for a connector that has not read its earlier input answers 503,
|
|
48
|
+
and connector
|
|
49
|
+
stderr a stalled log has not taken one step after the connector exited is
|
|
50
|
+
dropped; only a JSON-RPC answer (result or error) ends a request's wait. A stop
|
|
51
|
+
signal ends the open session and exits 0, abandoning output a stalled log has
|
|
52
|
+
not taken one step later. Exit code 3 is gone.
|
|
53
|
+
- An upstream message with a line break inside (a CR other than CRLF framing,
|
|
54
|
+
or an LF in an http body) is refused like an unreadable one: the connector
|
|
55
|
+
could read its parts as other messages than the one judged.
|
|
56
|
+
- The message session judges every upstream message under the principal of the
|
|
57
|
+
request that carried it (the stdio path passes the anonymous principal), and
|
|
58
|
+
a `tools/list` reply or a status read under the principal of the request it
|
|
59
|
+
answers.
|
|
60
|
+
- Resolution of `@sema-agent/connector-<name>` from the launcher's own
|
|
61
|
+
`package.json`; a package that cannot be resolved, or whose `bin` is not a
|
|
62
|
+
single name, exits with code 4 and `connector.not_installed`.
|
|
63
|
+
- MCP stdio path of newline-delimited messages in both directions, with no
|
|
64
|
+
runtime dependencies. The connector's stdout and stderr and the upstream
|
|
65
|
+
input all go through one pump with flow control, so a slow reader holds the
|
|
66
|
+
writer back instead of growing the launcher's memory, whether or not the
|
|
67
|
+
output contains newlines. The connector's stderr passes as it arrives; the
|
|
68
|
+
two message streams are read whole line by line, up to 4 MiB per line,
|
|
69
|
+
through the stages below, and nothing is forwarded unread; messages no stage
|
|
70
|
+
concerns are forwarded byte for byte. From upstream, a line that is not a
|
|
71
|
+
single JSON-RPC message within 4 MiB, that repeats a key in any object, or
|
|
72
|
+
whose id is neither a string nor a number gets one error with `id` null and
|
|
73
|
+
`data.code` `connector.forbidden_scope` (CN-004). From the connector, a
|
|
74
|
+
reply is forwarded only for a request the launcher forwarded and has not seen
|
|
75
|
+
answered; a longer line, a batch or a message with a repeated key is dropped,
|
|
76
|
+
and every pending request gets a `connector.internal` error instead (CN-005),
|
|
77
|
+
so a tool result over 4 MiB arrives as that error; their ids stay taken until
|
|
78
|
+
the connector answers them (a request with one is refused meanwhile), so a
|
|
79
|
+
late reply never answers another request (CN-008). An id is also taken while
|
|
80
|
+
its request is pending, so a request reusing it is refused, not forwarded
|
|
81
|
+
(CN-031). Upstream input is also held
|
|
82
|
+
back while upstream does not read the launcher's own replies. Once a reader has gone
|
|
83
|
+
away, what can no longer be delivered to it is dropped instead of waited for;
|
|
84
|
+
after a stop signal has run the teardown below to its end, undelivered output
|
|
85
|
+
is abandoned. When the connector process ends, every request it left
|
|
86
|
+
unanswered gets a JSON-RPC error with `data.code` `connector.internal` naming
|
|
87
|
+
how it ended. The connector exiting with code 0 on its own exits the launcher
|
|
88
|
+
with code 0. A non-zero exit, a signal or stalled input (below) exits with
|
|
89
|
+
code 5 (with one error with `id` null when no request was pending).
|
|
90
|
+
- The connector never outlives the launcher. It runs as its own process group,
|
|
91
|
+
so helper processes it starts (for example an upstream binary sharing its
|
|
92
|
+
stdio) go down with it. One teardown ladder, one second per step: end the
|
|
93
|
+
connector's input, then `SIGTERM` to the group, then `SIGKILL` to the group.
|
|
94
|
+
The end of upstream input enters the ladder at its first step; `SIGTERM`,
|
|
95
|
+
`SIGINT` or `SIGHUP` sent to the launcher enters it at the second, passing that
|
|
96
|
+
signal on; the connector's own exit enters it at the second for whatever is
|
|
97
|
+
left of its group. The ladder finishes inside an MCP stdio client's own
|
|
98
|
+
shutdown (2 seconds after closing input before `SIGTERM`, 2 seconds after
|
|
99
|
+
`SIGTERM` before `SIGKILL`). The exit code follows the connector process.
|
|
100
|
+
- Stalled input (CN-025): input reaches the connector one piece of at most
|
|
101
|
+
1 KiB at a time; a connector that takes none of its waiting input for ten
|
|
102
|
+
teardown steps (10 seconds) has stopped reading and enters the ladder at its
|
|
103
|
+
second step, on both transports. Its unanswered requests get
|
|
104
|
+
`connector.internal` naming the stalled input, and the run is abnormal
|
|
105
|
+
(stdio: exit 5). A client that only closes its pipe while input waits behind a
|
|
106
|
+
connector that stopped reading no longer leaves the launcher and the connector
|
|
107
|
+
running.
|
|
108
|
+
- Receipts (CN-031): one `connector-receipt-v1` receipt per `tools/call` that
|
|
109
|
+
names its tool, on both transports, minted by the launcher's session once the
|
|
110
|
+
call is settled and written as the stderr line `sema-connector: receipt <JSON>`:
|
|
111
|
+
`refused` when the launcher refused it, `ok` for a result, `failed` for a
|
|
112
|
+
result with `isError`, a connector error (with its `data.code` only when that
|
|
113
|
+
is a code of the closed set) or a `connector.internal` answer of the launcher.
|
|
114
|
+
It holds the connector and tool names, the start time, the duration, the
|
|
115
|
+
outcome and the error code, nothing of the call's arguments; a tool name that
|
|
116
|
+
carries a secret value is written as `[redacted]`. A connector
|
|
117
|
+
stderr line that would start a line of the launcher's stderr with
|
|
118
|
+
`sema-connector:` gets `connector: ` in front, so such lines are always the
|
|
119
|
+
launcher's own.
|
|
120
|
+
- The connector process gets a fixed environment instead of the launcher's:
|
|
121
|
+
`HOME`, `LOGNAME`, `USER`, `PATH`, `SHELL`, `TERM`, `LANG`, `LC_ALL` and `TZ`
|
|
122
|
+
when present, and `<NAME>_FILE` with the path of each of its secret files
|
|
123
|
+
(CN-005).
|
|
124
|
+
- `resolveConfigDir(env)`, for connector CLI subcommands: the configuration
|
|
125
|
+
directory is `CONFIG_LOCAL_DIR`, which must be an absolute path; not set or
|
|
126
|
+
empty is refused, and no other variable or default is read (CN-028).
|
|
127
|
+
- Secrets (CN-004): `openFileSecrets({ configDir, connector })` lists handles
|
|
128
|
+
(name, connector, source, optional expiry; never the value) and stores values
|
|
129
|
+
atomically. Layout: `<configuration directory>/connectors/<name>/` with mode
|
|
130
|
+
0700, one 0600 file per handle, optional `<handle>.meta.json` with
|
|
131
|
+
`expiresAt`. Listing refuses wider modes, symbolic links and unreadable
|
|
132
|
+
expiry data; the launcher then exits with code 2 and `connector.internal`
|
|
133
|
+
before the connector starts. The launcher reads each value once, through a
|
|
134
|
+
function the entry does not export, into a per-launch 0600 copy that the
|
|
135
|
+
connector receives and that is removed when the launcher ends, and keeps
|
|
136
|
+
exactly those values out of what it forwards, looking for them also behind
|
|
137
|
+
JSON escapes: a connector stdout line carrying one is withheld, a reply
|
|
138
|
+
becoming a `connector.internal` error, and the connector's stderr is
|
|
139
|
+
scrubbed, also across writes. A line that fails to be judged is not
|
|
140
|
+
forwarded.
|
|
141
|
+
- Principal (CN-004): `verifyAssertion(compactJws, { issuer, audience, keys, now })`
|
|
142
|
+
checks a JWS compact assertion with Node's crypto in the contract's order
|
|
143
|
+
(algorithm allow-list `EdDSA` / `ES256`, key by `kid` with matching key type
|
|
144
|
+
and no other `alg`, signature, presence of every required claim with `iat` a
|
|
145
|
+
non-negative integer and `jti` a non-empty string (CN-029), `iss`, `aud`,
|
|
146
|
+
`exp` with 30 seconds of tolerance, non-empty `sub` and `space`, `scope`
|
|
147
|
+
pattern); every refusal is
|
|
148
|
+
`connector.bad_assertion` with the name of the failed step and nothing of the
|
|
149
|
+
token. `hostedResolver` takes a principal only from a verifiable assertion in
|
|
150
|
+
the `connector-assertion` request header (`ASSERTION_HEADER`: the header
|
|
151
|
+
name is the engine's standard to define; this value is the proposal the
|
|
152
|
+
launcher reads until the engine exports its constant, CN-034; a hosted
|
|
153
|
+
catalog entry names it with the settings schema's `assertionHeader` key,
|
|
154
|
+
CN-020), so a bare name header is
|
|
155
|
+
refused; `standaloneResolver` is anonymous with scope
|
|
156
|
+
`tool:*`.
|
|
157
|
+
- Scope (CN-004): one judge for `tools/list` and `tools/call`: a tool is
|
|
158
|
+
granted by `tool:*` or `tool:<name>` in the principal's scope. Tools outside
|
|
159
|
+
the grant are not listed, and calls to them are answered with JSON-RPC error
|
|
160
|
+
`-32602` and `connector.forbidden_scope` without reaching the connector.
|
|
161
|
+
- Normalize (CN-005): listed tool annotations keep `title` and the hints at
|
|
162
|
+
their MCP default (`destructiveHint: true`, `idempotentHint: false`,
|
|
163
|
+
`openWorldHint: true`); `readOnlyHint`, other hint values and unknown keys
|
|
164
|
+
are removed.
|
|
165
|
+
- Status (CN-005): the launcher serves `sema-connector://<name>/status`, a
|
|
166
|
+
`connector-status-v1` value with state `unauthorized`, `authorized` or
|
|
167
|
+
`expired` from the handles read at start and their expiry, and the tools
|
|
168
|
+
the reading request's principal sees; hosted, also at `GET /status`
|
|
169
|
+
(CN-008). Reads never reach the connector; `resources/list` gains the
|
|
170
|
+
status entry (merged with the connector's own resources, never repeated), and
|
|
171
|
+
`initialize` gains the resources capability when the connector has none.
|
|
172
|
+
- `mount({ name, configDir })` (CN-005, CN-028): writes the standalone entry
|
|
173
|
+
into `<configDir>/config.d/mcp.json`, and the entry's arguments end with
|
|
174
|
+
`--config-dir <configDir>`, the same directory. `configDir` must be an
|
|
175
|
+
absolute path (used normalized); mount reads no environment variable and has
|
|
176
|
+
no default. The same entry again changes nothing, another entry under the
|
|
177
|
+
same name (also one naming another configuration directory, or none) is
|
|
178
|
+
refused as a conflict, every other byte of the file is kept, and the file is
|
|
179
|
+
replaced atomically with its mode kept, under a lock file that excludes
|
|
180
|
+
concurrent mount writers and that only its holder removes. Refusals are
|
|
181
|
+
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,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>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { INSPECT_LIMIT_BYTES, INTERNAL_ERROR, INVALID_REQUEST, STATUS_BEFORE_INITIALIZE, errorLine, idKey, isId, read } from './session.js';
|
|
4
|
+
export const MCP_PATH = '/mcp';
|
|
5
|
+
export const STATUS_PATH = '/status';
|
|
6
|
+
const SESSION_HEADER = 'mcp-session-id';
|
|
7
|
+
const VERSION_HEADER = 'mcp-protocol-version';
|
|
8
|
+
const BAD_ASSERTION = 'connector.bad_assertion';
|
|
9
|
+
const NOT_TAKEN = 'connector.forbidden_scope';
|
|
10
|
+
const INTERNAL = 'connector.internal';
|
|
11
|
+
class Live {
|
|
12
|
+
id = randomBytes(16).toString('hex');
|
|
13
|
+
waiting = new Map();
|
|
14
|
+
current = null;
|
|
15
|
+
initialized = false;
|
|
16
|
+
version = null;
|
|
17
|
+
ending = false;
|
|
18
|
+
link;
|
|
19
|
+
gone;
|
|
20
|
+
constructor(start, released) {
|
|
21
|
+
this.link = start((line) => this.deliver(line));
|
|
22
|
+
this.gone = this.link.gone.then(() => released(this));
|
|
23
|
+
}
|
|
24
|
+
deliver(line) {
|
|
25
|
+
let m;
|
|
26
|
+
try {
|
|
27
|
+
m = JSON.parse(line);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (m === null || typeof m !== 'object' || Array.isArray(m) || 'method' in m || !('result' in m || 'error' in m))
|
|
33
|
+
return;
|
|
34
|
+
const id = m.id;
|
|
35
|
+
if (isId(id))
|
|
36
|
+
this.waiting.get(idKey(id))?.answer(line);
|
|
37
|
+
else if (id === null)
|
|
38
|
+
this.current?.answer(line);
|
|
39
|
+
}
|
|
40
|
+
end(signal) {
|
|
41
|
+
this.ending = true;
|
|
42
|
+
this.link.end(signal);
|
|
43
|
+
return this.gone;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const refusal = (status, message, dataCode = NOT_TAKEN) => [status, errorLine(null, INVALID_REQUEST, message, dataCode)];
|
|
47
|
+
function reply(res, status, body, headers = {}) {
|
|
48
|
+
if (res.headersSent || res.writableEnded)
|
|
49
|
+
return;
|
|
50
|
+
const text = body === undefined ? undefined : body.endsWith('\n') ? body.slice(0, -1) : body;
|
|
51
|
+
res.writeHead(status, { ...(text !== undefined ? { 'content-type': 'application/json' } : {}), ...headers });
|
|
52
|
+
res.end(text);
|
|
53
|
+
}
|
|
54
|
+
function single(req, name) {
|
|
55
|
+
const values = req.headersDistinct[name];
|
|
56
|
+
if (values === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
return values.length === 1 ? values[0] : null;
|
|
59
|
+
}
|
|
60
|
+
const sameId = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
61
|
+
function readBody(req) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const parts = [];
|
|
64
|
+
let size = 0;
|
|
65
|
+
req.on('data', (chunk) => {
|
|
66
|
+
size += chunk.length;
|
|
67
|
+
if (size <= INSPECT_LIMIT_BYTES)
|
|
68
|
+
parts.push(chunk);
|
|
69
|
+
else
|
|
70
|
+
parts.length = 0;
|
|
71
|
+
});
|
|
72
|
+
req.on('end', () => resolve(size > INSPECT_LIMIT_BYTES ? 'oversized' : Buffer.concat(parts)));
|
|
73
|
+
req.on('error', () => resolve(null));
|
|
74
|
+
req.on('close', () => resolve(null));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
const isJsonMediaType = (value) => (value ?? '').split(';')[0]?.trim().toLowerCase() === 'application/json';
|
|
78
|
+
export function serveHttp(options) {
|
|
79
|
+
let live = null;
|
|
80
|
+
let closing = false;
|
|
81
|
+
const open = new Set();
|
|
82
|
+
const released = (l) => {
|
|
83
|
+
if (live === l)
|
|
84
|
+
live = null;
|
|
85
|
+
};
|
|
86
|
+
const named = (req) => {
|
|
87
|
+
const sid = single(req, SESSION_HEADER);
|
|
88
|
+
if (sid === undefined)
|
|
89
|
+
return refusal(400, 'the request names no session: open one with initialize, then send its mcp-session-id with every request');
|
|
90
|
+
if (sid === null)
|
|
91
|
+
return refusal(400, 'mcp-session-id is given more than once');
|
|
92
|
+
if (live === null || !live.initialized || live.ending || !sameId(sid, live.id))
|
|
93
|
+
return refusal(404, 'no open session has this mcp-session-id');
|
|
94
|
+
return live;
|
|
95
|
+
};
|
|
96
|
+
const judge = (l, body, principal, res, opening) => {
|
|
97
|
+
const r = read(body);
|
|
98
|
+
const id = r.kind === 'message' && typeof r.m.method === 'string' && isId(r.m.id) ? r.m.id : null;
|
|
99
|
+
const key = id === null ? null : idKey(id);
|
|
100
|
+
if (key !== null && l.waiting.has(key)) {
|
|
101
|
+
reply(res, ...refusal(409, 'a request with this id is already waiting for its answer'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (l.link.busy()) {
|
|
105
|
+
reply(res, ...refusal(503, 'the connector has not read its earlier input yet; send the request again later', INTERNAL));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
let answered = false;
|
|
109
|
+
const waiter = {
|
|
110
|
+
id: id ?? '',
|
|
111
|
+
answer: (line) => {
|
|
112
|
+
if (key !== null)
|
|
113
|
+
l.waiting.delete(key);
|
|
114
|
+
if (answered)
|
|
115
|
+
return;
|
|
116
|
+
answered = true;
|
|
117
|
+
const m = JSON.parse(line);
|
|
118
|
+
const headers = {};
|
|
119
|
+
if (opening) {
|
|
120
|
+
if (m.result !== undefined && m.result !== null && !l.ending) {
|
|
121
|
+
l.initialized = true;
|
|
122
|
+
l.version = typeof m.result.protocolVersion === 'string' ? m.result.protocolVersion : null;
|
|
123
|
+
headers[SESSION_HEADER] = l.id;
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
void l.end();
|
|
127
|
+
}
|
|
128
|
+
reply(res, m.id === null ? 400 : 200, line, headers);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
if (key !== null)
|
|
132
|
+
l.waiting.set(key, waiter);
|
|
133
|
+
res.on('close', () => {
|
|
134
|
+
if (answered)
|
|
135
|
+
return;
|
|
136
|
+
answered = true;
|
|
137
|
+
if (opening)
|
|
138
|
+
void l.end();
|
|
139
|
+
});
|
|
140
|
+
l.current = waiter;
|
|
141
|
+
try {
|
|
142
|
+
l.link.send(body, principal);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
l.current = null;
|
|
146
|
+
}
|
|
147
|
+
if (!answered && key === null) {
|
|
148
|
+
answered = true;
|
|
149
|
+
if (opening)
|
|
150
|
+
void l.end();
|
|
151
|
+
reply(res, 202);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
const post = async (req, res, principal) => {
|
|
155
|
+
if (!isJsonMediaType(req.headers['content-type']))
|
|
156
|
+
return reply(res, ...refusal(415, 'the body must be one JSON-RPC message with Content-Type application/json'));
|
|
157
|
+
const body = await readBody(req);
|
|
158
|
+
if (body === null)
|
|
159
|
+
return;
|
|
160
|
+
if (body === 'oversized')
|
|
161
|
+
return reply(res, ...refusal(413, `the body is over ${INSPECT_LIMIT_BYTES / (1024 * 1024)} MiB, more than the launcher reads to check a message against the granted scope`));
|
|
162
|
+
if (closing)
|
|
163
|
+
return reply(res, ...refusal(503, 'the launcher is stopping', INTERNAL));
|
|
164
|
+
const r = read(body);
|
|
165
|
+
if (r.kind === 'message' && r.m.method === 'initialize') {
|
|
166
|
+
if (live !== null)
|
|
167
|
+
return reply(res, ...refusal(409, 'a session is already open: the launcher serves one session at a time'));
|
|
168
|
+
live = new Live(options.start, released);
|
|
169
|
+
return judge(live, body, principal, res, true);
|
|
170
|
+
}
|
|
171
|
+
const l = named(req);
|
|
172
|
+
if (Array.isArray(l))
|
|
173
|
+
return reply(res, ...l);
|
|
174
|
+
const version = single(req, VERSION_HEADER);
|
|
175
|
+
if (version === null || (version !== undefined && version !== l.version))
|
|
176
|
+
return reply(res, ...refusal(400, 'mcp-protocol-version is not the version the connector answered initialize with'));
|
|
177
|
+
judge(l, body, principal, res, false);
|
|
178
|
+
};
|
|
179
|
+
const remove = async (req, res) => {
|
|
180
|
+
const l = named(req);
|
|
181
|
+
if (Array.isArray(l))
|
|
182
|
+
return reply(res, ...l);
|
|
183
|
+
await l.end();
|
|
184
|
+
reply(res, 204);
|
|
185
|
+
};
|
|
186
|
+
const status = (res, principal) => {
|
|
187
|
+
const l = live;
|
|
188
|
+
if (l === null || !l.initialized || l.ending)
|
|
189
|
+
return reply(res, 503, errorLine(null, INTERNAL_ERROR, STATUS_BEFORE_INITIALIZE, INTERNAL));
|
|
190
|
+
if (l.link.busy())
|
|
191
|
+
return reply(res, ...refusal(503, 'the connector has not read its earlier input yet; read the status again later', INTERNAL));
|
|
192
|
+
const id = `status/${randomUUID()}`;
|
|
193
|
+
const key = idKey(id);
|
|
194
|
+
const waiter = {
|
|
195
|
+
id,
|
|
196
|
+
answer: (line) => {
|
|
197
|
+
l.waiting.delete(key);
|
|
198
|
+
const m = JSON.parse(line);
|
|
199
|
+
const text = m.result?.contents?.[0]?.text;
|
|
200
|
+
if (typeof text === 'string')
|
|
201
|
+
reply(res, 200, text);
|
|
202
|
+
else
|
|
203
|
+
reply(res, 503, line);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
l.waiting.set(key, waiter);
|
|
207
|
+
try {
|
|
208
|
+
l.link.readStatus(id, principal);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
l.waiting.delete(key);
|
|
212
|
+
reply(res, 503, errorLine(id, INTERNAL_ERROR, 'the status could not be read', INTERNAL));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const failed = (res) => () => reply(res, 500, errorLine(null, INTERNAL_ERROR, 'the launcher could not handle this request', INTERNAL));
|
|
216
|
+
const handle = (req, res) => {
|
|
217
|
+
open.add(res);
|
|
218
|
+
res.on('close', () => open.delete(res));
|
|
219
|
+
if (closing)
|
|
220
|
+
return reply(res, ...refusal(503, 'the launcher is stopping', INTERNAL), { connection: 'close' });
|
|
221
|
+
const verdict = options.resolver.resolve({ headers: req.headersDistinct });
|
|
222
|
+
if (!verdict.ok)
|
|
223
|
+
return reply(res, ...refusal(401, verdict.reason, BAD_ASSERTION));
|
|
224
|
+
const path = new URL(req.url ?? '/', 'http://launcher.invalid').pathname;
|
|
225
|
+
if (path === STATUS_PATH) {
|
|
226
|
+
if (req.method === 'GET')
|
|
227
|
+
return status(res, verdict.principal);
|
|
228
|
+
return reply(res, ...refusal(405, '/status takes GET only'), { allow: 'GET' });
|
|
229
|
+
}
|
|
230
|
+
if (path !== MCP_PATH)
|
|
231
|
+
return reply(res, ...refusal(404, 'the launcher serves /mcp and /status only'));
|
|
232
|
+
if (req.method === 'POST')
|
|
233
|
+
return void post(req, res, verdict.principal).catch(failed(res));
|
|
234
|
+
if (req.method === 'DELETE')
|
|
235
|
+
return void remove(req, res).catch(failed(res));
|
|
236
|
+
return reply(res, ...refusal(405, '/mcp takes POST and DELETE: the evaluation form opens no stream (GET)'), { allow: 'POST, DELETE' });
|
|
237
|
+
};
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
const server = createServer((req, res) => {
|
|
240
|
+
try {
|
|
241
|
+
handle(req, res);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
failed(res)();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
server.once('error', (e) => resolve({ ok: false, code: e.code ?? 'unknown' }));
|
|
248
|
+
server.listen(options.port, options.host, () => {
|
|
249
|
+
resolve({
|
|
250
|
+
ok: true,
|
|
251
|
+
close: async (signal) => {
|
|
252
|
+
closing = true;
|
|
253
|
+
server.close();
|
|
254
|
+
const l = live;
|
|
255
|
+
if (l !== null)
|
|
256
|
+
await l.end(signal);
|
|
257
|
+
const finished = [...open].map((res) => new Promise((done) => (res.writableFinished ? done() : res.once('close', () => done()))));
|
|
258
|
+
await Promise.race([Promise.all(finished), new Promise((done) => setTimeout(done, 1_000).unref())]);
|
|
259
|
+
server.closeAllConnections();
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { resolveConfigDir } from './config-dir.js';
|
|
2
|
+
export type { ConfigDirResolution, Env } from './config-dir.js';
|
|
3
|
+
export { openFileSecrets } from './secrets.js';
|
|
4
|
+
export type { FileSecrets, PutOptions, SecretHandle, SecretListing } from './secrets.js';
|
|
5
|
+
export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion } from './principal.js';
|
|
6
|
+
export type { AnonymousPrincipal, AssertionPrincipal, AssertionStep, Principal, PrincipalResolver, PrincipalResult, RequestContext, VerifyOptions, VerifyResult, } from './principal.js';
|
|
7
|
+
export { mount } from './mount.js';
|
|
8
|
+
export type { MountOptions, MountRefusal, MountResult } from './mount.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare function skipWhitespace(t: string, i: number): number;
|
|
2
|
+
export declare function endOfString(t: string, i: number): number;
|
|
3
|
+
export declare function endOfValue(t: string, i: number): number;
|
|
4
|
+
export interface Span {
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly start: number;
|
|
7
|
+
readonly valueStart: number;
|
|
8
|
+
readonly valueEnd: number;
|
|
9
|
+
}
|
|
10
|
+
export declare function spansOf(t: string, open: number, keyed: boolean): Span[];
|
|
11
|
+
export declare function hasRepeatedKey(t: string): boolean;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const WHITESPACE = ' \t\n\r';
|
|
2
|
+
export function skipWhitespace(t, i) {
|
|
3
|
+
while (i < t.length && WHITESPACE.includes(t.charAt(i)))
|
|
4
|
+
i++;
|
|
5
|
+
return i;
|
|
6
|
+
}
|
|
7
|
+
export function endOfString(t, i) {
|
|
8
|
+
for (i++; i < t.length; i++) {
|
|
9
|
+
if (t[i] === '\\')
|
|
10
|
+
i++;
|
|
11
|
+
else if (t[i] === '"')
|
|
12
|
+
return i + 1;
|
|
13
|
+
}
|
|
14
|
+
return t.length;
|
|
15
|
+
}
|
|
16
|
+
export function endOfValue(t, i) {
|
|
17
|
+
if (t[i] === '"')
|
|
18
|
+
return endOfString(t, i);
|
|
19
|
+
if (t[i] === '{' || t[i] === '[') {
|
|
20
|
+
let depth = 0;
|
|
21
|
+
while (i < t.length) {
|
|
22
|
+
const c = t[i];
|
|
23
|
+
if (c === '"') {
|
|
24
|
+
i = endOfString(t, i);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (c === '{' || c === '[')
|
|
28
|
+
depth++;
|
|
29
|
+
else if ((c === '}' || c === ']') && --depth === 0)
|
|
30
|
+
return i + 1;
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
return t.length;
|
|
34
|
+
}
|
|
35
|
+
while (i < t.length && !',}]'.includes(t.charAt(i)) && !WHITESPACE.includes(t.charAt(i)))
|
|
36
|
+
i++;
|
|
37
|
+
return i;
|
|
38
|
+
}
|
|
39
|
+
export function spansOf(t, open, keyed) {
|
|
40
|
+
const close = keyed ? '}' : ']';
|
|
41
|
+
const spans = [];
|
|
42
|
+
let i = skipWhitespace(t, open + 1);
|
|
43
|
+
while (i < t.length && t[i] !== close) {
|
|
44
|
+
const start = i;
|
|
45
|
+
let key = '';
|
|
46
|
+
if (keyed) {
|
|
47
|
+
const keyEnd = endOfString(t, i);
|
|
48
|
+
key = JSON.parse(t.slice(i, keyEnd));
|
|
49
|
+
i = skipWhitespace(t, skipWhitespace(t, keyEnd) + 1);
|
|
50
|
+
}
|
|
51
|
+
const valueStart = i;
|
|
52
|
+
const valueEnd = endOfValue(t, i);
|
|
53
|
+
spans.push({ key, start, valueStart, valueEnd });
|
|
54
|
+
i = skipWhitespace(t, valueEnd);
|
|
55
|
+
if (t[i] === ',')
|
|
56
|
+
i = skipWhitespace(t, i + 1);
|
|
57
|
+
}
|
|
58
|
+
return spans;
|
|
59
|
+
}
|
|
60
|
+
export function hasRepeatedKey(t) {
|
|
61
|
+
const objects = [];
|
|
62
|
+
let expectKey = false;
|
|
63
|
+
for (let i = 0; i < t.length; i++) {
|
|
64
|
+
const c = t[i];
|
|
65
|
+
if (c === '"') {
|
|
66
|
+
const end = endOfString(t, i);
|
|
67
|
+
const top = objects[objects.length - 1];
|
|
68
|
+
if (expectKey && top instanceof Set) {
|
|
69
|
+
const key = JSON.parse(t.slice(i, end));
|
|
70
|
+
if (top.has(key))
|
|
71
|
+
return true;
|
|
72
|
+
top.add(key);
|
|
73
|
+
expectKey = false;
|
|
74
|
+
}
|
|
75
|
+
i = end - 1;
|
|
76
|
+
}
|
|
77
|
+
else if (c === '{') {
|
|
78
|
+
objects.push(new Set());
|
|
79
|
+
expectKey = true;
|
|
80
|
+
}
|
|
81
|
+
else if (c === '[') {
|
|
82
|
+
objects.push(null);
|
|
83
|
+
expectKey = false;
|
|
84
|
+
}
|
|
85
|
+
else if (c === '}' || c === ']') {
|
|
86
|
+
objects.pop();
|
|
87
|
+
expectKey = false;
|
|
88
|
+
}
|
|
89
|
+
else if (c === ',')
|
|
90
|
+
expectKey = objects[objects.length - 1] instanceof Set;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
package/dist/jwks.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Env } from './config-dir.js';
|
|
2
|
+
export type KeySetSource = {
|
|
3
|
+
readonly kind: 'inline';
|
|
4
|
+
readonly text: string;
|
|
5
|
+
} | {
|
|
6
|
+
readonly kind: 'url';
|
|
7
|
+
readonly from: string;
|
|
8
|
+
readonly url: URL;
|
|
9
|
+
};
|
|
10
|
+
export type KeySet = {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
readonly keys: unknown;
|
|
13
|
+
} | {
|
|
14
|
+
readonly ok: false;
|
|
15
|
+
readonly problem: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function keySetSource(env: Env, issuer: string): KeySetSource | string;
|
|
18
|
+
export declare function obtainKeySet(source: KeySetSource): Promise<KeySet>;
|