canli-validation-mcp 0.7.1 → 0.8.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/README.md +4 -2
- package/package.json +5 -4
- package/src/schemas.mjs +58 -14
- package/src/series-file.mjs +51 -13
- package/src/server.mjs +34 -40
package/README.md
CHANGED
|
@@ -378,6 +378,8 @@ HTTP stub. It neither publishes a package nor issues a production API key.
|
|
|
378
378
|
|
|
379
379
|
## Dependencies
|
|
380
380
|
|
|
381
|
-
Only `@modelcontextprotocol/
|
|
382
|
-
|
|
381
|
+
Only `@modelcontextprotocol/server` (pinned exact; the MCP SDK's server package, which itself
|
|
382
|
+
depends only on `@modelcontextprotocol/core` and `zod`) and `zod` (pinned exact): four packages in
|
|
383
|
+
the installed tree, where 0.7 and earlier pulled in about 95 through `@modelcontextprotocol/sdk`.
|
|
384
|
+
No other runtime dependency is added, and nothing in this package touches the site's root `package.json`,
|
|
383
385
|
`.vercelignore`, `api/`, `scripts/`, `js/`, or `public/`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-validation-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Check whether a backtest is real: deflated Sharpe, CSCV probability of backtest overfitting, minimum track record and backtest length, haircut Sharpe and luck-equivalent trials, as an MCP server. Local mode, hosted endpoint, signed receipts.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
"README.md"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
-
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"test": "node --test test/*.test.mjs test/*.test.js",
|
|
20
20
|
"test:package": "node test/package-smoke.mjs"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@modelcontextprotocol/
|
|
23
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
24
24
|
"zod": "4.6.5"
|
|
25
25
|
},
|
|
26
26
|
"mcpName": "io.github.arhancanli/canli-validation-mcp",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"url": "https://github.com/arhancanli/canlicapital/issues"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@modelcontextprotocol/client": "2.1.0",
|
|
37
38
|
"fast-check": "4.10.2"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/schemas.mjs
CHANGED
|
@@ -47,7 +47,7 @@ export const FIELD_DESCRIPTIONS = Object.freeze({
|
|
|
47
47
|
autocorrelation: "First-order autocorrelation of the returns, -1 to 1; default 0. Corrects the annualized Sharpe as Lo (2002).",
|
|
48
48
|
other_sharpes: "Annualized Sharpe ratios of the other tests, over the same observations; adds the Holm and BHY haircuts.",
|
|
49
49
|
lt_trials: "Independent trials tried; adds the chance that the best of them reached this Sharpe by luck.",
|
|
50
|
-
|
|
50
|
+
lt_skew: "Skewness of the returns; below -0.5 the reading warns that the counts are too generous.",
|
|
51
51
|
variants: "Optional returns of every variant tried, this one included, as fractions: one row per period, one column per variant. Adds the overfitting check.",
|
|
52
52
|
returns_file: "Path to a CSV or JSON file of the returns on the machine running this server, instead of returns. Not available on the hosted endpoint.",
|
|
53
53
|
returns_column: "Header name or 1-based position of the returns column when returns_file has several numeric columns.",
|
|
@@ -108,7 +108,7 @@ export const deflatedSharpeToolShape = z
|
|
|
108
108
|
|
|
109
109
|
export const overfittingInput = z
|
|
110
110
|
.object({
|
|
111
|
-
matrix: z.array(z.array(z.number())).min(2).max(20000).describe(d.matrix),
|
|
111
|
+
matrix: z.array(z.array(z.number()).max(200)).min(2).max(20000).describe(d.matrix),
|
|
112
112
|
n_splits: z.number().int().positive().optional().describe(d.n_splits),
|
|
113
113
|
max_combinations: z.number().int().positive().max(2000).optional().describe(d.max_combinations),
|
|
114
114
|
seed: z.number().optional().describe(d.seed),
|
|
@@ -192,7 +192,7 @@ export const luckTrialsInput = z
|
|
|
192
192
|
periods_per_year: z.number().min(1).max(10000).describe(d.periods_per_year),
|
|
193
193
|
observations: z.number().int().min(3).max(1000000).describe(d.hc_observations),
|
|
194
194
|
effective_independent_trials: z.number().int().min(1).max(1000000000).optional().describe(d.lt_trials),
|
|
195
|
-
skew: z.number().min(-100).max(100).optional().describe(d.
|
|
195
|
+
skew: z.number().min(-100).max(100).optional().describe(d.lt_skew),
|
|
196
196
|
autocorrelation: z.number().gt(-1).lt(1).optional().describe(d.autocorrelation),
|
|
197
197
|
})
|
|
198
198
|
.strict();
|
|
@@ -212,7 +212,7 @@ export const auditBacktestToolShape = z
|
|
|
212
212
|
cross_trial_sharpe_sd_annualized: z.number().min(0).max(10).describe(d.cross_trial_sharpe_sd_annualized),
|
|
213
213
|
benchmark_sharpe_annualized: z.number().min(-10).max(10).optional().describe(d.benchmark_sharpe_annualized),
|
|
214
214
|
confidence: z.number().gt(0).lt(1).optional().describe(d.confidence),
|
|
215
|
-
variants: z.array(z.array(z.number())).min(2).max(20000).optional().describe(d.variants),
|
|
215
|
+
variants: z.array(z.array(z.number()).max(200)).min(2).max(20000).optional().describe(d.variants),
|
|
216
216
|
variants_file: z.string().min(1).max(4096).optional().describe(d.variants_file),
|
|
217
217
|
n_splits: z.number().int().positive().optional().describe(d.n_splits),
|
|
218
218
|
})
|
|
@@ -303,17 +303,61 @@ export const REGISTRY_DESCRIPTION_MAX = 100;
|
|
|
303
303
|
// calls the tool, not only inside the returned envelope.
|
|
304
304
|
export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
305
305
|
get_key: `Issue a free canlicapital.com validation key (POST /api/v1/keys) and hold it in memory for this session. Only needed before a validation when neither CANLI_KEY nor local mode is set; the read tools (get_receipt, service_status, company_financial_history) never need a key. ${LIMITS_SENTENCES.quotas}`,
|
|
306
|
-
validate_deflated_sharpe: `
|
|
307
|
-
audit_backtest: `Audit one strategy's return series in one call: deflated Sharpe, the minimum track record length for its Sharpe to beat the benchmark, and, with every variant's returns, the probability of backtest overfitting. Point returns_file at the backtest's CSV or JSON rather than copying long series into the call. Each check is the matching validate_ tool's result with its own receipt, side by side; the audit does not grade the strategy. Uses one validation per check. ${LIMITS_SENTENCES.notAdmission}`,
|
|
308
|
-
validate_overfitting: `Probability (0 to 1)
|
|
309
|
-
validate_paper_evidence: `Whether a paper or simulated performance record meets canli.paper-evidence.v0, with each failure's JSON pointer. ${LIMITS_SENTENCES.scope}`,
|
|
310
|
-
validate_backtest_length: `Minimum backtest length, in years, before the best of N independent trials is not expected to reach a target Sharpe by luck, and with backtest_years, the most independent trials those years allow. Send effective_independent_trials, backtest_years, or both. ${LIMITS_SENTENCES.notAdmission}`,
|
|
311
|
-
validate_luck_trials: `How many skill-less strategies a search would have had to try for its best to reach this Sharpe by luck, and, with a trial count, the chance that it did. Calibrated by Monte Carlo. ${LIMITS_SENTENCES.notAdmission}`,
|
|
312
|
-
validate_haircut_sharpe: `Haircut Sharpe ratio for multiple testing (Harvey and Liu, 2015): the Sharpe a single test would have needed once the number of tests is counted, by Bonferroni and for independent tests, and with the other tests' Sharpe ratios by Holm and BHY. ${LIMITS_SENTENCES.notAdmission}`,
|
|
313
|
-
validate_track_record: `Minimum track record length, in observations and years, for an observed Sharpe to beat a benchmark at a confidence level, and with observations, the record's probabilistic Sharpe so far. ${LIMITS_SENTENCES.notAdmission}`,
|
|
314
|
-
validate_breadth: `Highest book Sharpe reachable by adding sleeves of this quality and correlation, the Sharpe at a sleeve count, and the sleeves a target needs. ${LIMITS_SENTENCES.scope}`,
|
|
306
|
+
validate_deflated_sharpe: `Deflated Sharpe ratio: the probability (0 to 1) that one selected strategy's Sharpe beats the best Sharpe that luck alone would give across the variants tried, with the probabilistic Sharpe and that luck benchmark. Use it for the strategy you kept after a search, when you know how many variants were tried and how their Sharpe ratios spread; send the seven statistics or a return series, not both. With every variant's returns use validate_overfitting; to state luck as a trial count use validate_luck_trials; for a multiple-testing haircut use validate_haircut_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
307
|
+
audit_backtest: `Audit one strategy's return series in one call: deflated Sharpe, the minimum track record length for its Sharpe to beat the benchmark, and, with every variant's returns, the probability of backtest overfitting. Point returns_file at the backtest's CSV or JSON rather than copying long series into the call. Each check is the matching validate_ tool's result with its own receipt, side by side; the audit does not grade the strategy. Prefer it to calling those validators one by one when you have one strategy's return series. Uses one validation per check. ${LIMITS_SENTENCES.notAdmission}`,
|
|
308
|
+
validate_overfitting: `Probability of backtest overfitting (0 to 1) by CSCV: how often the variant that is best in sample falls below the median out of sample. Use it when you have every variant's returns as a matrix (periods by variants); with summary statistics only, use validate_deflated_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
309
|
+
validate_paper_evidence: `Whether a paper or simulated performance record meets canli.paper-evidence.v0, with each failure's JSON pointer. Use it on a record document before publishing or relying on it: it checks structure and required disclosures, not whether the returns are good. ${LIMITS_SENTENCES.scope}`,
|
|
310
|
+
validate_backtest_length: `Minimum backtest length, in years, before the best of N independent trials is not expected to reach a target Sharpe by luck, and with backtest_years, the most independent trials those years allow. Send effective_independent_trials, backtest_years, or both. Use it before or while planning a search, to size the backtest for the number of trials; once a search has a result, use validate_deflated_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
311
|
+
validate_luck_trials: `How many skill-less strategies a search would have had to try for its best to reach this Sharpe by luck, and, with a trial count, the chance that it did. Calibrated by Monte Carlo. Use it to state luck as a count ("as good as the best of N random tries") or to test a result against the trials actually run; for a probability that the Sharpe is real, use validate_deflated_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
312
|
+
validate_haircut_sharpe: `Haircut Sharpe ratio for multiple testing (Harvey and Liu, 2015): the Sharpe a single test would have needed once the number of tests is counted, by Bonferroni and for independent tests, and with the other tests' Sharpe ratios by Holm and BHY. Use it to report a Sharpe adjusted for the number of tests, as finance papers do; for a probability that the Sharpe is real, use validate_deflated_sharpe. ${LIMITS_SENTENCES.notAdmission}`,
|
|
313
|
+
validate_track_record: `Minimum track record length, in observations and years, for an observed Sharpe to beat a benchmark at a confidence level, and with observations, the record's probabilistic Sharpe so far. Use it for a live or paper record: how long it must run before its Sharpe is evidence. To size a backtest against the number of trials, use validate_backtest_length. ${LIMITS_SENTENCES.notAdmission}`,
|
|
314
|
+
validate_breadth: `Highest book Sharpe reachable by adding sleeves of this quality and correlation, the Sharpe at a sleeve count, and the sleeves a target needs. Use it for portfolio construction, to see what adding strategies can and cannot do; it validates no single strategy. ${LIMITS_SENTENCES.scope}`,
|
|
315
315
|
verify_receipt: `Check a validation receipt's Ed25519 signature offline against the canlicapital.com public key bundled in this package, that its output hashes to its output_sha256, and that its content hashes to its id. Send an id to fetch the receipt first, or a receipt already fetched. ${LIMITS_SENTENCES.unsigned}`,
|
|
316
|
-
get_receipt: `Fetch a stored verdict by receipt id (GET /api/v1/receipts/{id}) to re-
|
|
316
|
+
get_receipt: `Fetch a stored verdict by receipt id (GET /api/v1/receipts/{id}) to re-read an earlier result. No key. To check that the receipt is genuine, use verify_receipt. ${LIMITS_SENTENCES.unsigned}`,
|
|
317
317
|
service_status: `Whether the validation API is up, with quota constants (GET /api/v1/validate/status); check after a timeout before resubmitting. No key. ${LIMITS_SENTENCES.scope}`,
|
|
318
318
|
company_financial_history: `SEC-reported financial history for one company from the canlicapital.com company reference (GET /company-data/{cik}.json), by cik or by ticker (resolved through GET /api/v1/company-tickers.json, companies in the release only). Without a concept it lists the available histories; with one it returns observations, newest first, each with its filing accession, form, filed date and unit, plus the SHA-256 of the original SEC response. No key required. ${COMPANY_REFERENCE_BOUNDARY}`,
|
|
319
319
|
});
|
|
320
|
+
|
|
321
|
+
// Output schemas (0.8.0). Published OPEN: extra fields always pass. A client validates a tool's
|
|
322
|
+
// structured result against the output schema it listed, so a closed schema turns every field a
|
|
323
|
+
// later version adds into a failed call (2026-09-26, the mcp-factory servers). Every field is
|
|
324
|
+
// optional because refusals and local results carry different subsets. They are kept terse: the
|
|
325
|
+
// tool list is re-sent to the model every turn, and one sentence per schema says what to read.
|
|
326
|
+
const refusal = z.looseObject({}).nullable().optional();
|
|
327
|
+
const sentences = z.array(z.string()).optional();
|
|
328
|
+
const loose = z.looseObject({}).optional();
|
|
329
|
+
|
|
330
|
+
export const validationOutput = z
|
|
331
|
+
.looseObject({
|
|
332
|
+
data: loose,
|
|
333
|
+
error: refusal,
|
|
334
|
+
limits: sentences,
|
|
335
|
+
receipt: z.looseObject({}).nullable().optional(),
|
|
336
|
+
computed: z.string().optional(),
|
|
337
|
+
note: z.string().optional(),
|
|
338
|
+
})
|
|
339
|
+
.describe("data.result holds the statistics (data.plain_reading states them); limits say what the result does not establish; receipt ({id, url}) is the signed record, null when none was stored; error ({code, message}) is set only on refusal.");
|
|
340
|
+
|
|
341
|
+
export const auditOutput = z
|
|
342
|
+
.looseObject({ readings: loose, checks: loose, not_run: loose, limits: sentences, note: z.string().optional(), error: refusal })
|
|
343
|
+
.describe("checks holds each check's full validate_ result by name; readings one sentence per check; not_run the checks skipped and why.");
|
|
344
|
+
|
|
345
|
+
export const keyOutput = z
|
|
346
|
+
.looseObject({ note: z.string().optional(), key_source: z.string().optional(), key_present: z.boolean().optional(), data: loose, error: refusal })
|
|
347
|
+
.describe("key_source says which key the session uses; data.key is set when a new key was issued.");
|
|
348
|
+
|
|
349
|
+
export const receiptOutput = z
|
|
350
|
+
.looseObject({ data: loose, error: refusal, limits: sentences })
|
|
351
|
+
.describe("data is the stored receipt: validator, input, output, their hashes, source hashes and signature.");
|
|
352
|
+
|
|
353
|
+
export const verifyReceiptOutput = z
|
|
354
|
+
.looseObject({ receipt_id: z.string().nullable().optional(), valid: z.boolean().optional(), checks: z.unknown().optional(), key_id: z.string().nullable().optional(), meaning: z.string().optional(), error: refusal })
|
|
355
|
+
.describe("valid is true only when the signature, output hash and content id all check out; checks lists each.");
|
|
356
|
+
|
|
357
|
+
export const statusOutput = z
|
|
358
|
+
.looseObject({ data: loose, limits: sentences, error: refusal })
|
|
359
|
+
.describe("data.store_reachable, data.quotas and, when available, data.usage for this key.");
|
|
360
|
+
|
|
361
|
+
export const companyHistoryOutput = z
|
|
362
|
+
.looseObject({ company: loose, claim_boundary: z.string().optional(), source: loose, histories: z.array(z.unknown()).optional(), history: loose, error: refusal })
|
|
363
|
+
.describe("Without a concept, histories lists what is available; with one, history holds the observations newest first as columns and rows, each with its filing. Values are as reported to the SEC.");
|
package/src/series-file.mjs
CHANGED
|
@@ -41,6 +41,26 @@ function readText(path) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const isNumber = (cell) => cell.trim() !== "" && Number.isFinite(Number(cell.trim()));
|
|
44
|
+
// Cells a spreadsheet or pandas writes for a missing value.
|
|
45
|
+
const isMissing = (cell) => /^(|nan|na|n\/a|#n\/a|null|none|-)$/i.test(cell.trim());
|
|
46
|
+
const DATE = /^(\d{4}[-/.]\d{1,2}[-/.]\d{1,2}|\d{1,2}[-/.]\d{1,2}[-/.]\d{2,4})([ T].*)?$/;
|
|
47
|
+
const kindOf = (cell) => (isNumber(cell) ? "number" : isMissing(cell) ? "missing" : DATE.test(cell.trim()) ? "date" : "text");
|
|
48
|
+
|
|
49
|
+
// The first row is a header when one of its cells differs in kind from the cells below it: a label
|
|
50
|
+
// above numbers or dates. A date above dates is data, so a file without a header keeps its first row.
|
|
51
|
+
function hasHeader(first, below) {
|
|
52
|
+
if (!first.some((cell) => !isNumber(cell))) return false;
|
|
53
|
+
if (!below.length) return true;
|
|
54
|
+
return first.some((cell, j) => {
|
|
55
|
+
if (isNumber(cell)) return false;
|
|
56
|
+
// Missing values say nothing about what a column holds, so they do not vote.
|
|
57
|
+
const kinds = below.map((row) => kindOf(row[j] ?? "")).filter((k) => k !== "missing");
|
|
58
|
+
if (!kinds.length) return true;
|
|
59
|
+
const count = (k) => kinds.filter((x) => x === k).length;
|
|
60
|
+
const common = [...new Set(kinds)].sort((a, b) => count(b) - count(a))[0];
|
|
61
|
+
return kindOf(cell) !== common;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
44
64
|
|
|
45
65
|
// A JSON array (of numbers, or of arrays of numbers), or delimited text: comma, semicolon or tab
|
|
46
66
|
// separated, one row per line, with an optional header row naming the columns.
|
|
@@ -65,7 +85,7 @@ function parseTable(text, path) {
|
|
|
65
85
|
const delimiter = [",", ";", "\t"].find((d) => lines[0].includes(d)) ?? ",";
|
|
66
86
|
const split = (line) => line.split(delimiter).map((cell) => cell.trim());
|
|
67
87
|
const first = split(lines[0]);
|
|
68
|
-
const header = first.
|
|
88
|
+
const header = hasHeader(first, lines.slice(1, 6).map(split)) ? first : null;
|
|
69
89
|
const body = header ? lines.slice(1) : lines;
|
|
70
90
|
const width = (header ?? first).length;
|
|
71
91
|
const rows = body.map((line, i) => {
|
|
@@ -85,40 +105,58 @@ function isRowCounter(name, values) {
|
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
// Numeric columns only: a date or label column is dropped, never parsed; a row counter is skipped.
|
|
108
|
+
// A column of numbers with some empty or NaN cells is not dropped: it is reported as incomplete, so
|
|
109
|
+
// a blank cell can never make the reader pick a different column (a benchmark) in its place.
|
|
88
110
|
function numericColumns({ header, rows }) {
|
|
89
111
|
const width = rows[0]?.length ?? 0;
|
|
90
112
|
const columns = [];
|
|
113
|
+
const incomplete = [];
|
|
91
114
|
const skipped = [];
|
|
115
|
+
const offset = header ? 2 : 1; // file line of the first data row
|
|
92
116
|
for (let j = 0; j < width; j += 1) {
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
const cells = rows.map((row) => (typeof row[j] === "number" ? String(row[j]) : String(row[j])));
|
|
118
|
+
const numeric = cells.filter(isNumber).length;
|
|
119
|
+
if (numeric === cells.length) {
|
|
120
|
+
const values = cells.map(Number);
|
|
95
121
|
if (isRowCounter(header ? header[j] : null, values)) skipped.push(j + 1);
|
|
96
122
|
else columns.push({ index: j, name: header ? header[j] : String(j + 1), values });
|
|
123
|
+
} else if (numeric > 0 && numeric * 2 >= cells.length && cells.every((c) => isNumber(c) || isMissing(c))) {
|
|
124
|
+
const gaps = cells.map((c, i) => (isNumber(c) ? null : i + offset)).filter((i) => i !== null);
|
|
125
|
+
incomplete.push({ index: j, name: header ? header[j] : String(j + 1), gaps });
|
|
97
126
|
}
|
|
98
127
|
}
|
|
99
|
-
return { columns, skipped };
|
|
128
|
+
return { columns, incomplete, skipped };
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
const lineList = (gaps) => (gaps.length > 5 ? `${gaps.slice(0, 5).join(", ")} and ${gaps.length - 5} more` : gaps.join(", "));
|
|
132
|
+
const incompleteError = (path, c) => new Error(`${path}: column ${c.index + 1} holds numbers but is empty or not a number on line${c.gaps.length > 1 ? "s" : ""} ${lineList(c.gaps)}; fill or remove those rows, or pick another column with returns_column`);
|
|
133
|
+
|
|
102
134
|
// One series. With several numeric columns, `column` (a header name or a 1-based index) picks it.
|
|
103
|
-
// Returns the values and the positions of any row-counter
|
|
135
|
+
// Returns the values, the 1-based position of the column read, and the positions of any row-counter
|
|
136
|
+
// columns skipped. Without `column`, any incomplete numeric column is refused, naming its lines.
|
|
104
137
|
export function readSeriesFile(path, column) {
|
|
105
138
|
const table = parseTable(readText(path), path);
|
|
106
139
|
if (!table.rows.length) throw new Error(`${path}: no data rows`);
|
|
107
|
-
const { columns, skipped } = numericColumns(table);
|
|
108
|
-
|
|
140
|
+
const { columns, incomplete, skipped } = numericColumns(table);
|
|
141
|
+
const match = (list) => list.find((c) => c.name === String(column)) ?? list.find((c) => String(c.index + 1) === String(column));
|
|
109
142
|
if (column === undefined) {
|
|
143
|
+
if (incomplete.length) throw incompleteError(path, incomplete[0]);
|
|
144
|
+
if (!columns.length) throw new Error(`${path}: no column holds only numbers`);
|
|
110
145
|
if (columns.length > 1) throw new Error(`${path}: ${columns.length} numeric columns (positions ${columns.map((c) => c.index + 1).join(", ")}); pick one with returns_column, by header name or position`);
|
|
111
|
-
return { values: columns[0].values, skipped };
|
|
146
|
+
return { values: columns[0].values, column: columns[0].index + 1, skipped };
|
|
112
147
|
}
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
148
|
+
const gappy = match(incomplete);
|
|
149
|
+
if (gappy) throw incompleteError(path, gappy);
|
|
150
|
+
const picked = match(columns);
|
|
151
|
+
if (!picked) throw new Error(`${path}: returns_column matches no numeric column; numeric columns are at positions ${columns.map((c) => c.index + 1).join(", ") || "none"}`);
|
|
152
|
+
return { values: picked.values, column: picked.index + 1, skipped };
|
|
116
153
|
}
|
|
117
154
|
|
|
118
|
-
// Every numeric column is one variant; rows are periods.
|
|
155
|
+
// Every numeric column is one variant; rows are periods. An incomplete column is refused.
|
|
119
156
|
export function readMatrixFile(path) {
|
|
120
157
|
const table = parseTable(readText(path), path);
|
|
121
|
-
const { columns, skipped } = numericColumns(table);
|
|
158
|
+
const { columns, incomplete, skipped } = numericColumns(table);
|
|
159
|
+
if (incomplete.length) throw incompleteError(path, incomplete[0]);
|
|
122
160
|
if (columns.length < 2) throw new Error(`${path}: needs at least 2 numeric columns, one per variant`);
|
|
123
161
|
return { matrix: table.rows.map((_, i) => columns.map((c) => c.values[i])), skipped };
|
|
124
162
|
}
|
package/src/server.mjs
CHANGED
|
@@ -8,33 +8,13 @@
|
|
|
8
8
|
// CANLI_KEY; when CANLI_KEY is set, get_key does not call the network.
|
|
9
9
|
import { readFileSync, realpathSync } from "node:fs";
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
|
-
import { McpServer } from "@modelcontextprotocol/
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { computeLocally } from "./local.mjs";
|
|
14
14
|
import { readMatrixFile, readSeriesFile } from "./series-file.mjs";
|
|
15
15
|
import { verifyReceipt } from "./local/js/receipt-statement.js";
|
|
16
|
-
import { StdioServerTransport } from "@modelcontextprotocol/
|
|
17
|
-
import {
|
|
18
|
-
breadthInput,
|
|
19
|
-
trackRecordInput,
|
|
20
|
-
auditBacktestInput,
|
|
21
|
-
verifyReceiptToolShape,
|
|
22
|
-
backtestLengthInput,
|
|
23
|
-
haircutSharpeInput,
|
|
24
|
-
luckTrialsInput,
|
|
25
|
-
auditBacktestToolShape,
|
|
26
|
-
companyHistoryInput,
|
|
27
|
-
companyHistoryToolShape,
|
|
28
|
-
deflatedSharpeInput,
|
|
29
|
-
deflatedSharpeToolShape,
|
|
30
|
-
emptyInput,
|
|
31
|
-
getKeyInput,
|
|
32
|
-
getReceiptInput,
|
|
33
|
-
overfittingInput,
|
|
34
|
-
LIMITS_SENTENCES,
|
|
35
|
-
paperEvidenceInput,
|
|
36
|
-
TOOL_DESCRIPTIONS,
|
|
37
|
-
} from "./schemas.mjs";
|
|
16
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
17
|
+
import { breadthInput, trackRecordInput, auditBacktestInput, verifyReceiptToolShape, backtestLengthInput, haircutSharpeInput, luckTrialsInput, auditBacktestToolShape, companyHistoryInput, companyHistoryToolShape, deflatedSharpeInput, deflatedSharpeToolShape, emptyInput, getKeyInput, getReceiptInput, overfittingInput, LIMITS_SENTENCES, paperEvidenceInput, TOOL_DESCRIPTIONS, validationOutput, auditOutput, keyOutput, receiptOutput, verifyReceiptOutput, statusOutput, companyHistoryOutput } from "./schemas.mjs";
|
|
38
18
|
|
|
39
19
|
export const DEFAULT_BASE = "https://canlicapital.com";
|
|
40
20
|
export const SERVER_NAME = "canlicapital-validation-mcp";
|
|
@@ -369,7 +349,7 @@ export async function toolAuditBacktest(session, args) {
|
|
|
369
349
|
checks,
|
|
370
350
|
...(input.returns_file || input.variants_file
|
|
371
351
|
? { source: {
|
|
372
|
-
...(returnsRead ? { returns_file: input.returns_file, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
|
|
352
|
+
...(returnsRead ? { returns_file: input.returns_file, returns_column_position: returnsRead.column, observations: returns.length, ...(returnsRead.skipped.length ? { skipped_row_counter_columns: returnsRead.skipped } : {}) } : {}),
|
|
373
353
|
...(variantsRead ? { variants_file: input.variants_file, variants: variants[0].length, periods: variants.length, ...(variantsRead.skipped.length ? { skipped_variant_row_counter_columns: variantsRead.skipped } : {}) } : {}),
|
|
374
354
|
} }
|
|
375
355
|
: {}),
|
|
@@ -392,7 +372,21 @@ export async function toolVerifyReceipt(session, args) {
|
|
|
392
372
|
data = response.envelope?.data;
|
|
393
373
|
}
|
|
394
374
|
const endpoint = String(data?.endpoint ?? "").replace(/^\/api\/v1\//, "");
|
|
395
|
-
|
|
375
|
+
// A receipt missing a field (output, bindings, input_sha256) cannot be hashed, and that is a
|
|
376
|
+
// failed check, not a tool error: the answer is still "not issued for this content".
|
|
377
|
+
let result;
|
|
378
|
+
try {
|
|
379
|
+
result = verifyReceipt({ ...data, endpoint }, session.receiptKeys ?? RECEIPT_KEYS.keys);
|
|
380
|
+
} catch {
|
|
381
|
+
const missing = ["id", "endpoint", "input_sha256", "output", "bindings", "signature"].filter((k) => data?.[k] === undefined);
|
|
382
|
+
return asText({
|
|
383
|
+
receipt_id: data?.id ?? null,
|
|
384
|
+
valid: false,
|
|
385
|
+
checks: { well_formed: false, id_matches_content: false, signature_valid: false, key_published: false },
|
|
386
|
+
key_id: data?.signature?.key_id ?? null,
|
|
387
|
+
meaning: `The receipt is not well formed${missing.length ? `: it has no ${missing.join(", ")}` : ""}, so it cannot be checked. Send the whole receipt object as get_receipt or the validation result returned it, or send its id.`,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
396
390
|
return asText({
|
|
397
391
|
receipt_id: data?.id ?? null,
|
|
398
392
|
valid: result.valid,
|
|
@@ -428,7 +422,7 @@ async function resolveTicker(session, ticker) {
|
|
|
428
422
|
try {
|
|
429
423
|
res = await session.fetchImpl(`${session.base}/api/v1/company-tickers.json`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
|
|
430
424
|
} catch {
|
|
431
|
-
throw new Error(signal.aborted ? "
|
|
425
|
+
throw new Error(`company_financial_history: ${signal.aborted ? "the ticker index request timed out" : "could not reach the ticker index"} at ${session.base}. Retry, check service_status, or pass the SEC CIK instead of a ticker.`);
|
|
432
426
|
}
|
|
433
427
|
if (!res.ok) throw new Error(`company_financial_history: the ticker index returned HTTP ${res.status}`);
|
|
434
428
|
session.tickerIndex = await res.json();
|
|
@@ -526,72 +520,72 @@ export function registerTools(server, session) {
|
|
|
526
520
|
const register = (name, ...rest) => { if (enabled.has(name)) server.registerTool(name, ...rest); };
|
|
527
521
|
register(
|
|
528
522
|
"get_key",
|
|
529
|
-
{ title: "Get a free validation key", annotations: { title: "Get a free validation key", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, description: TOOL_DESCRIPTIONS.get_key, inputSchema: getKeyInput },
|
|
523
|
+
{ title: "Get a free validation key", annotations: { title: "Get a free validation key", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, description: TOOL_DESCRIPTIONS.get_key, inputSchema: getKeyInput, outputSchema: keyOutput },
|
|
530
524
|
(args) => toolGetKey(session, args),
|
|
531
525
|
);
|
|
532
526
|
register(
|
|
533
527
|
"validate_deflated_sharpe",
|
|
534
|
-
{ title: "Validate deflated Sharpe", annotations: { title: "Validate deflated Sharpe", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_deflated_sharpe, inputSchema: deflatedSharpeToolShape },
|
|
528
|
+
{ title: "Validate deflated Sharpe", annotations: { title: "Validate deflated Sharpe", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_deflated_sharpe, inputSchema: deflatedSharpeToolShape, outputSchema: validationOutput },
|
|
535
529
|
(args) => toolValidateDeflatedSharpe(session, args),
|
|
536
530
|
);
|
|
537
531
|
register(
|
|
538
532
|
"validate_overfitting",
|
|
539
|
-
{ title: "Validate overfitting (CSCV)", annotations: { title: "Validate overfitting (CSCV)", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_overfitting, inputSchema: overfittingInput },
|
|
533
|
+
{ title: "Validate overfitting (CSCV)", annotations: { title: "Validate overfitting (CSCV)", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_overfitting, inputSchema: overfittingInput, outputSchema: validationOutput },
|
|
540
534
|
(args) => toolValidateOverfitting(session, args),
|
|
541
535
|
);
|
|
542
536
|
register(
|
|
543
537
|
"validate_paper_evidence",
|
|
544
|
-
{ title: "Validate paper evidence", annotations: { title: "Validate paper evidence", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_paper_evidence, inputSchema: paperEvidenceInput },
|
|
538
|
+
{ title: "Validate paper evidence", annotations: { title: "Validate paper evidence", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_paper_evidence, inputSchema: paperEvidenceInput, outputSchema: validationOutput },
|
|
545
539
|
(args) => toolValidatePaperEvidence(session, args),
|
|
546
540
|
);
|
|
547
541
|
register(
|
|
548
542
|
"validate_breadth",
|
|
549
|
-
{ title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
|
|
543
|
+
{ title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput, outputSchema: validationOutput },
|
|
550
544
|
(args) => toolValidateBreadth(session, args),
|
|
551
545
|
);
|
|
552
546
|
register(
|
|
553
547
|
"validate_track_record",
|
|
554
|
-
{ title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput },
|
|
548
|
+
{ title: "Minimum track record length", annotations: { title: "Minimum track record length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_track_record, inputSchema: trackRecordInput, outputSchema: validationOutput },
|
|
555
549
|
(args) => toolValidateTrackRecord(session, args),
|
|
556
550
|
);
|
|
557
551
|
register(
|
|
558
552
|
"validate_backtest_length",
|
|
559
|
-
{ title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput },
|
|
553
|
+
{ title: "Minimum backtest length", annotations: { title: "Minimum backtest length", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_backtest_length, inputSchema: backtestLengthInput, outputSchema: validationOutput },
|
|
560
554
|
(args) => toolValidateBacktestLength(session, args),
|
|
561
555
|
);
|
|
562
556
|
register(
|
|
563
557
|
"validate_haircut_sharpe",
|
|
564
|
-
{ title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput },
|
|
558
|
+
{ title: "Haircut Sharpe ratio", annotations: { title: "Haircut Sharpe ratio", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_haircut_sharpe, inputSchema: haircutSharpeInput, outputSchema: validationOutput },
|
|
565
559
|
(args) => toolValidateHaircutSharpe(session, args),
|
|
566
560
|
);
|
|
567
561
|
register(
|
|
568
562
|
"validate_luck_trials",
|
|
569
|
-
{ title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput },
|
|
563
|
+
{ title: "Luck-equivalent trials", annotations: { title: "Luck-equivalent trials", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_luck_trials, inputSchema: luckTrialsInput, outputSchema: validationOutput },
|
|
570
564
|
(args) => toolValidateLuckTrials(session, args),
|
|
571
565
|
);
|
|
572
566
|
register(
|
|
573
567
|
"audit_backtest",
|
|
574
|
-
{ title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape },
|
|
568
|
+
{ title: "Audit a backtest", annotations: { title: "Audit a backtest", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.audit_backtest, inputSchema: auditBacktestToolShape, outputSchema: auditOutput },
|
|
575
569
|
(args) => toolAuditBacktest(session, args),
|
|
576
570
|
);
|
|
577
571
|
register(
|
|
578
572
|
"get_receipt",
|
|
579
|
-
{ title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
|
|
573
|
+
{ title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput, outputSchema: receiptOutput },
|
|
580
574
|
(args) => toolGetReceipt(session, args),
|
|
581
575
|
);
|
|
582
576
|
register(
|
|
583
577
|
"verify_receipt",
|
|
584
|
-
{ title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape },
|
|
578
|
+
{ title: "Verify a receipt", annotations: { title: "Verify a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.verify_receipt, inputSchema: verifyReceiptToolShape, outputSchema: verifyReceiptOutput },
|
|
585
579
|
(args) => toolVerifyReceipt(session, args),
|
|
586
580
|
);
|
|
587
581
|
register(
|
|
588
582
|
"service_status",
|
|
589
|
-
{ title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
|
|
583
|
+
{ title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput, outputSchema: statusOutput },
|
|
590
584
|
() => toolServiceStatus(session),
|
|
591
585
|
);
|
|
592
586
|
register(
|
|
593
587
|
"company_financial_history",
|
|
594
|
-
{ title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape },
|
|
588
|
+
{ title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryToolShape, outputSchema: companyHistoryOutput },
|
|
595
589
|
(args) => toolCompanyFinancialHistory(session, args),
|
|
596
590
|
);
|
|
597
591
|
}
|