mcp-context-cost 0.13.2 → 0.14.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.
@@ -0,0 +1,355 @@
1
+ /**
2
+ * The rules behind a registry scan, offline and under test. The part that
3
+ * talks to the network is `tools/scan-registry.ts`; this module is what it
4
+ * applies to whatever the network returned.
5
+ *
6
+ * The 2026-09-04 expansion of `servers.yaml` — the block opening "REGISTRY
7
+ * LONG-TAIL EXPANSION (2026-09-04)" — was produced by a script that was never
8
+ * committed. The comment above that block is the whole record of the method:
9
+ * scan the official registry, rank the npm/PyPI stdio packages by weekly
10
+ * downloads, check provenance by org and repo, append. A method that exists
11
+ * only as a comment cannot be re-run, and a count it states ("14,283 distinct
12
+ * active servers") cannot be reproduced. This is that scan, written down.
13
+ *
14
+ * Two things it deliberately is not.
15
+ *
16
+ * It is not a provenance rule. The comment says the entries were
17
+ * "provenance-checked by org and repo", but no rule for that check is recorded
18
+ * anywhere in the repository — the reasons for what was left out were written
19
+ * in a scratchpad, not here. So a candidate carries the two strings that
20
+ * judgment compared, `nameOwner` (the `<owner>` in an `io.github.<owner>/…`
21
+ * registry name, null for the reverse-DNS names that make up a large share of
22
+ * the registry) and `repoOwner` (the first path segment of `repository.url`),
23
+ * and nothing else: no `ownersAgree`, no verdict. The comparison is a human
24
+ * step, and the expansion commit that uses this output is where it is
25
+ * recorded.
26
+ *
27
+ * It is not a source of entries, only of drafts. A registry package id does
28
+ * not reveal how the server is launched: `agent-device` needs `mcp`,
29
+ * `githits` needs `mcp start`, `emailmd` needs `mcp`, `hana-cli` ships a
30
+ * separate `hana-cli-mcp` bin, and `@ankimcp/anki-mcp-server` serves HTTP
31
+ * unless told `--stdio` (servers.yaml, the comments beside each). Most records
32
+ * carry no `runtimeHint`, so most draft commands are the `npx -y <pkg>` /
33
+ * `uvx <pkg>` guess, which is exactly the class those five belonged to.
34
+ * `draftEntry` therefore marks a guessed command as guessed, and every draft is
35
+ * probed by the harness before it becomes a row.
36
+ *
37
+ * What the registry actually does, verified live on 2026-09-05 and pinned in
38
+ * `test/fixtures/registry/`: `GET /v0/servers?version=latest` returns one row
39
+ * per name — the server does the dedupe, so none is done here — but among
40
+ * those rows are `deprecated` ones, so the client still filters on
41
+ * `_meta['io.modelcontextprotocol.registry/official'].status`. `limit` is
42
+ * capped at 100 (500 is answered 422). `metadata.nextCursor` is a
43
+ * `name:version` string and is absent on the last page.
44
+ */
45
+ import type { ServerEntry } from './report.js';
46
+ export declare const SCAN_METHOD = "registry-scan/1";
47
+ export declare const REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0/servers";
48
+ /** The largest `limit` the registry accepts; a larger one is answered 422. */
49
+ export declare const REGISTRY_PAGE_LIMIT = 100;
50
+ /**
51
+ * Names per npm bulk lookup. The live response to a 130-name request is
52
+ * `{"error":"exceeded max bulk size of 128"}` (test/fixtures/registry/npm-bulk-130.json).
53
+ */
54
+ export declare const NPM_BULK_LIMIT = 128;
55
+ /** The `_meta` key the registry files its own status under. */
56
+ export declare const OFFICIAL_META = "io.modelcontextprotocol.registry/official";
57
+ export interface RegistryArgument {
58
+ type?: string;
59
+ name?: string;
60
+ value?: string;
61
+ valueHint?: string;
62
+ isRequired?: boolean;
63
+ description?: string;
64
+ }
65
+ export interface RegistryEnvironmentVariable {
66
+ name: string;
67
+ isRequired?: boolean;
68
+ isSecret?: boolean;
69
+ description?: string;
70
+ }
71
+ export interface RegistryPackage {
72
+ registryType: string;
73
+ identifier: string;
74
+ version?: string;
75
+ runtimeHint?: string;
76
+ runtimeArguments?: RegistryArgument[];
77
+ packageArguments?: RegistryArgument[];
78
+ transport?: {
79
+ type?: string;
80
+ };
81
+ environmentVariables?: RegistryEnvironmentVariable[];
82
+ }
83
+ export interface RegistryRecord {
84
+ server: {
85
+ name: string;
86
+ version: string;
87
+ title?: string;
88
+ description?: string;
89
+ repository?: {
90
+ url: string;
91
+ source?: string;
92
+ subfolder?: string;
93
+ };
94
+ packages?: RegistryPackage[];
95
+ remotes?: {
96
+ type: string;
97
+ url: string;
98
+ }[];
99
+ };
100
+ _meta?: {
101
+ [OFFICIAL_META]?: {
102
+ status?: string;
103
+ isLatest?: boolean;
104
+ publishedAt?: string;
105
+ updatedAt?: string;
106
+ };
107
+ };
108
+ }
109
+ export interface RegistryPage {
110
+ servers: RegistryRecord[];
111
+ metadata?: {
112
+ nextCursor?: string;
113
+ count?: number;
114
+ };
115
+ }
116
+ export interface WalkResult {
117
+ records: RegistryRecord[];
118
+ pages: number;
119
+ /** True when the walk did not cover the whole registry: a page cap stopped it, or it started from a cursor. */
120
+ truncated: boolean;
121
+ /** The cursor the walk started from, when it did not start at the beginning. */
122
+ startedAt?: string;
123
+ /** Where a truncated walk would resume; absent when the last page was reached. */
124
+ lastCursor?: string;
125
+ }
126
+ /**
127
+ * Page through `version=latest` until the registry stops handing out a cursor.
128
+ *
129
+ * The fetch is a parameter so the walk itself is under test: the loop that
130
+ * decides when to stop, and whether stopping early is *said*, is the part that
131
+ * would otherwise live only in the script nothing imports. `measure-adoption.ts`
132
+ * has the same MAX_PAGES rule and no test holds it.
133
+ *
134
+ * `truncated` is never silent. It is true whenever the records do not cover
135
+ * the whole registry — the page cap fired while a next cursor was still
136
+ * present, or the walk was resumed from a cursor and so never saw what came
137
+ * before it. `lastCursor` is where to resume.
138
+ */
139
+ export declare function walkLatest(fetchPage: (cursor?: string) => Promise<RegistryPage>, opts?: {
140
+ maxPages?: number;
141
+ cursor?: string;
142
+ }): Promise<WalkResult>;
143
+ export declare function isActive(record: RegistryRecord): boolean;
144
+ export declare function distinctNames(records: RegistryRecord[]): number;
145
+ /** The `<owner>` of an `io.github.<owner>/…` name; null for every other namespace. */
146
+ export declare function registryNameOwner(name: string): string | null;
147
+ /** The first path segment of a repository URL — the GitHub org or user — verbatim, or null. */
148
+ export declare function repositoryOwner(url: string | undefined): string | null;
149
+ export type PackageRegistry = 'npm' | 'pypi';
150
+ export interface ScanCandidate {
151
+ registryName: string;
152
+ version: string;
153
+ description?: string;
154
+ registry: PackageRegistry;
155
+ pkg: string;
156
+ packageVersion?: string;
157
+ runtimeHint?: string;
158
+ runtimeArguments?: RegistryArgument[];
159
+ packageArguments?: RegistryArgument[];
160
+ environmentVariables: RegistryEnvironmentVariable[];
161
+ repositoryUrl: string | null;
162
+ /** The two strings the provenance judgment compares. Emitted, not compared. */
163
+ nameOwner: string | null;
164
+ repoOwner: string | null;
165
+ }
166
+ export declare function metricKey(registry: PackageRegistry, pkg: string): string;
167
+ /** The `package:` spellings that name a package, as opposed to a docker image, a git URL or a remote. */
168
+ export declare const PACKAGE_ID: RegExp;
169
+ /**
170
+ * Registry-qualified keys for every package `servers.yaml` already names.
171
+ *
172
+ * `package:` is free text in the file. Beside plain npm names and the
173
+ * `<name> (PyPI)` spelling it holds `ghcr.io/… (docker)`, `git+https://…`,
174
+ * `remote via mcp-remote bridge (no auth)` and `remote (Streamable HTTP,
175
+ * OAuth)`; none of those is a package this scan could find, so none is added.
176
+ * The keys are qualified by registry because one string can name two
177
+ * packages — `basic-memory` on PyPI is not `basic-memory` on npm — and the
178
+ * PyPI suffix is the only thing that tells them apart.
179
+ */
180
+ export declare function trackedPackages(doc: unknown): Set<string>;
181
+ export declare function alreadyTracked(registry: PackageRegistry, pkg: string, tracked: Set<string>): boolean;
182
+ /**
183
+ * One candidate per untracked npm/PyPI stdio package on an active record.
184
+ *
185
+ * A package listed by two registry names is one package; the first record to
186
+ * name it wins and the second is dropped, so the download lookup is made once
187
+ * and the draft is drafted once. Records with only `remotes`, or only `oci`
188
+ * packages, contribute nothing: this harness measures what `npx`/`uvx` can
189
+ * start, and a remote endpoint or a container image is a different entry
190
+ * shape a human writes by hand.
191
+ */
192
+ export declare function candidatesFrom(records: RegistryRecord[], tracked: Set<string>): ScanCandidate[];
193
+ /**
194
+ * Which names go to which npm endpoint. The bulk endpoint refuses a scoped
195
+ * name outright — `{"error":"scoped packages are not currently supported in
196
+ * bulk lookups"}`, recorded live — so scoped names each get a single lookup,
197
+ * and unscoped names are chunked at the bulk ceiling.
198
+ *
199
+ * A chunk is never one name long. npm decides the response shape from the
200
+ * URL, not from the caller's intent: a comma-joined list is answered as a map
201
+ * keyed by name, and a lone name — the same URL a single lookup uses — is
202
+ * answered in the flat `{downloads, start, end, package}` shape
203
+ * (test/fixtures/registry/npm-single-unscoped.json is the response to exactly
204
+ * such a URL). The first version of this function chunked without that rule,
205
+ * so any run with 1 (mod NPM_BULK_LIMIT) unscoped candidates sent its last
206
+ * name alone, `parseNpmBulk` iterated the flat object as if it were a map,
207
+ * filed `downloads`/`start`/`end`/`package` as package names with no figure,
208
+ * and the real package got no entry at all. A one-page smoke run on 2026-09-05
209
+ * showed it: `pretrip-mcp`, 90 downloads that week by the single endpoint,
210
+ * was refused as "no weekly-download figure". A trailing single name goes to
211
+ * the single endpoint, which accepts unscoped names, and the bulk parser
212
+ * refuses the flat shape outright so the mistake cannot come back quietly.
213
+ */
214
+ export declare function splitForNpm(names: string[]): {
215
+ bulk: string[][];
216
+ single: string[];
217
+ };
218
+ /**
219
+ * The bulk shape is a map keyed by package name; an unknown name maps to
220
+ * `null` (live: `"no-such-package-…":null` beside two real rows). Null is
221
+ * "no figure", never zero — a zero would rank the package, a null keeps it
222
+ * out of the drafted set with a reason.
223
+ *
224
+ * The single flat shape is refused, not iterated. Iterating it is what filed
225
+ * `npm:downloads` and `npm:start` as package names and lost `pretrip-mcp`'s
226
+ * figure (see `splitForNpm`); a top-level numeric `downloads` beside a string
227
+ * `package` is that shape and nothing else, since a package name cannot be
228
+ * the bare word `downloads` beside a number.
229
+ */
230
+ export declare function parseNpmBulk(body: unknown): Map<string, number | null>;
231
+ /** The single shape is flat `{downloads, start, end, package}`; a 404 body is `{error}` and reads as no figure. */
232
+ export declare function parseNpmSingle(body: unknown): number | null;
233
+ /** pypistats `recent`: `{data:{last_day,last_month,last_week},package,type}`. Anything else is no figure. */
234
+ export declare function parsePypi(body: unknown): number | null;
235
+ /**
236
+ * The name pypistats keys a package by: PEP 503 normalised, so runs of `.`,
237
+ * `_` and `-` become one `-`. `servers.yaml` already cites it that way — the
238
+ * `aws-documentation` row's package is `awslabs.aws-documentation-mcp-server`
239
+ * and its source is `pypistats.org/packages/awslabs-aws-documentation-mcp-server`
240
+ * — and the test that compares every row against `metricSourceFor` is what
241
+ * found the difference.
242
+ */
243
+ export declare function pypiName(pkg: string): string;
244
+ /**
245
+ * The citation forms `servers.yaml` uses, base form only. Some rows carry a
246
+ * suffix after the form (`npm weekly; uses ~/.kube/config`, `npm weekly,
247
+ * 2026-08-09..15`); those are the human's note and are not generated here.
248
+ * PyPI cites the HTML page, not the API URL the figure was read from, because
249
+ * that is what every PyPI row in the file cites.
250
+ */
251
+ export declare function metricSourceFor(registry: PackageRegistry, pkg: string): string;
252
+ export type Draft = {
253
+ entry: ServerEntry;
254
+ commandGuessed: boolean;
255
+ optionalEnv: string[];
256
+ } | {
257
+ refused: string;
258
+ };
259
+ /**
260
+ * The launch line, and whether it is a guess.
261
+ *
262
+ * With the conventional `runtimeHint` (`npx` for npm, `uvx` for PyPI) and
263
+ * `runtimeArguments` the record says how it is run (`npx -y keyblind` then
264
+ * `start`, live) and the draft repeats it, not guessed. With the conventional
265
+ * hint alone the draft is the conventional line. With no hint — most records —
266
+ * it is `npx -y <pkg>` / `uvx <pkg>`, and `guessed` is true so the operator
267
+ * can see which drafts are the class that needed a subcommand.
268
+ *
269
+ * An unconventional hint is a guess whether or not arguments come with it.
270
+ * `ai.callmcp/server` (page2.json) carries `runtimeHint: "node"` for an npm
271
+ * package; the harness starts npm packages through `npx`, so a `node …` line
272
+ * is one it has to probe, not one the record vouched for. The first version
273
+ * of this function let a hint-with-arguments through as not-a-guess whatever
274
+ * the hint was, which would have marked `node dist/index.js <pkg>` as
275
+ * recorded fact. The arguments are still carried into the line, because they
276
+ * are the record's own account of what the runtime is given.
277
+ *
278
+ * A required argument the registry only describes (`<project_root>`) is left
279
+ * as a placeholder, which also marks the command guessed: it cannot run as
280
+ * written.
281
+ */
282
+ export declare function draftCommand(c: ScanCandidate): {
283
+ command: string;
284
+ guessed: boolean;
285
+ };
286
+ export declare function draftName(pkg: string): string;
287
+ /**
288
+ * A draft entry in the shape `servers.yaml` takes, or a refusal that says why.
289
+ *
290
+ * Total over its input by design. Drawn from live records, drafts fail the
291
+ * schema for ordinary reasons — about half the records on a page carry no
292
+ * `repository.url`, a package nobody downloads has no figure, and an env
293
+ * name like `2Captcha_API_KEY` (live) is not a variable name a shell accepts.
294
+ * Each of those is a fact worth printing beside the candidate, not a crash
295
+ * and not a row with a field quietly missing; so the refusal carries the
296
+ * reason, and the ranked output lists refused candidates alongside drafted
297
+ * ones.
298
+ *
299
+ * `env` lists the variables the record marks required; the others are carried
300
+ * beside the draft as `optionalEnv`, because docker mode injects a placeholder
301
+ * for every listed name and a placeholder port or URL is the class of failure
302
+ * `envValues` exists to undo (servers.yaml, the `hevy` and `keboola` comments).
303
+ */
304
+ export declare function draftEntry(c: ScanCandidate, metric: number | null, why?: string): Draft;
305
+ export interface RankedCandidate extends ScanCandidate {
306
+ metric: number | null;
307
+ metricSource: string;
308
+ draft: Draft;
309
+ }
310
+ /**
311
+ * Rank by weekly downloads, unmetered last, and draft each one. `metrics` is
312
+ * keyed by `metricKey`; a candidate the lookup never reached reads as null.
313
+ */
314
+ /**
315
+ * `reasons` says why a figure is missing where the tool knows — a lookup that
316
+ * gave up on a 429, a host that rate-limited the rest of the pass. The first
317
+ * real run of the scan died on one package's 429 after a seven-minute crawl
318
+ * and wrote nothing; now the package is refused with that reason and the run
319
+ * goes on, which is the only way an output ever gets written.
320
+ */
321
+ export declare function rankCandidates(candidates: ScanCandidate[], metrics: Map<string, number | null>, reasons?: Map<string, string>): RankedCandidate[];
322
+ export interface ScanOutput {
323
+ method: string;
324
+ scannedAt: string;
325
+ registry: string;
326
+ pages: number;
327
+ truncated: boolean;
328
+ startedAt?: string;
329
+ lastCursor?: string;
330
+ records: number;
331
+ distinctLatest: number;
332
+ active: number;
333
+ candidates: number;
334
+ drafted: number;
335
+ refused: number;
336
+ /** Candidates refused only because a download figure could not be fetched this run, and why. */
337
+ unmetered?: {
338
+ count: number;
339
+ why: string;
340
+ };
341
+ elapsedSeconds: number;
342
+ provenance: string;
343
+ ranked: RankedCandidate[];
344
+ }
345
+ export declare const PROVENANCE_NOTE = "nameOwner and repoOwner are the two strings the provenance judgment compares; the judgment is made by a person and recorded in the expansion commit, not here.";
346
+ export declare function assembleScan(walk: WalkResult, ranked: RankedCandidate[], meta: {
347
+ scannedAt: string;
348
+ elapsedSeconds: number;
349
+ unmetered?: {
350
+ count: number;
351
+ why: string;
352
+ };
353
+ }): ScanOutput;
354
+ /** The one line an expansion commit quotes. Every number in it is in the JSON beside it. */
355
+ export declare function summaryLine(scan: ScanOutput): string;