@clien-ai/mcp 0.14.0 → 0.15.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.
@@ -52,7 +52,7 @@ import { readFinalHypothesisState } from './hypothesis-final.js';
52
52
  import { finalClauseCoverageMatchesIntegrity, readRobustnessCounts, reportClaimNeedsIndependentMarketAuthority, sourceAuthenticatesMarketEconomics, } from '../hypothesis-semantics.js';
53
53
  import { parseReceiptId, resolveSourceReceipt } from './receipt-children.js';
54
54
  import { resolveMarketSignals } from './market-signals.js';
55
- import { MCP_OVERVIEW_REF_CAP, MCP_OVERVIEW_SPAN_CAP, REPORT_PORTRAIT_PATHS, sanitizeMcpMarkdownTitle, sanitizeMcpReportTitle, } from '../types/report.js';
55
+ import { MCP_OVERVIEW_REF_CAP, MCP_OVERVIEW_SPAN_CAP, REPORT_PORTRAIT_PATHS, isModelAttestedReportClaim, parseUnverifiedSourceId, receiptRowHasSafeDestination, safeSourceUrl, sanitizeMcpMarkdownTitle, sanitizeMcpReportTitle, } from '../types/report.js';
56
56
  import { readSemanticTheme } from '../types/semantic-theme.js';
57
57
  import { isDisplaySuitableExcerpt } from '../lib/excerpt-suitability.js';
58
58
  /**
@@ -165,6 +165,28 @@ const ATTESTED_WINDOW_LABEL = 'cited window (NOT a verified span)';
165
165
  * carries the meaning the shared word no longer can.
166
166
  */
167
167
  const UNSOURCED_LABEL = 'unsourced';
168
+ /**
169
+ * FUL-1219: the words on an unverified-source line (Alex D1), keyed by the stored label. An unknown
170
+ * label has no entry and renders nothing: a guessed word would be a claim about the page.
171
+ *
172
+ * ⚠️ THE LINE SHAPE IS THE GUARD, as with `ATTESTED_WINDOW_LABEL`. A receipt is `← RRCP-s{n}` on
173
+ * the row or an indented `"…"` excerpt in a pool; this line has neither, names itself
174
+ * `unverified source` and ends `(NOT a receipt)`, so no reader keying on the receipt shapes can
175
+ * take it for one. Pinned by `unverified-sources.test.ts`.
176
+ */
177
+ const UNVERIFIED_SOURCE_WORDS = {
178
+ paid_market_report: 'paid market report',
179
+ company_own_page: 'company\'s own page',
180
+ related_source: 'related source',
181
+ };
182
+ const UNVERIFIED_SOURCE_LABEL = 'unverified source';
183
+ /** Plan D8: at most three lines per claim; the rest become one "+N more" line. */
184
+ const UNVERIFIED_SOURCE_CAP = 3;
185
+ /**
186
+ * Replaces `from model knowledge, unverified` on an unsourced row that shows an unverified source:
187
+ * the page listed beneath contradicts "model knowledge", but it is still no receipt.
188
+ */
189
+ const NO_VERIFIED_RECEIPT = 'no verified receipt';
168
190
  /**
169
191
  * The three labels on a robustness row's continuation lines (FUL-614).
170
192
  *
@@ -316,20 +338,18 @@ function overviewCapNote(truncated, cap, noun) {
316
338
  return '';
317
339
  return `\n_(showing at most ${cap} stored ${noun} — additional entries were omitted before public allowlisting)_`;
318
340
  }
319
- /** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
320
- function safeReceiptUrl(raw) {
321
- if (typeof raw !== 'string' || !/^https?:\/\//i.test(raw))
322
- return null;
323
- try {
324
- const url = new URL(raw);
325
- if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
326
- return null;
327
- }
328
- return url.href;
329
- }
330
- catch {
331
- return null;
332
- }
341
+ /** FUL-1225: printed where a receipt's link would be when its stored URL is unsafe to show. */
342
+ const URL_WITHHELD = '(url withheld)';
343
+ /**
344
+ * FUL-1225: a receipt row's link, split into the two questions it answers. `reachable` is FUL-534's
345
+ * grant condition (a safe destination, shown or withheld by the projection) and decides state and
346
+ * tally. `href` is what may be printed: only a URL `safeSourceUrl` accepts, else null. Keeping them
347
+ * apart is what lets an unprintable URL go unlinked without the claim going unsourced.
348
+ */
349
+ function receiptLink(source) {
350
+ if (!source)
351
+ return { reachable: false, href: null };
352
+ return { reachable: receiptRowHasSafeDestination(source), href: safeSourceUrl(source.url) };
333
353
  }
334
354
  const OVERVIEW_ASSERTION_MAX = 320;
335
355
  function overviewReceiptSupportsClaim(claim, receipt) {
@@ -351,14 +371,14 @@ function resolveOverviewRef(ref, claimSpan, reportEvidence, forumThreads) {
351
371
  if (!match)
352
372
  return null;
353
373
  const source = asRecord(reportEvidence[Number(match[1])]);
354
- const href = safeReceiptUrl(source?.url);
374
+ const link = receiptLink(source);
355
375
  const quote = str(source?.quote);
356
- if (!source || !href || !quote || !isDisplaySuitableExcerpt(quote) || !overviewReceiptSupportsClaim(claimSpan, quote))
376
+ if (!source || !link.reachable || !quote || !isDisplaySuitableExcerpt(quote) || !overviewReceiptSupportsClaim(claimSpan, quote))
357
377
  return null;
358
378
  if (reportClaimNeedsIndependentMarketAuthority('summary', claimSpan) &&
359
379
  !sourceAuthenticatesMarketEconomics(source))
360
380
  return null;
361
- return `${sourceId} -> ${href}`;
381
+ return `${sourceId} -> ${link.href ?? URL_WITHHELD}`;
362
382
  }
363
383
  if (kind === 'forum_receipt') {
364
384
  const receiptId = safeId(record?.receiptId);
@@ -375,7 +395,7 @@ function resolveOverviewRef(ref, claimSpan, reportEvidence, forumThreads) {
375
395
  !isDisplaySuitableExcerpt(excerpt) ||
376
396
  !overviewReceiptSupportsClaim(claimSpan, excerpt))
377
397
  return [];
378
- const href = safeReceiptUrl(thread?.url);
398
+ const href = safeSourceUrl(thread?.url);
379
399
  return [`${receiptId}${href
380
400
  ? ` -> ${href}`
381
401
  : ` -> cached excerpt: "${safeInline(excerpt, OVERVIEW_ASSERTION_MAX) ?? '[unavailable]'}"`}`];
@@ -474,9 +494,10 @@ function resolvePersonaReceipt(rawClaim, personas) {
474
494
  if (sourceIndex < 0)
475
495
  return null;
476
496
  const source = asRecord(sources[sourceIndex]);
477
- const href = safeReceiptUrl(source?.url);
478
- if (!source || !href)
497
+ const link = receiptLink(source);
498
+ if (!source || !link.reachable)
479
499
  return null;
500
+ const href = link.href;
480
501
  // Branch on the RAW value, not on the resolved one: only `undefined` is "legacy claim, nothing
481
502
  // to resolve" and every present value — including `null` — is a promise this parent must keep.
482
503
  const claimedReceiptId = claim?.receiptId;
@@ -690,7 +711,7 @@ function resolveReportReceipt(rawClaim, reportEvidence) {
690
711
  return null;
691
712
  const source = asRecord(reportEvidence[sourceIndex]);
692
713
  if (!source ||
693
- !safeReceiptUrl(source.url) ||
714
+ !receiptRowHasSafeDestination(source) ||
694
715
  (kind === 'grounded' && !isDisplaySuitableExcerpt(source.quote)))
695
716
  return null;
696
717
  if (reportClaimNeedsIndependentMarketAuthority(claim?.section, claim?.text) &&
@@ -737,7 +758,62 @@ function digestSpanOnRow(quote, span) {
737
758
  const normalizedSpan = normalize(span);
738
759
  return normalizedSpan.length > 0 && normalize(quote).includes(normalizedSpan);
739
760
  }
740
- function renderReportClaim(raw, receipt, corroborating = []) {
761
+ /**
762
+ * FUL-1219: the unverified-source lines under one claim, or '' when there are none.
763
+ *
764
+ * Only a stored, non-attested `NO_RECEIPT` claim shows them: on a GROUNDED claim they would sit
765
+ * beside a receipt, on a model-attested one beside "cited", and a SPECULATION claim never expected
766
+ * evidence. `getReport` hands the RAW payload through after schema drift, so every check the
767
+ * projection makes is repeated here: the id resolves only through the anchored `RUVS-` parser (a
768
+ * receipt id never reaches the unverified pool), the pool row must be of the claim's section and
769
+ * have a plain, printable-ASCII http(s) URL not already listed under the claim, and the label must
770
+ * be one this client knows. Only the host is printed; the full URL stays in the structured channel.
771
+ */
772
+ function unverifiedSourceLines(rawClaim, unverifiedEvidence) {
773
+ const claim = asRecord(rawClaim);
774
+ if (str(claim?.state) !== 'NO_RECEIPT')
775
+ return '';
776
+ // A model-attested claim renders `cited → RRCP-s{n}`; an unverified line beside "cited" would read
777
+ // as support (plan stop condition (d)). Same marker test as the projection and the app.
778
+ if (isModelAttestedReportClaim(claim))
779
+ return '';
780
+ const lines = [];
781
+ const seen = new Set();
782
+ let resolved = 0;
783
+ for (const raw of asArray(claim?.unverifiedSources)) {
784
+ const entry = asRecord(raw);
785
+ const index = parseUnverifiedSourceId(entry?.sourceId);
786
+ const word = typeof entry?.label === 'string' && Object.hasOwn(UNVERIFIED_SOURCE_WORDS, entry.label)
787
+ ? UNVERIFIED_SOURCE_WORDS[entry.label]
788
+ : null;
789
+ const row = index === null ? null : asRecord(unverifiedEvidence[index]);
790
+ // Parity with the app: a pool row of another section is not this claim's page, and a URL
791
+ // already listed under this claim is not listed twice.
792
+ if (!row || row.section !== claim?.section)
793
+ continue;
794
+ const url = safeSourceUrl(row.url);
795
+ if (!word || !url || seen.has(url))
796
+ continue;
797
+ const host = safeInline(new URL(url).hostname.replace(/^www\./, ''), 120);
798
+ if (!host)
799
+ continue;
800
+ seen.add(url);
801
+ resolved += 1;
802
+ if (lines.length < UNVERIFIED_SOURCE_CAP) {
803
+ lines.push(`\n · ${UNVERIFIED_SOURCE_LABEL} RUVS-s${index} (${word}): ${host} (NOT a receipt)`);
804
+ }
805
+ }
806
+ if (lines.length === 0)
807
+ return '';
808
+ const stored = claim?.unverifiedSourcesOmitted;
809
+ const omitted = (Number.isSafeInteger(stored) && stored > 0 ? stored : 0) +
810
+ (resolved - lines.length);
811
+ if (omitted > 0) {
812
+ lines.push(`\n · +${omitted} more ${omitted === 1 ? UNVERIFIED_SOURCE_LABEL : `${UNVERIFIED_SOURCE_LABEL}s`} found`);
813
+ }
814
+ return lines.join('');
815
+ }
816
+ function renderReportClaim(raw, receipt, corroborating = [], unverified = '') {
741
817
  const claim = asRecord(raw);
742
818
  if (!claim)
743
819
  return '- (unreadable claim entry)';
@@ -771,7 +847,7 @@ function renderReportClaim(raw, receipt, corroborating = []) {
771
847
  provenance = ` · ${ATTESTED_LABEL} → ${receipt.sourceId} (NOT a receipt)`;
772
848
  }
773
849
  else if (rawState === 'NO_RECEIPT') {
774
- provenance = ` · ${UNSOURCED_LABEL} — from model knowledge, unverified`;
850
+ provenance = ` · ${UNSOURCED_LABEL} — ${unverified ? NO_VERIFIED_RECEIPT : 'from model knowledge, unverified'}`;
775
851
  }
776
852
  // A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
777
853
  // neither hidden from the reader nor readable as grounding.
@@ -780,7 +856,7 @@ function renderReportClaim(raw, receipt, corroborating = []) {
780
856
  }
781
857
  const where = section ? ` (${section})` : '';
782
858
  const body = text ? ` — "${text}"` : '';
783
- return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(receipt)}`;
859
+ return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(receipt)}${unverified}`;
784
860
  }
785
861
  /**
786
862
  * The attested claim's window: the captured snapshot text an independent judge read
@@ -862,6 +938,7 @@ function renderReportSpine(reportData, channel) {
862
938
  'figure as unverified.');
863
939
  }
864
940
  const reportEvidence = asArray(reportData.reportEvidence);
941
+ const unverifiedEvidence = asArray(reportData.unverifiedEvidence);
865
942
  const rows = claims.map((claim) => ({
866
943
  claim,
867
944
  receipt: resolveReportReceipt(claim, reportEvidence),
@@ -870,7 +947,21 @@ function renderReportSpine(reportData, channel) {
870
947
  const attested = rows.filter(({ receipt }) => receipt?.kind === 'attested').length;
871
948
  const attestedNote = attested > 0 ? ` (of which ${attested} ${ATTESTED_LABEL})` : '';
872
949
  const shown = rows.slice(0, CLAIM_RENDER_CAP);
873
- const lines = shown.map(({ claim, receipt }) => renderReportClaim(claim, receipt, corroboratingReceiptIds(claim, receipt, reportEvidence)));
950
+ let anyUnverified = false;
951
+ const lines = shown.map(({ claim, receipt }) => {
952
+ const unverified = unverifiedSourceLines(claim, unverifiedEvidence);
953
+ if (unverified)
954
+ anyUnverified = true;
955
+ return renderReportClaim(claim, receipt, corroboratingReceiptIds(claim, receipt, reportEvidence), unverified);
956
+ });
957
+ // FUL-1219: said only when a row carries such a line, so a report without them renders
958
+ // byte-identically to before.
959
+ const unverifiedNote = anyUnverified
960
+ ? `An indented \`· ${UNVERIFIED_SOURCE_LABEL} RUVS-s{n} (…)\` line under a NO_RECEIPT row is a page ` +
961
+ 'the research found for that claim that did NOT verify, labelled with why (a paid market report, ' +
962
+ `the company's own page, or a related source); its full URL is \`${channel}.report_data.unverifiedEvidence[n]\`. It is NOT a ` +
963
+ 'receipt: it changes no state and counts toward no tally above. '
964
+ : '';
874
965
  // FUL-358 — SAY WHICH POPULATION THE HEADLINE COUNTED.
875
966
  //
876
967
  // The prose below has always explained that a `summary` claim usually restates a `market` or
@@ -902,6 +993,7 @@ function renderReportSpine(reportData, channel) {
902
993
  'text as we captured it: read it as the context the judge weighed, never as a span checked against ' +
903
994
  'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. A stored GROUNDED grade ' +
904
995
  'whose receipt is unavailable is shown as neutral audit context and counted NO_RECEIPT. ' +
996
+ unverifiedNote +
905
997
  'Key on the rendered state and receipt arrow, never on stored state or source-id presence alone.\n' +
906
998
  `${lines.join('\n')}` +
907
999
  capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
@@ -1017,7 +1109,7 @@ function renderReceiptPools(reportData, channel) {
1017
1109
  // they sit on the SAME `- RCP-…` line, so a newline in either forges the
1018
1110
  // receipt row the comment below says a forged quote would.
1019
1111
  const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
1020
- const url = safeInline(receipt.href, 200) ?? '(no url)';
1112
+ const url = receipt.href === null ? URL_WITHHELD : safeInline(receipt.href, 200) ?? '(no url)';
1021
1113
  // Indented under its own receipt line so the pool still scans as a list of
1022
1114
  // pointers; a receipt with no cached quote simply has no second line, which
1023
1115
  // is itself worth seeing — it is a pointer that grounds nothing until fetched.
@@ -1101,7 +1193,10 @@ function renderReceiptPools(reportData, channel) {
1101
1193
  // claim's arrow with a source nobody fetched.
1102
1194
  const section = safeInline(source?.section, 40) ?? 'unknown section';
1103
1195
  const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
1104
- const url = safeInline(source?.url, 200) ?? '(no url)';
1196
+ // FUL-1225: the stored string is printed only when the display check accepts it; a withheld
1197
+ // or unsafe one never reaches the text channel.
1198
+ const link = receiptLink(source);
1199
+ const url = link.href ? safeInline(source?.url, 200) ?? '(no url)' : link.reachable ? URL_WITHHELD : '(no url)';
1105
1200
  // FUL-148: `publishedDate` is the figure's recency; `retrievedAt` is only when
1106
1201
  // WE fetched it. Rendering retrievedAt as recency would be a lie, so an absent
1107
1202
  // publication date is shown honestly as unknown rather than substituted.
@@ -1252,7 +1347,7 @@ function renderCommunityPatterns(reportData, channel) {
1252
1347
  const sentiment = safeInline(thread?.sentimentSummary, MODEL_FRAMING_TEXT_MAX);
1253
1348
  const title = safeInline(thread?.title, MODEL_FRAMING_TEXT_MAX) ?? 'Untitled discussion';
1254
1349
  const theme = readSemanticTheme(thread?.theme, title);
1255
- const href = safeReceiptUrl(thread?.url);
1350
+ const href = safeSourceUrl(thread?.url);
1256
1351
  return [
1257
1352
  `- Theme: ${theme ?? 'not recorded'}`,
1258
1353
  ` Sentiment: ${sentiment ?? 'not recorded'}`,
@@ -1812,9 +1907,9 @@ function renderInsightCrossReferences(reportData) {
1812
1907
  const source = Number.isSafeInteger(index) ? asRecord(evidence[index]) : null;
1813
1908
  const authorityEligible = !reportClaimNeedsIndependentMarketAuthority('summary', statement) ||
1814
1909
  sourceAuthenticatesMarketEconomics(source);
1815
- const href = source && authorityEligible ? safeReceiptUrl(source.url) : null;
1816
- return sourceId && href
1817
- ? `${label} → ${sourceId} — ${href}`
1910
+ const link = authorityEligible ? receiptLink(source) : { reachable: false, href: null };
1911
+ return sourceId && link.reachable
1912
+ ? `${label} → ${sourceId} — ${link.href ?? URL_WITHHELD}`
1818
1913
  : `${label} (no resolvable receipt)`;
1819
1914
  })
1820
1915
  .filter((source) => Boolean(source));
@@ -1836,16 +1931,16 @@ function resolveCrossCuttingEvidence(reportData, ref, claim) {
1836
1931
  !sourceAuthenticatesMarketEconomics(source))
1837
1932
  return null;
1838
1933
  const label = safeInline(source.platform, MODEL_FRAMING_TEXT_MAX) || 'Web source';
1839
- const href = safeReceiptUrl(source.url);
1934
+ const link = receiptLink(source);
1840
1935
  const hasQuote = Object.prototype.hasOwnProperty.call(source, 'quote');
1841
1936
  if (hasQuote && !isDisplaySuitableExcerpt(source.quote))
1842
1937
  return null;
1843
1938
  const quote = hasQuote
1844
1939
  ? safeInline(source.quote, RECEIPT_QUOTE_MAX)
1845
1940
  : null;
1846
- if (!href)
1941
+ if (!link.reachable)
1847
1942
  return null;
1848
- return `${label}${href ? ` — ${href}` : ''}${quote ? `\n > ${quote}` : ''}`;
1943
+ return `${label} — ${link.href ?? URL_WITHHELD}${quote ? `\n > ${quote}` : ''}`;
1849
1944
  }
1850
1945
  if (ref.kind === 'forum_receipt') {
1851
1946
  const receiptId = safeId(ref.receiptId);
@@ -1862,7 +1957,7 @@ function resolveCrossCuttingEvidence(reportData, ref, claim) {
1862
1957
  return null;
1863
1958
  const match = matches[0];
1864
1959
  const label = safeInline(match.thread?.platform, MODEL_FRAMING_TEXT_MAX) || 'Community source';
1865
- const href = safeReceiptUrl(match.thread?.url);
1960
+ const href = safeSourceUrl(match.thread?.url);
1866
1961
  const quote = isDisplaySuitableExcerpt(match.receipt?.excerpt)
1867
1962
  ? safeInline(match.receipt?.excerpt, RECEIPT_QUOTE_MAX)
1868
1963
  : null;
@@ -1960,12 +2055,12 @@ function renderProjectedSourceBearingOutput(reportData, channel) {
1960
2055
  else {
1961
2056
  const evidenceRows = asArray(reportData.reportEvidence).slice(0, LIST_RENDER_CAP).flatMap((raw, index) => {
1962
2057
  const source = asRecord(raw);
1963
- const href = safeReceiptUrl(source?.url);
1964
- if (!source || !href)
2058
+ const link = receiptLink(source);
2059
+ if (!source || !link.reachable)
1965
2060
  return [];
1966
2061
  const section = safeInline(source.section, 40);
1967
2062
  const platform = safeInline(source.platform, 40) ?? 'Source';
1968
- return [`- RRCP-s${index}${section ? ` (${section})` : ''} — ${platform} — ${href}`];
2063
+ return [`- RRCP-s${index}${section ? ` (${section})` : ''} — ${platform} — ${link.href ?? URL_WITHHELD}`];
1969
2064
  });
1970
2065
  if (evidenceRows.length > 0)
1971
2066
  sections.push(`## Report Sources\n\n${evidenceRows.join('\n')}`);
@@ -2145,7 +2240,7 @@ function renderMarketSizing(reportData) {
2145
2240
  const lines = signals.map((signal) => {
2146
2241
  const context = safeInline(signal.context, 360) ?? 'Context unavailable';
2147
2242
  const sources = (signal.sources ?? (signal.source ? [signal.source] : []))
2148
- .map((source) => safeInline(source.url, 240))
2243
+ .map((source) => safeSourceUrl(source.url) ? safeInline(source.url, 240) : URL_WITHHELD)
2149
2244
  .filter((url) => Boolean(url));
2150
2245
  const citations = sources.length > 0 ? ` ← ${sources.join(', ')}` : '';
2151
2246
  if (signal.kind !== 'size')
@@ -2363,6 +2458,10 @@ function renderRedditRetrieval(reportData) {
2363
2458
  * raw passthrough payload (see the module header). Returns the full digest, or
2364
2459
  * the explicit "no trust data" banner — never an empty string, because an empty
2365
2460
  * digest and a clean one must not look the same.
2461
+ *
2462
+ * ⚠️ FUL-1225: callers pass the output of exactly ONE `projectReportDataForMcp` (via
2463
+ * `prepareReportDataForMcp`). The projection strips stored `urlWithheld` / `urlWithheldHost` markers
2464
+ * before setting its own; on unprojected JSON a forged marker would be trusted as a reachable receipt.
2366
2465
  */
2367
2466
  export function buildTrustDigest(reportData, channel) {
2368
2467
  const rawData = asRecord(reportData);