@timidan/rite 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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * src/instrument/auto-adapter.js — Auto-adapter generator.
3
+ *
4
+ * Given a JS/TS module that exports named functions, wraps each target entry
5
+ * and sink with lightweight instrumentation — no code modification needed.
6
+ *
7
+ * How it works:
8
+ * 1. Dynamically imports the target module.
9
+ * 2. Wraps named entry functions to capture call arguments.
10
+ * 3. Wraps the named sink function to record every invocation.
11
+ * 4. Exposes runCase(caseSpec) using the same adapter contract as rite.adapter.mjs.
12
+ *
13
+ * Limitations:
14
+ * - Works for ES modules and CJS modules with named exports.
15
+ * - The sink must be exported OR accessible via a state object returned by a factory.
16
+ * - Does not handle async generator patterns or prototype-chain dispatch.
17
+ * - Does not instrument code behind dynamic require() or conditional imports.
18
+ * - For complex services (dependency injection, class hierarchies), a manual
19
+ * adapter is still recommended.
20
+ *
21
+ * Usage (programmatic):
22
+ * import { generateAdapter } from './auto-adapter.js';
23
+ * const adapter = await generateAdapter({
24
+ * modulePath: './src/service.js',
25
+ * factoryFn: 'createService', // optional: if module exports a factory
26
+ * factoryArgs: [{ orders: [...] }],
27
+ * entries: ['customerRefund', 'supportRefund'],
28
+ * sink: 'issueRefund', // name to detect in call stack / exports
29
+ * snapshotFn: 'snapshot', // optional: for before/after state
30
+ * });
31
+ * const report = await verify(config, adapter);
32
+ *
33
+ * Usage (CLI):
34
+ * rite instrument --module ./src/service.js \
35
+ * --factory createService \
36
+ * --entries customerRefund,supportRefund \
37
+ * --sink issueRefund \
38
+ * --snapshot snapshot \
39
+ * --out examples/generated/rite.adapter.mjs
40
+ */
41
+
42
+ import { pathToFileURL } from 'node:url';
43
+ import { writeFileSync } from 'node:fs';
44
+
45
+ // ------------------------------------------------------------------ //
46
+ // Effect schema registry //
47
+ // ------------------------------------------------------------------ //
48
+ // Registered sink schemas validate that effects match before comparison.
49
+ // See src/instrument/effect-schema.js for the full registry.
50
+
51
+ /**
52
+ * @typedef {{
53
+ * modulePath: string,
54
+ * factoryFn?: string,
55
+ * factoryArgs?: unknown[],
56
+ * entries: string[],
57
+ * sink: string,
58
+ * snapshotFn?: string,
59
+ * seedBuilder?: (caseSpec: object) => object[],
60
+ * }} InstrumentOptions
61
+ */
62
+
63
+ /**
64
+ * Generate a live adapter object (not a file) by wrapping a loaded module.
65
+ * @param {InstrumentOptions} opts
66
+ * @returns {Promise<{ runCase: (caseSpec: object) => Promise<object> }>}
67
+ */
68
+ export async function generateAdapter(opts) {
69
+ const { modulePath, factoryFn, factoryArgs = [], entries, sink, snapshotFn, seedBuilder } = opts;
70
+
71
+ const mod = await import(pathToFileURL(modulePath).href);
72
+
73
+ return {
74
+ /**
75
+ * runCase — conforms to the rite adapter contract.
76
+ */
77
+ async runCase(caseSpec) {
78
+ const { path: entryName, actor, input } = caseSpec;
79
+
80
+ if (!entries.includes(entryName)) {
81
+ throw new Error(`Entry "${entryName}" not in instrumented entries: ${entries.join(', ')}`);
82
+ }
83
+
84
+ // Build fixture args — use seedBuilder if provided, else empty
85
+ const seeds = seedBuilder ? seedBuilder(caseSpec) : [];
86
+
87
+ // Create a fresh service instance per case
88
+ let instance;
89
+ const capturedSinkCalls = [];
90
+
91
+ if (factoryFn) {
92
+ const factory = mod[factoryFn];
93
+ if (typeof factory !== 'function')
94
+ throw new Error(`Factory "${factoryFn}" not found in module`);
95
+ instance = factory(...(seeds.length ? [{ orders: seeds }, ...factoryArgs.slice(1)] : factoryArgs));
96
+ } else {
97
+ // Module-level exports: wrap directly
98
+ instance = mod;
99
+ }
100
+
101
+ // Wrap the sink to capture calls
102
+ const originalSink = instance[sink];
103
+ if (typeof originalSink === 'function') {
104
+ instance[sink] = function (...args) {
105
+ const result = originalSink.apply(this, args);
106
+ capturedSinkCalls.push({ args, result });
107
+ return result;
108
+ };
109
+ }
110
+ // Note: if sink is a private closure (most common case), use snapshotFn to infer effects.
111
+
112
+ const entryFn = instance[entryName];
113
+ if (typeof entryFn !== 'function')
114
+ throw new Error(`Entry "${entryName}" not found on instance`);
115
+
116
+ // Snapshot before
117
+ const snapshotBefore = snapshotFn && typeof instance[snapshotFn] === 'function'
118
+ ? instance[snapshotFn]()
119
+ : null;
120
+ const eventsBefore = snapshotBefore?.events?.length ?? 0;
121
+
122
+ // Call entry
123
+ const result = entryFn.call(instance, input, { actorId: actor });
124
+
125
+ // Snapshot after
126
+ const snapshotAfter = snapshotFn && typeof instance[snapshotFn] === 'function'
127
+ ? instance[snapshotFn]()
128
+ : null;
129
+ const eventsAfter = snapshotAfter?.events?.length ?? 0;
130
+
131
+ // Derive effects from snapshot delta (works for private sinks)
132
+ const newEvents = snapshotAfter?.events
133
+ ? snapshotAfter.events.slice(eventsBefore)
134
+ : capturedSinkCalls.map(c => c.args[0] ?? c.args);
135
+
136
+ const effects = newEvents.map(({ refundId: _rid, ...rest }) => rest);
137
+
138
+ const orderId = input?.orderId;
139
+ const stateBefore = snapshotBefore?.orders?.find(o => o.id === orderId) ?? null;
140
+ const stateAfter = snapshotAfter?.orders?.find(o => o.id === orderId) ?? null;
141
+ const stateChanged = JSON.stringify(stateBefore) !== JSON.stringify(stateAfter);
142
+
143
+ return {
144
+ decision: result?.ok === true ? 'allow' : 'deny',
145
+ effects,
146
+ stateBefore,
147
+ stateAfter,
148
+ stateChanged,
149
+ };
150
+ },
151
+ };
152
+ }
153
+
154
+ // ------------------------------------------------------------------ //
155
+ // File generator — write a static adapter file //
156
+ // ------------------------------------------------------------------ //
157
+
158
+ /**
159
+ * Generate and write a rite.adapter.mjs file for a given module.
160
+ * The generated file is a fully self-contained adapter that can be committed.
161
+ * @param {InstrumentOptions & { outPath: string, relativeModulePath: string }} opts
162
+ */
163
+ export function writeAdapterFile(opts) {
164
+ const { entries, sink, snapshotFn, factoryFn, outPath, relativeModulePath } = opts;
165
+
166
+ const importLine = factoryFn
167
+ ? `import { ${factoryFn} } from '${relativeModulePath}';`
168
+ : `import * as mod from '${relativeModulePath}';`;
169
+
170
+ const instanceExpr = factoryFn
171
+ ? `${factoryFn}({ orders: seedsForCase(input.orderId) })`
172
+ : `mod`;
173
+
174
+ const snapshotBefore = snapshotFn
175
+ ? `const snapBefore = instance.${snapshotFn}();`
176
+ : `const snapBefore = { events: [], orders: [] };`;
177
+
178
+ const snapshotAfter = snapshotFn
179
+ ? `const snapAfter = instance.${snapshotFn}();`
180
+ : `const snapAfter = { events: [], orders: [] };`;
181
+
182
+ const entryDispatch = entries.map(e =>
183
+ ` if (entryName === '${e}') result = instance.${e}(input, { actorId: actor });`
184
+ ).join('\n');
185
+
186
+ const content = `/**
187
+ * rite.adapter.mjs — Auto-generated by \`rite instrument\`.
188
+ * Edit seed definitions and the SEEDS map to match your fixtures.
189
+ * Generated: ${new Date().toISOString()}
190
+ */
191
+
192
+ ${importLine}
193
+
194
+ // TODO: populate SEEDS with your fixture data.
195
+ const SEEDS = {
196
+ 'example-id': {
197
+ id: 'example-id',
198
+ ownerId: 'actor-owner',
199
+ paymentStatus: 'paid',
200
+ amountCents: 1000,
201
+ currency: 'USD',
202
+ refunded: false,
203
+ },
204
+ };
205
+
206
+ function seedsForCase(resourceId) {
207
+ const seed = SEEDS[resourceId];
208
+ if (!seed) throw new Error(\`No seed for: \${resourceId}\`);
209
+ return [{ ...seed }];
210
+ }
211
+
212
+ export async function runCase(caseSpec) {
213
+ const { path: entryName, actor, input } = caseSpec;
214
+ const instance = ${instanceExpr};
215
+ ${snapshotBefore}
216
+ let result;
217
+ ${entryDispatch}
218
+ else throw new Error(\`Unknown entry: \${entryName}\`);
219
+ ${snapshotAfter}
220
+ const newEvents = snapAfter.events.slice(snapBefore.events.length);
221
+ const effects = newEvents.map(({ refundId: _rid, ...rest }) => rest);
222
+ const orderId = input?.orderId ?? input?.resourceId;
223
+ const stateBefore = snapBefore.orders?.find(o => o.id === orderId) ?? null;
224
+ const stateAfter = snapAfter.orders?.find(o => o.id === orderId) ?? null;
225
+ const stateChanged = JSON.stringify(stateBefore) !== JSON.stringify(stateAfter);
226
+ return {
227
+ decision: result?.ok === true ? 'allow' : 'deny',
228
+ effects,
229
+ stateBefore,
230
+ stateAfter,
231
+ stateChanged,
232
+ };
233
+ }
234
+ `;
235
+
236
+ writeFileSync(outPath, content, 'utf-8');
237
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * src/instrument/effect-schema.js — Typed effect registry.
3
+ *
4
+ * Defines named sink event schemas. The engine validates adapter-reported
5
+ * effects against the registered schema before structural comparison.
6
+ * This prevents two adapters describing the same event differently from
7
+ * both passing silently.
8
+ *
9
+ * Schema format:
10
+ * { field: 'type' | ['type', required?] }
11
+ * Types: 'string' | 'number' | 'boolean' | 'object' | 'any'
12
+ *
13
+ * Register your schema in rite.config.json under "effectSchema":
14
+ * "effectSchema": "refund-event"
15
+ *
16
+ * Or inline in config:
17
+ * "effectSchema": {
18
+ * "orderId": "string",
19
+ * "actorId": "string",
20
+ * "amountCents": "number",
21
+ * "currency": "string"
22
+ * }
23
+ */
24
+
25
+ // ------------------------------------------------------------------ //
26
+ // Built-in schemas //
27
+ // ------------------------------------------------------------------ //
28
+
29
+ /** @type {Record<string, Record<string, string | [string, boolean]>>} */
30
+ const BUILT_IN_SCHEMAS = {
31
+ 'refund-event': {
32
+ orderId: 'string',
33
+ actorId: 'string',
34
+ amountCents: 'number',
35
+ currency: 'string',
36
+ },
37
+ 'transfer-event': {
38
+ fromAccountId: 'string',
39
+ toAccountId: 'string',
40
+ amountCents: 'number',
41
+ currency: 'string',
42
+ },
43
+ 'permission-grant': {
44
+ subjectId: 'string',
45
+ resourceId: 'string',
46
+ permission: 'string',
47
+ grantedBy: 'string',
48
+ },
49
+ };
50
+
51
+ // ------------------------------------------------------------------ //
52
+ // Validator //
53
+ // ------------------------------------------------------------------ //
54
+
55
+ /**
56
+ * Resolve a schema from either a registered name or an inline object.
57
+ * @param {string | Record<string,string> | undefined} schemaRef
58
+ * @returns {Record<string,string> | null}
59
+ */
60
+ export function resolveSchema(schemaRef) {
61
+ if (!schemaRef) return null;
62
+ if (typeof schemaRef === 'string') return BUILT_IN_SCHEMAS[schemaRef] ?? null;
63
+ if (typeof schemaRef === 'object') return schemaRef;
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Validate one effect object against a schema.
69
+ * Returns an array of violation strings (empty = valid).
70
+ * @param {object} effect
71
+ * @param {Record<string,string>} schema
72
+ * @param {string} context — for error messages
73
+ * @returns {string[]}
74
+ */
75
+ export function validateEffect(effect, schema, context = 'effect') {
76
+ const violations = [];
77
+ for (const [field, typeSpec] of Object.entries(schema)) {
78
+ const [expectedType, required = true] = Array.isArray(typeSpec) ? typeSpec : [typeSpec, true];
79
+ const value = /** @type {Record<string,unknown>} */ (effect)[field];
80
+ if (value === undefined || value === null) {
81
+ if (required) violations.push(`${context}.${field}: required field missing`);
82
+ continue;
83
+ }
84
+ if (expectedType === 'any') continue;
85
+ const actualType = typeof value;
86
+ if (actualType !== expectedType)
87
+ violations.push(`${context}.${field}: expected ${expectedType}; got ${actualType} (${JSON.stringify(value)})`);
88
+ }
89
+ // Reject unknown fields — adapters must not smuggle extra data
90
+ for (const key of Object.keys(effect)) {
91
+ if (!(key in schema))
92
+ violations.push(`${context}.${key}: unexpected field not in schema`);
93
+ }
94
+ return violations;
95
+ }
96
+
97
+ /**
98
+ * Validate all effects in a case result against the config schema.
99
+ * Returns all violations across all effects.
100
+ * @param {object[]} effects
101
+ * @param {string | Record<string,string> | undefined} schemaRef
102
+ * @returns {string[]}
103
+ */
104
+ export function validateEffects(effects, schemaRef) {
105
+ const schema = resolveSchema(schemaRef);
106
+ if (!schema || !Array.isArray(effects)) return [];
107
+ const violations = [];
108
+ for (let i = 0; i < effects.length; i++) {
109
+ violations.push(...validateEffect(effects[i], schema, `effects[${i}]`));
110
+ }
111
+ return violations;
112
+ }
113
+
114
+ /** @returns {string[]} Names of all built-in schemas */
115
+ export function listSchemas() {
116
+ return Object.keys(BUILT_IN_SCHEMAS);
117
+ }
118
+
119
+ // ------------------------------------------------------------------ //
120
+ // Unified effect comparison //
121
+ // ------------------------------------------------------------------ //
122
+
123
+ /**
124
+ * Compare observed effects against expected effects and an optional schema.
125
+ * This is the single authoritative comparison used by both the engine and
126
+ * the browser runner — neither re-implements this logic.
127
+ *
128
+ * Checks (in order):
129
+ * 1. Count mismatch — a denial that produces effects always FAIL
130
+ * 2. Per-field structural match against expected values
131
+ * 3. Schema validation (if schemaRef provided)
132
+ *
133
+ * @param {object[]} observed
134
+ * @param {object[]} expected
135
+ * @param {string | Record<string,string> | undefined} [schemaRef]
136
+ * @returns {string[]} failure strings (empty = pass)
137
+ */
138
+ export function compareEffects(observed, expected, schemaRef) {
139
+ const failures = [];
140
+ const obs = Array.isArray(observed) ? observed : [];
141
+ const exp = Array.isArray(expected) ? expected : [];
142
+
143
+ // 1. Count
144
+ if (obs.length !== exp.length) {
145
+ failures.push(
146
+ `effects count: expected ${exp.length}; observed ${obs.length}` +
147
+ (obs.length > 0 ? ` (first: ${JSON.stringify(obs[0])})` : '')
148
+ );
149
+ }
150
+
151
+ // 2. Per-field structural match
152
+ for (let i = 0; i < exp.length; i++) {
153
+ const eEff = exp[i];
154
+ const oEff = obs[i];
155
+ if (!oEff) break; // already caught by count check
156
+ for (const key of Object.keys(eEff)) {
157
+ if (eEff[key] !== oEff[key])
158
+ failures.push(`effects[${i}].${key}: expected ${JSON.stringify(eEff[key])}; observed ${JSON.stringify(oEff[key])}`);
159
+ }
160
+ }
161
+
162
+ // 3. Schema validation
163
+ if (schemaRef && obs.length > 0) {
164
+ failures.push(...validateEffects(obs, schemaRef));
165
+ }
166
+
167
+ return failures;
168
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * src/mcp/server.js — Local MCP stdio server for Rite.
3
+ *
4
+ * Tools:
5
+ * rite_analyze — Read configured rule and path scope from a config file
6
+ * rite_verify — Run verification using the same core engine as the CLI
7
+ * rite_report — Read and render a saved report file
8
+ *
9
+ * Stdout carries MCP protocol messages only. Logs go to stderr.
10
+ * Workspace and file path arguments are validated; no generic shell execution.
11
+ *
12
+ * Start: node src/cli/rite.js mcp (or rite mcp)
13
+ * Test: npx @modelcontextprotocol/inspector node src/cli/rite.js mcp
14
+ */
15
+
16
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
17
+ import { resolve, dirname } from 'node:path';
18
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
19
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
20
+ import { z } from 'zod';
21
+ import { validateConfig, verify, ENGINE_VERSION } from '../core/engine.js';
22
+ import { loadAdapterFromConfig } from '../core/loader.js';
23
+
24
+ // ------------------------------------------------------------------ //
25
+ // Helpers shared with CLI //
26
+ // ------------------------------------------------------------------ //
27
+
28
+ function safeJson(filePath) {
29
+ try {
30
+ return { ok: true, data: JSON.parse(readFileSync(filePath, 'utf-8')) };
31
+ } catch (e) {
32
+ return { ok: false, error: e.message };
33
+ }
34
+ }
35
+
36
+ function renderReportText(report) {
37
+ const lines = [];
38
+ lines.push(`Rite v${ENGINE_VERSION} — ${report.ruleId}`);
39
+ lines.push(`Rule: ${report.rule}`);
40
+ lines.push(`Status: ${report.status} Generated: ${report.generatedAt}`);
41
+ lines.push('');
42
+ for (const r of report.results) {
43
+ const icon = r.status === 'PASS' ? '✓' : r.status === 'FAIL' ? '✗' : '!';
44
+ lines.push(`${icon} ${r.status.padEnd(5)} [${r.path}] ${r.id}`);
45
+ if (r.failures?.length) {
46
+ for (const f of r.failures) lines.push(` ${f}`);
47
+ }
48
+ if (r.error) lines.push(` ERROR: ${r.error.split('\n')[0]}`);
49
+ }
50
+ const total = report.results.length;
51
+ const passed = report.results.filter(r => r.status === 'PASS').length;
52
+ lines.push('');
53
+ lines.push(`${report.status}: ${passed}/${total} passed`);
54
+ return lines.join('\n');
55
+ }
56
+
57
+ // ------------------------------------------------------------------ //
58
+ // Server //
59
+ // ------------------------------------------------------------------ //
60
+
61
+ export async function startMcpServer() {
62
+ const server = new McpServer({
63
+ name: 'rite',
64
+ version: ENGINE_VERSION,
65
+ });
66
+
67
+ // ---- rite_analyze ------------------------------------------------ //
68
+ server.tool(
69
+ 'rite_analyze',
70
+ 'Read a Rite config file and return its rule, configured entry paths, and case summary.',
71
+ {
72
+ config: z.string().describe('Absolute or relative path to rite.config.json'),
73
+ },
74
+ async ({ config: configArg }) => {
75
+ const configPath = resolve(configArg);
76
+ if (!existsSync(configPath)) {
77
+ return { content: [{ type: 'text', text: `Config not found: ${configPath}` }], isError: true };
78
+ }
79
+ const parsed = safeJson(configPath);
80
+ if (!parsed.ok) {
81
+ return { content: [{ type: 'text', text: `Failed to parse config: ${parsed.error}` }], isError: true };
82
+ }
83
+ const errs = validateConfig(parsed.data);
84
+ if (errs.length) {
85
+ return { content: [{ type: 'text', text: `Config invalid:\n${errs.join('\n')}` }], isError: true };
86
+ }
87
+ const cfg = parsed.data;
88
+ const summary = [
89
+ `ruleId: ${cfg.ruleId}`,
90
+ `rule: ${cfg.rule}`,
91
+ '',
92
+ 'Configured paths:',
93
+ ...cfg.paths.map(p => ` ${p.entry} → ${p.sink} (${p.source})`),
94
+ '',
95
+ `Cases (${cfg.cases.length}):`,
96
+ ...cfg.cases.map(c => ` ${c.id} [${c.path}] actor=${c.actor} expected=${c.expected.decision}`),
97
+ ].join('\n');
98
+ return { content: [{ type: 'text', text: summary }] };
99
+ }
100
+ );
101
+
102
+ // ---- rite_verify ------------------------------------------------- //
103
+ server.tool(
104
+ 'rite_verify',
105
+ 'Run Rite verification using the same core engine as the CLI. Returns the full report as JSON.',
106
+ {
107
+ config: z.string().describe('Absolute or relative path to rite.config.json'),
108
+ out: z.string().optional().describe('Optional path to write the JSON report'),
109
+ },
110
+ async ({ config: configArg, out: outArg }) => {
111
+ const configPath = resolve(configArg);
112
+ if (!existsSync(configPath)) {
113
+ return { content: [{ type: 'text', text: `Config not found: ${configPath}` }], isError: true };
114
+ }
115
+ const parsed = safeJson(configPath);
116
+ if (!parsed.ok) {
117
+ return { content: [{ type: 'text', text: `Failed to parse config: ${parsed.error}` }], isError: true };
118
+ }
119
+ const errs = validateConfig(parsed.data);
120
+ if (errs.length) {
121
+ return { content: [{ type: 'text', text: `Config invalid:\n${errs.join('\n')}` }], isError: true };
122
+ }
123
+
124
+ const cfg = parsed.data;
125
+ const loaded = await loadAdapterFromConfig(cfg, configPath);
126
+ if (!loaded.ok) {
127
+ return { content: [{ type: 'text', text: loaded.error }], isError: true };
128
+ }
129
+ const { adapter } = loaded;
130
+
131
+ let report;
132
+ try {
133
+ report = await verify(cfg, adapter, { configPath });
134
+ } catch (e) {
135
+ return { content: [{ type: 'text', text: `Engine error: ${e.message}` }], isError: true };
136
+ }
137
+
138
+ if (outArg) {
139
+ try {
140
+ writeFileSync(resolve(outArg), JSON.stringify(report, null, 2), 'utf-8');
141
+ } catch (e) {
142
+ process.stderr.write(`rite-mcp: could not write report: ${e.message}\n`);
143
+ }
144
+ }
145
+
146
+ const text = renderReportText(report);
147
+ const json = JSON.stringify(report, null, 2);
148
+ return {
149
+ content: [
150
+ { type: 'text', text },
151
+ { type: 'text', text: '\n--- JSON ---\n' + json },
152
+ ],
153
+ };
154
+ }
155
+ );
156
+
157
+ // ---- rite_report ------------------------------------------------- //
158
+ server.tool(
159
+ 'rite_report',
160
+ 'Read and render a saved Rite report JSON file.',
161
+ {
162
+ file: z.string().describe('Absolute or relative path to a Rite report JSON file'),
163
+ },
164
+ async ({ file }) => {
165
+ const filePath = resolve(file);
166
+ if (!existsSync(filePath)) {
167
+ return { content: [{ type: 'text', text: `Report not found: ${filePath}` }], isError: true };
168
+ }
169
+ const parsed = safeJson(filePath);
170
+ if (!parsed.ok) {
171
+ return { content: [{ type: 'text', text: `Failed to parse report: ${parsed.error}` }], isError: true };
172
+ }
173
+ const report = parsed.data;
174
+ if (!report.results || !report.status) {
175
+ return { content: [{ type: 'text', text: 'File does not look like a Rite report.' }], isError: true };
176
+ }
177
+ return { content: [{ type: 'text', text: renderReportText(report) }] };
178
+ }
179
+ );
180
+
181
+ // Start transport
182
+ const transport = new StdioServerTransport();
183
+ await server.connect(transport);
184
+ process.stderr.write(`rite-mcp: server started (Rite v${ENGINE_VERSION})\n`);
185
+ }