canli-validation-mcp 0.1.1 → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Arhan Canli / Canli Capital
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -11,6 +11,11 @@ This package is published to npm as [`canli-validation-mcp`](https://www.npmjs.c
11
11
  Run it with `npx`, no install step, as shown below. A local checkout is only needed to develop or
12
12
  test this package itself; see "Local checkout" near the bottom.
13
13
 
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.
18
+
14
19
  ## What the API is (and is not)
15
20
 
16
21
  The engine is the product. The service runs your submitted numbers through the same honesty
@@ -54,6 +59,13 @@ If `CANLI_KEY` is not set, call `get_key` once per session before the four valid
54
59
  returns lives only in this process's memory for the life of the session; it is not written to
55
60
  disk.
56
61
 
62
+ HTTP failures and API error envelopes are marked as MCP tool errors while preserving
63
+ the complete JSON envelope. A successful validation with a negative verdict remains
64
+ a normal result. Requests have a 30-second deadline covering headers and body, reject
65
+ redirects, and are never retried automatically. A timeout may occur after the service
66
+ has processed a request; check service status before deciding to submit again.
67
+ Non-JSON response bodies and raw network errors are omitted from tool errors.
68
+
57
69
  ## Install
58
70
 
59
71
  No install step. `npx` fetches the published package on first run, so every client config below
@@ -108,7 +120,8 @@ const { tools } = await client.listTools();
108
120
  console.log(tools.map((t) => t.name));
109
121
 
110
122
  const keyResult = await client.callTool({ name: "get_key", arguments: { label: "my-agent" } });
111
- console.log(keyResult.content[0].text); // the full envelope, key included
123
+ if (keyResult.isError) throw new Error("Key setup failed; inspect the error privately.");
124
+ // The session retains the issued key. Avoid printing its envelope into logs.
112
125
 
113
126
  const result = await client.callTool({
114
127
  name: "validate_deflated_sharpe",
@@ -159,6 +172,7 @@ in place of `npx -y canli-validation-mcp` in any config above.
159
172
 
160
173
  ```bash
161
174
  npm test
175
+ npm run test:package
162
176
  ```
163
177
 
164
178
  Runs `node --test` over `test/*.test.mjs`: schema round-trips against the API's own OpenAPI and
@@ -169,6 +183,11 @@ file contains an em dash, and one test that spawns the actual server binary and
169
183
  MCP `tools/list` and `callTool` handshake over stdio against a local HTTP stub, so the wiring is
170
184
  proven rather than assumed.
171
185
 
186
+ `test:package` creates the actual npm tarball, checks its exact file list and license,
187
+ installs it in a temporary consumer directory, and runs the stdio test against that
188
+ installed entry. Dependency installation contacts npm; tool calls use only the local
189
+ HTTP stub. It neither publishes a package nor issues a production API key.
190
+
172
191
  ## Dependencies
173
192
 
174
193
  Only `@modelcontextprotocol/sdk` (pinned exact) and `zod` (pinned exact). No other runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canli-validation-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",
@@ -16,11 +16,21 @@
16
16
  "README.md"
17
17
  ],
18
18
  "scripts": {
19
- "test": "node --test test/*.test.mjs"
19
+ "test": "node --test test/*.test.mjs",
20
+ "test:package": "node test/package-smoke.mjs"
20
21
  },
21
22
  "dependencies": {
22
23
  "@modelcontextprotocol/sdk": "1.30.0",
23
24
  "zod": "4.5.4"
24
25
  },
25
- "mcpName": "io.github.arhancanli/canli-validation-mcp"
26
+ "mcpName": "io.github.arhancanli/canli-validation-mcp",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/arhancanli/canlicapital.git",
30
+ "directory": "mcp"
31
+ },
32
+ "homepage": "https://canlicapital.com/developers",
33
+ "bugs": {
34
+ "url": "https://github.com/arhancanli/canlicapital/issues"
35
+ }
26
36
  }
package/src/schemas.mjs CHANGED
@@ -120,9 +120,16 @@ export const LIMITS_SENTENCES = Object.freeze({
120
120
  scope: "This verdict is about the series exactly as submitted. The service never saw the data source, its costs, survivorship, or any lookahead in how the series was built.",
121
121
  notAdmission: "A deflated Sharpe or overfitting probability above or below any threshold is not admission to anything and is not a forecast.",
122
122
  unsigned: "The receipt is content-hashed and reproducible from the open-source core it names. It is not signed.",
123
- quotas: "Quotas: 1000 validations per key per UTC day, 5 keys per client per UTC day, 1048576 bytes per request, 20000 observations per series, 200 variants per matrix.",
123
+ quotas: "Quotas: 1000 validations per key per UTC day, 5 keys per client per UTC day, 1048576 bytes per validation request, 1024 bytes per key revocation request, 20000 observations per series, 200 variants per matrix.",
124
124
  });
125
125
 
126
+ // The MCP registry caps server.json's top-level description at 100 characters (its 422 reads
127
+ // "expected length <= 100"), shorter than any sentence above. That one description carries this
128
+ // clause instead: the verbatim tail of `notAdmission`, so it cannot drift from the boundary
129
+ // language either (test/schemas.test.mjs pins the tail, test/registry-files.test.mjs the cap).
130
+ export const REGISTRY_LIMITS_CLAUSE = "is not admission to anything and is not a forecast.";
131
+ export const REGISTRY_DESCRIPTION_MAX = 100;
132
+
126
133
  // One tool description per tool, each stating (in one sentence lifted from the boundary language
127
134
  // above) what the tool's result cannot be used to claim, so an agent sees this before it ever
128
135
  // calls the tool, not only inside the returned envelope.
package/src/server.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
  // sentences beside it that say what the number does not establish (see schemas.mjs, LIMITS_SENTENCES
7
7
  // and TOOL_DESCRIPTIONS). Reads CANLI_API_BASE (default https://canlicapital.com) and an optional
8
8
  // CANLI_KEY; when CANLI_KEY is set, get_key does not call the network.
9
+ import { readFileSync, realpathSync } from "node:fs";
10
+ import { pathToFileURL } from "node:url";
9
11
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
13
  import {
@@ -22,7 +24,8 @@ import {
22
24
 
23
25
  export const DEFAULT_BASE = "https://canlicapital.com";
24
26
  export const SERVER_NAME = "canlicapital-validation-mcp";
25
- export const SERVER_VERSION = "0.1.0";
27
+ export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
28
+ export const REQUEST_TIMEOUT_MS = 30_000;
26
29
 
27
30
  // One place a value fails a zod schema turns into a short, readable message instead of a raw
28
31
  // ZodError, so a thrown error reads well inside an MCP isError result.
@@ -33,12 +36,14 @@ function parseOrThrow(schema, value, label) {
33
36
  throw new Error(`${label}: ${issues}`);
34
37
  }
35
38
 
36
- export function createSession({ base, fetchImpl, envKey } = {}) {
39
+ export function createSession({ base, fetchImpl, envKey, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
40
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) throw new Error("Request timeout must be a positive integer");
37
41
  return {
38
42
  base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
39
43
  fetchImpl: fetchImpl ?? fetch,
40
44
  envKey: envKey ?? process.env.CANLI_KEY ?? undefined,
41
45
  key: undefined,
46
+ timeoutMs,
42
47
  };
43
48
  }
44
49
 
@@ -46,18 +51,31 @@ async function callApi(session, { path, method = "GET", body }) {
46
51
  const headers = { "Content-Type": "application/json" };
47
52
  const key = session.key ?? session.envKey;
48
53
  if (key) headers.Authorization = `Bearer ${key}`;
49
- const init = { method, headers };
54
+ const signal = AbortSignal.timeout(session.timeoutMs);
55
+ const init = { method, headers, signal, redirect: "error" };
50
56
  if (body !== undefined) init.body = JSON.stringify(body);
51
- const res = await session.fetchImpl(`${session.base}${path}`, init);
52
- const text = await res.text();
57
+ let res, text;
53
58
  try {
54
- return JSON.parse(text);
59
+ res = await session.fetchImpl(`${session.base}${path}`, init);
60
+ text = await res.text();
55
61
  } catch {
56
- throw new Error(`${path} returned a non-JSON body (status ${res.status}): ${text.slice(0, 300)}`);
62
+ throw new Error(signal.aborted
63
+ ? `${path} exceeded the request deadline. The server may have processed the request; no automatic retry was sent.`
64
+ : `${path} could not be reached or read. Check the API base and service status; no automatic retry was sent.`);
57
65
  }
66
+ let envelope;
67
+ try {
68
+ envelope = JSON.parse(text);
69
+ } catch {
70
+ throw new Error(`${path} returned a non-JSON body (status ${res.status}). Response body omitted.`);
71
+ }
72
+ return { envelope, failed: res.status >= 400 || Boolean(envelope?.error) };
58
73
  }
59
74
 
60
- const asText = (envelope) => ({ content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] });
75
+ const asText = (envelope, failed = false) => ({
76
+ content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }],
77
+ ...(failed ? { isError: true } : {}),
78
+ });
61
79
 
62
80
  export async function toolGetKey(session, args) {
63
81
  const { label } = parseOrThrow(getKeyInput, args, "get_key");
@@ -68,9 +86,9 @@ export async function toolGetKey(session, args) {
68
86
  key_present: true,
69
87
  });
70
88
  }
71
- const envelope = await callApi(session, { path: "/api/v1/keys", method: "POST", body: { label } });
72
- if (envelope?.data?.key) session.key = envelope.data.key;
73
- return asText(envelope);
89
+ const response = await callApi(session, { path: "/api/v1/keys", method: "POST", body: { label } });
90
+ if (!response.failed && response.envelope?.data?.key) session.key = response.envelope.data.key;
91
+ return asText(response.envelope, response.failed);
74
92
  }
75
93
 
76
94
  export async function toolValidateDeflatedSharpe(session, args) {
@@ -81,37 +99,37 @@ export async function toolValidateDeflatedSharpe(session, args) {
81
99
  "or a return series (returns, periods_per_year, effective_independent_trials, cross_trial_sharpe_sd_annualized), never a mix of both and never neither.",
82
100
  );
83
101
  }
84
- const envelope = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
85
- return asText(envelope);
102
+ const response = await callApi(session, { path: "/api/v1/validate/deflated-sharpe", method: "POST", body: parsed.data });
103
+ return asText(response.envelope, response.failed);
86
104
  }
87
105
 
88
106
  export async function toolValidateOverfitting(session, args) {
89
107
  const body = parseOrThrow(overfittingInput, args, "validate_overfitting");
90
- const envelope = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
91
- return asText(envelope);
108
+ const response = await callApi(session, { path: "/api/v1/validate/overfitting", method: "POST", body });
109
+ return asText(response.envelope, response.failed);
92
110
  }
93
111
 
94
112
  export async function toolValidatePaperEvidence(session, args) {
95
113
  const body = parseOrThrow(paperEvidenceInput, args, "validate_paper_evidence");
96
- const envelope = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
97
- return asText(envelope);
114
+ const response = await callApi(session, { path: "/api/v1/validate/paper-evidence", method: "POST", body });
115
+ return asText(response.envelope, response.failed);
98
116
  }
99
117
 
100
118
  export async function toolValidateBreadth(session, args) {
101
119
  const body = parseOrThrow(breadthInput, args, "validate_breadth");
102
- const envelope = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
103
- return asText(envelope);
120
+ const response = await callApi(session, { path: "/api/v1/validate/breadth", method: "POST", body });
121
+ return asText(response.envelope, response.failed);
104
122
  }
105
123
 
106
124
  export async function toolGetReceipt(session, args) {
107
125
  const { id } = parseOrThrow(getReceiptInput, args, "get_receipt");
108
- const envelope = await callApi(session, { path: `/api/v1/receipts/${id}` });
109
- return asText(envelope);
126
+ const response = await callApi(session, { path: `/api/v1/receipts/${id}` });
127
+ return asText(response.envelope, response.failed);
110
128
  }
111
129
 
112
130
  export async function toolServiceStatus(session) {
113
- const envelope = await callApi(session, { path: "/api/v1/validate/status" });
114
- return asText(envelope);
131
+ const response = await callApi(session, { path: "/api/v1/validate/status" });
132
+ return asText(response.envelope, response.failed);
115
133
  }
116
134
 
117
135
  export function registerTools(server, session) {
@@ -164,7 +182,7 @@ async function main() {
164
182
  await server.connect(transport);
165
183
  }
166
184
 
167
- const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
185
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
168
186
  if (isMain) {
169
187
  main().catch((err) => {
170
188
  console.error(`${SERVER_NAME}: fatal`, err);