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
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.
|
|
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.
|
|
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
|
package/dist/bindings/python.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
package/dist/project/gate.js
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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`).
|
package/dist/surfaces/agents.js
CHANGED
|
@@ -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: [],
|
package/dist/surfaces/codex.js
CHANGED
|
@@ -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,
|
|
95
|
-
// signed
|
|
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", () => {
|
package/dist/surfaces/dsh.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
65
|
-
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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", () => {
|
package/dist/surfaces/mcpb.d.ts
CHANGED
|
@@ -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;
|
package/dist/surfaces/mcpb.js
CHANGED
|
@@ -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:
|
|
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
|
-
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
},
|