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.
- package/README.md +166 -0
- package/dist/bindings/index.d.ts +23 -0
- package/dist/bindings/index.js +29 -0
- package/dist/bindings/python.d.ts +33 -0
- package/dist/bindings/python.js +805 -0
- package/dist/bindings/python.test.d.ts +1 -0
- package/dist/bindings/python.test.js +316 -0
- package/dist/bindings/typescript.d.ts +39 -0
- package/dist/bindings/typescript.js +758 -0
- package/dist/bindings/typescript.test.d.ts +1 -0
- package/dist/bindings/typescript.test.js +264 -0
- package/dist/commands.d.ts +188 -0
- package/dist/commands.js +662 -0
- package/dist/commands.test.d.ts +1 -0
- package/dist/commands.test.js +215 -0
- package/dist/e2e.test.d.ts +1 -0
- package/dist/e2e.test.js +124 -0
- package/dist/hosts/browser.d.ts +10 -0
- package/dist/hosts/browser.js +94 -0
- package/dist/hosts/github.d.ts +66 -0
- package/dist/hosts/github.js +247 -0
- package/dist/hosts/openclaw.d.ts +4 -0
- package/dist/hosts/openclaw.js +94 -0
- package/dist/hosts/template.d.ts +15 -0
- package/dist/hosts/template.js +142 -0
- package/dist/hosts/web.d.ts +4 -0
- package/dist/hosts/web.js +89 -0
- package/dist/identity/name.d.ts +20 -0
- package/dist/identity/name.js +33 -0
- package/dist/identity/name.test.d.ts +1 -0
- package/dist/identity/name.test.js +37 -0
- package/dist/identity/read.d.ts +9 -0
- package/dist/identity/read.js +106 -0
- package/dist/identity/read.test.d.ts +1 -0
- package/dist/identity/read.test.js +76 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/introspect/index.d.ts +15 -0
- package/dist/introspect/index.js +63 -0
- package/dist/model.d.ts +236 -0
- package/dist/model.js +214 -0
- package/dist/ops.d.ts +255 -0
- package/dist/ops.js +297 -0
- package/dist/project/apply.d.ts +35 -0
- package/dist/project/apply.js +360 -0
- package/dist/project/apply.test.d.ts +1 -0
- package/dist/project/apply.test.js +285 -0
- package/dist/project/gate.d.ts +129 -0
- package/dist/project/gate.js +597 -0
- package/dist/project/history.d.ts +62 -0
- package/dist/project/history.js +41 -0
- package/dist/project/load.d.ts +21 -0
- package/dist/project/load.js +82 -0
- package/dist/project/lock.d.ts +28 -0
- package/dist/project/lock.js +42 -0
- package/dist/project/plan.d.ts +5 -0
- package/dist/project/plan.js +96 -0
- package/dist/report/coverage.d.ts +22 -0
- package/dist/report/coverage.js +72 -0
- package/dist/report/coverage.test.d.ts +1 -0
- package/dist/report/coverage.test.js +93 -0
- package/dist/surfaces/agent-plugins.d.ts +10 -0
- package/dist/surfaces/agent-plugins.js +36 -0
- package/dist/surfaces/agents.d.ts +35 -0
- package/dist/surfaces/agents.js +609 -0
- package/dist/surfaces/agents.template.d.ts +8 -0
- package/dist/surfaces/agents.template.js +21 -0
- package/dist/surfaces/agents.test.d.ts +1 -0
- package/dist/surfaces/agents.test.js +181 -0
- package/dist/surfaces/browser-extension.d.ts +101 -0
- package/dist/surfaces/browser-extension.js +1034 -0
- package/dist/surfaces/browser-extension.test.d.ts +1 -0
- package/dist/surfaces/browser-extension.test.js +145 -0
- package/dist/surfaces/claude.d.ts +2 -0
- package/dist/surfaces/claude.js +81 -0
- package/dist/surfaces/codex.d.ts +13 -0
- package/dist/surfaces/codex.js +101 -0
- package/dist/surfaces/codex.test.d.ts +1 -0
- package/dist/surfaces/codex.test.js +67 -0
- package/dist/surfaces/cursor.d.ts +2 -0
- package/dist/surfaces/cursor.js +50 -0
- package/dist/surfaces/distribution.test.d.ts +1 -0
- package/dist/surfaces/distribution.test.js +146 -0
- package/dist/surfaces/dsh.d.ts +25 -0
- package/dist/surfaces/dsh.js +147 -0
- package/dist/surfaces/dsh.test.d.ts +1 -0
- package/dist/surfaces/dsh.test.js +100 -0
- package/dist/surfaces/external.d.ts +9 -0
- package/dist/surfaces/external.js +4 -0
- package/dist/surfaces/gemini.d.ts +29 -0
- package/dist/surfaces/gemini.js +68 -0
- package/dist/surfaces/gemini.test.d.ts +1 -0
- package/dist/surfaces/gemini.test.js +74 -0
- package/dist/surfaces/hermes-native.d.ts +53 -0
- package/dist/surfaces/hermes-native.js +507 -0
- package/dist/surfaces/hermes-native.test.d.ts +1 -0
- package/dist/surfaces/hermes-native.test.js +156 -0
- package/dist/surfaces/kernel.d.ts +5 -0
- package/dist/surfaces/kernel.js +63 -0
- package/dist/surfaces/mcp-registry.d.ts +19 -0
- package/dist/surfaces/mcp-registry.js +160 -0
- package/dist/surfaces/mcp-registry.test.d.ts +1 -0
- package/dist/surfaces/mcp-registry.test.js +79 -0
- package/dist/surfaces/mcpb.d.ts +11 -0
- package/dist/surfaces/mcpb.js +118 -0
- package/dist/surfaces/mcpb.test.d.ts +1 -0
- package/dist/surfaces/mcpb.test.js +104 -0
- package/dist/surfaces/npm.d.ts +6 -0
- package/dist/surfaces/npm.js +72 -0
- package/dist/surfaces/openclaw-native.d.ts +93 -0
- package/dist/surfaces/openclaw-native.js +956 -0
- package/dist/surfaces/openclaw-native.test.d.ts +1 -0
- package/dist/surfaces/openclaw-native.test.js +246 -0
- package/dist/surfaces/pypi.d.ts +5 -0
- package/dist/surfaces/pypi.js +82 -0
- package/dist/surfaces/readme.d.ts +7 -0
- package/dist/surfaces/readme.js +181 -0
- package/dist/surfaces/registry.d.ts +6 -0
- package/dist/surfaces/registry.js +73 -0
- package/dist/surfaces/shared.d.ts +49 -0
- package/dist/surfaces/shared.js +112 -0
- package/dist/surfaces/skill.d.ts +7 -0
- package/dist/surfaces/skill.js +118 -0
- package/dist/surfaces/skill.test.d.ts +1 -0
- package/dist/surfaces/skill.test.js +87 -0
- package/dist/surfaces/web.d.ts +97 -0
- package/dist/surfaces/web.js +1285 -0
- package/dist/surfaces/web.test.d.ts +1 -0
- package/dist/surfaces/web.test.js +255 -0
- package/dist/surfaces/workflows.d.ts +2 -0
- package/dist/surfaces/workflows.js +809 -0
- package/dist/surfaces/workflows.test.d.ts +1 -0
- package/dist/surfaces/workflows.test.js +563 -0
- package/dist/toolfactory/cli.d.ts +2 -0
- package/dist/toolfactory/cli.js +65 -0
- package/dist/toolfactory/config.d.ts +3 -0
- package/dist/toolfactory/config.js +27 -0
- package/dist/toolfactory/mcp.d.ts +37 -0
- package/dist/toolfactory/mcp.js +269 -0
- package/dist/toolfactory/types.d.ts +31 -0
- package/dist/toolfactory/types.js +10 -0
- package/dist/toolfactory/web.d.ts +4 -0
- package/dist/toolfactory/web.js +40 -0
- package/dist/validate/agent-plugins.d.ts +5 -0
- package/dist/validate/agent-plugins.js +46 -0
- package/package.json +68 -0
- package/schemas/agent-plugins/mcp.schema.json +120 -0
- package/schemas/agent-plugins/plugin.schema.json +65 -0
- package/src/bindings/index.ts +47 -0
- package/src/bindings/python.test.ts +374 -0
- package/src/bindings/python.ts +862 -0
- package/src/bindings/typescript.test.ts +329 -0
- package/src/bindings/typescript.ts +818 -0
- package/src/commands.test.ts +253 -0
- package/src/commands.ts +886 -0
- package/src/e2e.test.ts +169 -0
- package/src/hosts/browser.ts +106 -0
- package/src/hosts/github.ts +321 -0
- package/src/hosts/openclaw.ts +110 -0
- package/src/hosts/template.ts +149 -0
- package/src/hosts/web.ts +111 -0
- package/src/identity/name.test.ts +43 -0
- package/src/identity/name.ts +39 -0
- package/src/identity/read.test.ts +92 -0
- package/src/identity/read.ts +119 -0
- package/src/index.ts +11 -0
- package/src/introspect/index.ts +72 -0
- package/src/model.ts +386 -0
- package/src/ops.ts +333 -0
- package/src/project/apply.test.ts +348 -0
- package/src/project/apply.ts +417 -0
- package/src/project/gate.ts +717 -0
- package/src/project/history.ts +46 -0
- package/src/project/load.ts +104 -0
- package/src/project/lock.ts +54 -0
- package/src/project/plan.ts +101 -0
- package/src/report/coverage.test.ts +108 -0
- package/src/report/coverage.ts +113 -0
- package/src/surfaces/agent-plugins.ts +47 -0
- package/src/surfaces/agents.template.ts +32 -0
- package/src/surfaces/agents.test.ts +206 -0
- package/src/surfaces/agents.ts +729 -0
- package/src/surfaces/browser-extension.test.ts +175 -0
- package/src/surfaces/browser-extension.ts +1095 -0
- package/src/surfaces/claude.ts +99 -0
- package/src/surfaces/codex.test.ts +71 -0
- package/src/surfaces/codex.ts +105 -0
- package/src/surfaces/cursor.ts +62 -0
- package/src/surfaces/distribution.test.ts +166 -0
- package/src/surfaces/dsh.test.ts +110 -0
- package/src/surfaces/dsh.ts +159 -0
- package/src/surfaces/external.ts +13 -0
- package/src/surfaces/gemini.test.ts +80 -0
- package/src/surfaces/gemini.ts +84 -0
- package/src/surfaces/hermes-native.test.ts +179 -0
- package/src/surfaces/hermes-native.ts +584 -0
- package/src/surfaces/kernel.ts +68 -0
- package/src/surfaces/mcp-registry.test.ts +99 -0
- package/src/surfaces/mcp-registry.ts +190 -0
- package/src/surfaces/mcpb.test.ts +112 -0
- package/src/surfaces/mcpb.ts +130 -0
- package/src/surfaces/npm.ts +78 -0
- package/src/surfaces/openclaw-native.test.ts +283 -0
- package/src/surfaces/openclaw-native.ts +1070 -0
- package/src/surfaces/pypi.ts +84 -0
- package/src/surfaces/readme.ts +218 -0
- package/src/surfaces/registry.ts +79 -0
- package/src/surfaces/shared.ts +140 -0
- package/src/surfaces/skill.test.ts +103 -0
- package/src/surfaces/skill.ts +136 -0
- package/src/surfaces/web.test.ts +303 -0
- package/src/surfaces/web.ts +1310 -0
- package/src/surfaces/workflows.test.ts +677 -0
- package/src/surfaces/workflows.ts +936 -0
- package/src/toolfactory/cli.ts +74 -0
- package/src/toolfactory/config.ts +33 -0
- package/src/toolfactory/mcp.ts +311 -0
- package/src/toolfactory/types.ts +52 -0
- package/src/toolfactory/web.ts +45 -0
- package/src/validate/agent-plugins.ts +53 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-CCXhQKK3.css +2 -0
- package/web/dist/assets/index-D1PwZ3qS.js +10 -0
- 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
|
+
}
|