toolfactory 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/bindings/python.js +1 -1
- package/dist/model.d.ts +1 -0
- package/dist/model.js +9 -1
- package/dist/project/gate.js +34 -20
- package/dist/project/history.d.ts +1 -0
- package/dist/surfaces/agents.js +6 -0
- package/dist/surfaces/codex.js +1 -1
- package/dist/surfaces/codex.test.js +10 -0
- package/dist/surfaces/distribution.test.js +118 -4
- package/dist/surfaces/dsh.js +4 -1
- package/dist/surfaces/dsh.test.js +3 -3
- package/dist/surfaces/hermes-native.js +5 -3
- package/dist/surfaces/hermes-native.test.js +21 -0
- package/dist/surfaces/mcp-registry.js +16 -2
- package/dist/surfaces/mcp-registry.test.js +5 -0
- package/dist/surfaces/mcpb.d.ts +1 -0
- package/dist/surfaces/mcpb.js +36 -23
- package/dist/surfaces/mcpb.test.js +31 -3
- package/dist/surfaces/npm.js +45 -14
- package/dist/surfaces/pypi.js +27 -3
- package/dist/surfaces/readme.js +10 -7
- package/dist/surfaces/registry.js +3 -3
- package/dist/surfaces/workflows.js +13 -6
- package/dist/surfaces/workflows.test.js +70 -3
- package/dist/toolfactory/cli.js +1 -1
- package/dist/toolfactory/mcp.js +1 -1
- package/package.json +1 -1
- package/src/bindings/python.ts +1 -1
- package/src/model.ts +11 -1
- package/src/project/gate.ts +35 -20
- package/src/surfaces/agents.ts +7 -0
- package/src/surfaces/codex.test.ts +10 -0
- package/src/surfaces/codex.ts +4 -3
- package/src/surfaces/distribution.test.ts +139 -4
- package/src/surfaces/dsh.test.ts +3 -3
- package/src/surfaces/dsh.ts +4 -1
- package/src/surfaces/hermes-native.test.ts +22 -0
- package/src/surfaces/hermes-native.ts +5 -2
- package/src/surfaces/mcp-registry.test.ts +8 -0
- package/src/surfaces/mcp-registry.ts +20 -2
- package/src/surfaces/mcpb.test.ts +32 -5
- package/src/surfaces/mcpb.ts +44 -23
- package/src/surfaces/npm.ts +47 -15
- package/src/surfaces/pypi.ts +27 -3
- package/src/surfaces/readme.ts +11 -6
- package/src/surfaces/registry.ts +3 -3
- package/src/surfaces/workflows.test.ts +87 -3
- package/src/surfaces/workflows.ts +13 -6
- package/src/toolfactory/cli.ts +1 -1
- package/src/toolfactory/mcp.ts +1 -1
- package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
- package/web/dist/assets/{index-D1PwZ3qS.js → index-Spapkkvh.js} +1 -1
- package/web/dist/index.html +2 -2
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
1
5
|
import { describe, expect, it } from "vitest";
|
|
2
6
|
import type { Operation, Project, SurfaceId } from "../model.js";
|
|
3
7
|
import { buildPlan } from "../project/plan.js";
|
|
4
8
|
import { surface as claude } from "./claude.js";
|
|
5
9
|
import { surface as npm } from "./npm.js";
|
|
6
|
-
import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
|
|
10
|
+
import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
|
|
7
11
|
import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
|
|
8
12
|
|
|
9
13
|
const echo: Operation = { name: "echo", inputSchema: { type: "object" }, requires: [] };
|
|
@@ -52,6 +56,134 @@ describe("npm", () => {
|
|
|
52
56
|
"--yes publint --strict",
|
|
53
57
|
);
|
|
54
58
|
});
|
|
59
|
+
|
|
60
|
+
it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
|
|
61
|
+
const python = project(["npm", "pypi", "cli", "mcp"], {
|
|
62
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
|
|
63
|
+
});
|
|
64
|
+
const files = npm.plan(python);
|
|
65
|
+
const launcher = files.find((file) => file.path === "bin/hello.mjs");
|
|
66
|
+
const packageJson = files.find((file) => file.path === "package.json");
|
|
67
|
+
if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
|
|
68
|
+
throw new Error("expected a launcher and package metadata");
|
|
69
|
+
}
|
|
70
|
+
expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
|
|
71
|
+
expect(launcher.content).toContain("...process.argv.slice(2)");
|
|
72
|
+
expect(packageJson.patch).toMatchObject({
|
|
73
|
+
name: "hello",
|
|
74
|
+
version: "0.1.0",
|
|
75
|
+
bin: { hello: "./bin/hello.mjs" },
|
|
76
|
+
files: ["bin", "README.md", "LICENSE"],
|
|
77
|
+
});
|
|
78
|
+
expect(region(python)).toContain(
|
|
79
|
+
"requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("ships the built web page in Python's prebuilt wheel before release publication", () => {
|
|
84
|
+
const python = project(["pypi", "web"], {
|
|
85
|
+
tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
|
|
86
|
+
});
|
|
87
|
+
const [file] = pypi.plan(python);
|
|
88
|
+
if (file?.kind !== "merge") throw new Error("expected Python package metadata");
|
|
89
|
+
expect(file.patch).toMatchObject({
|
|
90
|
+
tool: {
|
|
91
|
+
hatch: {
|
|
92
|
+
build: {
|
|
93
|
+
artifacts: ["web/dist/**"],
|
|
94
|
+
targets: {
|
|
95
|
+
wheel: {
|
|
96
|
+
"only-include": ["src/hello", "web/dist"],
|
|
97
|
+
sources: { src: "", "web/dist": "hello/web" },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(JSON.stringify(file.patch)).not.toContain("force-include");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
|
|
109
|
+
|
|
110
|
+
describe.skipIf(!uv)("Python release source", () => {
|
|
111
|
+
it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
|
|
112
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
|
|
113
|
+
mkdirSync(join(root, "src", "hello"), { recursive: true });
|
|
114
|
+
mkdirSync(join(root, "web", "dist"), { recursive: true });
|
|
115
|
+
writeFileSync(
|
|
116
|
+
join(root, "pyproject.toml"),
|
|
117
|
+
`[project]
|
|
118
|
+
name = "hello"
|
|
119
|
+
version = "0.1.0"
|
|
120
|
+
readme = "README.md"
|
|
121
|
+
|
|
122
|
+
[build-system]
|
|
123
|
+
requires = ["hatchling"]
|
|
124
|
+
build-backend = "hatchling.build"
|
|
125
|
+
|
|
126
|
+
[tool.hatch.build]
|
|
127
|
+
artifacts = ["web/dist/**"]
|
|
128
|
+
|
|
129
|
+
[tool.hatch.build.targets.wheel]
|
|
130
|
+
packages = ["src/hello"]
|
|
131
|
+
only-include = ["src/hello", "web/dist"]
|
|
132
|
+
|
|
133
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
134
|
+
src = ""
|
|
135
|
+
"web/dist" = "hello/web"
|
|
136
|
+
|
|
137
|
+
[tool.hatch.build.targets.sdist]
|
|
138
|
+
only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
|
|
139
|
+
`,
|
|
140
|
+
);
|
|
141
|
+
writeFileSync(join(root, "README.md"), "# hello\n");
|
|
142
|
+
writeFileSync(join(root, "LICENSE"), "MIT\n");
|
|
143
|
+
writeFileSync(join(root, ".gitignore"), "dist/\n");
|
|
144
|
+
writeFileSync(join(root, "src", "hello", "__init__.py"), "");
|
|
145
|
+
mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
|
|
146
|
+
writeFileSync(
|
|
147
|
+
join(root, "web", "dist", "index.html"),
|
|
148
|
+
'<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n',
|
|
149
|
+
);
|
|
150
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
|
|
151
|
+
writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
|
|
152
|
+
const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
|
|
153
|
+
cwd: root,
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
});
|
|
156
|
+
expect(build.status, build.stderr).toBe(0);
|
|
157
|
+
const wheel = spawnSync(
|
|
158
|
+
"unzip",
|
|
159
|
+
["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")],
|
|
160
|
+
{ encoding: "utf8" },
|
|
161
|
+
);
|
|
162
|
+
expect(wheel.status, wheel.stderr).toBe(0);
|
|
163
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.css");
|
|
164
|
+
expect(wheel.stdout).toContain("hello/web/assets/app.js");
|
|
165
|
+
const stage = join(root, "dist", "mcpb");
|
|
166
|
+
mkdirSync(stage, { recursive: true });
|
|
167
|
+
const extract = spawnSync(
|
|
168
|
+
"tar",
|
|
169
|
+
[
|
|
170
|
+
"-xzf",
|
|
171
|
+
join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
|
|
172
|
+
"-C",
|
|
173
|
+
stage,
|
|
174
|
+
"--strip-components=1",
|
|
175
|
+
],
|
|
176
|
+
{ encoding: "utf8" },
|
|
177
|
+
);
|
|
178
|
+
expect(extract.status, extract.stderr).toBe(0);
|
|
179
|
+
const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
|
|
180
|
+
expect(sync.status, sync.stderr).toBe(0);
|
|
181
|
+
expect(existsSync(join(stage, "README.md"))).toBe(true);
|
|
182
|
+
expect(existsSync(join(stage, "LICENSE"))).toBe(true);
|
|
183
|
+
expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
|
|
184
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
|
|
185
|
+
expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
|
|
186
|
+
});
|
|
55
187
|
});
|
|
56
188
|
|
|
57
189
|
describe("claude", () => {
|
|
@@ -103,14 +235,17 @@ describe("readme", () => {
|
|
|
103
235
|
expect(body).toContain(
|
|
104
236
|
"download `hello.mcpb` from the GitHub Release and double-click to install",
|
|
105
237
|
);
|
|
106
|
-
// The extension's three channels — unpacked from a checkout,
|
|
107
|
-
// signed
|
|
238
|
+
// The extension's three channels — unpacked from a checkout, release assets (including an
|
|
239
|
+
// optional signed XPI), and store listings — and the pairing step that ends each.
|
|
108
240
|
expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
|
|
109
241
|
expect(body).toContain("web-ext run");
|
|
110
242
|
expect(body).toContain(
|
|
111
243
|
"`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`",
|
|
112
244
|
);
|
|
113
|
-
expect(body).toContain(
|
|
245
|
+
expect(body).toContain(
|
|
246
|
+
"When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`",
|
|
247
|
+
);
|
|
248
|
+
expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
|
|
114
249
|
expect(body).toContain("`npx -y hello mcp --http --pair`");
|
|
115
250
|
});
|
|
116
251
|
|
package/src/surfaces/dsh.test.ts
CHANGED
|
@@ -68,8 +68,8 @@ describe("dsh", () => {
|
|
|
68
68
|
command: "npx",
|
|
69
69
|
args: ["-y", "hello.tool@0.1.0", "mcp"],
|
|
70
70
|
env: {
|
|
71
|
-
APIKEY:
|
|
72
|
-
REGION:
|
|
71
|
+
APIKEY: 'process.env.APIKEY ?? ""',
|
|
72
|
+
REGION: 'process.env.REGION ?? ""',
|
|
73
73
|
},
|
|
74
74
|
failOnStartupError: true,
|
|
75
75
|
},
|
|
@@ -79,7 +79,7 @@ describe("dsh", () => {
|
|
|
79
79
|
]);
|
|
80
80
|
// DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
|
|
81
81
|
// restated as the `!!js` expression DSH evaluates when it loads the patch.
|
|
82
|
-
expect(patch).toContain(
|
|
82
|
+
expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
|
package/src/surfaces/dsh.ts
CHANGED
|
@@ -71,7 +71,10 @@ function patch(project: Project, launch: { command: string; args: string[] }): s
|
|
|
71
71
|
const env = Object.fromEntries(
|
|
72
72
|
Object.keys(configProperties(project)).map((key) => [
|
|
73
73
|
envName(key),
|
|
74
|
-
|
|
74
|
+
// Schemastery's dict rejects the empty object produced when every `!!js`
|
|
75
|
+
// value resolves to undefined. Keep every declared key string-valued;
|
|
76
|
+
// an absent optional credential remains the empty string for the child.
|
|
77
|
+
jsExpression(`process.env.${envName(key)} ?? ""`),
|
|
75
78
|
]),
|
|
76
79
|
);
|
|
77
80
|
const row = {
|
|
@@ -21,6 +21,12 @@ const echo: Operation = {
|
|
|
21
21
|
requires: [],
|
|
22
22
|
};
|
|
23
23
|
const shoot: Operation = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
|
|
24
|
+
const web: Operation = {
|
|
25
|
+
name: "web",
|
|
26
|
+
description: "Open the generated operations page.",
|
|
27
|
+
inputSchema: { type: "object" },
|
|
28
|
+
requires: [],
|
|
29
|
+
};
|
|
24
30
|
|
|
25
31
|
function project(overrides: Partial<Project> = {}): Project {
|
|
26
32
|
return {
|
|
@@ -128,6 +134,22 @@ describe("hermes-native", () => {
|
|
|
128
134
|
expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
|
|
129
135
|
});
|
|
130
136
|
|
|
137
|
+
it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
|
|
138
|
+
const target = project({
|
|
139
|
+
tool: {
|
|
140
|
+
...project().tool,
|
|
141
|
+
binding: "python",
|
|
142
|
+
surfaces: ["hermes-native", "cli", "mcp", "web"],
|
|
143
|
+
},
|
|
144
|
+
operations: [echo, web],
|
|
145
|
+
});
|
|
146
|
+
const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
|
|
147
|
+
expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
|
|
148
|
+
expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
|
|
149
|
+
expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
|
|
150
|
+
expect(native).toContain('handler=_handler(schema["name"])');
|
|
151
|
+
});
|
|
152
|
+
|
|
131
153
|
it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
|
|
132
154
|
const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
|
|
133
155
|
expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
configProperties,
|
|
37
37
|
dataDirEnvName,
|
|
38
38
|
envName,
|
|
39
|
+
has,
|
|
39
40
|
isSensitive,
|
|
40
41
|
pypiName,
|
|
41
42
|
requiredConfig,
|
|
@@ -333,14 +334,16 @@ interface PluginCore {
|
|
|
333
334
|
/** Python core: the handler calls the author's own operation in-process. */
|
|
334
335
|
function pythonCore(project: Project): PluginCore {
|
|
335
336
|
const pkg = projectName.pythonPackage(project.identity.name);
|
|
337
|
+
const web = has(project, "web");
|
|
336
338
|
return {
|
|
337
339
|
stdlib: ["import asyncio", "import inspect"],
|
|
338
340
|
external: [
|
|
339
341
|
"from pydantic import BaseModel",
|
|
340
|
-
`from ${pkg}.ops import OPERATIONS`,
|
|
342
|
+
`from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
|
|
341
343
|
`from ${pkg}.toolfactory.config import context`,
|
|
344
|
+
...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
|
|
342
345
|
],
|
|
343
|
-
body:
|
|
346
|
+
body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
|
|
344
347
|
"""Fail loudly if the core package and this plugin disagree on the operations."""
|
|
345
348
|
for operation in OPERATIONS:
|
|
346
349
|
if operation.name == name:
|
|
@@ -65,6 +65,14 @@ describe("mcp-registry Dockerfile", () => {
|
|
|
65
65
|
|
|
66
66
|
const py = files(project({ tool: { ...project().tool, binding: "python" } }));
|
|
67
67
|
expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
|
|
68
|
+
|
|
69
|
+
const pythonWeb = files(
|
|
70
|
+
project({
|
|
71
|
+
tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
|
|
75
|
+
expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
|
|
68
76
|
});
|
|
69
77
|
});
|
|
70
78
|
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
pypiName,
|
|
19
19
|
requiredConfig,
|
|
20
20
|
} from "./shared.js";
|
|
21
|
+
import { WEB_DIR } from "./web.js";
|
|
21
22
|
|
|
22
23
|
export const SERVER_SCHEMA_ID =
|
|
23
24
|
"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
|
|
@@ -94,8 +95,20 @@ const DOCKER_PACKAGE_MANAGERS: Record<
|
|
|
94
95
|
*/
|
|
95
96
|
export function dockerfileTemplate(project: Project, label: string): string {
|
|
96
97
|
if (project.tool.binding === "python") {
|
|
98
|
+
const web = has(project, "web");
|
|
97
99
|
return `# syntax=docker/dockerfile:1
|
|
98
|
-
|
|
100
|
+
${
|
|
101
|
+
web
|
|
102
|
+
? `FROM ${NODE_IMAGE} AS web
|
|
103
|
+
WORKDIR /app
|
|
104
|
+
COPY web/package.json web/package-lock.json ./web/
|
|
105
|
+
RUN npm -C web ci
|
|
106
|
+
COPY web ./web
|
|
107
|
+
RUN npm -C web run build
|
|
108
|
+
|
|
109
|
+
`
|
|
110
|
+
: ""
|
|
111
|
+
}FROM ${UV_IMAGE} AS build
|
|
99
112
|
WORKDIR /app
|
|
100
113
|
COPY pyproject.toml ./
|
|
101
114
|
COPY src ./src
|
|
@@ -107,7 +120,12 @@ WORKDIR /app
|
|
|
107
120
|
COPY --from=build /app/.venv ./.venv
|
|
108
121
|
COPY --from=build /app/src ./src
|
|
109
122
|
COPY --from=build /app/pyproject.toml ./
|
|
110
|
-
|
|
123
|
+
${
|
|
124
|
+
web
|
|
125
|
+
? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
|
|
126
|
+
`
|
|
127
|
+
: ""
|
|
128
|
+
}ENV PATH="/app/.venv/bin:$PATH"
|
|
111
129
|
ENTRYPOINT ${JSON.stringify(dockerEntrypoint(project))}
|
|
112
130
|
`;
|
|
113
131
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { NODE_ENGINES } from "../bindings/typescript.js";
|
|
3
3
|
import type { Operation, Project } from "../model.js";
|
|
4
|
-
import { MCPB_PIN, surface as mcpb } from "./mcpb.js";
|
|
4
|
+
import { MCPB_PIN, surface as mcpb, UV_MANIFEST_VERSION } from "./mcpb.js";
|
|
5
5
|
|
|
6
6
|
const echo: Operation = {
|
|
7
7
|
name: "echo",
|
|
@@ -95,11 +95,38 @@ describe("mcpb", () => {
|
|
|
95
95
|
expect(manifestFile.content).not.toContain("privacy_policies");
|
|
96
96
|
});
|
|
97
97
|
|
|
98
|
-
it("
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
it("uses the official v0.4 uv bundle contract for Python, with its canonical PyPI surface", () => {
|
|
99
|
+
const pythonProject: Project = {
|
|
100
|
+
...project,
|
|
101
|
+
tool: { ...project.tool, binding: "python", surfaces: ["mcp", "pypi", "mcpb"] },
|
|
102
|
+
};
|
|
103
|
+
const [file] = mcpb.plan(pythonProject);
|
|
104
|
+
if (file?.kind !== "file") throw new Error("expected one whole file");
|
|
105
|
+
expect(JSON.parse(file.content)).toMatchObject({
|
|
106
|
+
manifest_version: UV_MANIFEST_VERSION,
|
|
107
|
+
server: {
|
|
108
|
+
type: "uv",
|
|
109
|
+
entry_point: "src/hello_tool/toolfactory/mcp.py",
|
|
110
|
+
mcp_config: {
|
|
111
|
+
command: "uv",
|
|
112
|
+
args: [
|
|
113
|
+
"run",
|
|
114
|
+
"--directory",
|
|
115
|
+
"${__dirname}",
|
|
116
|
+
"python",
|
|
117
|
+
"-m",
|
|
118
|
+
"hello_tool.toolfactory.mcp",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
compatibility: { runtimes: { python: ">=3.11" } },
|
|
123
|
+
});
|
|
124
|
+
expect(() =>
|
|
125
|
+
mcpb.plan({ ...pythonProject, tool: { ...pythonProject.tool, surfaces: ["mcp", "mcpb"] } }),
|
|
126
|
+
).toThrow(/requires the "pypi" surface/);
|
|
127
|
+
});
|
|
102
128
|
|
|
129
|
+
it("declares the kernel's own Node floor and validates with the pinned official CLI", () => {
|
|
103
130
|
expect(mcpb.validate?.(project)).toEqual([
|
|
104
131
|
{
|
|
105
132
|
label: "mcpb validate",
|
package/src/surfaces/mcpb.ts
CHANGED
|
@@ -3,22 +3,30 @@
|
|
|
3
3
|
* Anthropic's Connectors Directory takes a *local* server through.
|
|
4
4
|
*
|
|
5
5
|
* A bundle is a zip whose root is the server plus its dependencies plus a `manifest.json`. That
|
|
6
|
-
* root is a staging tree, not the repository:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* packaging step copies it in; nothing here duplicates what `npm pack` already decides ships.
|
|
6
|
+
* root is a staging tree, not the repository: TypeScript stages the npm tarball with production
|
|
7
|
+
* dependencies, while Python stages its `pyproject.toml` and source for host-managed uv. This
|
|
8
|
+
* surface emits one tracked file — the manifest — and the packaging step copies it in.
|
|
10
9
|
*
|
|
11
|
-
* `manifest_version`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* TypeScript uses `manifest_version` `0.2`, the floor Anthropic's directory submission requires.
|
|
11
|
+
* Python uses v0.4's official `uv` server type, which keeps the bundle portable without shipping
|
|
12
|
+
* an interpreter or a virtual environment.
|
|
14
13
|
*
|
|
15
14
|
* `privacy_policies` (the URLs of the privacy policies of the services a bundle talks to, which
|
|
16
15
|
* Anthropic's directory submission requires) comes from `tool.json` `mcpb.privacyPolicies`; a
|
|
17
16
|
* homepage is not one, so nothing is invented when the key is absent.
|
|
18
17
|
*/
|
|
18
|
+
import { kernelDir as pythonKernelDir, pythonPackage } from "../bindings/python.js";
|
|
19
19
|
import { KERNEL_DIR, NODE_ENGINES } from "../bindings/typescript.js";
|
|
20
20
|
import type { Project, Surface } from "../model.js";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
compact,
|
|
23
|
+
configProperties,
|
|
24
|
+
envName,
|
|
25
|
+
has,
|
|
26
|
+
isSensitive,
|
|
27
|
+
json,
|
|
28
|
+
mcpVerdict,
|
|
29
|
+
} from "./shared.js";
|
|
22
30
|
|
|
23
31
|
export const HOST_DIR = "hosts/mcpb";
|
|
24
32
|
export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
|
|
@@ -31,6 +39,7 @@ export const MCPB_PIN = "2.1.2";
|
|
|
31
39
|
|
|
32
40
|
/** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
|
|
33
41
|
export const MANIFEST_VERSION = "0.2";
|
|
42
|
+
export const UV_MANIFEST_VERSION = "0.4";
|
|
34
43
|
|
|
35
44
|
/**
|
|
36
45
|
* The kernel's built `mcp` entry inside the bundle root — the same file the `mcp-registry`
|
|
@@ -55,12 +64,10 @@ const USER_CONFIG_TYPES = new Set(["string", "number", "boolean"]);
|
|
|
55
64
|
export const surface: Surface = {
|
|
56
65
|
id: "mcpb",
|
|
57
66
|
plan(project: Project) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// (Claude Desktop ships Node, not Python), which `npm pack` plus `npm install` gives the
|
|
61
|
-
// TypeScript binding for free and `uv` gives no equivalent of.
|
|
67
|
+
const python = project.tool.binding === "python";
|
|
68
|
+
if (python && !has(project, "pypi")) {
|
|
62
69
|
throw new Error(
|
|
63
|
-
'Surface "mcpb"
|
|
70
|
+
'Surface "mcpb" with the python binding requires the "pypi" surface, the canonical runtime distribution.',
|
|
64
71
|
);
|
|
65
72
|
}
|
|
66
73
|
const { identity } = project;
|
|
@@ -82,7 +89,7 @@ export const surface: Surface = {
|
|
|
82
89
|
Object.keys(properties).map((key) => [envName(key), `\${user_config.${key}}`]),
|
|
83
90
|
);
|
|
84
91
|
const manifest = compact({
|
|
85
|
-
manifest_version: MANIFEST_VERSION,
|
|
92
|
+
manifest_version: python ? UV_MANIFEST_VERSION : MANIFEST_VERSION,
|
|
86
93
|
name: identity.name,
|
|
87
94
|
display_name: identity.name,
|
|
88
95
|
version: identity.version,
|
|
@@ -95,15 +102,29 @@ export const surface: Surface = {
|
|
|
95
102
|
privacy_policies: project.tool.mcpb?.privacyPolicies,
|
|
96
103
|
keywords: identity.keywords,
|
|
97
104
|
server: {
|
|
98
|
-
type: "node",
|
|
99
|
-
entry_point: entryPoint(),
|
|
105
|
+
type: python ? "uv" : "node",
|
|
106
|
+
entry_point: python ? `${pythonKernelDir(project)}/mcp.py` : entryPoint(),
|
|
100
107
|
// `${__dirname}` is the installed bundle's own directory: an install is an unzip
|
|
101
|
-
// somewhere else, so nothing in the launch may be repository-relative.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
// somewhere else, so nothing in the launch may be repository-relative. For `uv`, this
|
|
109
|
+
// delegates dependency resolution and Python provisioning to the host's supported runtime.
|
|
110
|
+
mcp_config: python
|
|
111
|
+
? compact({
|
|
112
|
+
command: "uv",
|
|
113
|
+
args: [
|
|
114
|
+
"run",
|
|
115
|
+
"--directory",
|
|
116
|
+
"${__dirname}",
|
|
117
|
+
"python",
|
|
118
|
+
"-m",
|
|
119
|
+
`${pythonPackage(project)}.toolfactory.mcp`,
|
|
120
|
+
],
|
|
121
|
+
env: Object.keys(env).length ? env : undefined,
|
|
122
|
+
})
|
|
123
|
+
: compact({
|
|
124
|
+
command: "node",
|
|
125
|
+
args: [`\${__dirname}/${entryPoint()}`],
|
|
126
|
+
env: Object.keys(env).length ? env : undefined,
|
|
127
|
+
}),
|
|
107
128
|
},
|
|
108
129
|
// Exactly the operations the kernel's own `tools/list` serves (§4.3): the manifest is what
|
|
109
130
|
// the install UI shows before the server ever runs, so it must not promise more.
|
|
@@ -111,7 +132,7 @@ export const surface: Surface = {
|
|
|
111
132
|
.filter((operation) => mcpVerdict(operation).kind !== "excluded")
|
|
112
133
|
.map((operation) => compact({ name: operation.name, description: operation.description })),
|
|
113
134
|
user_config: userConfig.length ? Object.fromEntries(userConfig) : undefined,
|
|
114
|
-
compatibility: { runtimes: { node: NODE_ENGINES } },
|
|
135
|
+
compatibility: { runtimes: python ? { python: ">=3.11" } : { node: NODE_ENGINES } },
|
|
115
136
|
});
|
|
116
137
|
return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
|
|
117
138
|
},
|
package/src/surfaces/npm.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { githubSlug } from "../hosts/github.js";
|
|
7
|
-
import type { Surface } from "../model.js";
|
|
7
|
+
import type { Project, Surface } from "../model.js";
|
|
8
8
|
import { registryName } from "./mcp-registry.js";
|
|
9
|
-
import { compact, has, npmName } from "./shared.js";
|
|
9
|
+
import { compact, has, npmName, pypiName } from "./shared.js";
|
|
10
10
|
import { WEB_DIR } from "./web.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -22,14 +22,31 @@ function repositoryField(
|
|
|
22
22
|
return { type: "git", url: slug ? `git+https://github.com/${slug}.git` : repository };
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* A deliberately thin cross-registry bridge: npm supplies discovery and a familiar `npx` entry,
|
|
27
|
+
* while uvx resolves the canonical Python distribution. `uvx` owns Python acquisition, caching,
|
|
28
|
+
* and the package install; the npm package never downloads an interpreter itself.
|
|
29
|
+
*/
|
|
30
|
+
function pythonLauncher(project: Project): string {
|
|
31
|
+
const { identity } = project;
|
|
32
|
+
return `#!/usr/bin/env node
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
|
|
35
|
+
const child = spawnSync("uvx", ["--from", ${JSON.stringify(`${pypiName(project)}==${identity.version}`)}, ${JSON.stringify(identity.name)}, ...process.argv.slice(2)], {
|
|
36
|
+
stdio: "inherit",
|
|
37
|
+
});
|
|
38
|
+
if (child.error) throw child.error;
|
|
39
|
+
process.exit(child.status ?? 1);
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
export const surface: Surface = {
|
|
26
44
|
id: "npm",
|
|
27
45
|
plan(project) {
|
|
28
|
-
if (project.tool.binding !== "typescript") {
|
|
29
|
-
throw new Error('Surface "npm" requires the typescript binding.');
|
|
30
|
-
}
|
|
31
46
|
const { identity } = project;
|
|
32
47
|
const authored = project.tool.identity === "package.json";
|
|
48
|
+
const python = project.tool.binding === "python";
|
|
49
|
+
const launcher = `bin/${identity.name}.mjs`;
|
|
33
50
|
const patch = compact({
|
|
34
51
|
name: authored ? undefined : npmName(project),
|
|
35
52
|
version: authored ? undefined : identity.version,
|
|
@@ -41,21 +58,36 @@ export const surface: Surface = {
|
|
|
41
58
|
type: "module",
|
|
42
59
|
bin:
|
|
43
60
|
has(project, "cli") || has(project, "mcp")
|
|
44
|
-
? {
|
|
61
|
+
? {
|
|
62
|
+
[identity.name]: python ? `./${launcher}` : "./dist/toolfactory/cli.js",
|
|
63
|
+
}
|
|
45
64
|
: undefined,
|
|
46
65
|
// `web/dist` only with the `web` surface: the kernel serves the built page from inside
|
|
47
66
|
// the installed package, so `npx <tool> mcp --http --open` works without a checkout.
|
|
48
|
-
files:
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
files: python
|
|
68
|
+
? ["bin", "README.md", "LICENSE"]
|
|
69
|
+
: [
|
|
70
|
+
"dist",
|
|
71
|
+
"src",
|
|
72
|
+
"schemas",
|
|
73
|
+
...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
|
|
74
|
+
"README.md",
|
|
75
|
+
"LICENSE",
|
|
76
|
+
],
|
|
56
77
|
mcpName: has(project, "mcp-registry") ? registryName(project) : undefined,
|
|
57
78
|
});
|
|
58
|
-
return [
|
|
79
|
+
return [
|
|
80
|
+
...(python && (has(project, "cli") || has(project, "mcp"))
|
|
81
|
+
? [{ kind: "file" as const, path: launcher, content: pythonLauncher(project) }]
|
|
82
|
+
: []),
|
|
83
|
+
{
|
|
84
|
+
kind: "merge" as const,
|
|
85
|
+
path: "package.json",
|
|
86
|
+
format: "json" as const,
|
|
87
|
+
patch,
|
|
88
|
+
owned: ["bin", "files"],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
59
91
|
},
|
|
60
92
|
validate(project) {
|
|
61
93
|
return [
|
package/src/surfaces/pypi.ts
CHANGED
|
@@ -29,19 +29,43 @@ export const surface: Surface = {
|
|
|
29
29
|
? { [project.identity.name]: cliEntryPoint(project) }
|
|
30
30
|
: undefined,
|
|
31
31
|
}),
|
|
32
|
-
//
|
|
33
|
-
//
|
|
32
|
+
// Hatch ignores a missing `only-include` path, unlike `force-include`, which makes
|
|
33
|
+
// `uv sync` work in a fresh checkout before the optional page is built. At package time,
|
|
34
|
+
// the gate builds `web/dist` first and Hatch maps it into the installed package.
|
|
34
35
|
...(has(project, "web")
|
|
35
36
|
? {
|
|
36
37
|
tool: {
|
|
37
38
|
hatch: {
|
|
38
39
|
build: {
|
|
40
|
+
// Hatch's normal file selection honors .gitignore; `web/dist` is ignored in
|
|
41
|
+
// every generated project, so artifacts is the standard explicit opt-in for
|
|
42
|
+
// the release build's compiled page.
|
|
43
|
+
artifacts: [`${WEB_DIR}/dist/**`],
|
|
39
44
|
targets: {
|
|
40
45
|
wheel: {
|
|
41
|
-
"
|
|
46
|
+
"only-include": [`src/${pythonPackage(project)}`, `${WEB_DIR}/dist`],
|
|
47
|
+
sources: {
|
|
48
|
+
src: "",
|
|
42
49
|
[`${WEB_DIR}/dist`]: `${pythonPackage(project)}/${WEB_DIR}`,
|
|
43
50
|
},
|
|
44
51
|
},
|
|
52
|
+
// This is the source MCPB stages, so include every declared project metadata
|
|
53
|
+
// file alongside the package and built page. `only-include` ignores absent
|
|
54
|
+
// legal files without making fresh checkouts fail.
|
|
55
|
+
sdist: {
|
|
56
|
+
"only-include": [
|
|
57
|
+
"pyproject.toml",
|
|
58
|
+
"README.md",
|
|
59
|
+
"LICENSE",
|
|
60
|
+
"LICENSE.md",
|
|
61
|
+
"LICENSE.txt",
|
|
62
|
+
"NOTICE",
|
|
63
|
+
"NOTICE.md",
|
|
64
|
+
"NOTICE.txt",
|
|
65
|
+
`src/${pythonPackage(project)}`,
|
|
66
|
+
`${WEB_DIR}/dist`,
|
|
67
|
+
],
|
|
68
|
+
},
|
|
45
69
|
},
|
|
46
70
|
},
|
|
47
71
|
},
|