@cyanheads/mcp-ts-core 0.13.8 → 0.13.9
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 +17 -13
- package/CLAUDE.md +17 -13
- package/README.md +2 -2
- package/changelog/0.13.x/0.13.9.md +113 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +20 -9
- package/dist/config/index.js.map +1 -1
- package/dist/core/app.d.ts +6 -3
- package/dist/core/app.d.ts.map +1 -1
- package/dist/core/app.js +6 -4
- package/dist/core/app.js.map +1 -1
- package/dist/core/context.d.ts +25 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/serverManifest.d.ts +6 -0
- package/dist/core/serverManifest.d.ts.map +1 -1
- package/dist/core/serverManifest.js +6 -0
- package/dist/core/serverManifest.js.map +1 -1
- package/dist/linter/rules/tool-rules.d.ts +2 -1
- package/dist/linter/rules/tool-rules.d.ts.map +1 -1
- package/dist/linter/rules/tool-rules.js +36 -1
- package/dist/linter/rules/tool-rules.js.map +1 -1
- package/dist/mcp-server/inputRequired.d.ts +14 -5
- package/dist/mcp-server/inputRequired.d.ts.map +1 -1
- package/dist/mcp-server/inputRequired.js +15 -8
- package/dist/mcp-server/inputRequired.js.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts +163 -40
- package/dist/mcp-server/tools/utils/inputPrevalidation.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/inputPrevalidation.js +330 -114
- package/dist/mcp-server/tools/utils/inputPrevalidation.js.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +23 -10
- package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js +296 -80
- package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.d.ts.map +1 -1
- package/dist/mcp-server/transports/http/httpTransport.js +65 -9
- package/dist/mcp-server/transports/http/httpTransport.js.map +1 -1
- package/dist/mcp-server/transports/stdio/stdioTransport.d.ts +9 -5
- package/dist/mcp-server/transports/stdio/stdioTransport.d.ts.map +1 -1
- package/dist/mcp-server/transports/stdio/stdioTransport.js +9 -5
- package/dist/mcp-server/transports/stdio/stdioTransport.js.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.d.ts +6 -2
- package/dist/services/canvas/core/CanvasRegistry.d.ts.map +1 -1
- package/dist/services/canvas/core/CanvasRegistry.js +7 -3
- package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts +82 -18
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +620 -328
- package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts +11 -7
- package/dist/services/canvas/providers/duckdb/exportWriter.d.ts.map +1 -1
- package/dist/services/canvas/providers/duckdb/exportWriter.js +19 -16
- package/dist/services/canvas/providers/duckdb/exportWriter.js.map +1 -1
- package/dist/services/mirror/core/defineMirror.d.ts +1 -0
- package/dist/services/mirror/core/defineMirror.d.ts.map +1 -1
- package/dist/services/mirror/core/defineMirror.js +1 -0
- package/dist/services/mirror/core/defineMirror.js.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/network/pacer.d.ts +38 -5
- package/dist/utils/network/pacer.d.ts.map +1 -1
- package/dist/utils/network/pacer.js +87 -25
- package/dist/utils/network/pacer.js.map +1 -1
- package/dist/utils/telemetry/attributes.d.ts +5 -1
- package/dist/utils/telemetry/attributes.d.ts.map +1 -1
- package/dist/utils/telemetry/attributes.js +5 -1
- package/dist/utils/telemetry/attributes.js.map +1 -1
- package/framework-skills/add-app-tool/SKILL.md +3 -3
- package/framework-skills/add-export/SKILL.md +5 -16
- package/framework-skills/add-prompt/SKILL.md +7 -3
- package/framework-skills/add-resource/SKILL.md +7 -5
- package/framework-skills/add-tool/SKILL.md +12 -10
- package/framework-skills/api-auth/SKILL.md +2 -2
- package/framework-skills/api-canvas/SKILL.md +17 -8
- package/framework-skills/api-config/SKILL.md +4 -4
- package/framework-skills/api-context/SKILL.md +14 -3
- package/framework-skills/api-errors/SKILL.md +8 -7
- package/framework-skills/api-linter/SKILL.md +26 -7
- package/framework-skills/api-mirror/SKILL.md +2 -1
- package/framework-skills/api-telemetry/SKILL.md +4 -4
- package/framework-skills/api-utils/SKILL.md +2 -2
- package/framework-skills/design-mcp-server/SKILL.md +2 -2
- package/framework-skills/field-test/SKILL.md +4 -4
- package/framework-skills/git-wrapup/SKILL.md +8 -6
- package/framework-skills/orchestrations/SKILL.md +7 -6
- package/framework-skills/orchestrations/workflows/field-test-fix.md +9 -19
- package/framework-skills/orchestrations/workflows/fix-wrapup-release.md +7 -7
- package/framework-skills/orchestrations/workflows/greenfield-build.md +8 -5
- package/framework-skills/orchestrations/workflows/maintenance-release.md +8 -8
- package/framework-skills/polish-docs-meta/SKILL.md +4 -4
- package/framework-skills/release-and-publish/SKILL.md +6 -4
- package/framework-skills/release-pr-review/SKILL.md +37 -23
- package/framework-skills/report-issue-framework/SKILL.md +7 -5
- package/framework-skills/report-issue-local/SKILL.md +8 -6
- package/framework-skills/security-pass/SKILL.md +8 -8
- package/package.json +3 -3
- package/scripts/devcheck.ts +7 -6
- package/scripts/lint-mcp.ts +87 -27
- package/scripts/lint-packaging.ts +61 -0
- package/scripts/release-github.ts +117 -5
- package/templates/_.mcpbignore +2 -0
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
File a bug or feature request against this MCP server's own repo. Use for server-specific issues — tool logic, service integrations, config problems, or domain bugs that aren't caused by the framework.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.12"
|
|
8
8
|
audience: external
|
|
9
9
|
type: workflow
|
|
10
10
|
---
|
|
@@ -38,7 +38,8 @@ gh repo view --json nameWithOwner -q '.nameWithOwner'
|
|
|
38
38
|
gh issue list --search "your error message or keyword" --state all
|
|
39
39
|
|
|
40
40
|
# Assess a close match before commenting — is it already linked to a fix or referenced elsewhere?
|
|
41
|
-
gh issue view <number>
|
|
41
|
+
gh issue view <number> # body
|
|
42
|
+
gh issue view <number> --comments # thread only — without a TTY it prints no body
|
|
42
43
|
gh api 'repos/{owner}/{repo}/issues/<number>/timeline' --paginate \
|
|
43
44
|
--jq '.[] | select(.event=="cross-referenced") | .source.issue | "\(.repository.full_name)#\(.number) — \(.title)"'
|
|
44
45
|
```
|
|
@@ -87,11 +88,11 @@ gh issue create \
|
|
|
87
88
|
--body "$(cat <<'ISSUE'
|
|
88
89
|
### Server version
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
<package.json version>
|
|
91
92
|
|
|
92
93
|
### mcp-ts-core version
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
<installed version from node_modules/@cyanheads/mcp-ts-core/package.json — not the ^ range>
|
|
95
96
|
|
|
96
97
|
### Runtime
|
|
97
98
|
|
|
@@ -99,7 +100,7 @@ Bun
|
|
|
99
100
|
|
|
100
101
|
### Runtime version
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
<bun --version>
|
|
103
104
|
|
|
104
105
|
### Transport
|
|
105
106
|
|
|
@@ -258,7 +259,8 @@ When genuinely ambiguous, file against this server's repo and note that it might
|
|
|
258
259
|
## Following Up
|
|
259
260
|
|
|
260
261
|
```bash
|
|
261
|
-
# View issue
|
|
262
|
+
# View the issue body, then its comment thread (--comments without a TTY prints no body)
|
|
263
|
+
gh issue view <number>
|
|
262
264
|
gh issue view <number> --comments
|
|
263
265
|
|
|
264
266
|
# Add context
|
|
@@ -4,7 +4,7 @@ description: >
|
|
|
4
4
|
Review an MCP server for common security gaps: LLM-facing surfaces as injection vector (tools, resources, prompts, descriptions), scope blast radius, destructive ops without consent, upstream auth shape, input sinks (URL / path / roots / shell / schema strictness / ReDoS), tenant isolation, leakage through errors and telemetry, unbounded resources, and HTTP-mode deployment surface. Use before a release, after a batch of handler changes, or when the user asks for a security review, audit, or hardening pass. Produces grouped findings and a numbered options list.
|
|
5
5
|
metadata:
|
|
6
6
|
author: cyanheads
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.11"
|
|
8
8
|
audience: external
|
|
9
9
|
type: audit
|
|
10
10
|
---
|
|
@@ -116,7 +116,7 @@ grep -rn "auth: \[" src/mcp-server/tools/definitions/
|
|
|
116
116
|
|
|
117
117
|
```bash
|
|
118
118
|
grep -rn "destructiveHint" src/mcp-server/tools/definitions/
|
|
119
|
-
grep -
|
|
119
|
+
grep -rnE "ctx\.requestInput|ctx\.inputs" src/mcp-server/tools/definitions/
|
|
120
120
|
```
|
|
121
121
|
|
|
122
122
|
**Check:**
|
|
@@ -127,7 +127,7 @@ grep -rn "ctx.requestInput\|ctx.inputs" src/mcp-server/tools/definitions/
|
|
|
127
127
|
- Consent is scoped to the specific target (e.g., record ID rendered in the message), not a generic "proceed?"
|
|
128
128
|
- Any `requestState` carried across rounds is integrity-protected if it influences authorization, resource access, or which target gets mutated. It round-trips through the client and comes back attacker-controlled; the SDK does not sign or verify it.
|
|
129
129
|
- **A consent gate's state is server-issued and single-use.** A client can send `inputResponses` plus a `requestState` of its own on the very FIRST call — honored on 2025-era connections, even from a client that declared no `elicitation` — so a handler that only *compares* client-carried state against a fresh resolution deletes on a forged "accepted" answer without ever prompting. A signed state closes forgery but not replay within its TTL. Keep the confirmed target (plus a content hash, so a same-path swap is caught) in a server-side record keyed by a random id, send only the id, redeem it before anything else in the handler, and refuse an unknown, used, or expired id.
|
|
130
|
-
- **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback
|
|
130
|
+
- **The weak point is answerability, not availability.** `ctx.requestInput` is present on every transport and both protocol eras — the 2025-era shim issues the real `elicitation/create` round trip, the 2026-07-28 client fulfils the embedded request directly. A client that never retries simply leaves the destructive step un-run, which fails safe. Keep `destructiveHint: true` so client-side approval flows still surface the risk, and do not accept "proceed anyway when the round is unavailable" as a fallback. On a 2025-era connection whose client lacks the capability, `ctx.requestInput` throws `client_capability_missing` inside the handler; catching that to run the side effect is exactly this bypass — let it propagate.
|
|
131
131
|
|
|
132
132
|
**Smell:** `destructiveHint: true` file with no `ctx.requestInput` in it. Or `ctx.inputs.accepted('confirm')` with no schema argument — the content could be anything. Or a handler that re-issues the same request after a `decline`.
|
|
133
133
|
|
|
@@ -157,22 +157,22 @@ LLM-supplied inputs feel internal but aren't. Classic sinks apply, amplified. Sa
|
|
|
157
157
|
grep -rn "z.string().url()" src/
|
|
158
158
|
|
|
159
159
|
# Path sinks — traversal
|
|
160
|
-
grep -
|
|
160
|
+
grep -rnE "readFile|writeFile|readdirSync|createReadStream|statSync" src/
|
|
161
161
|
|
|
162
162
|
# Shell sinks — command injection
|
|
163
163
|
grep -rnE "\b(exec|spawn|execSync|spawnSync)\b" src/
|
|
164
164
|
|
|
165
165
|
# Merges — prototype pollution
|
|
166
|
-
grep -
|
|
166
|
+
grep -rnE "Object\.assign\b|structuredClone" src/
|
|
167
167
|
|
|
168
168
|
# Lookups — prototype chain read through an object literal
|
|
169
169
|
grep -rnE "\[[a-zA-Z_$][a-zA-Z0-9_$.]*\] *\?\? |\[[a-zA-Z_$][a-zA-Z0-9_$.]*\] *\|\| " src/
|
|
170
170
|
|
|
171
171
|
# Roots — client-shared filesystem
|
|
172
|
-
grep -
|
|
172
|
+
grep -rnE "roots/list|ctx\.roots" src/
|
|
173
173
|
|
|
174
174
|
# Schema laxity — fields sneaking past validation
|
|
175
|
-
grep -
|
|
175
|
+
grep -rnE "\.passthrough\(\)|\.loose\(\)|looseObject\(|\.catchall\(" src/mcp-server/
|
|
176
176
|
```
|
|
177
177
|
|
|
178
178
|
**Check:**
|
|
@@ -245,7 +245,7 @@ Unbounded = DoS of self, upstream, or the LLM's context window (billing-DoS is r
|
|
|
245
245
|
|
|
246
246
|
```bash
|
|
247
247
|
grep -rnE "while\s*\(|for\s*\(.*of" src/mcp-server/tools/definitions/
|
|
248
|
-
grep -
|
|
248
|
+
grep -rnE "cursor|nextPage|paginate" src/
|
|
249
249
|
grep -rn "JSON.parse\b" src/
|
|
250
250
|
```
|
|
251
251
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyanheads/mcp-ts-core",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.9",
|
|
4
4
|
"mcpName": "io.github.cyanheads/mcp-ts-core",
|
|
5
5
|
"description": "Agent-native TypeScript framework for MCP servers. Includes runtime infrastructure and agent skills for building, testing, and shipping servers.",
|
|
6
6
|
"files": [
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
"@cloudflare/workers-types": "5.20260922.1",
|
|
202
202
|
"@duckdb/node-api": "^1.5.5-r.5",
|
|
203
203
|
"@hono/otel": "^1.1.2",
|
|
204
|
-
"@modelcontextprotocol/client": "^2.
|
|
204
|
+
"@modelcontextprotocol/client": "^2.1.0",
|
|
205
205
|
"@opentelemetry/api-logs": "^0.222.0",
|
|
206
206
|
"@opentelemetry/exporter-logs-otlp-http": "^0.222.0",
|
|
207
207
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.222.0",
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
},
|
|
291
291
|
"dependencies": {
|
|
292
292
|
"@hono/node-server": "^2.1.1",
|
|
293
|
-
"@modelcontextprotocol/server": "^2.
|
|
293
|
+
"@modelcontextprotocol/server": "^2.1.0",
|
|
294
294
|
"@opentelemetry/api": "^1.9.1",
|
|
295
295
|
"hono": "^4.13.8",
|
|
296
296
|
"jose": "^6.2.12",
|
package/scripts/devcheck.ts
CHANGED
|
@@ -711,12 +711,12 @@ const ALL_CHECKS: Check[] = [
|
|
|
711
711
|
canFix: false,
|
|
712
712
|
// Validates env var alignment between manifest.json (MCPB bundle) and
|
|
713
713
|
// server.json (MCP Registry), plus plugin marketplace manifests (#240), the
|
|
714
|
-
// bundle-content guards on .mcpbignore (#343),
|
|
715
|
-
// (#418). Runs when any of those inputs
|
|
716
|
-
// none exist — consumers on an HTTP-only
|
|
717
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// only covered incidentally.
|
|
714
|
+
// bundle-content guards on .mcpbignore (#343), the README version badge
|
|
715
|
+
// (#418), and the Dockerfile build platform. Runs when any of those inputs
|
|
716
|
+
// is present; skipped cleanly when none exist — consumers on an HTTP-only
|
|
717
|
+
// deploy are unaffected. README.md and Dockerfile are triggers in their own
|
|
718
|
+
// right: each check must gate a project that carries no bundle or plugin
|
|
719
|
+
// metadata at all, which the other inputs only covered incidentally.
|
|
720
720
|
getCommand: () => {
|
|
721
721
|
const inputs = [
|
|
722
722
|
'manifest.json',
|
|
@@ -725,6 +725,7 @@ const ALL_CHECKS: Check[] = [
|
|
|
725
725
|
'.codex-plugin/mcp.json',
|
|
726
726
|
'.mcpbignore',
|
|
727
727
|
'README.md',
|
|
728
|
+
'Dockerfile',
|
|
728
729
|
];
|
|
729
730
|
if (!inputs.some((input) => existsSync(path.join(ROOT_DIR, input)))) return null;
|
|
730
731
|
return ['bun', 'run', 'scripts/lint-packaging.ts'];
|
package/scripts/lint-mcp.ts
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
* 3. Extracts exported definitions by duck-typing (has name/handler/input etc.)
|
|
13
13
|
* 4. Feeds them into `validateDefinitions()`
|
|
14
14
|
*
|
|
15
|
+
* A definition file whose `import()` rejects, and a `server.json` that exists
|
|
16
|
+
* but does not parse, are errors (`definition-import-failed`,
|
|
17
|
+
* `server-json-parse`): what they declare cannot be checked, so the run fails
|
|
18
|
+
* instead of passing without them. The remaining files are still linted.
|
|
19
|
+
*
|
|
15
20
|
* Runtime-agnostic: works with bun, tsx, and Node.js (via ts-node/esm).
|
|
16
21
|
*
|
|
17
22
|
* Rule knobs come from the project's `devcheck.config.json` `lint` block, so one
|
|
@@ -20,7 +25,7 @@
|
|
|
20
25
|
* @module scripts/lint-mcp
|
|
21
26
|
*/
|
|
22
27
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
23
|
-
import { join, resolve } from 'node:path';
|
|
28
|
+
import { join, relative, resolve } from 'node:path';
|
|
24
29
|
import { fileURLToPath } from 'node:url';
|
|
25
30
|
|
|
26
31
|
// ---------------------------------------------------------------------------
|
|
@@ -100,19 +105,64 @@ function discoverFiles(): string[] {
|
|
|
100
105
|
return [...new Set(files)];
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Load failures
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** Where the rule reference lives — the breadcrumb `validateDefinitions()` appends too. */
|
|
113
|
+
const SKILL_REFERENCE_PATH = 'framework-skills/api-linter/SKILL.md';
|
|
114
|
+
|
|
115
|
+
/** A file the CLI could not load, shaped like the diagnostics it is printed beside. */
|
|
116
|
+
interface LoadFailure {
|
|
117
|
+
message: string;
|
|
118
|
+
rule: 'definition-import-failed' | 'server-json-parse';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The text of a thrown value. An `AggregateError` keeps its causes in `errors`
|
|
123
|
+
* — Bun rejects a definition that fails to transpile with one whose own message
|
|
124
|
+
* only counts them ("2 errors building …") — so each cause is appended.
|
|
125
|
+
*/
|
|
126
|
+
function describeError(err: unknown): string {
|
|
127
|
+
if (!(err instanceof Error)) return String(err);
|
|
128
|
+
const message = err.message || err.name;
|
|
129
|
+
if (!(err instanceof AggregateError) || err.errors.length === 0) return message;
|
|
130
|
+
return `${message} (${err.errors.map(describeError).join('; ')})`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function loadFailure(rule: LoadFailure['rule'], file: string, err: unknown): LoadFailure {
|
|
134
|
+
const anchor = rule === 'server-json-parse' ? 'server-json-rules' : rule;
|
|
135
|
+
return {
|
|
136
|
+
rule,
|
|
137
|
+
message: `${file}: ${describeError(err)}\nSee: ${SKILL_REFERENCE_PATH}#${anchor}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
// ---------------------------------------------------------------------------
|
|
104
142
|
// Main
|
|
105
143
|
// ---------------------------------------------------------------------------
|
|
106
144
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
145
|
+
/** A parsed JSON file, or the error an existing one failed to read or parse with. */
|
|
146
|
+
type JsonRead = { ok: true; value: unknown } | { ok: false; error: unknown };
|
|
147
|
+
|
|
148
|
+
/** Reads and parses a JSON file. `undefined` when the file does not exist. */
|
|
149
|
+
function readJson(path: string): JsonRead | undefined {
|
|
150
|
+
if (!existsSync(path)) return;
|
|
109
151
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
152
|
+
return { ok: true, value: JSON.parse(readFileSync(path, 'utf-8')) };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { ok: false, error };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Try to read and parse a JSON file. Returns undefined when absent, and warns when unparseable. */
|
|
159
|
+
function tryReadJson(path: string): unknown {
|
|
160
|
+
const read = readJson(path);
|
|
161
|
+
if (read?.ok === false) {
|
|
162
|
+
console.warn(`Warning: Failed to parse ${path}: ${describeError(read.error)}`);
|
|
114
163
|
return;
|
|
115
164
|
}
|
|
165
|
+
return read?.value;
|
|
116
166
|
}
|
|
117
167
|
|
|
118
168
|
/** The `lint` block of `devcheck.config.json`, as far as this script reads it. */
|
|
@@ -149,12 +199,17 @@ export function readLintOptions(configPath = resolve('devcheck.config.json')): L
|
|
|
149
199
|
|
|
150
200
|
async function main(): Promise<void> {
|
|
151
201
|
const files = discoverFiles();
|
|
202
|
+
const failures: LoadFailure[] = [];
|
|
152
203
|
|
|
153
204
|
// Discover server.json and package.json at project root
|
|
154
|
-
const
|
|
205
|
+
const serverJsonRead = readJson(resolve('server.json'));
|
|
206
|
+
if (serverJsonRead?.ok === false) {
|
|
207
|
+
failures.push(loadFailure('server-json-parse', 'server.json', serverJsonRead.error));
|
|
208
|
+
}
|
|
209
|
+
const serverJson = serverJsonRead?.ok ? serverJsonRead.value : undefined;
|
|
155
210
|
const packageJson = tryReadJson(resolve('package.json')) as { version?: string } | undefined;
|
|
156
211
|
|
|
157
|
-
if (files.length === 0 && serverJson == null) {
|
|
212
|
+
if (files.length === 0 && serverJson == null && failures.length === 0) {
|
|
158
213
|
console.log('No MCP definition files or server.json found. Skipping lint.');
|
|
159
214
|
process.exit(0);
|
|
160
215
|
}
|
|
@@ -162,36 +217,42 @@ async function main(): Promise<void> {
|
|
|
162
217
|
const tools: unknown[] = [];
|
|
163
218
|
const resources: unknown[] = [];
|
|
164
219
|
const prompts: unknown[] = [];
|
|
220
|
+
let failedImports = 0;
|
|
165
221
|
|
|
166
222
|
for (const file of files) {
|
|
223
|
+
let mod: Record<string, unknown>;
|
|
167
224
|
try {
|
|
168
|
-
|
|
169
|
-
for (const exported of Object.values(mod)) {
|
|
170
|
-
if (isToolLike(exported)) tools.push(exported);
|
|
171
|
-
else if (isResourceLike(exported)) resources.push(exported);
|
|
172
|
-
else if (isPromptLike(exported)) prompts.push(exported);
|
|
173
|
-
}
|
|
225
|
+
mod = await import(file);
|
|
174
226
|
} catch (err) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
227
|
+
failures.push(loadFailure('definition-import-failed', relative(process.cwd(), file), err));
|
|
228
|
+
failedImports++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
for (const exported of Object.values(mod)) {
|
|
232
|
+
if (isToolLike(exported)) tools.push(exported);
|
|
233
|
+
else if (isResourceLike(exported)) resources.push(exported);
|
|
234
|
+
else if (isPromptLike(exported)) prompts.push(exported);
|
|
178
235
|
}
|
|
179
236
|
}
|
|
180
237
|
|
|
181
238
|
const defTotal = tools.length + resources.length + prompts.length;
|
|
182
|
-
if (defTotal === 0 && serverJson == null) {
|
|
239
|
+
if (defTotal === 0 && serverJson == null && failures.length === 0) {
|
|
183
240
|
console.log(`Scanned ${files.length} files but found no definitions. Skipping lint.`);
|
|
184
241
|
process.exit(0);
|
|
185
242
|
}
|
|
186
243
|
|
|
187
244
|
const parts: string[] = [];
|
|
188
|
-
if (defTotal > 0) {
|
|
245
|
+
if (defTotal > 0 || failedImports > 0) {
|
|
246
|
+
const fileCount =
|
|
247
|
+
failedImports === 0
|
|
248
|
+
? `${files.length}`
|
|
249
|
+
: `${files.length - failedImports} of ${files.length}`;
|
|
189
250
|
parts.push(
|
|
190
|
-
`${tools.length} tool(s), ${resources.length} resource(s), ${prompts.length} prompt(s) from ${
|
|
251
|
+
`${tools.length} tool(s), ${resources.length} resource(s), ${prompts.length} prompt(s) from ${fileCount} file(s)`,
|
|
191
252
|
);
|
|
192
253
|
}
|
|
193
254
|
if (serverJson != null) parts.push('server.json');
|
|
194
|
-
console.log(`Linting ${parts.join(' + ')}...`);
|
|
255
|
+
if (parts.length > 0) console.log(`Linting ${parts.join(' + ')}...`);
|
|
195
256
|
|
|
196
257
|
const report = validateDefinitions({
|
|
197
258
|
tools,
|
|
@@ -202,14 +263,15 @@ async function main(): Promise<void> {
|
|
|
202
263
|
...readLintOptions(),
|
|
203
264
|
});
|
|
204
265
|
|
|
266
|
+
const errors = [...failures, ...report.errors];
|
|
205
267
|
for (const w of report.warnings) {
|
|
206
268
|
console.warn(` ⚠ [${w.rule}] ${w.message}`);
|
|
207
269
|
}
|
|
208
|
-
for (const e of
|
|
270
|
+
for (const e of errors) {
|
|
209
271
|
console.error(` ✗ [${e.rule}] ${e.message}`);
|
|
210
272
|
}
|
|
211
273
|
|
|
212
|
-
if (
|
|
274
|
+
if (errors.length === 0) {
|
|
213
275
|
if (report.warnings.length > 0) {
|
|
214
276
|
console.log(`\nPassed with ${report.warnings.length} warning(s).`);
|
|
215
277
|
} else {
|
|
@@ -217,9 +279,7 @@ async function main(): Promise<void> {
|
|
|
217
279
|
}
|
|
218
280
|
process.exit(0);
|
|
219
281
|
} else {
|
|
220
|
-
console.error(
|
|
221
|
-
`\nFailed: ${report.errors.length} error(s), ${report.warnings.length} warning(s).`,
|
|
222
|
-
);
|
|
282
|
+
console.error(`\nFailed: ${errors.length} error(s), ${report.warnings.length} warning(s).`);
|
|
223
283
|
process.exit(1);
|
|
224
284
|
}
|
|
225
285
|
}
|
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
* so without the entry a release that bundles before publishing ships the
|
|
66
66
|
* server and its production dependencies inside the npm tarball
|
|
67
67
|
* (issue #469). Skipped when `manifest.json` or `files` is absent.
|
|
68
|
+
* 14. manifest.json version parity: `version` must equal `package.json`'s, the
|
|
69
|
+
* same rule check 10 applies to the plugin manifests — the bundle's install
|
|
70
|
+
* dialog shows it. Skipped when `manifest.json` or the package version is
|
|
71
|
+
* absent.
|
|
72
|
+
* 15. Dockerfile build platform: every stage that runs `bun run build` must
|
|
73
|
+
* start `FROM --platform=$BUILDPLATFORM`. Without it the non-native leg of
|
|
74
|
+
* a multi-arch `docker buildx` build runs under QEMU, where bun >= 1.4
|
|
75
|
+
* aborts inside the build and no image publishes for either architecture —
|
|
76
|
+
* and the image publishes last, after npm and the MCP Registry. Skipped
|
|
77
|
+
* when there is no `Dockerfile` or no stage builds.
|
|
68
78
|
*
|
|
69
79
|
* Every check skips cleanly when its input is absent — consumers who deleted
|
|
70
80
|
* `manifest.json` for an HTTP-only deploy, or who haven't built a bundle,
|
|
@@ -103,6 +113,7 @@ interface Manifest {
|
|
|
103
113
|
name?: string;
|
|
104
114
|
server?: { mcp_config?: { args?: unknown[]; env?: Record<string, string> } };
|
|
105
115
|
user_config?: Record<string, ManifestUserConfigEntry>;
|
|
116
|
+
version?: unknown;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
const USER_CONFIG_REF = /^\$\{user_config\.([\w-]+)\}$/;
|
|
@@ -775,6 +786,49 @@ export function checkBundleExcludedFromFiles(files: unknown): string[] {
|
|
|
775
786
|
];
|
|
776
787
|
}
|
|
777
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Check 14: manifest.json `version` must equal `package.json`'s. Skipped when
|
|
791
|
+
* the package version is absent — the same fail-safe as checks 10 and 12.
|
|
792
|
+
*/
|
|
793
|
+
export function checkManifestVersion(manifest: Manifest, packageVersion?: string): string[] {
|
|
794
|
+
if (!packageVersion || manifest.version === packageVersion) return [];
|
|
795
|
+
return [
|
|
796
|
+
manifest.version === undefined
|
|
797
|
+
? `manifest.json has no "version" — must declare the package.json version "${packageVersion}"`
|
|
798
|
+
: `manifest.json "version" is "${String(manifest.version)}" — must equal the package.json version "${packageVersion}"`,
|
|
799
|
+
];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const DOCKERFILE_FROM = /^\s*FROM\s/i;
|
|
803
|
+
const DOCKERFILE_BUILD_PLATFORM = /--platform=\$\{?BUILDPLATFORM\}?(?:\s|$)/;
|
|
804
|
+
const DOCKERFILE_BUILD_STEP = /\bbun run (?:re)?build\b/;
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Check 15: every Dockerfile stage that runs `bun run build` must be pinned to
|
|
808
|
+
* the build platform. Stages are split at `FROM` lines; comment lines are
|
|
809
|
+
* ignored so a note mentioning the build does not count as one.
|
|
810
|
+
*/
|
|
811
|
+
export function checkDockerfileBuildPlatform(dockerfile: string): string[] {
|
|
812
|
+
const stages: { from: string; line: number; builds: boolean }[] = [];
|
|
813
|
+
for (const [index, line] of dockerfile.split('\n').entries()) {
|
|
814
|
+
if (DOCKERFILE_FROM.test(line)) {
|
|
815
|
+
stages.push({ from: line.trim(), line: index + 1, builds: false });
|
|
816
|
+
} else if (!line.trimStart().startsWith('#') && DOCKERFILE_BUILD_STEP.test(line)) {
|
|
817
|
+
const stage = stages.at(-1);
|
|
818
|
+
if (stage) stage.builds = true;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return stages
|
|
823
|
+
.filter((stage) => stage.builds && !DOCKERFILE_BUILD_PLATFORM.test(stage.from))
|
|
824
|
+
.map(
|
|
825
|
+
(stage) =>
|
|
826
|
+
`Dockerfile:${stage.line} "${stage.from}" runs \`bun run build\` without --platform=$BUILDPLATFORM — ` +
|
|
827
|
+
`a multi-arch buildx build then runs it under QEMU, where bun aborts and no image publishes; ` +
|
|
828
|
+
`start the build stage with "FROM --platform=$BUILDPLATFORM" and copy dist/ into a separate runtime stage`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
778
832
|
/** Read `packaging.pluginManifests` from devcheck.config.json; default on. */
|
|
779
833
|
function pluginManifestsEnabled(): boolean {
|
|
780
834
|
const cfg = tryReadJson<{ packaging?: { pluginManifests?: boolean } }>(
|
|
@@ -862,6 +916,7 @@ async function main(): Promise<void> {
|
|
|
862
916
|
errors.push(...checkManifestIdentity(manifest, unscopedName));
|
|
863
917
|
}
|
|
864
918
|
|
|
919
|
+
errors.push(...checkManifestVersion(manifest, pkg?.version));
|
|
865
920
|
errors.push(...checkBundleExcludedFromFiles(pkg?.files));
|
|
866
921
|
} else {
|
|
867
922
|
notes.push('No manifest.json — skipping manifest/server.json alignment checks.');
|
|
@@ -911,6 +966,12 @@ async function main(): Promise<void> {
|
|
|
911
966
|
}
|
|
912
967
|
}
|
|
913
968
|
|
|
969
|
+
// ── Dockerfile build platform (check 15) ──
|
|
970
|
+
const dockerfilePath = resolve('Dockerfile');
|
|
971
|
+
if (existsSync(dockerfilePath)) {
|
|
972
|
+
errors.push(...checkDockerfileBuildPlatform(readFileSync(dockerfilePath, 'utf-8')));
|
|
973
|
+
}
|
|
974
|
+
|
|
914
975
|
// ── README version badge (check 12) ──
|
|
915
976
|
const readmePath = resolve('README.md');
|
|
916
977
|
if (existsSync(readmePath)) {
|
|
@@ -18,6 +18,15 @@
|
|
|
18
18
|
* The framework itself has no `manifest.json`/`.mcpb`, so the attach path is
|
|
19
19
|
* skipped here but scaffolded servers that do have a manifest get the full flow.
|
|
20
20
|
*
|
|
21
|
+
* Before any `gh` call it validates the tag — `--notes-from-tag` publishes the
|
|
22
|
+
* message verbatim as the release body, so a malformed tag becomes a malformed
|
|
23
|
+
* public release. `--check` runs only that validation, for use right after
|
|
24
|
+
* `git tag -a` and before the tag is pushed. The rules are the ones the
|
|
25
|
+
* `release-and-publish` skill states for the annotation: an annotated tag, a
|
|
26
|
+
* subject of at most 72 characters with no version and no `;`, flat bullets
|
|
27
|
+
* with no section headers, no signature block leaked into the body, and the
|
|
28
|
+
* `[CHANGELOG v<version>](…)` link as the final line.
|
|
29
|
+
*
|
|
21
30
|
* @module scripts/release-github
|
|
22
31
|
*
|
|
23
32
|
* @example
|
|
@@ -25,6 +34,10 @@
|
|
|
25
34
|
* // bun run release:github
|
|
26
35
|
*
|
|
27
36
|
* @example
|
|
37
|
+
* // Validate the tag annotation only (exit 1 on a violation, no gh calls):
|
|
38
|
+
* // bun run release:github -- --check
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
28
41
|
* // Dry-run — print the command that would be executed without running it:
|
|
29
42
|
* // bun run release:github -- --dry-run
|
|
30
43
|
*/
|
|
@@ -33,8 +46,86 @@ import { spawnSync } from 'node:child_process';
|
|
|
33
46
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
47
|
import { resolve } from 'node:path';
|
|
35
48
|
import process from 'node:process';
|
|
49
|
+
import { fileURLToPath } from 'node:url';
|
|
36
50
|
|
|
37
51
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
52
|
+
const CHECK_ONLY = process.argv.includes('--check');
|
|
53
|
+
|
|
54
|
+
/** A tag subject longer than this reads as a digest, not a release title. */
|
|
55
|
+
const MAX_SUBJECT_LENGTH = 72;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Section headers belong in the changelog entry, never in the tag body: a
|
|
59
|
+
* markdown heading, a Keep a Changelog section name, or any other line that is
|
|
60
|
+
* not a bullet and ends in a colon (`Dependency bumps:`, `Highlights:`).
|
|
61
|
+
*/
|
|
62
|
+
const SECTION_HEADER =
|
|
63
|
+
/^(?:#{1,6}\s.*|(?:Added|Changed|Deprecated|Removed|Fixed|Security|Dependencies)\s*:?|[^\s\-*+[].*:)$/;
|
|
64
|
+
|
|
65
|
+
/** The parts of an annotated tag that become the GitHub Release title and body. */
|
|
66
|
+
export interface TagMessage {
|
|
67
|
+
body: string;
|
|
68
|
+
/** `git for-each-ref %(objecttype)` — `tag` for an annotated tag, `commit` for a lightweight one. */
|
|
69
|
+
objectType: string;
|
|
70
|
+
subject: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates a tag annotation against the release-body rules. Returns one
|
|
75
|
+
* message per violation; an empty array means the tag is publishable.
|
|
76
|
+
*/
|
|
77
|
+
export function checkTagMessage(tag: TagMessage, version: string): string[] {
|
|
78
|
+
if (tag.objectType !== 'tag') {
|
|
79
|
+
return [
|
|
80
|
+
`v${version} is a lightweight tag — recreate it annotated: git tag -a v${version} -F <file>`,
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const errors: string[] = [];
|
|
85
|
+
const { subject } = tag;
|
|
86
|
+
if (subject.length > MAX_SUBJECT_LENGTH) {
|
|
87
|
+
errors.push(
|
|
88
|
+
`subject is ${subject.length} characters — keep it one short theme (≤${MAX_SUBJECT_LENGTH}); the bullets carry the digest`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (subject.includes(version)) {
|
|
92
|
+
errors.push(
|
|
93
|
+
`subject contains the version "${version}" — GitHub prepends "v${version}:" to the title`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (subject.includes(';')) {
|
|
97
|
+
errors.push('subject contains ";" — one theme, not a list of changes');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (tag.body.includes('-----BEGIN')) {
|
|
101
|
+
errors.push(
|
|
102
|
+
'body contains a signature block — the signature did not parse (usually --cleanup=verbatim); recreate the tag with --cleanup=whitespace before it is pushed',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lines = tag.body
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map((line) => line.trim())
|
|
109
|
+
.filter((line) => line.length > 0);
|
|
110
|
+
const headers = lines.filter((line) => SECTION_HEADER.test(line));
|
|
111
|
+
if (headers.length > 0) {
|
|
112
|
+
errors.push(
|
|
113
|
+
`body has section headers (${headers.map((h) => `"${h}"`).join(', ')}) — flat bullets only; sections belong in the changelog entry`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
118
|
+
const changelogLink = new RegExp(
|
|
119
|
+
`^\\[CHANGELOG v${escaped}\\]\\(\\S+/changelog/\\d+\\.\\d+\\.x/${escaped}\\.md\\)`,
|
|
120
|
+
);
|
|
121
|
+
if (!changelogLink.test(lines.at(-1) ?? '')) {
|
|
122
|
+
errors.push(
|
|
123
|
+
`final line is not the changelog link — end the body with "[CHANGELOG v${version}](https://github.com/<OWNER>/<REPO>/blob/main/changelog/<major.minor>.x/${version}.md)"`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
38
129
|
|
|
39
130
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
40
131
|
|
|
@@ -107,15 +198,34 @@ function main(): void {
|
|
|
107
198
|
if (!subject) {
|
|
108
199
|
console.error(
|
|
109
200
|
`Tag ${tag} not found locally or has no subject line. ` +
|
|
110
|
-
`Create the annotated tag first: git tag -a ${tag} -
|
|
201
|
+
`Create the annotated tag first: git tag -a ${tag} -F <file>`,
|
|
111
202
|
);
|
|
112
203
|
process.exit(1);
|
|
113
204
|
}
|
|
114
205
|
|
|
206
|
+
// 3. Validate the annotation — it becomes the public release body verbatim
|
|
207
|
+
const errors = checkTagMessage(
|
|
208
|
+
{
|
|
209
|
+
subject,
|
|
210
|
+
body: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(contents:body)']),
|
|
211
|
+
objectType: run('git', ['for-each-ref', `refs/tags/${tag}`, '--format=%(objecttype)']),
|
|
212
|
+
},
|
|
213
|
+
version,
|
|
214
|
+
);
|
|
215
|
+
if (errors.length > 0) {
|
|
216
|
+
console.error(`Tag ${tag} is not publishable:`);
|
|
217
|
+
for (const error of errors) console.error(` ✗ ${error}`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
if (CHECK_ONLY) {
|
|
221
|
+
console.log(`Tag ${tag} OK.`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
115
225
|
const title = `${tag}: ${subject}`;
|
|
116
226
|
const hasMcpb = existsSync('manifest.json');
|
|
117
227
|
|
|
118
|
-
//
|
|
228
|
+
// 4. Build the gh release create command
|
|
119
229
|
const createArgs = [
|
|
120
230
|
'release',
|
|
121
231
|
'create',
|
|
@@ -152,7 +262,7 @@ function main(): void {
|
|
|
152
262
|
console.log(' asset: dist/*.mcpb');
|
|
153
263
|
}
|
|
154
264
|
|
|
155
|
-
//
|
|
265
|
+
// 5. Try to create the release
|
|
156
266
|
const createResult = gh(createArgs, { required: false });
|
|
157
267
|
|
|
158
268
|
if (!createResult.startsWith('__ERROR__:')) {
|
|
@@ -170,7 +280,7 @@ function main(): void {
|
|
|
170
280
|
process.exit(1);
|
|
171
281
|
}
|
|
172
282
|
|
|
173
|
-
//
|
|
283
|
+
// 6. Release already exists — repair: upload asset (if applicable) and set title.
|
|
174
284
|
console.log(`Release ${tag} already exists. Repairing…`);
|
|
175
285
|
|
|
176
286
|
if (hasMcpb) {
|
|
@@ -184,4 +294,6 @@ function main(): void {
|
|
|
184
294
|
console.log(`Release ${tag} repaired.`);
|
|
185
295
|
}
|
|
186
296
|
|
|
187
|
-
|
|
297
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
298
|
+
main();
|
|
299
|
+
}
|