@orangepro/orangepro-mcp 0.2.11 → 0.2.13

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
@@ -1,9 +1,27 @@
1
- # OrangePro
1
+ # OrangePro MCP
2
+
3
+ [![npm version](https://badge.fury.io/js/@orangepro%2Fmcp-server.svg )](https://www.npmjs.com/package/@orangepro/mcp-server )
4
+ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg )](LICENSE)
5
+ [![npm downloads](https://img.shields.io/npm/dw/@orangepro/mcp-server.svg )](https://www.npmjs.com/package/@orangepro/mcp-server )
6
+ [![orangepro-mcp MCP server](https://glama.ai/mcp/servers/OrangeproAI/orangepro-mcp/badges/score.svg )](https://glama.ai/mcp/servers/OrangeproAI/orangepro-mcp )
2
7
 
3
8
  **Find the behaviors your tests miss. Generate grounded tests that actually run.**
4
9
 
5
10
  `opro` builds a knowledge graph from your local checkout, maps every behavior in your code, shows which ones are tested and which aren't, and generates integration-level tests grounded in real symbols — not hallucinated imports. It runs as a CLI and a local stdio MCP server.
6
11
 
12
+ Once you run the mcp server against a repo, you can get behavior-coverage/html
13
+ **<a href="https://orangeproai.github.io/orangepro-mcp/twenty-crm-behavior-coverage.html" target="_blank">→ Live example: Twenty CRM behavior coverage report</a>**
14
+
15
+ <img width="895" alt="OrangePro system map — entry lanes, services, evidence tiers" src="https://github.com/user-attachments/assets/1ceba779-e0ec-4ec1-99ce-001bc3589b42" />
16
+
17
+ *Fig 1: System map — entry lanes (GraphQL, HTTP, Jobs) flowing into services, sized by traffic, colored by evidence tier, red-ringed by risk.*
18
+
19
+ <img width="818" height="1284" alt="Screenshot 2026-07-30 at 4 38 35 PM" src="https://github.com/user-attachments/assets/1c41475d-be2a-4c6d-ace6-c6546573bf97" />
20
+
21
+
22
+ *Fig 2: Priority gaps — top 20 unproven behaviors ranked by blast radius, with generated test drafts and applicable testing categories.*
23
+
24
+ ---
7
25
  Install the target repository's dependencies first, then run OrangePro from that repository:
8
26
 
9
27
  ```bash
@@ -35,7 +53,6 @@ orangepro_generated/ ← contained generated tests; existing source file
35
53
  The report opens on a **system map** of your repo — entry lanes (GraphQL/HTTP/Jobs) flowing into the services they reach, sized by traffic, colored by evidence tier, risk-ringed — identical on every run. Each completed rerun shows a **delta banner** against the previous completed run. The report discloses when bounded path enumeration prunes additional branch expansions. Every behavior gets a plain-English description; every top risk gets a deterministic context line and a state-aware next step.
36
54
 
37
55
  Run `opro export` when you want a machine-readable evidence pack.
38
- <img width="895" height="960" alt="Screenshot 2026-07-08 at 1 18 01 AM" src="https://github.com/user-attachments/assets/73b8a812-2eab-43a1-8aed-4289545e630b" />
39
56
 
40
57
  ---
41
58
 
@@ -11,6 +11,7 @@ import { GENERATED_DIR } from "./runHints.js";
11
11
  import { behaviorNodes, findNode, nodesByKind, priorityRank } from "../graph/factories.js";
12
12
  import { structurallyUnconfirmable } from "../graph/confirmable.js";
13
13
  import { shortHash } from "../util/hash.js";
14
+ import { targetFingerprint } from "../ledger.js";
14
15
  import { reportProgress } from "../util/progress.js";
15
16
  import { redactSecrets, redactSecretsPreservingLineCount } from "../util/redact.js";
16
17
  import { systemClock } from "../util/time.js";
@@ -2042,6 +2043,25 @@ function planGroundedBuckets(graph, targets, framework, fileReader, limit, expli
2042
2043
  }
2043
2044
  return plan;
2044
2045
  }
2046
+ /**
2047
+ * Previously generated drafts that may be reused verbatim for `behavior`: the
2048
+ * newest run's RUNNABLE, non-stale drafts recorded against the target's CURRENT
2049
+ * fingerprint. A missing fingerprint (unanalyzed file, unparseable id) pins
2050
+ * nothing — the conservative direction is to regenerate, never to serve a draft
2051
+ * whose target we cannot prove is unchanged.
2052
+ */
2053
+ function pinnedDraftsFor(graph, behavior, fingerprint) {
2054
+ if (!fingerprint || behavior.kind !== "CodeSymbol")
2055
+ return [];
2056
+ const matches = (graph.generated_tests ?? []).filter((t) => t.target_symbol_external_id === behavior.external_id &&
2057
+ t.target_fingerprint === fingerprint &&
2058
+ t.runnable !== false &&
2059
+ t.stale !== true);
2060
+ if (matches.length === 0)
2061
+ return [];
2062
+ const latestRunId = matches[matches.length - 1].run_id;
2063
+ return matches.filter((t) => t.run_id === latestRunId);
2064
+ }
2045
2065
  /**
2046
2066
  * Select the same target behaviors + gathered context for the A/B comparison, so
2047
2067
  * both arms target identical behaviors and differ ONLY in whether the KG evidence
@@ -2067,7 +2087,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2067
2087
  const { targets, warnings } = selectTargets(graph, opts);
2068
2088
  const framework = pickFramework(graph, opts, targets, fileReader);
2069
2089
  const runSelection = targetsForFramework(graph, targets, framework);
2070
- const runTargets = runSelection.targets;
2090
+ let runTargets = runSelection.targets;
2071
2091
  warnings.push(...runSelection.warnings);
2072
2092
  const systemPrompt = opts.systemPrompt ?? buildSystemPrompt();
2073
2093
  const promptVersion = inputMode === "graph_grounded" && opts.prompt_version === "v5" ? PROMPT_VERSION_V5 : PROMPT_VERSION;
@@ -2076,6 +2096,24 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2076
2096
  const run_id = `local-gen-${runSeed}`;
2077
2097
  const generated = [];
2078
2098
  const missing = [];
2099
+ // Pin: a target whose code is byte-identical to what a previously RUNNABLE draft
2100
+ // was written against gets that draft back verbatim — no model call, no new
2101
+ // persisted record, no drift in what the agent is told to run. Regeneration
2102
+ // resumes the moment the target's fingerprint changes.
2103
+ const fingerprintOf = (behavior) => behavior.kind === "CodeSymbol" ? targetFingerprint(graph, behavior.external_id) : undefined;
2104
+ if (opts.pin_unchanged && inputMode === "graph_grounded") {
2105
+ const remaining = [];
2106
+ for (const behavior of runTargets) {
2107
+ const pinned = pinnedDraftsFor(graph, behavior, fingerprintOf(behavior));
2108
+ if (pinned.length === 0 || generated.length >= limit) {
2109
+ remaining.push(behavior);
2110
+ continue;
2111
+ }
2112
+ generated.push(...pinned.slice(0, limit - generated.length).map((t) => ({ ...t, pinned: true })));
2113
+ warnings.push(`Reused ${pinned.length} pinned draft(s) for "${behavior.title || behavior.external_id}" — the target is unchanged since they were generated (no model call).`);
2114
+ }
2115
+ runTargets = remaining;
2116
+ }
2079
2117
  if (inputMode === "raw_prompt") {
2080
2118
  // Internal baseline only: broad sampling, one raw test per target (no buckets).
2081
2119
  // Cap ATTEMPTS (not successes): with empty completions skipped below, capping
@@ -2439,6 +2477,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2439
2477
  grounding: { entity_ids: gc.entityIds, source_refs: [], weak_relationships_used: [] },
2440
2478
  weak_evidence_used: false,
2441
2479
  target_symbol_external_id: behavior.external_id,
2480
+ ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}),
2442
2481
  runnable: false,
2443
2482
  unresolved_reason: reason
2444
2483
  });
@@ -2460,7 +2499,9 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2460
2499
  import_provenance
2461
2500
  },
2462
2501
  weak_evidence_used: gc.weakUsed.length > 0,
2463
- ...(behavior.kind === "CodeSymbol" ? { target_symbol_external_id: behavior.external_id } : {}),
2502
+ ...(behavior.kind === "CodeSymbol"
2503
+ ? { target_symbol_external_id: behavior.external_id, ...(fingerprintOf(behavior) ? { target_fingerprint: fingerprintOf(behavior) } : {}) }
2504
+ : {}),
2464
2505
  runnable: true
2465
2506
  });
2466
2507
  }
@@ -2631,7 +2672,12 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2631
2672
  import_provenance
2632
2673
  },
2633
2674
  weak_evidence_used: item.weakUsed.length > 0,
2634
- ...(item.behavior.kind === "CodeSymbol" ? { target_symbol_external_id: item.behavior.external_id } : {}),
2675
+ ...(item.behavior.kind === "CodeSymbol"
2676
+ ? {
2677
+ target_symbol_external_id: item.behavior.external_id,
2678
+ ...(fingerprintOf(item.behavior) ? { target_fingerprint: fingerprintOf(item.behavior) } : {})
2679
+ }
2680
+ : {}),
2635
2681
  runnable,
2636
2682
  ...(unresolved_reason ? { unresolved_reason } : {})
2637
2683
  });
@@ -2640,7 +2686,11 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2640
2686
  if (targets.length === 0) {
2641
2687
  warnings.push("No behavior anchors available to target. Add requirements/templates or analyze a path with tests.");
2642
2688
  }
2643
- const run = generated.length
2689
+ // A pinned draft belongs to the run that FIRST produced it: it must not be
2690
+ // re-listed under this run_id (nor re-persisted), or every pin-only generation
2691
+ // would duplicate the same draft in the graph.
2692
+ const freshTests = generated.filter((t) => !t.pinned);
2693
+ const run = freshTests.length
2644
2694
  ? {
2645
2695
  run_id,
2646
2696
  model_provider: provider.providerName,
@@ -2648,7 +2698,7 @@ export async function generateTests(graph, opts, provider, fileReader, clock = s
2648
2698
  input_mode: inputMode,
2649
2699
  prompt_version: promptVersion,
2650
2700
  created_at,
2651
- generated_test_ids: generated.map((t) => t.id)
2701
+ generated_test_ids: freshTests.map((t) => t.id)
2652
2702
  }
2653
2703
  : null;
2654
2704
  return { run, generated_tests: generated, missing_evidence: missing, warnings };
@@ -41,7 +41,7 @@ import { renderCoverageReport } from "./pack/coverageReport.js";
41
41
  import { confirmedCoverageByLayer } from "./score/coverage.js";
42
42
  import { prepareRuntimeCoverage } from "./analyze/coverageArtifacts.js";
43
43
  import { appendLedgerRecord, canReproveLanguage, loadLedger, ledgerStats, proofEdgesFor, reproveTarget, resolveTargetSymbol, targetFingerprint, targetLanguage } from "./ledger.js";
44
- import { buildRtm, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
44
+ import { buildRtm, provenSymbolIds, renderRtmCsv, renderRtmMarkdown } from "./rtm.js";
45
45
  import { buildProofDoctor, distillProofAttempts, loadProofAttempts, proofAttemptsFresh, writeProofAttempts } from "./proofDoctor.js";
46
46
  import { tryScopedReprove } from "./reprove/scoped.js";
47
47
  import { resolveContained, toWorkspaceRel } from "./reprove/paths.js";
@@ -811,7 +811,10 @@ export function opProofDoctor(root) {
811
811
  export function opGaps(root, opts = {}) {
812
812
  const graph = loadGraph(workspacePaths(root).graphPath);
813
813
  const gaps = findGaps(graph, opts);
814
- const topRiskGaps = rankRiskGaps(graph, { limit: opts.limit ?? 10, repoRoot: root }).map((gap) => ({
814
+ // Ledger proofs are invisible to the graph's hard edges; pass them so a type
815
+ // whose every method is Dynamically Proven is suppressed instead of ranked.
816
+ const provenIds = provenSymbolIds(graph, loadLedger(root));
817
+ const topRiskGaps = rankRiskGaps(graph, { limit: opts.limit ?? 10, repoRoot: root, provenIds }).map((gap) => ({
815
818
  external_id: gap.id,
816
819
  title: gap.title,
817
820
  file: gap.file,
@@ -1598,7 +1601,11 @@ export async function opStart(root, opts = {}, deps = defaultDeps()) {
1598
1601
  try {
1599
1602
  const graphForGeneration = loadGraph(workspacePaths(root).graphPath);
1600
1603
  const generatedTargets = new Set((graphForGeneration.generated_tests ?? []).map((t) => t.target_symbol_external_id).filter((id) => Boolean(id)));
1601
- const targetIds = rankRiskGaps(graphForGeneration, { repoRoot: root, limit: START_GENERATE_RISK_LIMIT })
1604
+ const targetIds = rankRiskGaps(graphForGeneration, {
1605
+ repoRoot: root,
1606
+ limit: START_GENERATE_RISK_LIMIT,
1607
+ provenIds: provenSymbolIds(graphForGeneration, loadLedger(root))
1608
+ })
1602
1609
  .map((gap) => gap.id)
1603
1610
  .filter((id) => !generatedTargets.has(id));
1604
1611
  if (targetIds.length) {
@@ -1792,12 +1799,24 @@ export async function opGenerate(root, opts = {}, deps = defaultDeps()) {
1792
1799
  };
1793
1800
  }
1794
1801
  const reader = fileReaderFor(graph.workspace.root);
1795
- const result = await generateTests(graph, { target_ids: opts.target_ids, framework: opts.framework, limit: opts.limit, input_mode: opts.input_mode, prompt_version: opts.prompt_version }, provider, reader, deps.clock);
1796
- if (result.run && result.generated_tests.length) {
1802
+ const result = await generateTests(graph, {
1803
+ target_ids: opts.target_ids,
1804
+ framework: opts.framework,
1805
+ limit: opts.limit,
1806
+ input_mode: opts.input_mode,
1807
+ prompt_version: opts.prompt_version,
1808
+ // Persisting lane: a runnable draft for an unchanged target is reused as-is
1809
+ // rather than re-bought from the model on every run.
1810
+ pin_unchanged: opts.pin_unchanged ?? true
1811
+ }, provider, reader, deps.clock);
1812
+ // Pinned drafts are already IN graph.generated_tests — appending them again
1813
+ // would duplicate a draft on every generation of an unchanged target.
1814
+ const freshTests = result.generated_tests.filter((t) => !t.pinned);
1815
+ if (result.run && freshTests.length) {
1797
1816
  const next = {
1798
1817
  ...graph,
1799
1818
  generation_runs: [...graph.generation_runs, result.run],
1800
- generated_tests: [...graph.generated_tests, ...result.generated_tests],
1819
+ generated_tests: [...graph.generated_tests, ...freshTests],
1801
1820
  updated_at: deps.clock()
1802
1821
  };
1803
1822
  saveGraph(paths.graphPath, next);
package/dist/local/rtm.js CHANGED
@@ -47,6 +47,21 @@ export function buildRtm(graph, ledger, opts = {}) {
47
47
  const rows = opts.limit && opts.limit > 0 ? filteredRows.slice(0, opts.limit) : filteredRows;
48
48
  return { summary: summarizeRows(baseRows, unionRows), rows, ...(opts.scope ? { scope: opts.scope } : {}) };
49
49
  }
50
+ /**
51
+ * CodeSymbol ids the RTM would call Dynamically Proven — the SAME selection
52
+ * buildRtm uses (`selectLedgerBySymbol(...).proven`), so proof judgment has one
53
+ * definition. Consumers outside the RTM (risk ranking's container suppression)
54
+ * need it because a proof leaves no graph edge and its target frequently sits
55
+ * outside the deterministic denominator.
56
+ */
57
+ export function provenSymbolIds(graph, ledger) {
58
+ const out = new Set();
59
+ for (const [symbol, selected] of selectLedgerBySymbol(ledger, graph)) {
60
+ if (selected.proven)
61
+ out.add(symbol);
62
+ }
63
+ return out;
64
+ }
50
65
  function inScope(node, targetSet, fileSet) {
51
66
  if (!targetSet && !fileSet)
52
67
  return true;
@@ -11,7 +11,37 @@ function symbolFile(n) {
11
11
  function symbolTitle(n) {
12
12
  return n.title ?? n.external_id.split("#")[1] ?? n.external_id;
13
13
  }
14
- function confirmedBehaviorIds(graph) {
14
+ /**
15
+ * Structural container → member children, read from the analyzer's own
16
+ * `properties.member_of` (the same signal src/local/reprove/scoped.ts uses).
17
+ * Id-prefix matching cannot do this job: `sym:f.ts#a.b` is a child of
18
+ * `sym:f.ts#a` only by string luck, and a dotted symbol name with no container
19
+ * node would be mis-parented. A container is resolved inside the child's OWN
20
+ * file: a same-named symbol elsewhere is a different thing, and mis-parenting
21
+ * here would suppress a genuine gap.
22
+ */
23
+ function containerChildren(graph) {
24
+ const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol");
25
+ const idByFileTitle = new Map();
26
+ for (const n of symbols)
27
+ idByFileTitle.set(`${symbolFile(n)}#${symbolTitle(n)}`, n.external_id);
28
+ const out = new Map();
29
+ for (const n of symbols) {
30
+ const memberOf = n.properties.member_of;
31
+ if (typeof memberOf !== "string" || memberOf === "")
32
+ continue;
33
+ const containerId = idByFileTitle.get(`${symbolFile(n)}#${memberOf}`);
34
+ if (!containerId || containerId === n.external_id)
35
+ continue;
36
+ const list = out.get(containerId);
37
+ if (list)
38
+ list.push(n.external_id);
39
+ else
40
+ out.set(containerId, [n.external_id]);
41
+ }
42
+ return out;
43
+ }
44
+ function confirmedBehaviorIds(graph, provenIds) {
15
45
  const ids = new Set();
16
46
  const nodeKinds = new Map(graph.nodes.map((n) => [n.external_id, n.kind]));
17
47
  for (const e of graph.edges) {
@@ -27,13 +57,15 @@ function confirmedBehaviorIds(graph) {
27
57
  // A container type whose every method child is confirmed has no distinct
28
58
  // untested surface left — suppress it from the gap ranking rather than
29
59
  // listing it as an unlinked candidate above its own proven methods.
30
- const symbolIds = graph.nodes.filter((n) => n.kind === "CodeSymbol").map((n) => n.external_id);
31
- for (const id of symbolIds) {
32
- if (ids.has(id))
60
+ // "Confirmed" here means a hard static link OR a current ledger proof: the
61
+ // proof lane leaves no graph edge and usually sits outside the denominator,
62
+ // so a hard-edge-only check never fires for a dynamically proven type.
63
+ const eligible = new Set(graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true).map((n) => n.external_id));
64
+ for (const [containerId, children] of containerChildren(graph)) {
65
+ if (ids.has(containerId) || !eligible.has(containerId))
33
66
  continue;
34
- const children = symbolIds.filter((s) => s.startsWith(id + "."));
35
- if (children.length > 0 && children.every((c) => ids.has(c)))
36
- ids.add(id);
67
+ if (children.every((c) => ids.has(c) || provenIds?.has(c) === true))
68
+ ids.add(containerId);
37
69
  }
38
70
  return ids;
39
71
  }
@@ -325,7 +357,7 @@ function candidateSignalIds(graph, candidateIds) {
325
357
  }
326
358
  export function rankRiskGaps(graph, opts = {}) {
327
359
  const limit = opts.limit ?? 20;
328
- const confirmed = confirmedBehaviorIds(graph);
360
+ const confirmed = confirmedBehaviorIds(graph, opts.provenIds);
329
361
  const symbols = graph.nodes.filter((n) => n.kind === "CodeSymbol" && n.denominator_eligible === true && !n.stale && !confirmed.has(n.external_id));
330
362
  const symbolIds = new Set(symbols.map((s) => s.external_id));
331
363
  const symbolsByFile = new Map();
@@ -758,11 +758,11 @@ function renderRisks(){
758
758
  hiddenGeneratedFlows
759
759
  ? \`<div class="paywall-num">\${hiddenGeneratedFlows} more flows with tests</div>
760
760
  <div class="paywall-txt">OrangePro accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Use the “Flows with tests” filter to review them first.</div>
761
- <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">View all on OrangePro Platform &rarr;</a>\`
761
+ <a class="paywall-btn" href="https://platform.orangepro.ai/" target="_blank">View all on OrangePro Platform &rarr;</a>\`
762
762
  : remainingRiskFlows
763
763
  ? \`<div class="paywall-num">\${remainingRiskFlows} high-risk flows left</div>
764
764
  <div class="paywall-txt">The local MCP accepted \${D.generatedTotal} runnable generated test\${D.generatedTotal===1?"":"s"} across \${generatedRiskCount} high-risk flow\${generatedRiskCount===1?"":"s"}. Generate the remaining high-risk flow tests on OrangePro Platform.</div>
765
- <a class="paywall-btn" href="https://app.orangepro.ai" target="_blank">Generate remaining tests on Platform &rarr;</a>\`
765
+ <a class="paywall-btn" href="https://platform.orangepro.ai/" target="_blank">Generate remaining tests on Platform &rarr;</a>\`
766
766
  : \`<div class="paywall-num">All generated tests are shown</div>
767
767
  <div class="paywall-txt">OrangePro generated tests for every high-risk flow in this report, and every generated test is visible here.</div>\`));
768
768
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangepro/orangepro-mcp",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "private": false,
5
5
  "description": "OrangePro (`opro`) — a local-first, BYOK CLI + MCP server that builds an evidence graph from a local checkout, ingests runtime coverage, and generates grounded tests. Metadata-only exports; no source upload; generated tests stay local.",
6
6
  "license": "MIT",
@@ -131,4 +131,4 @@
131
131
  "unplugin-swc": "^1.5.9",
132
132
  "vitest": "^3.2.0"
133
133
  }
134
- }
134
+ }