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.
Files changed (3) hide show
  1. package/README.md +54 -10
  2. package/package.json +1 -1
  3. 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 seven tools: issue a free key, run the four validators (deflated Sharpe,
5
- CSCV overfitting, paper-evidence conformance, breadth ceiling), fetch a stored receipt, and read
6
- service status. Every tool returns the full API envelope as its result text, success or error,
7
- so the agent cannot see a number without the sentences beside it that say what the number does
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
- Run it with `npx`, no install step, as shown below. A local checkout is only needed to develop or
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
- Release checkpoint, September 20, 2026: npm's latest version is 0.1.1. This checkout
15
- prepares 0.1.2; its HTTP error flags, request deadlines and redirect handling described
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.2.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
- export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
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 ?? undefined,
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, null, 2) }],
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
  }