easy-local-mcp 0.3.9

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,321 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { createConnection, createServer } from 'node:net';
4
+ import { mkdir, open, readFile, rm, chmod } from 'node:fs/promises';
5
+ import { homedir } from 'node:os';
6
+ import { resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { controlEndpoint } from './control-endpoint.js';
9
+ import { ensureControlSecret, getControlSecret } from './security.js';
10
+ import { ensureRelayConfigured } from './relay-config.js';
11
+ export const stateDir = resolve(homedir(), '.localmcp');
12
+ export const logFile = resolve(stateDir, 'agent.log');
13
+ const endpoint = controlEndpoint(stateDir);
14
+ const lockFile = resolve(stateDir, 'control.lock');
15
+ const pause = (ms) => new Promise(resolvePause => setTimeout(resolvePause, ms));
16
+ function secureEqual(left, right) {
17
+ const a = Buffer.from(left);
18
+ const b = Buffer.from(right);
19
+ return a.length === b.length && timingSafeEqual(a, b);
20
+ }
21
+ export function maskMcpUrl(url) {
22
+ if (!url)
23
+ return '-';
24
+ try {
25
+ const parsed = new URL(url);
26
+ const segments = parsed.pathname.split('/').filter(Boolean);
27
+ if (segments.length) {
28
+ const last = segments.length - 1;
29
+ segments[last] = '<redacted>';
30
+ parsed.pathname = '/' + segments.join('/');
31
+ }
32
+ parsed.search = '';
33
+ parsed.hash = '';
34
+ parsed.username = '';
35
+ parsed.password = '';
36
+ return parsed.href;
37
+ }
38
+ catch {
39
+ return '<redacted>';
40
+ }
41
+ }
42
+ export async function request(command = 'status', options = {}) {
43
+ const secret = await getControlSecret();
44
+ return new Promise((resolveStatus, reject) => {
45
+ const socket = createConnection(endpoint.address);
46
+ let data = '';
47
+ const timer = setTimeout(() => socket.destroy(new Error('Easy Local MCP control request timed out')), 20000);
48
+ socket.on('connect', () => {
49
+ const payload = {
50
+ command,
51
+ secret,
52
+ ...(options.minutes === undefined ? {} : { minutes: options.minutes }),
53
+ ...(options.workerUrl === undefined ? {} : { workerUrl: options.workerUrl })
54
+ };
55
+ socket.write(JSON.stringify(payload) + '\n');
56
+ });
57
+ socket.on('data', chunk => {
58
+ data += chunk;
59
+ if (data.length > 65536) {
60
+ socket.destroy(new Error('Invalid control response'));
61
+ }
62
+ });
63
+ socket.on('error', reject);
64
+ socket.on('close', () => clearTimeout(timer));
65
+ socket.on('end', () => {
66
+ try {
67
+ if (!data) {
68
+ throw Object.assign(new Error('Easy Local MCP control connection closed'), { code: 'ECONNRESET' });
69
+ }
70
+ const response = JSON.parse(data);
71
+ if (response.error)
72
+ reject(new Error(response.error));
73
+ else
74
+ resolveStatus(response);
75
+ }
76
+ catch (error) {
77
+ reject(error);
78
+ }
79
+ });
80
+ });
81
+ }
82
+ export async function status() {
83
+ try {
84
+ return await request();
85
+ }
86
+ catch (error) {
87
+ if (!['ENOENT', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE'].includes(error.code)) {
88
+ throw error;
89
+ }
90
+ return {
91
+ status: 'stopped',
92
+ pid: null,
93
+ url: null,
94
+ config: resolve(process.env.LOCALMCP_CONFIG || resolve(stateDir, 'localmcp.json')),
95
+ log: logFile,
96
+ ready: false,
97
+ locked: true,
98
+ unlockExpiresAt: null,
99
+ workerUrl: null,
100
+ deviceId: null,
101
+ workerManagedByEnv: process.env.LOCALMCP_WORKER_URL !== undefined
102
+ };
103
+ }
104
+ }
105
+ export function printStatus(value) {
106
+ console.log(`Status: ${value.status}\n`
107
+ + `PID: ${value.pid ?? '-'}\n`
108
+ + `Security: ${value.locked ? 'LOCKED' : 'UNLOCKED'}\n`
109
+ + `Unlock expires: ${value.unlockExpiresAt ?? '-'}\n`
110
+ + `MCP URL: ${maskMcpUrl(value.url)}\n`
111
+ + `Config: ${value.config}\n`
112
+ + `Log: ${value.log}`);
113
+ }
114
+ export function printUrl(value) {
115
+ if (value.status !== 'running' || !value.url) {
116
+ throw new Error('Easy Local MCP is not running or has no MCP URL');
117
+ }
118
+ console.log(value.url);
119
+ }
120
+ async function locked(action) {
121
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
122
+ const deadline = Date.now() + 90000;
123
+ while (true) {
124
+ try {
125
+ const file = await open(lockFile, 'wx', 0o600);
126
+ try {
127
+ await file.writeFile(String(process.pid));
128
+ }
129
+ finally {
130
+ await file.close();
131
+ }
132
+ break;
133
+ }
134
+ catch (error) {
135
+ if (error.code !== 'EEXIST')
136
+ throw error;
137
+ let pid = 0;
138
+ try {
139
+ pid = Number(await readFile(lockFile, 'utf8'));
140
+ }
141
+ catch (readError) {
142
+ if (readError.code !== 'ENOENT')
143
+ throw readError;
144
+ }
145
+ if (Number.isInteger(pid) && pid > 0) {
146
+ try {
147
+ process.kill(pid, 0);
148
+ }
149
+ catch (killError) {
150
+ if (killError.code === 'ESRCH') {
151
+ await rm(lockFile, { force: true });
152
+ continue;
153
+ }
154
+ }
155
+ }
156
+ if (Date.now() > deadline) {
157
+ throw new Error(`Another Easy Local MCP command is still running (${lockFile})`);
158
+ }
159
+ await pause(100);
160
+ }
161
+ }
162
+ try {
163
+ return await action();
164
+ }
165
+ finally {
166
+ await rm(lockFile, { force: true });
167
+ }
168
+ }
169
+ export async function control(command, initialize = async () => { }) {
170
+ return locked(async () => {
171
+ let current = await status();
172
+ if (command === 'reload') {
173
+ if (current.status === 'stopped') {
174
+ throw new Error('Easy Local MCP is stopped; run easy-local-mcp to start it');
175
+ }
176
+ current = await request('reload');
177
+ console.log('Easy Local MCP configuration reloaded.');
178
+ }
179
+ else if (command === 'stop') {
180
+ if (current.status === 'running') {
181
+ await request('stop');
182
+ for (let i = 0; i < 100; i++) {
183
+ current = await status();
184
+ if (current.status === 'stopped')
185
+ break;
186
+ await pause(100);
187
+ }
188
+ if (current.status !== 'stopped') {
189
+ throw new Error(`Easy Local MCP did not stop; see ${logFile}`);
190
+ }
191
+ }
192
+ }
193
+ else {
194
+ let child;
195
+ if (current.status === 'stopped') {
196
+ await ensureRelayConfigured();
197
+ await initialize();
198
+ if (endpoint.socketFile) {
199
+ await rm(endpoint.socketFile, { force: true });
200
+ }
201
+ const log = await open(logFile, 'a', 0o600);
202
+ try {
203
+ child = spawn(process.execPath, [fileURLToPath(new URL('./agent.js', import.meta.url))], {
204
+ detached: true,
205
+ stdio: ['ignore', log.fd, log.fd],
206
+ env: process.env,
207
+ windowsHide: process.platform === 'win32'
208
+ });
209
+ await new Promise((ready, reject) => {
210
+ child.once('spawn', ready);
211
+ child.once('error', reject);
212
+ });
213
+ child.unref();
214
+ }
215
+ finally {
216
+ await log.close();
217
+ }
218
+ }
219
+ const deadline = Date.now() + 60000;
220
+ while (!current.ready) {
221
+ if (child && (child.exitCode !== null || child.signalCode !== null)) {
222
+ throw new Error(`Easy Local MCP startup failed; see ${logFile}`);
223
+ }
224
+ if (Date.now() > deadline) {
225
+ child?.kill('SIGTERM');
226
+ throw new Error(`Easy Local MCP startup timed out; see ${logFile}`);
227
+ }
228
+ await pause(100);
229
+ current = await status();
230
+ }
231
+ }
232
+ printStatus(current);
233
+ });
234
+ }
235
+ export async function serveControl(getStatus, handlers) {
236
+ const controlSecret = await ensureControlSecret();
237
+ const server = createServer({ allowHalfOpen: true }, socket => {
238
+ let data = '';
239
+ let handled = false;
240
+ socket.setTimeout(20000, () => socket.destroy());
241
+ socket.on('error', () => { });
242
+ const handle = () => {
243
+ if (handled || socket.destroyed)
244
+ return;
245
+ handled = true;
246
+ void (async () => {
247
+ let parsed;
248
+ try {
249
+ parsed = JSON.parse(data.trim());
250
+ }
251
+ catch {
252
+ throw new Error('Invalid control request');
253
+ }
254
+ if (!parsed
255
+ || typeof parsed.command !== 'string'
256
+ || typeof parsed.secret !== 'string'
257
+ || !secureEqual(parsed.secret, controlSecret)) {
258
+ throw new Error('Unauthorized control request');
259
+ }
260
+ switch (parsed.command) {
261
+ case 'status':
262
+ break;
263
+ case 'reload':
264
+ await handlers.reload();
265
+ break;
266
+ case 'unlock':
267
+ await handlers.unlock(parsed.minutes);
268
+ break;
269
+ case 'lock':
270
+ await handlers.lock();
271
+ break;
272
+ case 'rotate':
273
+ await handlers.rotate();
274
+ break;
275
+ case 'reregister':
276
+ if (typeof parsed.workerUrl !== 'string' || !parsed.workerUrl) {
277
+ throw new Error('workerUrl is required');
278
+ }
279
+ await handlers.reregister(parsed.workerUrl);
280
+ break;
281
+ case 'stop':
282
+ socket.once('close', handlers.stop);
283
+ break;
284
+ default:
285
+ throw new Error('Unknown control command');
286
+ }
287
+ socket.end(JSON.stringify(await getStatus()));
288
+ })().catch(error => {
289
+ socket.end(JSON.stringify({
290
+ error: error instanceof Error ? error.message : String(error)
291
+ }));
292
+ });
293
+ };
294
+ socket.on('data', chunk => {
295
+ data += chunk;
296
+ if (data.length > 4096) {
297
+ socket.destroy();
298
+ return;
299
+ }
300
+ if (data.includes('\n')) {
301
+ handle();
302
+ }
303
+ });
304
+ socket.on('end', handle);
305
+ });
306
+ await new Promise((ready, reject) => {
307
+ server.once('error', reject);
308
+ server.listen(endpoint.address, ready);
309
+ });
310
+ if (endpoint.socketFile) {
311
+ await chmod(endpoint.socketFile, 0o600);
312
+ }
313
+ return async () => {
314
+ await new Promise((done, reject) => {
315
+ server.close(error => error ? reject(error) : done());
316
+ });
317
+ if (endpoint.socketFile) {
318
+ await rm(endpoint.socketFile, { force: true });
319
+ }
320
+ };
321
+ }
@@ -0,0 +1,86 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
+ import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
4
+ export class McpLoader {
5
+ servers;
6
+ loaded = [];
7
+ starting = new Map();
8
+ pending = new Set();
9
+ track(operation) { this.pending.add(operation); return operation.finally(() => this.pending.delete(operation)); }
10
+ constructor(servers) {
11
+ this.servers = servers;
12
+ }
13
+ getLoaded(name) { return this.loaded.find(server => server.name === name); }
14
+ async ensure(name) {
15
+ const existing = this.getLoaded(name);
16
+ if (existing)
17
+ return existing;
18
+ const inFlight = this.starting.get(name);
19
+ if (inFlight)
20
+ return inFlight;
21
+ const cfg = this.servers[name];
22
+ if (!cfg)
23
+ throw new Error(`Unknown MCP server '${name}'`);
24
+ const operation = (async () => {
25
+ const client = new Client({ name: `easy-local-mcp-${name}`, version: '0.3.0' });
26
+ const transport = new StdioClientTransport({ command: cfg.command, args: cfg.args || [], env: cfg.env, stderr: 'inherit' });
27
+ const server = { name, client, tools: [] };
28
+ try {
29
+ await client.connect(transport);
30
+ await this.refresh(server);
31
+ this.loaded.push(server);
32
+ return server;
33
+ }
34
+ catch (error) {
35
+ await client.close().catch(() => { });
36
+ throw error;
37
+ }
38
+ finally {
39
+ this.starting.delete(name);
40
+ }
41
+ })();
42
+ this.starting.set(name, operation);
43
+ return operation;
44
+ }
45
+ async start() {
46
+ try {
47
+ for (const name of Object.keys(this.servers))
48
+ await this.ensure(name);
49
+ }
50
+ catch (error) {
51
+ await this.close();
52
+ throw error;
53
+ }
54
+ }
55
+ async refresh(server) {
56
+ const tools = [];
57
+ let cursor;
58
+ do {
59
+ const page = await server.client.listTools({ cursor });
60
+ tools.push(...page.tools);
61
+ cursor = page.nextCursor;
62
+ } while (cursor);
63
+ server.tools = tools;
64
+ return tools;
65
+ }
66
+ listServers() { return Object.keys(this.servers).map(name => ({ name, running: !!this.getLoaded(name) })); }
67
+ async listTools(server) {
68
+ return this.track((async () => this.refresh(await this.ensure(server)))());
69
+ }
70
+ async call(serverName, toolName, args) {
71
+ const server = await this.ensure(serverName);
72
+ const tool = server.tools.find(tool => tool.name === toolName);
73
+ if (!tool)
74
+ throw new Error(`Unknown MCP tool '${toolName}' on server '${serverName}'; use list_mcp_tools first`);
75
+ const validation = new AjvJsonSchemaValidator().getValidator(tool.inputSchema)(args);
76
+ if (!validation.valid)
77
+ throw new Error(`Invalid arguments for MCP tool '${toolName}': ${validation.errorMessage}`);
78
+ return this.track(server.client.callTool({ name: toolName, arguments: args }, undefined, { timeout: 60000 }));
79
+ }
80
+ async close() {
81
+ await Promise.allSettled([...this.pending, ...this.starting.values()]);
82
+ await Promise.allSettled(this.loaded.map(server => server.client.close()));
83
+ this.loaded = [];
84
+ this.starting.clear();
85
+ }
86
+ }
@@ -0,0 +1,122 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ const MAX_BUFFER = 1024 * 1024;
4
+ export class ProcessManager {
5
+ processes = new Map();
6
+ start(command, cwd) {
7
+ const env = {};
8
+ for (const key of ['PATH', 'HOME', 'USER', 'TMPDIR', 'LANG', 'SHELL', 'SystemRoot'])
9
+ if (process.env[key])
10
+ env[key] = process.env[key];
11
+ const child = spawn(command, { cwd, shell: true, detached: process.platform !== 'win32', env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: process.platform === 'win32' });
12
+ const proc = { id: randomUUID(), child, command, cwd, stdout: '', stderr: '', stdoutOffset: 0, stderrOffset: 0, startedAt: new Date().toISOString(), running: true, exitCode: null, signal: null };
13
+ const collect = (stream, chunk) => {
14
+ const text = chunk.toString('utf8');
15
+ proc[stream] += text;
16
+ if (Buffer.byteLength(proc[stream]) > MAX_BUFFER) {
17
+ const excess = Buffer.byteLength(proc[stream]) - MAX_BUFFER;
18
+ proc[stream] = Buffer.from(proc[stream]).subarray(excess).toString('utf8');
19
+ if (stream === 'stdout')
20
+ proc.stdoutOffset += excess;
21
+ else
22
+ proc.stderrOffset += excess;
23
+ }
24
+ };
25
+ child.stdout?.on('data', c => collect('stdout', c));
26
+ child.stderr?.on('data', c => collect('stderr', c));
27
+ const markExited = (code, signal) => {
28
+ proc.running = false;
29
+ proc.exitCode = code;
30
+ proc.signal = signal;
31
+ };
32
+ // Use exit for process state. On Windows, close can be delayed indefinitely
33
+ // when descendants inherit the shell's stdio handles after the shell exits.
34
+ child.on('exit', markExited);
35
+ child.on('close', markExited);
36
+ child.on('error', () => { proc.running = false; });
37
+ this.processes.set(proc.id, proc);
38
+ return { processId: proc.id, pid: child.pid, running: true, startedAt: proc.startedAt };
39
+ }
40
+ read(id, stdoutCursor = 0, stderrCursor = 0) {
41
+ const proc = this.mustGet(id);
42
+ const readOne = (text, base, cursor) => {
43
+ const effective = Math.max(cursor, base);
44
+ const data = Buffer.from(text);
45
+ const local = Math.min(Math.max(0, effective - base), data.length);
46
+ return { text: data.subarray(local).toString('utf8'), nextCursor: base + data.length, truncatedBeforeCursor: cursor < base };
47
+ };
48
+ const out = readOne(proc.stdout, proc.stdoutOffset, stdoutCursor);
49
+ const err = readOne(proc.stderr, proc.stderrOffset, stderrCursor);
50
+ return { processId: id, stdout: out.text, stderr: err.text, nextStdoutCursor: out.nextCursor, nextStderrCursor: err.nextCursor, stdoutTruncated: out.truncatedBeforeCursor, stderrTruncated: err.truncatedBeforeCursor, running: proc.running, exitCode: proc.exitCode, signal: proc.signal };
51
+ }
52
+ write(id, input) {
53
+ const proc = this.mustGet(id);
54
+ if (!proc.running || !proc.child.stdin || proc.child.stdin.destroyed)
55
+ throw new Error('Process stdin is not available');
56
+ proc.child.stdin.write(input);
57
+ return { processId: id, bytes: Buffer.byteLength(input) };
58
+ }
59
+ stop(id) {
60
+ const proc = this.mustGet(id);
61
+ if (!proc.running)
62
+ return { processId: id, running: false, exitCode: proc.exitCode, signal: proc.signal };
63
+ this.terminate(proc, false);
64
+ return { processId: id, stopping: true };
65
+ }
66
+ list() {
67
+ return [...this.processes.values()].map(proc => ({ processId: proc.id, pid: proc.child.pid, command: proc.command, cwd: proc.cwd, startedAt: proc.startedAt, running: proc.running, exitCode: proc.exitCode, signal: proc.signal }));
68
+ }
69
+ async close() {
70
+ const pending = [];
71
+ for (const proc of this.processes.values()) {
72
+ if (!proc.running)
73
+ continue;
74
+ pending.push(this.waitForExit(proc));
75
+ this.terminate(proc, true);
76
+ }
77
+ await Promise.allSettled(pending);
78
+ }
79
+ terminate(proc, force) {
80
+ try {
81
+ if (process.platform === 'win32' && proc.child.pid) {
82
+ const killer = spawn('taskkill', ['/PID', String(proc.child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
83
+ killer.once('error', () => {
84
+ try {
85
+ proc.child.kill(force ? 'SIGKILL' : 'SIGTERM');
86
+ }
87
+ catch { }
88
+ });
89
+ }
90
+ else if (proc.child.pid) {
91
+ process.kill(-proc.child.pid, force ? 'SIGKILL' : 'SIGTERM');
92
+ }
93
+ else {
94
+ proc.child.kill(force ? 'SIGKILL' : 'SIGTERM');
95
+ }
96
+ }
97
+ catch { }
98
+ }
99
+ async waitForExit(proc) {
100
+ if (!proc.running)
101
+ return;
102
+ await new Promise(resolveExit => {
103
+ let settled = false;
104
+ const finish = () => {
105
+ if (settled)
106
+ return;
107
+ settled = true;
108
+ clearTimeout(timer);
109
+ resolveExit();
110
+ };
111
+ const timer = setTimeout(finish, 3000);
112
+ proc.child.once('exit', finish);
113
+ proc.child.once('close', finish);
114
+ });
115
+ }
116
+ mustGet(id) {
117
+ const proc = this.processes.get(id);
118
+ if (!proc)
119
+ throw new Error('Unknown processId');
120
+ return proc;
121
+ }
122
+ }
@@ -0,0 +1,145 @@
1
+ import { access, readFile, rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import { DEFAULT_PUBLIC_WORKER_URL, validatedWorkerOrigin } from './relay.js';
5
+ import { secureWriteFileAtomic } from './security.js';
6
+ const relayStateDir = resolve(homedir(), '.localmcp');
7
+ export const relayConfigFile = resolve(relayStateDir, 'relay.json');
8
+ export const registeredWorkerFile = resolve(relayStateDir, 'worker.json');
9
+ export const pendingRegistrationTokenFile = resolve(relayStateDir, 'registration-token.pending');
10
+ async function readJson(path) {
11
+ try {
12
+ return JSON.parse(await readFile(path, 'utf8'));
13
+ }
14
+ catch (error) {
15
+ if (error.code === 'ENOENT')
16
+ return undefined;
17
+ throw error;
18
+ }
19
+ }
20
+ function workerUrlFrom(value, path) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
22
+ throw new Error(`Invalid Relay configuration in ${path}`);
23
+ }
24
+ const workerUrl = value.workerUrl;
25
+ if (typeof workerUrl !== 'string' || !workerUrl.trim()) {
26
+ throw new Error(`Invalid workerUrl in ${path}`);
27
+ }
28
+ return validatedWorkerOrigin(workerUrl).href;
29
+ }
30
+ export async function relaySetupState() {
31
+ const envWorker = process.env.LOCALMCP_WORKER_URL?.trim();
32
+ if (envWorker) {
33
+ return {
34
+ configured: true,
35
+ workerUrl: validatedWorkerOrigin(envWorker).href,
36
+ suggestedWorkerUrl: validatedWorkerOrigin(envWorker).href,
37
+ source: 'env',
38
+ managedByEnv: true,
39
+ registrationTokenManagedByEnv: process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined
40
+ };
41
+ }
42
+ const saved = await readJson(relayConfigFile);
43
+ if (saved !== undefined) {
44
+ const workerUrl = workerUrlFrom(saved, relayConfigFile);
45
+ return {
46
+ configured: true,
47
+ workerUrl,
48
+ suggestedWorkerUrl: workerUrl,
49
+ source: 'saved',
50
+ managedByEnv: false,
51
+ registrationTokenManagedByEnv: process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined
52
+ };
53
+ }
54
+ const registered = await readJson(registeredWorkerFile);
55
+ if (registered !== undefined) {
56
+ const workerUrl = workerUrlFrom(registered, registeredWorkerFile);
57
+ return {
58
+ configured: true,
59
+ workerUrl,
60
+ suggestedWorkerUrl: workerUrl,
61
+ source: 'registered',
62
+ managedByEnv: false,
63
+ registrationTokenManagedByEnv: process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined
64
+ };
65
+ }
66
+ return {
67
+ configured: false,
68
+ workerUrl: null,
69
+ suggestedWorkerUrl: validatedWorkerOrigin(DEFAULT_PUBLIC_WORKER_URL).href,
70
+ source: 'unconfigured',
71
+ managedByEnv: false,
72
+ registrationTokenManagedByEnv: process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined
73
+ };
74
+ }
75
+ export async function ensureRelayConfigured() {
76
+ const envWorker = process.env.LOCALMCP_WORKER_URL?.trim();
77
+ if (envWorker) {
78
+ validatedWorkerOrigin(envWorker);
79
+ return;
80
+ }
81
+ const saved = await readJson(relayConfigFile);
82
+ if (saved !== undefined) {
83
+ workerUrlFrom(saved, relayConfigFile);
84
+ return;
85
+ }
86
+ try {
87
+ await access(registeredWorkerFile);
88
+ return;
89
+ }
90
+ catch (error) {
91
+ if (error.code !== 'ENOENT')
92
+ throw error;
93
+ }
94
+ throw new Error('Relay is not configured. Open the Easy Local MCP Control Center and choose a Relay before starting the Agent.');
95
+ }
96
+ export async function configuredRelayUrl() {
97
+ const state = await relaySetupState();
98
+ if (!state.configured || !state.workerUrl) {
99
+ throw new Error('Relay is not configured. Open the Easy Local MCP Control Center and choose a Relay before starting the Agent.');
100
+ }
101
+ return state.workerUrl;
102
+ }
103
+ export async function saveRelayPreference(workerUrl) {
104
+ if (process.env.LOCALMCP_WORKER_URL) {
105
+ throw new Error('Relay is controlled by LOCALMCP_WORKER_URL; remove the environment override before changing it in the UI.');
106
+ }
107
+ const normalized = validatedWorkerOrigin(workerUrl).href;
108
+ await secureWriteFileAtomic(relayConfigFile, JSON.stringify({ workerUrl: normalized }, null, 2) + '\n');
109
+ return normalized;
110
+ }
111
+ export async function savePendingRegistrationToken(value) {
112
+ if (process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined) {
113
+ if (value?.trim()) {
114
+ throw new Error('Registration token is controlled by LOCALMCP_REGISTRATION_TOKEN; remove the environment override before changing it in the UI.');
115
+ }
116
+ return;
117
+ }
118
+ const token = value?.trim() ?? '';
119
+ if (!token) {
120
+ await rm(pendingRegistrationTokenFile, { force: true });
121
+ return;
122
+ }
123
+ if (token.length > 8192) {
124
+ throw new Error('Registration token is too long');
125
+ }
126
+ await secureWriteFileAtomic(pendingRegistrationTokenFile, token + '\n');
127
+ }
128
+ export async function registrationToken() {
129
+ if (process.env.LOCALMCP_REGISTRATION_TOKEN !== undefined) {
130
+ return process.env.LOCALMCP_REGISTRATION_TOKEN;
131
+ }
132
+ try {
133
+ return (await readFile(pendingRegistrationTokenFile, 'utf8')).trim() || undefined;
134
+ }
135
+ catch (error) {
136
+ if (error.code === 'ENOENT')
137
+ return undefined;
138
+ throw error;
139
+ }
140
+ }
141
+ export async function clearPendingRegistrationToken() {
142
+ if (process.env.LOCALMCP_REGISTRATION_TOKEN === undefined) {
143
+ await rm(pendingRegistrationTokenFile, { force: true });
144
+ }
145
+ }