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.
Files changed (54) hide show
  1. package/README.md +8 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/model.d.ts +1 -0
  4. package/dist/model.js +9 -1
  5. package/dist/project/gate.js +34 -20
  6. package/dist/project/history.d.ts +1 -0
  7. package/dist/surfaces/agents.js +6 -0
  8. package/dist/surfaces/codex.js +1 -1
  9. package/dist/surfaces/codex.test.js +10 -0
  10. package/dist/surfaces/distribution.test.js +118 -4
  11. package/dist/surfaces/dsh.js +4 -1
  12. package/dist/surfaces/dsh.test.js +3 -3
  13. package/dist/surfaces/hermes-native.js +5 -3
  14. package/dist/surfaces/hermes-native.test.js +21 -0
  15. package/dist/surfaces/mcp-registry.js +16 -2
  16. package/dist/surfaces/mcp-registry.test.js +5 -0
  17. package/dist/surfaces/mcpb.d.ts +1 -0
  18. package/dist/surfaces/mcpb.js +36 -23
  19. package/dist/surfaces/mcpb.test.js +31 -3
  20. package/dist/surfaces/npm.js +45 -14
  21. package/dist/surfaces/pypi.js +27 -3
  22. package/dist/surfaces/readme.js +10 -7
  23. package/dist/surfaces/registry.js +3 -3
  24. package/dist/surfaces/workflows.js +13 -6
  25. package/dist/surfaces/workflows.test.js +70 -3
  26. package/dist/toolfactory/cli.js +1 -1
  27. package/dist/toolfactory/mcp.js +1 -1
  28. package/package.json +1 -1
  29. package/src/bindings/python.ts +1 -1
  30. package/src/model.ts +11 -1
  31. package/src/project/gate.ts +35 -20
  32. package/src/surfaces/agents.ts +7 -0
  33. package/src/surfaces/codex.test.ts +10 -0
  34. package/src/surfaces/codex.ts +4 -3
  35. package/src/surfaces/distribution.test.ts +139 -4
  36. package/src/surfaces/dsh.test.ts +3 -3
  37. package/src/surfaces/dsh.ts +4 -1
  38. package/src/surfaces/hermes-native.test.ts +22 -0
  39. package/src/surfaces/hermes-native.ts +5 -2
  40. package/src/surfaces/mcp-registry.test.ts +8 -0
  41. package/src/surfaces/mcp-registry.ts +20 -2
  42. package/src/surfaces/mcpb.test.ts +32 -5
  43. package/src/surfaces/mcpb.ts +44 -23
  44. package/src/surfaces/npm.ts +47 -15
  45. package/src/surfaces/pypi.ts +27 -3
  46. package/src/surfaces/readme.ts +11 -6
  47. package/src/surfaces/registry.ts +3 -3
  48. package/src/surfaces/workflows.test.ts +87 -3
  49. package/src/surfaces/workflows.ts +13 -6
  50. package/src/toolfactory/cli.ts +1 -1
  51. package/src/toolfactory/mcp.ts +1 -1
  52. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  53. package/web/dist/assets/{index-D1PwZ3qS.js → index-Spapkkvh.js} +1 -1
  54. 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, the release assets (only the
107
- // signed xpi installs itself), the store listings — and the pairing step that ends each.
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("Mozilla-signed `.xpi`");
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
 
@@ -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: "process.env.APIKEY",
72
- REGION: "process.env.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("APIKEY: !!js process.env.APIKEY");
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", () => {
@@ -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
- jsExpression(`process.env.${envName(key)}`),
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: `def _operation(name: str) -> Any:
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
- FROM ${UV_IMAGE} AS build
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
- ENV PATH="/app/.venv/bin:$PATH"
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("declares the kernel's own Node floor, and refuses a binding whose package is not the bundle", () => {
99
- expect(() => mcpb.plan({ ...project, tool: { ...project.tool, binding: "python" } })).toThrow(
100
- /requires the typescript binding/,
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",
@@ -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: the payload is exactly the npm tarball with
7
- * production dependencies installed into it, which `src/project/gate.ts` builds under
8
- * `dist/mcpb/` at package time. So this surface emits one tracked file — the manifest — and the
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` is `0.2`, which is what `mcpb init` itself writes for a new bundle (0.3 and
12
- * 0.4 exist, and add icon variants, localization and the `uv` server type — none of which this
13
- * projection uses) and the floor Anthropic's directory submission requires.
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 { compact, configProperties, envName, isSensitive, json, mcpVerdict } from "./shared.js";
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
- if (project.tool.binding !== "typescript") {
59
- // A python bundle would have to vendor an interpreter's site-packages into the bundle root
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" requires the typescript binding: a bundle root is the npm tarball with its production dependencies installed into it.',
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
- mcp_config: compact({
103
- command: "node",
104
- args: [`\${__dirname}/${entryPoint()}`],
105
- env: Object.keys(env).length ? env : undefined,
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
  },
@@ -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
- ? { [identity.name]: "./dist/toolfactory/cli.js" }
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
- "dist",
50
- "src",
51
- "schemas",
52
- ...(has(project, "web") ? [`${WEB_DIR}/dist`] : []),
53
- "README.md",
54
- "LICENSE",
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 [{ kind: "merge", path: "package.json", format: "json", patch, owned: ["bin"] }];
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 [
@@ -29,19 +29,43 @@ export const surface: Surface = {
29
29
  ? { [project.identity.name]: cliEntryPoint(project) }
30
30
  : undefined,
31
31
  }),
32
- // The kernel serves the built page from inside its own package, so the wheel has to
33
- // carry it: `packages` only copies the package tree, `force-include` maps anything else.
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
- "force-include": {
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
  },