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,805 @@
1
+ /**
2
+ * Python binding: the six language-specific things, as templates against upstream
3
+ * packages only (`mcp` 2.x + pydantic 2, and the standard library for the CLI). The
4
+ * kernel files under `src/<pkg>/toolfactory/` are regenerated on every build; the
5
+ * author's `src/<pkg>/ops.py` is scaffolded once and then owned.
6
+ */
7
+ import { stringify as toml } from "smol-toml";
8
+ import { projectName } from "../identity/name.js";
9
+ import { secretsOf } from "../project/gate.js";
10
+ import { compact, configProperties, dataDirEnvName, envName, has, liveCredentials, liveExample, pypiName, } from "../surfaces/shared.js";
11
+ import { WEB_DIR } from "../surfaces/web.js";
12
+ /** Import package: the canonical name with `.` and `-` folded to `_` (PEP 8 / PEP 503). */
13
+ export function pythonPackage(project) {
14
+ return projectName.pythonPackage(project.identity.name);
15
+ }
16
+ export function kernelDir(project) {
17
+ return `src/${pythonPackage(project)}/toolfactory`;
18
+ }
19
+ export function opsPath(project) {
20
+ return `src/${pythonPackage(project)}/ops.py`;
21
+ }
22
+ export const LIVE_TEST_PATH = "tests/test_live.py";
23
+ /** What the `live` CI job runs; the author adds `--env-file .env` locally (documented in the file header). */
24
+ export const LIVE_TEST_COMMAND = `uv run --with pytest pytest -q ${LIVE_TEST_PATH}`;
25
+ const LIVE_GUARD_BEGIN = "# tf:live-guard";
26
+ const LIVE_GUARD_END = "# /tf:live-guard";
27
+ export function cliEntryPoint(project) {
28
+ return `${pythonPackage(project)}.toolfactory.cli:main`;
29
+ }
30
+ /** `uv run` syncs the project into `.venv` on first use, so no build step is needed. */
31
+ export function kernelCommand(project) {
32
+ return {
33
+ command: "uv",
34
+ args: ["run", "--quiet", "python", "-m", `${pythonPackage(project)}.toolfactory.mcp`],
35
+ };
36
+ }
37
+ /** The CLI counterpart of `kernelCommand`: same module path, `cli` instead of `mcp`. */
38
+ export function cliCommand(project) {
39
+ return {
40
+ command: "uv",
41
+ args: ["run", "--quiet", "python", "-m", `${pythonPackage(project)}.toolfactory.cli`],
42
+ };
43
+ }
44
+ const HEADER = "# Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
45
+ function typesTemplate() {
46
+ return `${HEADER}"""The operation contract: what an entry in \`OPERATIONS\` looks like."""
47
+
48
+ from __future__ import annotations
49
+
50
+ from collections.abc import Callable
51
+ from dataclasses import dataclass, field
52
+ from pathlib import Path
53
+ from typing import Any, Literal
54
+
55
+ from pydantic import BaseModel
56
+
57
+ #: Host capabilities an operation may declare. Portable: net, fs, shell, secret.
58
+ Capability = Literal["net", "fs", "shell", "secret", "browser", "model", "user-input", "channel"]
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Context:
63
+ """Configuration read from the environment, keyed as declared in dev.toolfactory/tool.json."""
64
+
65
+ config: dict[str, str | None]
66
+ #: Directory this tool may keep its own state in. Created by the tool, not by the kernel.
67
+ data_dir: Path
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Operation:
72
+ """One operation: a pure function of (arguments, context.config, filesystem)."""
73
+
74
+ name: str
75
+ description: str
76
+ input: type[BaseModel]
77
+ handler: Callable[[Any, Context], Any]
78
+ output: type[BaseModel] | None = None
79
+ requires: tuple[Capability, ...] = ()
80
+ annotations: dict[str, bool] = field(default_factory=dict)
81
+
82
+ #: Capabilities every surface can satisfy on its own.
83
+ PORTABLE: frozenset[str] = frozenset({"net", "fs", "shell", "secret"})
84
+
85
+
86
+ def serves(operation: "Operation", surface: Literal["mcp", "cli"]) -> bool:
87
+ """Whether a surface can run an operation: MCP needs a portable one; a CLI also has a human for user-input."""
88
+ return all(c in PORTABLE or (surface == "cli" and c == "user-input") for c in operation.requires)
89
+ `;
90
+ }
91
+ function initTemplate() {
92
+ return `${HEADER}"""Generated kernel: the MCP server, the CLI, and the operation contract."""
93
+
94
+ from .types import Capability, Context, Operation
95
+
96
+ __all__ = ["Capability", "Context", "Operation"]
97
+ `;
98
+ }
99
+ function configTemplate(project) {
100
+ const entries = Object.keys(configProperties(project)).map((key) => ` ${JSON.stringify(key)}: os.environ.get(${JSON.stringify(envName(key))}),\n`);
101
+ const body = entries.length ? `{\n${entries.join("")} }` : "{}";
102
+ const name = project.identity.name;
103
+ const dataDirEnv = dataDirEnvName(project);
104
+ return `${HEADER}"""Configuration is read from the environment; hosts inject it from their own settings UI."""
105
+
106
+ import os
107
+ from pathlib import Path
108
+
109
+ from .types import Context
110
+
111
+
112
+ def data_dir() -> Path:
113
+ """Where this tool may keep its own state: ${dataDirEnv} when a host sets it, else the
114
+ platform's own data location (XDG on POSIX, %LOCALAPPDATA% on Windows)."""
115
+ # Hosts that launch the kernel export their own name for it: Agent Plugins clients and
116
+ # OpenClaw set PLUGIN_DATA, Claude Code sets CLAUDE_PLUGIN_DATA.
117
+ configured = (
118
+ os.environ.get(${JSON.stringify(dataDirEnv)})
119
+ or os.environ.get("PLUGIN_DATA")
120
+ or os.environ.get("CLAUDE_PLUGIN_DATA")
121
+ )
122
+ if configured:
123
+ return Path(configured)
124
+ if os.name == "nt":
125
+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
126
+ else:
127
+ base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")
128
+ return base / ${JSON.stringify(name)}
129
+
130
+
131
+ def context() -> Context:
132
+ return Context(config=${body}, data_dir=data_dir())
133
+ `;
134
+ }
135
+ /**
136
+ * What the kernel adds when the `web` surface is selected: the Starlette routes for the built
137
+ * page and — only when the project declares a secret — `/env`, which the page's Secrets panel
138
+ * reads and writes. `.env` is the developer's own file, resolved from the working directory:
139
+ * names out, one name in, never a value in a log or a response.
140
+ */
141
+ function webRoutes(secrets) {
142
+ const env = !secrets.length
143
+ ? ""
144
+ : `
145
+
146
+
147
+ #: The names this project declares; \`/env\` writes no other.
148
+ SECRETS = ${JSON.stringify(secrets)}
149
+
150
+
151
+ def env_path() -> Path:
152
+ return Path.cwd() / ".env"
153
+
154
+
155
+ def env_lines() -> "list[str]":
156
+ try:
157
+ return env_path().read_text().rstrip("\\n").split("\\n")
158
+ except OSError:
159
+ return []
160
+
161
+
162
+ def env_present() -> "list[str]":
163
+ """Which declared names \`.env\` already carries a value for. Names only — never a value."""
164
+ found = {}
165
+ for line in env_lines():
166
+ name, _, value = line.lstrip().removeprefix("export ").partition("=")
167
+ if value.strip():
168
+ found[name.strip()] = True
169
+ return [name for name in SECRETS if name in found]
170
+
171
+
172
+ def env_write(name: str, value: str) -> None:
173
+ """Rewrite one line of \`.env\`, keeping every other line, and keep the file private."""
174
+ lines = env_lines()
175
+ for index, line in enumerate(lines):
176
+ if line.lstrip().removeprefix("export ").partition("=")[0].strip() == name:
177
+ lines[index] = f"{name}={value}"
178
+ break
179
+ else:
180
+ lines.append(f"{name}={value}")
181
+ env_path().write_text("\\n".join(lines) + "\\n")
182
+ env_path().chmod(0o600)
183
+ # The name, never the value: this log is the one place a secret could leak by accident.
184
+ print(f".env: set {name}", file=sys.stderr, flush=True)
185
+
186
+
187
+ def env_route(token: "str | None") -> Route:
188
+ """\`GET\` the names present, \`POST {name, value}\` one of them. Writing needs the bearer token."""
189
+
190
+ async def endpoint(request: Any) -> JSONResponse:
191
+ # Names, never values: what this project declares and which \`.env\` already carries.
192
+ if request.method == "GET":
193
+ return JSONResponse({"declared": SECRETS, "present": env_present()})
194
+ # Even on loopback: without a token in force there is no client to trust with a write.
195
+ if token is None:
196
+ return JSONResponse({"error": "unpaired: restart with \`mcp --http --open\`"}, status_code=403)
197
+ try:
198
+ payload = await request.json()
199
+ name, value = payload["name"], payload["value"]
200
+ except Exception:
201
+ return JSONResponse({"error": "expected {name, value}"}, status_code=400)
202
+ if name not in SECRETS:
203
+ return JSONResponse({"error": "not a declared secret"}, status_code=400)
204
+ if not isinstance(value, str) or not value:
205
+ return JSONResponse({"error": "empty value"}, status_code=400)
206
+ env_write(name, value)
207
+ return JSONResponse({"declared": SECRETS, "present": env_present()})
208
+
209
+ return Route("/env", endpoint, methods=["GET", "POST"])`;
210
+ return `
211
+
212
+ def web_dist() -> "Path | None":
213
+ """The built page: the wheel force-includes \`${WEB_DIR}/dist\` here, a checkout has it in place."""
214
+ package = Path(__file__).resolve().parent.parent
215
+ for candidate in (package / "${WEB_DIR}", package.parent.parent / "${WEB_DIR}" / "dist"):
216
+ if candidate.is_dir():
217
+ return candidate
218
+ return None
219
+
220
+
221
+ def web_route() -> "Mount | Route":
222
+ """Everything that is not an API route is the page, or the hint that it was never built."""
223
+ directory = web_dist()
224
+ if directory is not None:
225
+ return Mount("/", app=StaticFiles(directory=directory, html=True))
226
+
227
+ async def missing(request: Any) -> PlainTextResponse:
228
+ return PlainTextResponse("no ${WEB_DIR}/dist; run \`npm -C ${WEB_DIR} run build\`\\n", status_code=404)
229
+
230
+ return Route("/{path:path}", missing)${env}
231
+ `;
232
+ }
233
+ /**
234
+ * The `web` operation, generated with the `web` surface: it reaches every surface at once
235
+ * (MCP `tools/call`, the skill, the CLI, the page itself) without a socket inside the stdio
236
+ * server — the kernel's own HTTP listener runs in a detached child, which is also what opens
237
+ * the browser, so the page appears on the machine running the tool and no host has to open a
238
+ * URL a tool returns.
239
+ */
240
+ function webTemplate() {
241
+ return `${HEADER}"""The \`web\` operation: start the kernel's HTTP listener and hand back its URL."""
242
+
243
+ import re
244
+ import subprocess
245
+ import sys
246
+ from typing import Any
247
+ from urllib.parse import urlsplit
248
+
249
+ from pydantic import BaseModel
250
+
251
+ from .types import Context, Operation
252
+
253
+ SERVING = re.compile(r"Serving MCP streamable HTTP on (\\S+)")
254
+
255
+
256
+ class WebInput(BaseModel):
257
+ pass
258
+
259
+
260
+ class WebOutput(BaseModel):
261
+ url: str
262
+
263
+
264
+ def web(arguments: WebInput, context: Context) -> WebOutput:
265
+ child: Any = subprocess.Popen(
266
+ [sys.executable, "-m", f"{__package__}.mcp", "--http", "0", "--open"],
267
+ stdout=subprocess.DEVNULL,
268
+ stderr=subprocess.PIPE,
269
+ text=True,
270
+ # The listener outlives this call, and this process: an operation may not hold a socket.
271
+ start_new_session=True,
272
+ )
273
+ for line in child.stderr:
274
+ found = SERVING.search(line)
275
+ if found:
276
+ served = urlsplit(found.group(1))
277
+ # The page, not the endpoint, and without the token the child already handed the
278
+ # browser: an operation's result travels through an agent, and a token is a credential.
279
+ return WebOutput(url=f"{served.scheme}://{served.netloc}/")
280
+ raise RuntimeError("the kernel exited before it started serving")
281
+
282
+
283
+ OPERATION = Operation(
284
+ name="web",
285
+ description=(
286
+ "Open this tool's web app: serves the operations page and the MCP endpoint on a free "
287
+ "local port, opens a browser there, and returns the URL."
288
+ ),
289
+ input=WebInput,
290
+ output=WebOutput,
291
+ requires=("shell", "net"),
292
+ annotations={"openWorldHint": False},
293
+ handler=web,
294
+ )
295
+ `;
296
+ }
297
+ function mcpTemplate(project) {
298
+ const tokenEnv = `${envName(project.identity.name)}_MCP_TOKEN`;
299
+ const web = has(project, "web");
300
+ const declared = web ? secretsOf(project) : [];
301
+ return `${HEADER}"""The kernel MCP server: every operation registered from its pydantic input model.
302
+
303
+ Serves over stdio by default; \`serve_http()\` (or the CLI's \`mcp --http [port]\`) serves the
304
+ same server over MCP streamable HTTP instead: one Starlette app — the SDK's own streamable-HTTP
305
+ route${web ? ", the built web app" : ""}${declared.length ? " and `/env`" : ""} — under uvicorn.${web
306
+ ? `
307
+ The page is served beside the endpoint so it POSTs same-origin, and \`--open\` is the whole launch.`
308
+ : ""}
309
+ """
310
+
311
+ import argparse
312
+ import hashlib
313
+ import hmac
314
+ import inspect
315
+ import os
316
+ import secrets
317
+ import sys${web
318
+ ? `
319
+ import webbrowser`
320
+ : ""}
321
+ from pathlib import Path
322
+ from typing import Annotated, Any
323
+
324
+ from mcp.server.mcpserver import MCPServer${web
325
+ ? `
326
+ from starlette.responses import JSONResponse, PlainTextResponse
327
+ from starlette.routing import Mount, Route
328
+ from starlette.staticfiles import StaticFiles`
329
+ : ""}
330
+
331
+ from ..ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}
332
+ from .config import context, data_dir
333
+ from .types import Operation, serves${web
334
+ ? `
335
+ from .web import OPERATION as WEB_OPERATION
336
+
337
+ #: The authored operations plus the generated \`web\` one; \`introspect\` snapshots this list.
338
+ OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]`
339
+ : ""}
340
+
341
+ server = MCPServer(
342
+ name=${JSON.stringify(project.identity.name)},
343
+ version=${JSON.stringify(project.identity.version ?? "0.0.0")},
344
+ description=${JSON.stringify(project.identity.description ?? project.identity.name)},
345
+ )
346
+
347
+
348
+ def bind(operation: Operation) -> Any:
349
+ """Give the handler the signature of its input model, so the SDK publishes that schema."""
350
+ parameters = [
351
+ inspect.Parameter(
352
+ name,
353
+ inspect.Parameter.KEYWORD_ONLY,
354
+ annotation=Annotated[spec.annotation, spec],
355
+ default=inspect.Parameter.empty if spec.is_required() else spec.default,
356
+ )
357
+ for name, spec in operation.input.model_fields.items()
358
+ ]
359
+
360
+ async def call(**arguments: Any) -> Any:
361
+ result = operation.handler(operation.input(**arguments), context())
362
+ return await result if inspect.isawaitable(result) else result
363
+
364
+ call.__name__ = operation.name
365
+ call.__signature__ = inspect.Signature(
366
+ parameters,
367
+ return_annotation=operation.output if operation.output else inspect.Signature.empty,
368
+ )
369
+ return call
370
+
371
+
372
+ #: toolfactory introspects with every operation visible; a served instance lists only what MCP can run.
373
+ INTROSPECTING = bool(os.environ.get("TOOLFACTORY_INTROSPECT"))
374
+
375
+ for operation in OPERATIONS:
376
+ if not INTROSPECTING and not serves(operation, "mcp"):
377
+ continue
378
+ server.add_tool(
379
+ bind(operation),
380
+ name=operation.name,
381
+ description=operation.description,
382
+ annotations=operation.annotations or None,
383
+ meta={"dev.toolfactory": {"requires": list(operation.requires)}},
384
+ )
385
+
386
+
387
+ def serve() -> None:
388
+ server.run("stdio")
389
+
390
+
391
+ #: The pairing token, so a browser extension (or anything else on loopback) can prove it is the
392
+ #: client the user paired: the endpoint requires \`Authorization: Bearer <token>\` when
393
+ #: ${tokenEnv} is set or \`mcp --http --pair\` has written one under the data directory. With
394
+ #: neither, nothing changes and the endpoint stays open on loopback.
395
+ TOKEN_FILE = "relay-token"
396
+
397
+
398
+ def token_path() -> Path:
399
+ return data_dir() / TOKEN_FILE
400
+
401
+
402
+ def paired_token() -> str | None:
403
+ configured = os.environ.get(${JSON.stringify(tokenEnv)})
404
+ if configured:
405
+ return configured
406
+ try:
407
+ return token_path().read_text().strip() or None
408
+ except OSError:
409
+ return None
410
+
411
+
412
+ def mint_token() -> str:
413
+ """Mint a token and keep it 0600 under the data directory. Never rotates an existing one."""
414
+ token = secrets.token_urlsafe(32)
415
+ path = token_path()
416
+ path.parent.mkdir(parents=True, exist_ok=True)
417
+ path.write_text(f"{token}\\n")
418
+ path.chmod(0o600)
419
+ return token
420
+
421
+
422
+ def guard(app: Any, token: str, paths: "set[str]") -> Any:
423
+ """Wrap the ASGI app: no \`Authorization: Bearer <token>\`, no request — on the API paths only.
424
+ The page itself is never guarded: a browser cannot put a header on its own document request."""
425
+ expected = hashlib.sha256(token.encode()).digest()
426
+
427
+ async def guarded(scope: Any, receive: Any, send: Any) -> None:
428
+ if scope["type"] == "http" and scope["path"] in paths:
429
+ presented = dict(scope["headers"]).get(b"authorization", b"").removeprefix(b"Bearer ")
430
+ if not hmac.compare_digest(hashlib.sha256(presented).digest(), expected):
431
+ await send(
432
+ {
433
+ "type": "http.response.start",
434
+ "status": 401,
435
+ "headers": [(b"www-authenticate", b"Bearer")],
436
+ }
437
+ )
438
+ await send({"type": "http.response.body", "body": b""})
439
+ return
440
+ await app(scope, receive, send)
441
+
442
+ return guarded
443
+ ${web ? webRoutes(declared) : ""}
444
+
445
+ def serve_http(
446
+ host: str = "127.0.0.1",
447
+ port: int = 3000,
448
+ path: str = "/mcp",
449
+ pair: bool = False,${web
450
+ ? `
451
+ open_browser: bool = False,`
452
+ : ""}
453
+ ) -> None:
454
+ """Serve over MCP streamable HTTP instead of stdio. Opt-in; stdio stays the default transport."""
455
+ import uvicorn
456
+ ${web
457
+ ? `
458
+ # \`--open\` needs a token for the page to call back with; an existing one is never rotated,
459
+ # because the browser extension holds it.
460
+ token = mint_token() if pair else (paired_token() or (mint_token() if open_browser else None))`
461
+ : `
462
+ token = mint_token() if pair else paired_token()`}
463
+ # One ASGI app: the SDK's own streamable-HTTP route (its session-manager lifespan with it),
464
+ # plus ours. Appending to its router is what keeps that lifespan the app's own.
465
+ app = server.streamable_http_app(streamable_http_path=path, stateless_http=True, host=host)${web
466
+ ? `${declared.length ? "\n app.router.routes.append(env_route(token))" : ""}
467
+ app.router.routes.append(web_route())`
468
+ : ""}
469
+ if token is not None:
470
+ app = guard(app, token, {path${web && declared.length ? ', "/env"' : ""}})
471
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
472
+ # Bind before serving: \`--http 0\` asks the OS for a free port and the URL is only known then.
473
+ sock = config.bind_socket()
474
+ base = f"http://{host}:{sock.getsockname()[1]}"
475
+ if pair:
476
+ print(f"{base}{path}#{token}", flush=True)
477
+ suffix = " (bearer token required)" if token else ""
478
+ print(f"Serving MCP streamable HTTP on {base}{path}{suffix}", file=sys.stderr, flush=True)${web
479
+ ? `
480
+ if open_browser:
481
+ webbrowser.open(f"{base}/#{token}")`
482
+ : ""}
483
+ uvicorn.Server(config).run(sockets=[sock])
484
+
485
+
486
+ def main() -> None:
487
+ parser = argparse.ArgumentParser()
488
+ parser.add_argument(
489
+ "--http",
490
+ nargs="?",
491
+ const=3000,
492
+ type=int,
493
+ default=None,
494
+ metavar="PORT",
495
+ help="serve over MCP streamable HTTP instead of stdio (default port 3000, host 127.0.0.1, path /mcp)",
496
+ )
497
+ parser.add_argument(
498
+ "--pair",
499
+ action="store_true",
500
+ help="serve over HTTP with a fresh pairing token: prints <url>#<token> and requires it as a bearer token",
501
+ )${web
502
+ ? `
503
+ parser.add_argument(
504
+ "--open",
505
+ action="store_true",
506
+ help="serve over HTTP and open the web app in this machine's browser",
507
+ )`
508
+ : ""}
509
+ args = parser.parse_args()
510
+ if args.http is None and not args.pair${web ? " and not args.open" : ""}:
511
+ serve()
512
+ else:
513
+ # \`--http 0\` is a port, not a missing one: only \`None\` means "use the default".
514
+ serve_http(port=3000 if args.http is None else args.http, pair=args.pair${web ? ", open_browser=args.open" : ""})
515
+
516
+
517
+ if __name__ == "__main__":
518
+ main()
519
+ `;
520
+ }
521
+ function cliTemplate(project) {
522
+ const web = has(project, "web");
523
+ const mcpCommand = has(project, "mcp")
524
+ ? `
525
+ mcp = subcommands.add_parser(
526
+ "mcp", help="Serve the operations as an MCP server over stdio, or over streamable HTTP with --http"
527
+ )
528
+ mcp.add_argument(
529
+ "--http",
530
+ nargs="?",
531
+ const=3000,
532
+ type=int,
533
+ default=None,
534
+ metavar="PORT",
535
+ help="serve over MCP streamable HTTP instead of stdio (default port 3000, host 127.0.0.1, path /mcp)",
536
+ )
537
+ mcp.add_argument(
538
+ "--pair",
539
+ action="store_true",
540
+ help="serve over HTTP with a fresh pairing token: prints <url>#<token> and requires it as a bearer token",
541
+ )${web
542
+ ? `
543
+ mcp.add_argument(
544
+ "--open",
545
+ action="store_true",
546
+ help="serve over HTTP and open the web app in this machine's browser",
547
+ )`
548
+ : ""}
549
+ mcp.set_defaults(tf_operation=None, tf_schema={})
550
+ `
551
+ : "";
552
+ const mcpDispatch = has(project, "mcp")
553
+ ? ` if options.tf_operation is None:
554
+ if options.http is not None or options.pair${web ? " or options.open" : ""}:
555
+ from .mcp import serve_http
556
+
557
+ serve_http(port=3000 if options.http is None else options.http, pair=options.pair${web ? ", open_browser=options.open" : ""})
558
+ else:
559
+ from .mcp import serve
560
+
561
+ serve()
562
+ return
563
+ `
564
+ : "";
565
+ return `${HEADER}"""The kernel CLI: one subcommand per operation, built from its input model's schema."""
566
+
567
+ import argparse
568
+ import asyncio
569
+ import inspect
570
+ import json
571
+ from typing import Any
572
+
573
+ from pydantic import BaseModel
574
+
575
+ from ..ops import OPERATIONS${web ? " as AUTHORED_OPERATIONS" : ""}
576
+ from .types import serves
577
+ from .config import context${web
578
+ ? `
579
+ from .web import OPERATION as WEB_OPERATION
580
+
581
+ #: The authored operations plus the generated \`web\` one, exactly as the MCP kernel lists them.
582
+ OPERATIONS = [*AUTHORED_OPERATIONS, WEB_OPERATION]`
583
+ : ""}
584
+
585
+
586
+ def kind(schema: dict[str, Any]) -> str:
587
+ """The JSON Schema type of a property, looking through the optional/nullable union."""
588
+ if "type" in schema:
589
+ return str(schema["type"])
590
+ for variant in schema.get("anyOf", []):
591
+ if variant.get("type") not in (None, "null"):
592
+ return str(variant["type"])
593
+ return "string"
594
+
595
+
596
+ def coerce(value: str, of: str) -> Any:
597
+ if of in ("integer", "number"):
598
+ return int(value) if of == "integer" else float(value)
599
+ if of == "array":
600
+ return [item.strip() for item in value.split(",") if item.strip()]
601
+ if of == "object":
602
+ return json.loads(value)
603
+ return value
604
+
605
+
606
+ def parser() -> argparse.ArgumentParser:
607
+ root = argparse.ArgumentParser(
608
+ prog=${JSON.stringify(project.identity.name)},
609
+ description=${JSON.stringify(project.identity.description ?? project.identity.name)},
610
+ )
611
+ subcommands = root.add_subparsers(dest="tf_command", required=True)
612
+ for operation in OPERATIONS:
613
+ if not serves(operation, "cli"):
614
+ continue
615
+ schema = operation.input.model_json_schema()
616
+ command = subcommands.add_parser(operation.name, help=operation.description)
617
+ command.add_argument(
618
+ "--json", dest="tf_json", metavar="JSON", help="all arguments as one JSON object"
619
+ )
620
+ for name, property in (schema.get("properties") or {}).items():
621
+ of = kind(property)
622
+ hint = {"array": " (comma-separated)", "object": " (JSON)"}.get(of, "")
623
+ help_text = f"{property.get('description', name)}{hint}"
624
+ if of == "boolean":
625
+ command.add_argument(f"--{name}", action="store_true", default=None, help=help_text)
626
+ else:
627
+ command.add_argument(f"--{name}", metavar="VALUE", help=help_text)
628
+ command.set_defaults(tf_operation=operation, tf_schema=schema)
629
+ ${mcpCommand} return root
630
+
631
+
632
+ def main(argv: list[str] | None = None) -> None:
633
+ options = parser().parse_args(argv)
634
+ ${mcpDispatch} operation = options.tf_operation
635
+ properties: dict[str, Any] = options.tf_schema.get("properties") or {}
636
+ values: dict[str, Any] = json.loads(options.tf_json) if options.tf_json else {}
637
+ for name, property in properties.items():
638
+ given = getattr(options, name, None)
639
+ if given is not None:
640
+ values[name] = given if isinstance(given, bool) else coerce(given, kind(property))
641
+ result = operation.handler(operation.input(**values), context())
642
+ if inspect.isawaitable(result):
643
+ result = asyncio.run(result)
644
+ payload = result.model_dump(mode="json") if isinstance(result, BaseModel) else result
645
+ print(json.dumps(payload, indent=2, default=str))
646
+
647
+
648
+ if __name__ == "__main__":
649
+ main()
650
+ `;
651
+ }
652
+ function opsScaffold() {
653
+ return `"""Your tool's operations: pure functions of (arguments, context.config, filesystem).
654
+
655
+ Declare \`requires\` for anything a host must provide; toolfactory decides per surface
656
+ whether that operation is native, bridged, degraded, or excluded.
657
+ """
658
+
659
+ from pydantic import BaseModel, Field
660
+
661
+ from .toolfactory import Context, Operation
662
+
663
+
664
+ class EchoInput(BaseModel):
665
+ text: str = Field(description="Text to echo back")
666
+
667
+
668
+ class EchoOutput(BaseModel):
669
+ text: str
670
+
671
+
672
+ def echo(arguments: EchoInput, context: Context) -> EchoOutput:
673
+ return EchoOutput(text=arguments.text)
674
+
675
+
676
+ OPERATIONS = [
677
+ Operation(
678
+ name="echo",
679
+ description="Return the text you pass in.",
680
+ input=EchoInput,
681
+ output=EchoOutput,
682
+ annotations={"readOnlyHint": True},
683
+ handler=echo,
684
+ ),
685
+ ]
686
+ `;
687
+ }
688
+ /**
689
+ * The T4 live tier: real calls against the real service, kept out of the default `pytest` run
690
+ * by a generated guard over the credentials `tool.json` declares as required and sensitive.
691
+ * The markers hold only the guard; the example test around them is the author's.
692
+ */
693
+ export function liveTest(project) {
694
+ const credentials = liveCredentials(project).map(envName);
695
+ const example = liveExample(project);
696
+ if (credentials.length === 0 || !example)
697
+ return [];
698
+ const tuple = `(${credentials.map((name) => JSON.stringify(name)).join(", ")}${credentials.length === 1 ? "," : ""})`;
699
+ const guard = `
700
+ CREDENTIALS = ${tuple}
701
+ LIVE = all(os.environ.get(name) for name in CREDENTIALS)
702
+ `;
703
+ const pkg = pythonPackage(project);
704
+ const template = `"""Live tests: real calls against the real service, so they run only when the credentials
705
+ (${credentials.join(", ")}) are in the environment — a plain \`pytest\` run skips them.
706
+
707
+ Locally: copy .env.example to .env, fill it in, then
708
+ ${LIVE_TEST_COMMAND.replace("uv run ", "uv run --env-file .env ")}
709
+ In CI: the \`live\` job of .github/workflows/ci.yml, from the \`live-tests\` environment.
710
+ """
711
+
712
+ import asyncio
713
+ import inspect
714
+ import os
715
+
716
+ import pytest
717
+
718
+ from ${pkg}.ops import OPERATIONS
719
+ from ${pkg}.toolfactory.config import context
720
+
721
+ ${LIVE_GUARD_BEGIN}${LIVE_GUARD_END}
722
+
723
+
724
+ @pytest.mark.skipif(not LIVE, reason=f"live credentials are not set: {', '.join(CREDENTIALS)}")
725
+ def test_${example.name.replace(/[^A-Za-z0-9_]/g, "_")}() -> None:
726
+ operation = next(op for op in OPERATIONS if op.name == ${JSON.stringify(example.name)})
727
+ result = operation.handler(
728
+ operation.input.model_validate_json(${JSON.stringify(JSON.stringify(example.args))}), context()
729
+ )
730
+ if inspect.isawaitable(result):
731
+ result = asyncio.run(result)
732
+ # Replace this with the assertion that proves the real service answered correctly.
733
+ assert result is not None
734
+ `;
735
+ return [
736
+ {
737
+ kind: "region",
738
+ path: LIVE_TEST_PATH,
739
+ regions: [{ begin: LIVE_GUARD_BEGIN, end: LIVE_GUARD_END, content: guard }],
740
+ template,
741
+ },
742
+ ];
743
+ }
744
+ /** Generated on every build. */
745
+ export function kernel(project) {
746
+ const dir = kernelDir(project);
747
+ const files = [
748
+ { kind: "file", path: `${dir}/__init__.py`, content: initTemplate() },
749
+ { kind: "file", path: `${dir}/types.py`, content: typesTemplate() },
750
+ { kind: "file", path: `${dir}/config.py`, content: configTemplate(project) },
751
+ ];
752
+ files.push({ kind: "file", path: `${dir}/mcp.py`, content: mcpTemplate(project) });
753
+ // The `web` surface's own operation: generated here because it is the kernel's listener it
754
+ // starts, and appended to `OPERATIONS` by the two templates above, so `introspect` sees it.
755
+ if (has(project, "web")) {
756
+ files.push({ kind: "file", path: `${dir}/web.py`, content: webTemplate() });
757
+ }
758
+ return files;
759
+ }
760
+ /** The CLI surface's file. */
761
+ export function cli(project) {
762
+ return [{ kind: "file", path: `${kernelDir(project)}/cli.py`, content: cliTemplate(project) }];
763
+ }
764
+ /** The `[project]` table the scaffold and the pypi surface agree on. */
765
+ export function projectTable(project) {
766
+ const { identity } = project;
767
+ return compact({
768
+ name: pypiName(project),
769
+ version: identity.version,
770
+ description: identity.description,
771
+ license: identity.license,
772
+ keywords: identity.keywords,
773
+ authors: identity.author?.name
774
+ ? [compact({ name: identity.author.name, email: identity.author.email })]
775
+ : undefined,
776
+ urls: identity.homepage || identity.repository
777
+ ? compact({ Homepage: identity.homepage, Repository: identity.repository })
778
+ : undefined,
779
+ });
780
+ }
781
+ /** Written by `init` only when absent; owned by the author afterwards. */
782
+ export function scaffold(project) {
783
+ const pkg = pythonPackage(project);
784
+ const pyproject = {
785
+ project: {
786
+ ...projectTable(project),
787
+ "requires-python": ">=3.11",
788
+ dependencies: ["mcp>=2,<3", "pydantic>=2"],
789
+ ...(has(project, "cli")
790
+ ? { scripts: { [project.identity.name]: cliEntryPoint(project) } }
791
+ : {}),
792
+ },
793
+ "build-system": { requires: ["hatchling"], "build-backend": "hatchling.build" },
794
+ tool: { hatch: { build: { targets: { wheel: { packages: [`src/${pkg}`] } } } } },
795
+ };
796
+ return [
797
+ {
798
+ kind: "file",
799
+ path: `src/${pkg}/__init__.py`,
800
+ content: `"""${project.identity.description ?? project.identity.name}"""\n`,
801
+ },
802
+ { kind: "file", path: opsPath(project), content: opsScaffold() },
803
+ { kind: "file", path: "pyproject.toml", content: `${toml(pyproject)}\n` },
804
+ ];
805
+ }