Skip to content

Commit 9406408

Browse files
authored
Merge of #574
2 parents 60c88e7 + c965c7f commit 9406408

8 files changed

Lines changed: 664 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hevy-mcp": patch
3+
---
4+
5+
Expose MCP registry metadata through the package `mcpName` field and a synchronized `server.json` manifest.

.github/workflows/build-and-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
- name: Run type check
5858
run: npm run check:types
5959

60+
- name: Check server manifest
61+
run: npm run check:server-manifest
62+
6063
- name: Verify commit messages
6164
if: github.event_name == 'pull_request' && matrix.node-version == '24.x'
6265
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
id: changesets
6060
uses: changesets/action@v1
6161
with:
62+
version: npm run version:changesets
6263
publish: npm run release
6364
commit: "chore: version packages"
6465
title: "chore: version packages"

package.json

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424
},
2525
"files": [
2626
"dist",
27-
"README.md"
27+
"README.md",
28+
"server.json"
2829
],
2930
"type": "module",
3031
"main": "dist/index.mjs",
@@ -40,13 +41,17 @@
4041
"openapi": "node ./scripts/openapi-spec.js",
4142
"build": "tsdown",
4243
"build:client": "kubb generate",
43-
"prepack": "npm run build",
44+
"prepack": "npm run check:server-manifest && npm run build",
4445
"start": "node --env-file .env dist/cli.mjs",
4546
"dev": "tsx watch --env-file .env --clear-screen=false src/cli.ts",
4647
"check": "oxlint . --type-aware --type-check && oxfmt . --check",
4748
"check:fix": "oxlint . --type-aware --type-check && oxfmt . --write",
4849
"check:types": "tsc --noEmit",
4950
"check:changeset": "changeset status --since=origin/main",
51+
"check:server-manifest": "node scripts/server-manifest.mjs check",
52+
"sync:server-manifest": "node scripts/server-manifest.mjs sync",
53+
"version:changesets": "changeset version && npm run sync:server-manifest",
54+
"version": "npm run sync:server-manifest && git add server.json",
5055
"version:patch": "npm version patch",
5156
"version:minor": "npm version minor",
5257
"version:major": "npm version major",
@@ -97,5 +102,6 @@
97102
},
98103
"engines": {
99104
"node": ">=20.0.0"
100-
}
105+
},
106+
"mcpName": "io.github.chrisdoc/hevy-mcp"
101107
}

scripts/server-manifest.d.mts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface RunServerManifestOptions {
2+
mode: "check" | "sync";
3+
rootDir?: string;
4+
}
5+
6+
export interface RunServerManifestResult {
7+
changed: boolean;
8+
drift: string[];
9+
}
10+
11+
export function runServerManifest(
12+
options: RunServerManifestOptions,
13+
): Promise<RunServerManifestResult>;

scripts/server-manifest.mjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { readFile, writeFile } from "node:fs/promises";
2+
import { resolve } from "node:path";
3+
import { pathToFileURL } from "node:url";
4+
5+
const manifestSchema =
6+
"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
7+
const repositoryUrl = "https://github.com/chrisdoc/hevy-mcp";
8+
9+
function assert(condition, message) {
10+
if (!condition) {
11+
throw new Error(message);
12+
}
13+
}
14+
15+
async function readJson(path, label) {
16+
let contents;
17+
try {
18+
contents = await readFile(path, "utf8");
19+
} catch (error) {
20+
throw new Error(`Unable to read ${label}: ${error.message}`);
21+
}
22+
23+
try {
24+
return { contents, value: JSON.parse(contents) };
25+
} catch (error) {
26+
throw new Error(`${label} is not valid JSON: ${error.message}`);
27+
}
28+
}
29+
30+
function validatePackageJson(packageJson) {
31+
assert(
32+
packageJson?.name === "hevy-mcp",
33+
"package.json name must be hevy-mcp",
34+
);
35+
assert(
36+
packageJson.mcpName === "io.github.chrisdoc/hevy-mcp",
37+
"package.json mcpName must be io.github.chrisdoc/hevy-mcp",
38+
);
39+
assert(
40+
typeof packageJson.version === "string" && packageJson.version.length > 0,
41+
"package.json version must be a non-empty string",
42+
);
43+
assert(
44+
Array.isArray(packageJson.files) &&
45+
packageJson.files.includes("server.json"),
46+
"package.json files must include server.json",
47+
);
48+
}
49+
50+
function validateManifestShape(manifest) {
51+
assert(
52+
manifest && typeof manifest === "object" && !Array.isArray(manifest),
53+
"server.json must contain an object",
54+
);
55+
assert(
56+
manifest.$schema === manifestSchema,
57+
"server.json has an unexpected $schema",
58+
);
59+
assert(
60+
manifest.title === "Hevy MCP Server",
61+
"server.json has an unexpected title",
62+
);
63+
assert(
64+
manifest.description ===
65+
"MCP server for managing workouts, routines, and exercise data through the Hevy API",
66+
"server.json has an unexpected description",
67+
);
68+
assert(
69+
manifest.repository?.url === repositoryUrl &&
70+
manifest.repository?.source === "github",
71+
"server.json has unexpected repository metadata",
72+
);
73+
assert(
74+
Array.isArray(manifest.packages) && manifest.packages.length === 1,
75+
"server.json must contain exactly one package",
76+
);
77+
78+
const [packageEntry] = manifest.packages;
79+
assert(
80+
packageEntry.registryType === "npm",
81+
"server.json package registryType must be npm",
82+
);
83+
assert(
84+
packageEntry.transport?.type === "stdio",
85+
"server.json package transport must be stdio",
86+
);
87+
assert(
88+
Array.isArray(packageEntry.environmentVariables) &&
89+
packageEntry.environmentVariables.length === 1,
90+
"server.json package must declare exactly one environment variable",
91+
);
92+
93+
const [apiKey] = packageEntry.environmentVariables;
94+
assert(
95+
apiKey.name === "HEVY_API_KEY" &&
96+
apiKey.description === "API key for authenticating with the Hevy API" &&
97+
apiKey.isRequired === true &&
98+
apiKey.isSecret === true,
99+
"server.json has unexpected HEVY_API_KEY metadata",
100+
);
101+
}
102+
103+
function findDrift(packageJson, manifest) {
104+
const packageEntry = manifest.packages[0];
105+
const drift = [];
106+
107+
if (manifest.name !== packageJson.mcpName) {
108+
drift.push("name");
109+
}
110+
if (manifest.version !== packageJson.version) {
111+
drift.push("version");
112+
}
113+
if (packageEntry.identifier !== packageJson.name) {
114+
drift.push("packages[0].identifier");
115+
}
116+
if (packageEntry.version !== packageJson.version) {
117+
drift.push("packages[0].version");
118+
}
119+
120+
return drift;
121+
}
122+
123+
export async function runServerManifest({ mode, rootDir = process.cwd() }) {
124+
assert(
125+
mode === "check" || mode === "sync",
126+
`Invalid mode ${JSON.stringify(mode)}; expected "check" or "sync"`,
127+
);
128+
129+
const packagePath = resolve(rootDir, "package.json");
130+
const manifestPath = resolve(rootDir, "server.json");
131+
const [{ value: packageJson }, { value: manifest }] = await Promise.all([
132+
readJson(packagePath, "package.json"),
133+
readJson(manifestPath, "server.json"),
134+
]);
135+
136+
validatePackageJson(packageJson);
137+
validateManifestShape(manifest);
138+
139+
const drift = findDrift(packageJson, manifest);
140+
if (mode === "check") {
141+
assert(
142+
drift.length === 0,
143+
`server.json is out of sync with package.json: ${drift.join(", ")}. Run npm run sync:server-manifest.`,
144+
);
145+
return { changed: false, drift };
146+
}
147+
148+
if (drift.length === 0) {
149+
return { changed: false, drift };
150+
}
151+
152+
manifest.name = packageJson.mcpName;
153+
manifest.version = packageJson.version;
154+
manifest.packages[0].identifier = packageJson.name;
155+
manifest.packages[0].version = packageJson.version;
156+
157+
const updatedContents = `${JSON.stringify(manifest, null, "\t")}\n`;
158+
await writeFile(manifestPath, updatedContents, "utf8");
159+
160+
return { changed: true, drift };
161+
}
162+
163+
const isCli =
164+
process.argv[1] !== undefined &&
165+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
166+
167+
if (isCli) {
168+
try {
169+
const result = await runServerManifest({ mode: process.argv[2] });
170+
console.log(
171+
result.changed
172+
? "Synchronized server.json with package.json."
173+
: "server.json is synchronized with package.json.",
174+
);
175+
} catch (error) {
176+
console.error(`server-manifest: ${error.message}`);
177+
process.exitCode = 1;
178+
}
179+
}

server.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3+
"name": "io.github.chrisdoc/hevy-mcp",
4+
"title": "Hevy MCP Server",
5+
"description": "MCP server for managing workouts, routines, and exercise data through the Hevy API",
6+
"repository": {
7+
"url": "https://github.com/chrisdoc/hevy-mcp",
8+
"source": "github"
9+
},
10+
"version": "1.28.0",
11+
"packages": [
12+
{
13+
"registryType": "npm",
14+
"identifier": "hevy-mcp",
15+
"version": "1.28.0",
16+
"transport": {
17+
"type": "stdio"
18+
},
19+
"environmentVariables": [
20+
{
21+
"name": "HEVY_API_KEY",
22+
"description": "API key for authenticating with the Hevy API",
23+
"isRequired": true,
24+
"isSecret": true
25+
}
26+
]
27+
}
28+
]
29+
}

0 commit comments

Comments
 (0)