canli-validation-mcp 0.3.1 → 0.5.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,16 +9,21 @@
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";
12
14
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
15
  import {
14
16
  breadthInput,
17
+ trackRecordInput,
15
18
  companyHistoryInput,
19
+ companyHistoryToolShape,
16
20
  deflatedSharpeInput,
17
21
  deflatedSharpeToolShape,
18
22
  emptyInput,
19
23
  getKeyInput,
20
24
  getReceiptInput,
21
25
  overfittingInput,
26
+ LIMITS_SENTENCES,
22
27
  paperEvidenceInput,
23
28
  TOOL_DESCRIPTIONS,
24
29
  } from "./schemas.mjs";
@@ -46,7 +51,14 @@ export function configuredKey(value) {
46
51
  return trimmed === "" || /^\$\{[^}]*\}$/.test(trimmed) ? undefined : trimmed;
47
52
  }
48
53
 
49
- export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted } = {}) {
54
+ // CANLI_LOCAL=1 (or "true") turns on private local mode: the validators run on this machine and
55
+ // nothing about the submitted series leaves it. An empty or unsubstituted value is off.
56
+ export function configuredLocal(value) {
57
+ const v = configuredKey(value);
58
+ return v === "1" || v?.toLowerCase() === "true";
59
+ }
60
+
61
+ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted, local } = {}) {
50
62
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
51
63
  return {
52
64
  base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
@@ -57,6 +69,7 @@ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIM
57
69
  // Set by the hosted endpoint (api/mcp.js): which key a request runs under, so get_key can say
58
70
  // so instead of issuing a key the next stateless request would never see.
59
71
  hosted: hosted ?? undefined,
72
+ local: local ?? configuredLocal(process.env.CANLI_LOCAL),
60
73
  };
61
74
  }
62
75
 
@@ -89,8 +102,11 @@ async function callApi(session, { path, method = "GET", body }) {
89
102
  // never reads; every field, boundary sentence and provenance value is kept. Measured on the live
90
103
  // Apple StockholdersEquity record (README, "Compact context"): 2,214 -> 1,560 tokens minified,
91
104
  // 1,056 with the columnar history below.
105
+ // Every result carries the envelope twice: as text, which every client shows, and as structured
106
+ // content, which a client can read field by field. The two are the same object.
92
107
  const asText = (envelope, failed = false) => ({
93
108
  content: [{ type: "text", text: JSON.stringify(envelope) }],
109
+ ...(envelope && typeof envelope === "object" && !Array.isArray(envelope) ? { structuredContent: envelope } : {}),
94
110
  ...(failed ? { isError: true } : {}),
95
111
  });
96
112
 
@@ -111,6 +127,15 @@ export function columnarObservations(observations) {
111
127
 
112
128
  export async function toolGetKey(session, args) {
113
129
  const { label } = parseOrThrow(getKeyInput, args, "get_key");
130
+ if (session.local) {
131
+ // Local mode computes the validators on this machine; issuing a key would be a network call
132
+ // the user turned local mode on to avoid, and would spend the per-client issuance quota.
133
+ return asText({
134
+ note: "Local mode is on: the validators run on this machine and need no key. No key was issued and no request was sent.",
135
+ key_source: "local",
136
+ key_present: false,
137
+ });
138
+ }
114
139
  if (session.hosted) {
115
140
  // The hosted endpoint is stateless: a key issued here would be gone by the next request, and
116
141
  // every hosted caller shares the platform's egress address and so its per-client issuance quota.
@@ -146,28 +171,39 @@ export async function toolValidateDeflatedSharpe(session, args) {
146
171
  "or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
147
172
  );
148
173
  }
174
+ if (session.local) { const local = computeLocally("validate_deflated_sharpe", parsed.data); return asText(local.envelope, local.failed); }
149
175
  const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
150
176
  return asText(response.envelope, response.failed);
151
177
  }
152
178
 
153
179
  export async function toolValidateOverfitting(session, args) {
154
180
  const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
181
+ if (session.local) { const local = computeLocally("validate_overfitting", body); return asText(local.envelope, local.failed); }
155
182
  const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
156
183
  return asText(response.envelope, response.failed);
157
184
  }
158
185
 
159
186
  export async function toolValidatePaperEvidence(session, args) {
160
187
  const body = parseOrThrow(paperEvidenceInput, args, "validate_paper_evidence");
188
+ if (session.local) { const local = computeLocally("validate_paper_evidence", body); return asText(local.envelope, local.failed); }
161
189
  const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
162
190
  return asText(response.envelope, response.failed);
163
191
  }
164
192
 
165
193
  export async function toolValidateBreadth(session, args) {
166
194
  const body = parseOrThrow(breadthInput, args, "validate_breadth");
195
+ if (session.local) { const local = computeLocally("validate_breadth", body); return asText(local.envelope, local.failed); }
167
196
  const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
168
197
  return asText(response.envelope, response.failed);
169
198
  }
170
199
 
200
+ export async function toolValidateTrackRecord(session, args) {
201
+ const body = parseOrThrow(trackRecordInput, args, "validate_track_record");
202
+ if (session.local) { const local = computeLocally("validate_track_record", body); return asText(local.envelope, local.failed); }
203
+ const response = await callApi(session, { path: "/api/v1/validate/track-record", method: "POST", body });
204
+ return asText(response.envelope, response.failed);
205
+ }
206
+
171
207
  export async function toolGetReceipt(session, args) {
172
208
  const { id } = parseOrThrow(getReceiptInput, args, "get_receipt");
173
209
  const response = await callApi(session, { path: `/api/v1/receipts/${id}` });
@@ -183,9 +219,37 @@ export async function toolServiceStatus(session) {
183
219
  // field that says where a value came from (accession, form, filed date, unit, source hash) and
184
220
  // the record's own claim_boundary and policy sentences, so an agent cannot quote a number
185
221
  // without its provenance or boundary.
222
+ // Ticker -> CIK through the published index of companies in the release, fetched once per session.
223
+ async function resolveTicker(session, ticker) {
224
+ if (!session.tickerIndex) {
225
+ const signal = AbortSignal.timeout(session.timeoutMs);
226
+ let res;
227
+ try {
228
+ res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
229
+ } catch {
230
+ throw new Error(signal.aborted ? "company_financial_history: the ticker index request timed out" : "company_financial_history: could not reach the ticker index");
231
+ }
232
+ if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
233
+ session.tickerIndex = await res.json();
234
+ }
235
+ const key = ticker.toUpperCase();
236
+ const cik = session.tickerIndex.tickers?.[key];
237
+ if (!cik) {
238
+ 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` } };
239
+ }
240
+ return { cik };
241
+ }
242
+
186
243
  export async function toolCompanyFinancialHistory(session, args) {
187
- const { cik, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
188
- const id = cik.padStart(10, "0");
244
+ const { cik, ticker, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
245
+ let id;
246
+ if (ticker !== undefined) {
247
+ const resolved = await resolveTicker(session, ticker);
248
+ if (resolved.error) return asText(resolved.error, true);
249
+ id = resolved.cik;
250
+ } else {
251
+ id = cik.padStart(10, "0");
252
+ }
189
253
  const path = `/company-data/${id}.json`;
190
254
  const signal = AbortSignal.timeout(session.timeoutMs);
191
255
  let res, text;
@@ -282,6 +346,11 @@ export function registerTools(server, session) {
282
346
  { title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
283
347
  (args) => toolValidateBreadth(session, args),
284
348
  );
349
+ server.registerTool(
350
+ "validate_track_record",
351
+ { title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
352
+ (args) => toolValidateTrackRecord(session, args),
353
+ );
285
354
  server.registerTool(
286
355
  "get_receipt",
287
356
  { title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
@@ -294,14 +363,113 @@ export function registerTools(server, session) {
294
363
  );
295
364
  server.registerTool(
296
365
  "company_financial_history",
297
- { title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryInput },
366
+ { title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape },
298
367
  (args) => toolCompanyFinancialHistory(session, args),
299
368
  );
300
369
  }
301
370
 
371
+ // Guided workflows a user can pick in a client that shows MCP prompts. Arguments are strings, as
372
+ // the protocol defines them; the prompt only writes the instructions, the tools do the work.
373
+ export function registerPrompts(server) {
374
+ server.registerPrompt(
375
+ "validate_backtest",
376
+ {
377
+ title: "Validate a backtest before trusting it",
378
+ description: "Walk through the deflated Sharpe, overfitting and track-record checks for one strategy, and report what the numbers do not establish.",
379
+ argsSchema: {
380
+ strategy: z.string().describe("What the strategy is and what data it was backtested on"),
381
+ variants_tried: z.string().optional().describe("How many variants, parameter sets or ideas were tried before this one"),
382
+ },
383
+ },
384
+ ({ strategy, variants_tried }) => ({
385
+ messages: [{
386
+ role: "user",
387
+ content: {
388
+ type: "text",
389
+ text: [
390
+ `Validate this backtest before I trust it: ${strategy}.`,
391
+ 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.",
392
+ "1. validate_deflated_sharpe with the return series (or the seven contract inputs), counting every variant tried as a trial.",
393
+ "2. If I can share the returns of every variant, validate_overfitting on that matrix.",
394
+ "3. validate_track_record for how long a live record must run before this Sharpe clears a benchmark I care about.",
395
+ "Report each number with the limits sentences its result carries, say plainly what they do not establish, and give me each receipt id.",
396
+ ].join("\n"),
397
+ },
398
+ }],
399
+ }),
400
+ );
401
+ server.registerPrompt(
402
+ "track_record_needed",
403
+ {
404
+ title: "How long a track record do I need?",
405
+ 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.",
406
+ argsSchema: {
407
+ sharpe: z.string().describe("The observed annualized Sharpe ratio"),
408
+ frequency: z.string().describe("How often returns are measured: daily, weekly or monthly"),
409
+ benchmark: z.string().optional().describe("The annualized Sharpe it must beat; 0 if not given"),
410
+ record_length: z.string().optional().describe("How long the record already is, if known"),
411
+ },
412
+ },
413
+ ({ sharpe, frequency, benchmark, record_length }) => ({
414
+ messages: [{
415
+ role: "user",
416
+ content: {
417
+ type: "text",
418
+ text: [
419
+ `How long a track record does an annualized Sharpe of ${sharpe} on ${frequency} returns need to be believably above ${benchmark ?? "0"}?`,
420
+ "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.",
421
+ record_length ? `The record already runs ${record_length}; convert it to observations and include it, and tell me whether it is long enough.` : "",
422
+ "State that the answer is about sample uncertainty and the shape of the returns, not a forecast.",
423
+ ].filter(Boolean).join("\n"),
424
+ },
425
+ }],
426
+ }),
427
+ );
428
+ }
429
+
430
+ // Reference documents a client can read: the boundary language every result carries, and the
431
+ // sources and independent checks behind each validator.
432
+ export const RESOURCE_TEXT = Object.freeze({
433
+ limits: [
434
+ "# What a canlicapital.com validation result does not establish",
435
+ "",
436
+ ...Object.values(LIMITS_SENTENCES).map((s) => `- ${s}`),
437
+ ].join("\n"),
438
+ sources: [
439
+ "# Sources and independent checks",
440
+ "",
441
+ "- 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.",
442
+ "- 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.",
443
+ "- 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.",
444
+ "- Source code: https://github.com/arhancanli/canli-validation-mcp and https://github.com/arhancanli/canlicapital",
445
+ ].join("\n"),
446
+ });
447
+
448
+ export function registerResources(server) {
449
+ server.registerResource(
450
+ "limits",
451
+ "canli://limits",
452
+ { title: "What a result does not establish", description: "The boundary sentences every validation result carries.", mimeType: "text/markdown" },
453
+ (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.limits }] }),
454
+ );
455
+ server.registerResource(
456
+ "sources",
457
+ "canli://sources",
458
+ { title: "Sources and independent checks", description: "The papers behind each validator and how each is checked against them.", mimeType: "text/markdown" },
459
+ (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: RESOURCE_TEXT.sources }] }),
460
+ );
461
+ }
462
+
463
+ // Everything the server exposes: the npm package and the hosted endpoint both call this.
464
+ export function registerAll(server, session) {
465
+ registerTools(server, session);
466
+ registerPrompts(server);
467
+ registerResources(server);
468
+ }
469
+
302
470
  export function createServer(session = createSession()) {
303
471
  const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
304
- registerTools(server, session);
472
+ registerAll(server, session);
305
473
  return server;
306
474
  }
307
475