@sema-agent/connectors 0.3.0 → 0.5.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 +77 -0
- package/INTEGRATION.md +87 -0
- package/dist/cli-kit.js +11 -26
- package/dist/launcher.js +61 -25
- package/dist/own-package.d.ts +4 -0
- package/dist/own-package.js +8 -0
- package/dist/secrets.d.ts +1 -0
- package/dist/secrets.js +34 -0
- package/dist/session.d.ts +6 -0
- package/dist/session.js +32 -2
- package/dist/status.d.ts +5 -1
- package/dist/status.js +5 -2
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ All notable changes to this package are documented here. Released sections are
|
|
|
4
4
|
frozen: once a version is published its section is never edited again; new text
|
|
5
5
|
goes into the next version's section.
|
|
6
6
|
|
|
7
|
+
## 0.5.0 (2026-10-15)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `FileSecrets.remove(name)` (CN-048): removes a handle's value file and its
|
|
12
|
+
metadata file together, inside the lock of the connector's directory
|
|
13
|
+
(unless `withLock` already holds it); returns whether the handle existed
|
|
14
|
+
before. Absent is a no-op.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `FileSecrets.put` refuses the empty string (CN-048): a handle always has a
|
|
19
|
+
value, so `put(name, '')` throws a `TypeError` instead of writing one.
|
|
20
|
+
Nothing is written; an existing handle's value and metadata are untouched
|
|
21
|
+
byte for byte. A connector that needs to take a handle away uses `remove`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- A `tools/call` outside the granted scope, sent as a notification (no
|
|
26
|
+
`id`): the launcher no longer answers it with an id-null
|
|
27
|
+
`connector.forbidden_scope` error and no longer writes its receipt (CN-047).
|
|
28
|
+
Nothing answers a notification, whatever refuses it; this already held for
|
|
29
|
+
a parked session's refusal (CN-046) and now holds for the scope judge's
|
|
30
|
+
too, the same form (`id === null ? [] : ...`).
|
|
31
|
+
- `mountCommand` (and every CLI kit command) with a connector name `mount`
|
|
32
|
+
refuses no longer throws before `mount` is called: the refusal arrives as the
|
|
33
|
+
documented one line on standard error, `mount refused (invalid_name): ...`,
|
|
34
|
+
with exit 1 (CN-050). `cliOutput` gives such a name the launcher's own program
|
|
35
|
+
name instead of refusing it itself; the name rule is judged once, by `mount`.
|
|
36
|
+
|
|
37
|
+
## 0.4.0 (2026-10-15)
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- `INTEGRATION.md` ships with the package: how a command-line tool mounts a
|
|
42
|
+
connector of the default catalog through `mount`, `mountCommand` and `seed`,
|
|
43
|
+
what it reads back, and what the engine then sees.
|
|
44
|
+
- The parked session (CN-046): in standalone mode the launcher never starts a
|
|
45
|
+
connector whose status would be `unauthorized` (no secret handle, the
|
|
46
|
+
status's own judgement); it answers the session itself, through the same
|
|
47
|
+
session and stages as a connector run. `initialize` is answered with the
|
|
48
|
+
requested protocol version, the tools and resources capabilities and
|
|
49
|
+
`serverInfo` `sema-connector` at this package's version; `tools/list` lists
|
|
50
|
+
no tools, `resources/list` the status entry alone, the status reads
|
|
51
|
+
`unauthorized` with no tools, and `ping` is answered. A tool call is refused
|
|
52
|
+
with `-32603`, another resource read with `-32602` and any other method with
|
|
53
|
+
`-32601`, each with `connector.auth_required` and the message
|
|
54
|
+
`connector <name> has no credentials; run its auth`; the refused call's
|
|
55
|
+
receipt is `refused` with `connector.auth_required`. The session ends with
|
|
56
|
+
exit 0 at the end of input or on a stop signal. So
|
|
57
|
+
`sema-connector-<name> status` before `auth` prints the `unauthorized`
|
|
58
|
+
status and exits 0, and an upstream program that would begin a login of its
|
|
59
|
+
own without a token never starts.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- A standalone launcher starts a connector only when it has a secret handle
|
|
64
|
+
(CN-046). Before, it started every connector, and one that refuses to start
|
|
65
|
+
without its handles (as the Gitea and GitHub connectors do) ended the run
|
|
66
|
+
with exit 5 and `connector.internal`. A connector that needs no credential
|
|
67
|
+
at all is parked as well until a secret handle is stored for it. A connector
|
|
68
|
+
whose secret handle has expired, and every hosted connector, is started as
|
|
69
|
+
before.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
|
|
73
|
+
- `sema-connector-<name> status` (CN-049) no longer discovers the status
|
|
74
|
+
resource's URI through `resources/list` and then insists on exactly one
|
|
75
|
+
entry there; it reads the status resource straight by its URI (the one
|
|
76
|
+
`statusUri` makes). Before, a started connector that lists resources of its
|
|
77
|
+
own made `resources/list` answer with more than the status entry the
|
|
78
|
+
launcher adds, so `status` always failed with "the launcher did not list
|
|
79
|
+
exactly one resource, its status" once authorized, even though the launcher
|
|
80
|
+
itself serves the status resource correctly. This affected the GitHub
|
|
81
|
+
connector once authorized (github-mcp-server lists 4 `ui://` resources of
|
|
82
|
+
its own); the Gitea connector, which lists none, was not affected.
|
|
83
|
+
|
|
7
84
|
## 0.3.0 (2026-10-15)
|
|
8
85
|
|
|
9
86
|
### Added
|
package/INTEGRATION.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Integrating `@sema-agent/connectors` from a command-line tool
|
|
2
|
+
|
|
3
|
+
How a tool outside this repository (the CLI's `connectors add`, for one) mounts a connector of the default
|
|
4
|
+
catalog into the engine's local configuration and reads what happened, using this package only. The rules
|
|
5
|
+
live in the repository's `docs/ARCHITECTURE.md` ("Mounting", "Seeding the default catalog", "Parked
|
|
6
|
+
session"); this page states the surface a tool consumes and does not restate the rules.
|
|
7
|
+
|
|
8
|
+
## The configuration directory
|
|
9
|
+
|
|
10
|
+
One configuration directory per engine installation, the engine's local one: `CONFIG_LOCAL_DIR`, an
|
|
11
|
+
absolute path. `resolveConfigDir(process.env)` turns the variable into `{ ok: true, dir }` or
|
|
12
|
+
`{ ok: false, reason }` (not set, empty, or not absolute; the reason names the variable, never the value).
|
|
13
|
+
It is the same root `sema-connector seed --config-dir <dir>` seeds and the same root every connector CLI
|
|
14
|
+
(`sema-connector-<name> auth | mount | status`) reads from `CONFIG_LOCAL_DIR`. Entries land in
|
|
15
|
+
`<dir>/config.d/mcp.json`; a connector's secrets live in `<dir>/connectors/<name>/`.
|
|
16
|
+
|
|
17
|
+
## Mounting one connector
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { mount, mountCommand, resolveConfigDir, DEFAULT_CONNECTORS, SEED_VERSION, seed } from '@sema-agent/connectors'
|
|
21
|
+
|
|
22
|
+
const result = mount({ name: 'gitea', configDir: dir })
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `mount(options: { name: string; configDir: string }): MountResult`. There is no `entry` argument: the entry
|
|
26
|
+
is fixed by the package and written as
|
|
27
|
+
`{"name": "<name>", "transport": {"kind": "stdio", "command": "sema-connector", "args": ["run", "<name>",
|
|
28
|
+
"--mode", "standalone", "--transport", "stdio", "--config-dir", "<configDir>"]}}`, never with environment
|
|
29
|
+
references or headers. A tool never composes an entry of its own.
|
|
30
|
+
- `MountResult` is `{ ok: true, changed: boolean, file: string }` (`changed: false` when the same entry was
|
|
31
|
+
already there, byte for byte apart from key order; `file` is the catalog file written) or
|
|
32
|
+
`{ ok: false, reason: MountRefusal, message: string }` with `reason` one of the closed set
|
|
33
|
+
`config_dir | invalid_name | reserved_name | unreadable | unexpected_shape | conflict | locked |
|
|
34
|
+
changed_during_write | write_failed`. `mount` does not throw for a refusal.
|
|
35
|
+
- Idempotence: a second `mount` of the same name changes nothing. Another entry under the same name (other
|
|
36
|
+
arguments, another `--config-dir`, `enabled: false`, environment references) is `conflict` and is never
|
|
37
|
+
overwritten; the tool reports it and leaves the person to remove the entry. `mount` keeps no marker of
|
|
38
|
+
its own.
|
|
39
|
+
- `mountCommand(name, configDir): number` is the same call with the CLI's output: on success one line on
|
|
40
|
+
standard output (`mounted connector <name> in <file>; restart the engine to apply`, or
|
|
41
|
+
`connector <name> is already mounted in <file>`) and exit 0; on a refusal one line on standard error,
|
|
42
|
+
`sema-connector-<name>: mount refused (<reason>): <message>`, and exit 1. The prefix is the program name
|
|
43
|
+
of the connector's own CLI; a name `mount` refuses (`invalid_name`, `reserved_name`) gets the launcher's own,
|
|
44
|
+
`sema-connector: `, since no connector program exists for it. A thin wrapper returns its number as its
|
|
45
|
+
exit code and passes both streams through.
|
|
46
|
+
- `mount` does not check that the connector package is installed: an entry for a connector that is not
|
|
47
|
+
installed makes the launcher exit 4 (`connector.not_installed`) when the engine starts it. A tool that
|
|
48
|
+
wants only installed connectors mounted uses the seed.
|
|
49
|
+
|
|
50
|
+
## The default catalog and the seed
|
|
51
|
+
|
|
52
|
+
- `DEFAULT_CONNECTORS: readonly { name: string; since: number }[]`: the one list of default connectors, in
|
|
53
|
+
order; `name` is the connector (the package is `@sema-agent/connector-<name>`), `since` the seed version
|
|
54
|
+
that added it. `SEED_VERSION` is the newest `since`.
|
|
55
|
+
- `seed({ configDir }): SeedResult` mounts every installed connector of the default catalog through `mount`,
|
|
56
|
+
resolving each with the launcher's own rule first, and remembers the outcome per name in
|
|
57
|
+
`<dir>/config.d/.sema-connector-seed.json`, so an entry a person removed is never brought back. Readings per
|
|
58
|
+
name: `mounted | present | conflict | not_installed | refused`; the whole seed is refused as `config_dir |
|
|
59
|
+
locked | unreadable | unexpected_shape | write_failed`. `sema-connector seed --config-dir <dir>` is the same
|
|
60
|
+
call as a command (exit 0 when every connector is mounted, present or not installed, 1 otherwise, 2 for a bad
|
|
61
|
+
command line). A tool that offers "mount the defaults" runs the seed; a tool that offers "mount this one"
|
|
62
|
+
calls `mount`.
|
|
63
|
+
|
|
64
|
+
## Installing the pieces
|
|
65
|
+
|
|
66
|
+
`@sema-agent/connectors` is consumed as an installed package, not bundled: `statusCommand` starts the
|
|
67
|
+
`sema-connector` launcher of the very copy it belongs to, and `seed` resolves connector packages from that
|
|
68
|
+
copy's place in `node_modules`, both through the package's own `package.json` (`import.meta.url`), so a copy
|
|
69
|
+
inlined into another program's bundle finds neither. `mount` and `mountCommand` have no such dependency.
|
|
70
|
+
|
|
71
|
+
The entry `mount` writes names the command `sema-connector`; the engine runs it from its `PATH`. The
|
|
72
|
+
launcher is the `bin` of `@sema-agent/connectors`, and a connector is the `bin` of
|
|
73
|
+
`@sema-agent/connector-<name>`, installed where that launcher resolves it (the same `node_modules`, a global
|
|
74
|
+
install, or `NODE_PATH`). A tool that mounts a connector does not install either; it tells the person what to
|
|
75
|
+
install (`npm install -g @sema-agent/connectors @sema-agent/connector-<name>`, then the connector's `install`
|
|
76
|
+
and `auth` with the same `CONFIG_LOCAL_DIR`). An entry whose launcher is not on the engine's `PATH`, or whose
|
|
77
|
+
connector package is not installed, fails at the engine's next start (exit 4, `connector.not_installed`);
|
|
78
|
+
`seed` mounts only connectors the launcher that runs it finds.
|
|
79
|
+
|
|
80
|
+
## What the engine sees
|
|
81
|
+
|
|
82
|
+
Entries are read by the engine from `config.d/mcp.json` through the server's local file configuration
|
|
83
|
+
(the same path that reads every other stdio entry there); an entry `mount` wrote is read at the engine's next
|
|
84
|
+
start, and the launcher it names reads the connector's secrets under the same directory. Before `auth` has
|
|
85
|
+
stored the connector's credential the launcher parks the session: the connector lists no tools, its status
|
|
86
|
+
reads `unauthorized`, and a tool call is refused with `connector.auth_required`; after `auth`, the next start
|
|
87
|
+
runs the connector.
|
package/dist/cli-kit.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
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
2
|
import { resolveConfigDir } from './config-dir.js';
|
|
6
3
|
import { CONNECTOR_NAME } from './connector-name.js';
|
|
7
4
|
import { hasRepeatedKey } from './json-text.js';
|
|
8
5
|
import { mount } from './mount.js';
|
|
6
|
+
import { ownManifest } from './own-package.js';
|
|
7
|
+
import { MCP_PROTOCOL_VERSION } from './session.js';
|
|
8
|
+
import { statusUri } from './status.js';
|
|
9
9
|
export function cliOutput(name) {
|
|
10
|
-
|
|
11
|
-
throw new Error('a connector name is lowercase letters and digits in hyphen-separated words');
|
|
12
|
-
const program = `sema-connector-${name}`;
|
|
10
|
+
const program = typeof name === 'string' && CONNECTOR_NAME.test(name) ? `sema-connector-${name}` : 'sema-connector';
|
|
13
11
|
return {
|
|
14
12
|
program,
|
|
15
13
|
say: (line) => void process.stdout.write(`${line}\n`),
|
|
@@ -106,16 +104,10 @@ export function mountCommand(name, configDir) {
|
|
|
106
104
|
say(r.changed ? `mounted connector ${name} in ${r.file}; restart the engine to apply` : `connector ${name} is already mounted in ${r.file}`);
|
|
107
105
|
return 0;
|
|
108
106
|
}
|
|
109
|
-
const PROTOCOL_VERSION = '2025-11-25';
|
|
110
107
|
const STATUS_TIMEOUT_MS = 10_000;
|
|
111
108
|
const KILL_GRACE_MS = 2_000;
|
|
112
109
|
const SHOWN_LINES = 5;
|
|
113
110
|
const RECEIPT_PREFIX = 'sema-connector: receipt ';
|
|
114
|
-
function ownManifest() {
|
|
115
|
-
const url = new URL('../package.json', import.meta.url);
|
|
116
|
-
const manifest = JSON.parse(readFileSync(url, 'utf8'));
|
|
117
|
-
return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
|
|
118
|
-
}
|
|
119
111
|
function lineReader(stream) {
|
|
120
112
|
const lines = [];
|
|
121
113
|
const waiters = [];
|
|
@@ -184,24 +176,17 @@ export async function statusCommand(name, configDir) {
|
|
|
184
176
|
let found = null;
|
|
185
177
|
let problem = null;
|
|
186
178
|
try {
|
|
187
|
-
const init = await reply(1, 'initialize', { protocolVersion:
|
|
179
|
+
const init = await reply(1, 'initialize', { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: program, version: own.version } });
|
|
188
180
|
if (init?.result === undefined)
|
|
189
181
|
problem = 'the launcher did not complete initialize';
|
|
190
182
|
else {
|
|
191
183
|
write({ method: 'notifications/initialized' });
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const read = await reply(3, 'resources/read', { uri });
|
|
199
|
-
const text = read?.result?.contents?.[0]?.text;
|
|
200
|
-
if (typeof text !== 'string')
|
|
201
|
-
problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
|
|
202
|
-
else
|
|
203
|
-
found = JSON.parse(text);
|
|
204
|
-
}
|
|
184
|
+
const read = await reply(2, 'resources/read', { uri: statusUri(name) });
|
|
185
|
+
const text = read?.result?.contents?.[0]?.text;
|
|
186
|
+
if (typeof text !== 'string')
|
|
187
|
+
problem = `the status could not be read${typeof read?.error?.message === 'string' ? `: ${read.error.message}` : ''}`;
|
|
188
|
+
else
|
|
189
|
+
found = JSON.parse(text);
|
|
205
190
|
}
|
|
206
191
|
}
|
|
207
192
|
catch {
|
package/dist/launcher.js
CHANGED
|
@@ -7,13 +7,14 @@ import { absoluteDir, given } from './config-dir.js';
|
|
|
7
7
|
import { CONNECTOR_NAME } from './connector-name.js';
|
|
8
8
|
import { serveHttp } from './http.js';
|
|
9
9
|
import { keySetSource, obtainKeySet } from './jwks.js';
|
|
10
|
+
import { ownManifest } from './own-package.js';
|
|
10
11
|
import { hostedResolver, standaloneResolver } from './principal.js';
|
|
11
12
|
import { Scrubber, secretMatcher } from './redact.js';
|
|
12
13
|
import { resolveConnector } from './resolve-connector.js';
|
|
13
14
|
import { NO_HANDLES, copyForLaunch, handleDirectory } from './secrets.js';
|
|
14
15
|
import { seed } from './seed.js';
|
|
15
|
-
import { INSPECT_LIMIT_BYTES, Session } from './session.js';
|
|
16
|
-
import { mintStatus } from './status.js';
|
|
16
|
+
import { INSPECT_LIMIT_BYTES, Session, parkedAnswer } from './session.js';
|
|
17
|
+
import { mintState, mintStatus } from './status.js';
|
|
17
18
|
import { writeFailure } from './write-all.js';
|
|
18
19
|
const EXIT = { ok: 0, notSeeded: 1, guard: 2, notInstalled: 4, connectorFailed: 5 };
|
|
19
20
|
const NOT_INSTALLED = 'connector.not_installed';
|
|
@@ -381,31 +382,20 @@ function childEnvironment(env, connectorDir, secrets) {
|
|
|
381
382
|
const hostedConnectorDir = (name) => join(tmpdir(), `sema-connector-${randomUUID()}`, 'connectors', name);
|
|
382
383
|
const describe = (o) => `connector process ${o.stalled ? `stopped reading its input (stalled input: none of it taken for ${STALL_MS / 1000} s) and ` : ''}${o.error ? `could not be started (${o.error.message})` : o.signal !== null ? `was terminated by signal ${o.signal}` : `exited with code ${String(o.code)}`}`;
|
|
383
384
|
const abnormal = (o) => o.code !== 0 || o.stalled === true;
|
|
384
|
-
function
|
|
385
|
-
|
|
386
|
-
const toGroup = (signal) => {
|
|
387
|
-
if (child.pid === undefined)
|
|
388
|
-
return false;
|
|
389
|
-
try {
|
|
390
|
-
process.kill(-child.pid, signal);
|
|
391
|
-
return true;
|
|
392
|
-
}
|
|
393
|
-
catch {
|
|
394
|
-
return false;
|
|
395
|
-
}
|
|
396
|
-
};
|
|
397
|
-
const toChild = new Out(child.stdin);
|
|
398
|
-
const perform = (actions) => {
|
|
385
|
+
function performer(log, toUpstream, toConnector) {
|
|
386
|
+
return (actions) => {
|
|
399
387
|
for (const a of actions) {
|
|
400
388
|
if (a.to === 'receipt')
|
|
401
389
|
log.line(`${RECEIPT_PREFIX}${asciiJson(a.receipt)}\n`);
|
|
402
390
|
else if (a.to === 'upstream')
|
|
403
391
|
toUpstream(a.data);
|
|
404
392
|
else
|
|
405
|
-
|
|
393
|
+
toConnector(a.data);
|
|
406
394
|
}
|
|
407
395
|
};
|
|
408
|
-
|
|
396
|
+
}
|
|
397
|
+
function judging(perform) {
|
|
398
|
+
return (judge, unreadable) => {
|
|
409
399
|
let actions;
|
|
410
400
|
try {
|
|
411
401
|
actions = judge();
|
|
@@ -415,6 +405,23 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
|
|
|
415
405
|
}
|
|
416
406
|
perform(actions);
|
|
417
407
|
};
|
|
408
|
+
}
|
|
409
|
+
function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstreamOut, log, stderrAfterExitMs) {
|
|
410
|
+
const child = spawn(process.execPath, [binPath], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env: childEnv });
|
|
411
|
+
const toGroup = (signal) => {
|
|
412
|
+
if (child.pid === undefined)
|
|
413
|
+
return false;
|
|
414
|
+
try {
|
|
415
|
+
process.kill(-child.pid, signal);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const toChild = new Out(child.stdin);
|
|
423
|
+
const perform = performer(log, toUpstream, (data) => toChild.write(data));
|
|
424
|
+
const judged = judging(perform);
|
|
418
425
|
let rung = 0;
|
|
419
426
|
let stopped = false;
|
|
420
427
|
const climb = (to, signal = 'SIGTERM') => {
|
|
@@ -458,7 +465,7 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
|
|
|
458
465
|
return ended;
|
|
459
466
|
})();
|
|
460
467
|
return {
|
|
461
|
-
toChild,
|
|
468
|
+
input: toChild,
|
|
462
469
|
perform,
|
|
463
470
|
fromUpstream: (line, principal) => judged(() => session.fromUpstream(line, principal), () => session.upstreamOversized()),
|
|
464
471
|
endInput: () => climb(1),
|
|
@@ -472,13 +479,37 @@ function startConnector(binPath, childEnv, session, scrubber, toUpstream, upstre
|
|
|
472
479
|
outcome,
|
|
473
480
|
};
|
|
474
481
|
}
|
|
475
|
-
|
|
482
|
+
function parkedRun(session, connector, stdout, log) {
|
|
483
|
+
const serverInfo = { name: 'sema-connector', version: ownManifest().version };
|
|
484
|
+
let end = () => undefined;
|
|
485
|
+
const ended = new Promise((resolve) => (end = () => resolve({ code: 0, signal: null })));
|
|
486
|
+
const perform = performer(log, (data) => stdout.write(data), (data) => {
|
|
487
|
+
const answer = parkedAnswer(data, connector, serverInfo);
|
|
488
|
+
if (answer !== null)
|
|
489
|
+
judged(() => session.fromConnector(Buffer.from(JSON.stringify(answer), 'utf8')), () => session.connectorOversized());
|
|
490
|
+
});
|
|
491
|
+
const judged = judging(perform);
|
|
492
|
+
return {
|
|
493
|
+
input: stdout,
|
|
494
|
+
perform,
|
|
495
|
+
fromUpstream: (line, principal) => judged(() => session.fromUpstream(line, principal), () => session.upstreamOversized()),
|
|
496
|
+
endInput: end,
|
|
497
|
+
stop: () => {
|
|
498
|
+
end();
|
|
499
|
+
setTimeout(abandon, TEARDOWN_STEP_MS).unref();
|
|
500
|
+
},
|
|
501
|
+
outcome: ended.then((outcome) => {
|
|
502
|
+
perform(session.connectorGone('the launcher ended the parked session', false));
|
|
503
|
+
return outcome;
|
|
504
|
+
}),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
async function runStdio(packageName, run, session, stdout, log) {
|
|
476
508
|
const resolution = standaloneResolver().resolve({ headers: {} });
|
|
477
509
|
if (!resolution.ok)
|
|
478
510
|
throw new Error('the standalone principal cannot be refused');
|
|
479
511
|
const anonymous = resolution.principal;
|
|
480
|
-
const
|
|
481
|
-
const upstream = pump(process.stdin, run.toChild, { line: (line) => run.fromUpstream(line, anonymous), oversized: () => run.perform(session.upstreamOversized()) }, [stdout]);
|
|
512
|
+
const upstream = pump(process.stdin, run.input, { line: (line) => run.fromUpstream(line, anonymous), oversized: () => run.perform(session.upstreamOversized()) }, [stdout]);
|
|
482
513
|
void upstream.then(() => run.endInput());
|
|
483
514
|
for (const s of STOP_SIGNALS)
|
|
484
515
|
process.on(s, (signal) => run.stop(signal));
|
|
@@ -500,7 +531,7 @@ async function runHosted(plan, keys, packageName, binPath, env, log) {
|
|
|
500
531
|
const run = startConnector(binPath, childEnvironment(env, hostedConnectorDir(plan.name), NO_HANDLES), session, new Scrubber(NO_HANDLES.values), (data) => deliver(String(data)), null, log, TEARDOWN_STEP_MS);
|
|
501
532
|
return {
|
|
502
533
|
send: (raw, principal) => run.fromUpstream(raw, principal),
|
|
503
|
-
busy: () => run.
|
|
534
|
+
busy: () => run.input.behind(),
|
|
504
535
|
readStatus: (id, principal) => run.perform(session.statusFor(id, principal)),
|
|
505
536
|
end: (signal) => (signal === undefined ? run.endInput() : run.stop(signal)),
|
|
506
537
|
gone: run.outcome.then((o) => {
|
|
@@ -585,14 +616,19 @@ export async function main(argv, env) {
|
|
|
585
616
|
log.line(`sema-connector: ${NOT_INSTALLED}: package ${resolved.packageName} ${resolved.reason}\n`);
|
|
586
617
|
return finish(EXIT.notInstalled);
|
|
587
618
|
}
|
|
619
|
+
const parked = mintState({ handles: secrets.handles, nowMs: Date.now() }) === 'unauthorized';
|
|
588
620
|
const session = new Session({
|
|
589
621
|
connector: plan.name,
|
|
590
622
|
status: (tools) => mintStatus({ handles: secrets.handles, nowMs: Date.now(), tools }),
|
|
591
623
|
ownIdPrefix: `sema-connector/${randomUUID()}`,
|
|
592
624
|
withheld: secretMatcher(secrets.values),
|
|
625
|
+
...(parked ? { parked: true } : {}),
|
|
593
626
|
});
|
|
594
627
|
try {
|
|
595
|
-
|
|
628
|
+
const run = parked
|
|
629
|
+
? parkedRun(session, plan.name, stdout, log)
|
|
630
|
+
: startConnector(resolved.binPath, childEnvironment(env, handleDirectory(plan.configDir, plan.name), secrets), session, new Scrubber(secrets.values), (data) => stdout.write(data), stdout, log);
|
|
631
|
+
return finish(await runStdio(resolved.packageName, run, session, stdout, log));
|
|
596
632
|
}
|
|
597
633
|
finally {
|
|
598
634
|
secrets.remove();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export function ownManifest() {
|
|
5
|
+
const url = new URL('../package.json', import.meta.url);
|
|
6
|
+
const manifest = JSON.parse(readFileSync(url, 'utf8'));
|
|
7
|
+
return { version: manifest.version, launcher: join(dirname(fileURLToPath(url)), manifest.bin['sema-connector'] ?? '') };
|
|
8
|
+
}
|
package/dist/secrets.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export interface FileSecrets {
|
|
|
20
20
|
readonly directory: string;
|
|
21
21
|
list(): SecretListing;
|
|
22
22
|
put(name: string, value: string, options?: PutOptions): SecretHandle;
|
|
23
|
+
remove(name: string): boolean;
|
|
23
24
|
withLock<T>(fn: () => T): T;
|
|
24
25
|
}
|
|
25
26
|
export declare const isSecret: (handle: SecretHandle) => boolean;
|
package/dist/secrets.js
CHANGED
|
@@ -232,6 +232,8 @@ function putHandle(dir, connector, name, value, options) {
|
|
|
232
232
|
throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
|
|
233
233
|
if (typeof value !== 'string')
|
|
234
234
|
throw new Error('a secret value is a string');
|
|
235
|
+
if (value === '')
|
|
236
|
+
throw new TypeError('a secret value is never the empty string (use remove to take the handle away)');
|
|
235
237
|
if (options.expiresAt !== undefined && !validExpiry(options.expiresAt))
|
|
236
238
|
throw new Error('expiresAt must be an RFC 3339 UTC time ending in Z');
|
|
237
239
|
if (options.secret !== undefined && typeof options.secret !== 'boolean')
|
|
@@ -242,6 +244,37 @@ function putHandle(dir, connector, name, value, options) {
|
|
|
242
244
|
throw new Error(r.problem);
|
|
243
245
|
return r.value;
|
|
244
246
|
}
|
|
247
|
+
function removeHandle(dir, connector, name) {
|
|
248
|
+
if (!HANDLE_NAME.test(name))
|
|
249
|
+
throw new Error('a handle name is upper-case letters, digits and underscores, starting with a letter');
|
|
250
|
+
try {
|
|
251
|
+
lstatSync(dir);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
if (e.code === 'ENOENT')
|
|
255
|
+
return false;
|
|
256
|
+
throw new Error(`connector ${connector}: the secrets directory cannot be read`);
|
|
257
|
+
}
|
|
258
|
+
const dirProblem = entryProblem(dir, 'directory', 'the secrets directory');
|
|
259
|
+
if (dirProblem !== null)
|
|
260
|
+
throw new Error(`connector ${connector}: ${dirProblem}`);
|
|
261
|
+
const r = locked(dir, connector, () => {
|
|
262
|
+
let existed = true;
|
|
263
|
+
try {
|
|
264
|
+
rmSync(join(dir, name));
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (e.code !== 'ENOENT')
|
|
268
|
+
throw e;
|
|
269
|
+
existed = false;
|
|
270
|
+
}
|
|
271
|
+
rmSync(join(dir, name + META_SUFFIX), { force: true });
|
|
272
|
+
return existed;
|
|
273
|
+
});
|
|
274
|
+
if (!r.ok)
|
|
275
|
+
throw new Error(r.problem);
|
|
276
|
+
return r.value;
|
|
277
|
+
}
|
|
245
278
|
export function openFileSecrets(options) {
|
|
246
279
|
if (typeof options?.configDir !== 'string' || !isAbsolute(options.configDir))
|
|
247
280
|
throw new Error('configDir must be an absolute path');
|
|
@@ -253,6 +286,7 @@ export function openFileSecrets(options) {
|
|
|
253
286
|
directory: dir,
|
|
254
287
|
list: () => listHandles(dir, connector),
|
|
255
288
|
put: (name, value, putOptions = {}) => putHandle(dir, connector, name, value, putOptions),
|
|
289
|
+
remove: (name) => removeHandle(dir, connector, name),
|
|
256
290
|
withLock: (fn) => {
|
|
257
291
|
ensureDirectory(dir, connector);
|
|
258
292
|
const r = locked(dir, connector, fn);
|
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
|
-
|
|
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]);
|
|
@@ -90,7 +118,9 @@ export class Session {
|
|
|
90
118
|
switch (m.method) {
|
|
91
119
|
case 'tools/call': {
|
|
92
120
|
if (typeof name !== 'string' || !isGranted(grant, name))
|
|
93
|
-
return [up(errorLine(id, INVALID_PARAMS, 'the requested tool is not in the granted scope', FORBIDDEN_SCOPE)), ...this.settle(call, 'refused', FORBIDDEN_SCOPE)];
|
|
121
|
+
return id === null ? [] : [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
|
|
16
|
+
export function mintState(input) {
|
|
17
17
|
const secrets = input.handles.filter(isSecret);
|
|
18
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/connectors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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"
|