@cyanheads/mcp-ts-core 0.10.4 → 0.10.6
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/AGENTS.md +6 -2
- package/CLAUDE.md +6 -2
- package/README.md +1 -1
- package/changelog/0.10.x/0.10.4.md +1 -1
- package/changelog/0.10.x/0.10.5.md +60 -0
- package/changelog/0.10.x/0.10.6.md +37 -0
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +5 -8
- package/dist/core/app.js.map +1 -1
- package/dist/core/worker.d.ts.map +1 -1
- package/dist/core/worker.js +0 -2
- package/dist/core/worker.js.map +1 -1
- package/dist/linter/rules/enrichment-rules.js +1 -1
- package/dist/linter/rules/enrichment-rules.js.map +1 -1
- package/dist/logs/combined.log +8 -0
- package/dist/logs/error.log +4 -0
- package/dist/logs/interactions.log +0 -0
- package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -0
- package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +11 -0
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.d.ts.map +1 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js +6 -1
- package/dist/services/mirror/sqlite/sqliteMirrorStore.js.map +1 -1
- package/dist/services/mirror/types.d.ts +4 -2
- package/dist/services/mirror/types.d.ts.map +1 -1
- package/dist/services/speech/providers/elevenlabs.provider.d.ts +2 -2
- package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
- package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
- package/dist/services/speech/providers/whisper.provider.d.ts +1 -1
- package/dist/services/speech/providers/whisper.provider.js +3 -3
- package/dist/services/speech/providers/whisper.provider.js.map +1 -1
- package/dist/services/speech/types.d.ts +4 -4
- package/dist/services/speech/types.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
- package/dist/storage/providers/cloudflare/r2Provider.js +9 -2
- package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
- package/dist/testing/vitest.d.ts +76 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +70 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/utils/internal/encoding.d.ts.map +1 -1
- package/dist/utils/internal/encoding.js +6 -17
- package/dist/utils/internal/encoding.js.map +1 -1
- package/dist/utils/internal/performance.d.ts +17 -27
- package/dist/utils/internal/performance.d.ts.map +1 -1
- package/dist/utils/internal/performance.js +23 -45
- package/dist/utils/internal/performance.js.map +1 -1
- package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
- package/dist/utils/network/fetchWithTimeout.js +6 -13
- package/dist/utils/network/fetchWithTimeout.js.map +1 -1
- package/dist/utils/network/retry.js +11 -9
- package/dist/utils/network/retry.js.map +1 -1
- package/dist/utils/security/rateLimiter.d.ts +5 -0
- package/dist/utils/security/rateLimiter.d.ts.map +1 -1
- package/dist/utils/security/rateLimiter.js +7 -0
- package/dist/utils/security/rateLimiter.js.map +1 -1
- package/package.json +22 -11
- package/scripts/clean-mcpb.ts +118 -0
- package/scripts/lint-packaging.ts +312 -66
- package/skills/api-testing/SKILL.md +47 -1
- package/skills/api-workers/SKILL.md +97 -1
- package/skills/orchestrations/SKILL.md +1 -1
- package/skills/orchestrations/workflows/fix-wrapup-release.md +9 -5
- package/skills/polish-docs-meta/SKILL.md +4 -2
- package/templates/AGENTS.md +2 -2
- package/templates/CLAUDE.md +2 -2
- package/templates/manifest.json +0 -1
- package/templates/package.json +1 -1
- package/templates/src/index.ts +2 -0
- package/templates/tests/tools/echo.tool.test.ts +21 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Post-pack MCPB bundle cleaner. Runs after `mcpb pack` (wired
|
|
4
|
+
* into the `bundle` package script) to remove bundle content the pack step
|
|
5
|
+
* cannot exclude:
|
|
6
|
+
*
|
|
7
|
+
* 1. `mcpb clean <bundle>` — official dev-dependency prune + manifest
|
|
8
|
+
* validation (a measured real-world bundle dropped 61 MB → 12.9 MB).
|
|
9
|
+
* 2. Exact-name strip of agent-doc entries nested under `node_modules/` —
|
|
10
|
+
* dependency-shipped `skills/`, `.claude/`, `.agents/` trees and stray
|
|
11
|
+
* `SKILL.md` files. Root-anchored `.mcpbignore` patterns cannot reach
|
|
12
|
+
* these by design (issues #146/#207); this is issue #230.
|
|
13
|
+
* 3. Re-list and assert zero matching entries remain.
|
|
14
|
+
*
|
|
15
|
+
* Entry names are passed to `zip -d` with `-nw` (no-wildcard) so they match
|
|
16
|
+
* literally — bracketed runtime filenames like `pages/[slug].js` exist in
|
|
17
|
+
* real packages and must not glob. Bundles are unsigned in this flow; if
|
|
18
|
+
* `mcpb sign` is ever adopted, this script must run before signing.
|
|
19
|
+
*
|
|
20
|
+
* Usage: `bun run scripts/clean-mcpb.ts dist/<name>.mcpb`
|
|
21
|
+
*
|
|
22
|
+
* @module scripts/clean-mcpb
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, statSync } from 'node:fs';
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Agent-doc entries under `node_modules/` that must not ship in a bundle.
|
|
31
|
+
* KEEP IN SYNC with `AGENT_DOC_ENTRY` in `scripts/lint-packaging.ts`
|
|
32
|
+
* (post-bundle content check) — a unit test asserts the two are identical.
|
|
33
|
+
*/
|
|
34
|
+
export const AGENT_DOC_ENTRY =
|
|
35
|
+
/^node_modules\/.*(?:\/skills\/|\/\.claude\/|\/\.agents\/|\/SKILL\.md$)/;
|
|
36
|
+
|
|
37
|
+
/** Filter a bundle entry listing down to the agent-doc entries to strip. */
|
|
38
|
+
export function filterAgentDocEntries(entries: string[]): string[] {
|
|
39
|
+
return entries.filter((entry) => AGENT_DOC_ENTRY.test(entry));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Listing a 12k-entry bundle exceeds execFileSync's 1 MB default buffer. */
|
|
43
|
+
const MAX_LIST_BUFFER = 64 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** `zip -d` argv batch size — stays clear of ARG_MAX with long entry names. */
|
|
46
|
+
const DELETE_BATCH = 200;
|
|
47
|
+
|
|
48
|
+
function listEntries(bundle: string): string[] {
|
|
49
|
+
return execFileSync('unzip', ['-Z1', bundle], { encoding: 'utf-8', maxBuffer: MAX_LIST_BUFFER })
|
|
50
|
+
.split('\n')
|
|
51
|
+
.filter((line) => line.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function run(cmd: string, args: string[]): void {
|
|
55
|
+
execFileSync(cmd, args, { stdio: 'inherit' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function mb(bytes: number): string {
|
|
59
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function main(): void {
|
|
63
|
+
const bundleArg = process.argv[2];
|
|
64
|
+
if (!bundleArg) {
|
|
65
|
+
console.error('Usage: clean-mcpb.ts <bundle.mcpb> — run after `mcpb pack`.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
const bundle = resolve(bundleArg);
|
|
69
|
+
if (!existsSync(bundle)) {
|
|
70
|
+
console.error(`No bundle at ${bundle} — run \`mcpb pack\` first (see the \`bundle\` script).`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sizeBefore = statSync(bundle).size;
|
|
75
|
+
|
|
76
|
+
// 1. Official prune: removes dev dependencies, validates the manifest.
|
|
77
|
+
try {
|
|
78
|
+
run('npx', ['-y', '@anthropic-ai/mcpb', 'clean', bundle]);
|
|
79
|
+
} catch {
|
|
80
|
+
console.error('✗ `mcpb clean` failed — bundle left as packed.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Exact-name strip of dependency-shipped agent docs.
|
|
85
|
+
let doomed: string[] = [];
|
|
86
|
+
try {
|
|
87
|
+
doomed = filterAgentDocEntries(listEntries(bundle));
|
|
88
|
+
for (let i = 0; i < doomed.length; i += DELETE_BATCH) {
|
|
89
|
+
run('zip', ['-q', '-d', '-nw', bundle, ...doomed.slice(i, i + DELETE_BATCH)]);
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
93
|
+
console.error(
|
|
94
|
+
'✗ Info-ZIP `zip`/`unzip` not found on PATH — required for the agent-doc strip.',
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
console.error(`✗ Agent-doc strip failed: ${err instanceof Error ? err.message : err}`);
|
|
98
|
+
}
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 3. Verify: zero matching entries remain.
|
|
103
|
+
const remaining = filterAgentDocEntries(listEntries(bundle));
|
|
104
|
+
if (remaining.length > 0) {
|
|
105
|
+
console.error(`✗ ${remaining.length} agent-doc entries still present after strip, e.g.:`);
|
|
106
|
+
for (const entry of remaining.slice(0, 5)) console.error(` ${entry}`);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sizeAfter = statSync(bundle).size;
|
|
111
|
+
console.log(
|
|
112
|
+
`Bundle cleaned: ${mb(sizeBefore)} → ${mb(sizeAfter)} (${doomed.length} agent-doc entries stripped).`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
117
|
+
main();
|
|
118
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @fileoverview MCPB packaging linter — validates env var alignment between
|
|
4
4
|
* `manifest.json` (MCPB bundle install UX) and `server.json` (MCP Registry
|
|
5
|
-
* discovery) for stdio packages, and guards against bundle-content
|
|
5
|
+
* discovery) for stdio packages, and guards against bundle-content and
|
|
6
|
+
* identity mistakes.
|
|
6
7
|
*
|
|
7
8
|
* Used by devcheck and as a standalone script: `bun run lint:packaging` /
|
|
8
9
|
* `npm run lint:packaging`.
|
|
@@ -22,17 +23,26 @@
|
|
|
22
23
|
* `node_modules/x/skills/` (runtime path bypass, issues #172/#207).
|
|
23
24
|
* 7. Bundle-content guard: `.mcpbignore` patterns must not strip critical
|
|
24
25
|
* runtime package paths (e.g. `node_modules/@opentelemetry/api/build/src/`).
|
|
26
|
+
* 8. Post-bundle content: a built `.mcpb` under `dist/` must contain zero
|
|
27
|
+
* `node_modules/**` agent-doc entries (dependency-shipped `skills/`,
|
|
28
|
+
* `.claude/`, `.agents/`, `SKILL.md`) — unreachable by root-anchored
|
|
29
|
+
* `.mcpbignore` patterns; `scripts/clean-mcpb.ts` strips them at bundle
|
|
30
|
+
* time (issue #230).
|
|
31
|
+
* 9. Identity: `name`/`title` literals in `createApp()` /
|
|
32
|
+
* `createWorkerHandler()` (src/index.ts, src/worker.ts) and manifest
|
|
33
|
+
* `display_name` must equal the unscoped package name; a partial
|
|
34
|
+
* `name`/`title` pair warns without failing (issue #231).
|
|
25
35
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Skips cleanly when `manifest.json` is absent — consumers who deleted it for
|
|
30
|
-
* an HTTP-only deploy should not fail this check.
|
|
36
|
+
* Every check skips cleanly when its input is absent — consumers who deleted
|
|
37
|
+
* `manifest.json` for an HTTP-only deploy, or who haven't built a bundle,
|
|
38
|
+
* should not fail the checks that need those files.
|
|
31
39
|
*
|
|
32
40
|
* @module scripts/lint-packaging
|
|
33
41
|
*/
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
42
|
+
import { execFileSync } from 'node:child_process';
|
|
43
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
44
|
+
import { join, resolve } from 'node:path';
|
|
45
|
+
import { fileURLToPath } from 'node:url';
|
|
36
46
|
|
|
37
47
|
interface ServerJsonEnvVar {
|
|
38
48
|
default?: string;
|
|
@@ -56,6 +66,7 @@ interface ManifestUserConfigEntry {
|
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
interface Manifest {
|
|
69
|
+
display_name?: unknown;
|
|
59
70
|
name?: string;
|
|
60
71
|
server?: { mcp_config?: { env?: Record<string, string> } };
|
|
61
72
|
user_config?: Record<string, ManifestUserConfigEntry>;
|
|
@@ -69,20 +80,31 @@ const USER_CONFIG_REF = /^\$\{user_config\.([\w-]+)\}$/;
|
|
|
69
80
|
* stripping nested runtime paths like `node_modules/x/skills/`. Keep in step
|
|
70
81
|
* with the directory entries in `templates/_.mcpbignore`.
|
|
71
82
|
*/
|
|
72
|
-
const KNOWN_DEV_DIRS = ['skills/', '.agents/', '.claude/'];
|
|
83
|
+
export const KNOWN_DEV_DIRS = ['skills/', '.agents/', '.claude/'];
|
|
73
84
|
|
|
74
85
|
/**
|
|
75
86
|
* Critical runtime paths that must NOT be stripped by any `.mcpbignore` pattern.
|
|
76
87
|
* These are sampled representative paths — enough to catch a bare `skills/`
|
|
77
88
|
* pattern accidentally stripping `node_modules/…/skills/`.
|
|
78
89
|
*/
|
|
79
|
-
const CRITICAL_RUNTIME_PATHS = [
|
|
90
|
+
export const CRITICAL_RUNTIME_PATHS = [
|
|
80
91
|
'node_modules/@opentelemetry/api/build/src/',
|
|
81
92
|
'node_modules/@modelcontextprotocol/sdk/dist/',
|
|
82
93
|
'node_modules/@cyanheads/mcp-ts-core/dist/',
|
|
83
94
|
'dist/index.js',
|
|
84
95
|
];
|
|
85
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Agent-doc entries under `node_modules/` that must not ship in a bundle.
|
|
99
|
+
* KEEP IN SYNC with `AGENT_DOC_ENTRY` in `scripts/clean-mcpb.ts` (the strip
|
|
100
|
+
* step this check verifies) — a unit test asserts the two are identical.
|
|
101
|
+
*/
|
|
102
|
+
export const AGENT_DOC_ENTRY =
|
|
103
|
+
/^node_modules\/.*(?:\/skills\/|\/\.claude\/|\/\.agents\/|\/SKILL\.md$)/;
|
|
104
|
+
|
|
105
|
+
/** The canonical in-code identity pair — both must equal the unscoped package name. */
|
|
106
|
+
const IDENTITY_PAIR = ['name', 'title'] as const;
|
|
107
|
+
|
|
86
108
|
function tryReadJson<T>(path: string): T | undefined {
|
|
87
109
|
try {
|
|
88
110
|
if (!existsSync(path)) return;
|
|
@@ -94,7 +116,7 @@ function tryReadJson<T>(path: string): T | undefined {
|
|
|
94
116
|
}
|
|
95
117
|
|
|
96
118
|
/**
|
|
97
|
-
* Run bundle-content checks (5–7) against
|
|
119
|
+
* Run bundle-content checks (5–7) against raw `.mcpbignore` content.
|
|
98
120
|
*
|
|
99
121
|
* Uses the `ignore` package (already a devDependency in scaffolded servers)
|
|
100
122
|
* to evaluate which paths survive the ignore rules. Returns an array of error
|
|
@@ -105,20 +127,32 @@ function tryReadJson<T>(path: string): T | undefined {
|
|
|
105
127
|
* devDependencies (`^7.0.5`) and is therefore available in the server's
|
|
106
128
|
* `node_modules` when `bun run lint:packaging` is invoked there.
|
|
107
129
|
*/
|
|
108
|
-
|
|
130
|
+
interface IgnoreMatcher {
|
|
131
|
+
add(patterns: string[]): IgnoreMatcher;
|
|
132
|
+
ignores(path: string): boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `ignore` package's factory, typed structurally — the CJS interop shape
|
|
137
|
+
* of `import('ignore')` differs between the script and test tsconfig programs,
|
|
138
|
+
* so naming the module's own types breaks one or the other.
|
|
139
|
+
*/
|
|
140
|
+
type IgnoreFactory = (options?: unknown) => IgnoreMatcher;
|
|
141
|
+
|
|
142
|
+
export async function checkBundleContent(raw: string): Promise<string[]> {
|
|
109
143
|
const errors: string[] = [];
|
|
110
144
|
|
|
111
|
-
let createIgnore:
|
|
145
|
+
let createIgnore: IgnoreFactory;
|
|
112
146
|
try {
|
|
113
147
|
// Dynamic import so the rest of the linter still runs when `ignore` is absent.
|
|
114
|
-
|
|
148
|
+
const mod: unknown = await import('ignore');
|
|
149
|
+
createIgnore = ((mod as { default?: unknown }).default ?? mod) as IgnoreFactory;
|
|
115
150
|
} catch {
|
|
116
151
|
// `ignore` not installed — skip the guard without failing (e.g. in a minimal
|
|
117
152
|
// CI environment that omits devDependencies).
|
|
118
153
|
return errors;
|
|
119
154
|
}
|
|
120
155
|
|
|
121
|
-
const raw = readFileSync(mcpbignorePath, 'utf-8');
|
|
122
156
|
const lines = raw
|
|
123
157
|
.split('\n')
|
|
124
158
|
.map((l) => l.trim())
|
|
@@ -180,81 +214,291 @@ async function checkBundleContent(mcpbignorePath: string): Promise<string[]> {
|
|
|
180
214
|
return errors;
|
|
181
215
|
}
|
|
182
216
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Check 8: a built bundle must contain zero `node_modules/**` agent-doc
|
|
219
|
+
* entries. `scripts/clean-mcpb.ts` (wired into the `bundle` script) strips
|
|
220
|
+
* them after `mcpb pack`.
|
|
221
|
+
*/
|
|
222
|
+
export function checkBundleEntries(entries: string[], bundleLabel: string): string[] {
|
|
223
|
+
const offending = entries.filter((entry) => AGENT_DOC_ENTRY.test(entry));
|
|
224
|
+
if (offending.length === 0) return [];
|
|
225
|
+
const sample = offending
|
|
226
|
+
.slice(0, 5)
|
|
227
|
+
.map((entry) => `\n ${entry}`)
|
|
228
|
+
.join('');
|
|
229
|
+
return [
|
|
230
|
+
`${bundleLabel} contains ${offending.length} node_modules agent-doc entries ` +
|
|
231
|
+
`(dependency-shipped skills/, .claude/, .agents/, SKILL.md) — re-run the \`bundle\` ` +
|
|
232
|
+
`script (scripts/clean-mcpb.ts strips them):${sample}`,
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Collect the direct (depth-1) property lines of the options object passed to
|
|
238
|
+
* `createApp()` / `createWorkerHandler()`. Returns undefined when no call with
|
|
239
|
+
* an inline options object exists (e.g. the framework's bare `createApp()`
|
|
240
|
+
* dev entry).
|
|
241
|
+
*
|
|
242
|
+
* Line-based, no AST: nested object literals (a `setup(core) { … }` body,
|
|
243
|
+
* `extensions: { … }`) are excluded by brace-depth tracking so an inner
|
|
244
|
+
* `name:`/`title:` key can't false-positive the identity check. Reliable for
|
|
245
|
+
* the template-scaffolded entrypoint shape with single-line string literals.
|
|
246
|
+
*/
|
|
247
|
+
function identityCandidateLines(source: string): string[] | undefined {
|
|
248
|
+
const call = source.match(/\b(?:createApp|createWorkerHandler)\s*\(\s*\{/);
|
|
249
|
+
if (call?.index === undefined) return;
|
|
189
250
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
251
|
+
const lines: string[] = [];
|
|
252
|
+
let depth = 0;
|
|
253
|
+
let lineStartDepth = 0;
|
|
254
|
+
let buf = '';
|
|
255
|
+
let inString: string | undefined;
|
|
256
|
+
let inBlockComment = false;
|
|
257
|
+
|
|
258
|
+
const flush = (): void => {
|
|
259
|
+
const trimmed = buf.trim();
|
|
260
|
+
if (
|
|
261
|
+
lineStartDepth === 1 &&
|
|
262
|
+
trimmed.length > 0 &&
|
|
263
|
+
!trimmed.startsWith('//') &&
|
|
264
|
+
!trimmed.startsWith('*')
|
|
265
|
+
) {
|
|
266
|
+
lines.push(trimmed);
|
|
267
|
+
}
|
|
268
|
+
buf = '';
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
for (let i = call.index + call[0].length - 1; i < source.length; i++) {
|
|
272
|
+
const ch = source[i] as string;
|
|
273
|
+
if (ch === '\n') {
|
|
274
|
+
flush();
|
|
275
|
+
lineStartDepth = depth;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
buf += ch;
|
|
279
|
+
if (inBlockComment) {
|
|
280
|
+
if (ch === '/' && source[i - 1] === '*') inBlockComment = false;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (inString) {
|
|
284
|
+
if (ch === '\\') {
|
|
285
|
+
buf += source[i + 1] ?? '';
|
|
286
|
+
i++;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (ch === inString) inString = undefined;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (ch === '/' && source[i + 1] === '/') {
|
|
293
|
+
const nl = source.indexOf('\n', i);
|
|
294
|
+
const end = nl === -1 ? source.length : nl;
|
|
295
|
+
buf += source.slice(i + 1, end);
|
|
296
|
+
i = end - 1;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (ch === '/' && source[i + 1] === '*') {
|
|
300
|
+
inBlockComment = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
304
|
+
inString = ch;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (ch === '{' || ch === '(' || ch === '[') {
|
|
308
|
+
depth++;
|
|
309
|
+
} else if (ch === '}' || ch === ')' || ch === ']') {
|
|
310
|
+
depth--;
|
|
311
|
+
if (depth === 0) {
|
|
312
|
+
flush();
|
|
313
|
+
return lines;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
194
316
|
}
|
|
317
|
+
flush();
|
|
318
|
+
return lines;
|
|
319
|
+
}
|
|
195
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Check 9 (entrypoint surface): `name`/`title` literals passed to
|
|
323
|
+
* `createApp()` / `createWorkerHandler()` must equal the unscoped package
|
|
324
|
+
* name. A partial pair (one or both missing) warns without failing — explicit
|
|
325
|
+
* `name` also keeps scoped npm names out of the served `server_name`.
|
|
326
|
+
*/
|
|
327
|
+
export function checkEntrypointIdentity(
|
|
328
|
+
source: string,
|
|
329
|
+
unscopedName: string,
|
|
330
|
+
fileLabel: string,
|
|
331
|
+
): { errors: string[]; warnings: string[] } {
|
|
196
332
|
const errors: string[] = [];
|
|
333
|
+
const warnings: string[] = [];
|
|
197
334
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
335
|
+
const optionLines = identityCandidateLines(source);
|
|
336
|
+
if (!optionLines) return { errors, warnings };
|
|
337
|
+
|
|
338
|
+
const present = new Set<string>();
|
|
339
|
+
for (const field of IDENTITY_PAIR) {
|
|
340
|
+
const fieldRe = new RegExp(`^['"\`]?${field}['"\`]?\\s*:`);
|
|
341
|
+
const literalRe = new RegExp(`^['"\`]?${field}['"\`]?\\s*:\\s*(['"\`])((?:(?!\\1).)*)\\1`);
|
|
342
|
+
for (const line of optionLines) {
|
|
343
|
+
if (!fieldRe.test(line)) continue;
|
|
344
|
+
present.add(field);
|
|
345
|
+
const literal = line.match(literalRe)?.[2];
|
|
346
|
+
if (literal !== undefined && literal !== unscopedName) {
|
|
347
|
+
errors.push(
|
|
348
|
+
`${fileLabel} sets ${field}: "${literal}" — must equal the unscoped package name ` +
|
|
349
|
+
`"${unscopedName}" (display identity is the machine name on every surface)`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
202
353
|
}
|
|
203
354
|
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
355
|
+
const missing = IDENTITY_PAIR.filter((field) => !present.has(field));
|
|
356
|
+
if (missing.length > 0) {
|
|
357
|
+
warnings.push(
|
|
358
|
+
`${fileLabel} identity pair is partial — missing: ${missing.join(', ')} ` +
|
|
359
|
+
`(set both name and title to the unscoped package name "${unscopedName}")`,
|
|
209
360
|
);
|
|
210
|
-
if (missing.length > 0) {
|
|
211
|
-
errors.push(
|
|
212
|
-
`manifest.json user_config["${key}"] is missing required field(s): ${missing.join(', ')} — mcpb pack will reject this`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
361
|
}
|
|
216
362
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const manifestEnv = manifest.server?.mcp_config?.env ?? {};
|
|
220
|
-
const manifestEnvKeys = new Set(Object.keys(manifestEnv));
|
|
363
|
+
return { errors, warnings };
|
|
364
|
+
}
|
|
221
365
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
366
|
+
/** Check 9 (manifest surface): `display_name`, when present, must be the unscoped package name. */
|
|
367
|
+
export function checkManifestIdentity(manifest: Manifest, unscopedName: string): string[] {
|
|
368
|
+
if (typeof manifest.display_name === 'string' && manifest.display_name !== unscopedName) {
|
|
369
|
+
return [
|
|
370
|
+
`manifest.json "display_name" is "${manifest.display_name}" — must equal the unscoped ` +
|
|
371
|
+
`package name "${unscopedName}"`,
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
227
376
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
377
|
+
async function main(): Promise<void> {
|
|
378
|
+
const errors: string[] = [];
|
|
379
|
+
const warnings: string[] = [];
|
|
380
|
+
const notes: string[] = [];
|
|
381
|
+
|
|
382
|
+
const pkg = tryReadJson<{ name?: string }>(resolve('package.json'));
|
|
383
|
+
const unscopedName = pkg?.name?.split('/').pop();
|
|
235
384
|
|
|
236
|
-
|
|
237
|
-
|
|
385
|
+
// ── Manifest-dependent checks (1–4 + manifest identity) ──
|
|
386
|
+
const manifestPath = resolve('manifest.json');
|
|
387
|
+
let manifest: Manifest | undefined;
|
|
388
|
+
if (existsSync(manifestPath)) {
|
|
389
|
+
manifest = tryReadJson<Manifest>(manifestPath);
|
|
390
|
+
if (!manifest) {
|
|
391
|
+
console.error('manifest.json is unreadable or malformed.');
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
238
394
|
|
|
239
|
-
if (
|
|
395
|
+
if (manifest.name?.includes('/')) {
|
|
240
396
|
errors.push(
|
|
241
|
-
`manifest.json
|
|
397
|
+
`manifest.json "name" contains a scope prefix ("${manifest.name}") — use the bare package name (e.g. "${manifest.name.split('/').pop()}")`,
|
|
242
398
|
);
|
|
243
399
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
400
|
+
|
|
401
|
+
const userConfig = manifest.user_config ?? {};
|
|
402
|
+
for (const [key, entry] of Object.entries(userConfig)) {
|
|
403
|
+
if (typeof entry !== 'object' || entry === null) continue;
|
|
404
|
+
const missing = (['title', 'type'] as const).filter(
|
|
405
|
+
(f) => typeof entry[f] !== 'string' || (entry[f] as string).length === 0,
|
|
247
406
|
);
|
|
407
|
+
if (missing.length > 0) {
|
|
408
|
+
errors.push(
|
|
409
|
+
`manifest.json user_config["${key}"] is missing required field(s): ${missing.join(', ')} — mcpb pack will reject this`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
248
412
|
}
|
|
413
|
+
|
|
414
|
+
const serverJson = tryReadJson<ServerJson>(resolve('server.json'));
|
|
415
|
+
if (serverJson) {
|
|
416
|
+
const manifestEnv = manifest.server?.mcp_config?.env ?? {};
|
|
417
|
+
const manifestEnvKeys = new Set(Object.keys(manifestEnv));
|
|
418
|
+
|
|
419
|
+
const manifestUserConfigKeys = new Set(
|
|
420
|
+
Object.entries(manifestEnv)
|
|
421
|
+
.filter(([, v]) => typeof v === 'string' && USER_CONFIG_REF.test(v))
|
|
422
|
+
.map(([k]) => k),
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
const stdioEnvVars = (serverJson.packages ?? [])
|
|
426
|
+
.filter((p) => p.transport?.type === 'stdio')
|
|
427
|
+
.flatMap((p) => p.environmentVariables ?? []);
|
|
428
|
+
const stdioEnvNames = new Set(stdioEnvVars.map((v) => v.name));
|
|
429
|
+
const requiredStdioEnvNames = new Set(
|
|
430
|
+
stdioEnvVars.filter((v) => v.isRequired === true && v.default == null).map((v) => v.name),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const missingInServerJson = [...manifestUserConfigKeys].filter((k) => !stdioEnvNames.has(k));
|
|
434
|
+
const missingInManifest = [...requiredStdioEnvNames].filter((k) => !manifestEnvKeys.has(k));
|
|
435
|
+
|
|
436
|
+
if (missingInServerJson.length > 0) {
|
|
437
|
+
errors.push(
|
|
438
|
+
`manifest.json references user_config env var(s) not advertised in server.json stdio environmentVariables[]: ${missingInServerJson.join(', ')}`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (missingInManifest.length > 0) {
|
|
442
|
+
errors.push(
|
|
443
|
+
`server.json declares required stdio env var(s) without default missing from manifest.json mcp_config.env: ${missingInManifest.join(', ')}`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (unscopedName) {
|
|
449
|
+
errors.push(...checkManifestIdentity(manifest, unscopedName));
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
notes.push('No manifest.json — skipping manifest/server.json alignment checks.');
|
|
249
453
|
}
|
|
250
454
|
|
|
251
|
-
// Bundle-content guard (checks 5–7)
|
|
455
|
+
// ── Bundle-content guard (checks 5–7) ──
|
|
252
456
|
const mcpbignorePath = resolve('.mcpbignore');
|
|
253
457
|
if (existsSync(mcpbignorePath)) {
|
|
254
|
-
|
|
255
|
-
errors.push(...bundleErrors);
|
|
458
|
+
errors.push(...(await checkBundleContent(readFileSync(mcpbignorePath, 'utf-8'))));
|
|
256
459
|
}
|
|
257
460
|
|
|
461
|
+
// ── Post-bundle content check (8) ──
|
|
462
|
+
const distDir = resolve('dist');
|
|
463
|
+
if (existsSync(distDir)) {
|
|
464
|
+
for (const file of readdirSync(distDir).filter((f) => f.endsWith('.mcpb'))) {
|
|
465
|
+
try {
|
|
466
|
+
const listing = execFileSync('unzip', ['-Z1', join(distDir, file)], {
|
|
467
|
+
encoding: 'utf-8',
|
|
468
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
469
|
+
});
|
|
470
|
+
errors.push(
|
|
471
|
+
...checkBundleEntries(
|
|
472
|
+
listing.split('\n').filter((line) => line.length > 0),
|
|
473
|
+
`dist/${file}`,
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
478
|
+
notes.push(`unzip not available — skipping bundle content check for dist/${file}.`);
|
|
479
|
+
} else {
|
|
480
|
+
errors.push(
|
|
481
|
+
`failed to list entries of dist/${file}: ${err instanceof Error ? err.message : err}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── Entrypoint identity check (9) ──
|
|
489
|
+
if (unscopedName) {
|
|
490
|
+
for (const entry of ['src/index.ts', 'src/worker.ts']) {
|
|
491
|
+
const entryPath = resolve(entry);
|
|
492
|
+
if (!existsSync(entryPath)) continue;
|
|
493
|
+
const result = checkEntrypointIdentity(readFileSync(entryPath, 'utf-8'), unscopedName, entry);
|
|
494
|
+
errors.push(...result.errors);
|
|
495
|
+
warnings.push(...result.warnings);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for (const note of notes) console.log(note);
|
|
500
|
+
for (const warning of warnings) console.warn(` ⚠ ${warning}`);
|
|
501
|
+
|
|
258
502
|
if (errors.length === 0) {
|
|
259
503
|
console.log('Packaging alignment OK.');
|
|
260
504
|
process.exit(0);
|
|
@@ -263,4 +507,6 @@ async function main(): Promise<void> {
|
|
|
263
507
|
process.exit(1);
|
|
264
508
|
}
|
|
265
509
|
|
|
266
|
-
|
|
510
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
511
|
+
await main();
|
|
512
|
+
}
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Testing patterns for MCP tool/resource handlers using `createMockContext` and Vitest. Covers mock context options, handler testing, McpError assertions, format testing, Vitest config setup, and test isolation conventions.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.5"
|
|
8
8
|
audience: external
|
|
9
9
|
type: reference
|
|
10
10
|
---
|
|
@@ -19,6 +19,52 @@ Tests target handler behavior directly — call `handler(input, ctx)`, assert on
|
|
|
19
19
|
|
|
20
20
|
---
|
|
21
21
|
|
|
22
|
+
## `mcpTest` — fixture-based Vitest test
|
|
23
|
+
|
|
24
|
+
`mcpTest` is a `test.extend`-based Vitest test that provides `ctx` and `storage` as **per-test fixtures** — fresh instances for every test, eliminating the `createMockContext()` boilerplate and enforcing the fresh-context-per-test convention automatically.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { mcpTest } from '@cyanheads/mcp-ts-core/testing/vitest';
|
|
28
|
+
|
|
29
|
+
mcpTest('echoes the message', async ({ ctx }) => {
|
|
30
|
+
const result = await echoTool.handler(echoTool.input.parse({ message: 'hi' }), ctx);
|
|
31
|
+
expect(result.message).toBe('hi');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
mcpTest('uses storage fixture', async ({ ctx, storage }) => {
|
|
35
|
+
const svc = new MyService(config, storage);
|
|
36
|
+
const result = await svc.doWork(ctx);
|
|
37
|
+
expect(result).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Fixtures
|
|
42
|
+
|
|
43
|
+
| Fixture | Type | Per-test? | Notes |
|
|
44
|
+
|:--------|:-----|:----------|:------|
|
|
45
|
+
| `ctx` | `Context` | Yes | Fresh `createMockContext()` each test |
|
|
46
|
+
| `storage` | `StorageService` | Yes | Fresh `createInMemoryStorage()` each test |
|
|
47
|
+
|
|
48
|
+
### Extending with the function form
|
|
49
|
+
|
|
50
|
+
Override fixtures using the **function form** (`async ({}, use) => { ... }`) to preserve per-test freshness. A bare-value override shares one mutable instance across the entire file — defeating the fixture's isolation guarantee.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createMockContext } from '@cyanheads/mcp-ts-core/testing/vitest';
|
|
54
|
+
|
|
55
|
+
// Correct — function form gives each test a fresh context:
|
|
56
|
+
const tenantTest = mcpTest.extend({
|
|
57
|
+
ctx: async ({}, use) => { await use(createMockContext({ tenantId: 'test-tenant' })); },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Wrong — bare value shares one ctx across every test in the file:
|
|
61
|
+
// const tenantTest = mcpTest.extend({ ctx: createMockContext({ tenantId: 'test-tenant' }) });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`createMockContext` and `createInMemoryStorage` are re-exported from `@cyanheads/mcp-ts-core/testing/vitest` so overrides don't need a second import.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
22
68
|
## `createMockContext` options
|
|
23
69
|
|
|
24
70
|
```ts
|