@unified-product-graph/mcp-server 0.9.0 → 0.9.1

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/index.js CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { parseArgs } from "util";
5
- import * as fs3 from "fs/promises";
6
- import * as path6 from "path";
7
- import { UPGFileStore } from "@unified-product-graph/sdk";
5
+ import * as fs4 from "fs/promises";
6
+ import * as path7 from "path";
7
+ import { UPGFileStore as UPGFileStore2 } from "@unified-product-graph/sdk";
8
8
 
9
9
  // src/server.ts
10
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import fs2 from "fs";
12
+ import fs3 from "fs";
13
13
  import { fileURLToPath as fileURLToPath2 } from "url";
14
- import * as path5 from "path";
14
+ import * as path6 from "path";
15
15
  import {
16
16
  CallToolRequestSchema,
17
17
  ListToolsRequestSchema
@@ -2741,7 +2741,8 @@ var UPG_CROSS_EDGE_TYPES = [
2741
2741
  "depends_on_product",
2742
2742
  "cannibalises",
2743
2743
  "succeeds",
2744
- "hosts"
2744
+ "hosts",
2745
+ "contributes_to"
2745
2746
  ];
2746
2747
  var TYPES = getTypes();
2747
2748
  var TYPES_SET = new Set(TYPES);
@@ -24646,7 +24647,7 @@ function serializePortfolioWithHeader(doc, opts) {
24646
24647
  header.integrity = { algorithm: INTEGRITY_ALGORITHM, body: computeBodyChecksum(doc) };
24647
24648
  return JSON.stringify({ $upg: header, ...body }, null, 2) + "\n";
24648
24649
  }
24649
- var UPG_VERSION = "0.9.0";
24650
+ var UPG_VERSION = "0.9.1";
24650
24651
  var MARKDOWN_FORMAT_VERSION = "0.1";
24651
24652
  var UPG_TYPES = getTypes();
24652
24653
  var UPG_TYPES_SET = new Set(UPG_TYPES);
@@ -26849,6 +26850,38 @@ import {
26849
26850
  WorkspaceAlreadyExistsError,
26850
26851
  WorkspaceNotInitialisedError
26851
26852
  } from "@unified-product-graph/sdk";
26853
+ function isExistingFile(p) {
26854
+ try {
26855
+ return fs.statSync(p).isFile();
26856
+ } catch {
26857
+ return false;
26858
+ }
26859
+ }
26860
+ function findWorkspaceUpgFiles(cwd) {
26861
+ const candidates = [];
26862
+ let topEntries;
26863
+ try {
26864
+ topEntries = fs.readdirSync(cwd, { withFileTypes: true });
26865
+ } catch {
26866
+ return candidates;
26867
+ }
26868
+ for (const entry of topEntries) {
26869
+ if (entry.isFile() && entry.name.endsWith(".upg")) {
26870
+ candidates.push(path3.join(cwd, entry.name));
26871
+ } else if (entry.isDirectory() && (entry.name === ".upg" || !entry.name.startsWith("."))) {
26872
+ try {
26873
+ const subEntries = fs.readdirSync(path3.join(cwd, entry.name), { withFileTypes: true });
26874
+ for (const sub of subEntries) {
26875
+ if (sub.isFile() && sub.name.endsWith(".upg")) {
26876
+ candidates.push(path3.join(cwd, entry.name, sub.name));
26877
+ }
26878
+ }
26879
+ } catch {
26880
+ }
26881
+ }
26882
+ }
26883
+ return candidates;
26884
+ }
26852
26885
  var listLocalProducts = (_args, _ctx) => {
26853
26886
  const cwd = process.cwd();
26854
26887
  const products = [];
@@ -26871,26 +26904,7 @@ var listLocalProducts = (_args, _ctx) => {
26871
26904
  }
26872
26905
  } catch {
26873
26906
  }
26874
- const candidates = [];
26875
- const topEntries = fs.readdirSync(cwd, { withFileTypes: true });
26876
- for (const entry of topEntries) {
26877
- if (entry.isFile() && entry.name.endsWith(".upg")) {
26878
- candidates.push(path3.join(cwd, entry.name));
26879
- } else if (entry.isDirectory() && (entry.name === ".upg" || !entry.name.startsWith("."))) {
26880
- try {
26881
- const subEntries = fs.readdirSync(
26882
- path3.join(cwd, entry.name),
26883
- { withFileTypes: true }
26884
- );
26885
- for (const sub of subEntries) {
26886
- if (sub.isFile() && sub.name.endsWith(".upg")) {
26887
- candidates.push(path3.join(cwd, entry.name, sub.name));
26888
- }
26889
- }
26890
- } catch {
26891
- }
26892
- }
26893
- }
26907
+ const candidates = findWorkspaceUpgFiles(cwd);
26894
26908
  for (const filePath of candidates) {
26895
26909
  try {
26896
26910
  const raw = fs.readFileSync(filePath, "utf-8");
@@ -26920,21 +26934,19 @@ var switchProduct = async (args, ctx) => {
26920
26934
  if (typeof fileArg !== "string" || fileArg.length === 0) {
26921
26935
  return textError("Missing required parameter: file (alias: product). Pass a .upg path or a bare product name.");
26922
26936
  }
26923
- let resolved = path3.resolve(fileArg);
26924
- if (!fs.existsSync(resolved)) {
26925
- const cwd = process.cwd();
26926
- const workspaceCandidates = [
26927
- path3.join(cwd, ".upg", fileArg),
26928
- path3.join(cwd, ".upg", fileArg + ".upg")
26929
- ];
26930
- const found = workspaceCandidates.find((c) => fs.existsSync(c));
26931
- if (found) {
26932
- resolved = found;
26933
- } else {
26934
- return textError(
26935
- `File not found: ${resolved} (also checked .upg/${fileArg} and .upg/${fileArg}.upg)`
26936
- );
26937
- }
26937
+ const cwd = process.cwd();
26938
+ const direct = path3.resolve(fileArg);
26939
+ const candidates = [
26940
+ path3.join(cwd, ".upg", fileArg),
26941
+ path3.join(cwd, ".upg", `${fileArg}.upg`),
26942
+ direct,
26943
+ `${direct}.upg`
26944
+ ];
26945
+ const resolved = candidates.find(isExistingFile);
26946
+ if (!resolved) {
26947
+ return textError(
26948
+ `File not found: ${direct} (also checked .upg/${fileArg} and .upg/${fileArg}.upg). Pass a .upg path or a bare product name from list_local_products.`
26949
+ );
26938
26950
  }
26939
26951
  try {
26940
26952
  await store.flush();
@@ -27419,6 +27431,312 @@ var batchCreateCrossProductEdges = async (args, _ctx) => {
27419
27431
  );
27420
27432
  };
27421
27433
 
27434
+ // src/tools/portfolio-read.ts
27435
+ import * as path4 from "path";
27436
+ import * as fs2 from "fs";
27437
+ import { UPGFileStore, computeGraphDigest as computeGraphDigest2 } from "@unified-product-graph/sdk";
27438
+
27439
+ // src/lib/graph-traverse.ts
27440
+ function traverseGraph(reader, params) {
27441
+ const fromType = params.from;
27442
+ const fromId = params.from_id;
27443
+ if (!fromType && !fromId) {
27444
+ return { ok: false, error: 'Provide either "from" (entity type) or "from_id" (node ID)' };
27445
+ }
27446
+ const traverseEdgeTypes = params.traverse;
27447
+ const maxDepth = Math.min(Math.max(params.depth ?? 3, 1), 10);
27448
+ const maxNodes = Math.min(Math.max(params.limit ?? 200, 1), 1e3);
27449
+ const includeFields = new Set(params.include ?? ["title", "status", "type"]);
27450
+ includeFields.add("id");
27451
+ includeFields.add("type");
27452
+ let startNodes;
27453
+ if (fromId) {
27454
+ const node = reader.getNode(fromId);
27455
+ if (!node) return { ok: false, error: `Node not found: ${fromId}` };
27456
+ startNodes = [node];
27457
+ } else {
27458
+ startNodes = reader.getAllNodes().filter((n) => n.type === fromType);
27459
+ }
27460
+ if (startNodes.length === 0) {
27461
+ return { ok: true, result: { nodes: [], edges: [], total_nodes: 0, total_edges: 0, truncated: false } };
27462
+ }
27463
+ const visited = /* @__PURE__ */ new Set();
27464
+ const collectedNodes = [];
27465
+ const collectedEdges = /* @__PURE__ */ new Map();
27466
+ const queue = [];
27467
+ let truncated = false;
27468
+ let maxDepthReached = 0;
27469
+ for (const n of startNodes) {
27470
+ if (collectedNodes.length >= maxNodes) {
27471
+ truncated = true;
27472
+ break;
27473
+ }
27474
+ visited.add(n.id);
27475
+ collectedNodes.push(n);
27476
+ queue.push({ id: n.id, level: 0 });
27477
+ }
27478
+ while (queue.length > 0) {
27479
+ if (collectedNodes.length >= maxNodes) {
27480
+ truncated = true;
27481
+ break;
27482
+ }
27483
+ const { id, level } = queue.shift();
27484
+ if (level > maxDepthReached) maxDepthReached = level;
27485
+ if (level >= maxDepth) continue;
27486
+ const edges = reader.getEdgesForNode(id);
27487
+ for (const edge of edges) {
27488
+ if (edge.source !== id) continue;
27489
+ if (traverseEdgeTypes && traverseEdgeTypes.length > 0) {
27490
+ const edgeTypeForLevel = level < traverseEdgeTypes.length ? traverseEdgeTypes[level] : traverseEdgeTypes[traverseEdgeTypes.length - 1];
27491
+ if (edgeTypeForLevel.startsWith("!")) {
27492
+ if (edge.type === edgeTypeForLevel.slice(1)) continue;
27493
+ } else {
27494
+ if (edge.type !== edgeTypeForLevel) continue;
27495
+ }
27496
+ }
27497
+ collectedEdges.set(edge.id, edge);
27498
+ const neighborId = edge.target;
27499
+ if (!visited.has(neighborId)) {
27500
+ visited.add(neighborId);
27501
+ const neighbor = reader.getNode(neighborId);
27502
+ if (neighbor) {
27503
+ if (collectedNodes.length >= maxNodes) {
27504
+ truncated = true;
27505
+ break;
27506
+ }
27507
+ collectedNodes.push(neighbor);
27508
+ queue.push({ id: neighborId, level: level + 1 });
27509
+ }
27510
+ }
27511
+ }
27512
+ }
27513
+ const propInclude = params.property_include;
27514
+ const propFilter = propInclude && propInclude.length > 0 ? new Set(propInclude) : null;
27515
+ const projectedNodes = collectedNodes.map((n) => {
27516
+ const projected = { id: n.id, type: n.type };
27517
+ if (includeFields.has("title")) projected.title = n.title;
27518
+ if (includeFields.has("status")) projected.status = n.status;
27519
+ if (includeFields.has("tags")) projected.tags = n.tags;
27520
+ if (includeFields.has("description")) projected.description = n.description;
27521
+ if (includeFields.has("properties")) {
27522
+ if (propFilter && n.properties) {
27523
+ const filtered = {};
27524
+ for (const key of propFilter) {
27525
+ if (key in n.properties) filtered[key] = n.properties[key];
27526
+ }
27527
+ projected.properties = filtered;
27528
+ } else {
27529
+ projected.properties = n.properties;
27530
+ }
27531
+ }
27532
+ return projected;
27533
+ });
27534
+ const edgeInclude = params.edge_include;
27535
+ let edgeArray;
27536
+ if (edgeInclude !== void 0 && edgeInclude.length === 0) {
27537
+ edgeArray = [];
27538
+ } else {
27539
+ const edgeFields = edgeInclude ? new Set(edgeInclude) : null;
27540
+ edgeArray = [...collectedEdges.values()].map((e) => {
27541
+ if (!edgeFields) return { id: e.id, type: e.type, source: e.source, target: e.target };
27542
+ const projected = {};
27543
+ if (edgeFields.has("id")) projected.id = e.id;
27544
+ if (edgeFields.has("type")) projected.type = e.type;
27545
+ if (edgeFields.has("source")) projected.source = e.source;
27546
+ if (edgeFields.has("target")) projected.target = e.target;
27547
+ return projected;
27548
+ });
27549
+ }
27550
+ const result = {
27551
+ nodes: projectedNodes,
27552
+ edges: edgeArray,
27553
+ total_nodes: projectedNodes.length,
27554
+ total_edges: edgeArray.length,
27555
+ truncated
27556
+ };
27557
+ if (truncated) result.truncated_at_depth = maxDepthReached;
27558
+ return { ok: true, result };
27559
+ }
27560
+
27561
+ // src/tools/portfolio-read.ts
27562
+ function resolveScopedProducts(cwd, scope) {
27563
+ const all = [];
27564
+ for (const absPath of findWorkspaceUpgFiles(cwd)) {
27565
+ try {
27566
+ const doc = JSON.parse(fs2.readFileSync(absPath, "utf-8"));
27567
+ if (!doc.product) continue;
27568
+ all.push({
27569
+ id: doc.product.id ?? null,
27570
+ title: doc.product.title ?? "(untitled)",
27571
+ file: path4.relative(cwd, absPath),
27572
+ absPath
27573
+ });
27574
+ } catch {
27575
+ }
27576
+ }
27577
+ if (!scope || scope.length === 0) {
27578
+ return { products: all, unmatched: [] };
27579
+ }
27580
+ const matches = (p, want) => p.id === want || p.file === want || path4.basename(p.file) === want || path4.basename(p.file, ".upg") === want;
27581
+ const products = all.filter((p) => scope.some((want) => matches(p, want)));
27582
+ const unmatched = scope.filter((want) => !all.some((p) => matches(p, want)));
27583
+ return { products, unmatched };
27584
+ }
27585
+ async function readerFor(product, activeStore) {
27586
+ const activePath = activeStore.getFilePath();
27587
+ if (activePath && path4.resolve(activePath) === path4.resolve(product.absPath)) {
27588
+ return { reader: activeStore, store: activeStore, active: true };
27589
+ }
27590
+ const store = new UPGFileStore();
27591
+ await store.loadReadOnly(product.absPath);
27592
+ return { reader: store, store, active: false };
27593
+ }
27594
+ var portfolioQuery = async (args, ctx) => {
27595
+ const { store } = ctx;
27596
+ const from = args.from;
27597
+ const fromId = args.from_id;
27598
+ if (!from && !fromId) {
27599
+ return textError('Provide either "from" (entity type) or "from_id" (node ID)');
27600
+ }
27601
+ const scope = args.scope;
27602
+ const cwd = process.cwd();
27603
+ const { products, unmatched } = resolveScopedProducts(cwd, scope);
27604
+ if (products.length === 0) {
27605
+ return text(
27606
+ JSON.stringify(
27607
+ {
27608
+ products: [],
27609
+ products_searched: 0,
27610
+ products_with_matches: 0,
27611
+ empty_products: [],
27612
+ ...unmatched.length > 0 ? { unmatched_scope: unmatched } : {},
27613
+ note: scope && scope.length > 0 ? "No workspace products matched the requested scope." : "No products found in the workspace. Run from a directory with a .upg/ workspace."
27614
+ },
27615
+ null,
27616
+ 2
27617
+ )
27618
+ );
27619
+ }
27620
+ const perProductLimit = Math.min(Math.max(args.limit ?? 100, 1), 1e3);
27621
+ const params = {
27622
+ from,
27623
+ from_id: fromId,
27624
+ traverse: args.traverse,
27625
+ depth: args.depth,
27626
+ limit: perProductLimit,
27627
+ include: args.include,
27628
+ edge_include: args.edge_include,
27629
+ property_include: args.property_include
27630
+ };
27631
+ const matched = [];
27632
+ const emptyProducts = [];
27633
+ const errored = [];
27634
+ let totalNodes = 0;
27635
+ let totalEdges = 0;
27636
+ for (const product of products) {
27637
+ let reader;
27638
+ try {
27639
+ ;
27640
+ ({ reader } = await readerFor(product, store));
27641
+ } catch (err) {
27642
+ errored.push({ product_id: product.id, file: product.file, error: err.message });
27643
+ continue;
27644
+ }
27645
+ const outcome = traverseGraph(reader, params);
27646
+ if (!outcome.ok) {
27647
+ emptyProducts.push(product.id ?? product.file);
27648
+ continue;
27649
+ }
27650
+ const r = outcome.result;
27651
+ if (r.total_nodes === 0) {
27652
+ emptyProducts.push(product.id ?? product.file);
27653
+ continue;
27654
+ }
27655
+ totalNodes += r.total_nodes;
27656
+ totalEdges += r.total_edges;
27657
+ matched.push({
27658
+ product_id: product.id,
27659
+ file: product.file,
27660
+ title: product.title,
27661
+ total_nodes: r.total_nodes,
27662
+ total_edges: r.total_edges,
27663
+ nodes: r.nodes,
27664
+ edges: r.edges,
27665
+ ...r.truncated ? { truncated: true, truncated_at_depth: r.truncated_at_depth } : {}
27666
+ });
27667
+ }
27668
+ const guard = preflightPayload({
27669
+ toolName: "portfolio_query",
27670
+ nodeCount: totalNodes,
27671
+ edgeCount: totalEdges,
27672
+ compactEdges: true,
27673
+ argsHint: `from=${from ?? fromId}, products=${matched.length}, limit=${perProductLimit}`
27674
+ });
27675
+ if (guard.kind === "refuse") return guard.result;
27676
+ const response = {
27677
+ products: matched,
27678
+ products_searched: products.length,
27679
+ products_with_matches: matched.length,
27680
+ total_nodes: totalNodes,
27681
+ total_edges: totalEdges,
27682
+ empty_products: emptyProducts
27683
+ };
27684
+ if (errored.length > 0) response.errored_products = errored;
27685
+ if (unmatched.length > 0) response.unmatched_scope = unmatched;
27686
+ if (guard.kind === "warn") Object.assign(response, guard.fields);
27687
+ return text(JSON.stringify(response, null, 2));
27688
+ };
27689
+ var portfolioDigest = async (args, ctx) => {
27690
+ const { store } = ctx;
27691
+ const scope = args.scope;
27692
+ const cwd = process.cwd();
27693
+ const { products, unmatched } = resolveScopedProducts(cwd, scope);
27694
+ const summaries = [];
27695
+ const errored = [];
27696
+ const byStage = {};
27697
+ let totalNodes = 0;
27698
+ let totalEdges = 0;
27699
+ for (const product of products) {
27700
+ try {
27701
+ const { store: reader } = await readerFor(product, store);
27702
+ const digest = computeGraphDigest2(reader);
27703
+ const stage = digest.product.stage || "unset";
27704
+ byStage[stage] = (byStage[stage] ?? 0) + 1;
27705
+ totalNodes += digest.counts.total_nodes;
27706
+ totalEdges += digest.counts.total_edges;
27707
+ const topTypes = Object.entries(digest.counts.by_type).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([type, count]) => ({ type, count }));
27708
+ summaries.push({
27709
+ product_id: product.id,
27710
+ file: product.file,
27711
+ title: digest.product.title,
27712
+ stage: digest.product.stage || null,
27713
+ total_nodes: digest.counts.total_nodes,
27714
+ total_edges: digest.counts.total_edges,
27715
+ health: digest.health,
27716
+ coverage_pct: digest.coverage.stage_summary?.overall_pct ?? null,
27717
+ top_types: topTypes
27718
+ });
27719
+ } catch (err) {
27720
+ errored.push({ product_id: product.id, file: product.file, error: err.message });
27721
+ }
27722
+ }
27723
+ const response = {
27724
+ products: summaries,
27725
+ rollup: {
27726
+ products: summaries.length,
27727
+ total_nodes: totalNodes,
27728
+ total_edges: totalEdges,
27729
+ by_stage: byStage
27730
+ }
27731
+ };
27732
+ if (errored.length > 0) response.errored_products = errored;
27733
+ if (unmatched.length > 0) response.unmatched_scope = unmatched;
27734
+ if (products.length === 0) {
27735
+ response.note = scope && scope.length > 0 ? "No workspace products matched the requested scope." : "No products found in the workspace. Run from a directory with a .upg/ workspace.";
27736
+ }
27737
+ return text(JSON.stringify(response, null, 2));
27738
+ };
27739
+
27422
27740
  // src/tools/schema.ts
27423
27741
  var getEntitySchema = (args, _ctx) => {
27424
27742
  const rawType = args.type;
@@ -28120,24 +28438,24 @@ var prioritise = (args, ctx) => {
28120
28438
  };
28121
28439
  var trace = (args, ctx) => {
28122
28440
  const anchor = args.anchor;
28123
- const path7 = args.path;
28441
+ const path8 = args.path;
28124
28442
  const edgesOverride = args.edges_override;
28125
28443
  if (!anchor) {
28126
28444
  return textError("Missing required parameter: anchor (entity_id)");
28127
28445
  }
28128
- if (!path7 || !Array.isArray(path7) || path7.length === 0) {
28446
+ if (!path8 || !Array.isArray(path8) || path8.length === 0) {
28129
28447
  return textError("Missing required parameter: path (UPGEntityType[])");
28130
28448
  }
28131
- if (edgesOverride && edgesOverride.length !== path7.length) {
28449
+ if (edgesOverride && edgesOverride.length !== path8.length) {
28132
28450
  return textError(
28133
- `edges_override length (${edgesOverride.length}) must match path length (${path7.length})`
28451
+ `edges_override length (${edgesOverride.length}) must match path length (${path8.length})`
28134
28452
  );
28135
28453
  }
28136
- const result = executeTrace(ctx.store, anchor, path7, edgesOverride);
28454
+ const result = executeTrace(ctx.store, anchor, path8, edgesOverride);
28137
28455
  const payload = {
28138
28456
  params: {
28139
28457
  anchor,
28140
- path: path7,
28458
+ path: path8,
28141
28459
  edges_override: edgesOverride ?? null
28142
28460
  },
28143
28461
  trail: result.trail,
@@ -28733,7 +29051,7 @@ var migrateStatus = (args, ctx) => {
28733
29051
 
28734
29052
  // src/tools/sync.ts
28735
29053
  import * as fsp4 from "fs/promises";
28736
- import * as path4 from "path";
29054
+ import * as path5 from "path";
28737
29055
  import { nodeId, edgeId as edgeId4 } from "@unified-product-graph/sdk";
28738
29056
  var getSyncState = async (_args, ctx) => {
28739
29057
  const { store, sync } = ctx;
@@ -28901,7 +29219,7 @@ var pushToCloud = async (args, ctx) => {
28901
29219
  const productId = args.product_id;
28902
29220
  if (!cloudEndpoint || !apiKey) {
28903
29221
  try {
28904
- const mcpConfigPath = path4.join(process.cwd(), ".mcp.json");
29222
+ const mcpConfigPath = path5.join(process.cwd(), ".mcp.json");
28905
29223
  const mcpRaw = await fsp4.readFile(mcpConfigPath, "utf-8");
28906
29224
  const mcpConfig = JSON.parse(mcpRaw);
28907
29225
  const upgCloud = mcpConfig.mcpServers?.["upg-cloud"];
@@ -28986,8 +29304,8 @@ var pushToCloud = async (args, ctx) => {
28986
29304
  };
28987
29305
 
28988
29306
  // src/tools/skills.ts
28989
- import { existsSync as existsSync2, lstatSync, readlinkSync, readFileSync as readFileSync2, readdirSync as readdirSync2, realpathSync } from "fs";
28990
- import { join as join5, resolve as resolve2, dirname as dirname3 } from "path";
29307
+ import { existsSync as existsSync2, lstatSync, readlinkSync, readFileSync as readFileSync3, readdirSync as readdirSync2, realpathSync } from "fs";
29308
+ import { join as join5, resolve as resolve3, dirname as dirname3 } from "path";
28991
29309
  import { fileURLToPath } from "url";
28992
29310
  function repoRoot() {
28993
29311
  return process.cwd();
@@ -29016,7 +29334,7 @@ function resolveBundledSkillsDir() {
29016
29334
  } catch {
29017
29335
  md = process.cwd();
29018
29336
  }
29019
- for (const c of [resolve2(md, "..", "skills"), resolve2(md, "..", "..", "skills"), resolve2(md, "skills")]) {
29337
+ for (const c of [resolve3(md, "..", "skills"), resolve3(md, "..", "..", "skills"), resolve3(md, "skills")]) {
29020
29338
  if (isSkillsDir(c)) return c;
29021
29339
  }
29022
29340
  let dir = md;
@@ -29030,12 +29348,12 @@ function resolveBundledSkillsDir() {
29030
29348
  return null;
29031
29349
  }
29032
29350
  function sourceSkillsDir() {
29033
- const cwdPath = resolve2(repoRoot(), "packages/upg-mcp-server/skills");
29351
+ const cwdPath = resolve3(repoRoot(), "packages/upg-mcp-server/skills");
29034
29352
  if (existsSync2(cwdPath)) return cwdPath;
29035
29353
  return resolveBundledSkillsDir() ?? cwdPath;
29036
29354
  }
29037
29355
  function deployedSkillsDir() {
29038
- return resolve2(repoRoot(), ".claude/skills");
29356
+ return resolve3(repoRoot(), ".claude/skills");
29039
29357
  }
29040
29358
  function parseFrontmatter(body) {
29041
29359
  if (!body.startsWith("---\n")) return null;
@@ -29079,11 +29397,11 @@ function auditOne(name) {
29079
29397
  let deployedFrontmatter = null;
29080
29398
  let deployedFirstHeading = null;
29081
29399
  if (deployedExists) {
29082
- const deployedBody = readFileSync2(deployedPath, "utf8");
29400
+ const deployedBody = readFileSync3(deployedPath, "utf8");
29083
29401
  deployedFrontmatter = parseFrontmatter(deployedBody);
29084
29402
  deployedFirstHeading = firstHeading(deployedBody);
29085
29403
  if (sourceExists) {
29086
- const sourceBody = readFileSync2(sourcePath, "utf8");
29404
+ const sourceBody = readFileSync3(sourcePath, "utf8");
29087
29405
  inSync = deployedBody === sourceBody;
29088
29406
  if (!inSync) {
29089
29407
  issues.push("Deployed SKILL.md differs from canonical source; symlink is stale or broken");
@@ -30044,7 +30362,7 @@ var TOOL_DEFINITIONS = [
30044
30362
  },
30045
30363
  {
30046
30364
  name: "list_cross_edge_types",
30047
- description: "List the canonical cross-product edge types from `UPG_CROSS_EDGE_TYPES`: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts`. Portfolio-level relationships across products. Distinct from the within-product `UPG_EDGE_CATALOG`.",
30365
+ description: "List the canonical cross-product edge types from `UPG_CROSS_EDGE_TYPES`: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts`, `contributes_to`. Portfolio-level relationships across products. Distinct from the within-product `UPG_EDGE_CATALOG`.",
30048
30366
  inputSchema: { type: "object", properties: {} }
30049
30367
  },
30050
30368
  {
@@ -30525,7 +30843,7 @@ var TOOL_DEFINITIONS = [
30525
30843
  },
30526
30844
  {
30527
30845
  name: "create_cross_product_edge",
30528
- description: "Create a cross-product relationship between two entities in different products within a portfolio graph. Types: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts` (host product runs the hosted product inside itself, directed host to hosted).",
30846
+ description: "Create a cross-product relationship between two entities in different products within a portfolio graph. Types: `shares_persona`, `shares_competitor`, `shares_metric`, `depends_on_product`, `cannibalises`, `succeeds`, `hosts` (host product runs the hosted product inside itself, directed host to hosted), `contributes_to` (a product strategy entity rolls up to a higher-level one, e.g. product objective \u2192 company objective, product key_result \u2192 company key_result; directed subordinate to superior).",
30529
30847
  inputSchema: {
30530
30848
  type: "object",
30531
30849
  properties: {
@@ -30533,7 +30851,7 @@ var TOOL_DEFINITIONS = [
30533
30851
  target_id: { type: "string", description: "Target node ID" },
30534
30852
  type: {
30535
30853
  type: "string",
30536
- enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts"],
30854
+ enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts", "contributes_to"],
30537
30855
  description: "Cross-product relationship type"
30538
30856
  },
30539
30857
  source_product_id: { type: "string", description: "Product ID of the source node" },
@@ -30569,7 +30887,7 @@ var TOOL_DEFINITIONS = [
30569
30887
  target_id: { type: "string", description: "Target node ID (bare or qualified {product_id}/{node_id})" },
30570
30888
  type: {
30571
30889
  type: "string",
30572
- enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts"],
30890
+ enum: ["shares_persona", "shares_competitor", "shares_metric", "depends_on_product", "cannibalises", "succeeds", "hosts", "contributes_to"],
30573
30891
  description: "Cross-product relationship type"
30574
30892
  },
30575
30893
  source_product_id: { type: "string", description: "Product ID of the source node (qualifies a bare source_id)" },
@@ -30588,6 +30906,58 @@ var TOOL_DEFINITIONS = [
30588
30906
  description: "List all cross-product edges stored in the portfolio document (`.upg/portfolio.upg`). Empty list when the portfolio document is absent.",
30589
30907
  inputSchema: { type: "object", properties: {} }
30590
30908
  },
30909
+ {
30910
+ name: "portfolio_query",
30911
+ description: 'Traverse the graph ACROSS products in one call (the multi-product `query`). Runs the same BFS (typed-edge traversal + field projection) against every product in scope and tags each subgraph with its source `product_id`, without `switch_product` (the active product is read live; others are read-only). Use for portfolio-level questions ("every product\'s strategy region", "which products have a persona"). `from_id` only matches in its owning product. Read-only.',
30912
+ inputSchema: {
30913
+ type: "object",
30914
+ properties: {
30915
+ from: { type: "string", description: "Start from all nodes of this type (in each product)" },
30916
+ from_id: { type: "string", description: "Start from a specific node ID. Node IDs are product-local; only the owning product returns results." },
30917
+ traverse: {
30918
+ type: "array",
30919
+ items: { type: "string" },
30920
+ description: "Edge types to follow at each level (in order). If omitted, follows all edges. Prefix with ! to exclude."
30921
+ },
30922
+ depth: { type: "number", description: "Max traversal depth (default 3, max 10)" },
30923
+ include: {
30924
+ type: "array",
30925
+ items: { type: "string" },
30926
+ description: 'Fields per node: "title", "status", "tags", "description", "properties" (default: title, status, type)'
30927
+ },
30928
+ limit: { type: "number", description: "Max nodes per product (default 100, max 1000)" },
30929
+ edge_include: {
30930
+ type: "array",
30931
+ items: { type: "string" },
30932
+ description: 'Edge fields to return: "id", "type", "source", "target". Empty array = no edges. Default: all fields.'
30933
+ },
30934
+ property_include: {
30935
+ type: "array",
30936
+ items: { type: "string" },
30937
+ description: 'When "properties" is in include, only return these property keys.'
30938
+ },
30939
+ scope: {
30940
+ type: "array",
30941
+ items: { type: "string" },
30942
+ description: "Product IDs (or files) to query. Omit to query ALL products in the workspace. Match by product id, relative file, or basename."
30943
+ }
30944
+ }
30945
+ }
30946
+ },
30947
+ {
30948
+ name: "portfolio_digest",
30949
+ description: "Roll up every product's counts, health, and stage-coverage in one call (the multi-product `get_graph_digest`). The strategic-surface read that otherwise required `switch_product` + `get_graph_digest` per graph. Returns per-product summaries plus a portfolio rollup (totals, products-by-stage). Read-only; never mutates active-product state.",
30950
+ inputSchema: {
30951
+ type: "object",
30952
+ properties: {
30953
+ scope: {
30954
+ type: "array",
30955
+ items: { type: "string" },
30956
+ description: "Product IDs (or files) to summarise. Omit to summarise ALL products in the workspace."
30957
+ }
30958
+ }
30959
+ }
30960
+ },
30591
30961
  {
30592
30962
  name: "migrate_cross_edges",
30593
30963
  description: "Migrate inline cross-product edges from the current product's `edges[]` into the portfolio document (`.upg/portfolio.upg`) with qualified IDs. `dry_run: true` (default) previews; `dry_run: false` applies. Requires `source_product_id` to qualify source node IDs.",
@@ -30774,6 +31144,8 @@ var HANDLERS = {
30774
31144
  attach_product_to_portfolio: attachProductToPortfolioTool,
30775
31145
  detach_product_from_portfolio: detachProductFromPortfolioTool,
30776
31146
  list_portfolio_cross_edges: listPortfolioCrossEdges,
31147
+ portfolio_query: portfolioQuery,
31148
+ portfolio_digest: portfolioDigest,
30777
31149
  migrate_cross_edges: migrateCrossEdges,
30778
31150
  get_sync_state: getSyncState,
30779
31151
  apply_pull_changeset: applyPullChangeset,
@@ -30832,9 +31204,9 @@ var SERVER_INSTRUCTIONS = [
30832
31204
  ].join("\n");
30833
31205
  function resolvePackageVersion() {
30834
31206
  try {
30835
- const here = path5.dirname(fileURLToPath2(import.meta.url));
30836
- const pkgPath = path5.resolve(here, "..", "package.json");
30837
- const raw = fs2.readFileSync(pkgPath, "utf-8");
31207
+ const here = path6.dirname(fileURLToPath2(import.meta.url));
31208
+ const pkgPath = path6.resolve(here, "..", "package.json");
31209
+ const raw = fs3.readFileSync(pkgPath, "utf-8");
30838
31210
  const pkg = JSON.parse(raw);
30839
31211
  if (typeof pkg.version === "string" && pkg.version.length > 0) return pkg.version;
30840
31212
  } catch {
@@ -30876,7 +31248,7 @@ function createServer(store) {
30876
31248
  const result = handler ? await handler(args, ctx) : textError(`Unknown tool: ${name}`);
30877
31249
  if (logFile) {
30878
31250
  const entry = JSON.stringify({ ts: t0, tool: name, params: args, result, durationMs: Date.now() - t0 });
30879
- fs2.appendFileSync(logFile, entry + "\n");
31251
+ fs3.appendFileSync(logFile, entry + "\n");
30880
31252
  }
30881
31253
  return result;
30882
31254
  });
@@ -30893,15 +31265,15 @@ import { nanoid } from "nanoid";
30893
31265
  import { fileURLToPath as fileURLToPath3 } from "url";
30894
31266
  import { realpathSync as realpathSync2 } from "fs";
30895
31267
  async function discoverUPGFile(explicitFile) {
30896
- if (explicitFile) return path6.resolve(explicitFile);
31268
+ if (explicitFile) return path7.resolve(explicitFile);
30897
31269
  const cwd = process.cwd();
30898
- const workspacePath = path6.join(cwd, ".upg", "workspace.json");
31270
+ const workspacePath = path7.join(cwd, ".upg", "workspace.json");
30899
31271
  try {
30900
- const raw = await fs3.readFile(workspacePath, "utf-8");
31272
+ const raw = await fs4.readFile(workspacePath, "utf-8");
30901
31273
  const workspace = JSON.parse(raw);
30902
31274
  if (workspace.default_product) {
30903
- const filePath = path6.join(cwd, ".upg", workspace.default_product);
30904
- await fs3.access(filePath);
31275
+ const filePath = path7.join(cwd, ".upg", workspace.default_product);
31276
+ await fs4.access(filePath);
30905
31277
  const title = workspace.products?.find(
30906
31278
  (p) => p.file === workspace.default_product
30907
31279
  )?.title ?? workspace.default_product;
@@ -30912,16 +31284,16 @@ async function discoverUPGFile(explicitFile) {
30912
31284
  return filePath;
30913
31285
  }
30914
31286
  } catch {
30915
- const upgDir = path6.join(cwd, ".upg");
31287
+ const upgDir = path7.join(cwd, ".upg");
30916
31288
  try {
30917
- const dirEntries = await fs3.readdir(upgDir);
31289
+ const dirEntries = await fs4.readdir(upgDir);
30918
31290
  const upgFiles = dirEntries.filter((f) => f.endsWith(".upg")).sort();
30919
31291
  if (upgFiles.length > 0) {
30920
31292
  const products = [];
30921
31293
  for (const file of upgFiles) {
30922
- let title = path6.basename(file, ".upg");
31294
+ let title = path7.basename(file, ".upg");
30923
31295
  try {
30924
- const raw = await fs3.readFile(path6.join(upgDir, file), "utf-8");
31296
+ const raw = await fs4.readFile(path7.join(upgDir, file), "utf-8");
30925
31297
  const doc = JSON.parse(raw);
30926
31298
  if (doc.product?.title) title = doc.product.title;
30927
31299
  } catch {
@@ -30933,12 +31305,12 @@ async function discoverUPGFile(explicitFile) {
30933
31305
  default_product: upgFiles[0],
30934
31306
  products
30935
31307
  };
30936
- await fs3.writeFile(workspacePath, JSON.stringify(workspace, null, 2) + "\n", "utf-8");
31308
+ await fs4.writeFile(workspacePath, JSON.stringify(workspace, null, 2) + "\n", "utf-8");
30937
31309
  process.stderr.write(
30938
31310
  `UPG workspace: auto-created workspace.json (${upgFiles.length} product${upgFiles.length > 1 ? "s" : ""})
30939
31311
  `
30940
31312
  );
30941
- const filePath = path6.join(upgDir, upgFiles[0]);
31313
+ const filePath = path7.join(upgDir, upgFiles[0]);
30942
31314
  process.stderr.write(`UPG workspace: loading "${products[0].title}"
30943
31315
  `);
30944
31316
  return filePath;
@@ -30947,17 +31319,17 @@ async function discoverUPGFile(explicitFile) {
30947
31319
  }
30948
31320
  }
30949
31321
  try {
30950
- const entries = await fs3.readdir(cwd);
31322
+ const entries = await fs4.readdir(cwd);
30951
31323
  const upgFiles = entries.filter((f) => f.endsWith(".upg")).sort();
30952
31324
  if (upgFiles.length === 1) {
30953
- return path6.resolve(upgFiles[0]);
31325
+ return path7.resolve(upgFiles[0]);
30954
31326
  }
30955
31327
  if (upgFiles.length > 1) {
30956
31328
  process.stderr.write(
30957
31329
  `Found ${upgFiles.length} .upg files: loading ${upgFiles[0]}. Use --file to pick a specific one.
30958
31330
  `
30959
31331
  );
30960
- return path6.resolve(upgFiles[0]);
31332
+ return path7.resolve(upgFiles[0]);
30961
31333
  }
30962
31334
  } catch {
30963
31335
  }
@@ -30977,7 +31349,7 @@ async function runMcpServer() {
30977
31349
  });
30978
31350
  let resolvedPath = await discoverUPGFile(values.file);
30979
31351
  if (!resolvedPath) {
30980
- const defaultFile = path6.resolve("product.upg");
31352
+ const defaultFile = path7.resolve("product.upg");
30981
31353
  const title = values.title ?? "My Product";
30982
31354
  const blank = {
30983
31355
  upg_version: UPG_VERSION,
@@ -30993,16 +31365,16 @@ async function runMcpServer() {
30993
31365
  nodes: [],
30994
31366
  edges: []
30995
31367
  };
30996
- await fs3.mkdir(path6.dirname(defaultFile), { recursive: true });
30997
- await fs3.writeFile(defaultFile, serializeCanonical(blank), "utf-8");
31368
+ await fs4.mkdir(path7.dirname(defaultFile), { recursive: true });
31369
+ await fs4.writeFile(defaultFile, serializeCanonical(blank), "utf-8");
30998
31370
  process.stderr.write(`Created new UPG file: ${defaultFile}
30999
31371
  `);
31000
31372
  resolvedPath = defaultFile;
31001
31373
  } else {
31002
31374
  try {
31003
- await fs3.access(resolvedPath);
31375
+ await fs4.access(resolvedPath);
31004
31376
  } catch {
31005
- const title = values.title ?? path6.basename(resolvedPath, ".upg");
31377
+ const title = values.title ?? path7.basename(resolvedPath, ".upg");
31006
31378
  const blank = {
31007
31379
  upg_version: UPG_VERSION,
31008
31380
  exported_at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -31017,13 +31389,13 @@ async function runMcpServer() {
31017
31389
  nodes: [],
31018
31390
  edges: []
31019
31391
  };
31020
- await fs3.mkdir(path6.dirname(resolvedPath), { recursive: true });
31021
- await fs3.writeFile(resolvedPath, serializeCanonical(blank), "utf-8");
31392
+ await fs4.mkdir(path7.dirname(resolvedPath), { recursive: true });
31393
+ await fs4.writeFile(resolvedPath, serializeCanonical(blank), "utf-8");
31022
31394
  process.stderr.write(`Created new UPG file: ${resolvedPath}
31023
31395
  `);
31024
31396
  }
31025
31397
  }
31026
- const store = new UPGFileStore();
31398
+ const store = new UPGFileStore2();
31027
31399
  store.setWriter("upg-mcp-local", SERVER_VERSION);
31028
31400
  await store.load(resolvedPath);
31029
31401
  const nodes = store.getAllNodes();