scalix 1.5.0 → 1.5.2

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.
@@ -4,9 +4,11 @@ import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { sandboxPath, getSandboxRoot } from './sandbox.js';
6
6
  import { isPrivateHost } from './constants.js';
7
+ import { checkLocalAccess, isLocalOriginApproved } from './browser-access.js';
7
8
  import { encodeImageToDataUrl } from './tools-vision.js';
8
- // Validate a navigation target before handing it to Playwright: http/https only
9
- // and never a localhost/private-network host (defeats browser-based SSRF).
9
+ // Validate a navigation target's shape before handing it to Playwright:
10
+ // http/https only. Whether a *local* target is reachable is a separate,
11
+ // user-answerable question — see checkLocalAccess in browser-access.ts.
10
12
  // Returns an error string when the URL must be rejected, otherwise null.
11
13
  function validateNavigationUrl(rawUrl) {
12
14
  let parsed;
@@ -19,18 +21,17 @@ function validateNavigationUrl(rawUrl) {
19
21
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
20
22
  return `Blocked: only http/https URLs are allowed (got ${parsed.protocol})`;
21
23
  }
22
- if (isPrivateHost(parsed.hostname)) {
23
- return 'Blocked: navigation to localhost and private networks is not allowed';
24
- }
25
24
  return null;
26
25
  }
27
26
  // Re-check the URL a page actually landed on (post-redirect) and reject when it
28
- // resolved to a private host.
27
+ // resolved to a private host the user has not cleared. A redirect is not a
28
+ // moment to interrupt with a prompt, so an unapproved local origin is simply
29
+ // refused here — the model can navigate to it directly and be asked properly.
29
30
  function validateResolvedUrl(rawUrl) {
30
31
  try {
31
32
  const parsed = new URL(rawUrl);
32
33
  if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
33
- if (isPrivateHost(parsed.hostname)) {
34
+ if (isPrivateHost(parsed.hostname) && !isLocalOriginApproved(rawUrl)) {
34
35
  return 'Blocked: navigation redirected to a private network address';
35
36
  }
36
37
  }
@@ -40,6 +41,27 @@ function validateResolvedUrl(rawUrl) {
40
41
  }
41
42
  return null;
42
43
  }
44
+ let frameSink = null;
45
+ /** Front ends that can display the agent's browser register here. */
46
+ export function setBrowserFrameSink(fn) {
47
+ frameSink = fn;
48
+ }
49
+ // Capture is best-effort and must never turn a working action into a failed
50
+ // one: a page that navigated away mid-shot is a missing frame, not an error.
51
+ async function emitFrame(page, action) {
52
+ if (!frameSink)
53
+ return;
54
+ try {
55
+ const buf = await page.screenshot({ type: 'jpeg', quality: 55 });
56
+ frameSink({
57
+ action,
58
+ url: page.url(),
59
+ title: await page.title().catch(() => ''),
60
+ dataUrl: `data:image/jpeg;base64,${buf.toString('base64')}`,
61
+ });
62
+ }
63
+ catch { }
64
+ }
43
65
  let browserInstance = null;
44
66
  let browserContext = null;
45
67
  let browserPage = null;
@@ -76,12 +98,30 @@ async function ensureBrowser() {
76
98
  browserPage = null;
77
99
  }
78
100
  }
79
- browserInstance = await pw.chromium.launch({ headless: true });
80
- browserContext = await browserInstance.newContext({
81
- viewport: { width: 1280, height: 720 },
82
- });
83
- browserPage = await browserContext.newPage();
84
- return { browser: browserInstance, page: browserPage };
101
+ try {
102
+ browserInstance = await pw.chromium.launch({ headless: true });
103
+ browserContext = await browserInstance.newContext({
104
+ viewport: { width: 1280, height: 720 },
105
+ });
106
+ browserPage = await browserContext.newPage();
107
+ return { browser: browserInstance, page: browserPage };
108
+ }
109
+ catch (err) {
110
+ if (browserInstance) {
111
+ try {
112
+ await browserInstance.close();
113
+ }
114
+ catch { }
115
+ browserInstance = null;
116
+ }
117
+ browserContext = null;
118
+ browserPage = null;
119
+ const msg = err?.message || String(err);
120
+ if (msg.includes('Executable doesn\'t exist') || msg.includes('playwright install') || msg.includes('chromium')) {
121
+ throw new Error(`Playwright browser binaries not found. Run 'npx playwright install chromium' to enable browser automation.`);
122
+ }
123
+ throw err;
124
+ }
85
125
  }
86
126
  export async function closeBrowser() {
87
127
  if (browserContext) {
@@ -111,6 +151,11 @@ export const browserNavigateTool = tool({
111
151
  const preCheck = validateNavigationUrl(url);
112
152
  if (preCheck)
113
153
  return { error: preCheck };
154
+ // localhost and the LAN need the user's say-so, once per origin per
155
+ // project. This is what lets the agent open the dev server it just edited.
156
+ const localGate = await checkLocalAccess(url);
157
+ if (localGate)
158
+ return { error: localGate };
114
159
  const session = await ensureBrowser();
115
160
  if (!session) {
116
161
  return { error: 'Playwright not available. Install it: npm install playwright && npx playwright install chromium' };
@@ -130,6 +175,7 @@ export const browserNavigateTool = tool({
130
175
  return { error: postCheck, url: currentUrl };
131
176
  }
132
177
  const title = await session.page.title();
178
+ await emitFrame(session.page, `navigate ${currentUrl}`);
133
179
  return {
134
180
  url: currentUrl,
135
181
  title,
@@ -228,7 +274,7 @@ export const browserScreenshotTool = tool({
228
274
  overlay.remove();
229
275
  });
230
276
  }
231
- return {
277
+ const shot = {
232
278
  url: session.page.url(),
233
279
  title: await session.page.title(),
234
280
  path: outputPath || undefined,
@@ -236,6 +282,11 @@ export const browserScreenshotTool = tool({
236
282
  dataUrl,
237
283
  annotations,
238
284
  };
285
+ // Reuse the shot the model is about to look at rather than taking a
286
+ // second one — the annotated overlay is exactly what is worth showing.
287
+ if (frameSink)
288
+ frameSink({ action: 'screenshot', url: shot.url, title: shot.title, dataUrl });
289
+ return shot;
239
290
  }
240
291
  catch (error) {
241
292
  return { error: `Screenshot failed: ${error.message}` };
@@ -273,6 +324,7 @@ export const browserClickTool = tool({
273
324
  }
274
325
  await session.page.waitForTimeout(500);
275
326
  const target = typeof x === 'number' ? `coords:(${x},${y})` : selector || `text:"${text}"`;
327
+ await emitFrame(session.page, `click ${target}`);
276
328
  return { clicked: target, url: session.page.url() };
277
329
  }
278
330
  catch (error) {
@@ -323,6 +375,7 @@ export const browserMouseTool = tool({
323
375
  await session.page.waitForTimeout(300);
324
376
  break;
325
377
  }
378
+ await emitFrame(session.page, `${action} (${x},${y})`);
326
379
  return { action, x, y, url: session.page.url() };
327
380
  }
328
381
  catch (error) {
@@ -355,6 +408,7 @@ export const browserTypeTool = tool({
355
408
  await session.page.press(selector, 'Enter');
356
409
  await session.page.waitForTimeout(500);
357
410
  }
411
+ await emitFrame(session.page, `type into ${selector}`);
358
412
  return { typed: text.length > 50 ? text.slice(0, 50) + '...' : text, selector, url: session.page.url() };
359
413
  }
360
414
  catch (error) {
@@ -489,7 +543,16 @@ export const browserSessionRestoreTool = tool({
489
543
  browserPage = null;
490
544
  }
491
545
  if (!browserInstance) {
492
- browserInstance = await pw.chromium.launch({ headless: true });
546
+ try {
547
+ browserInstance = await pw.chromium.launch({ headless: true });
548
+ }
549
+ catch (err) {
550
+ const msg = err?.message || String(err);
551
+ if (msg.includes('Executable doesn\'t exist') || msg.includes('playwright install') || msg.includes('chromium')) {
552
+ return { error: `Session restore failed: Playwright browser binaries not found. Run 'npx playwright install chromium' to enable browser automation.` };
553
+ }
554
+ return { error: `Session restore failed: ${msg}` };
555
+ }
493
556
  }
494
557
  browserContext = await browserInstance.newContext({
495
558
  viewport: session.viewport,
@@ -497,7 +560,11 @@ export const browserSessionRestoreTool = tool({
497
560
  });
498
561
  browserPage = await browserContext.newPage();
499
562
  if (session.url && session.url !== 'about:blank') {
500
- const preCheck = validateNavigationUrl(session.url);
563
+ const preCheck = validateNavigationUrl(session.url)
564
+ // A saved session is a stored URL replayed without the model naming
565
+ // it, so a local target still has to clear the same consent gate as
566
+ // a fresh navigation.
567
+ || await checkLocalAccess(session.url);
501
568
  if (preCheck) {
502
569
  return { error: `Session restore blocked: saved URL rejected — ${preCheck}` };
503
570
  }
@@ -575,6 +642,10 @@ export const browserCloseTool = tool({
575
642
  }
576
643
  catch { }
577
644
  await closeBrowser();
645
+ // An empty frame means "the agent's browser is gone" — the viewer keeps the
646
+ // last picture but stops implying it is live.
647
+ if (frameSink)
648
+ frameSink({ action: 'closed', url: '', title: '', dataUrl: '' });
578
649
  return { status: 'closed', sessionSaved: saveSession || undefined };
579
650
  },
580
651
  });
@@ -160,9 +160,21 @@ export const searchFilesTool = tool({
160
160
  }
161
161
  }
162
162
  // Fallback: built-in search using fast-glob + regex
163
- const globPattern = fileGlob || '**/*';
164
- const files = await fg(globPattern, {
165
- cwd,
163
+ let searchCwd = cwd;
164
+ let targetPattern = fileGlob || '**/*';
165
+ if (searchPath) {
166
+ const safeTarget = sandboxPath(searchPath);
167
+ const st = await fs.stat(safeTarget).catch(() => null);
168
+ if (st && !st.isDirectory()) {
169
+ searchCwd = path.dirname(safeTarget);
170
+ targetPattern = path.basename(safeTarget);
171
+ }
172
+ else {
173
+ searchCwd = safeTarget;
174
+ }
175
+ }
176
+ const files = await fg(targetPattern, {
177
+ cwd: searchCwd,
166
178
  ignore: ['node_modules/**', '.git/**', 'dist/**', 'coverage/**', '*.lock'],
167
179
  absolute: true,
168
180
  onlyFiles: true,
@@ -0,0 +1,74 @@
1
+ import { tool } from './tool-helper.js';
2
+ import { z } from 'zod';
3
+ import { loadMemory, addConvention, addPattern, addNote } from './project-memory.js';
4
+ import { getPersistentCwd } from './tools.js';
5
+ export const saveMemoryTool = tool({
6
+ name: 'save_memory',
7
+ description: 'Save a project convention, architectural pattern, or durable note into the project memory (.scalix/memory.json). Use whenever the user asks you to remember something, corrects a convention, or when establishing project standards.',
8
+ inputSchema: z.object({
9
+ category: z.enum(['convention', 'pattern', 'note']).describe('Type of memory to store'),
10
+ key: z.string().max(100).optional().describe('Convention key (e.g., "package_manager", "database", "api_base", "styling") when category is "convention"'),
11
+ value: z.string().max(1000).optional().describe('Convention value (e.g., "pnpm", "PostgreSQL", "Tailwind CSS") when category is "convention"'),
12
+ note: z.string().max(2000).optional().describe('Text note/guideline/rule when category is "note"'),
13
+ pattern: z.object({
14
+ name: z.string().max(100).describe('Pattern name (e.g., "Repository Pattern", "Zod Validation")'),
15
+ description: z.string().max(2000).describe('Detailed explanation of how and when to use this pattern'),
16
+ example: z.string().max(2000).optional().describe('Code snippet or usage example'),
17
+ }).optional().describe('Pattern definition when category is "pattern"'),
18
+ }),
19
+ execute: async ({ category, key, value, note, pattern }) => {
20
+ const cwd = getPersistentCwd() || process.cwd();
21
+ if (category === 'convention') {
22
+ if (!key || !value) {
23
+ return { success: false, error: 'Both "key" and "value" are required when saving a convention.' };
24
+ }
25
+ await addConvention(cwd, key, value, 'user');
26
+ return { success: true, message: `Saved convention: ${key} = ${value}` };
27
+ }
28
+ if (category === 'pattern') {
29
+ if (!pattern || !pattern.name || !pattern.description) {
30
+ return { success: false, error: '"pattern.name" and "pattern.description" are required when saving a pattern.' };
31
+ }
32
+ await addPattern(cwd, pattern);
33
+ return { success: true, message: `Saved pattern: ${pattern.name}` };
34
+ }
35
+ if (category === 'note') {
36
+ if (!note || !note.trim()) {
37
+ return { success: false, error: '"note" text is required when saving a note.' };
38
+ }
39
+ await addNote(cwd, note.trim());
40
+ return { success: true, message: `Saved note: "${note.trim()}"` };
41
+ }
42
+ return { success: false, error: `Unsupported category "${category}"` };
43
+ },
44
+ });
45
+ export const getMemoryTool = tool({
46
+ name: 'get_memory',
47
+ description: 'Retrieve stored project memory (.scalix/memory.json), including conventions, architectural patterns, and user notes.',
48
+ inputSchema: z.object({
49
+ category: z.enum(['all', 'conventions', 'patterns', 'notes']).optional().describe('Filter by memory category (default: "all")'),
50
+ }),
51
+ execute: async ({ category = 'all' }) => {
52
+ const cwd = getPersistentCwd() || process.cwd();
53
+ const mem = await loadMemory(cwd);
54
+ if (category === 'conventions') {
55
+ return { conventions: mem.conventions, lastUpdated: mem.lastUpdated };
56
+ }
57
+ if (category === 'patterns') {
58
+ return { patterns: mem.patterns, lastUpdated: mem.lastUpdated };
59
+ }
60
+ if (category === 'notes') {
61
+ return { notes: mem.notes, lastUpdated: mem.lastUpdated };
62
+ }
63
+ return {
64
+ conventions: mem.conventions,
65
+ patterns: mem.patterns,
66
+ notes: mem.notes,
67
+ lastUpdated: mem.lastUpdated,
68
+ };
69
+ },
70
+ });
71
+ export const memoryTools = [
72
+ saveMemoryTool,
73
+ getMemoryTool,
74
+ ];
package/dist/tools-web.js CHANGED
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
  import dns from 'node:dns';
6
6
  import { sandboxPath } from './sandbox.js';
7
7
  import { logger } from './logger.js';
8
- import { USER_AGENT, SEARCH_ENDPOINTS, WEB_DEFAULTS, TOOL_DEFAULTS, HTML_STRIP_TAGS, isPrivateHost, isPrivateIp, } from './constants.js';
8
+ import { USER_AGENT, SEARCH_ENDPOINTS, WEB_DEFAULTS, TOOL_DEFAULTS, HTML_STRIP_TAGS, isPrivateHost, isPrivateIp, isHostExplicitlyAllowed, } from './constants.js';
9
9
  import { detectPromptInjection } from './injection-detector.js';
10
10
  import { DEFAULT_LIMITS } from './settings.js';
11
11
  const MAX_FETCH_REDIRECTS = 5;
@@ -20,6 +20,10 @@ async function validateFetchTarget(target) {
20
20
  if (isPrivateHost(hostname)) {
21
21
  return 'Blocked: requests to localhost and private networks are not allowed';
22
22
  }
23
+ // An operator who allow-listed this exact name did so knowing it points into
24
+ // a private range; re-checking the resolved address would only undo that.
25
+ if (isHostExplicitlyAllowed(hostname))
26
+ return null;
23
27
  try {
24
28
  const addresses = await dns.promises.lookup(hostname, { all: true });
25
29
  if (addresses.some((a) => isPrivateIp(a.address))) {
@@ -325,30 +329,86 @@ export const treeTool = tool({
325
329
  return { tree: lines.join('\n') };
326
330
  },
327
331
  });
332
+ // The tool advertises itself as the one to reach for on large files, so it is
333
+ // the last place that should read the whole file into a string first. Above
334
+ // `readFileMaxSize` it streams and stops at the requested end line; below it,
335
+ // the whole-file read stays (it is cheaper and gives an exact totalLines).
336
+ // Either way the span itself is capped — `endLine: 10_000_000` used to return
337
+ // every line in the file and blow the context window open.
338
+ const MAX_LINE_SPAN = 2000;
339
+ const MAX_LINE_CHARS = 4000;
340
+ async function streamLineRange(safePath, start, end) {
341
+ const { createReadStream } = await import('node:fs');
342
+ const readline = await import('node:readline');
343
+ const rl = readline.createInterface({
344
+ input: createReadStream(safePath, { encoding: 'utf-8' }),
345
+ crlfDelay: Infinity,
346
+ });
347
+ const lines = [];
348
+ let n = 0;
349
+ let reachedEof = true;
350
+ try {
351
+ for await (const line of rl) {
352
+ n++;
353
+ if (n < start)
354
+ continue;
355
+ lines.push({ lineNumber: n, text: line.slice(0, MAX_LINE_CHARS) });
356
+ if (n >= end) {
357
+ reachedEof = false;
358
+ break;
359
+ }
360
+ }
361
+ }
362
+ finally {
363
+ rl.close();
364
+ }
365
+ return { lines, reachedEof, scanned: n };
366
+ }
328
367
  export const readFileLinesTool = tool({
329
368
  name: 'read_file_lines',
330
369
  description: 'Read specific line range from a file. Useful for large files. Returns lines with line numbers.',
331
370
  inputSchema: z.object({
332
371
  filePath: z.string().max(500).describe('Relative path to the file'),
333
372
  startLine: z.number().min(1).describe('First line to read (1-based)'),
334
- endLine: z.number().min(1).optional().describe('Last line to read (default: startLine + 100)'),
373
+ endLine: z.number().min(1).optional().describe(`Last line to read (default: startLine + 100, at most ${MAX_LINE_SPAN} lines per call)`),
335
374
  }),
336
375
  execute: async ({ filePath, startLine, endLine }) => {
337
376
  try {
338
377
  const safePath = sandboxPath(filePath);
339
- const content = await fs.readFile(safePath, 'utf-8');
340
- const allLines = content.split('\n');
341
378
  const start = Math.max(1, startLine);
342
379
  if (endLine !== undefined && endLine < start) {
343
380
  return { error: `endLine (${endLine}) must be >= startLine (${start})` };
344
381
  }
382
+ const requestedEnd = endLine ?? (start + 100);
383
+ const cappedEnd = Math.min(requestedEnd, start + MAX_LINE_SPAN - 1);
384
+ const spanCapped = cappedEnd < requestedEnd;
385
+ const stat = await fs.stat(safePath);
386
+ if (stat.isDirectory())
387
+ return { error: `${filePath} is a directory` };
388
+ if (stat.size > _limits.readFileMaxSize) {
389
+ const { lines, reachedEof, scanned } = await streamLineRange(safePath, start, cappedEnd);
390
+ if (lines.length === 0) {
391
+ return { error: `startLine (${start}) exceeds file length (${scanned} lines)`, totalLines: scanned };
392
+ }
393
+ const last = lines[lines.length - 1].lineNumber;
394
+ return {
395
+ filePath,
396
+ startLine: start,
397
+ endLine: last,
398
+ ...(reachedEof ? { totalLines: scanned } : { hint: `Showing lines ${start}-${last}; file exceeds ${_limits.readFileMaxSize} bytes so the total line count was not counted` }),
399
+ ...(spanCapped ? { truncated: `Capped at ${MAX_LINE_SPAN} lines per call; continue from line ${last + 1}` } : {}),
400
+ lines,
401
+ };
402
+ }
403
+ const content = await fs.readFile(safePath, 'utf-8');
404
+ const allLines = content.split('\n');
345
405
  if (start > allLines.length) {
346
406
  return { error: `startLine (${start}) exceeds file length (${allLines.length} lines)`, totalLines: allLines.length };
347
407
  }
348
- const end = Math.min(endLine ?? (startLine + 100), allLines.length);
408
+ const end = Math.min(cappedEnd, allLines.length);
349
409
  const selectedLines = allLines.slice(start - 1, end).map((line, i) => ({
350
410
  lineNumber: start + i,
351
- text: line,
411
+ text: line.slice(0, MAX_LINE_CHARS),
352
412
  }));
353
413
  return {
354
414
  filePath,
@@ -356,6 +416,7 @@ export const readFileLinesTool = tool({
356
416
  startLine: start,
357
417
  endLine: end,
358
418
  ...(end < allLines.length ? { hint: `Showing lines ${start}-${end} of ${allLines.length}` } : {}),
419
+ ...(spanCapped ? { truncated: `Capped at ${MAX_LINE_SPAN} lines per call; continue from line ${end + 1}` } : {}),
359
420
  lines: selectedLines,
360
421
  };
361
422
  }
package/dist/tools.js CHANGED
@@ -8,8 +8,9 @@ import path from 'node:path';
8
8
  import { safeEvaluate } from './safe-math.js';
9
9
  import { sandboxPath, isBlockedCommand, detectInjection, sanitizeEnvForCommand, getShellInfo, isWithinRoot, getSandboxRoot } from './sandbox.js';
10
10
  import { DEFAULT_LIMITS } from './settings.js';
11
- import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE, isPrivateHost } from './constants.js';
11
+ import { TOOL_DEFAULTS, BINARY_DETECT_BUFFER_SIZE } from './constants.js';
12
12
  import { computeDiff, formatDiff } from './diff.js';
13
+ import { normalizeMcpConfig, validateMcpUrl } from './mcp.js';
13
14
  let _limits = DEFAULT_LIMITS;
14
15
  export function setToolLimits(limits) { _limits = limits; }
15
16
  const execAsync = promisify(exec);
@@ -359,24 +360,21 @@ export const addMcpServerTool = tool({
359
360
  }),
360
361
  execute: async ({ label, url }) => {
361
362
  try {
362
- let parsedUrl;
363
- try {
364
- parsedUrl = new URL(url);
365
- }
366
- catch {
367
- return { success: false, error: `Invalid MCP server URL: "${url}" is not a valid URL` };
368
- }
369
- if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
370
- return { success: false, error: `Invalid MCP server URL: only http/https schemes are allowed (got ${parsedUrl.protocol})` };
371
- }
372
- if (isPrivateHost(parsedUrl.hostname)) {
373
- return { success: false, error: 'Blocked: MCP server URL points at localhost or a private network address' };
363
+ // Shared with the config-file and /mcp paths so all three refuse the
364
+ // same URLs — this check used to live only here, which is how the
365
+ // config file ended up being the way around it.
366
+ const blocked = validateMcpUrl(url);
367
+ if (blocked) {
368
+ return { success: false, error: `Invalid MCP server URL: ${blocked}` };
374
369
  }
375
370
  const mcpConfigPath = path.resolve(process.cwd(), 'mcp-services.json');
376
371
  let config = { mcpServers: [] };
377
372
  try {
378
373
  if (fsSync.existsSync(mcpConfigPath)) {
379
- const parsed = JSON.parse(fsSync.readFileSync(mcpConfigPath, 'utf-8'));
374
+ // Through normalizeMcpConfig, because the IDE panel writes the
375
+ // object-map dialect. Blindly resetting a non-array mcpServers to []
376
+ // here would silently delete every server the user configured there.
377
+ const parsed = normalizeMcpConfig(JSON.parse(fsSync.readFileSync(mcpConfigPath, 'utf-8')));
380
378
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
381
379
  config = parsed;
382
380
  if (!Array.isArray(config.mcpServers))
@@ -385,6 +383,9 @@ export const addMcpServerTool = tool({
385
383
  }
386
384
  }
387
385
  catch { }
386
+ if (config.mcpServers.some((s) => s?.serverLabel === label)) {
387
+ return { success: false, error: `An MCP server labelled "${label}" is already configured` };
388
+ }
388
389
  config.mcpServers.push({ serverLabel: label, serverUrl: url });
389
390
  await fs.writeFile(mcpConfigPath, JSON.stringify(config, null, 2), 'utf-8');
390
391
  return { success: true, message: `Added MCP server ${label} at ${url}` };
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Scalix Coder loads and executes code/config from the current project
5
5
  * directory — plugin handler modules (dynamic import), hooks (shell commands),
6
+ * MCP servers (a spawned process, or a URL we hand an environment secret to),
6
7
  * scoped rules (injected into the model context) and local settings overrides
7
8
  * (which can change permission mode / security). Opening the tool in an
8
9
  * untrusted / freshly-cloned repository must NOT silently run any of that.
@@ -43,7 +44,12 @@ function normalize(cwd) {
43
44
  export function hasProjectExecSurface(cwd = process.cwd()) {
44
45
  const candidates = ['mcp-services.json'];
45
46
  for (const dir of PROJECT_LOOKUP_DIRS) {
46
- candidates.push(path.join(dir, 'plugins'), path.join(dir, 'hooks.json'), path.join(dir, 'rules'), path.join(dir, 'settings.local.json'));
47
+ candidates.push(path.join(dir, 'plugins'), path.join(dir, 'hooks.json'),
48
+ // resolveMcpConfigPath() falls back to .scalix/mcp-services.json, so a
49
+ // repo can carry MCP config without a root-level file. Listing only the
50
+ // root filename meant an MCP-only clone looked inert and the user was
51
+ // never offered the trust prompt for the one thing it did have.
52
+ path.join(dir, 'mcp-services.json'), path.join(dir, 'rules'), path.join(dir, 'settings.local.json'));
47
53
  }
48
54
  return candidates.some(rel => {
49
55
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalix",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "AI coding agent for the terminal — edit code, run commands, and build with Scalix",
5
5
  "main": "dist/headless.js",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "start:headless": "tsx src/headless.ts",
18
18
  "dev": "tsx watch src/cli.tsx",
19
19
  "build": "tsc",
20
+ "bundle:ide": "node scripts/bundle-ide.mjs",
20
21
  "typecheck": "tsc --noEmit",
21
22
  "lint": "eslint src/ tests/",
22
23
  "test": "vitest run",
@@ -51,6 +52,7 @@
51
52
  },
52
53
  "type": "module",
53
54
  "dependencies": {
55
+ "@modelcontextprotocol/sdk": "^1.30.0",
54
56
  "chalk": "^5.6.2",
55
57
  "cli-highlight": "^2.1.11",
56
58
  "eventemitter3": "^5.0.4",