@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/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
+ }
@@ -0,0 +1,12 @@
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';
9
+ export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
10
+ export type { CliOutput, ConnectorCommands, StdinJson, StdinLine } from './cli-kit.js';
11
+ export { isLoopbackHttpOrHttps } from './jwks.js';
12
+ export { writeAll } from './write-all.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { resolveConfigDir } from './config-dir.js';
2
+ export { openFileSecrets } from './secrets.js';
3
+ export { ASSERTION_HEADER, hostedResolver, standaloneResolver, verifyAssertion } from './principal.js';
4
+ export { mount } from './mount.js';
5
+ export { cliOutput, connectorCli, mountCommand, readStdinJson, readStdinLine, statusCommand } from './cli-kit.js';
6
+ export { isLoopbackHttpOrHttps } from './jwks.js';
7
+ export { writeAll } from './write-all.js';
@@ -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,19 @@
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 isLoopbackHttpOrHttps(url: URL): boolean;
18
+ export declare function keySetSource(env: Env, issuer: string): KeySetSource | string;
19
+ export declare function obtainKeySet(source: KeySetSource): Promise<KeySet>;
package/dist/jwks.js ADDED
@@ -0,0 +1,81 @@
1
+ import { get as httpGet } from 'node:http';
2
+ import { get as httpsGet } from 'node:https';
3
+ import { given } from './config-dir.js';
4
+ const INLINE = 'CONNECTOR_ASSERTION_JWKS';
5
+ const OVERRIDE = 'CONNECTOR_ASSERTION_JWKS_URL';
6
+ const WELL_KNOWN = '/.well-known/jwks.json';
7
+ const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost'];
8
+ export function isLoopbackHttpOrHttps(url) {
9
+ return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK_HOSTS.includes(url.hostname));
10
+ }
11
+ const FETCH_LIMIT_BYTES = 1024 * 1024;
12
+ const FETCH_TIMEOUT_MS = 10_000;
13
+ export function keySetSource(env, issuer) {
14
+ const inline = env[INLINE];
15
+ const override = env[OVERRIDE];
16
+ if (given(override) && given(inline))
17
+ return `both ${OVERRIDE} and ${INLINE} are set; the key set must have one source`;
18
+ if (given(inline))
19
+ return { kind: 'inline', text: inline };
20
+ const from = given(override) ? OVERRIDE : `the default key set location (CONNECTOR_ASSERTION_ISSUER followed by ${WELL_KNOWN})`;
21
+ let url;
22
+ try {
23
+ url = new URL(given(override) ? override : issuer.replace(/\/+$/, '') + WELL_KNOWN);
24
+ }
25
+ catch {
26
+ return `${from} is not a URL`;
27
+ }
28
+ if (url.username !== '' || url.password !== '')
29
+ return `${from} carries credentials; a key set URL never does`;
30
+ if (isLoopbackHttpOrHttps(url))
31
+ return { kind: 'url', from, url };
32
+ return `${from} must be an https URL, or an http URL on 127.0.0.1 or localhost`;
33
+ }
34
+ const isKeySet = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && Array.isArray(v.keys);
35
+ export async function obtainKeySet(source) {
36
+ const what = source.kind === 'inline' ? INLINE : `the key set from ${source.from}`;
37
+ let text;
38
+ if (source.kind === 'inline')
39
+ text = source.text;
40
+ else {
41
+ const fetched = await fetchOnce(source.url);
42
+ if (!fetched.ok)
43
+ return { ok: false, problem: `${what} could not be fetched: ${fetched.why}` };
44
+ text = fetched.text;
45
+ }
46
+ let value;
47
+ try {
48
+ value = JSON.parse(text);
49
+ }
50
+ catch {
51
+ return { ok: false, problem: `${what} is not JSON` };
52
+ }
53
+ return isKeySet(value) ? { ok: true, keys: value } : { ok: false, problem: `${what} is not a JSON Web Key Set (an object with a keys list)` };
54
+ }
55
+ function fetchOnce(url) {
56
+ return new Promise((resolve) => {
57
+ const get = url.protocol === 'https:' ? httpsGet : httpGet;
58
+ const req = get(url, { agent: false, headers: { accept: 'application/json' } }, (res) => {
59
+ if (res.statusCode !== 200) {
60
+ res.resume();
61
+ resolve({ ok: false, why: `it answered HTTP ${String(res.statusCode)}` });
62
+ return;
63
+ }
64
+ const parts = [];
65
+ let size = 0;
66
+ res.on('data', (chunk) => {
67
+ size += chunk.length;
68
+ if (size > FETCH_LIMIT_BYTES) {
69
+ req.destroy();
70
+ resolve({ ok: false, why: `its answer is over ${FETCH_LIMIT_BYTES / (1024 * 1024)} MiB` });
71
+ }
72
+ else
73
+ parts.push(chunk);
74
+ });
75
+ res.on('end', () => resolve({ ok: true, text: Buffer.concat(parts).toString('utf8') }));
76
+ res.on('error', () => resolve({ ok: false, why: 'its answer broke off' }));
77
+ });
78
+ req.setTimeout(FETCH_TIMEOUT_MS, () => req.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));
79
+ req.on('error', (e) => resolve({ ok: false, why: `the request failed (${e.code ?? 'no answer'})` }));
80
+ });
81
+ }
@@ -0,0 +1,2 @@
1
+ import { type Env } from './config-dir.js';
2
+ export declare function main(argv: readonly string[], env: Env): Promise<number>;