@projitive/mcp 1.1.2 → 1.2.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/README.md CHANGED
@@ -84,7 +84,7 @@ MCP client config example (`mcp.json`):
84
84
  "command": "npx",
85
85
  "args": ["-y", "@projitive/mcp"],
86
86
  "env": {
87
- "PROJITIVE_SCAN_ROOT_PATH": "/absolute/path/to/your/workspace",
87
+ "PROJITIVE_SCAN_ROOT_PATHS": "/workspace/a:/workspace/b",
88
88
  "PROJITIVE_SCAN_MAX_DEPTH": "3"
89
89
  }
90
90
  }
@@ -94,7 +94,9 @@ MCP client config example (`mcp.json`):
94
94
 
95
95
  Environment variables (required):
96
96
 
97
- - `PROJITIVE_SCAN_ROOT_PATH`: required scan root for discovery methods.
97
+ - `PROJITIVE_SCAN_ROOT_PATHS`: required scan roots for discovery methods.
98
+ - Use platform-delimiter string (`:` on Linux/macOS, `;` on Windows), e.g. `/workspace/a:/workspace/b`.
99
+ - Fallback: if not set, legacy `PROJITIVE_SCAN_ROOT_PATH` is used.
98
100
  - `PROJITIVE_SCAN_MAX_DEPTH`: required scan depth for discovery methods (integer `0-8`).
99
101
 
100
102
  Local path startup is not the recommended usage mode in this README.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projitive/mcp",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Projitive MCP Server for project and task discovery/update",
5
5
  "license": "ISC",
6
6
  "author": "",
@@ -46,9 +46,43 @@ function requireEnvVar(name) {
46
46
  }
47
47
  return value.trim();
48
48
  }
49
+ function normalizeScanRoots(rootPaths) {
50
+ const normalized = rootPaths
51
+ .map((entry) => entry.trim())
52
+ .filter((entry) => entry.length > 0)
53
+ .map((entry) => normalizePath(entry));
54
+ return Array.from(new Set(normalized));
55
+ }
56
+ function parseScanRoots(rawValue) {
57
+ const trimmed = rawValue.trim();
58
+ if (trimmed.length === 0) {
59
+ return [];
60
+ }
61
+ return trimmed.split(path.delimiter);
62
+ }
63
+ export function resolveScanRoots(inputPaths) {
64
+ const normalizedInputPaths = normalizeScanRoots(inputPaths ?? []);
65
+ if (normalizedInputPaths.length > 0) {
66
+ return normalizedInputPaths;
67
+ }
68
+ const configuredRoots = process.env.PROJITIVE_SCAN_ROOT_PATHS;
69
+ const rootsFromMultiEnv = typeof configuredRoots === "string"
70
+ ? normalizeScanRoots(parseScanRoots(configuredRoots))
71
+ : [];
72
+ if (rootsFromMultiEnv.length > 0) {
73
+ return rootsFromMultiEnv;
74
+ }
75
+ const legacyRoot = process.env.PROJITIVE_SCAN_ROOT_PATH;
76
+ const rootsFromLegacyEnv = typeof legacyRoot === "string"
77
+ ? normalizeScanRoots([legacyRoot])
78
+ : [];
79
+ if (rootsFromLegacyEnv.length > 0) {
80
+ return rootsFromLegacyEnv;
81
+ }
82
+ throw new Error("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATHS (or legacy PROJITIVE_SCAN_ROOT_PATH)");
83
+ }
49
84
  export function resolveScanRoot(inputPath) {
50
- const configuredRoot = requireEnvVar("PROJITIVE_SCAN_ROOT_PATH");
51
- return normalizePath(inputPath ?? configuredRoot);
85
+ return resolveScanRoots(inputPath ? [inputPath] : undefined)[0];
52
86
  }
53
87
  export function resolveScanDepth(inputDepth) {
54
88
  const configuredDepthRaw = requireEnvVar("PROJITIVE_SCAN_MAX_DEPTH");
@@ -210,6 +244,10 @@ export async function discoverProjects(rootPath, maxDepth) {
210
244
  await walk(rootPath, 0);
211
245
  return Array.from(new Set(results)).sort();
212
246
  }
247
+ export async function discoverProjectsAcrossRoots(rootPaths, maxDepth) {
248
+ const perRootResults = await Promise.all(rootPaths.map((rootPath) => discoverProjects(rootPath, maxDepth)));
249
+ return Array.from(new Set(perRootResults.flat())).sort();
250
+ }
213
251
  async function pathExists(targetPath) {
214
252
  const accessResult = await catchIt(fs.access(targetPath));
215
253
  return !accessResult.isError();
@@ -362,14 +400,15 @@ export function registerProjectTools(server) {
362
400
  description: "Start here when project path is unknown; discover all governance roots",
363
401
  inputSchema: {},
364
402
  }, async () => {
365
- const root = resolveScanRoot();
403
+ const roots = resolveScanRoots();
366
404
  const depth = resolveScanDepth();
367
- const projects = await discoverProjects(root, depth);
405
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
368
406
  const markdown = renderToolResponseMarkdown({
369
407
  toolName: "projectScan",
370
408
  sections: [
371
409
  summarySection([
372
- `- rootPath: ${root}`,
410
+ `- rootPaths: ${roots.join(", ")}`,
411
+ `- rootCount: ${roots.length}`,
373
412
  `- maxDepth: ${depth}`,
374
413
  `- discoveredCount: ${projects.length}`,
375
414
  ]),
@@ -398,9 +437,9 @@ export function registerProjectTools(server) {
398
437
  limit: z.number().int().min(1).max(50).optional(),
399
438
  },
400
439
  }, async ({ limit }) => {
401
- const root = resolveScanRoot();
440
+ const roots = resolveScanRoots();
402
441
  const depth = resolveScanDepth();
403
- const projects = await discoverProjects(root, depth);
442
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
404
443
  const snapshots = await Promise.all(projects.map(async (governanceDir) => {
405
444
  const snapshot = await readTasksSnapshot(governanceDir);
406
445
  const inProgress = snapshot.tasks.filter((task) => task.status === "IN_PROGRESS").length;
@@ -435,7 +474,8 @@ export function registerProjectTools(server) {
435
474
  toolName: "projectNext",
436
475
  sections: [
437
476
  summarySection([
438
- `- rootPath: ${root}`,
477
+ `- rootPaths: ${roots.join(", ")}`,
478
+ `- rootCount: ${roots.length}`,
439
479
  `- maxDepth: ${depth}`,
440
480
  `- matchedProjects: ${projects.length}`,
441
481
  `- actionableProjects: ${ranked.length}`,
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { discoverProjects, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoot, resolveScanDepth, toProjectPath, registerProjectTools } from "./project.js";
5
+ import { discoverProjects, discoverProjectsAcrossRoots, hasProjectMarker, initializeProjectStructure, resolveGovernanceDir, resolveScanRoots, resolveScanDepth, toProjectPath, registerProjectTools } from "./project.js";
6
6
  const tempPaths = [];
7
7
  async function createTempDir() {
8
8
  const dir = await fs.mkdtemp(path.join(os.tmpdir(), "projitive-mcp-test-"));
@@ -186,6 +186,15 @@ describe("projitive module", () => {
186
186
  const projects = await discoverProjects(root, 3);
187
187
  expect(projects).toEqual([]);
188
188
  });
189
+ it("ignores non-existent roots when scanning across multiple roots", async () => {
190
+ const validRoot = await createTempDir();
191
+ const validProject = path.join(validRoot, "project-a");
192
+ const missingRoot = path.join(validRoot, "__missing_root__");
193
+ await fs.mkdir(validProject, { recursive: true });
194
+ await fs.writeFile(path.join(validProject, ".projitive"), "", "utf-8");
195
+ const projects = await discoverProjectsAcrossRoots([missingRoot, validRoot], 3);
196
+ expect(projects).toContain(validProject);
197
+ });
189
198
  });
190
199
  describe("initializeProjectStructure", () => {
191
200
  it("initializes governance structure under default .projitive directory", async () => {
@@ -267,20 +276,30 @@ describe("projitive module", () => {
267
276
  expect(toProjectPath("/a/b/c")).toBe("/a/b");
268
277
  });
269
278
  });
270
- describe("resolveScanRoot", () => {
271
- it("uses environment variable when no input path", () => {
279
+ describe("resolveScanRoots", () => {
280
+ it("uses legacy environment variable when no multi-root env is provided", () => {
272
281
  vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
273
- expect(resolveScanRoot()).toBe("/test/root");
282
+ expect(resolveScanRoots()).toEqual(["/test/root"]);
274
283
  vi.unstubAllEnvs();
275
284
  });
276
- it("uses input path when provided", () => {
285
+ it("uses input paths when provided", () => {
277
286
  vi.stubEnv("PROJITIVE_SCAN_ROOT_PATH", "/test/root");
278
- expect(resolveScanRoot("/custom/path")).toBe("/custom/path");
287
+ expect(resolveScanRoots(["/custom/path", " /custom/path ", "/second/path"])).toEqual(["/custom/path", "/second/path"]);
288
+ vi.unstubAllEnvs();
289
+ });
290
+ it("uses PROJITIVE_SCAN_ROOT_PATHS with platform delimiter", () => {
291
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATHS", ["/root/a", "/root/b", "", " /root/c "].join(path.delimiter));
292
+ expect(resolveScanRoots()).toEqual(["/root/a", "/root/b", "/root/c"]);
293
+ vi.unstubAllEnvs();
294
+ });
295
+ it("treats JSON-like string as plain delimiter input", () => {
296
+ vi.stubEnv("PROJITIVE_SCAN_ROOT_PATHS", JSON.stringify(["/json/a", "/json/b"]));
297
+ expect(resolveScanRoots()).toHaveLength(1);
279
298
  vi.unstubAllEnvs();
280
299
  });
281
- it("throws error when required environment variable missing", () => {
300
+ it("throws error when no root environment variables are configured", () => {
282
301
  vi.unstubAllEnvs();
283
- expect(() => resolveScanRoot()).toThrow("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATH");
302
+ expect(() => resolveScanRoots()).toThrow("Missing required environment variable: PROJITIVE_SCAN_ROOT_PATHS");
284
303
  });
285
304
  });
286
305
  describe("resolveScanDepth", () => {
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { candidateFilesFromArtifacts, discoverGovernanceArtifacts, findTextReferences } from "../common/index.js";
5
5
  import { asText, evidenceSection, guidanceSection, lintSection, nextCallSection, renderErrorMarkdown, renderToolResponseMarkdown, summarySection, } from "../common/index.js";
6
6
  import { catchIt, TASK_LINT_CODES, renderLintSuggestions } from "../common/index.js";
7
- import { resolveGovernanceDir, resolveScanDepth, resolveScanRoot, discoverProjects, toProjectPath } from "./project.js";
7
+ import { resolveGovernanceDir, resolveScanDepth, resolveScanRoots, discoverProjectsAcrossRoots, toProjectPath } from "./project.js";
8
8
  import { isValidRoadmapId } from "./roadmap.js";
9
9
  import { SUB_STATE_PHASES, BLOCKER_TYPES } from "../types.js";
10
10
  export const TASKS_START = "<!-- PROJITIVE:TASKS:START -->";
@@ -820,9 +820,9 @@ export function registerTaskTools(server) {
820
820
  limit: z.number().int().min(1).max(20).optional(),
821
821
  },
822
822
  }, async ({ limit }) => {
823
- const root = resolveScanRoot();
823
+ const roots = resolveScanRoots();
824
824
  const depth = resolveScanDepth();
825
- const projects = await discoverProjects(root, depth);
825
+ const projects = await discoverProjectsAcrossRoots(roots, depth);
826
826
  const rankedCandidates = rankActionableTaskCandidates(await readActionableTaskCandidates(projects));
827
827
  if (rankedCandidates.length === 0) {
828
828
  const projectSnapshots = await Promise.all(projects.map(async (governanceDir) => {
@@ -850,7 +850,8 @@ export function registerTaskTools(server) {
850
850
  toolName: "taskNext",
851
851
  sections: [
852
852
  summarySection([
853
- `- rootPath: ${root}`,
853
+ `- rootPaths: ${roots.join(", ")}`,
854
+ `- rootCount: ${roots.length}`,
854
855
  `- maxDepth: ${depth}`,
855
856
  `- matchedProjects: ${projects.length}`,
856
857
  "- actionableTasks: 0",
@@ -900,7 +901,8 @@ export function registerTaskTools(server) {
900
901
  toolName: "taskNext",
901
902
  sections: [
902
903
  summarySection([
903
- `- rootPath: ${root}`,
904
+ `- rootPaths: ${roots.join(", ")}`,
905
+ `- rootCount: ${roots.length}`,
904
906
  `- maxDepth: ${depth}`,
905
907
  `- matchedProjects: ${projects.length}`,
906
908
  `- actionableTasks: ${rankedCandidates.length}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@projitive/mcp",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Projitive MCP Server for project and task discovery/update",
5
5
  "license": "ISC",
6
6
  "author": "",