@modootoday/pmcp 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 +93 -0
- package/NOTICE +49 -0
- package/README.md +69 -0
- package/SECURITY.md +27 -0
- package/dist/chunk-UTABV2C4.js +33 -0
- package/dist/chunk-WIVQKKE4.js +363 -0
- package/dist/chunk-X3KWFZBP.js +134 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1885 -0
- package/dist/index.d.ts +263 -0
- package/dist/index.js +43 -0
- package/dist/stdio.d.ts +14 -0
- package/dist/stdio.js +13 -0
- package/package.json +96 -0
- package/skills/package-skill-catalog/SKILL.md +237 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intent key and the cache contract.
|
|
5
|
+
*
|
|
6
|
+
* Separate from the sqlite implementation so that ranking, which needs the key
|
|
7
|
+
* and the interface, does not pull a bun-only module into a consumer running
|
|
8
|
+
* under node. Only `cache.ts` requires bun.
|
|
9
|
+
*/
|
|
10
|
+
interface IntentCacheStats {
|
|
11
|
+
readonly hits: number;
|
|
12
|
+
readonly misses: number;
|
|
13
|
+
/** Rows found for the key but written by a different model. */
|
|
14
|
+
readonly mismatched: number;
|
|
15
|
+
readonly rows: number;
|
|
16
|
+
/** Which sqlite builtin answered, or absent when there is no cache. */
|
|
17
|
+
readonly provider?: string;
|
|
18
|
+
}
|
|
19
|
+
interface IntentCache {
|
|
20
|
+
/** The cached vector for an intent, or null when nothing usable is stored. */
|
|
21
|
+
get(intent: string): Float32Array | null;
|
|
22
|
+
put(intent: string, vector: Float32Array): void;
|
|
23
|
+
stats(): IntentCacheStats;
|
|
24
|
+
close(): void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Same question, differently typed. Case, punctuation and spacing only — the
|
|
28
|
+
* key has to survive a caller writing "How do I wire X?" and "how do i wire x".
|
|
29
|
+
*/
|
|
30
|
+
declare function normaliseIntent(intent: string): string;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Query vectors, kept on disk between sessions.
|
|
34
|
+
*
|
|
35
|
+
* An MCP server starts per session and exits with it, so a process-local cache
|
|
36
|
+
* begins empty every time and the same question pays its full embed cost in
|
|
37
|
+
* every session that asks it. The table outlives the process; that is the whole
|
|
38
|
+
* reason it is a table.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface IntentCacheOptions {
|
|
42
|
+
/** Database file. `:memory:` is accepted and is what the tests use. */
|
|
43
|
+
readonly path: string;
|
|
44
|
+
/** The embedder's identity. Rows from another model are never scored. */
|
|
45
|
+
readonly modelId: string;
|
|
46
|
+
/** Vector width. A row of a different width is a different model. */
|
|
47
|
+
readonly dims: number;
|
|
48
|
+
/** Rows kept. The least recently asked are dropped past this. */
|
|
49
|
+
readonly limit?: number;
|
|
50
|
+
/** Injected for tests. Real time otherwise. */
|
|
51
|
+
readonly now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
declare const DEFAULT_INTENT_CACHE_LIMIT = 4096;
|
|
54
|
+
/**
|
|
55
|
+
* Opens the cache, or returns null when it cannot be opened.
|
|
56
|
+
*
|
|
57
|
+
* Null is the normal answer on a read-only volume, a locked file, a corrupt
|
|
58
|
+
* database, or a runtime with neither builtin: the caller embeds on demand
|
|
59
|
+
* instead. A cache that throws would turn a missing optimisation into a failed
|
|
60
|
+
* search, and an older runtime is that case rather than an error.
|
|
61
|
+
*/
|
|
62
|
+
declare function openIntentCache(options: IntentCacheOptions): Promise<IntentCache | null>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The skills the installed packages brought with them.
|
|
66
|
+
*
|
|
67
|
+
* Derived from `node_modules` at read time rather than from a list somebody
|
|
68
|
+
* maintains: installing a package is how its skill arrives and uninstalling it
|
|
69
|
+
* is how the skill leaves, so the catalog is always true of the project.
|
|
70
|
+
*/
|
|
71
|
+
interface SkillEntry {
|
|
72
|
+
/** `<package>/<slug>`. Unique, and stable while the package is installed. */
|
|
73
|
+
readonly name: string;
|
|
74
|
+
readonly package: string;
|
|
75
|
+
readonly slug: string;
|
|
76
|
+
/** Frontmatter description. The only text `find` ranks against. */
|
|
77
|
+
readonly description: string;
|
|
78
|
+
/** Absolute path to the SKILL.md. `call` reads the body from here. */
|
|
79
|
+
readonly path: string;
|
|
80
|
+
}
|
|
81
|
+
interface CatalogOptions {
|
|
82
|
+
/** Directories to walk. Each is a `node_modules` root, not a package root. */
|
|
83
|
+
readonly roots: readonly string[];
|
|
84
|
+
/** Package name prefixes to consider. Empty means every package. */
|
|
85
|
+
readonly scopes?: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Directories a walk does not enter. Kept as short as the evidence allows: a
|
|
89
|
+
* name here is a package whose skill will never be found, and the cost of one
|
|
90
|
+
* extra readdir is far below the cost of missing a skill. Measured 20260906 --
|
|
91
|
+
* `lib` was on this list and hid playwright's `lib/skill/SKILL.md`, which is
|
|
92
|
+
* the most widely installed skill in the real world.
|
|
93
|
+
*/
|
|
94
|
+
declare const SKIPPED_DIRS: ReadonlySet<string>;
|
|
95
|
+
/**
|
|
96
|
+
* How deep below a package root a SKILL.md is looked for. Three reaches every
|
|
97
|
+
* convention in use (skills, .agent/skills, .claude/skills, .gemini/skills,
|
|
98
|
+
* .codex/skills); four was measured to find nothing more.
|
|
99
|
+
*/
|
|
100
|
+
declare const MAX_SKILL_DEPTH = 3;
|
|
101
|
+
/**
|
|
102
|
+
* Reads `name` and `description` out of YAML frontmatter without a parser.
|
|
103
|
+
*
|
|
104
|
+
* A parser is a dependency this server does not otherwise need, and the two
|
|
105
|
+
* fields it wants are flat scalars on their own line. Anything more nested is
|
|
106
|
+
* not read, which is the honest limit rather than a partial YAML.
|
|
107
|
+
*/
|
|
108
|
+
declare function readFrontmatter(source: string): Record<string, string>;
|
|
109
|
+
/**
|
|
110
|
+
* The body after the frontmatter, or the whole file when there is none.
|
|
111
|
+
*
|
|
112
|
+
* The slice starts at the newline terminating the closing `---`, and a blank
|
|
113
|
+
* line after it is separator rather than content, so both are dropped.
|
|
114
|
+
*/
|
|
115
|
+
declare function readBody(source: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Every skill reachable from the given roots, sorted by name.
|
|
118
|
+
*
|
|
119
|
+
* A package with no skills directory contributes nothing and costs one
|
|
120
|
+
* `existsSync` per convention. A skill with no description is skipped rather
|
|
121
|
+
* than listed:
|
|
122
|
+
* `find` ranks descriptions, so one without a description can only ever be
|
|
123
|
+
* found by already knowing its name, and listing it would spend a session's
|
|
124
|
+
* tokens on an entry that cannot answer.
|
|
125
|
+
*/
|
|
126
|
+
declare function readCatalog(options: CatalogOptions): SkillEntry[];
|
|
127
|
+
/** A catalog entry without its body, which is what `catalog` returns. */
|
|
128
|
+
declare function withoutBody({ path, ...rest }: SkillEntry): Omit<SkillEntry, "path">;
|
|
129
|
+
/** Path relative to a root, for a message a reader can act on. */
|
|
130
|
+
declare function displayPath(root: string, entry: SkillEntry): string;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Ranking an intent against skill descriptions.
|
|
134
|
+
*
|
|
135
|
+
* Semantic when an embedder is supplied, lexical otherwise, and the answer says
|
|
136
|
+
* which. A caller that cannot tell the two apart cannot tell a degraded server
|
|
137
|
+
* from a working one.
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
type Ranking = "semantic" | "lexical";
|
|
141
|
+
interface Match {
|
|
142
|
+
readonly name: string;
|
|
143
|
+
readonly package: string;
|
|
144
|
+
readonly description: string;
|
|
145
|
+
readonly score: number;
|
|
146
|
+
}
|
|
147
|
+
interface FindResult {
|
|
148
|
+
readonly ranking: Ranking;
|
|
149
|
+
readonly matches: readonly Match[];
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The encoder, supplied by the caller. There is no default and no bundled
|
|
153
|
+
* model: which model runs decides what the scores mean, so it is the consuming
|
|
154
|
+
* repository's declaration rather than this package's.
|
|
155
|
+
*/
|
|
156
|
+
interface Embedder {
|
|
157
|
+
readonly modelId: string;
|
|
158
|
+
readonly dims: number;
|
|
159
|
+
embed(text: string): Promise<Float32Array>;
|
|
160
|
+
}
|
|
161
|
+
interface FindOptions {
|
|
162
|
+
readonly entries: readonly SkillEntry[];
|
|
163
|
+
readonly intent: string;
|
|
164
|
+
readonly limit?: number;
|
|
165
|
+
readonly embedder?: Embedder;
|
|
166
|
+
readonly cache?: IntentCache;
|
|
167
|
+
/** Precomputed description vectors, keyed by skill name. */
|
|
168
|
+
readonly vectors?: ReadonlyMap<string, Float32Array>;
|
|
169
|
+
}
|
|
170
|
+
declare const DEFAULT_FIND_LIMIT = 5;
|
|
171
|
+
/**
|
|
172
|
+
* Token overlap and bigram Dice, averaged. Deterministic and dependency-free:
|
|
173
|
+
* this is the answer when there is no model, so it must never be the thing
|
|
174
|
+
* that fails.
|
|
175
|
+
*/
|
|
176
|
+
declare function lexicalScore(intent: string, description: string): number;
|
|
177
|
+
/** Cosine over two equal-width vectors. Zero when either has no magnitude. */
|
|
178
|
+
declare function cosine(a: Float32Array, b: Float32Array): number;
|
|
179
|
+
/**
|
|
180
|
+
* Ranked matches, and which ranking produced them.
|
|
181
|
+
*
|
|
182
|
+
* Returns rather than loads: a search verb that also acts cannot be used to
|
|
183
|
+
* look, and the whole reason this server exists is that looking should be
|
|
184
|
+
* cheap.
|
|
185
|
+
*/
|
|
186
|
+
declare function find(options: FindOptions): Promise<FindResult>;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The three tools, and nothing else.
|
|
190
|
+
*
|
|
191
|
+
* The point of the server is that a session's startup cost does not grow with
|
|
192
|
+
* the number of skills installed: the host lists three tools whatever the
|
|
193
|
+
* catalog holds, and a body is read only when one is asked for.
|
|
194
|
+
*/
|
|
195
|
+
|
|
196
|
+
interface SkillServerOptions extends CatalogOptions {
|
|
197
|
+
readonly embedder?: Embedder;
|
|
198
|
+
readonly cache?: IntentCache;
|
|
199
|
+
readonly vectors?: ReadonlyMap<string, Float32Array>;
|
|
200
|
+
/** Injected for tests. Reads the catalog off disk otherwise. */
|
|
201
|
+
readonly loadCatalog?: (options: CatalogOptions) => SkillEntry[];
|
|
202
|
+
}
|
|
203
|
+
interface CatalogResponse {
|
|
204
|
+
readonly count: number;
|
|
205
|
+
readonly packages: readonly {
|
|
206
|
+
readonly package: string;
|
|
207
|
+
readonly skills: readonly {
|
|
208
|
+
readonly name: string;
|
|
209
|
+
readonly description: string;
|
|
210
|
+
}[];
|
|
211
|
+
}[];
|
|
212
|
+
}
|
|
213
|
+
interface CallResponse {
|
|
214
|
+
readonly name: string;
|
|
215
|
+
readonly body: string;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The catalog, the search and the read, as plain functions.
|
|
219
|
+
*
|
|
220
|
+
* Separate from the MCP wiring so they can be exercised without a transport,
|
|
221
|
+
* and so a consumer that wants them from a hook or a script is not made to
|
|
222
|
+
* speak a protocol to get them.
|
|
223
|
+
*/
|
|
224
|
+
declare function createSkillTools(options: SkillServerOptions): {
|
|
225
|
+
/** Grouped, and without bodies. The cheap overview and the search's miss path. */
|
|
226
|
+
catalog(): CatalogResponse;
|
|
227
|
+
/** Ranked names. Returns; never loads a body. */
|
|
228
|
+
find(intent: string, limit?: number): Promise<FindResult>;
|
|
229
|
+
/** One body, at the moment it is needed. */
|
|
230
|
+
call(name: string): CallResponse | null;
|
|
231
|
+
/** Drops the memoised catalog, so the next call re-reads the tree. */
|
|
232
|
+
refresh(): void;
|
|
233
|
+
entries: () => Omit<SkillEntry, "path">[];
|
|
234
|
+
};
|
|
235
|
+
type SkillTools = ReturnType<typeof createSkillTools>;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The three tools, spoken over MCP.
|
|
239
|
+
*
|
|
240
|
+
* Thin on purpose: every decision is in `createSkillTools`, and this file only
|
|
241
|
+
* names the tools and shapes their arguments. A consumer that wants the same
|
|
242
|
+
* three operations from a hook or a script uses the tools directly and never
|
|
243
|
+
* speaks a protocol.
|
|
244
|
+
*/
|
|
245
|
+
|
|
246
|
+
/** Reported to the host on connect. */
|
|
247
|
+
declare const SERVER_NAME = "pmcp";
|
|
248
|
+
/**
|
|
249
|
+
* Registers the three tools on a server.
|
|
250
|
+
*
|
|
251
|
+
* Exported separately from the stdio entry so a consumer already running an MCP
|
|
252
|
+
* server can add these to it rather than starting a second process.
|
|
253
|
+
*/
|
|
254
|
+
declare function registerSkillTools(server: McpServer, tools: SkillTools): McpServer;
|
|
255
|
+
/**
|
|
256
|
+
* A server with the three tools registered.
|
|
257
|
+
*
|
|
258
|
+
* The catalog is read on first use rather than here, so a host that connects
|
|
259
|
+
* and never asks pays nothing for the walk.
|
|
260
|
+
*/
|
|
261
|
+
declare function createSkillServer(options: SkillServerOptions): McpServer;
|
|
262
|
+
|
|
263
|
+
export { type CallResponse, type CatalogOptions, type CatalogResponse, DEFAULT_FIND_LIMIT, DEFAULT_INTENT_CACHE_LIMIT, type Embedder, type FindOptions, type FindResult, type IntentCache, type IntentCacheOptions, type IntentCacheStats, MAX_SKILL_DEPTH, type Match, type Ranking, SERVER_NAME, SKIPPED_DIRS, type SkillEntry, type SkillServerOptions, type SkillTools, cosine, createSkillServer, createSkillTools, displayPath, find, lexicalScore, normaliseIntent, openIntentCache, readBody, readCatalog, readFrontmatter, registerSkillTools, withoutBody };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_INTENT_CACHE_LIMIT,
|
|
3
|
+
openIntentCache
|
|
4
|
+
} from "./chunk-X3KWFZBP.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_FIND_LIMIT,
|
|
7
|
+
MAX_SKILL_DEPTH,
|
|
8
|
+
SERVER_NAME,
|
|
9
|
+
SKIPPED_DIRS,
|
|
10
|
+
cosine,
|
|
11
|
+
createSkillServer,
|
|
12
|
+
createSkillTools,
|
|
13
|
+
displayPath,
|
|
14
|
+
find,
|
|
15
|
+
lexicalScore,
|
|
16
|
+
normaliseIntent,
|
|
17
|
+
readBody,
|
|
18
|
+
readCatalog,
|
|
19
|
+
readFrontmatter,
|
|
20
|
+
registerSkillTools,
|
|
21
|
+
withoutBody
|
|
22
|
+
} from "./chunk-WIVQKKE4.js";
|
|
23
|
+
export {
|
|
24
|
+
DEFAULT_FIND_LIMIT,
|
|
25
|
+
DEFAULT_INTENT_CACHE_LIMIT,
|
|
26
|
+
MAX_SKILL_DEPTH,
|
|
27
|
+
SERVER_NAME,
|
|
28
|
+
SKIPPED_DIRS,
|
|
29
|
+
cosine,
|
|
30
|
+
createSkillServer,
|
|
31
|
+
createSkillTools,
|
|
32
|
+
displayPath,
|
|
33
|
+
find,
|
|
34
|
+
lexicalScore,
|
|
35
|
+
normaliseIntent,
|
|
36
|
+
openIntentCache,
|
|
37
|
+
readBody,
|
|
38
|
+
readCatalog,
|
|
39
|
+
readFrontmatter,
|
|
40
|
+
registerSkillTools,
|
|
41
|
+
withoutBody
|
|
42
|
+
};
|
|
43
|
+
//# sourceMappingURL=index.js.map
|
package/dist/stdio.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The stdio entry a host launches.
|
|
4
|
+
*
|
|
5
|
+
* Wired as `node <path in node_modules>`, never `npx`: the server's position in
|
|
6
|
+
* the dependency tree is its data source, and from an npx cache it is not a
|
|
7
|
+
* sibling of the consumer's packages and would find no skills at all.
|
|
8
|
+
*/
|
|
9
|
+
/** `node_modules` roots to walk, from argv or from the working directory. */
|
|
10
|
+
declare function resolveRoots(argv: readonly string[], cwd: string): string[];
|
|
11
|
+
declare function serve(roots: readonly string[], scopes?: readonly string[]): void;
|
|
12
|
+
declare function main(argv?: readonly string[]): void;
|
|
13
|
+
|
|
14
|
+
export { main, resolveRoots, serve };
|
package/dist/stdio.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modootoday/pmcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"author": "modootoday",
|
|
6
|
+
"description": "An MCP server that finds the skills your installed packages already ship. Reads SKILL.md out of node_modules by shape rather than by path, so it sees the skills, .agent, .claude, .gemini and .codex conventions alike, and keeps session startup flat as the number of installed skills grows.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"skills",
|
|
11
|
+
"agent",
|
|
12
|
+
"node-modules",
|
|
13
|
+
"catalog"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://pmcp.build",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/modootoday/pmcp.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/modootoday/pmcp/issues"
|
|
22
|
+
},
|
|
23
|
+
"packageManager": "bun@1.3.14",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"registry": "https://registry.npmjs.org/",
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "./dist/index.js",
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"import": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./stdio": {
|
|
37
|
+
"types": "./dist/stdio.d.ts",
|
|
38
|
+
"import": "./dist/stdio.js"
|
|
39
|
+
},
|
|
40
|
+
"./cli": {
|
|
41
|
+
"types": "./dist/cli.d.ts",
|
|
42
|
+
"import": "./dist/cli.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"bin": {
|
|
46
|
+
"pmcp": "dist/cli.js"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"dist",
|
|
50
|
+
"!dist/**/*.map",
|
|
51
|
+
"!dist/.tsbuildinfo",
|
|
52
|
+
"skills",
|
|
53
|
+
"README.md",
|
|
54
|
+
"SECURITY.md",
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"NOTICE"
|
|
57
|
+
],
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"typecheck": "tsc --noEmit --incremental --tsBuildInfoFile ./dist/.tsbuildinfo && tsc -p tsconfig.tests.json --noEmit",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"clean": "rimraf dist",
|
|
63
|
+
"catalog:refresh": "node scripts/refresh-catalog.mjs",
|
|
64
|
+
"site:build": "node scripts/build-catalog-pages.mjs",
|
|
65
|
+
"site:deployed": "node scripts/check-deployed.mjs",
|
|
66
|
+
"prepack": "npm run build && npm run typecheck && npm test",
|
|
67
|
+
"format": "prettier --write .",
|
|
68
|
+
"format:check": "prettier --check ."
|
|
69
|
+
},
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
72
|
+
"zod": "^4.5.1",
|
|
73
|
+
"semver": "7.8.5"
|
|
74
|
+
},
|
|
75
|
+
"peerDependencies": {
|
|
76
|
+
"@huggingface/transformers": ">=3"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@huggingface/transformers": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"@types/node": "^22.15.21",
|
|
85
|
+
"@types/semver": "7.8.0",
|
|
86
|
+
"prettier": "^3.6.2",
|
|
87
|
+
"rimraf": "^6.0.1",
|
|
88
|
+
"tsup": "^8.5.1",
|
|
89
|
+
"typescript": "^5.9.3",
|
|
90
|
+
"vitest": "^4.0.18"
|
|
91
|
+
},
|
|
92
|
+
"engines": {
|
|
93
|
+
"node": ">=22.0.0",
|
|
94
|
+
"bun": ">=1.3.0"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: package-skill-catalog
|
|
3
|
+
description: A project has grown past a hundred dependencies and nobody knows which of them shipped usage documentation an agent could read, so every question about an installed package is answered by opening its source; or an agent's context is being spent listing skills it never opens, and startup cost grows with every dependency added.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pmcp
|
|
7
|
+
|
|
8
|
+
An MCP server that reads `SKILL.md` files out of `node_modules` and serves them
|
|
9
|
+
as three tools, so a session pays for the one skill it opens rather than for the
|
|
10
|
+
whole catalog.
|
|
11
|
+
|
|
12
|
+
## When to reach for it
|
|
13
|
+
|
|
14
|
+
Reach for this when the skills you want an agent to read arrive as npm packages
|
|
15
|
+
and leave when those packages are uninstalled. Discovery is derived from the
|
|
16
|
+
installed tree at request time, so the catalog is always true of the project and
|
|
17
|
+
there is no list for anyone to maintain.
|
|
18
|
+
|
|
19
|
+
Do not reach for this to hold a project's own skills. Those live in the
|
|
20
|
+
repository, they are not installed, and a host that already loads them does not
|
|
21
|
+
need a server in front of them. This exists for the skills you did not write.
|
|
22
|
+
|
|
23
|
+
## Install and wire
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npm install --save-dev pmcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For a host that spawns MCP servers over stdio:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"pmcp": { "command": "npx", "args": ["@modootoday/pmcp"] }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Flags, all optional:
|
|
40
|
+
|
|
41
|
+
| Flag | Effect |
|
|
42
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
43
|
+
| `--root=<dir>` | Use this `node_modules` instead of discovering upwards. Repeatable. |
|
|
44
|
+
| `--scope=@acme,@other` | Only catalogue packages whose names start with one of these. |
|
|
45
|
+
|
|
46
|
+
Embedded in a host you are writing yourself:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { createSkillServer } from "@modootoday/pmcp";
|
|
50
|
+
|
|
51
|
+
const server = createSkillServer({
|
|
52
|
+
roots: ["./node_modules"],
|
|
53
|
+
scopes: ["@acme"],
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What the consuming repository must supply
|
|
58
|
+
|
|
59
|
+
Nothing, to read skills. To publish one, a package supplies a `SKILL.md` whose
|
|
60
|
+
YAML frontmatter carries `name` and `description`, at any of these paths:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
<package>/skills/<slug>/SKILL.md
|
|
64
|
+
<package>/.agent/skills/<slug>/SKILL.md
|
|
65
|
+
<package>/.claude/skills/<slug>/SKILL.md
|
|
66
|
+
<package>/.gemini/skills/<slug>/SKILL.md
|
|
67
|
+
<package>/.codex/skills/<slug>/SKILL.md
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The path is not what makes it a skill; the file is. Discovery walks each package
|
|
71
|
+
to a depth of three and treats any `SKILL.md` as a skill, skipping build output,
|
|
72
|
+
sources, nested installs and test corpora.
|
|
73
|
+
|
|
74
|
+
The `description` is the only text ranked, so write it as the symptom a reader
|
|
75
|
+
already has rather than as a list of what the package does. A skill with no
|
|
76
|
+
`description` is skipped rather than listed: ranking could never surface it, and
|
|
77
|
+
listing it would spend a session's tokens on an entry that cannot answer.
|
|
78
|
+
|
|
79
|
+
The package must also ship the file. A `SKILL.md` outside the manifest's `files`
|
|
80
|
+
array is not in the tarball and therefore not in anyone's `node_modules`.
|
|
81
|
+
|
|
82
|
+
## API
|
|
83
|
+
|
|
84
|
+
### Server
|
|
85
|
+
|
|
86
|
+
`createSkillServer(options)` returns a configured MCP server. `options.roots` is
|
|
87
|
+
required and holds `node_modules` directories, not package directories.
|
|
88
|
+
`options.scopes` filters by package-name prefix.
|
|
89
|
+
|
|
90
|
+
`SERVER_NAME` is the name the server reports at initialize.
|
|
91
|
+
|
|
92
|
+
### Catalog
|
|
93
|
+
|
|
94
|
+
`readCatalog({roots, scopes})` returns `SkillEntry[]`, sorted by name and
|
|
95
|
+
deduplicated: the first root wins, because a nested `node_modules` holds an older
|
|
96
|
+
copy of a package the outer root already resolved.
|
|
97
|
+
|
|
98
|
+
A `SkillEntry` carries `name` (`<package>/<slug>`), `package`, `slug`,
|
|
99
|
+
`description` and the absolute `path` of the file.
|
|
100
|
+
|
|
101
|
+
`readFrontmatter(source)` and `readBody(source)` split a `SKILL.md`. The
|
|
102
|
+
frontmatter reader is deliberately not a YAML parser: it reads flat scalars on
|
|
103
|
+
their own line and nothing nested, which is the honest limit rather than a
|
|
104
|
+
partial YAML.
|
|
105
|
+
|
|
106
|
+
`MAX_SKILL_DEPTH` and `SKIPPED_DIRS` are exported so a caller can see what
|
|
107
|
+
discovery will and will not walk.
|
|
108
|
+
|
|
109
|
+
### Tools on the wire
|
|
110
|
+
|
|
111
|
+
| Tool | Argument | Returns |
|
|
112
|
+
| --------------- | -------------------------------------- | ---------------------------------- |
|
|
113
|
+
| `skill_catalog` | optional scope | every skill's name and description |
|
|
114
|
+
| `skill_find` | `intent`, a sentence; optional `limit` | ranked matches |
|
|
115
|
+
| `skill_call` | `name` | that skill's body |
|
|
116
|
+
|
|
117
|
+
## Worked examples
|
|
118
|
+
|
|
119
|
+
### Reading the catalog without a server
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { readCatalog } from "@modootoday/pmcp";
|
|
123
|
+
|
|
124
|
+
const catalog = readCatalog({ roots: ["./node_modules"] });
|
|
125
|
+
console.log(`${catalog.length} skills`);
|
|
126
|
+
for (const entry of catalog.slice(0, 3)) {
|
|
127
|
+
console.log(`${entry.name}: ${entry.description}`);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Asking a question rather than a keyword
|
|
132
|
+
|
|
133
|
+
`skill_find` ranks descriptions, which are written as symptoms, so a sentence
|
|
134
|
+
retrieves and a bare noun does not. This is the difference the tool descriptions
|
|
135
|
+
are trying to teach, and it is easy to get wrong:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// Retrieves: the sentence shares vocabulary with a symptom description.
|
|
139
|
+
await client.callTool({
|
|
140
|
+
name: "skill_find",
|
|
141
|
+
arguments: {
|
|
142
|
+
intent: "my build copies dist into the image but a stale bundle ships",
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Does not: one word matches many descriptions weakly and none strongly.
|
|
147
|
+
// Use skill_catalog for this shape of question; it filters rather than ranks.
|
|
148
|
+
await client.callTool({ name: "skill_find", arguments: { intent: "build" } });
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Scoping a large tree
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const mine = readCatalog({
|
|
155
|
+
roots: ["./node_modules"],
|
|
156
|
+
scopes: ["@acme/"],
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The prefix is matched against the package name with `startsWith`, so `@acme`
|
|
161
|
+
also matches `@acmetools/thing`. Include the trailing slash when you mean the
|
|
162
|
+
scope.
|
|
163
|
+
|
|
164
|
+
## Testing against it
|
|
165
|
+
|
|
166
|
+
### Build a tree, do not mock the filesystem
|
|
167
|
+
|
|
168
|
+
`readCatalog` takes roots, so a test supplies a real temporary directory. This
|
|
169
|
+
is faster than a filesystem mock and it exercises the walk rather than a stand-in
|
|
170
|
+
for it.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
174
|
+
import { tmpdir } from "node:os";
|
|
175
|
+
import { join } from "node:path";
|
|
176
|
+
import { readCatalog } from "@modootoday/pmcp";
|
|
177
|
+
|
|
178
|
+
const root = mkdtempSync(join(tmpdir(), "skills-"));
|
|
179
|
+
const pkg = join(root, "node_modules", "example");
|
|
180
|
+
mkdirSync(join(pkg, "skills", "wiring"), { recursive: true });
|
|
181
|
+
writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "example" }));
|
|
182
|
+
writeFileSync(
|
|
183
|
+
join(pkg, "skills", "wiring", "SKILL.md"),
|
|
184
|
+
"---\nname: wiring\ndescription: Something breaks and you need this.\n---\n\nbody\n",
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const catalog = readCatalog({ roots: [join(root, "node_modules")] });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Assert on what was found, never that something was found
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// Proves nothing: passes on an empty tree and on a broken walk alike.
|
|
194
|
+
expect(catalog).toBeDefined();
|
|
195
|
+
|
|
196
|
+
// Proves the walk reached this package under this convention.
|
|
197
|
+
expect(catalog.map((entry) => entry.name)).toContain("example/wiring");
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
A test that asserts a non-empty result cannot distinguish a catalog that found
|
|
201
|
+
the wrong thing from one that found the right thing. Name the entry.
|
|
202
|
+
|
|
203
|
+
## Invariants
|
|
204
|
+
|
|
205
|
+
- **Discovery is derived, never declared.** Installing a package is how its
|
|
206
|
+
skill arrives and uninstalling it is how the skill leaves. There is no list.
|
|
207
|
+
- **A skill without a description is not catalogued.** Ranking is over
|
|
208
|
+
descriptions, so an entry without one could only be reached by already knowing
|
|
209
|
+
its name.
|
|
210
|
+
- **The first root wins.** A nested `node_modules` holds an older copy of an
|
|
211
|
+
already-resolved package; the outer one is the one the project runs.
|
|
212
|
+
- **Absence is not failure.** No model, no SQLite, and an unreadable file each
|
|
213
|
+
degrade the answer rather than ending the request, and the response says which
|
|
214
|
+
path answered so a fallback is visible rather than silent.
|
|
215
|
+
- **Startup does not grow with the catalog.** Bodies are read by `skill_call`,
|
|
216
|
+
one at a time. Adding a dependency that ships ten skills adds ten short
|
|
217
|
+
descriptions, not ten documents.
|
|
218
|
+
|
|
219
|
+
## What it will not do
|
|
220
|
+
|
|
221
|
+
The MCP tools read installed files and return text. They do not install packages
|
|
222
|
+
or make catalog API requests. The CLI's available, install and sync commands are
|
|
223
|
+
separate from that local reading path.
|
|
224
|
+
|
|
225
|
+
The CLI does not write directly into node_modules or treat OAuth access tokens
|
|
226
|
+
as npm credentials. Installation uses the project's npm or Bun configuration,
|
|
227
|
+
requires confirmation, and verifies registry integrity and the installed version.
|
|
228
|
+
Use --dry-run to inspect changes without applying them. Sync does not delete
|
|
229
|
+
unrelated dependencies or skills authored by the user.
|
|
230
|
+
|
|
231
|
+
It will not parse full YAML. Frontmatter is read as flat scalars, so a nested
|
|
232
|
+
structure is ignored rather than half-understood.
|
|
233
|
+
|
|
234
|
+
The default server ranks lexically. The optional index command requires
|
|
235
|
+
`@huggingface/transformers` and may load model assets when explicitly invoked.
|
|
236
|
+
Installing that peer alone is not proof that the server uses its index.
|
|
237
|
+
No postinstall hook downloads a model.
|