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