@nowline/mcp 0.9.0 → 0.10.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/src/index.ts CHANGED
@@ -80,7 +80,8 @@ if (help) {
80
80
  'Start the Nowline MCP server.',
81
81
  '',
82
82
  'Options:',
83
- ' --root <path> Allowed root for file operations. Defaults to cwd.',
83
+ ' --root <path> Allowed root for file operations. Defaults to the',
84
+ " client's MCP roots, else cwd.",
84
85
  ' --port <N> Listen on localhost:<N> using Streamable HTTP transport.',
85
86
  ' When omitted, uses stdio transport (default).',
86
87
  ' --version, -v Print version and exit.',
package/src/server.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  UpdateOutputSchema,
80
80
  ValidateOutputSchema,
81
81
  } from './schemas.js';
82
+ import { createWorkspaceRoots, isInsideRoots, type WorkspaceRoots } from './workspace-roots.js';
82
83
 
83
84
  // ---- MCP Apps UI (in-chat live preview) -------------------------------------
84
85
  //
@@ -174,11 +175,10 @@ function mutatingTool(title: string, opts: { destructiveHint: boolean; idempoten
174
175
 
175
176
  // ---- Server factory ---------------------------------------------------------
176
177
 
177
- function resolveAndGuard(filePath: string, allowedRoot: string): string {
178
- const abs = path.resolve(allowedRoot, filePath);
179
- const guard = path.resolve(allowedRoot);
180
- if (!abs.startsWith(guard + path.sep) && abs !== guard) {
181
- throw new PathOutsideRootError(filePath, allowedRoot);
178
+ function resolveAndGuard(filePath: string, roots: WorkspaceRoots): string {
179
+ const abs = path.resolve(roots.primary, filePath);
180
+ if (!isInsideRoots(abs, roots)) {
181
+ throw new PathOutsideRootError(filePath, roots.allowed.join(', '));
182
182
  }
183
183
  return abs;
184
184
  }
@@ -224,13 +224,13 @@ function findExample(name: string): ExampleFile | undefined {
224
224
 
225
225
  async function sourceAndPath(
226
226
  args: { source?: string; path?: string },
227
- allowedRoot: string,
227
+ roots: WorkspaceRoots,
228
228
  ): Promise<{ source: string; filePath: string }> {
229
229
  if (args.source !== undefined && args.path === undefined) {
230
- return { source: args.source, filePath: path.join(allowedRoot, 'unnamed.nowline') };
230
+ return { source: args.source, filePath: path.join(roots.primary, 'unnamed.nowline') };
231
231
  }
232
232
  if (args.path !== undefined) {
233
- const abs = resolveAndGuard(args.path, allowedRoot);
233
+ const abs = resolveAndGuard(args.path, roots);
234
234
  const source = args.source ?? (await fs.readFile(abs, 'utf-8'));
235
235
  return { source, filePath: abs };
236
236
  }
@@ -240,7 +240,10 @@ async function sourceAndPath(
240
240
  // ---- Server factory ---------------------------------------------------------
241
241
 
242
242
  export interface McpServerOptions {
243
- /** Working directory — all file paths are resolved relative to this root. Defaults to process.cwd(). */
243
+ /**
244
+ * Working directory: all file paths resolve relative to this root. When omitted, the
245
+ * server uses the client's MCP roots if it advertises them, else process.cwd().
246
+ */
244
247
  allowedRoot?: string;
245
248
  /** Whether the allowed root was explicitly configured (vs. defaulting to cwd). Controls smart export delivery for binary formats. Defaults to false. */
246
249
  rootConfigured?: boolean;
@@ -251,7 +254,6 @@ export interface McpServerOptions {
251
254
  }
252
255
 
253
256
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
254
- const allowedRoot = opts.allowedRoot ?? process.cwd();
255
257
  const rootConfigured = opts.rootConfigured ?? false;
256
258
  const server = new McpServer(
257
259
  {
@@ -279,6 +281,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
279
281
  },
280
282
  );
281
283
 
284
+ const workspaceRoots = createWorkspaceRoots(server, opts.allowedRoot);
285
+
282
286
  // ---- Resources ----------------------------------------------------------
283
287
 
284
288
  server.registerResource(
@@ -418,7 +422,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
418
422
  async (args) => {
419
423
  try {
420
424
  const { options } = resolveMcpOptions(args);
421
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
425
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
422
426
  const doc = await buildDocument(source);
423
427
  const diagnostics = collectMcpDiagnostics(doc, filePath);
424
428
  // One stage: the routed (wave-rule) include diagnostics join
@@ -478,7 +482,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
478
482
  },
479
483
  async (args) => {
480
484
  try {
481
- const abs = resolveAndGuard(args.path, allowedRoot);
485
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
482
486
  const source = await fs.readFile(abs, 'utf-8');
483
487
  const structured = { path: abs, source };
484
488
  return {
@@ -512,7 +516,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
512
516
  },
513
517
  async (args) => {
514
518
  try {
515
- const abs = resolveAndGuard(args.path, allowedRoot);
519
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
516
520
  const blocked = await diagnosticsErrorBlock(args.source, abs);
517
521
  if (!blocked.ok) return blocked.response;
518
522
  await fs.mkdir(path.dirname(abs), { recursive: true });
@@ -550,7 +554,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
550
554
  },
551
555
  async (args) => {
552
556
  try {
553
- const abs = resolveAndGuard(args.path, allowedRoot);
557
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
554
558
  const blocked = await diagnosticsErrorBlock(args.source, abs);
555
559
  if (!blocked.ok) return blocked.response;
556
560
  await fs.writeFile(abs, args.source, 'utf-8');
@@ -581,7 +585,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
581
585
  },
582
586
  async (args) => {
583
587
  try {
584
- const abs = resolveAndGuard(args.path, allowedRoot);
588
+ const abs = resolveAndGuard(args.path, await workspaceRoots());
585
589
  await fs.unlink(abs);
586
590
  const structured = { path: abs };
587
591
  return {
@@ -605,7 +609,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
605
609
  .string()
606
610
  .optional()
607
611
  .describe(
608
- 'Directory to scan. Defaults to the allowed root (cwd or --root). Absolute or relative.',
612
+ "Directory to scan. Defaults to the project root (--root, else the client's first MCP root, else cwd). Absolute or relative.",
609
613
  ),
610
614
  recursive: z
611
615
  .boolean()
@@ -617,9 +621,8 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
617
621
  },
618
622
  async (args) => {
619
623
  try {
620
- const dir = args.directory
621
- ? resolveAndGuard(args.directory, allowedRoot)
622
- : allowedRoot;
624
+ const roots = await workspaceRoots();
625
+ const dir = args.directory ? resolveAndGuard(args.directory, roots) : roots.primary;
623
626
  const recursive = args.recursive ?? false;
624
627
  const paths = await listNowlineFiles(dir, recursive);
625
628
  const structured = { paths };
@@ -715,7 +718,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
715
718
  let source: string;
716
719
  let filePath: string;
717
720
  try {
718
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
721
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
719
722
  } catch (err) {
720
723
  return handleToolError(err, args.path);
721
724
  }
@@ -795,7 +798,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
795
798
 
796
799
  if (args.output) {
797
800
  try {
798
- const outAbs = resolveAndGuard(args.output, allowedRoot);
801
+ const outAbs = resolveAndGuard(args.output, await workspaceRoots());
799
802
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
800
803
  await fs.writeFile(outAbs, bytes);
801
804
  const structured = {
@@ -949,7 +952,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
949
952
  let source: string;
950
953
  let filePath: string;
951
954
  try {
952
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
955
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
953
956
  } catch (err) {
954
957
  return handleToolError(err, args.path);
955
958
  }
@@ -1043,11 +1046,12 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1043
1046
  }
1044
1047
 
1045
1048
  // Write to disk if needed.
1046
- const outTarget = args.output ?? path.join(allowedRoot, defaultFilename);
1049
+ const roots = await workspaceRoots();
1050
+ const outTarget = args.output ?? path.join(roots.primary, defaultFilename);
1047
1051
  let writtenPath: string | undefined;
1048
1052
  if (doWrite) {
1049
1053
  try {
1050
- const outAbs = resolveAndGuard(outTarget, allowedRoot);
1054
+ const outAbs = resolveAndGuard(outTarget, roots);
1051
1055
  await fs.mkdir(path.dirname(outAbs), { recursive: true });
1052
1056
  await fs.writeFile(outAbs, bytes);
1053
1057
  writtenPath = outAbs;
@@ -1126,7 +1130,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1126
1130
  let source: string;
1127
1131
  let filePath: string;
1128
1132
  try {
1129
- ({ source, filePath } = await sourceAndPath(args, allowedRoot));
1133
+ ({ source, filePath } = await sourceAndPath(args, await workspaceRoots()));
1130
1134
  } catch (err) {
1131
1135
  return handleToolError(err, args.path);
1132
1136
  }
@@ -1175,7 +1179,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1175
1179
  async (args) => {
1176
1180
  try {
1177
1181
  if (args.to === 'json') {
1178
- const { source, filePath } = await sourceAndPath(args, allowedRoot);
1182
+ const { source, filePath } = await sourceAndPath(args, await workspaceRoots());
1179
1183
  const host = createNodeHostEnv(filePath);
1180
1184
  // The JSON AST does not depend on today's date, so convert
1181
1185
  // takes no `now`; the kernel still wants a concrete one.
@@ -1203,7 +1207,10 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
1203
1207
  const jsonSource =
1204
1208
  args.source ??
1205
1209
  (args.path
1206
- ? await fs.readFile(resolveAndGuard(args.path, allowedRoot), 'utf-8')
1210
+ ? await fs.readFile(
1211
+ resolveAndGuard(args.path, await workspaceRoots()),
1212
+ 'utf-8',
1213
+ )
1207
1214
  : null);
1208
1215
  if (!jsonSource) {
1209
1216
  throw new InputRequiredError('At least one of `source` or `path` is required.');
@@ -0,0 +1,99 @@
1
+ // Where the server's file tools resolve paths.
2
+ //
3
+ // An explicit root (`--root`, or `allowedRoot` from an embedder) always wins
4
+ // and the client is never asked. Without one, the server asks a client that
5
+ // advertises the MCP `roots` capability for its roots, because a harness may
6
+ // start the server somewhere other than the project (a plugin cache, the app
7
+ // bundle). A client without roots, an empty or non-`file://` list, or a failed
8
+ // `roots/list` falls back to `process.cwd()`.
9
+ //
10
+ // Multiple roots: every root is allowed, and relative paths resolve against
11
+ // the first. The answer is cached until the client sends
12
+ // `notifications/roots/list_changed`.
13
+ //
14
+ // Spec: specs/mcp.md "Shared tool contract".
15
+
16
+ import * as path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import type { McpServer } from '@modelcontextprotocol/server';
19
+
20
+ export interface WorkspaceRoots {
21
+ /** Relative paths resolve against this root; `list` and default export output use it. */
22
+ readonly primary: string;
23
+ /** Every root a path may land in. Always contains `primary`. */
24
+ readonly allowed: readonly string[];
25
+ }
26
+
27
+ /** Bounds a `roots/list` round trip so a client that never answers can't hang a tool call. */
28
+ const LIST_ROOTS_TIMEOUT_MS = 5_000;
29
+
30
+ export function staticRoots(root: string): WorkspaceRoots {
31
+ const abs = path.resolve(root);
32
+ return { primary: abs, allowed: [abs] };
33
+ }
34
+
35
+ /**
36
+ * Converts a `roots/list` result to filesystem roots. Non-`file://` and
37
+ * malformed URIs are skipped; duplicates collapse. Returns `undefined` when
38
+ * nothing usable remains.
39
+ */
40
+ export function rootsFromUris(uris: readonly string[]): WorkspaceRoots | undefined {
41
+ const paths: string[] = [];
42
+ for (const uri of uris) {
43
+ let abs: string;
44
+ try {
45
+ if (!uri.startsWith('file:')) continue;
46
+ abs = path.resolve(fileURLToPath(uri));
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (!paths.includes(abs)) paths.push(abs);
51
+ }
52
+ return paths.length > 0 ? { primary: paths[0], allowed: paths } : undefined;
53
+ }
54
+
55
+ /**
56
+ * Returns a function yielding the current roots for `server`. With
57
+ * `configuredRoot` set the result is fixed; otherwise it follows the client's
58
+ * roots as described above. Never rejects.
59
+ */
60
+ export function createWorkspaceRoots(
61
+ server: McpServer,
62
+ configuredRoot: string | undefined,
63
+ ): () => Promise<WorkspaceRoots> {
64
+ if (configuredRoot !== undefined) {
65
+ const fixed = staticRoots(configuredRoot);
66
+ return async () => fixed;
67
+ }
68
+
69
+ let cached: Promise<WorkspaceRoots> | undefined;
70
+ server.server.setNotificationHandler('notifications/roots/list_changed', () => {
71
+ cached = undefined;
72
+ });
73
+
74
+ async function fetchRoots(): Promise<WorkspaceRoots> {
75
+ const fallback = staticRoots(process.cwd());
76
+ if (!server.server.getClientCapabilities()?.roots) return fallback;
77
+ try {
78
+ const { roots } = await server.server.listRoots(undefined, {
79
+ timeout: LIST_ROOTS_TIMEOUT_MS,
80
+ });
81
+ return rootsFromUris(roots.map((r) => r.uri)) ?? fallback;
82
+ } catch {
83
+ return fallback;
84
+ }
85
+ }
86
+
87
+ return () => {
88
+ cached ??= fetchRoots();
89
+ return cached;
90
+ };
91
+ }
92
+
93
+ /** Whether the absolute path `abs` is one of the allowed roots or inside one. */
94
+ export function isInsideRoots(abs: string, roots: WorkspaceRoots): boolean {
95
+ return roots.allowed.some((root) => {
96
+ const prefix = root.endsWith(path.sep) ? root : root + path.sep;
97
+ return abs === root || abs.startsWith(prefix);
98
+ });
99
+ }