canli-validation-mcp 0.2.0 → 0.3.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 +54 -10
- package/package.json +1 -1
- package/src/server.mjs +64 -12
package/README.md
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
# canli-validation-mcp
|
|
2
2
|
|
|
3
3
|
An MCP (Model Context Protocol) server over canlicapital.com's free, keyed validation API. It
|
|
4
|
-
gives a coding agent
|
|
5
|
-
CSCV overfitting, paper-evidence conformance, breadth ceiling), fetch a stored receipt,
|
|
6
|
-
service status
|
|
7
|
-
|
|
8
|
-
not establish.
|
|
4
|
+
gives a coding agent eight tools: issue a free key, run the four validators (deflated Sharpe,
|
|
5
|
+
CSCV overfitting, paper-evidence conformance, breadth ceiling), fetch a stored receipt, read
|
|
6
|
+
service status, and read a company's reported financial history from SEC filings. Every tool
|
|
7
|
+
returns the full API envelope as its result text, success or error, so the agent cannot see a
|
|
8
|
+
number without the sentences beside it that say what the number does not establish.
|
|
9
9
|
|
|
10
10
|
This package is published to npm as [`canli-validation-mcp`](https://www.npmjs.com/package/canli-validation-mcp).
|
|
11
|
-
|
|
11
|
+
Also listed on the official MCP Registry (`io.github.arhancanli/canli-validation-mcp`) and
|
|
12
|
+
[cursor.directory](https://cursor.directory/plugins/canli-validation-mcp-1). Run it with `npx`, no install step, as shown below. A local checkout is only needed to develop or
|
|
12
13
|
test this package itself; see "Local checkout" near the bottom.
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
below are candidate changes until that version is published. Running unversioned
|
|
17
|
-
`npx` uses npm's latest release, not this checkout.
|
|
15
|
+
This README describes the version in `package.json`. Unversioned `npx` runs npm's latest
|
|
16
|
+
release; `npx -y canli-validation-mcp@<version>` pins one.
|
|
18
17
|
|
|
19
18
|
## What the API is (and is not)
|
|
20
19
|
|
|
@@ -58,6 +57,29 @@ original SEC response and the record's own boundary sentence: these are accounti
|
|
|
58
57
|
reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
|
|
59
58
|
outside the current release return an error with the available concepts listed.
|
|
60
59
|
|
|
60
|
+
## Compact context (0.3.0)
|
|
61
|
+
|
|
62
|
+
An agent pays for every token a tool returns, including whitespace it never reads. Since 0.3.0
|
|
63
|
+
every result is minified JSON, and a company history returns its observations as one `columns`
|
|
64
|
+
header and one row per observation, with a unit shared by every row stated once. No field is
|
|
65
|
+
dropped: every boundary sentence, accession number, form, filed date and source hash is still in
|
|
66
|
+
the result, and `columns` + `rows` rebuild each observation exactly.
|
|
67
|
+
|
|
68
|
+
Measured with `bench/token_cost.py` on live records (tokenizer: tiktoken `o200k_base`; other
|
|
69
|
+
tokenizers give different absolute counts), 20 observations each:
|
|
70
|
+
|
|
71
|
+
| Record | 0.2.0 (indented) | minified | 0.3.0 (minified, columnar) |
|
|
72
|
+
| --- | ---: | ---: | ---: |
|
|
73
|
+
| Apple, StockholdersEquity | 2,214 | 1,560 (−29.5%) | 1,060 (−52.1%) |
|
|
74
|
+
| Microsoft, CashAndCashEquivalentsAtCarryingValue | 2,231 | 1,574 (−29.4%) | 1,065 (−52.3%) |
|
|
75
|
+
|
|
76
|
+
The validation tools gain the minification only: the live `service_status` envelope measured 593
|
|
77
|
+
tokens indented and 475 minified (−19.9%); their boundary sentences are kept word for word. These
|
|
78
|
+
are measurements of these results, not a claim about any other server.
|
|
79
|
+
|
|
80
|
+
**Breaking change from 0.2.0:** `history.observations` is now `{unit?, columns, rows}` instead of
|
|
81
|
+
an array of objects.
|
|
82
|
+
|
|
61
83
|
## Configuration
|
|
62
84
|
|
|
63
85
|
| Variable | Default | Meaning |
|
|
@@ -82,6 +104,28 @@ No install step. `npx` fetches the published package on first run, so every clie
|
|
|
82
104
|
just spawns `npx -y canli-validation-mcp`. See "Local checkout" near the bottom to develop or test
|
|
83
105
|
this package itself instead of running the published one.
|
|
84
106
|
|
|
107
|
+
## Hosted endpoint (no install)
|
|
108
|
+
|
|
109
|
+
The same tools are served at `https://canlicapital.com/mcp` over MCP Streamable HTTP, for clients
|
|
110
|
+
that connect to a URL instead of spawning a process (Claude.ai connectors, ChatGPT, Cursor's remote
|
|
111
|
+
servers). Nothing to install, and no Node.js on your machine.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
claude mcp add --transport http canli https://canlicapital.com/mcp
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Without a key, requests run under a shared anonymous key, so the daily validation quota is shared
|
|
118
|
+
by every hosted caller. For your own quota, issue a free key (see
|
|
119
|
+
[/developers](https://canlicapital.com/developers#quickstart)) and send it as a header:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
claude mcp add --transport http canli https://canlicapital.com/mcp --header "Authorization: Bearer $CANLI_KEY"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The endpoint is stateless. On it, `get_key` issues nothing and says which key is in use, because a
|
|
126
|
+
key issued there would not reach the next request. A malformed Authorization header is refused
|
|
127
|
+
rather than replaced with the shared key.
|
|
128
|
+
|
|
85
129
|
## Claude Desktop
|
|
86
130
|
|
|
87
131
|
Add to `claude_desktop_config.json` (Settings, Developer, Edit Config):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-validation-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "MCP server for canlicapital.com's free validation API: deflated Sharpe, CSCV overfitting, paper-evidence conformance, and breadth ceiling, each returned as the full API envelope so the boundary language cannot be dropped.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
package/src/server.mjs
CHANGED
|
@@ -37,14 +37,26 @@ function parseOrThrow(schema, value, label) {
|
|
|
37
37
|
throw new Error(`${label}: ${issues}`);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// An empty CANLI_KEY, or an unsubstituted template such as "${user_config.api_key}" (what a
|
|
41
|
+
// desktop extension host passes for an optional field the user left unset), is no key at all, not
|
|
42
|
+
// a key to send.
|
|
43
|
+
export function configuredKey(value) {
|
|
44
|
+
if (typeof value !== "string") return undefined;
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
return trimmed === "" || /^\$\{[^}]*\}$/.test(trimmed) ? undefined : trimmed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS, hosted } = {}) {
|
|
41
50
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
|
|
42
51
|
return {
|
|
43
52
|
base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
|
|
44
53
|
fetchImpl: fetchImpl ?? fetch,
|
|
45
|
-
envKey: envKey ?? process.env.CANLI_KEY
|
|
54
|
+
envKey: envKey ?? configuredKey(process.env.CANLI_KEY),
|
|
46
55
|
key: undefined,
|
|
47
56
|
timeoutMs,
|
|
57
|
+
// Set by the hosted endpoint (api/mcp.js): which key a request runs under, so get_key can say
|
|
58
|
+
// so instead of issuing a key the next stateless request would never see.
|
|
59
|
+
hosted: hosted ?? undefined,
|
|
48
60
|
};
|
|
49
61
|
}
|
|
50
62
|
|
|
@@ -73,13 +85,47 @@ async function callApi(session, { path, method = "GET", body }) {
|
|
|
73
85
|
return { envelope, failed: res.status >= 400 || Boolean(envelope?.error) };
|
|
74
86
|
}
|
|
75
87
|
|
|
88
|
+
// Compact context (0.3.0): minified JSON. Indentation is whitespace an agent pays for in tokens and
|
|
89
|
+
// never reads; every field, boundary sentence and provenance value is kept. Measured on the live
|
|
90
|
+
// Apple StockholdersEquity record (README, "Compact context"): 2,214 -> 1,560 tokens minified,
|
|
91
|
+
// 1,056 with the columnar history below.
|
|
76
92
|
const asText = (envelope, failed = false) => ({
|
|
77
|
-
content: [{ type: "text", text: JSON.stringify(envelope
|
|
93
|
+
content: [{ type: "text", text: JSON.stringify(envelope) }],
|
|
78
94
|
...(failed ? { isError: true } : {}),
|
|
79
95
|
});
|
|
80
96
|
|
|
97
|
+
// A history's observations as one header and one row each, instead of every field name repeated
|
|
98
|
+
// on every observation. The column order is fixed so a row can be read without its keys; a unit
|
|
99
|
+
// shared by every row is stated once.
|
|
100
|
+
export const OBSERVATION_COLUMNS = ["end", "val", "accn", "fy", "fp", "form", "filed", "unit"];
|
|
101
|
+
|
|
102
|
+
export function columnarObservations(observations) {
|
|
103
|
+
const units = [...new Set(observations.map((o) => o.unit))];
|
|
104
|
+
const columns = units.length === 1 ? OBSERVATION_COLUMNS.filter((c) => c !== "unit") : OBSERVATION_COLUMNS;
|
|
105
|
+
return {
|
|
106
|
+
...(units.length === 1 ? { unit: units[0] } : {}),
|
|
107
|
+
columns,
|
|
108
|
+
rows: observations.map((o) => columns.map((c) => o[c] ?? null)),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
81
112
|
export async function toolGetKey(session, args) {
|
|
82
113
|
const { label } = parseOrThrow(getKeyInput, args, "get_key");
|
|
114
|
+
if (session.hosted) {
|
|
115
|
+
// The hosted endpoint is stateless: a key issued here would be gone by the next request, and
|
|
116
|
+
// every hosted caller shares the platform's egress address and so its per-client issuance quota.
|
|
117
|
+
const ownKey = "get a free key at https://canlicapital.com/developers#quickstart and send it as 'Authorization: Bearer <key>' to this endpoint";
|
|
118
|
+
const notes = {
|
|
119
|
+
caller: "This hosted session runs under the key in your Authorization header; no new key was issued.",
|
|
120
|
+
shared: `This hosted session runs under a shared anonymous key with a shared daily quota; no new key was issued. For your own quota, ${ownKey}.`,
|
|
121
|
+
none: `This hosted endpoint has no shared key configured, so validations need your own key: ${ownKey}.`,
|
|
122
|
+
};
|
|
123
|
+
return asText({
|
|
124
|
+
note: notes[session.hosted.keySource] ?? notes.none,
|
|
125
|
+
key_source: session.hosted.keySource,
|
|
126
|
+
key_present: session.hosted.keySource !== "none",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
83
129
|
if (session.envKey) {
|
|
84
130
|
return asText({
|
|
85
131
|
note: "CANLI_KEY is set in the environment; no new key was issued and no request was sent. Using the configured key for this session.",
|
|
@@ -199,50 +245,56 @@ export async function toolCompanyFinancialHistory(session, args) {
|
|
|
199
245
|
units: [...new Set((history.observations ?? []).map((o) => o.unit))],
|
|
200
246
|
total_observations: history.observations?.length ?? 0,
|
|
201
247
|
returned: observations.length,
|
|
202
|
-
observations,
|
|
248
|
+
observations: columnarObservations(observations),
|
|
203
249
|
},
|
|
204
250
|
});
|
|
205
251
|
}
|
|
206
252
|
|
|
253
|
+
// MCP tool annotations: every tool reaches the canlicapital.com API (openWorldHint). Reads are
|
|
254
|
+
// marked read-only; a validation stores a receipt and get_key creates a key, so neither is
|
|
255
|
+
// read-only, and nothing any tool does modifies or deletes a caller's data.
|
|
256
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
|
|
257
|
+
const WRITES_RECEIPT = { readOnlyHint: false, destructiveHint: false, openWorldHint: true };
|
|
258
|
+
|
|
207
259
|
export function registerTools(server, session) {
|
|
208
260
|
server.registerTool(
|
|
209
261
|
"get_key",
|
|
210
|
-
{ title: "Get a free validation key", description: TOOL_DESCRIPTIONS.get_key, inputSchema: getKeyInput },
|
|
262
|
+
{ 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 },
|
|
211
263
|
(args) => toolGetKey(session, args),
|
|
212
264
|
);
|
|
213
265
|
server.registerTool(
|
|
214
266
|
"validate_deflated_sharpe",
|
|
215
|
-
{ title: "Validate deflated Sharpe", description: TOOL_DESCRIPTIONS.validate_deflated_sharpe, inputSchema: deflatedSharpeToolShape },
|
|
267
|
+
{ title: "Validate deflated Sharpe", annotations: { title: "Validate deflated Sharpe", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_deflated_sharpe, inputSchema: deflatedSharpeToolShape },
|
|
216
268
|
(args) => toolValidateDeflatedSharpe(session, args),
|
|
217
269
|
);
|
|
218
270
|
server.registerTool(
|
|
219
271
|
"validate_overfitting",
|
|
220
|
-
{ title: "Validate overfitting (CSCV)", description: TOOL_DESCRIPTIONS.validate_overfitting, inputSchema: overfittingInput },
|
|
272
|
+
{ title: "Validate overfitting (CSCV)", annotations: { title: "Validate overfitting (CSCV)", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_overfitting, inputSchema: overfittingInput },
|
|
221
273
|
(args) => toolValidateOverfitting(session, args),
|
|
222
274
|
);
|
|
223
275
|
server.registerTool(
|
|
224
276
|
"validate_paper_evidence",
|
|
225
|
-
{ title: "Validate paper evidence", description: TOOL_DESCRIPTIONS.validate_paper_evidence, inputSchema: paperEvidenceInput },
|
|
277
|
+
{ title: "Validate paper evidence", annotations: { title: "Validate paper evidence", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_paper_evidence, inputSchema: paperEvidenceInput },
|
|
226
278
|
(args) => toolValidatePaperEvidence(session, args),
|
|
227
279
|
);
|
|
228
280
|
server.registerTool(
|
|
229
281
|
"validate_breadth",
|
|
230
|
-
{ title: "Validate breadth ceiling", description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
|
|
282
|
+
{ title: "Validate breadth ceiling", annotations: { title: "Validate breadth ceiling", ...WRITES_RECEIPT }, description: TOOL_DESCRIPTIONS.validate_breadth, inputSchema: breadthInput },
|
|
231
283
|
(args) => toolValidateBreadth(session, args),
|
|
232
284
|
);
|
|
233
285
|
server.registerTool(
|
|
234
286
|
"get_receipt",
|
|
235
|
-
{ title: "Get a receipt", description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
|
|
287
|
+
{ title: "Get a receipt", annotations: { title: "Get a receipt", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.get_receipt, inputSchema: getReceiptInput },
|
|
236
288
|
(args) => toolGetReceipt(session, args),
|
|
237
289
|
);
|
|
238
290
|
server.registerTool(
|
|
239
291
|
"service_status",
|
|
240
|
-
{ title: "Service status", description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
|
|
292
|
+
{ title: "Service status", annotations: { title: "Service status", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
|
|
241
293
|
() => toolServiceStatus(session),
|
|
242
294
|
);
|
|
243
295
|
server.registerTool(
|
|
244
296
|
"company_financial_history",
|
|
245
|
-
{ title: "Company financial history (SEC)", description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryInput },
|
|
297
|
+
{ title: "Company financial history (SEC)", annotations: { title: "Company financial history (SEC)", ...READ_ONLY }, description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryInput },
|
|
246
298
|
(args) => toolCompanyFinancialHistory(session, args),
|
|
247
299
|
);
|
|
248
300
|
}
|