toolfactory 0.1.1

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 (228) hide show
  1. package/README.md +166 -0
  2. package/dist/bindings/index.d.ts +23 -0
  3. package/dist/bindings/index.js +29 -0
  4. package/dist/bindings/python.d.ts +33 -0
  5. package/dist/bindings/python.js +805 -0
  6. package/dist/bindings/python.test.d.ts +1 -0
  7. package/dist/bindings/python.test.js +316 -0
  8. package/dist/bindings/typescript.d.ts +39 -0
  9. package/dist/bindings/typescript.js +758 -0
  10. package/dist/bindings/typescript.test.d.ts +1 -0
  11. package/dist/bindings/typescript.test.js +264 -0
  12. package/dist/commands.d.ts +188 -0
  13. package/dist/commands.js +662 -0
  14. package/dist/commands.test.d.ts +1 -0
  15. package/dist/commands.test.js +215 -0
  16. package/dist/e2e.test.d.ts +1 -0
  17. package/dist/e2e.test.js +124 -0
  18. package/dist/hosts/browser.d.ts +10 -0
  19. package/dist/hosts/browser.js +94 -0
  20. package/dist/hosts/github.d.ts +66 -0
  21. package/dist/hosts/github.js +247 -0
  22. package/dist/hosts/openclaw.d.ts +4 -0
  23. package/dist/hosts/openclaw.js +94 -0
  24. package/dist/hosts/template.d.ts +15 -0
  25. package/dist/hosts/template.js +142 -0
  26. package/dist/hosts/web.d.ts +4 -0
  27. package/dist/hosts/web.js +89 -0
  28. package/dist/identity/name.d.ts +20 -0
  29. package/dist/identity/name.js +33 -0
  30. package/dist/identity/name.test.d.ts +1 -0
  31. package/dist/identity/name.test.js +37 -0
  32. package/dist/identity/read.d.ts +9 -0
  33. package/dist/identity/read.js +106 -0
  34. package/dist/identity/read.test.d.ts +1 -0
  35. package/dist/identity/read.test.js +76 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.js +6 -0
  38. package/dist/introspect/index.d.ts +15 -0
  39. package/dist/introspect/index.js +63 -0
  40. package/dist/model.d.ts +236 -0
  41. package/dist/model.js +214 -0
  42. package/dist/ops.d.ts +255 -0
  43. package/dist/ops.js +297 -0
  44. package/dist/project/apply.d.ts +35 -0
  45. package/dist/project/apply.js +360 -0
  46. package/dist/project/apply.test.d.ts +1 -0
  47. package/dist/project/apply.test.js +285 -0
  48. package/dist/project/gate.d.ts +129 -0
  49. package/dist/project/gate.js +597 -0
  50. package/dist/project/history.d.ts +62 -0
  51. package/dist/project/history.js +41 -0
  52. package/dist/project/load.d.ts +21 -0
  53. package/dist/project/load.js +82 -0
  54. package/dist/project/lock.d.ts +28 -0
  55. package/dist/project/lock.js +42 -0
  56. package/dist/project/plan.d.ts +5 -0
  57. package/dist/project/plan.js +96 -0
  58. package/dist/report/coverage.d.ts +22 -0
  59. package/dist/report/coverage.js +72 -0
  60. package/dist/report/coverage.test.d.ts +1 -0
  61. package/dist/report/coverage.test.js +93 -0
  62. package/dist/surfaces/agent-plugins.d.ts +10 -0
  63. package/dist/surfaces/agent-plugins.js +36 -0
  64. package/dist/surfaces/agents.d.ts +35 -0
  65. package/dist/surfaces/agents.js +609 -0
  66. package/dist/surfaces/agents.template.d.ts +8 -0
  67. package/dist/surfaces/agents.template.js +21 -0
  68. package/dist/surfaces/agents.test.d.ts +1 -0
  69. package/dist/surfaces/agents.test.js +181 -0
  70. package/dist/surfaces/browser-extension.d.ts +101 -0
  71. package/dist/surfaces/browser-extension.js +1034 -0
  72. package/dist/surfaces/browser-extension.test.d.ts +1 -0
  73. package/dist/surfaces/browser-extension.test.js +145 -0
  74. package/dist/surfaces/claude.d.ts +2 -0
  75. package/dist/surfaces/claude.js +81 -0
  76. package/dist/surfaces/codex.d.ts +13 -0
  77. package/dist/surfaces/codex.js +101 -0
  78. package/dist/surfaces/codex.test.d.ts +1 -0
  79. package/dist/surfaces/codex.test.js +67 -0
  80. package/dist/surfaces/cursor.d.ts +2 -0
  81. package/dist/surfaces/cursor.js +50 -0
  82. package/dist/surfaces/distribution.test.d.ts +1 -0
  83. package/dist/surfaces/distribution.test.js +146 -0
  84. package/dist/surfaces/dsh.d.ts +25 -0
  85. package/dist/surfaces/dsh.js +147 -0
  86. package/dist/surfaces/dsh.test.d.ts +1 -0
  87. package/dist/surfaces/dsh.test.js +100 -0
  88. package/dist/surfaces/external.d.ts +9 -0
  89. package/dist/surfaces/external.js +4 -0
  90. package/dist/surfaces/gemini.d.ts +29 -0
  91. package/dist/surfaces/gemini.js +68 -0
  92. package/dist/surfaces/gemini.test.d.ts +1 -0
  93. package/dist/surfaces/gemini.test.js +74 -0
  94. package/dist/surfaces/hermes-native.d.ts +53 -0
  95. package/dist/surfaces/hermes-native.js +507 -0
  96. package/dist/surfaces/hermes-native.test.d.ts +1 -0
  97. package/dist/surfaces/hermes-native.test.js +156 -0
  98. package/dist/surfaces/kernel.d.ts +5 -0
  99. package/dist/surfaces/kernel.js +63 -0
  100. package/dist/surfaces/mcp-registry.d.ts +19 -0
  101. package/dist/surfaces/mcp-registry.js +160 -0
  102. package/dist/surfaces/mcp-registry.test.d.ts +1 -0
  103. package/dist/surfaces/mcp-registry.test.js +79 -0
  104. package/dist/surfaces/mcpb.d.ts +11 -0
  105. package/dist/surfaces/mcpb.js +118 -0
  106. package/dist/surfaces/mcpb.test.d.ts +1 -0
  107. package/dist/surfaces/mcpb.test.js +104 -0
  108. package/dist/surfaces/npm.d.ts +6 -0
  109. package/dist/surfaces/npm.js +72 -0
  110. package/dist/surfaces/openclaw-native.d.ts +93 -0
  111. package/dist/surfaces/openclaw-native.js +956 -0
  112. package/dist/surfaces/openclaw-native.test.d.ts +1 -0
  113. package/dist/surfaces/openclaw-native.test.js +246 -0
  114. package/dist/surfaces/pypi.d.ts +5 -0
  115. package/dist/surfaces/pypi.js +82 -0
  116. package/dist/surfaces/readme.d.ts +7 -0
  117. package/dist/surfaces/readme.js +181 -0
  118. package/dist/surfaces/registry.d.ts +6 -0
  119. package/dist/surfaces/registry.js +73 -0
  120. package/dist/surfaces/shared.d.ts +49 -0
  121. package/dist/surfaces/shared.js +112 -0
  122. package/dist/surfaces/skill.d.ts +7 -0
  123. package/dist/surfaces/skill.js +118 -0
  124. package/dist/surfaces/skill.test.d.ts +1 -0
  125. package/dist/surfaces/skill.test.js +87 -0
  126. package/dist/surfaces/web.d.ts +97 -0
  127. package/dist/surfaces/web.js +1285 -0
  128. package/dist/surfaces/web.test.d.ts +1 -0
  129. package/dist/surfaces/web.test.js +255 -0
  130. package/dist/surfaces/workflows.d.ts +2 -0
  131. package/dist/surfaces/workflows.js +809 -0
  132. package/dist/surfaces/workflows.test.d.ts +1 -0
  133. package/dist/surfaces/workflows.test.js +563 -0
  134. package/dist/toolfactory/cli.d.ts +2 -0
  135. package/dist/toolfactory/cli.js +65 -0
  136. package/dist/toolfactory/config.d.ts +3 -0
  137. package/dist/toolfactory/config.js +27 -0
  138. package/dist/toolfactory/mcp.d.ts +37 -0
  139. package/dist/toolfactory/mcp.js +269 -0
  140. package/dist/toolfactory/types.d.ts +31 -0
  141. package/dist/toolfactory/types.js +10 -0
  142. package/dist/toolfactory/web.d.ts +4 -0
  143. package/dist/toolfactory/web.js +40 -0
  144. package/dist/validate/agent-plugins.d.ts +5 -0
  145. package/dist/validate/agent-plugins.js +46 -0
  146. package/package.json +68 -0
  147. package/schemas/agent-plugins/mcp.schema.json +120 -0
  148. package/schemas/agent-plugins/plugin.schema.json +65 -0
  149. package/src/bindings/index.ts +47 -0
  150. package/src/bindings/python.test.ts +374 -0
  151. package/src/bindings/python.ts +862 -0
  152. package/src/bindings/typescript.test.ts +329 -0
  153. package/src/bindings/typescript.ts +818 -0
  154. package/src/commands.test.ts +253 -0
  155. package/src/commands.ts +886 -0
  156. package/src/e2e.test.ts +169 -0
  157. package/src/hosts/browser.ts +106 -0
  158. package/src/hosts/github.ts +321 -0
  159. package/src/hosts/openclaw.ts +110 -0
  160. package/src/hosts/template.ts +149 -0
  161. package/src/hosts/web.ts +111 -0
  162. package/src/identity/name.test.ts +43 -0
  163. package/src/identity/name.ts +39 -0
  164. package/src/identity/read.test.ts +92 -0
  165. package/src/identity/read.ts +119 -0
  166. package/src/index.ts +11 -0
  167. package/src/introspect/index.ts +72 -0
  168. package/src/model.ts +386 -0
  169. package/src/ops.ts +333 -0
  170. package/src/project/apply.test.ts +348 -0
  171. package/src/project/apply.ts +417 -0
  172. package/src/project/gate.ts +717 -0
  173. package/src/project/history.ts +46 -0
  174. package/src/project/load.ts +104 -0
  175. package/src/project/lock.ts +54 -0
  176. package/src/project/plan.ts +101 -0
  177. package/src/report/coverage.test.ts +108 -0
  178. package/src/report/coverage.ts +113 -0
  179. package/src/surfaces/agent-plugins.ts +47 -0
  180. package/src/surfaces/agents.template.ts +32 -0
  181. package/src/surfaces/agents.test.ts +206 -0
  182. package/src/surfaces/agents.ts +729 -0
  183. package/src/surfaces/browser-extension.test.ts +175 -0
  184. package/src/surfaces/browser-extension.ts +1095 -0
  185. package/src/surfaces/claude.ts +99 -0
  186. package/src/surfaces/codex.test.ts +71 -0
  187. package/src/surfaces/codex.ts +105 -0
  188. package/src/surfaces/cursor.ts +62 -0
  189. package/src/surfaces/distribution.test.ts +166 -0
  190. package/src/surfaces/dsh.test.ts +110 -0
  191. package/src/surfaces/dsh.ts +159 -0
  192. package/src/surfaces/external.ts +13 -0
  193. package/src/surfaces/gemini.test.ts +80 -0
  194. package/src/surfaces/gemini.ts +84 -0
  195. package/src/surfaces/hermes-native.test.ts +179 -0
  196. package/src/surfaces/hermes-native.ts +584 -0
  197. package/src/surfaces/kernel.ts +68 -0
  198. package/src/surfaces/mcp-registry.test.ts +99 -0
  199. package/src/surfaces/mcp-registry.ts +190 -0
  200. package/src/surfaces/mcpb.test.ts +112 -0
  201. package/src/surfaces/mcpb.ts +130 -0
  202. package/src/surfaces/npm.ts +78 -0
  203. package/src/surfaces/openclaw-native.test.ts +283 -0
  204. package/src/surfaces/openclaw-native.ts +1070 -0
  205. package/src/surfaces/pypi.ts +84 -0
  206. package/src/surfaces/readme.ts +218 -0
  207. package/src/surfaces/registry.ts +79 -0
  208. package/src/surfaces/shared.ts +140 -0
  209. package/src/surfaces/skill.test.ts +103 -0
  210. package/src/surfaces/skill.ts +136 -0
  211. package/src/surfaces/web.test.ts +303 -0
  212. package/src/surfaces/web.ts +1310 -0
  213. package/src/surfaces/workflows.test.ts +677 -0
  214. package/src/surfaces/workflows.ts +936 -0
  215. package/src/toolfactory/cli.ts +74 -0
  216. package/src/toolfactory/config.ts +33 -0
  217. package/src/toolfactory/mcp.ts +311 -0
  218. package/src/toolfactory/types.ts +52 -0
  219. package/src/toolfactory/web.ts +45 -0
  220. package/src/validate/agent-plugins.ts +53 -0
  221. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  222. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  223. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  224. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  225. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  226. package/web/dist/assets/index-CCXhQKK3.css +2 -0
  227. package/web/dist/assets/index-D1PwZ3qS.js +10 -0
  228. package/web/dist/index.html +14 -0
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
4
+ "title": "Agent Plugins Manifest",
5
+ "description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
10
+ "description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document."
11
+ },
12
+ "name": {
13
+ "type": "string",
14
+ "minLength": 1,
15
+ "maxLength": 64,
16
+ "pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$",
17
+ "description": "Human-readable plugin name."
18
+ },
19
+ "version": {
20
+ "type": "string"
21
+ },
22
+ "description": {
23
+ "type": "string"
24
+ },
25
+ "author": {
26
+ "type": "object",
27
+ "properties": {
28
+ "name": {
29
+ "type": "string"
30
+ },
31
+ "email": {
32
+ "type": "string"
33
+ },
34
+ "url": {
35
+ "type": "string"
36
+ }
37
+ },
38
+ "additionalProperties": false
39
+ },
40
+ "homepage": {
41
+ "type": "string"
42
+ },
43
+ "repository": {
44
+ "type": "string"
45
+ },
46
+ "license": {
47
+ "type": "string"
48
+ },
49
+ "keywords": {
50
+ "type": "array",
51
+ "items": {
52
+ "type": "string"
53
+ }
54
+ },
55
+ "extensions": {
56
+ "type": "object",
57
+ "description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.",
58
+ "additionalProperties": {
59
+ "type": "object"
60
+ }
61
+ }
62
+ },
63
+ "required": ["$schema", "name"],
64
+ "additionalProperties": false
65
+ }
@@ -0,0 +1,47 @@
1
+ import type { Binding, PlannedFile, Project } from "../model.js";
2
+ import * as python from "./python.js";
3
+ import * as typescript from "./typescript.js";
4
+
5
+ export interface BindingModule {
6
+ /** Files generated on every build for every tool: the contract, config, and the MCP server toolfactory introspects. */
7
+ kernel(project: Project): PlannedFile[];
8
+ /** The `cli` surface's files. */
9
+ cli(project: Project): PlannedFile[];
10
+ /** The T4 live test; empty when no config key is both required and sensitive. */
11
+ liveTest(project: Project): PlannedFile[];
12
+ /** Files written once by `init` when absent, then owned by the author. */
13
+ scaffold(project: Project): PlannedFile[];
14
+ /** How to launch the kernel MCP server from the repo root without a build step. */
15
+ kernelCommand(project: Project): { command: string; args: string[] };
16
+ /** The kernel CLI counterpart of `kernelCommand`, for the `cli` surface's smoke check. */
17
+ cliCommand(project: Project): { command: string; args: string[] };
18
+ }
19
+
20
+ const bindings: Partial<Record<Binding, BindingModule>> = {
21
+ typescript: {
22
+ kernel: typescript.kernel,
23
+ cli: typescript.cli,
24
+ liveTest: typescript.liveTest,
25
+ scaffold: typescript.scaffold,
26
+ kernelCommand: () => typescript.kernelCommand(),
27
+ cliCommand: () => typescript.cliCommand(),
28
+ },
29
+ python: {
30
+ kernel: python.kernel,
31
+ cli: python.cli,
32
+ liveTest: python.liveTest,
33
+ scaffold: python.scaffold,
34
+ kernelCommand: python.kernelCommand,
35
+ cliCommand: python.cliCommand,
36
+ },
37
+ };
38
+
39
+ export function registerBinding(id: Binding, module: BindingModule): void {
40
+ bindings[id] = module;
41
+ }
42
+
43
+ export function getBinding(id: Binding): BindingModule {
44
+ const binding = bindings[id];
45
+ if (!binding) throw new Error(`Binding "${id}" is not implemented in this toolfactory version.`);
46
+ return binding;
47
+ }
@@ -0,0 +1,374 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createServer as createNetServer } from "node:http";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { describe, expect, it } from "vitest";
7
+ import { listTools } from "../introspect/index.js";
8
+ import type { FullFile, MergeFile, PlannedFile, Project, SurfaceId } from "../model.js";
9
+ import { ToolConfigSchema } from "../model.js";
10
+ import { surface as pypi } from "../surfaces/pypi.js";
11
+ import {
12
+ cliCommand,
13
+ cli as cliFiles,
14
+ kernel,
15
+ kernelCommand,
16
+ liveTest,
17
+ scaffold,
18
+ } from "./python.js";
19
+
20
+ function project(surfaces: SurfaceId[], overrides: Partial<Project> = {}): Project {
21
+ return {
22
+ root: "/tmp/none",
23
+ tool: ToolConfigSchema.parse({
24
+ schemaVersion: 1,
25
+ identity: "plugin.json",
26
+ binding: "python",
27
+ surfaces,
28
+ config: { type: "object", properties: { apiKey: { type: "string" } } },
29
+ }),
30
+ identity: {
31
+ name: "hello.py",
32
+ version: "0.2.0",
33
+ description: "Say hello.",
34
+ license: "MIT",
35
+ repository: "https://github.com/acme/hello",
36
+ author: { name: "Ada", email: "ada@example.com" },
37
+ },
38
+ identityExtra: {},
39
+ operations: [],
40
+ toolfactoryVersion: "0.1.0",
41
+ ...overrides,
42
+ };
43
+ }
44
+
45
+ /** A project whose config declares one required, sensitive credential: what gates the T4 tier. */
46
+ function withCredential(surfaces: SurfaceId[]): Project {
47
+ const base = project(surfaces);
48
+ base.tool.config = {
49
+ type: "object",
50
+ properties: { passkey: { type: "string", "x-toolfactory": { sensitive: true } } },
51
+ required: ["passkey"],
52
+ };
53
+ base.tool.tests = { examples: { echo: { text: "hi" } } };
54
+ base.operations = [{ name: "echo", inputSchema: { type: "object" }, requires: [] }];
55
+ return base;
56
+ }
57
+
58
+ const paths = (files: PlannedFile[]) => files.map((file) => file.path);
59
+ const text = (files: PlannedFile[], path: string) =>
60
+ (files.find((file) => file.path === path) as FullFile).content;
61
+
62
+ describe("python binding", () => {
63
+ it("names every artifact from the canonical name", () => {
64
+ const both = project(["mcp", "cli"]);
65
+ expect(kernelCommand(both).args).toEqual([
66
+ "run",
67
+ "--quiet",
68
+ "python",
69
+ "-m",
70
+ "hello_py.toolfactory.mcp",
71
+ ]);
72
+ expect(cliCommand(both).args.at(-1)).toBe("hello_py.toolfactory.cli");
73
+ expect(paths([...kernel(both), ...cliFiles(both)])).toEqual([
74
+ "src/hello_py/toolfactory/__init__.py",
75
+ "src/hello_py/toolfactory/types.py",
76
+ "src/hello_py/toolfactory/config.py",
77
+ "src/hello_py/toolfactory/mcp.py",
78
+ "src/hello_py/toolfactory/cli.py",
79
+ ]);
80
+ // A file exists iff a selected surface owns it, and config keys become environment reads.
81
+ expect(paths(kernel(project(["mcp"])))).not.toContain("src/hello_py/toolfactory/cli.py");
82
+ expect(text(kernel(both), "src/hello_py/toolfactory/config.py")).toContain(
83
+ '"apiKey": os.environ.get("APIKEY")',
84
+ );
85
+ expect(text(scaffold(both), "pyproject.toml")).toContain(
86
+ '"hello.py" = "hello_py.toolfactory.cli:main"',
87
+ );
88
+ });
89
+
90
+ it("reads the data directory from <N>_DATA_DIR, else the platform default", () => {
91
+ const files = kernel(project(["mcp"]));
92
+ expect(text(files, "src/hello_py/toolfactory/types.py")).toContain("data_dir: Path");
93
+ const config = text(files, "src/hello_py/toolfactory/config.py");
94
+ expect(config).toContain('os.environ.get("HELLO_PY_DATA_DIR")');
95
+ expect(config).toContain('os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share"');
96
+ expect(config).toContain('os.environ.get("LOCALAPPDATA")');
97
+ expect(config).toContain('return base / "hello.py"');
98
+ expect(config).toContain("data_dir=data_dir()");
99
+ });
100
+
101
+ it("emits the T4 live test only for a required sensitive credential", () => {
102
+ expect(liveTest(project(["cli"]))).toEqual([]);
103
+
104
+ const live = liveTest(withCredential(["cli"]))[0];
105
+ if (live?.kind !== "region") throw new Error("expected a region file");
106
+ expect(live.path).toBe("tests/test_live.py");
107
+ expect(live.regions[0]?.content).toContain('CREDENTIALS = ("PASSKEY",)');
108
+ expect(live.regions[0]?.content).toContain("LIVE = all(os.environ.get(name)");
109
+ expect(live.template).toContain("@pytest.mark.skipif(not LIVE");
110
+ // pydantic's own JSON entry point, so tool.json's example arguments need no translation.
111
+ expect(live.template).toContain('operation.input.model_validate_json("{\\"text\\":\\"hi\\"}")');
112
+ // The header documents the local command; CI runs the same one without --env-file.
113
+ expect(live.template).toContain(
114
+ "uv run --env-file .env --with pytest pytest -q tests/test_live.py",
115
+ );
116
+ });
117
+
118
+ it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
119
+ const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
120
+ const mcp = text(files, "src/hello_py/toolfactory/mcp.py");
121
+ expect(mcp).toContain("def serve_http(");
122
+ // One ASGI app on both paths: the SDK's own streamable-HTTP app, run by uvicorn over a
123
+ // socket bound first, so `--http 0` can report the port it actually got.
124
+ expect(mcp).toContain("app = server.streamable_http_app(");
125
+ expect(mcp).toContain("sock = config.bind_socket()");
126
+ expect(mcp).toContain("uvicorn.Server(config).run(sockets=[sock])");
127
+ expect(mcp).toContain("Serving MCP streamable HTTP on {base}{path}{suffix}");
128
+ expect(mcp).toContain("stateless_http=True");
129
+ // Stdio is still the default: the standalone entrypoint only switches transport when --http is present.
130
+ expect(mcp).toContain("if args.http is None and not args.pair:");
131
+
132
+ // The pairing token: `<N>_MCP_TOKEN` or a `relay-token` file under the data directory, and
133
+ // `--pair` to mint one. Neither present means no token and no behaviour change.
134
+ expect(mcp).toContain('os.environ.get("HELLO_PY_MCP_TOKEN")');
135
+ expect(mcp).toContain("data_dir() / TOKEN_FILE");
136
+ expect(mcp).toContain("hmac.compare_digest(hashlib.sha256(presented).digest(), expected)");
137
+
138
+ const cli = text(files, "src/hello_py/toolfactory/cli.py");
139
+ expect(cli).toContain('"--http"');
140
+ expect(cli).toContain("from .mcp import serve_http");
141
+ expect(cli).toContain("if options.http is not None or options.pair:");
142
+ expect(cli).toContain('"--pair"');
143
+ // Without the `web` surface there is no page to serve, open or write secrets for.
144
+ expect(mcp).not.toContain("web_route()");
145
+ expect(mcp).not.toContain("SECRETS");
146
+ expect(paths(kernel(project(["mcp", "cli"])))).not.toContain("src/hello_py/toolfactory/web.py");
147
+ });
148
+
149
+ it("serves the page, opens it and takes a secret when the web surface is selected", () => {
150
+ const target = withCredential(["mcp", "cli", "web"]);
151
+ const files = [...kernel(target), ...cliFiles(target)];
152
+ const mcp = text(files, "src/hello_py/toolfactory/mcp.py");
153
+ // The static half: the wheel's own copy first, then this checkout's build output.
154
+ expect(mcp).toContain(
155
+ 'for candidate in (package / "web", package.parent.parent / "web" / "dist")',
156
+ );
157
+ expect(mcp).toContain('Mount("/", app=StaticFiles(directory=directory, html=True))');
158
+ expect(mcp).toContain("run `npm -C web run build`");
159
+ // The token guards the API paths only — a browser cannot put a header on its own document.
160
+ expect(mcp).toContain('app = guard(app, token, {path, "/env"})');
161
+ expect(mcp).toContain('SECRETS = ["PASSKEY"]');
162
+ expect(mcp).toContain('return JSONResponse({"declared": SECRETS, "present": env_present()})');
163
+ expect(mcp).toContain("env_path().chmod(0o600)");
164
+ expect(mcp).toContain('print(f".env: set {name}", file=sys.stderr, flush=True)');
165
+ expect(mcp).toContain('webbrowser.open(f"{base}/#{token}")');
166
+ expect(mcp).toContain("mint_token() if open_browser else None");
167
+
168
+ // The `web` operation: a detached child holds the socket, never this process.
169
+ const web = text(files, "src/hello_py/toolfactory/web.py");
170
+ expect(web).toContain('[sys.executable, "-m", f"{__package__}.mcp", "--http", "0", "--open"]');
171
+ expect(web).toContain("start_new_session=True");
172
+ expect(web).toContain('SERVING = re.compile(r"Serving MCP streamable HTTP on (\\S+)")');
173
+ expect(mcp).toContain("OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]");
174
+ expect(text(files, "src/hello_py/toolfactory/cli.py")).toContain(
175
+ "OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]",
176
+ );
177
+ });
178
+
179
+ it("projects identity into pyproject.toml only when it is not the identity file", () => {
180
+ const patch = (p: Project) =>
181
+ (pypi.plan(p)[0] as MergeFile).patch.project as Record<string, unknown>;
182
+ expect(patch(project(["pypi", "cli"]))).toMatchObject({
183
+ name: "hello-py",
184
+ version: "0.2.0",
185
+ authors: [{ name: "Ada", email: "ada@example.com" }],
186
+ urls: { Repository: "https://github.com/acme/hello" },
187
+ scripts: { "hello.py": "hello_py.toolfactory.cli:main" },
188
+ });
189
+ const authored = project(["pypi", "cli"]);
190
+ authored.tool.identity = "pyproject.toml";
191
+ expect(patch(authored)).toEqual({ scripts: { "hello.py": "hello_py.toolfactory.cli:main" } });
192
+ });
193
+
194
+ it("carries the MCP Registry ownership marker in the README", () => {
195
+ const files = pypi.plan(project(["pypi", "mcp-registry"]));
196
+ expect((files[0] as MergeFile).patch).toMatchObject({ project: { readme: "README.md" } });
197
+ const readme = files[1];
198
+ if (readme.kind !== "region") throw new Error("expected a region file");
199
+ expect(readme.regions[0]?.content).toContain("<!-- mcp-name: io.github.acme/hello.py -->");
200
+ });
201
+ });
202
+
203
+ const uv = spawnSync("uv", ["--version"], { encoding: "utf8" }).status === 0;
204
+
205
+ describe.skipIf(!uv)("python kernel, really run", () => {
206
+ it("serves tools/list and answers the CLI", { timeout: 300_000 }, async () => {
207
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-python-"));
208
+ const real = project(["mcp", "cli"], { root, identity: { name: "probe", version: "0.1.0" } });
209
+ for (const file of [...scaffold(real), ...kernel(real), ...cliFiles(real)]) {
210
+ const path = join(root, file.path);
211
+ mkdirSync(dirname(path), { recursive: true });
212
+ writeFileSync(path, (file as FullFile).content);
213
+ }
214
+ const sync = spawnSync("uv", ["sync", "--quiet"], { cwd: root, encoding: "utf8" });
215
+ expect(sync.status, sync.stderr).toBe(0);
216
+
217
+ const { command, args } = kernelCommand(real);
218
+ const ops = await listTools(root, command, args);
219
+ expect(ops.tools[0]).toMatchObject({
220
+ name: "echo",
221
+ inputSchema: { required: ["text"] },
222
+ outputSchema: { required: ["text"] },
223
+ _meta: { "dev.toolfactory": { requires: [] } },
224
+ });
225
+
226
+ const cli = cliCommand(real);
227
+ const ran = spawnSync(cli.command, [...cli.args, "echo", "--text", "hi"], {
228
+ cwd: root,
229
+ encoding: "utf8",
230
+ });
231
+ expect(JSON.parse(ran.stdout)).toEqual({ text: "hi" });
232
+ });
233
+
234
+ it("serves tools/list over --http, and requires the token minted by --pair", {
235
+ timeout: 300_000,
236
+ }, async () => {
237
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-python-http-"));
238
+ const real = project(["mcp", "cli"], { root, identity: { name: "probe", version: "0.1.0" } });
239
+ for (const file of [...scaffold(real), ...kernel(real), ...cliFiles(real)]) {
240
+ const path = join(root, file.path);
241
+ mkdirSync(dirname(path), { recursive: true });
242
+ writeFileSync(path, (file as FullFile).content);
243
+ }
244
+ const sync = spawnSync("uv", ["sync", "--quiet"], { cwd: root, encoding: "utf8" });
245
+ expect(sync.status, sync.stderr).toBe(0);
246
+
247
+ const dataDir = join(root, "data");
248
+ const env = { ...process.env, PROBE_DATA_DIR: dataDir };
249
+ const port = await freePort();
250
+ const cli = cliCommand(real);
251
+ const child = spawn(cli.command, [...cli.args, "mcp", "--http", String(port)], {
252
+ cwd: root,
253
+ env,
254
+ stdio: ["ignore", "pipe", "pipe"],
255
+ });
256
+ const stderr: string[] = [];
257
+ child.stderr?.on("data", (chunk: Buffer) => stderr.push(String(chunk)));
258
+
259
+ try {
260
+ const url = `http://127.0.0.1:${port}/mcp`;
261
+ const init = await waitForInitialize(url, 30_000).catch((error) => {
262
+ throw new Error(`${error instanceof Error ? error.message : error}\n${stderr.join("")}`);
263
+ });
264
+ expect(init.status).toBe(200);
265
+ const negotiated = (init.json as { result: { protocolVersion: string } }).result
266
+ .protocolVersion;
267
+
268
+ const list = await postRpc(url, { jsonrpc: "2.0", id: 2, method: "tools/list" }, negotiated);
269
+ expect(list.status).toBe(200);
270
+ const tools = (list.json as { result: { tools: { name: string }[] } }).result.tools;
271
+ expect(tools.map((tool) => tool.name)).toEqual(["echo"]);
272
+ } finally {
273
+ child.kill();
274
+ }
275
+
276
+ // The same server started with `--pair`: it prints the pairing string the extension's options
277
+ // page takes, keeps the token under the data directory, and refuses a request without it.
278
+ const pairPort = await freePort();
279
+ const paired = spawn(cli.command, [...cli.args, "mcp", "--http", String(pairPort), "--pair"], {
280
+ cwd: root,
281
+ env,
282
+ stdio: ["ignore", "pipe", "pipe"],
283
+ });
284
+ const stdout: string[] = [];
285
+ paired.stdout?.on("data", (chunk: Buffer) => stdout.push(String(chunk)));
286
+
287
+ try {
288
+ const url = `http://127.0.0.1:${pairPort}/mcp`;
289
+ const anonymous = await waitForInitialize(url, 30_000);
290
+ expect(anonymous.status).toBe(401);
291
+
292
+ const [printed, token] = stdout.join("").trim().split("\n")[0]?.split("#") ?? [];
293
+ expect(printed).toBe(url);
294
+ expect(readFileSync(join(dataDir, "relay-token"), "utf8").trim()).toBe(token);
295
+
296
+ const init = await waitForInitialize(url, 30_000, token);
297
+ expect(init.status).toBe(200);
298
+ } finally {
299
+ paired.kill();
300
+ }
301
+ });
302
+ });
303
+
304
+ function freePort(): Promise<number> {
305
+ return new Promise((resolve, reject) => {
306
+ const probe = createNetServer();
307
+ probe.on("error", reject);
308
+ probe.listen(0, "127.0.0.1", () => {
309
+ const address = probe.address();
310
+ const port = typeof address === "object" && address ? address.port : 0;
311
+ probe.close((error) => (error ? reject(error) : resolve(port)));
312
+ });
313
+ });
314
+ }
315
+
316
+ // The streamable HTTP transport requires `Accept: application/json, text/event-stream` on every
317
+ // POST, and `MCP-Protocol-Version` on every request *after* the one that negotiates it in `initialize`.
318
+ async function postRpc(
319
+ url: string,
320
+ body: unknown,
321
+ protocolVersion?: string,
322
+ token?: string,
323
+ ): Promise<{ status: number; json: unknown }> {
324
+ const headers: Record<string, string> = {
325
+ "Content-Type": "application/json",
326
+ Accept: "application/json, text/event-stream",
327
+ };
328
+ if (protocolVersion) headers["MCP-Protocol-Version"] = protocolVersion;
329
+ if (token) headers.Authorization = `Bearer ${token}`;
330
+ const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
331
+ const raw = await response.text();
332
+ const isEventStream =
333
+ response.headers.get("content-type")?.includes("text/event-stream") ?? false;
334
+ const payload = isEventStream
335
+ ? JSON.parse(
336
+ raw
337
+ .split("\n")
338
+ .find((line) => line.startsWith("data: "))
339
+ ?.slice("data: ".length) ?? "{}",
340
+ )
341
+ : JSON.parse(raw || "{}");
342
+ return { status: response.status, json: payload };
343
+ }
344
+
345
+ /** Retries `initialize` until the freshly-spawned server starts accepting connections. */
346
+ async function waitForInitialize(
347
+ url: string,
348
+ deadlineMs: number,
349
+ token?: string,
350
+ ): Promise<{ status: number; json: unknown }> {
351
+ const deadline = Date.now() + deadlineMs;
352
+ for (;;) {
353
+ try {
354
+ return await postRpc(
355
+ url,
356
+ {
357
+ jsonrpc: "2.0",
358
+ id: 1,
359
+ method: "initialize",
360
+ params: {
361
+ protocolVersion: "2026-07-28",
362
+ capabilities: {},
363
+ clientInfo: { name: "toolfactory-test", version: "0.0.0" },
364
+ },
365
+ },
366
+ undefined,
367
+ token,
368
+ );
369
+ } catch (error) {
370
+ if (Date.now() >= deadline) throw error;
371
+ await new Promise((resolve) => setTimeout(resolve, 250));
372
+ }
373
+ }
374
+ }