@sema-agent/connectors 0.2.0 → 0.4.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/seed.js ADDED
@@ -0,0 +1,134 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { absoluteDir } from './config-dir.js';
5
+ import { hasRepeatedKey } from './json-text.js';
6
+ import { mount, nameRefusal } from './mount.js';
7
+ import { takeLockFile } from './lock-file.js';
8
+ import { resolveConnector } from './resolve-connector.js';
9
+ import { writeFailure, writeAll } from './write-all.js';
10
+ export const DEFAULT_CONNECTORS = Object.freeze([Object.freeze({ name: 'gitea', since: 1 }), Object.freeze({ name: 'github', since: 1 })]);
11
+ export const SEED_VERSION = newestVersion(DEFAULT_CONNECTORS);
12
+ function newestVersion(catalog) {
13
+ const names = new Set();
14
+ for (const c of catalog) {
15
+ const refusal = nameRefusal(c.name);
16
+ if (refusal !== null)
17
+ throw new Error(`the default catalog names a connector mount refuses (${refusal.reason})`);
18
+ if (names.has(c.name))
19
+ throw new Error(`the default catalog names ${c.name} twice`);
20
+ names.add(c.name);
21
+ if (!Number.isSafeInteger(c.since) || c.since < 1)
22
+ throw new Error(`the default catalog gives ${c.name} a since that is not a positive integer`);
23
+ }
24
+ if (catalog.length === 0)
25
+ throw new Error('the default catalog is empty');
26
+ return Math.max(...catalog.map((c) => c.since));
27
+ }
28
+ const MARKER_FILE = '.sema-connector-seed.json';
29
+ const SEED_LOCK_FILE = `${MARKER_FILE}.lock`;
30
+ const RECORDED = ['mounted', 'present', 'not_installed'];
31
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ function readMarker(file) {
33
+ const where = `config.d/${MARKER_FILE}`;
34
+ let text;
35
+ try {
36
+ text = readFileSync(file, 'utf8');
37
+ }
38
+ catch (e) {
39
+ if (e.code === 'ENOENT')
40
+ return { marker: null };
41
+ return { refusal: 'unreadable', message: `${where} cannot be read` };
42
+ }
43
+ let doc;
44
+ try {
45
+ doc = JSON.parse(text);
46
+ }
47
+ catch {
48
+ return { refusal: 'unreadable', message: `${where} is not valid JSON` };
49
+ }
50
+ const shape = `${where} must be one JSON object {"version": <a positive integer>, "entries": {"<name>": "mounted" | "present" | "not_installed"}}`;
51
+ if (!isObject(doc) || Object.keys(doc).sort().join() !== 'entries,version' || hasRepeatedKey(text))
52
+ return { refusal: 'unexpected_shape', message: shape };
53
+ const { version, entries } = doc;
54
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1 || !isObject(entries))
55
+ return { refusal: 'unexpected_shape', message: shape };
56
+ for (const [name, value] of Object.entries(entries)) {
57
+ if (nameRefusal(name) !== null || typeof value !== 'string' || !RECORDED.includes(value))
58
+ return { refusal: 'unexpected_shape', message: shape };
59
+ }
60
+ return { marker: { version, entries: entries } };
61
+ }
62
+ export function seed(options) {
63
+ const configDir = absoluteDir(options?.configDir);
64
+ if (configDir === null)
65
+ return { ok: false, reason: 'config_dir', message: 'configDir must be the configuration directory as an absolute path; the seed reads no environment variable and has no default', readings: [] };
66
+ const dir = join(configDir, 'config.d');
67
+ let unlock;
68
+ try {
69
+ mkdirSync(dir, { recursive: true });
70
+ unlock = takeLockFile(join(dir, SEED_LOCK_FILE));
71
+ }
72
+ catch (e) {
73
+ return { ok: false, reason: 'write_failed', message: `config.d or the seed's lock could not be written (${writeFailure(e)}); nothing was mounted`, readings: [] };
74
+ }
75
+ if (unlock === null)
76
+ return { ok: false, reason: 'locked', message: `another seed is running on this directory; nothing was mounted: try again, and if no seed is running remove config.d/${SEED_LOCK_FILE}`, readings: [] };
77
+ try {
78
+ return seedLocked(configDir, dir);
79
+ }
80
+ finally {
81
+ unlock();
82
+ }
83
+ }
84
+ function seedLocked(configDir, dir) {
85
+ const file = join(dir, MARKER_FILE);
86
+ const read = readMarker(file);
87
+ if ('refusal' in read)
88
+ return { ok: false, reason: read.refusal, message: `${read.message}; nothing was mounted`, readings: [] };
89
+ const before = read.marker;
90
+ const entries = { ...before?.entries };
91
+ const readings = [];
92
+ for (const { name } of DEFAULT_CONNECTORS) {
93
+ const recorded = before !== null && Object.hasOwn(before.entries, name) ? before.entries[name] : undefined;
94
+ if (recorded === 'mounted' || recorded === 'present') {
95
+ readings.push({ name, outcome: 'present' });
96
+ continue;
97
+ }
98
+ const resolved = resolveConnector(name);
99
+ if ('reason' in resolved) {
100
+ readings.push({ name, outcome: 'not_installed', message: `package ${resolved.packageName} ${resolved.reason}` });
101
+ entries[name] = 'not_installed';
102
+ continue;
103
+ }
104
+ const r = mount({ name, configDir });
105
+ if (r.ok) {
106
+ const outcome = r.changed ? 'mounted' : 'present';
107
+ readings.push({ name, outcome });
108
+ entries[name] = outcome;
109
+ }
110
+ else if (r.reason === 'conflict')
111
+ readings.push({ name, outcome: 'conflict' });
112
+ else
113
+ readings.push({ name, outcome: 'refused', reason: r.reason, message: r.message });
114
+ }
115
+ const after = { version: SEED_VERSION, entries };
116
+ if (before === null || JSON.stringify(after) !== JSON.stringify(before)) {
117
+ const tmp = join(dir, `${MARKER_FILE}.${randomBytes(6).toString('hex')}.tmp`);
118
+ try {
119
+ const fd = openSync(tmp, 'wx', 0o666);
120
+ try {
121
+ writeAll(fd, JSON.stringify(after, null, 2) + '\n');
122
+ }
123
+ finally {
124
+ closeSync(fd);
125
+ }
126
+ renameSync(tmp, file);
127
+ }
128
+ catch (e) {
129
+ rmSync(tmp, { force: true });
130
+ return { ok: false, reason: 'write_failed', message: `config.d/${MARKER_FILE} could not be written (${writeFailure(e)}); it was left as it was, and the entries mounted stay mounted`, readings };
131
+ }
132
+ }
133
+ return { ok: true, readings, marker: file };
134
+ }
package/dist/session.d.ts CHANGED
@@ -11,6 +11,7 @@ export type Action = {
11
11
  export declare const INSPECT_LIMIT_BYTES: number;
12
12
  export declare const INVALID_REQUEST = -32600;
13
13
  export declare const INTERNAL_ERROR = -32603;
14
+ export declare const MCP_PROTOCOL_VERSION = "2025-11-25";
14
15
  export declare const STATUS_BEFORE_INITIALIZE = "the status is not available before the connector has answered initialize";
15
16
  export type Message = Record<string, unknown>;
16
17
  export interface SessionOptions {
@@ -18,6 +19,7 @@ export interface SessionOptions {
18
19
  readonly status: (tools: readonly string[]) => ConnectorMintedStatus;
19
20
  readonly ownIdPrefix: string;
20
21
  readonly withheld: (line: Buffer, parsed: unknown) => boolean;
22
+ readonly parked?: true;
21
23
  }
22
24
  export declare const isId: (v: unknown) => v is JsonRpcId;
23
25
  export declare const idKey: (id: JsonRpcId) => string;
@@ -32,6 +34,10 @@ export type Reading = {
32
34
  };
33
35
  export declare function read(raw: Buffer): Reading;
34
36
  export declare const errorLine: (id: JsonRpcId | null, code: number, message: string, dataCode: ConnectorErrorCode) => string;
37
+ export declare function parkedAnswer(line: Buffer | string, connector: string, serverInfo: {
38
+ readonly name: string;
39
+ readonly version: string;
40
+ }): Message | null;
35
41
  export declare class Session {
36
42
  private readonly options;
37
43
  private readonly uri;
package/dist/session.js CHANGED
@@ -8,15 +8,19 @@ const LIMIT_TEXT = `${INSPECT_LIMIT_BYTES / (1024 * 1024)} MiB`;
8
8
  const MAX_STATUS_PAGES = 64;
9
9
  const FORBIDDEN_SCOPE = 'connector.forbidden_scope';
10
10
  const INTERNAL = 'connector.internal';
11
+ const AUTH_REQUIRED = 'connector.auth_required';
11
12
  export const INVALID_REQUEST = -32600;
13
+ const METHOD_NOT_FOUND = -32601;
12
14
  const INVALID_PARAMS = -32602;
13
15
  export const INTERNAL_ERROR = -32603;
16
+ export const MCP_PROTOCOL_VERSION = '2025-11-25';
14
17
  const UNREADABLE_REQUEST = `the launcher forwards only single JSON-RPC messages of at most ${LIMIT_TEXT}, on one line and without repeated keys, which it can check against the granted scope`;
15
18
  const WITHHELD = 'the connector wrote one of its secret values into this reply, so the launcher withheld it';
16
19
  const NO_ID = 'the launcher forwards a request only with a string or number id, and needs one on the requests whose replies it rewrites or answers';
17
20
  export const STATUS_BEFORE_INITIALIZE = 'the status is not available before the connector has answered initialize';
18
21
  const TAKEN_ID = 'the connector may still answer an earlier request with this id (it has not answered it yet, or the launcher answered it after a line it could not read); use another id';
19
22
  const UNREADABLE_REPLY = `the connector wrote a message the launcher cannot read (over ${LIMIT_TEXT}, a batch, or a repeated key), which may have been this reply`;
23
+ const noCredentials = (connector) => `connector ${connector} has no credentials; run its auth`;
20
24
  const ERROR_CODES = new Set(Object.keys({
21
25
  'connector.auth_required': 0,
22
26
  'connector.auth_expired': 0,
@@ -49,7 +53,31 @@ export function read(raw) {
49
53
  return hasRepeatedKey(text) ? { kind: 'unreadable' } : { kind: 'message', m };
50
54
  }
51
55
  const lineOf = (m) => JSON.stringify(m) + '\n';
52
- export const errorLine = (id, code, message, dataCode) => lineOf({ jsonrpc: '2.0', id, error: { code, message, data: { code: dataCode } } });
56
+ const errorOf = (id, code, message, dataCode) => ({ jsonrpc: '2.0', id, error: { code, message, data: { code: dataCode } } });
57
+ export const errorLine = (id, code, message, dataCode) => lineOf(errorOf(id, code, message, dataCode));
58
+ export function parkedAnswer(line, connector, serverInfo) {
59
+ const r = read(typeof line === 'string' ? Buffer.from(line, 'utf8') : line);
60
+ if (r.kind !== 'message' || typeof r.m.method !== 'string' || !isId(r.m.id))
61
+ return null;
62
+ const id = r.m.id;
63
+ const result = (value) => ({ jsonrpc: '2.0', id, result: value });
64
+ switch (r.m.method) {
65
+ case 'initialize': {
66
+ const requested = asObject(r.m.params)?.protocolVersion;
67
+ return result({ protocolVersion: typeof requested === 'string' ? requested : MCP_PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { ...serverInfo } });
68
+ }
69
+ case 'ping':
70
+ return result({});
71
+ case 'tools/list':
72
+ return result({ tools: [] });
73
+ case 'resources/list':
74
+ return result({ resources: [] });
75
+ case 'resources/read':
76
+ return errorOf(id, INVALID_PARAMS, noCredentials(connector), AUTH_REQUIRED);
77
+ default:
78
+ return errorOf(id, METHOD_NOT_FOUND, noCredentials(connector), AUTH_REQUIRED);
79
+ }
80
+ }
53
81
  const up = (data) => ({ to: 'upstream', data });
54
82
  const down = (data) => ({ to: 'connector', data });
55
83
  const same = (raw) => Buffer.concat([raw, NEWLINE]);
@@ -91,6 +119,8 @@ export class Session {
91
119
  case 'tools/call': {
92
120
  if (typeof name !== 'string' || !isGranted(grant, name))
93
121
  return [up(errorLine(id, INVALID_PARAMS, 'the requested tool is not in the granted scope', FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
122
+ if (this.options.parked === true)
123
+ return id === null ? [] : [up(errorLine(id, INTERNAL_ERROR, noCredentials(this.options.connector), AUTH_REQUIRED)), ...this.settle(call, 'refused', AUTH_REQUIRED)];
94
124
  return this.forward(raw, id, grant, undefined, undefined, call);
95
125
  }
96
126
  case 'resources/read':
package/dist/status.d.ts CHANGED
@@ -1,9 +1,13 @@
1
- import type { ConnectorMintedStatus } from '@sema-agent/connectors-contract';
1
+ import type { ConnectorMintedState, ConnectorMintedStatus } from '@sema-agent/connectors-contract';
2
2
  import { type SecretHandle } from './secrets.js';
3
3
  export declare const STATUS_MIME_TYPE = "application/json";
4
4
  export declare const AUTHORIZE_URL_PATTERN: RegExp;
5
5
  export declare function statusUri(connector: string): string;
6
6
  export declare function statusResource(connector: string): Record<string, string>;
7
+ export declare function mintState(input: {
8
+ readonly handles: readonly SecretHandle[];
9
+ readonly nowMs: number;
10
+ }): ConnectorMintedState;
7
11
  export declare function mintStatus(input: {
8
12
  readonly handles: readonly SecretHandle[];
9
13
  readonly nowMs: number;
package/dist/status.js CHANGED
@@ -13,9 +13,12 @@ export function statusResource(connector) {
13
13
  mimeType: STATUS_MIME_TYPE,
14
14
  };
15
15
  }
16
- export function mintStatus(input) {
16
+ export function mintState(input) {
17
17
  const secrets = input.handles.filter(isSecret);
18
- const state = secrets.length === 0 ? 'unauthorized' : secrets.some((h) => h.expiresAt !== undefined && Date.parse(h.expiresAt) <= input.nowMs) ? 'expired' : 'authorized';
18
+ return secrets.length === 0 ? 'unauthorized' : secrets.some((h) => h.expiresAt !== undefined && Date.parse(h.expiresAt) <= input.nowMs) ? 'expired' : 'authorized';
19
+ }
20
+ export function mintStatus(input) {
21
+ const state = mintState(input);
19
22
  const link = state === 'unauthorized' && input.authorizeUrl !== undefined && AUTHORIZE_URL_PATTERN.test(input.authorizeUrl) ? { authorizeUrl: input.authorizeUrl } : {};
20
23
  return { state, tools: [...input.tools], ...link };
21
24
  }
@@ -1 +1,2 @@
1
1
  export declare function writeAll(fd: number, data: string | Uint8Array): void;
2
+ export declare function writeFailure(e: unknown): string;
package/dist/write-all.js CHANGED
@@ -13,3 +13,7 @@ export function writeAll(fd, data) {
13
13
  if (size !== bytes.length)
14
14
  throw new Error(`the file holds ${size} bytes after a write of ${bytes.length}`);
15
15
  }
16
+ export function writeFailure(e) {
17
+ const code = e?.code;
18
+ return typeof code === 'string' ? code : e instanceof Error ? e.message : 'unknown error';
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/connectors",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared connector layer and the sema-connector launcher (MCP stdio proxy with principal, scope, normalize, status and trace stages).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,7 +19,8 @@
19
19
  "files": [
20
20
  "dist",
21
21
  "bin",
22
- "CHANGELOG.md"
22
+ "CHANGELOG.md",
23
+ "INTEGRATION.md"
23
24
  ],
24
25
  "scripts": {
25
26
  "build": "tsc -p tsconfig.json"