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
package/README.md CHANGED
@@ -22,7 +22,7 @@ generated and every surface still installs, builds and publishes.
22
22
  - **Gemini CLI extension** — `gemini extensions install https://github.com/GoatInAHat/toolfactory`
23
23
  - **OpenClaw plugin** — `openclaw plugins install --link hosts/openclaw` from a checkout
24
24
  - **Hermes plugin** — `hermes plugins install https://github.com/GoatInAHat/toolfactory#hosts/hermes/toolfactory_hermes`
25
- - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.1.1.tgz`
25
+ - **DSH plugin** (experimental) — `dsh plugin --profile <profile> add ./hosts/dsh` from a checkout, or the release tarball `toolfactory-dsh-0.1.2.tgz`
26
26
  - **Web app** — `npx -y toolfactory mcp --http --open` serves the operations page beside the
27
27
  MCP endpoint on one port and opens it; over MCP or a skill, the `web` operation does the same and
28
28
  returns the URL.
@@ -55,8 +55,7 @@ None of it is required: with no GitHub, plain git or no git at all, everything b
55
55
  Use `--runtime none --surfaces skill,codex` when a plugin is only SKILL.md instructions for
56
56
  Codex. ToolFactory then emits no MCP server, operation snapshot, language scaffold, or consumer
57
57
  runtime dependency. `binding` remains required for config compatibility but is unused in this
58
- mode. Until this change is released, CI must invoke the checked-out ToolFactory source rather
59
- than resolve the published npm package.
58
+ mode. This is available in the published npm package starting with ToolFactory 0.1.1.
60
59
 
61
60
  1. Write operations in `src/ops.ts` (TypeScript) or `src/<pkg>/ops.py` (Python). Each one is a
62
61
  name, a description, an input schema, an optional output schema, an optional `requires`
@@ -148,6 +147,12 @@ Inside OpenClaw, install the tool you are building with
148
147
  `hermes plugins install file://<repo>#hosts/hermes/<pkg>` — every `hermes` run is a fresh process,
149
148
  and `hermes gateway restart` is only for the messaging gateway.
150
149
 
150
+ GSD uses the same skills, CLI and MCP server; no GSD-specific surface is needed. From a generated
151
+ project, run `bash .agents/setup --all` so `.mcp.json` is rendered even on a GSD-only machine,
152
+ then discover its server in an interactive GSD session before unattended use. GSD launched by
153
+ OpenClaw or Hermes loads the child project's configuration separately from the outer agent.
154
+ See [GSD compatibility](docs/gsd.md) for setup, runtime boundaries and the repeatable smoke test.
155
+
151
156
  ## The boundary
152
157
 
153
158
  Core logic is a pure function of JSON arguments, environment/config and the filesystem. An
@@ -210,7 +210,7 @@ def env_route(token: "str | None") -> Route:
210
210
  return `
211
211
 
212
212
  def web_dist() -> "Path | None":
213
- """The built page: the wheel force-includes \`${WEB_DIR}/dist\` here, a checkout has it in place."""
213
+ """The wheel maps \`${WEB_DIR}/dist\` here at package time; a checkout has it in place."""
214
214
  package = Path(__file__).resolve().parent.parent
215
215
  for candidate in (package / "${WEB_DIR}", package.parent.parent / "${WEB_DIR}" / "dist"):
216
216
  if candidate.is_dir():
package/dist/model.d.ts CHANGED
@@ -82,6 +82,7 @@ export declare const ToolConfigSchema: z.ZodObject<{
82
82
  }, z.core.$strip>>;
83
83
  codex: z.ZodOptional<z.ZodObject<{
84
84
  interface: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
85
+ mcp: z.ZodDefault<z.ZodBoolean>;
85
86
  }, z.core.$strip>>;
86
87
  browserExtension: z.ZodOptional<z.ZodObject<{
87
88
  endpoint: z.ZodOptional<z.ZodString>;
package/dist/model.js CHANGED
@@ -100,7 +100,15 @@ export const ToolConfigSchema = z
100
100
  .default({ examples: {} })
101
101
  .describe("Example arguments per operation, used by the generated surface-smoke tests."),
102
102
  npm: z.object({ scope: z.string().optional() }).optional(),
103
- codex: z.object({ interface: jsonObject.optional() }).optional(),
103
+ codex: z
104
+ .object({
105
+ interface: jsonObject.optional(),
106
+ mcp: z
107
+ .boolean()
108
+ .default(true)
109
+ .describe("Whether the Codex plugin manifest eagerly configures the generated MCP server."),
110
+ })
111
+ .optional(),
104
112
  browserExtension: z
105
113
  .object({
106
114
  endpoint: z
@@ -88,11 +88,18 @@ export function gateSteps(project) {
88
88
  const pm = commands(project);
89
89
  const cli = toolfactoryCli(project);
90
90
  const openclaw = has(project, "openclaw-native");
91
- const steps = [
92
- ...bootstrapSteps(project),
93
- { name: "toolfactory check", run: `${cli} check` },
94
- outputsStep(project),
95
- ];
91
+ const steps = [...bootstrapSteps(project)];
92
+ if (!typescript && has(project, "hermes-native")) {
93
+ // Hermes' launcher clears PYTHONPATH and runs its own venv. Its plugin dependency
94
+ // declaration is advisory, so Doctor can import a native Python core only after the
95
+ // project is installed into that interpreter. Run after bootstrap's `uv sync` on CI.
96
+ steps.push({
97
+ name: "Install core into Hermes",
98
+ run: 'uv pip install --python "$HOME/.hermes/hermes-agent/venv/bin/python" --editable .',
99
+ when: "ci",
100
+ });
101
+ }
102
+ steps.push({ name: "toolfactory check", run: `${cli} check` }, outputsStep(project));
96
103
  if (has(project, "claude")) {
97
104
  steps.push({
98
105
  name: "Install Claude Code CLI",
@@ -202,27 +209,34 @@ export function packageSteps(project) {
202
209
  if (has(project, "npm")) {
203
210
  steps.push({ name: "npm tarball", run: `npm pack --pack-destination ${RELEASE_DIR}` });
204
211
  }
212
+ if (has(project, "pypi")) {
213
+ // Keep the wheel and sdist apart from the release's npm tarballs, plugin bundles and web
214
+ // archive: PyPA's publisher accepts only Python distribution files. The MCPB stage below
215
+ // unpacks this same canonical sdist rather than reproducing its source-file selection.
216
+ steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}/pypi` });
217
+ }
205
218
  if (has(project, "mcpb")) {
206
- // A bundle root is the published package with its production dependencies installed into
207
- // it, so it is built from the tarball `npm pack` just wrote rather than from the checkout:
208
- // whatever ships to npm is exactly what ships to Claude Desktop. `--ignore-scripts` because
209
- // nothing in a bundle root may run a build; the tarball already carries `dist/`.
210
219
  const stage = "dist/mcpb";
211
- const tarball = npmTarball(project);
212
220
  steps.push({
213
221
  name: "MCPB bundle",
214
- run: [
215
- `rm -rf ${stage} && mkdir -p ${stage}`,
216
- `tar -xzf ${RELEASE_DIR}/${tarball} -C ${stage} --strip-components=1`,
217
- `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
218
- `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
219
- `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
220
- ].join(" && "),
222
+ run: project.tool.binding === "python"
223
+ ? [
224
+ `rm -rf ${stage} && mkdir -p ${stage}`,
225
+ `tar -xzf ${RELEASE_DIR}/pypi/*.tar.gz -C ${stage} --strip-components=1`,
226
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
227
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
228
+ ].join(" && ")
229
+ : [
230
+ // The Node bundle root is the published package with production dependencies
231
+ // installed, so whatever ships to npm is exactly what ships to Claude Desktop.
232
+ `rm -rf ${stage} && mkdir -p ${stage}`,
233
+ `tar -xzf ${RELEASE_DIR}/${npmTarball(project)} -C ${stage} --strip-components=1`,
234
+ `npm --prefix ${stage} install --omit=dev --ignore-scripts`,
235
+ `cp ${MCPB_MANIFEST_PATH} ${stage}/manifest.json`,
236
+ `npx -y @anthropic-ai/mcpb@${MCPB_PIN} pack ${stage} ${RELEASE_DIR}/${name}.mcpb`,
237
+ ].join(" && "),
221
238
  });
222
239
  }
223
- if (has(project, "pypi")) {
224
- steps.push({ name: "python distributions", run: `uv build --out-dir ${RELEASE_DIR}` });
225
- }
226
240
  if (has(project, "openclaw-native")) {
227
241
  // ClawHub's reusable workflow has no build step of its own, so the tarball it publishes has
228
242
  // to arrive already built (`package_artifact_name`).
@@ -28,6 +28,7 @@ export declare function toolAtRef(root: string, ref: string): {
28
28
  scope?: string | undefined;
29
29
  } | undefined;
30
30
  codex?: {
31
+ mcp: boolean;
31
32
  interface?: Record<string, unknown> | undefined;
32
33
  } | undefined;
33
34
  browserExtension?: {
@@ -58,6 +58,12 @@ export const RELOAD = [
58
58
  instructions: "`hermes skills trust` once in this repo, then a new conversation: the resolved skill directories are stable for a conversation.",
59
59
  line: "Run `hermes skills trust` once in this repo, then start a new Hermes conversation — every invocation re-scans plugins, skills and AGENTS.md. `/reload-mcp` only affects an already-open session; `hermes gateway restart` is only for the messaging gateway.",
60
60
  },
61
+ {
62
+ harness: "GSD",
63
+ env: [],
64
+ mcp: "`python3 .agents/sync.py --all` renders the shared `.mcp.json` even on a GSD-only machine. Refresh with `mcp_servers(refresh=true)`; start a new session after changing a connected server. Approve `mcp_discover` interactively once before unattended stdio use.",
65
+ instructions: "Reads `AGENTS.md` and `.agents/skills/` natively; `/reload` refreshes resources. `--bare` omits them.",
66
+ },
61
67
  {
62
68
  harness: "Gemini CLI",
63
69
  env: [],
@@ -39,7 +39,7 @@ export const surface = {
39
39
  license: identity.license,
40
40
  keywords: identity.keywords,
41
41
  skills: has(project, "skill") ? "./skills/" : undefined,
42
- mcpServers: has(project, "mcp")
42
+ mcpServers: has(project, "mcp") && project.tool.codex?.mcp !== false
43
43
  ? { [identity.name]: kernelLaunch(project, "${PLUGIN_ROOT}") }
44
44
  : undefined,
45
45
  interface: codexInterface,
@@ -64,4 +64,14 @@ describe("codex", () => {
64
64
  expect(JSON.parse(manifest.content)).toMatchObject({ skills: "./skills/" });
65
65
  expect(JSON.parse(manifest.content)).not.toHaveProperty("mcpServers");
66
66
  });
67
+ it("can omit the eager MCP server while retaining the plugin skill", () => {
68
+ const target = project(["skill", "codex", "mcp"]);
69
+ target.tool.codex = { mcp: false };
70
+ const manifest = codex.plan(target).find((entry) => entry.path === ".codex-plugin/plugin.json");
71
+ if (manifest?.kind !== "file")
72
+ throw new Error("expected a manifest");
73
+ const parsed = JSON.parse(manifest.content);
74
+ expect(parsed.skills).toBe("./skills/");
75
+ expect(parsed).not.toHaveProperty("mcpServers");
76
+ });
67
77
  });
@@ -1,8 +1,12 @@
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 { buildPlan } from "../project/plan.js";
3
7
  import { surface as claude } from "./claude.js";
4
8
  import { surface as npm } from "./npm.js";
5
- import { MCP_NAME_BEGIN, MCP_NAME_END } from "./pypi.js";
9
+ import { MCP_NAME_BEGIN, MCP_NAME_END, surface as pypi } from "./pypi.js";
6
10
  import { INSTALL_BEGIN, INSTALL_END, surface as readme } from "./readme.js";
7
11
  const echo = { name: "echo", inputSchema: { type: "object" }, requires: [] };
8
12
  function project(surfaces, overrides = {}) {
@@ -47,6 +51,115 @@ describe("npm", () => {
47
51
  });
48
52
  expect(npm.validate?.(project(["npm"])).map((command) => command.args.join(" "))).toContain("--yes publint --strict");
49
53
  });
54
+ it("makes a Python npm package a thin uvx launcher for its canonical PyPI distribution", () => {
55
+ const python = project(["npm", "pypi", "cli", "mcp"], {
56
+ tool: { ...project([]).tool, binding: "python", surfaces: ["npm", "pypi", "cli", "mcp"] },
57
+ });
58
+ const files = npm.plan(python);
59
+ const launcher = files.find((file) => file.path === "bin/hello.mjs");
60
+ const packageJson = files.find((file) => file.path === "package.json");
61
+ if (launcher?.kind !== "file" || packageJson?.kind !== "merge") {
62
+ throw new Error("expected a launcher and package metadata");
63
+ }
64
+ expect(launcher.content).toContain('spawnSync("uvx", ["--from", "hello==0.1.0", "hello"');
65
+ expect(launcher.content).toContain("...process.argv.slice(2)");
66
+ expect(packageJson.patch).toMatchObject({
67
+ name: "hello",
68
+ version: "0.1.0",
69
+ bin: { hello: "./bin/hello.mjs" },
70
+ files: ["bin", "README.md", "LICENSE"],
71
+ });
72
+ expect(region(python)).toContain("requires `uv`, and `hello` delegates to `uvx --from hello==0.1.0 hello`");
73
+ });
74
+ it("ships the built web page in Python's prebuilt wheel before release publication", () => {
75
+ const python = project(["pypi", "web"], {
76
+ tool: { ...project([]).tool, binding: "python", surfaces: ["pypi", "web"] },
77
+ });
78
+ const [file] = pypi.plan(python);
79
+ if (file?.kind !== "merge")
80
+ throw new Error("expected Python package metadata");
81
+ expect(file.patch).toMatchObject({
82
+ tool: {
83
+ hatch: {
84
+ build: {
85
+ artifacts: ["web/dist/**"],
86
+ targets: {
87
+ wheel: {
88
+ "only-include": ["src/hello", "web/dist"],
89
+ sources: { src: "", "web/dist": "hello/web" },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ });
96
+ expect(JSON.stringify(file.patch)).not.toContain("force-include");
97
+ });
98
+ });
99
+ const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
100
+ describe.skipIf(!uv)("Python release source", () => {
101
+ it("stages the built page and metadata from the canonical sdist for a uv bundle", () => {
102
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-python-sdist-"));
103
+ mkdirSync(join(root, "src", "hello"), { recursive: true });
104
+ mkdirSync(join(root, "web", "dist"), { recursive: true });
105
+ writeFileSync(join(root, "pyproject.toml"), `[project]
106
+ name = "hello"
107
+ version = "0.1.0"
108
+ readme = "README.md"
109
+
110
+ [build-system]
111
+ requires = ["hatchling"]
112
+ build-backend = "hatchling.build"
113
+
114
+ [tool.hatch.build]
115
+ artifacts = ["web/dist/**"]
116
+
117
+ [tool.hatch.build.targets.wheel]
118
+ packages = ["src/hello"]
119
+ only-include = ["src/hello", "web/dist"]
120
+
121
+ [tool.hatch.build.targets.wheel.sources]
122
+ src = ""
123
+ "web/dist" = "hello/web"
124
+
125
+ [tool.hatch.build.targets.sdist]
126
+ only-include = ["pyproject.toml", "README.md", "LICENSE", "src/hello", "web/dist"]
127
+ `);
128
+ writeFileSync(join(root, "README.md"), "# hello\n");
129
+ writeFileSync(join(root, "LICENSE"), "MIT\n");
130
+ writeFileSync(join(root, ".gitignore"), "dist/\n");
131
+ writeFileSync(join(root, "src", "hello", "__init__.py"), "");
132
+ mkdirSync(join(root, "web", "dist", "assets"), { recursive: true });
133
+ writeFileSync(join(root, "web", "dist", "index.html"), '<link rel="stylesheet" href="assets/app.css"><script src="assets/app.js"></script>\n');
134
+ writeFileSync(join(root, "web", "dist", "assets", "app.css"), "main{color:#000}\n");
135
+ writeFileSync(join(root, "web", "dist", "assets", "app.js"), "console.log('hello')\n");
136
+ const build = spawnSync("uv", ["build", "--out-dir", "dist/release/pypi"], {
137
+ cwd: root,
138
+ encoding: "utf8",
139
+ });
140
+ expect(build.status, build.stderr).toBe(0);
141
+ const wheel = spawnSync("unzip", ["-l", join(root, "dist", "release", "pypi", "hello-0.1.0-py2.py3-none-any.whl")], { encoding: "utf8" });
142
+ expect(wheel.status, wheel.stderr).toBe(0);
143
+ expect(wheel.stdout).toContain("hello/web/assets/app.css");
144
+ expect(wheel.stdout).toContain("hello/web/assets/app.js");
145
+ const stage = join(root, "dist", "mcpb");
146
+ mkdirSync(stage, { recursive: true });
147
+ const extract = spawnSync("tar", [
148
+ "-xzf",
149
+ join(root, "dist", "release", "pypi", "hello-0.1.0.tar.gz"),
150
+ "-C",
151
+ stage,
152
+ "--strip-components=1",
153
+ ], { encoding: "utf8" });
154
+ expect(extract.status, extract.stderr).toBe(0);
155
+ const sync = spawnSync("uv", ["sync", "--quiet", "--no-dev"], { cwd: stage, encoding: "utf8" });
156
+ expect(sync.status, sync.stderr).toBe(0);
157
+ expect(existsSync(join(stage, "README.md"))).toBe(true);
158
+ expect(existsSync(join(stage, "LICENSE"))).toBe(true);
159
+ expect(existsSync(join(stage, "web", "dist", "index.html"))).toBe(true);
160
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.css"))).toBe(true);
161
+ expect(existsSync(join(stage, "web", "dist", "assets", "app.js"))).toBe(true);
162
+ });
50
163
  });
51
164
  describe("claude", () => {
52
165
  it("makes the repository its own single-plugin marketplace", () => {
@@ -91,12 +204,13 @@ describe("readme", () => {
91
204
  expect(body).toContain("`codex plugin add hello@hello`");
92
205
  expect(body).toContain("`gemini extensions install https://github.com/GoatInAHat/Hello-Tool`");
93
206
  expect(body).toContain("download `hello.mcpb` from the GitHub Release and double-click to install");
94
- // The extension's three channels — unpacked from a checkout, the release assets (only the
95
- // signed xpi installs itself), the store listings — and the pairing step that ends each.
207
+ // The extension's three channels — unpacked from a checkout, release assets (including an
208
+ // optional signed XPI), and store listings — and the pairing step that ends each.
96
209
  expect(body).toContain("Load unpacked → `hosts/browser/.output/chrome-mv3`");
97
210
  expect(body).toContain("web-ext run");
98
211
  expect(body).toContain("`hello-0.1.0-chrome.zip`, `hello-0.1.0-firefox.zip`, `hello-0.1.0-edge.zip`");
99
- expect(body).toContain("Mozilla-signed `.xpi`");
212
+ expect(body).toContain("When Firefox signing credentials are configured, it also attaches a\n Mozilla-signed `.xpi`");
213
+ expect(body).not.toContain("Chrome no longer keeps side-loaded unpacked");
100
214
  expect(body).toContain("`npx -y hello mcp --http --pair`");
101
215
  });
102
216
  it("names the one command that opens the web app, and only when that surface is selected", () => {
@@ -64,7 +64,10 @@ function patch(project, launch) {
64
64
  const serverName = projectName.dshServer(project.identity.name);
65
65
  const env = Object.fromEntries(Object.keys(configProperties(project)).map((key) => [
66
66
  envName(key),
67
- jsExpression(`process.env.${envName(key)}`),
67
+ // Schemastery's dict rejects the empty object produced when every `!!js`
68
+ // value resolves to undefined. Keep every declared key string-valued;
69
+ // an absent optional credential remains the empty string for the child.
70
+ jsExpression(`process.env.${envName(key)} ?? ""`),
68
71
  ]));
69
72
  const row = {
70
73
  id: rowId(project),
@@ -61,8 +61,8 @@ describe("dsh", () => {
61
61
  command: "npx",
62
62
  args: ["-y", "hello.tool@0.1.0", "mcp"],
63
63
  env: {
64
- APIKEY: "process.env.APIKEY",
65
- REGION: "process.env.REGION",
64
+ APIKEY: 'process.env.APIKEY ?? ""',
65
+ REGION: 'process.env.REGION ?? ""',
66
66
  },
67
67
  failOnStartupError: true,
68
68
  },
@@ -72,7 +72,7 @@ describe("dsh", () => {
72
72
  ]);
73
73
  // DSH scrubs KEY/PASSWORD/SECRET/TOKEN names before spawning, so every config variable is
74
74
  // restated as the `!!js` expression DSH evaluates when it loads the patch.
75
- expect(patch).toContain("APIKEY: !!js process.env.APIKEY");
75
+ expect(patch).toContain('APIKEY: !!js process.env.APIKEY ?? ""');
76
76
  });
77
77
  it("validates by booting the checkout keylessly, and carries only MCP tool calls", () => {
78
78
  // The local row is the same shape against this working tree, and stays out of `files`.
@@ -31,7 +31,7 @@ import { KERNEL_DIR } from "../bindings/typescript.js";
31
31
  import { projectName } from "../identity/name.js";
32
32
  import { isPortable } from "../model.js";
33
33
  import { includedOperations } from "../report/coverage.js";
34
- import { compact, configProperties, dataDirEnvName, envName, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
34
+ import { compact, configProperties, dataDirEnvName, envName, has, isSensitive, pypiName, requiredConfig, rootEnvName, } from "./shared.js";
35
35
  export const HOST_DIR = "hosts/hermes";
36
36
  /**
37
37
  * `hermes_cli/plugins.py` `SUPPORTED_MANIFEST_VERSION` is 2 and the loader parses every field
@@ -287,14 +287,16 @@ function importBlock(core) {
287
287
  /** Python core: the handler calls the author's own operation in-process. */
288
288
  function pythonCore(project) {
289
289
  const pkg = projectName.pythonPackage(project.identity.name);
290
+ const web = has(project, "web");
290
291
  return {
291
292
  stdlib: ["import asyncio", "import inspect"],
292
293
  external: [
293
294
  "from pydantic import BaseModel",
294
- `from ${pkg}.ops import OPERATIONS`,
295
+ `from ${pkg}.ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}`,
295
296
  `from ${pkg}.toolfactory.config import context`,
297
+ ...(web ? [`from ${pkg}.toolfactory.web import OPERATION as WEB_OPERATION`] : []),
296
298
  ],
297
- body: `def _operation(name: str) -> Any:
299
+ body: `${web ? "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]\n\n\n" : ""}def _operation(name: str) -> Any:
298
300
  """Fail loudly if the core package and this plugin disagree on the operations."""
299
301
  for operation in OPERATIONS:
300
302
  if operation.name == name:
@@ -19,6 +19,12 @@ const echo = {
19
19
  requires: [],
20
20
  };
21
21
  const shoot = { name: "shoot", inputSchema: { type: "object" }, requires: ["browser"] };
22
+ const web = {
23
+ name: "web",
24
+ description: "Open the generated operations page.",
25
+ inputSchema: { type: "object" },
26
+ requires: [],
27
+ };
22
28
  function project(overrides = {}) {
23
29
  return {
24
30
  root: "/repo",
@@ -104,6 +110,21 @@ describe("hermes-native", () => {
104
110
  expect(pyproject.project.dependencies).toEqual(["hello>=1.2.0,<2"]);
105
111
  expect(pyproject.tool.uv.sources.hello).toEqual({ path: "../..", editable: true });
106
112
  });
113
+ it("gives a Python Hermes plugin the generated web operation used by its tool schema", () => {
114
+ const target = project({
115
+ tool: {
116
+ ...project().tool,
117
+ binding: "python",
118
+ surfaces: ["hermes-native", "cli", "mcp", "web"],
119
+ },
120
+ operations: [echo, web],
121
+ });
122
+ const native = emitted(target)[`${pluginDir(target)}/__init__.py`] ?? "";
123
+ expect(native).toContain("from hello.ops import OPERATIONS as AUTHORED_OPERATIONS");
124
+ expect(native).toContain("from hello.toolfactory.web import OPERATION as WEB_OPERATION");
125
+ expect(native).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
126
+ expect(native).toContain('handler=_handler(schema["name"])');
127
+ });
107
128
  it("emits a fake-ctx test that registers and calls a real handler, no Hermes import", () => {
108
129
  const shim = emitted(project())[`${HOST_DIR}/tests/test_plugin.py`] ?? "";
109
130
  expect(shim).not.toMatch(/^\s*(import|from) hermes/m);
@@ -7,6 +7,7 @@ import { pythonPackage } from "../bindings/python.js";
7
7
  import { KERNEL_DIR } from "../bindings/typescript.js";
8
8
  import { githubOwner, projectName } from "../identity/name.js";
9
9
  import { compact, configProperties, envName, has, isSensitive, json, npmName, pypiName, requiredConfig, } from "./shared.js";
10
+ import { WEB_DIR } from "./web.js";
10
11
  export const SERVER_SCHEMA_ID = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
11
12
  /** `owner` from the identity file's GitHub repository, or the error every registry name needs it for. */
12
13
  function requireGithubOwner(identity) {
@@ -69,8 +70,18 @@ const DOCKER_PACKAGE_MANAGERS = {
69
70
  */
70
71
  export function dockerfileTemplate(project, label) {
71
72
  if (project.tool.binding === "python") {
73
+ const web = has(project, "web");
72
74
  return `# syntax=docker/dockerfile:1
73
- FROM ${UV_IMAGE} AS build
75
+ ${web
76
+ ? `FROM ${NODE_IMAGE} AS web
77
+ WORKDIR /app
78
+ COPY web/package.json web/package-lock.json ./web/
79
+ RUN npm -C web ci
80
+ COPY web ./web
81
+ RUN npm -C web run build
82
+
83
+ `
84
+ : ""}FROM ${UV_IMAGE} AS build
74
85
  WORKDIR /app
75
86
  COPY pyproject.toml ./
76
87
  COPY src ./src
@@ -82,7 +93,10 @@ WORKDIR /app
82
93
  COPY --from=build /app/.venv ./.venv
83
94
  COPY --from=build /app/src ./src
84
95
  COPY --from=build /app/pyproject.toml ./
85
- ENV PATH="/app/.venv/bin:$PATH"
96
+ ${web
97
+ ? `COPY --from=web /app/web/dist ./src/${pythonPackage(project)}/${WEB_DIR}
98
+ `
99
+ : ""}ENV PATH="/app/.venv/bin:$PATH"
86
100
  ENTRYPOINT ${JSON.stringify(dockerEntrypoint(project))}
87
101
  `;
88
102
  }
@@ -52,6 +52,11 @@ describe("mcp-registry Dockerfile", () => {
52
52
  expect(npmBuilt.Dockerfile).not.toContain("corepack enable");
53
53
  const py = files(project({ tool: { ...project().tool, binding: "python" } }));
54
54
  expect(py.Dockerfile).toContain('ENTRYPOINT ["python","-m","hello.toolfactory.mcp"]');
55
+ const pythonWeb = files(project({
56
+ tool: { ...project().tool, binding: "python", surfaces: ["mcp-registry", "web"] },
57
+ }));
58
+ expect(pythonWeb.Dockerfile).toContain("FROM node:24-alpine AS web");
59
+ expect(pythonWeb.Dockerfile).toContain("COPY --from=web /app/web/dist ./src/hello/web");
55
60
  });
56
61
  });
57
62
  describe("mcp-registry server.json oci package", () => {
@@ -8,4 +8,5 @@ export declare const MANIFEST_PATH = "hosts/mcpb/manifest.json";
8
8
  export declare const MCPB_PIN = "2.1.2";
9
9
  /** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
10
10
  export declare const MANIFEST_VERSION = "0.2";
11
+ export declare const UV_MANIFEST_VERSION = "0.4";
11
12
  export declare const surface: Surface;
@@ -3,21 +3,21 @@
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
- import { compact, configProperties, envName, isSensitive, json, mcpVerdict } from "./shared.js";
20
+ import { compact, configProperties, envName, has, isSensitive, json, mcpVerdict, } from "./shared.js";
21
21
  export const HOST_DIR = "hosts/mcpb";
22
22
  export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
23
23
  /**
@@ -27,6 +27,7 @@ export const MANIFEST_PATH = `${HOST_DIR}/manifest.json`;
27
27
  export const MCPB_PIN = "2.1.2";
28
28
  /** What `mcpb init` writes for a new bundle, and the floor the directory submission requires. */
29
29
  export const MANIFEST_VERSION = "0.2";
30
+ export const UV_MANIFEST_VERSION = "0.4";
30
31
  /**
31
32
  * The kernel's built `mcp` entry inside the bundle root — the same file the `mcp-registry`
32
33
  * surface's Dockerfile runs, because both start from the published package's `dist/`.
@@ -47,11 +48,9 @@ const USER_CONFIG_TYPES = new Set(["string", "number", "boolean"]);
47
48
  export const surface = {
48
49
  id: "mcpb",
49
50
  plan(project) {
50
- if (project.tool.binding !== "typescript") {
51
- // A python bundle would have to vendor an interpreter's site-packages into the bundle root
52
- // (Claude Desktop ships Node, not Python), which `npm pack` plus `npm install` gives the
53
- // TypeScript binding for free and `uv` gives no equivalent of.
54
- throw new Error('Surface "mcpb" requires the typescript binding: a bundle root is the npm tarball with its production dependencies installed into it.');
51
+ const python = project.tool.binding === "python";
52
+ if (python && !has(project, "pypi")) {
53
+ throw new Error('Surface "mcpb" with the python binding requires the "pypi" surface, the canonical runtime distribution.');
55
54
  }
56
55
  const { identity } = project;
57
56
  const properties = configProperties(project);
@@ -70,7 +69,7 @@ export const surface = {
70
69
  // kernel reads its configuration from the same environment names it does everywhere else.
71
70
  const env = Object.fromEntries(Object.keys(properties).map((key) => [envName(key), `\${user_config.${key}}`]));
72
71
  const manifest = compact({
73
- manifest_version: MANIFEST_VERSION,
72
+ manifest_version: python ? UV_MANIFEST_VERSION : MANIFEST_VERSION,
74
73
  name: identity.name,
75
74
  display_name: identity.name,
76
75
  version: identity.version,
@@ -83,15 +82,29 @@ export const surface = {
83
82
  privacy_policies: project.tool.mcpb?.privacyPolicies,
84
83
  keywords: identity.keywords,
85
84
  server: {
86
- type: "node",
87
- entry_point: entryPoint(),
85
+ type: python ? "uv" : "node",
86
+ entry_point: python ? `${pythonKernelDir(project)}/mcp.py` : entryPoint(),
88
87
  // `${__dirname}` is the installed bundle's own directory: an install is an unzip
89
- // somewhere else, so nothing in the launch may be repository-relative.
90
- mcp_config: compact({
91
- command: "node",
92
- args: [`\${__dirname}/${entryPoint()}`],
93
- env: Object.keys(env).length ? env : undefined,
94
- }),
88
+ // somewhere else, so nothing in the launch may be repository-relative. For `uv`, this
89
+ // delegates dependency resolution and Python provisioning to the host's supported runtime.
90
+ mcp_config: python
91
+ ? compact({
92
+ command: "uv",
93
+ args: [
94
+ "run",
95
+ "--directory",
96
+ "${__dirname}",
97
+ "python",
98
+ "-m",
99
+ `${pythonPackage(project)}.toolfactory.mcp`,
100
+ ],
101
+ env: Object.keys(env).length ? env : undefined,
102
+ })
103
+ : compact({
104
+ command: "node",
105
+ args: [`\${__dirname}/${entryPoint()}`],
106
+ env: Object.keys(env).length ? env : undefined,
107
+ }),
95
108
  },
96
109
  // Exactly the operations the kernel's own `tools/list` serves (§4.3): the manifest is what
97
110
  // the install UI shows before the server ever runs, so it must not promise more.
@@ -99,7 +112,7 @@ export const surface = {
99
112
  .filter((operation) => mcpVerdict(operation).kind !== "excluded")
100
113
  .map((operation) => compact({ name: operation.name, description: operation.description })),
101
114
  user_config: userConfig.length ? Object.fromEntries(userConfig) : undefined,
102
- compatibility: { runtimes: { node: NODE_ENGINES } },
115
+ compatibility: { runtimes: python ? { python: ">=3.11" } : { node: NODE_ENGINES } },
103
116
  });
104
117
  return [{ kind: "file", path: MANIFEST_PATH, content: json(manifest) }];
105
118
  },