@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/dist/generated/ui-bundle.d.ts.map +1 -1
- package/dist/generated/ui-bundle.js +2 -2
- package/dist/generated/ui-bundle.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +4 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +26 -26
- package/dist/server.js.map +1 -1
- package/dist/workspace-roots.d.ts +23 -0
- package/dist/workspace-roots.d.ts.map +1 -0
- package/dist/workspace-roots.js +85 -0
- package/dist/workspace-roots.js.map +1 -0
- package/package.json +10 -10
- package/src/generated/ui-bundle.ts +2 -2
- package/src/index.ts +2 -1
- package/src/server.ts +34 -27
- package/src/workspace-roots.ts +99 -0
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
|
|
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,
|
|
178
|
-
const abs = path.resolve(
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
/**
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
621
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
+
}
|