@entigram/client 2.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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @entigram/client
2
+
3
+ Typed, dependency-free Node client for Entigram's local MCP governance server.
4
+ The client starts `etg serve` over a shell-free stdio transport, completes the
5
+ MCP initialize handshake, and exposes the current Entigram tool contract. It
6
+ does not duplicate policy evaluation or write `.etg/state.db` directly.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @entigram/client
12
+ ```
13
+
14
+ The `etg` command must already be installed and available on `PATH`:
15
+
16
+ ```sh
17
+ pipx install entigram-ai
18
+ ```
19
+
20
+ The package is published automatically with Entigram releases. Maintainers
21
+ should configure the repository's `NPM_TOKEN` secret with publish access to the
22
+ `@entigram` npm scope; the release workflow applies the release tag version
23
+ before publishing.
24
+
25
+ ## Example
26
+
27
+ ```js
28
+ import { EntigramClient } from '@entigram/client';
29
+
30
+ const entigram = new EntigramClient({ cwd: process.cwd() });
31
+
32
+ try {
33
+ const capabilities = await entigram.getCapabilities();
34
+ if (capabilities.ok === false) {
35
+ console.error(capabilities.error.code);
36
+ process.exitCode = 1;
37
+ } else {
38
+ const impact = await entigram.getImpact('src/order-service.ts');
39
+ console.log(impact);
40
+ }
41
+ } finally {
42
+ await entigram.close();
43
+ }
44
+ ```
45
+
46
+ Tool-level denials are returned as Entigram's stable `{ok: false, error:
47
+ {code, message, details}}` envelope. Transport failures, invalid MCP messages,
48
+ and timeouts throw `EntigramClientError`. Branch on `error.code`, not message
49
+ text.
50
+
51
+ The client maps the current tools as follows:
52
+
53
+ | Method | MCP tool |
54
+ | --- | --- |
55
+ | `getCapabilities()` | `etg_get_capabilities` |
56
+ | `getWorkspaceContext()` | `etg_get_workspace_context` |
57
+ | `getSchemas()` | `etg_get_schemas` |
58
+ | `getImpact(filePath)` | `etg_get_impact` |
59
+ | `getAssessmentCapabilities()` | `etg_get_assessment_capabilities` |
60
+ | `assess(payload)` | `etg_assess` |
61
+ | `proposeAlignment(payload)` | `etg_propose_alignment` |
62
+ | `logConflict(payload)` | `etg_log_conflict` |
63
+
64
+ `payload` may be a JSON object or an already encoded JSON string. The package
65
+ uses the existing MCP contract and should be versioned alongside that contract;
66
+ it is not a replacement for the Entigram Python runtime.
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@entigram/client",
3
+ "version": "2.2.0",
4
+ "description": "Typed Node client for Entigram's local MCP governance server",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.d.ts",
9
+ "import": "./src/index.js",
10
+ "default": "./src/index.js"
11
+ }
12
+ },
13
+ "types": "./src/index.d.ts",
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test test/client.test.mjs"
23
+ },
24
+ "keywords": [
25
+ "entigram",
26
+ "mcp",
27
+ "agent-governance",
28
+ "semantic-governance"
29
+ ],
30
+ "license": "Apache-2.0",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/Entigram-AI/entigram.git",
34
+ "directory": "packages/client"
35
+ },
36
+ "homepage": "https://entigram.ai"
37
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type JsonPrimitive = string | number | boolean | null;
2
+ export type JsonValue = JsonPrimitive | JsonObject | JsonValue[];
3
+ export interface JsonObject { [key: string]: JsonValue; }
4
+
5
+ export interface EntigramErrorEnvelope {
6
+ code: string;
7
+ message: string;
8
+ details?: JsonValue;
9
+ }
10
+
11
+ export interface EntigramResponse<T extends JsonValue = JsonObject> extends JsonObject {
12
+ ok?: boolean;
13
+ error?: EntigramErrorEnvelope;
14
+ data?: T;
15
+ }
16
+
17
+ export interface EntigramClientOptions {
18
+ command?: string;
19
+ args?: string[];
20
+ cwd?: string;
21
+ env?: Record<string, string | undefined>;
22
+ protocolVersion?: string;
23
+ timeoutMs?: number;
24
+ }
25
+
26
+ export class EntigramClientError extends Error {
27
+ readonly code: string;
28
+ constructor(message: string, options?: { code?: string; cause?: unknown });
29
+ }
30
+
31
+ export class EntigramClient {
32
+ constructor(options?: EntigramClientOptions);
33
+ readonly command: string;
34
+ readonly args: string[];
35
+ readonly cwd?: string;
36
+ readonly protocolVersion: string;
37
+ readonly timeoutMs: number;
38
+ readonly serverInfo: JsonObject | null;
39
+ readonly serverCapabilities: JsonObject | null;
40
+ connect(): Promise<this>;
41
+ callTool<T extends JsonValue = JsonObject>(name: string, args?: JsonObject): Promise<T>;
42
+ getCapabilities(): Promise<EntigramResponse>;
43
+ getWorkspaceContext(): Promise<EntigramResponse>;
44
+ getSchemas(): Promise<EntigramResponse>;
45
+ getImpact(filePath: string): Promise<EntigramResponse>;
46
+ getAssessmentCapabilities(): Promise<EntigramResponse>;
47
+ assess(payload: JsonObject | string): Promise<EntigramResponse>;
48
+ proposeAlignment(payload: JsonObject | string): Promise<EntigramResponse>;
49
+ logConflict(payload: JsonObject | string): Promise<EntigramResponse>;
50
+ close(): Promise<void>;
51
+ dispose(): Promise<void>;
52
+ }
package/src/index.js ADDED
@@ -0,0 +1,299 @@
1
+ import { spawn as defaultSpawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline';
3
+
4
+ const DEFAULT_PROTOCOL_VERSION = '2025-06-18';
5
+ const DEFAULT_TIMEOUT_MS = 30_000;
6
+
7
+ /**
8
+ * Error raised when the MCP transport cannot complete a request.
9
+ * Entigram tool-level denials are returned as normal `{ok: false}` envelopes
10
+ * so callers can branch on `error.code` without catching exceptions.
11
+ */
12
+ export class EntigramClientError extends Error {
13
+ constructor(message, { code = 'CLIENT_ERROR', cause } = {}) {
14
+ super(message, { cause });
15
+ this.name = 'EntigramClientError';
16
+ this.code = code;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * A small MCP stdio client for the local `etg serve` process.
22
+ *
23
+ * The client intentionally delegates governance decisions to Entigram's
24
+ * canonical runtime. It does not implement policy evaluation or write the
25
+ * workspace ledger itself.
26
+ */
27
+ export class EntigramClient {
28
+ constructor({
29
+ command = 'etg',
30
+ args = ['serve'],
31
+ cwd,
32
+ env,
33
+ protocolVersion = DEFAULT_PROTOCOL_VERSION,
34
+ timeoutMs = DEFAULT_TIMEOUT_MS,
35
+ spawn = defaultSpawn,
36
+ } = {}) {
37
+ if (typeof command !== 'string' || command.length === 0) {
38
+ throw new TypeError('command must be a non-empty string');
39
+ }
40
+ if (!Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) {
41
+ throw new TypeError('args must be an array of strings');
42
+ }
43
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
44
+ throw new TypeError('timeoutMs must be a positive number');
45
+ }
46
+
47
+ this.command = command;
48
+ this.args = [...args];
49
+ this.cwd = cwd;
50
+ this.env = env;
51
+ this.protocolVersion = protocolVersion;
52
+ this.timeoutMs = timeoutMs;
53
+ this.spawn = spawn;
54
+ this.child = null;
55
+ this.readline = null;
56
+ this.pending = new Map();
57
+ this.nextRequestId = 1;
58
+ this.connecting = null;
59
+ this.connected = false;
60
+ this.closed = false;
61
+ this.serverInfo = null;
62
+ this.serverCapabilities = null;
63
+ this.stderr = '';
64
+ }
65
+
66
+ /** Start the local MCP server and complete the MCP initialize handshake. */
67
+ async connect() {
68
+ if (this.connected) return this;
69
+ if (this.connecting) return this.connecting;
70
+
71
+ this.closed = false;
72
+ this.connecting = this.#start();
73
+ try {
74
+ await this.connecting;
75
+ return this;
76
+ } finally {
77
+ this.connecting = null;
78
+ }
79
+ }
80
+
81
+ async #start() {
82
+ if (this.child) {
83
+ throw new EntigramClientError('The client process is already started', {
84
+ code: 'CLIENT_ALREADY_STARTED',
85
+ });
86
+ }
87
+
88
+ let child;
89
+ try {
90
+ child = this.spawn(this.command, this.args, {
91
+ cwd: this.cwd,
92
+ env: this.env ? { ...process.env, ...this.env } : process.env,
93
+ shell: false,
94
+ stdio: ['pipe', 'pipe', 'pipe'],
95
+ });
96
+ } catch (error) {
97
+ throw new EntigramClientError(`Could not start ${this.command}`, {
98
+ code: 'CLIENT_SPAWN_FAILED',
99
+ cause: error,
100
+ });
101
+ }
102
+
103
+ this.child = child;
104
+ this.readline = createInterface({ input: child.stdout });
105
+ this.readline.on('line', (line) => this.#handleLine(line));
106
+ child.stderr?.on('data', (chunk) => {
107
+ this.stderr += String(chunk);
108
+ });
109
+ child.on('error', (error) => {
110
+ this.#failPending(new EntigramClientError(`Entigram server failed: ${error.message}`, {
111
+ code: 'CLIENT_PROCESS_ERROR',
112
+ cause: error,
113
+ }));
114
+ });
115
+ child.on('exit', (code, signal) => {
116
+ this.connected = false;
117
+ if (!this.closed) {
118
+ const detail = signal ? `signal ${signal}` : `code ${code}`;
119
+ this.#failPending(new EntigramClientError(`Entigram server exited with ${detail}`, {
120
+ code: 'CLIENT_PROCESS_EXITED',
121
+ }));
122
+ }
123
+ });
124
+
125
+ try {
126
+ const initialized = await this.#request('initialize', {
127
+ protocolVersion: this.protocolVersion,
128
+ capabilities: {},
129
+ clientInfo: { name: '@entigram/client', version: '0.1.0' },
130
+ });
131
+ this.serverInfo = initialized?.serverInfo ?? null;
132
+ this.serverCapabilities = initialized?.capabilities ?? null;
133
+ this.#notify('notifications/initialized', {});
134
+ this.connected = true;
135
+ } catch (error) {
136
+ await this.close();
137
+ throw error;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Call an Entigram MCP tool and return its parsed stable JSON envelope.
143
+ * Transport and protocol failures throw `EntigramClientError`; tool-level
144
+ * denials are returned with `ok: false`.
145
+ */
146
+ async callTool(name, args = {}) {
147
+ if (typeof name !== 'string' || name.length === 0) {
148
+ throw new TypeError('name must be a non-empty string');
149
+ }
150
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
151
+ throw new TypeError('args must be an object');
152
+ }
153
+ await this.connect();
154
+ const result = await this.#request('tools/call', { name, arguments: args });
155
+ const text = result?.content?.find((item) => item?.type === 'text')?.text;
156
+ if (typeof text !== 'string') {
157
+ throw new EntigramClientError(`Tool ${name} returned no JSON text content`, {
158
+ code: 'INVALID_TOOL_RESPONSE',
159
+ });
160
+ }
161
+ try {
162
+ return JSON.parse(text);
163
+ } catch (error) {
164
+ throw new EntigramClientError(`Tool ${name} returned invalid JSON`, {
165
+ code: 'INVALID_TOOL_RESPONSE',
166
+ cause: error,
167
+ });
168
+ }
169
+ }
170
+
171
+ getCapabilities() {
172
+ return this.callTool('etg_get_capabilities');
173
+ }
174
+
175
+ getWorkspaceContext() {
176
+ return this.callTool('etg_get_workspace_context');
177
+ }
178
+
179
+ getSchemas() {
180
+ return this.callTool('etg_get_schemas');
181
+ }
182
+
183
+ getImpact(filePath) {
184
+ if (typeof filePath !== 'string' || filePath.length === 0) {
185
+ throw new TypeError('filePath must be a non-empty string');
186
+ }
187
+ return this.callTool('etg_get_impact', { file_path: filePath });
188
+ }
189
+
190
+ getAssessmentCapabilities() {
191
+ return this.callTool('etg_get_assessment_capabilities');
192
+ }
193
+
194
+ assess(payload) {
195
+ return this.callTool('etg_assess', { payload: encodePayload(payload) });
196
+ }
197
+
198
+ proposeAlignment(payload) {
199
+ return this.callTool('etg_propose_alignment', { payload: encodePayload(payload) });
200
+ }
201
+
202
+ logConflict(payload) {
203
+ return this.callTool('etg_log_conflict', { payload: encodePayload(payload) });
204
+ }
205
+
206
+ /** Stop the local server and reject any in-flight requests. */
207
+ async close() {
208
+ this.closed = true;
209
+ this.connected = false;
210
+ this.#failPending(new EntigramClientError('Entigram client closed', {
211
+ code: 'CLIENT_CLOSED',
212
+ }));
213
+ this.readline?.close();
214
+ this.readline = null;
215
+ const child = this.child;
216
+ this.child = null;
217
+ if (!child || child.killed) return;
218
+ child.kill();
219
+ }
220
+
221
+ dispose() {
222
+ return this.close();
223
+ }
224
+
225
+ #notify(method, params) {
226
+ if (!this.child?.stdin?.writable) return;
227
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`);
228
+ }
229
+
230
+ #request(method, params) {
231
+ if (!this.child?.stdin?.writable) {
232
+ return Promise.reject(new EntigramClientError('Entigram server is not running', {
233
+ code: 'CLIENT_NOT_CONNECTED',
234
+ }));
235
+ }
236
+ const id = this.nextRequestId++;
237
+ return new Promise((resolve, reject) => {
238
+ const timer = setTimeout(() => {
239
+ this.pending.delete(id);
240
+ reject(new EntigramClientError(`Timed out waiting for MCP response to ${method}`, {
241
+ code: 'CLIENT_TIMEOUT',
242
+ }));
243
+ }, this.timeoutMs);
244
+ this.pending.set(id, { resolve, reject, timer });
245
+ try {
246
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
247
+ } catch (error) {
248
+ clearTimeout(timer);
249
+ this.pending.delete(id);
250
+ reject(new EntigramClientError(`Could not send MCP request ${method}`, {
251
+ code: 'CLIENT_WRITE_FAILED',
252
+ cause: error,
253
+ }));
254
+ }
255
+ });
256
+ }
257
+
258
+ #handleLine(line) {
259
+ if (!line.trim()) return;
260
+ let message;
261
+ try {
262
+ message = JSON.parse(line);
263
+ } catch (error) {
264
+ this.#failPending(new EntigramClientError('Entigram server emitted invalid JSON', {
265
+ code: 'INVALID_MCP_MESSAGE',
266
+ cause: error,
267
+ }));
268
+ return;
269
+ }
270
+ if (!Object.hasOwn(message, 'id')) return;
271
+ const request = this.pending.get(message.id);
272
+ if (!request) return;
273
+ this.pending.delete(message.id);
274
+ clearTimeout(request.timer);
275
+ if (message.error) {
276
+ request.reject(new EntigramClientError(message.error.message ?? 'MCP request failed', {
277
+ code: message.error.code ?? 'MCP_ERROR',
278
+ }));
279
+ } else {
280
+ request.resolve(message.result);
281
+ }
282
+ }
283
+
284
+ #failPending(error) {
285
+ for (const [id, request] of this.pending) {
286
+ clearTimeout(request.timer);
287
+ request.reject(error);
288
+ this.pending.delete(id);
289
+ }
290
+ }
291
+ }
292
+
293
+ function encodePayload(payload) {
294
+ if (typeof payload === 'string') return payload;
295
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
296
+ throw new TypeError('payload must be a JSON object or JSON string');
297
+ }
298
+ return JSON.stringify(payload);
299
+ }