canli-fundamentals-mcp 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +50 -0
- package/src/server.mjs +556 -0
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
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# canli-fundamentals-mcp
|
|
2
|
+
|
|
3
|
+
SEC company fundamentals **point in time**, as an MCP server. For any value a company reported in
|
|
4
|
+
XBRL, it tells your AI assistant three things: what was **first reported**, what the **latest**
|
|
5
|
+
filing says, and what was **known on a given date**. Every value comes with the filing behind it.
|
|
6
|
+
|
|
7
|
+
That difference matters. Apple's diluted EPS for fiscal 2018 was 11.91 in the annual reports of
|
|
8
|
+
2018 and 2019, and 2.98 in the 2020 report, after its 4-for-1 split. A backtest that reads today's
|
|
9
|
+
data for a 2018 decision uses a number nobody saw in 2018. Apple's accounts payable at the end of
|
|
10
|
+
fiscal 2017 was first reported as $49.05B and changed to $44.24B a year later. Across Apple's annual
|
|
11
|
+
figures, 232 reported periods changed after their first report.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y canli-fundamentals-mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Claude Code:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
claude mcp add canli-fundamentals -- npx -y canli-fundamentals-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Claude Desktop, Cursor or any MCP client (`mcpServers` in its config):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"canli-fundamentals": { "command": "npx", "args": ["-y", "canli-fundamentals-mcp"] }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then ask things like "What did Apple report as of 31 December 2018?", "Which of Microsoft's annual
|
|
34
|
+
numbers were restated?" or "Show every filing that reported Apple's 2017 accounts payable".
|
|
35
|
+
|
|
36
|
+
## Tools
|
|
37
|
+
|
|
38
|
+
| Tool | What it returns |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `known_as_of` | For each concept, the most recent period filed on or before a date and its value as it stood then, flagged if later restated. Defaults to ten common concepts. The point-in-time primitive for backtests. |
|
|
41
|
+
| `history` | One concept over time, newest first: `first_reported` (default), `latest`, or `as_of` a date. Annual, quarterly or all periods. |
|
|
42
|
+
| `restatements` | Periods whose value changed in a later filing, with first and latest values and the percent change. Scans every concept when none is given. |
|
|
43
|
+
| `vintages` | Every filing that reported one period of one concept, oldest first. |
|
|
44
|
+
| `list_concepts` | The concepts a company reports, with units, period counts, date range and how many periods changed. |
|
|
45
|
+
|
|
46
|
+
Companies are named by ticker (`AAPL`) or SEC CIK (`320193`). Concepts are XBRL names (`Revenues`,
|
|
47
|
+
`NetIncomeLoss`, `Assets`), us-gaap by default; prefix another taxonomy, for example
|
|
48
|
+
`dei:EntityCommonStockSharesOutstanding`. All five tools are read-only.
|
|
49
|
+
|
|
50
|
+
## Where the numbers come from, and how you can check them
|
|
51
|
+
|
|
52
|
+
For every company in its reference, [canlicapital.com](https://canlicapital.com/companies) serves
|
|
53
|
+
the SEC's XBRL companyfacts response byte for byte, as a gzip snapshot, and names the snapshot's
|
|
54
|
+
SHA-256 in the company's record. This server:
|
|
55
|
+
|
|
56
|
+
1. resolves the ticker from `https://canlicapital.com/api/v1/company-tickers.json`;
|
|
57
|
+
2. reads the company record at `/company-data/{CIK}.json`;
|
|
58
|
+
3. downloads the snapshot it names and refuses it unless its SHA-256 matches;
|
|
59
|
+
4. computes everything else on your machine.
|
|
60
|
+
|
|
61
|
+
Those GETs are the only network traffic. Each result names the snapshot (its date, hash, URL and the
|
|
62
|
+
SEC URL it came from), so you can fetch the same file from the SEC and compare.
|
|
63
|
+
|
|
64
|
+
How periods are handled:
|
|
65
|
+
|
|
66
|
+
- A period is its start and end date. A quarter and a year-to-date figure that end on the same day
|
|
67
|
+
are different periods, and values are never merged across them.
|
|
68
|
+
- Durations of 350 to 380 days are annual (52- and 53-week years included), 80 to 100 days
|
|
69
|
+
quarterly, and anything else (six- and nine-month year-to-date figures) appears only under
|
|
70
|
+
`periods: "all"`.
|
|
71
|
+
- A balance (an instant) takes the kind of the report that first carried it: year-end balances are
|
|
72
|
+
first reported in annual reports, quarter-end balances in 10-Qs.
|
|
73
|
+
- `first_reported` is the earliest filing by filed date, then accession number. `as_of` counts only
|
|
74
|
+
filings made on or before the date.
|
|
75
|
+
|
|
76
|
+
## Limits
|
|
77
|
+
|
|
78
|
+
- Values are as the SEC's companyfacts API reports them in the snapshot named with each result.
|
|
79
|
+
Filings made after the snapshot date are missing, so check `snapshot.fetched_at`.
|
|
80
|
+
- `filed` is the date a filing reached EDGAR. To avoid lookahead, treat a value as known from the
|
|
81
|
+
next trading day after it was filed.
|
|
82
|
+
- A changed value can be a restatement, a reclassification or a correction; the accession number
|
|
83
|
+
names the filing to read.
|
|
84
|
+
- Coverage is the SEC filers in the Canli company reference (6,415 tickers). A company outside it
|
|
85
|
+
returns an error naming its CIK.
|
|
86
|
+
- Company-reported data. Not investment advice.
|
|
87
|
+
|
|
88
|
+
## Settings
|
|
89
|
+
|
|
90
|
+
| Variable | Default | What it does |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| `CANLI_CACHE_DIR` | `~/.cache/canli-fundamentals` | Where snapshots are cached, named by their SHA-256, so a cached file can never go stale. Set it to an empty value to keep nothing on disk. |
|
|
93
|
+
| `CANLI_API_BASE` | `https://canlicapital.com` | The site to read from. |
|
|
94
|
+
|
|
95
|
+
## Part of the Canli MCP family
|
|
96
|
+
|
|
97
|
+
- [canli-validation-mcp](https://github.com/arhancanli/canli-validation-mcp): checks whether a
|
|
98
|
+
backtest is real (deflated Sharpe, probability of overfitting, minimum track record).
|
|
99
|
+
- [canli-research-mcp](https://github.com/arhancanli/canlicapital/tree/main/mcp-research): Canli
|
|
100
|
+
Capital's open research record.
|
|
101
|
+
|
|
102
|
+
Code MIT. The SEC filings and XBRL facts are U.S. government works in the public domain; Canli
|
|
103
|
+
Capital's selection and derived data are CC BY 4.0 (credit "Canli Capital (canlicapital.com)").
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "canli-fundamentals-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for SEC company fundamentals point in time: what was first reported, what was known on any date, and every later restatement, each value with the filing behind it. Computed locally from a hash-checked SEC snapshot.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"bin": {
|
|
9
|
+
"canli-fundamentals-mcp": "src/server.mjs"
|
|
10
|
+
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=20.10"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "node --test test/*.test.mjs"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@modelcontextprotocol/server": "2.1.0",
|
|
23
|
+
"zod": "4.6.5"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@modelcontextprotocol/client": "2.1.0"
|
|
27
|
+
},
|
|
28
|
+
"mcpName": "io.github.arhancanli/canli-fundamentals-mcp",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/arhancanli/canlicapital.git",
|
|
32
|
+
"directory": "mcp-fundamentals"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://canlicapital.com/developers",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/arhancanli/canlicapital/issues"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"mcp",
|
|
40
|
+
"model-context-protocol",
|
|
41
|
+
"sec",
|
|
42
|
+
"edgar",
|
|
43
|
+
"xbrl",
|
|
44
|
+
"fundamentals",
|
|
45
|
+
"point-in-time",
|
|
46
|
+
"restatements",
|
|
47
|
+
"backtesting",
|
|
48
|
+
"quantitative-finance"
|
|
49
|
+
]
|
|
50
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canli-fundamentals-mcp: SEC company fundamentals point in time. For any value a company reported
|
|
3
|
+
// in XBRL, it says what was first reported, what the latest filing says, and what was known on a
|
|
4
|
+
// given date, each with the filing behind it. That is the difference between a backtest that only
|
|
5
|
+
// uses what investors could have known and one that quietly reads restated numbers.
|
|
6
|
+
//
|
|
7
|
+
// Source: for every company in its reference, canlicapital.com serves the SEC companyfacts response
|
|
8
|
+
// byte for byte (a gzip snapshot) and names its SHA-256 in the company record. This server fetches
|
|
9
|
+
// the record, fetches the snapshot, refuses it unless the hash matches, and computes everything on
|
|
10
|
+
// this machine. Nothing is sent anywhere but the two GETs.
|
|
11
|
+
//
|
|
12
|
+
// Built to the family's cost and latency rules: five tools, so the tool list an agent re-reads
|
|
13
|
+
// every turn stays small and byte-identical; one snapshot download per company, cached on disk by
|
|
14
|
+
// its hash (a hash-named file can never go stale); compact columnar results.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
21
|
+
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
23
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
|
|
26
|
+
export const SERVER_NAME = "canli-fundamentals-mcp";
|
|
27
|
+
export const SERVER_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
28
|
+
const DEFAULT_BASE = "https://canlicapital.com";
|
|
29
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
30
|
+
const MIB = 1024 * 1024;
|
|
31
|
+
const MAX_RECORD_BYTES = 16 * MIB;
|
|
32
|
+
const MAX_SNAPSHOT_BYTES = 16 * MIB;
|
|
33
|
+
const MAX_RAW_BYTES = 256 * MIB;
|
|
34
|
+
|
|
35
|
+
// The boundary every result carries.
|
|
36
|
+
export const LIMITS = Object.freeze([
|
|
37
|
+
"Values are as the SEC's XBRL companyfacts API reported them for this company in the snapshot named here; filings made after the snapshot date are missing.",
|
|
38
|
+
"filed is the date the filing reached EDGAR. To avoid lookahead in a backtest, treat a value as known from the next trading day after it was filed.",
|
|
39
|
+
"A value that changed in a later filing may be a restatement, a reclassification or a correction; accn names the filing to read.",
|
|
40
|
+
"Company-reported data, not investment advice.",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export function createSession({ base, fetchImpl, cacheDir } = {}) {
|
|
44
|
+
const envCache = process.env.CANLI_CACHE_DIR;
|
|
45
|
+
return {
|
|
46
|
+
base: base ?? process.env.CANLI_API_BASE ?? DEFAULT_BASE,
|
|
47
|
+
fetchImpl: fetchImpl ?? fetch,
|
|
48
|
+
// A path, or an empty value to keep nothing on disk.
|
|
49
|
+
cacheDir: cacheDir !== undefined ? cacheDir : envCache !== undefined ? envCache : join(homedir(), ".cache", "canli-fundamentals"),
|
|
50
|
+
companies: new Map(),
|
|
51
|
+
tickers: null,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchBytes(session, path, maxBytes) {
|
|
56
|
+
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
57
|
+
let res;
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
res = await session.fetchImpl(`${session.base}${path}`, { signal, redirect: "error" });
|
|
61
|
+
const declared = Number(res.headers?.get?.("content-length"));
|
|
62
|
+
if (declared > maxBytes) return { status: 413, body: null };
|
|
63
|
+
body = Buffer.from(await res.arrayBuffer());
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(signal.aborted ? `${path} exceeded the request deadline.` : `${path} could not be reached.`);
|
|
66
|
+
}
|
|
67
|
+
if (body.length > maxBytes) return { status: 413, body: null };
|
|
68
|
+
return { status: res.status, body };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseJson(buf, what) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(buf);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(`${what} did not return JSON.`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
|
|
80
|
+
const isGzip = (buf) => buf.length > 2 && buf[0] === 0x1f && buf[1] === 0x8b;
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------------------------
|
|
83
|
+
// Loading a company: ticker or CIK, then the record, then the hash-checked SEC snapshot
|
|
84
|
+
// ---------------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
async function tickerMap(session) {
|
|
87
|
+
if (!session.tickers) {
|
|
88
|
+
const { status, body } = await fetchBytes(session, "/api/v1/company-tickers.json", 8 * MIB);
|
|
89
|
+
if (status >= 400) throw new Error(`The ticker list returned HTTP ${status}.`);
|
|
90
|
+
session.tickers = parseJson(body, "The ticker list").tickers ?? {};
|
|
91
|
+
}
|
|
92
|
+
return session.tickers;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function resolveCompany(session, company) {
|
|
96
|
+
const text = String(company).trim();
|
|
97
|
+
if (/^\d{1,10}$/.test(text)) return { cik: text.padStart(10, "0"), ticker: null };
|
|
98
|
+
const ticker = text.toUpperCase().replace(/^\$/, "");
|
|
99
|
+
const map = await tickerMap(session);
|
|
100
|
+
const cik = map[ticker] ?? map[ticker.replace(/\./g, "-")] ?? map[ticker.replace(/-/g, ".")];
|
|
101
|
+
if (!cik) throw new Error(`${text} is not a ticker in the Canli company reference (${Object.keys(map).length} tickers). Try the company's SEC CIK instead.`);
|
|
102
|
+
return { cik, ticker };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function snapshotBytes(session, sha, snapshotPath) {
|
|
106
|
+
const file = session.cacheDir ? join(session.cacheDir, `${sha}.json.gz`) : null;
|
|
107
|
+
if (file) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = gunzipSync(readFileSync(file), { maxOutputLength: MAX_RAW_BYTES });
|
|
110
|
+
if (sha256(raw) === sha) return raw;
|
|
111
|
+
} catch {
|
|
112
|
+
// Missing or unreadable: fetch it again.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const { status, body } = await fetchBytes(session, snapshotPath, MAX_SNAPSHOT_BYTES);
|
|
116
|
+
if (status === 413) throw new Error(`The SEC snapshot ${snapshotPath} is larger than ${MAX_SNAPSHOT_BYTES / MIB} MiB; not read.`);
|
|
117
|
+
if (status >= 400) throw new Error(`The SEC snapshot ${snapshotPath} returned HTTP ${status}.`);
|
|
118
|
+
let raw;
|
|
119
|
+
try {
|
|
120
|
+
raw = isGzip(body) ? gunzipSync(body, { maxOutputLength: MAX_RAW_BYTES }) : body;
|
|
121
|
+
} catch {
|
|
122
|
+
throw new Error(`The SEC snapshot ${snapshotPath} is not valid gzip.`);
|
|
123
|
+
}
|
|
124
|
+
if (sha256(raw) !== sha) throw new Error(`The SEC snapshot for this company does not match the SHA-256 its record publishes (${sha.slice(0, 12)}); nothing was used.`);
|
|
125
|
+
if (file) {
|
|
126
|
+
try {
|
|
127
|
+
mkdirSync(session.cacheDir, { recursive: true });
|
|
128
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
129
|
+
writeFileSync(tmp, isGzip(body) ? body : gzipSync(raw));
|
|
130
|
+
renameSync(tmp, file);
|
|
131
|
+
} catch {
|
|
132
|
+
// A cache that cannot be written only costs a download next time.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function loadCompany(session, company) {
|
|
139
|
+
const { cik, ticker } = await resolveCompany(session, company);
|
|
140
|
+
const cached = session.companies.get(cik);
|
|
141
|
+
if (cached) return ticker ? { ...cached, ticker } : cached;
|
|
142
|
+
const recordPath = `/company-data/${cik}.json`;
|
|
143
|
+
const { status, body } = await fetchBytes(session, recordPath, MAX_RECORD_BYTES);
|
|
144
|
+
if (status === 404) throw new Error(`CIK ${cik} is not in the Canli company reference. It covers the SEC filers listed at ${session.base}/companies.`);
|
|
145
|
+
if (status >= 400) throw new Error(`${recordPath} returned HTTP ${status}.`);
|
|
146
|
+
const record = parseJson(body, recordPath);
|
|
147
|
+
const sha = record.source_sha256;
|
|
148
|
+
const snapshotPath = record.source_snapshot;
|
|
149
|
+
if (!/^[0-9a-f]{64}$/.test(sha ?? "") || typeof snapshotPath !== "string" || !snapshotPath.startsWith("/company-data/sources/")) {
|
|
150
|
+
throw new Error(`The company record for CIK ${cik} names no verifiable SEC snapshot.`);
|
|
151
|
+
}
|
|
152
|
+
const facts = parseJson(await snapshotBytes(session, sha, snapshotPath), "The SEC snapshot");
|
|
153
|
+
const entry = {
|
|
154
|
+
cik,
|
|
155
|
+
ticker,
|
|
156
|
+
name: record.name ?? facts.entityName ?? null,
|
|
157
|
+
index: indexFacts(facts),
|
|
158
|
+
page: `${session.base}/companies/${cik}`,
|
|
159
|
+
snapshot: { fetched_at: record.fetched_at ?? null, sha256: sha, url: `${session.base}${snapshotPath}`, sec_url: record.source_url ?? null },
|
|
160
|
+
};
|
|
161
|
+
session.companies.set(cik, { ...entry, ticker: null });
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------------------------
|
|
166
|
+
// The point-in-time index: every reported period with every filing that reported it
|
|
167
|
+
// ---------------------------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const DAY_MS = 86400000;
|
|
170
|
+
const ANNUAL_FORM = /^(10-K|10-KT|20-F|40-F)/;
|
|
171
|
+
const QUARTERLY_FORM = /^10-Q/;
|
|
172
|
+
|
|
173
|
+
// A duration is annual at 350 to 380 days (52- and 53-week years included) and quarterly at 80 to
|
|
174
|
+
// 100; anything else (six- and nine-month year-to-date figures) is "other". An instant (a balance)
|
|
175
|
+
// has no length, so it takes the kind of the report that first carried it: a year-end balance is
|
|
176
|
+
// first reported in an annual report, a quarter-end balance in a 10-Q.
|
|
177
|
+
export function instantKind(firstForm) {
|
|
178
|
+
if (ANNUAL_FORM.test(firstForm ?? "")) return "annual";
|
|
179
|
+
return QUARTERLY_FORM.test(firstForm ?? "") ? "quarterly" : "other";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function durationKind(start, end) {
|
|
183
|
+
const days = Math.round((Date.parse(end) - Date.parse(start)) / DAY_MS) + 1;
|
|
184
|
+
if (days >= 350 && days <= 380) return "annual";
|
|
185
|
+
if (days >= 80 && days <= 100) return "quarterly";
|
|
186
|
+
return "other";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function indexFacts(facts) {
|
|
190
|
+
const concepts = new Map();
|
|
191
|
+
for (const [taxonomy, tags] of Object.entries(facts?.facts ?? {})) {
|
|
192
|
+
for (const [tag, body] of Object.entries(tags ?? {})) {
|
|
193
|
+
const units = {};
|
|
194
|
+
for (const [unit, rows] of Object.entries(body?.units ?? {})) {
|
|
195
|
+
if (!Array.isArray(rows)) continue;
|
|
196
|
+
const periods = new Map();
|
|
197
|
+
for (const r of rows) {
|
|
198
|
+
if (!r || typeof r.end !== "string" || typeof r.filed !== "string" || typeof r.val !== "number") continue;
|
|
199
|
+
const key = `${r.start ?? ""}/${r.end}`;
|
|
200
|
+
let p = periods.get(key);
|
|
201
|
+
if (!p) periods.set(key, (p = { start: r.start ?? null, end: r.end, vintages: [] }));
|
|
202
|
+
p.vintages.push({ val: r.val, filed: r.filed, form: r.form ?? null, accn: r.accn ?? null, fy: r.fy ?? null, fp: r.fp ?? null });
|
|
203
|
+
}
|
|
204
|
+
const list = [...periods.values()];
|
|
205
|
+
for (const p of list) {
|
|
206
|
+
// Filing order: by date, then accession number, so the order never depends on the file.
|
|
207
|
+
p.vintages.sort((a, b) => a.filed.localeCompare(b.filed) || String(a.accn).localeCompare(String(b.accn)));
|
|
208
|
+
p.kind = p.start ? durationKind(p.start, p.end) : instantKind(p.vintages[0].form);
|
|
209
|
+
p.changed = new Set(p.vintages.map((v) => v.val)).size > 1;
|
|
210
|
+
}
|
|
211
|
+
list.sort((a, b) => b.end.localeCompare(a.end) || String(b.start).localeCompare(String(a.start)));
|
|
212
|
+
if (list.length) units[unit] = list;
|
|
213
|
+
}
|
|
214
|
+
if (Object.keys(units).length) concepts.set(`${taxonomy}:${tag}`, { taxonomy, tag, label: body.label ?? tag, units });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return concepts;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const TAXONOMY_ORDER = ["us-gaap", "ifrs-full", "dei", "srt"];
|
|
221
|
+
const conceptName = (c) => (c.taxonomy === "us-gaap" ? c.tag : `${c.taxonomy}:${c.tag}`);
|
|
222
|
+
const periodCount = (c) => Object.values(c.units).reduce((n, list) => n + list.length, 0);
|
|
223
|
+
|
|
224
|
+
function findConcept(entry, concept) {
|
|
225
|
+
const q = concept.trim();
|
|
226
|
+
const lower = q.toLowerCase();
|
|
227
|
+
const all = [...entry.index.values()];
|
|
228
|
+
const exact = q.includes(":")
|
|
229
|
+
? all.filter((c) => `${c.taxonomy}:${c.tag}`.toLowerCase() === lower)
|
|
230
|
+
: all.filter((c) => c.tag.toLowerCase() === lower);
|
|
231
|
+
if (exact.length) {
|
|
232
|
+
return exact.sort((a, b) => rank(a.taxonomy) - rank(b.taxonomy))[0];
|
|
233
|
+
}
|
|
234
|
+
const words = lower.replace(/^[a-z-]+:/, "").match(/[a-z0-9]+/g) ?? [];
|
|
235
|
+
const close = all
|
|
236
|
+
.map((c) => ({ c, hits: words.filter((w) => c.tag.toLowerCase().includes(w) || String(c.label).toLowerCase().includes(w)).length }))
|
|
237
|
+
.filter((r) => r.hits > 0)
|
|
238
|
+
.sort((a, b) => b.hits - a.hits || periodCount(b.c) - periodCount(a.c))
|
|
239
|
+
.slice(0, 8)
|
|
240
|
+
.map((r) => conceptName(r.c));
|
|
241
|
+
throw new Error(`${entry.name} reports no concept named ${q}.${close.length ? ` Close matches: ${close.join(", ")}.` : ""} list_concepts shows every concept it reports.`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const rank = (taxonomy) => {
|
|
245
|
+
const i = TAXONOMY_ORDER.indexOf(taxonomy);
|
|
246
|
+
return i === -1 ? TAXONOMY_ORDER.length : i;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function chooseUnit(concept, unit) {
|
|
250
|
+
const units = Object.keys(concept.units);
|
|
251
|
+
if (unit !== undefined) {
|
|
252
|
+
if (!concept.units[unit]) throw new Error(`${conceptName(concept)} is reported in ${units.join(", ")}, not ${unit}.`);
|
|
253
|
+
return unit;
|
|
254
|
+
}
|
|
255
|
+
return units.sort((a, b) => concept.units[b].length - concept.units[a].length || a.localeCompare(b))[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const matchesKind = (p, periods) => periods === "all" || p.kind === periods;
|
|
259
|
+
|
|
260
|
+
// The vintage a basis selects, and whether any later filing reported a different value.
|
|
261
|
+
function select(p, basis, asOf) {
|
|
262
|
+
let i;
|
|
263
|
+
if (basis === "first_reported") i = 0;
|
|
264
|
+
else if (basis === "latest") i = p.vintages.length - 1;
|
|
265
|
+
else {
|
|
266
|
+
i = -1;
|
|
267
|
+
for (let k = 0; k < p.vintages.length && p.vintages[k].filed <= asOf; k += 1) i = k;
|
|
268
|
+
if (i === -1) return null;
|
|
269
|
+
}
|
|
270
|
+
const v = p.vintages[i];
|
|
271
|
+
const changedLater = p.vintages.slice(i + 1).some((x) => x.val !== v.val);
|
|
272
|
+
return { v, changedLater };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------------------------
|
|
276
|
+
// Tools
|
|
277
|
+
// ---------------------------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
const company = z.string().min(1).max(20).describe("Ticker (AAPL) or SEC CIK.");
|
|
280
|
+
const conceptArg = z.string().min(1).max(200).describe("XBRL concept, e.g. Revenues or Assets (us-gaap unless prefixed, e.g. dei:...).");
|
|
281
|
+
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "a date as YYYY-MM-DD");
|
|
282
|
+
const periodsArg = z.enum(["annual", "quarterly", "all"]).optional().describe("annual (default), quarterly, or all (adds year-to-date).");
|
|
283
|
+
const unitArg = z.string().min(1).max(40).optional().describe("Default: the unit with most periods.");
|
|
284
|
+
|
|
285
|
+
const asText = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], structuredContent: value });
|
|
286
|
+
|
|
287
|
+
function parse(schema, args, tool) {
|
|
288
|
+
const parsed = schema.safeParse(args ?? {});
|
|
289
|
+
if (!parsed.success) throw new Error(`${tool}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ")}`);
|
|
290
|
+
return parsed.data;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const head = (e) => ({
|
|
294
|
+
company: { cik: e.cik, name: e.name, ...(e.ticker ? { ticker: e.ticker } : {}), page: e.page },
|
|
295
|
+
snapshot: e.snapshot,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
export const listInput = z.object({
|
|
299
|
+
company,
|
|
300
|
+
search: z.string().min(1).max(100).optional().describe("Words in concept names or labels, e.g. revenue."),
|
|
301
|
+
limit: z.number().int().min(1).max(300).optional().describe("Default 40."),
|
|
302
|
+
}).strict();
|
|
303
|
+
|
|
304
|
+
export async function toolListConcepts(session, args) {
|
|
305
|
+
const { company: who, search, limit = 40 } = parse(listInput, args, "list_concepts");
|
|
306
|
+
const e = await loadCompany(session, who);
|
|
307
|
+
const words = search ? (search.toLowerCase().match(/[a-z0-9]+/g) ?? []) : [];
|
|
308
|
+
const rows = [...e.index.values()]
|
|
309
|
+
.map((c) => {
|
|
310
|
+
const unit = chooseUnit(c);
|
|
311
|
+
const list = c.units[unit];
|
|
312
|
+
const text = `${c.tag} ${c.label}`.toLowerCase();
|
|
313
|
+
return { c, unit, list, hits: words.filter((w) => text.includes(w)).length };
|
|
314
|
+
})
|
|
315
|
+
.filter((r) => !words.length || r.hits > 0)
|
|
316
|
+
.sort((a, b) => b.hits - a.hits || b.list[0].end.localeCompare(a.list[0].end) || b.list.length - a.list.length || a.c.tag.localeCompare(b.c.tag));
|
|
317
|
+
return asText({
|
|
318
|
+
...head(e),
|
|
319
|
+
concepts: e.index.size,
|
|
320
|
+
matched: rows.length,
|
|
321
|
+
columns: ["concept", "label", "unit", "periods", "first_end", "last_end", "changed_periods"],
|
|
322
|
+
rows: rows.slice(0, limit).map(({ c, unit, list }) => [conceptName(c), c.label, unit, list.length, list[list.length - 1].end, list[0].end, list.filter((p) => p.changed).length]),
|
|
323
|
+
next: "history reads one concept's values; known_as_of reads several as they stood on a date; restatements lists the periods whose value later changed.",
|
|
324
|
+
limits: LIMITS,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const historyInput = z.object({
|
|
329
|
+
company,
|
|
330
|
+
concept: conceptArg,
|
|
331
|
+
basis: z.enum(["first_reported", "latest", "as_of"]).optional().describe("first_reported (default), latest, or as_of (the value known on as_of)."),
|
|
332
|
+
as_of: date.optional().describe("For basis as_of: only filings on or before this date count."),
|
|
333
|
+
periods: periodsArg,
|
|
334
|
+
unit: unitArg,
|
|
335
|
+
limit: z.number().int().min(1).max(400).optional().describe("Default 20, newest first."),
|
|
336
|
+
}).strict();
|
|
337
|
+
|
|
338
|
+
export async function toolHistory(session, args) {
|
|
339
|
+
const { company: who, concept, basis = "first_reported", as_of: asOf, periods = "annual", unit, limit = 20 } = parse(historyInput, args, "history");
|
|
340
|
+
if (basis === "as_of" && !asOf) throw new Error("history: basis as_of needs an as_of date.");
|
|
341
|
+
const e = await loadCompany(session, who);
|
|
342
|
+
const c = findConcept(e, concept);
|
|
343
|
+
const u = chooseUnit(c, unit);
|
|
344
|
+
const picked = c.units[u]
|
|
345
|
+
.filter((p) => matchesKind(p, periods))
|
|
346
|
+
.map((p) => ({ p, s: select(p, basis, asOf) }))
|
|
347
|
+
.filter((r) => r.s);
|
|
348
|
+
return asText({
|
|
349
|
+
...head(e),
|
|
350
|
+
concept: { name: conceptName(c), label: c.label, unit: u, units: Object.keys(c.units) },
|
|
351
|
+
basis,
|
|
352
|
+
...(basis === "as_of" ? { as_of: asOf } : {}),
|
|
353
|
+
periods,
|
|
354
|
+
total: picked.length,
|
|
355
|
+
columns: ["end", "start", "val", "filed", "form", "accn", "changed"],
|
|
356
|
+
rows: picked.slice(0, limit).map(({ p, s }) => [p.end, p.start, s.v.val, s.v.filed, s.v.form, s.v.accn, basis === "latest" ? p.changed : s.changedLater]),
|
|
357
|
+
meaning: basis === "latest"
|
|
358
|
+
? "changed: this period was reported with a different value in at least one earlier filing."
|
|
359
|
+
: "changed: a filing after the one shown reported a different value for the period (see restatements or vintages).",
|
|
360
|
+
limits: LIMITS,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// When no concepts are named, known_as_of reads these; for each, the first name the company
|
|
365
|
+
// actually uses as of the date wins, since companies move between tags (Apple reported revenue as
|
|
366
|
+
// SalesRevenueNet before 2018 and as RevenueFromContractWithCustomerExcludingAssessedTax after).
|
|
367
|
+
export const DEFAULT_CONCEPTS = Object.freeze([
|
|
368
|
+
["Revenue", ["Revenues", "RevenueFromContractWithCustomerExcludingAssessedTax", "RevenueFromContractWithCustomerIncludingAssessedTax", "SalesRevenueNet"]],
|
|
369
|
+
["NetIncomeLoss", ["NetIncomeLoss", "ProfitLoss"]],
|
|
370
|
+
["OperatingIncomeLoss", ["OperatingIncomeLoss"]],
|
|
371
|
+
["EarningsPerShareDiluted", ["EarningsPerShareDiluted"]],
|
|
372
|
+
["Assets", ["Assets"]],
|
|
373
|
+
["Liabilities", ["Liabilities"]],
|
|
374
|
+
["StockholdersEquity", ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"]],
|
|
375
|
+
["CashAndCashEquivalentsAtCarryingValue", ["CashAndCashEquivalentsAtCarryingValue", "CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents"]],
|
|
376
|
+
["NetCashProvidedByUsedInOperatingActivities", ["NetCashProvidedByUsedInOperatingActivities"]],
|
|
377
|
+
["WeightedAverageNumberOfDilutedSharesOutstanding", ["WeightedAverageNumberOfDilutedSharesOutstanding"]],
|
|
378
|
+
]);
|
|
379
|
+
|
|
380
|
+
export const knownInput = z.object({
|
|
381
|
+
company,
|
|
382
|
+
as_of: date.describe("Decision date: only filings on or before it count."),
|
|
383
|
+
concepts: z.array(z.string().min(1).max(200)).min(1).max(20).optional().describe("Default: ten common ones (revenue, net income, EPS, assets, equity, cash flow...)."),
|
|
384
|
+
periods: z.enum(["annual", "quarterly"]).optional().describe("annual (default) or quarterly."),
|
|
385
|
+
}).strict();
|
|
386
|
+
|
|
387
|
+
// For one concept: the latest-ending period with a value filed on or before the date, and that value.
|
|
388
|
+
function knownFor(c, asOf, periods) {
|
|
389
|
+
const u = chooseUnit(c);
|
|
390
|
+
for (const p of c.units[u]) {
|
|
391
|
+
if (p.kind !== periods) continue;
|
|
392
|
+
const s = select(p, "as_of", asOf);
|
|
393
|
+
if (s) return { u, p, s };
|
|
394
|
+
}
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function toolKnownAsOf(session, args) {
|
|
399
|
+
const { company: who, as_of: asOf, concepts, periods = "annual" } = parse(knownInput, args, "known_as_of");
|
|
400
|
+
const e = await loadCompany(session, who);
|
|
401
|
+
const wanted = concepts ? concepts.map((name) => [name, [name]]) : DEFAULT_CONCEPTS;
|
|
402
|
+
const rows = [];
|
|
403
|
+
const missing = [];
|
|
404
|
+
for (const [label, names] of wanted) {
|
|
405
|
+
let best = null;
|
|
406
|
+
for (const name of names) {
|
|
407
|
+
let c;
|
|
408
|
+
try {
|
|
409
|
+
c = findConcept(e, name);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (concepts) throw err;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
const hit = knownFor(c, asOf, periods);
|
|
415
|
+
if (hit && (!best || hit.p.end > best.hit.p.end)) best = { c, hit };
|
|
416
|
+
}
|
|
417
|
+
if (!best) {
|
|
418
|
+
missing.push(label);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const { c, hit } = best;
|
|
422
|
+
rows.push([conceptName(c), hit.p.end, hit.p.start, hit.s.v.val, hit.u, hit.s.v.filed, hit.s.v.form, hit.s.v.accn, hit.s.changedLater]);
|
|
423
|
+
}
|
|
424
|
+
return asText({
|
|
425
|
+
...head(e),
|
|
426
|
+
as_of: asOf,
|
|
427
|
+
periods,
|
|
428
|
+
columns: ["concept", "end", "start", "val", "unit", "filed", "form", "accn", "changed_after"],
|
|
429
|
+
rows,
|
|
430
|
+
...(missing.length ? { missing } : {}),
|
|
431
|
+
meaning: "Each row is the most recent period whose value had been filed on or before as_of, with the value as it stood then. changed_after: a later filing reported a different value for that period, so a backtest reading today's data would have used a number nobody knew on as_of.",
|
|
432
|
+
limits: LIMITS,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export const restatementsInput = z.object({
|
|
437
|
+
company,
|
|
438
|
+
concept: conceptArg.optional().describe("Default: every concept."),
|
|
439
|
+
periods: periodsArg,
|
|
440
|
+
since: date.optional().describe("Only periods ending on or after."),
|
|
441
|
+
min_change_pct: z.number().min(0).max(1e6).optional().describe("Minimum absolute change, % of first value."),
|
|
442
|
+
limit: z.number().int().min(1).max(400).optional().describe("Default 25, newest first."),
|
|
443
|
+
}).strict();
|
|
444
|
+
|
|
445
|
+
export async function toolRestatements(session, args) {
|
|
446
|
+
const { company: who, concept, periods = "annual", since, min_change_pct: minPct = 0, limit = 25 } = parse(restatementsInput, args, "restatements");
|
|
447
|
+
const e = await loadCompany(session, who);
|
|
448
|
+
const scope = concept ? [findConcept(e, concept)] : [...e.index.values()];
|
|
449
|
+
let scanned = 0;
|
|
450
|
+
const found = [];
|
|
451
|
+
for (const c of scope) {
|
|
452
|
+
for (const [u, list] of Object.entries(c.units)) {
|
|
453
|
+
for (const p of list) {
|
|
454
|
+
if (!matchesKind(p, periods) || (since && p.end < since)) continue;
|
|
455
|
+
scanned += 1;
|
|
456
|
+
if (!p.changed) continue;
|
|
457
|
+
const first = p.vintages[0];
|
|
458
|
+
const last = p.vintages[p.vintages.length - 1];
|
|
459
|
+
const pct = first.val === 0 ? null : ((last.val - first.val) / Math.abs(first.val)) * 100;
|
|
460
|
+
if (minPct > 0 && (pct === null || Math.abs(pct) < minPct)) continue;
|
|
461
|
+
found.push({ c, u, p, first, last, pct });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
found.sort((a, b) => b.p.end.localeCompare(a.p.end) || Math.abs(b.pct ?? 0) - Math.abs(a.pct ?? 0) || conceptName(a.c).localeCompare(conceptName(b.c)));
|
|
466
|
+
const round = (x) => (x === null ? null : Math.round(x * 100) / 100);
|
|
467
|
+
return asText({
|
|
468
|
+
...head(e),
|
|
469
|
+
periods,
|
|
470
|
+
scanned,
|
|
471
|
+
changed: found.length,
|
|
472
|
+
columns: ["concept", "unit", "end", "start", "first_val", "first_filed", "latest_val", "latest_filed", "change_pct", "filings"],
|
|
473
|
+
rows: found.slice(0, limit).map(({ c, u, p, first, last, pct }) => [conceptName(c), u, p.end, p.start, first.val, first.filed, last.val, last.filed, round(pct), p.vintages.length]),
|
|
474
|
+
meaning: "Periods whose value in the latest filing differs from the first report. change_pct is relative to the first reported value (null when that was zero); vintages shows every filing in between.",
|
|
475
|
+
limits: LIMITS,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export const vintagesInput = z.object({
|
|
480
|
+
company,
|
|
481
|
+
concept: conceptArg,
|
|
482
|
+
end: date.describe("Period end."),
|
|
483
|
+
start: date.optional().describe("Period start, when several periods share the end (quarter vs year-to-date)."),
|
|
484
|
+
unit: unitArg,
|
|
485
|
+
}).strict();
|
|
486
|
+
|
|
487
|
+
export async function toolVintages(session, args) {
|
|
488
|
+
const { company: who, concept, end, start, unit } = parse(vintagesInput, args, "vintages");
|
|
489
|
+
const e = await loadCompany(session, who);
|
|
490
|
+
const c = findConcept(e, concept);
|
|
491
|
+
const u = chooseUnit(c, unit);
|
|
492
|
+
const hits = c.units[u].filter((p) => p.end === end && (start === undefined || p.start === start));
|
|
493
|
+
if (!hits.length) throw new Error(`${e.name} reports no ${conceptName(c)} period ending ${end}${start ? ` and starting ${start}` : ""} in ${u}; history lists the periods it does report.`);
|
|
494
|
+
const rows = [];
|
|
495
|
+
for (const p of hits) for (const v of p.vintages) rows.push([p.start, p.end, v.filed, v.form, v.accn, v.fy, v.fp, v.val]);
|
|
496
|
+
return asText({
|
|
497
|
+
...head(e),
|
|
498
|
+
concept: { name: conceptName(c), label: c.label, unit: u },
|
|
499
|
+
columns: ["start", "end", "filed", "form", "accn", "fy", "fp", "val"],
|
|
500
|
+
rows,
|
|
501
|
+
filing_url: `https://www.sec.gov/Archives/edgar/data/${Number(e.cik)}/{accn without dashes}/`,
|
|
502
|
+
meaning: "Every filing that reported this period, oldest first. fy and fp are the fiscal year and period of the filing that carried the value, not of the period itself.",
|
|
503
|
+
limits: LIMITS,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
508
|
+
|
|
509
|
+
export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
510
|
+
known_as_of: "What a company had reported as of a date: per concept, the latest period filed by as_of, its value then, and whether it was later restated. For point-in-time backtests and 'what was known on date X'.",
|
|
511
|
+
history: "One concept over time, newest first: as first reported (default), as latest filed, or as known on as_of, each with its filing and whether it later changed.",
|
|
512
|
+
restatements: "Periods whose value changed in a later filing, with first and latest values and % change. Omit concept to scan everything.",
|
|
513
|
+
vintages: "Every filing that reported one period of one concept, oldest first: the revision history behind a number.",
|
|
514
|
+
list_concepts: "The XBRL concepts a company reports, with units, periods, date range and changed-period counts. Search here for exact concept names.",
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Output schemas: published OPEN (extra fields always pass), every field optional. A client
|
|
518
|
+
// validates a result against the schema it listed, and a closed schema turns any field a later
|
|
519
|
+
// version adds into a failed call.
|
|
520
|
+
const table = { columns: z.array(z.string()).optional(), rows: z.array(z.unknown()).optional() };
|
|
521
|
+
const common = { company: z.looseObject({}).optional(), snapshot: z.looseObject({}).optional(), limits: z.array(z.string()).optional(), meaning: z.string().optional() };
|
|
522
|
+
export const OUTPUT_SCHEMAS = Object.freeze({
|
|
523
|
+
known_as_of: z.looseObject({ ...common, as_of: z.string().optional(), ...table, missing: z.array(z.string()).optional() })
|
|
524
|
+
.describe("rows: one per concept in the order of columns, the value as it stood on as_of; changed_after flags values later restated."),
|
|
525
|
+
history: z.looseObject({ ...common, concept: z.looseObject({}).optional(), basis: z.string().optional(), total: z.number().optional(), ...table })
|
|
526
|
+
.describe("rows: one per period, newest first, in the order of columns."),
|
|
527
|
+
restatements: z.looseObject({ ...common, scanned: z.number().optional(), changed: z.number().optional(), ...table })
|
|
528
|
+
.describe("rows: one per changed period, newest first, with first and latest values and change_pct."),
|
|
529
|
+
vintages: z.looseObject({ ...common, concept: z.looseObject({}).optional(), ...table, filing_url: z.string().optional() })
|
|
530
|
+
.describe("rows: every filing that reported the period, oldest first."),
|
|
531
|
+
list_concepts: z.looseObject({ ...common, concepts: z.number().optional(), matched: z.number().optional(), ...table })
|
|
532
|
+
.describe("rows: one per concept in the order of columns; concept is the name the other tools take."),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
export function registerTools(server, session) {
|
|
536
|
+
const tool = (name, title, inputSchema, fn) => server.registerTool(name, { title, annotations: { title, ...READ_ONLY }, description: TOOL_DESCRIPTIONS[name], inputSchema, outputSchema: OUTPUT_SCHEMAS[name] }, fn);
|
|
537
|
+
tool("known_as_of", "Known as of a date", knownInput, (args) => toolKnownAsOf(session, args));
|
|
538
|
+
tool("history", "Concept history", historyInput, (args) => toolHistory(session, args));
|
|
539
|
+
tool("restatements", "Restated periods", restatementsInput, (args) => toolRestatements(session, args));
|
|
540
|
+
tool("vintages", "Filing vintages", vintagesInput, (args) => toolVintages(session, args));
|
|
541
|
+
tool("list_concepts", "List concepts", listInput, (args) => toolListConcepts(session, args));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const isMain = (() => {
|
|
545
|
+
try {
|
|
546
|
+
return realpathSync(process.argv[1] ?? "") === realpathSync(fileURLToPath(import.meta.url));
|
|
547
|
+
} catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
})();
|
|
551
|
+
|
|
552
|
+
if (isMain) {
|
|
553
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
554
|
+
registerTools(server, createSession());
|
|
555
|
+
await server.connect(new StdioServerTransport());
|
|
556
|
+
}
|