canli-validation-mcp 0.1.2 → 0.3.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/README.md +33 -0
- package/package.json +1 -1
- package/src/schemas.mjs +14 -0
- package/src/server.mjs +97 -1
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ repository for the full design.
|
|
|
36
36
|
| `validate_breadth` | `POST /api/v1/validate/breadth` | yes |
|
|
37
37
|
| `get_receipt` | `GET /api/v1/receipts/{id}` | no |
|
|
38
38
|
| `service_status` | `GET /api/v1/validate/status` | no |
|
|
39
|
+
| `company_financial_history` | `GET /company-data/{cik}.json` | no |
|
|
39
40
|
|
|
40
41
|
`validate_deflated_sharpe` accepts exactly one of two input shapes, never a mix of both:
|
|
41
42
|
|
|
@@ -48,6 +49,38 @@ repository for the full design.
|
|
|
48
49
|
Sending fields from both shapes, or from neither, is rejected before any request leaves the
|
|
49
50
|
process; see `src/schemas.mjs`.
|
|
50
51
|
|
|
52
|
+
`company_financial_history` is different from the other tools: it reads the public company
|
|
53
|
+
reference at canlicapital.com, not the validation API. Give it a CIK (1 to 10 digits) to list a
|
|
54
|
+
company's available financial histories, or a CIK and a us-gaap concept such as `Revenues` to
|
|
55
|
+
get the observations, newest first (`limit` defaults to 40, maximum 200). Every observation
|
|
56
|
+
keeps its filing accession, form, filed date and unit, and the result carries the SHA-256 of the
|
|
57
|
+
original SEC response and the record's own boundary sentence: these are accounting values as
|
|
58
|
+
reported to the SEC, not market prices, returns or a recommendation. Companies and concepts
|
|
59
|
+
outside the current release return an error with the available concepts listed.
|
|
60
|
+
|
|
61
|
+
## Compact context (0.3.0)
|
|
62
|
+
|
|
63
|
+
An agent pays for every token a tool returns, including whitespace it never reads. Since 0.3.0
|
|
64
|
+
every result is minified JSON, and a company history returns its observations as one `columns`
|
|
65
|
+
header and one row per observation, with a unit shared by every row stated once. No field is
|
|
66
|
+
dropped: every boundary sentence, accession number, form, filed date and source hash is still in
|
|
67
|
+
the result, and `columns` + `rows` rebuild each observation exactly.
|
|
68
|
+
|
|
69
|
+
Measured with `bench/token_cost.py` on live records (tokenizer: tiktoken `o200k_base`; other
|
|
70
|
+
tokenizers give different absolute counts), 20 observations each:
|
|
71
|
+
|
|
72
|
+
| Record | 0.2.0 (indented) | minified | 0.3.0 (minified, columnar) |
|
|
73
|
+
| --- | ---: | ---: | ---: |
|
|
74
|
+
| Apple, StockholdersEquity | 2,214 | 1,560 (−29.5%) | 1,060 (−52.1%) |
|
|
75
|
+
| Microsoft, CashAndCashEquivalentsAtCarryingValue | 2,231 | 1,574 (−29.4%) | 1,065 (−52.3%) |
|
|
76
|
+
|
|
77
|
+
The validation tools gain the minification only: the live `service_status` envelope measured 593
|
|
78
|
+
tokens indented and 475 minified (−19.9%); their boundary sentences are kept word for word. These
|
|
79
|
+
are measurements of these results, not a claim about any other server.
|
|
80
|
+
|
|
81
|
+
**Breaking change from 0.2.0:** `history.observations` is now `{unit?, columns, rows}` instead of
|
|
82
|
+
an array of objects.
|
|
83
|
+
|
|
51
84
|
## Configuration
|
|
52
85
|
|
|
53
86
|
| Variable | Default | Meaning |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-validation-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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/schemas.mjs
CHANGED
|
@@ -110,6 +110,19 @@ export const getReceiptInput = z
|
|
|
110
110
|
|
|
111
111
|
export const emptyInput = z.object({}).strict();
|
|
112
112
|
|
|
113
|
+
// company_financial_history reads the public company reference, not the validation API.
|
|
114
|
+
export const companyHistoryInput = z
|
|
115
|
+
.object({
|
|
116
|
+
cik: z.string().regex(/^\d{1,10}$/, "A CIK is 1 to 10 digits"),
|
|
117
|
+
concept: z.string().regex(/^[A-Za-z][A-Za-z0-9]{0,99}$/, "A concept is a us-gaap tag such as Revenues or Assets").optional(),
|
|
118
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
119
|
+
})
|
|
120
|
+
.strict();
|
|
121
|
+
|
|
122
|
+
// The company reference carries its own boundary sentence in every record (claim_boundary),
|
|
123
|
+
// written by scripts/lib/company-reference.mjs. A test pins this copy to that source verbatim.
|
|
124
|
+
export const COMPANY_REFERENCE_BOUNDARY = "Public company accounting reference, not market prices, returns, an investment recommendation, or ALPHAC performance. Validate a separately constructed return series with the validation API; accounting values are not returns.";
|
|
125
|
+
|
|
113
126
|
// ---------------------------------------------------------------------------------------------
|
|
114
127
|
// Boundary language: verbatim sentences from api/_lib/limits.js LIMITS_TEXT. A test asserts each
|
|
115
128
|
// of these strings is still present, byte for byte, in that source array, so this module cannot
|
|
@@ -141,4 +154,5 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
141
154
|
validate_breadth: `Book Sharpe ceiling from per-sleeve quality and average pairwise correlation, and the sleeves a target needs. ${LIMITS_SENTENCES.scope}`,
|
|
142
155
|
get_receipt: `Fetch a stored verdict by its content-hash id (GET /api/v1/receipts/{id}), immutable and cacheable. ${LIMITS_SENTENCES.unsigned}`,
|
|
143
156
|
service_status: `Service, store and quota constants for the validation API (GET /api/v1/validate/status); no key required. ${LIMITS_SENTENCES.scope}`,
|
|
157
|
+
company_financial_history: `SEC-reported financial history for one company from the canlicapital.com company reference (GET /company-data/{cik}.json). 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}`,
|
|
144
158
|
});
|
package/src/server.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import {
|
|
14
14
|
breadthInput,
|
|
15
|
+
companyHistoryInput,
|
|
15
16
|
deflatedSharpeInput,
|
|
16
17
|
deflatedSharpeToolShape,
|
|
17
18
|
emptyInput,
|
|
@@ -72,11 +73,30 @@ async function callApi(session, { path, method = "GET", body }) {
|
|
|
72
73
|
return { envelope, failed: res.status >= 400 || Boolean(envelope?.error) };
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// Compact context (0.3.0): minified JSON. Indentation is whitespace an agent pays for in tokens and
|
|
77
|
+
// never reads; every field, boundary sentence and provenance value is kept. Measured on the live
|
|
78
|
+
// Apple StockholdersEquity record (README, "Compact context"): 2,214 -> 1,560 tokens minified,
|
|
79
|
+
// 1,056 with the columnar history below.
|
|
75
80
|
const asText = (envelope, failed = false) => ({
|
|
76
|
-
content: [{ type: "text", text: JSON.stringify(envelope
|
|
81
|
+
content: [{ type: "text", text: JSON.stringify(envelope) }],
|
|
77
82
|
...(failed ? { isError: true } : {}),
|
|
78
83
|
});
|
|
79
84
|
|
|
85
|
+
// A history's observations as one header and one row each, instead of every field name repeated
|
|
86
|
+
// on every observation. The column order is fixed so a row can be read without its keys; a unit
|
|
87
|
+
// shared by every row is stated once.
|
|
88
|
+
export const OBSERVATION_COLUMNS = ["end", "val", "accn", "fy", "fp", "form", "filed", "unit"];
|
|
89
|
+
|
|
90
|
+
export function columnarObservations(observations) {
|
|
91
|
+
const units = [...new Set(observations.map((o) => o.unit))];
|
|
92
|
+
const columns = units.length === 1 ? OBSERVATION_COLUMNS.filter((c) => c !== "unit") : OBSERVATION_COLUMNS;
|
|
93
|
+
return {
|
|
94
|
+
...(units.length === 1 ? { unit: units[0] } : {}),
|
|
95
|
+
columns,
|
|
96
|
+
rows: observations.map((o) => columns.map((c) => o[c] ?? null)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
export async function toolGetKey(session, args) {
|
|
81
101
|
const { label } = parseOrThrow(getKeyInput, args, "get_key");
|
|
82
102
|
if (session.envKey) {
|
|
@@ -132,6 +152,77 @@ export async function toolServiceStatus(session) {
|
|
|
132
152
|
return asText(response.envelope, response.failed);
|
|
133
153
|
}
|
|
134
154
|
|
|
155
|
+
// The company reference is a public data file, not an API envelope. The result keeps every
|
|
156
|
+
// field that says where a value came from (accession, form, filed date, unit, source hash) and
|
|
157
|
+
// the record's own claim_boundary and policy sentences, so an agent cannot quote a number
|
|
158
|
+
// without its provenance or boundary.
|
|
159
|
+
export async function toolCompanyFinancialHistory(session, args) {
|
|
160
|
+
const { cik, concept, limit = 40 } = parseOrThrow(companyHistoryInput, args, "company_financial_history");
|
|
161
|
+
const id = cik.padStart(10, "0");
|
|
162
|
+
const path = `/company-data/${id}.json`;
|
|
163
|
+
const signal = AbortSignal.timeout(session.timeoutMs);
|
|
164
|
+
let res, text;
|
|
165
|
+
try {
|
|
166
|
+
res = await session.fetchImpl(`${session.base}${path}`, { headers: { Accept: "application/json" }, signal, redirect: "error" });
|
|
167
|
+
text = await res.text();
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error(signal.aborted
|
|
170
|
+
? `${path} exceeded the request deadline; no automatic retry was sent.`
|
|
171
|
+
: `${path} could not be reached or read. Check the API base and service status; no automatic retry was sent.`);
|
|
172
|
+
}
|
|
173
|
+
if (res.status === 404) return asText({ error: { code: "not_found", message: `No company record for CIK ${id} in the current company reference release.` }, page: `${session.base}/companies` }, true);
|
|
174
|
+
if (res.status >= 400) return asText({ error: { code: "unavailable", message: `${path} returned status ${res.status}.` } }, true);
|
|
175
|
+
let record;
|
|
176
|
+
try {
|
|
177
|
+
record = JSON.parse(text);
|
|
178
|
+
} catch {
|
|
179
|
+
throw new Error(`${path} returned a non-JSON body (status ${res.status}). Response body omitted.`);
|
|
180
|
+
}
|
|
181
|
+
if (record?.schema !== "canli.company-reference.v1" || record.cik !== id || !Array.isArray(record.concepts) || typeof record.claim_boundary !== "string") {
|
|
182
|
+
return asText({ error: { code: "unexpected_record", message: `${path} did not return a canli.company-reference.v1 record for CIK ${id}.` } }, true);
|
|
183
|
+
}
|
|
184
|
+
const base = {
|
|
185
|
+
schema: "canli.mcp.company-history.v1",
|
|
186
|
+
company: { cik: record.cik, name: record.name },
|
|
187
|
+
claim_boundary: record.claim_boundary,
|
|
188
|
+
policy: record.policy,
|
|
189
|
+
source: {
|
|
190
|
+
sec_response_url: record.source_url,
|
|
191
|
+
sec_response_sha256: record.source_sha256,
|
|
192
|
+
snapshot: record.source_snapshot ? `${session.base}${record.source_snapshot}` : null,
|
|
193
|
+
fetched_at: record.fetched_at,
|
|
194
|
+
record: `${session.base}${path}`,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
if (concept === undefined) {
|
|
198
|
+
return asText({
|
|
199
|
+
...base,
|
|
200
|
+
page: `${session.base}/companies/${id}`,
|
|
201
|
+
histories: record.concepts.map((c) => ({ concept: c.tag, label: c.label, kind: c.kind, observations: c.observations?.length ?? 0, page: `${session.base}/companies/${id}/${c.tag}` })),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const history = record.concepts.find((c) => c.tag === concept);
|
|
205
|
+
if (!history) {
|
|
206
|
+
return asText({ ...base, error: { code: "concept_not_found", message: `${record.name} has no ${concept} history in this release.`, available: record.concepts.map((c) => c.tag) } }, true);
|
|
207
|
+
}
|
|
208
|
+
const observations = [...(history.observations ?? [])].sort((a, b) => String(b.end).localeCompare(String(a.end))).slice(0, limit);
|
|
209
|
+
return asText({
|
|
210
|
+
...base,
|
|
211
|
+
page: `${session.base}/companies/${id}/${history.tag}`,
|
|
212
|
+
history: {
|
|
213
|
+
concept: history.tag,
|
|
214
|
+
taxonomy: history.taxonomy,
|
|
215
|
+
label: history.label,
|
|
216
|
+
kind: history.kind,
|
|
217
|
+
meaning: history.meaning,
|
|
218
|
+
units: [...new Set((history.observations ?? []).map((o) => o.unit))],
|
|
219
|
+
total_observations: history.observations?.length ?? 0,
|
|
220
|
+
returned: observations.length,
|
|
221
|
+
observations: columnarObservations(observations),
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
135
226
|
export function registerTools(server, session) {
|
|
136
227
|
server.registerTool(
|
|
137
228
|
"get_key",
|
|
@@ -168,6 +259,11 @@ export function registerTools(server, session) {
|
|
|
168
259
|
{ title: "Service status", description: TOOL_DESCRIPTIONS.service_status, inputSchema: emptyInput },
|
|
169
260
|
() => toolServiceStatus(session),
|
|
170
261
|
);
|
|
262
|
+
server.registerTool(
|
|
263
|
+
"company_financial_history",
|
|
264
|
+
{ title: "Company financial history (SEC)", description: TOOL_DESCRIPTIONS.company_financial_history, inputSchema: companyHistoryInput },
|
|
265
|
+
(args) => toolCompanyFinancialHistory(session, args),
|
|
266
|
+
);
|
|
171
267
|
}
|
|
172
268
|
|
|
173
269
|
export function createServer(session = createSession()) {
|