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.
- package/README.md +23 -7
- package/dist/sweep/cross-check.js +2 -2
- package/dist/sweep/pr-check.d.ts +62 -0
- package/dist/sweep/pr-check.js +296 -0
- package/dist/sweep/registry-scan.d.ts +355 -0
- package/dist/sweep/registry-scan.js +432 -0
- package/dist/sweep/run.d.ts +17 -0
- package/dist/sweep/run.js +49 -9
- package/dist/sweep/servers-schema.d.ts +7 -0
- package/dist/sweep/servers-schema.js +8 -1
- package/package.json +2 -1
|
@@ -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;
|