canli-validation-mcp 0.4.0 → 0.6.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/src/server.mjs CHANGED
@@ -9,17 +9,29 @@
9
9
  import { readFileSync, realpathSync } from "node:fs";
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { z } from "zod";
13
+ import { computeLocally } from "./local.mjs";
14
+ import { readMatrixFile, readSeriesFile } from "./series-file.mjs";
15
+ import { verifyReceipt } from "./local/js/receipt-statement.js";
12
16
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
17
  import {
14
18
  breadthInput,
15
19
  trackRecordInput,
20
+ auditBacktestInput,
21
+ verifyReceiptToolShape,
22
+ backtestLengthInput,
23
+ haircutSharpeInput,
24
+ luckTrialsInput,
25
+ auditBacktestToolShape,
16
26
  companyHistoryInput,
27
+ companyHistoryToolShape,
17
28
  deflatedSharpeInput,
18
29
  deflatedSharpeToolShape,
19
30
  emptyInput,
20
31
  getKeyInput,
21
32
  getReceiptInput,
22
33
  overfittingInput,
34
+ LIMITS_SENTENCES,
23
35
  paperEvidenceInput,
24
36
  TOOL_DESCRIPTIONS,
25
37
  } from "./schemas.mjs";
@@ -47,7 +59,14 @@ export function configuredKey(value) {
47
59
  return trimmed === "" || /^\$\{[^}]*\}$/.test(trimmed) ? undefined : trimmed;
48
60
  }
49
61
 
50
- export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted } = {}) {
62
+ // CANLI_LOCAL=1 (or "true") turns on private local mode: the validators run on this machine and
63
+ // nothing about the submitted series leaves it. An empty or unsubstituted value is off.
64
+ export function configuredLocal(value) {
65
+ const v = configuredKey(value);
66
+ return v === "1" || v?.toLowerCase() === "true";
67
+ }
68
+
69
+ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local, fullEnvelope, receiptKeys } = {}) {
51
70
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
52
71
  return {
53
72
  base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
@@ -58,6 +77,10 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
58
77
  // Set by the hosted endpoint (api/mcp.js): which key a request runs under, so get_key can say
59
78
  // so instead of issuing a key the next stateless request would never see.
60
79
  hosted: hosted ?? undefined,
80
+ local: local ?? configuredLocal(process.env.CANLI_LOCAL),
81
+ fullEnvelope: fullEnvelope ?? configuredFullEnvelope(process.env.CANLI_FULL_ENVELOPE),
82
+ // Tests pass their own keys; everyone else verifies against the bundled published keys.
83
+ receiptKeys: receiptKeys ?? undefined,
61
84
  };
62
85
  }
63
86
 
@@ -90,11 +113,38 @@ async function callApi(session, { path, method = "GET", body }) {
90
113
  // never reads; every field, boundary sentence and provenance value is kept. Measured on the live
91
114
  // Apple StockholdersEquity record (README, "Compact context"): 2,214 -> 1,560 tokens minified,
92
115
  // 1,056 with the columnar history below.
116
+ // Every result carries the envelope twice: as text, which every client shows, and as structured
117
+ // content, which a client can read field by field. The two are the same object.
93
118
  const asText = (envelope, failed = false) => ({
94
119
  content: [{ type: "text", text: JSON.stringify(envelope) }],
120
+ ...(envelope && typeof envelope === "object" && !Array.isArray(envelope) ? { structuredContent: envelope } : {}),
95
121
  ...(failed ? { isError: true } : {}),
96
122
  });
97
123
 
124
+ // A validation result as the model reads it: the answer, the sentences saying what it does not
125
+ // establish, and the receipt that holds the rest. Metadata (schema, endpoint, timestamps, claim and
126
+ // capital class, human page), the source hashes and the quota sentence are about the service, not
127
+ // the answer; they stay in the stored receipt (get_receipt) and service_status, and
128
+ // CANLI_FULL_ENVELOPE=1 returns every field. On a result this cuts the text roughly in half.
129
+ const QUOTA_SENTENCE = /^Quotas:/;
130
+
131
+ export function compactEnvelope(envelope) {
132
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return envelope;
133
+ const out = {};
134
+ if (envelope.computed) { out.computed = envelope.computed; out.note = envelope.note; }
135
+ if ("data" in envelope) out.data = envelope.data;
136
+ if (envelope.error) out.error = envelope.error;
137
+ if (Array.isArray(envelope.limits)) out.limits = envelope.limits.filter((s) => !QUOTA_SENTENCE.test(s));
138
+ if ("receipt" in envelope) out.receipt = envelope.receipt ? { id: envelope.receipt.id, url: envelope.receipt.url } : null;
139
+ return out;
140
+ }
141
+
142
+ export function configuredFullEnvelope(value) {
143
+ return value === "1" || value === "true";
144
+ }
145
+
146
+ const validationText = (session, { envelope, failed }) => asText(session.fullEnvelope ? envelope : compactEnvelope(envelope), failed);
147
+
98
148
  // A history's observations as one header and one row each, instead of every field name repeated
99
149
  // on every observation. The column order is fixed so a row can be read without its keys; a unit
100
150
  // shared by every row is stated once.
@@ -112,6 +162,15 @@ export function columnarObservations(observations) {
112
162
 
113
163
  export async function toolGetKey(session, args) {
114
164
  const { label } = parseOrThrow(getKeyInput, args, "get_key");
165
+ if (session.local) {
166
+ // Local mode computes the validators on this machine; issuing a key would be a network call
167
+ // the user turned local mode on to avoid, and would spend the per-client issuance quota.
168
+ return asText({
169
+ note: "Local mode is on: the validators run on this machine and need no key. No key was issued and no request was sent.",
170
+ key_source: "local",
171
+ key_present: false,
172
+ });
173
+ }
115
174
  if (session.hosted) {
116
175
  // The hosted endpoint is stateless: a key issued here would be gone by the next request, and
117
176
  // every hosted caller shares the platform's egress address and so its per-client issuance quota.
@@ -147,32 +206,157 @@ export async function toolValidateDeflatedSharpe(session, args) {
147
206
  "or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
148
207
  );
149
208
  }
209
+ if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return validationText(session, local); }
150
210
  const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
151
- return asText(response.envelope, response.failed);
211
+ return validationText(session, response);
152
212
  }
153
213
 
154
214
  export async function toolValidateOverfitting(session, args) {
155
215
  const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
216
+ if (session.local) { const local = computeLocally("validate_overfitting", body); return validationText(session, local); }
156
217
  const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
157
- return asText(response.envelope, response.failed);
218
+ return validationText(session, response);
158
219
  }
159
220
 
160
221
  export async function toolValidatePaperEvidence(session, args) {
161
222
  const body = parseOrThrow(paperEvidenceInput, args, "validate_paper_evidence");
223
+ if (session.local) { const local = computeLocally("validate_paper_evidence", body); return validationText(session, local); }
162
224
  const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
163
- return asText(response.envelope, response.failed);
225
+ return validationText(session, response);
164
226
  }
165
227
 
166
228
  export async function toolValidateBreadth(session, args) {
167
229
  const body = parseOrThrow(breadthInput, args, "validate_breadth");
230
+ if (session.local) { const local = computeLocally("validate_breadth", body); return validationText(session, local); }
168
231
  const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
169
- return asText(response.envelope, response.failed);
232
+ return validationText(session, response);
170
233
  }
171
234
 
172
235
  export async function toolValidateTrackRecord(session, args) {
173
236
  const body = parseOrThrow(trackRecordInput, args, "validate_track_record");
237
+ if (session.local) { const local = computeLocally("validate_track_record", body); return validationText(session, local); }
174
238
  const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
175
- return asText(response.envelope, response.failed);
239
+ return validationText(session, response);
240
+ }
241
+
242
+ export async function toolValidateBacktestLength(session, args) {
243
+ const body = parseOrThrow(backtestLengthInput, args, "validate_backtest_length");
244
+ if (session.local) { const local = computeLocally("validate_backtest_length", body); return validationText(session, local); }
245
+ const response = await callApi(session, { path: "/api/v1/validate/backtest-length", method: "POST", body });
246
+ return validationText(session, response);
247
+ }
248
+
249
+ export async function toolValidateHaircutSharpe(session, args) {
250
+ const body = parseOrThrow(haircutSharpeInput, args, "validate_haircut_sharpe");
251
+ if (session.local) { const local = computeLocally("validate_haircut_sharpe", body); return validationText(session, local); }
252
+ const response = await callApi(session, { path: "/api/v1/validate/haircut-sharpe", method: "POST", body });
253
+ return validationText(session, response);
254
+ }
255
+
256
+ export async function toolValidateLuckTrials(session, args) {
257
+ const body = parseOrThrow(luckTrialsInput, args, "validate_luck_trials");
258
+ if (session.local) { const local = computeLocally("validate_luck_trials", body); return validationText(session, local); }
259
+ const response = await callApi(session, { path: "/api/v1/validate/luck-trials", method: "POST", body });
260
+ return validationText(session, response);
261
+ }
262
+
263
+ // One validator, run the same way its own tool runs it: on this machine in local mode, otherwise
264
+ // through the API (one validation of quota, one receipt).
265
+ async function runValidator(session, tool, path, body) {
266
+ if (session.local) return computeLocally(tool, body);
267
+ return callApi(session, { path, method: "POST", body });
268
+ }
269
+
270
+ const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
271
+
272
+ // audit_backtest: deflated Sharpe from the series, then the minimum track record length from the
273
+ // Sharpe, skew and kurtosis that check derived, then (with variants) CSCV overfitting. Each check
274
+ // keeps its envelope and receipt; the limits every envelope repeats are stated once at the top.
275
+ // A refused later check (for example a Sharpe that does not exceed the benchmark) is reported as
276
+ // that check's error, not as a failed audit; the audit fails only when the first check does.
277
+ export async function toolAuditBacktest(session, args) {
278
+ const input = parseOrThrow(auditBacktestInput, args, "audit_backtest");
279
+ if ((input.returns_file || input.variants_file) && session.hosted) {
280
+ throw new Error("audit_backtest: the hosted endpoint cannot read files on your machine; send returns (and variants) as numbers, or run the server locally with npx -y canli-validation-mcp.");
281
+ }
282
+ const returnsRead = input.returns_file ? readSeriesFile(input.returns_file, input.returns_column) : null;
283
+ const variantsRead = input.variants_file ? readMatrixFile(input.variants_file) : null;
284
+ const returns = input.returns ?? returnsRead.values;
285
+ const variants = input.variants ?? variantsRead?.matrix;
286
+ const { periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized } = input;
287
+ const dsr = await runValidator(session, "validate_deflated_sharpe", "/api/v1/validate/deflated-sharpe", {
288
+ returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized,
289
+ });
290
+ if (dsr.failed) return validationText(session, { envelope: dsr.envelope, failed: true });
291
+ const derived = dsr.envelope?.data?.derived_inputs ?? {};
292
+ const trackBody = {
293
+ observed_sharpe_annualized: derived.observed_sharpe_annualized,
294
+ periods_per_year,
295
+ skew: derived.skew,
296
+ non_excess_kurtosis: derived.non_excess_kurtosis,
297
+ observations: derived.observations,
298
+ ...(input.benchmark_sharpe_annualized !== undefined ? { benchmark_sharpe_annualized: input.benchmark_sharpe_annualized } : {}),
299
+ ...(input.confidence !== undefined ? { confidence: input.confidence } : {}),
300
+ };
301
+ const track = await runValidator(session, "validate_track_record", "/api/v1/validate/track-record", trackBody);
302
+ const overfit = variants
303
+ ? await runValidator(session, "validate_overfitting", "/api/v1/validate/overfitting", {
304
+ matrix: variants,
305
+ ...(input.n_splits !== undefined ? { n_splits: input.n_splits } : {}),
306
+ })
307
+ : null;
308
+ const shape = (e) => (session.fullEnvelope ? e : compactEnvelope(e));
309
+ const envelopes = { deflated_sharpe: shape(dsr.envelope), track_record: shape(track.envelope), ...(overfit ? { overfitting: shape(overfit.envelope) } : {}) };
310
+ const limits = envelopes.deflated_sharpe?.limits;
311
+ const shared = Object.values(envelopes).every((e) => sameJson(e?.limits, limits));
312
+ const checks = Object.fromEntries(
313
+ Object.entries(envelopes).map(([name, e]) => [name, shared ? Object.fromEntries(Object.entries(e).filter(([k]) => k !== "limits")) : e]),
314
+ );
315
+ const readings = Object.fromEntries(
316
+ Object.entries(envelopes).map(([name, e]) => [name, e?.data?.plain_reading ?? e?.error?.message ?? null]),
317
+ );
318
+ return asText({
319
+ schema: "canli.audit.v1",
320
+ note: "Each check is its validator's own result and receipt, side by side. The audit does not grade the strategy.",
321
+ ...(shared ? { limits } : {}),
322
+ readings,
323
+ checks,
324
+ ...(input.returns_file || input.variants_file
325
+ ? { source: {
326
+ ...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
327
+ ...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
328
+ } }
329
+ : {}),
330
+ ...(variants ? {} : { not_run: { overfitting: "Send variants or variants_file (every variant's returns) to add the overfitting check." } }),
331
+ });
332
+ }
333
+
334
+ // The public keys canlicapital.com signs receipts with, as published at
335
+ // /.well-known/canli-receipt-keys.json when this package was built; a test keeps the two identical.
336
+ const RECEIPT_KEYS = JSON.parse(readFileSync(new URL("./receipt-keys.json", import.meta.url), "utf8"));
337
+
338
+ // verify_receipt: every check is local; only fetching a receipt by id touches the network.
339
+ export async function toolVerifyReceipt(session, args) {
340
+ const input = parseOrThrow(verifyReceiptToolShape, args, "verify_receipt");
341
+ if ((input.id === undefined) === (input.receipt === undefined)) throw new Error("verify_receipt: send exactly one of id or receipt");
342
+ let data = input.receipt;
343
+ if (input.id !== undefined) {
344
+ const response = await callApi(session, { path: `/api/v1/receipts/${input.id}` });
345
+ if (response.failed) return asText(response.envelope, true);
346
+ data = response.envelope?.data;
347
+ }
348
+ const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
349
+ const result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
350
+ return asText({
351
+ receipt_id: data?.id ?? null,
352
+ valid: result.valid,
353
+ checks: result.checks,
354
+ key_id: result.key_id,
355
+ keys: "bundled with this package; published at https://canlicapital.com/.well-known/canli-receipt-keys.json",
356
+ meaning: result.valid
357
+ ? "canlicapital.com signed this exact output for this exact input, computed by the source files whose hashes the receipt lists. It says nothing about how the input series was built."
358
+ : "At least one check failed: do not treat this receipt as issued by canlicapital.com for this content.",
359
+ });
176
360
  }
177
361
 
178
362
  export async function toolGetReceipt(session, args) {
@@ -190,9 +374,37 @@ export async function toolServiceStatus(session) {
190
374
  // field that says where a value came from (accession, form, filed date, unit, source hash) and
191
375
  // the record's own claim_boundary and policy sentences, so an agent cannot quote a number
192
376
  // without its provenance or boundary.
377
+ // Ticker -> CIK through the published index of companies in the release, fetched once per session.
378
+ async function resolveTicker(session, ticker) {
379
+ if (!session.tickerIndex) {
380
+ const signal = AbortSignal.timeout(session.timeoutMs);
381
+ let res;
382
+ try {
383
+ res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
384
+ } catch {
385
+ throw new Error(signal.aborted ? "company_financial_history: the ticker index request timed out" : "company_financial_history: could not reach the ticker index");
386
+ }
387
+ if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
388
+ session.tickerIndex = await res.json();
389
+ }
390
+ const key = ticker.toUpperCase();
391
+ const cik = session.tickerIndex.tickers?.[key];
392
+ if (!cik) {
393
+ return { error: { error: { code: "unknown_ticker", message: `No company in the current company reference release trades as ${key}. Search by name at the page below, or pass the SEC CIK.` }, page: `${session.base}/companies` } };
394
+ }
395
+ return { cik };
396
+ }
397
+
193
398
  export async function toolCompanyFinancialHistory(session, args) {
194
- const { cik, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
195
- const id = cik.padStart(10, "0");
399
+ const { cik, ticker, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
400
+ let id;
401
+ if (ticker !== undefined) {
402
+ const resolved = await resolveTicker(session, ticker);
403
+ if (resolved.error) return asText(resolved.error, true);
404
+ id = resolved.cik;
405
+ } else {
406
+ id = cik.padStart(10, "0");
407
+ }
196
408
  const path = `/company-data/${id}.json`;
197
409
  const signal = AbortSignal.timeout(session.timeoutMs);
198
410
  let res, text;
@@ -294,11 +506,36 @@ export function registerTools(server, session) {
294
506
  { title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
295
507
  (args) => toolValidateTrackRecord(session, args),
296
508
  );
509
+ server.registerTool(
510
+ "validate_backtest_length",
511
+ { title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput },
512
+ (args) => toolValidateBacktestLength(session, args),
513
+ );
514
+ server.registerTool(
515
+ "validate_haircut_sharpe",
516
+ { title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput },
517
+ (args) => toolValidateHaircutSharpe(session, args),
518
+ );
519
+ server.registerTool(
520
+ "validate_luck_trials",
521
+ { title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput },
522
+ (args) => toolValidateLuckTrials(session, args),
523
+ );
524
+ server.registerTool(
525
+ "audit_backtest",
526
+ { title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape },
527
+ (args) => toolAuditBacktest(session, args),
528
+ );
297
529
  server.registerTool(
298
530
  "get_receipt",
299
531
  { title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
300
532
  (args) => toolGetReceipt(session, args),
301
533
  );
534
+ server.registerTool(
535
+ "verify_receipt",
536
+ { title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape },
537
+ (args) => toolVerifyReceipt(session, args),
538
+ );
302
539
  server.registerTool(
303
540
  "service_status",
304
541
  { title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
@@ -306,14 +543,116 @@ export function registerTools(server, session) {
306
543
  );
307
544
  server.registerTool(
308
545
  "company_financial_history",
309
- { title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryInput },
546
+ { title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape },
310
547
  (args) => toolCompanyFinancialHistory(session, args),
311
548
  );
312
549
  }
313
550
 
551
+ // Guided workflows a user can pick in a client that shows MCP prompts. Arguments are strings, as
552
+ // the protocol defines them; the prompt only writes the instructions, the tools do the work.
553
+ export function registerPrompts(server) {
554
+ server.registerPrompt(
555
+ "validate_backtest",
556
+ {
557
+ title: "Validate a backtest before trusting it",
558
+ description: "Walk through the deflated Sharpe, overfitting and track-record checks for one strategy, and report what the numbers do not establish.",
559
+ argsSchema: {
560
+ strategy: z.string().describe("What the strategy is and what data it was backtested on"),
561
+ variants_tried: z.string().optional().describe("How many variants, parameter sets or ideas were tried before this one"),
562
+ },
563
+ },
564
+ ({ strategy, variants_tried }) => ({
565
+ messages: [{
566
+ role: "user",
567
+ content: {
568
+ type: "text",
569
+ text: [
570
+ `Validate this backtest before I trust it: ${strategy}.`,
571
+ variants_tried ? `Variants tried before settling on it: ${variants_tried}.` : "Ask me how many variants were tried before settling on it; do not assume one.",
572
+ "1. validate_deflated_sharpe with the return series (or the seven contract inputs), counting every variant tried as a trial.",
573
+ "2. If I can share the returns of every variant, validate_overfitting on that matrix.",
574
+ "3. validate_track_record for how long a live record must run before this Sharpe clears a benchmark I care about.",
575
+ "Report each number with the limits sentences its result carries, say plainly what they do not establish, and give me each receipt id.",
576
+ ].join("\n"),
577
+ },
578
+ }],
579
+ }),
580
+ );
581
+ server.registerPrompt(
582
+ "track_record_needed",
583
+ {
584
+ title: "How long a track record do I need?",
585
+ description: "The minimum track record length for a Sharpe to clear a benchmark, with the record's own probabilistic Sharpe if its length is known.",
586
+ argsSchema: {
587
+ sharpe: z.string().describe("The observed annualized Sharpe ratio"),
588
+ frequency: z.string().describe("How often returns are measured: daily, weekly or monthly"),
589
+ benchmark: z.string().optional().describe("The annualized Sharpe it must beat; 0 if not given"),
590
+ record_length: z.string().optional().describe("How long the record already is, if known"),
591
+ },
592
+ },
593
+ ({ sharpe, frequency, benchmark, record_length }) => ({
594
+ messages: [{
595
+ role: "user",
596
+ content: {
597
+ type: "text",
598
+ text: [
599
+ `How long a track record does an annualized Sharpe of ${sharpe} on ${frequency} returns need to be believably above ${benchmark ?? "0"}?`,
600
+ "Call validate_track_record with periods_per_year for that frequency (252 daily, 52 weekly, 12 monthly). Ask me for the skewness and kurtosis of the returns; if I do not know them, run it for Normal returns (skew 0, kurtosis 3) and say that fat tails lengthen the answer.",
601
+ record_length ? `The record already runs ${record_length}; convert it to observations and include it, and tell me whether it is long enough.` : "",
602
+ "State that the answer is about sample uncertainty and the shape of the returns, not a forecast.",
603
+ ].filter(Boolean).join("\n"),
604
+ },
605
+ }],
606
+ }),
607
+ );
608
+ }
609
+
610
+ // Reference documents a client can read: the boundary language every result carries, and the
611
+ // sources and independent checks behind each validator.
612
+ export const RESOURCE_TEXT = Object.freeze({
613
+ limits: [
614
+ "# What a canlicapital.com validation result does not establish",
615
+ "",
616
+ ...Object.values(LIMITS_SENTENCES).map((s) => `- ${s}`),
617
+ ].join("\n"),
618
+ sources: [
619
+ "# Sources and independent checks",
620
+ "",
621
+ "- Deflated Sharpe ratio: Bailey and López de Prado, \"The Deflated Sharpe Ratio\", Journal of Portfolio Management, 2014. Reproduces the paper's worked example (pages 9 and 10) to its four printed decimals, checked in CI.",
622
+ "- Minimum track record length and probabilistic Sharpe against a benchmark: Bailey and López de Prado, \"The Sharpe Ratio Efficient Frontier\", Journal of Risk, 2012. Reproduces the paper's worked examples (page 11), checked in CI.",
623
+ "- Minimum backtest length: Bailey, Borwein, López de Prado and Zhu, \"Pseudo-Mathematics and Financial Charlatanism\", Notices of the American Mathematical Society, 2014. Reproduces the paper's statements exactly (the best of 10 trials at 1.57; 5 years allow at most 45 trials, 2 years at most 7), checked in CI.",
624
+ "- Haircut Sharpe ratio: Harvey and Liu, \"Backtesting\", Journal of Portfolio Management, 2015. Agrees with the authors' own Haircut_SR.m on every deterministic (Bonferroni) output to 1e-9; Holm and BHY agree with R's p.adjust; the Student t with R's pt and qt. Checked in CI.",
625
+ "- Luck-equivalent trials: Canli Capital's statistic, built from the Student t null of the Sharpe's t-statistic, the Sidak best-of-N probability and the expected maximum of Bailey, Borwein, López de Prado and Zhu (2014). Calibrated by Monte Carlo in CI: correct size for normal and Student t4 returns; too generous for negatively skewed returns (with 252 observations, a nominal 5 percent test rejected 10.8 percent of skill-less searches at skew -1.3).",
626
+ "- Probability of backtest overfitting by CSCV: Bailey, Borwein, López de Prado and Zhu, \"The Probability of Backtest Overfitting\", Journal of Computational Finance, 2017. Agrees with the CRAN package pbo on PBO and on every logit, after its documented rank convention, checked in CI.",
627
+ "- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
628
+ ].join("\n"),
629
+ });
630
+
631
+ export function registerResources(server) {
632
+ server.registerResource(
633
+ "limits",
634
+ "canli://limits",
635
+ { title: "What a result does not establish", description: "The boundary sentences every validation result carries.", mimeType: "text/markdown" },
636
+ (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.limits }] }),
637
+ );
638
+ server.registerResource(
639
+ "sources",
640
+ "canli://sources",
641
+ { title: "Sources and independent checks", description: "The papers behind each validator and how each is checked against them.", mimeType: "text/markdown" },
642
+ (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.sources }] }),
643
+ );
644
+ }
645
+
646
+ // Everything the server exposes: the npm package and the hosted endpoint both call this.
647
+ export function registerAll(server, session) {
648
+ registerTools(server, session);
649
+ registerPrompts(server);
650
+ registerResources(server);
651
+ }
652
+
314
653
  export function createServer(session = createSession()) {
315
654
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
316
- registerTools(server, session);
655
+ registerAll(server, session);
317
656
  return server;
318
657
  }
319
658