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,758 @@
|
|
|
1
|
+
import { secretsOf } from "../project/gate.js";
|
|
2
|
+
import { configProperties, dataDirEnvName, envName, has, liveCredentials, liveExample, npmName, } from "../surfaces/shared.js";
|
|
3
|
+
import { WEB_DIR } from "../surfaces/web.js";
|
|
4
|
+
export const KERNEL_DIR = "src/toolfactory";
|
|
5
|
+
export const OPS_PATH = "src/ops.ts";
|
|
6
|
+
export const LIVE_TEST_PATH = "tests/live.test.ts";
|
|
7
|
+
/**
|
|
8
|
+
* The scaffold script the `live` CI job and the author both run (§6 T4). Node's own
|
|
9
|
+
* `--env-file`, so no dotenv dependency (vitest does not put `.env` into `process.env`);
|
|
10
|
+
* `-if-exists` so CI, which injects the same names from the `live-tests` environment rather
|
|
11
|
+
* than a file, runs the same script; vitest's own entry file rather than `node_modules/.bin`,
|
|
12
|
+
* which is a shell shim under pnpm and cannot be handed to `node`.
|
|
13
|
+
*/
|
|
14
|
+
export const LIVE_TEST_SCRIPT = `node --env-file-if-exists=.env node_modules/vitest/vitest.mjs run ${LIVE_TEST_PATH}`;
|
|
15
|
+
const LIVE_GUARD_BEGIN = "// tf:live-guard";
|
|
16
|
+
const LIVE_GUARD_END = "// /tf:live-guard";
|
|
17
|
+
export function kernelCommand() {
|
|
18
|
+
return { command: "node", args: ["--import", "tsx", `${KERNEL_DIR}/mcp.ts`] };
|
|
19
|
+
}
|
|
20
|
+
export function cliCommand() {
|
|
21
|
+
return { command: "node", args: ["--import", "tsx", `${KERNEL_DIR}/cli.ts`] };
|
|
22
|
+
}
|
|
23
|
+
const HEADER = "// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.\n";
|
|
24
|
+
function typesTemplate() {
|
|
25
|
+
return `${HEADER}import type { z } from "zod";
|
|
26
|
+
|
|
27
|
+
/** Host capabilities an operation may declare. Portable: net, fs, shell, secret. */
|
|
28
|
+
export type Capability =
|
|
29
|
+
| "net"
|
|
30
|
+
| "fs"
|
|
31
|
+
| "shell"
|
|
32
|
+
| "secret"
|
|
33
|
+
| "browser"
|
|
34
|
+
| "model"
|
|
35
|
+
| "user-input"
|
|
36
|
+
| "channel";
|
|
37
|
+
|
|
38
|
+
export interface Context {
|
|
39
|
+
/** Configuration values read from the environment, keyed as declared in dev.toolfactory/tool.json. */
|
|
40
|
+
config: Record<string, string | undefined>;
|
|
41
|
+
/** Directory this tool may keep its own state in. Created by the tool, not by the kernel. */
|
|
42
|
+
dataDir: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Operation<Input extends z.ZodObject = z.ZodObject, Output extends z.ZodType = z.ZodType> {
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
48
|
+
input: Input;
|
|
49
|
+
output?: Output;
|
|
50
|
+
requires?: Capability[];
|
|
51
|
+
annotations?: {
|
|
52
|
+
readOnlyHint?: boolean;
|
|
53
|
+
destructiveHint?: boolean;
|
|
54
|
+
idempotentHint?: boolean;
|
|
55
|
+
openWorldHint?: boolean;
|
|
56
|
+
};
|
|
57
|
+
handler: (args: z.infer<Input>, context: Context) => Promise<z.infer<Output>> | z.infer<Output>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Identity function that pins the generic parameters so handlers are typed. */
|
|
61
|
+
export function operation<Input extends z.ZodObject, Output extends z.ZodType>(
|
|
62
|
+
definition: Operation<Input, Output>,
|
|
63
|
+
): Operation<Input, Output> {
|
|
64
|
+
return definition;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capabilities every surface can satisfy on its own. */
|
|
68
|
+
export const PORTABLE: ReadonlySet<Capability> = new Set(["net", "fs", "shell", "secret"]);
|
|
69
|
+
|
|
70
|
+
/** Whether a surface can run an operation: MCP needs a portable one; a CLI also has a human for user-input. */
|
|
71
|
+
export function serves(operation: { requires?: Capability[] }, surface: "mcp" | "cli"): boolean {
|
|
72
|
+
return (operation.requires ?? []).every(
|
|
73
|
+
(capability) => PORTABLE.has(capability) || (surface === "cli" && capability === "user-input"),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
function configTemplate(project) {
|
|
79
|
+
const keys = Object.keys(configProperties(project));
|
|
80
|
+
const entries = keys.map((key) => ` ${JSON.stringify(key)}: process.env[${JSON.stringify(envName(key))}],`);
|
|
81
|
+
const name = project.identity.name;
|
|
82
|
+
const dataDirEnv = dataDirEnvName(project);
|
|
83
|
+
return `${HEADER}import { homedir } from "node:os";
|
|
84
|
+
import { join } from "node:path";
|
|
85
|
+
import type { Context } from "./types.js";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Where this tool may keep its own state: ${dataDirEnv} when a host sets it, else the
|
|
89
|
+
* platform's own data location (XDG on POSIX, %LOCALAPPDATA% on Windows).
|
|
90
|
+
*/
|
|
91
|
+
function dataDir(): string {
|
|
92
|
+
// Hosts that launch the kernel export their own name for it: Agent Plugins clients and
|
|
93
|
+
// OpenClaw set PLUGIN_DATA, Claude Code sets CLAUDE_PLUGIN_DATA.
|
|
94
|
+
const configured =
|
|
95
|
+
process.env[${JSON.stringify(dataDirEnv)}] ??
|
|
96
|
+
process.env.PLUGIN_DATA ??
|
|
97
|
+
process.env.CLAUDE_PLUGIN_DATA;
|
|
98
|
+
if (configured) return configured;
|
|
99
|
+
const base =
|
|
100
|
+
process.platform === "win32"
|
|
101
|
+
? (process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"))
|
|
102
|
+
: (process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"));
|
|
103
|
+
return join(base, ${JSON.stringify(name)});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Configuration is read from the environment; hosts inject it from their own settings UI. */
|
|
107
|
+
export function context(): Context {
|
|
108
|
+
return {
|
|
109
|
+
config: {
|
|
110
|
+
${entries.join("\n")}
|
|
111
|
+
},
|
|
112
|
+
dataDir: dataDir(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The T4 live tier: real calls against the real service, kept out of the default `vitest run`
|
|
119
|
+
* by a generated guard over the credentials `tool.json` declares as required and sensitive.
|
|
120
|
+
* The markers hold only the guard; the example test around them is the author's.
|
|
121
|
+
*/
|
|
122
|
+
export function liveTest(project) {
|
|
123
|
+
const credentials = liveCredentials(project).map(envName);
|
|
124
|
+
const example = liveExample(project);
|
|
125
|
+
if (credentials.length === 0 || !example)
|
|
126
|
+
return [];
|
|
127
|
+
const guard = `
|
|
128
|
+
const CREDENTIALS = ${JSON.stringify(credentials)};
|
|
129
|
+
const live = CREDENTIALS.every((name) => process.env[name]);
|
|
130
|
+
`;
|
|
131
|
+
const template = `/**
|
|
132
|
+
* Live tests: real calls against the real service, so they run only when the credentials
|
|
133
|
+
* (${credentials.join(", ")}) are in the environment — \`npm test\` skips them.
|
|
134
|
+
*
|
|
135
|
+
* Locally: copy .env.example to .env, fill it in, then \`npm run test:live\`.
|
|
136
|
+
* In CI: the \`live\` job of .github/workflows/ci.yml, from the \`live-tests\` environment.
|
|
137
|
+
*/
|
|
138
|
+
import { describe, expect, it } from "vitest";
|
|
139
|
+
import { operations } from "../src/ops.js";
|
|
140
|
+
import { context } from "../src/toolfactory/config.js";
|
|
141
|
+
|
|
142
|
+
${LIVE_GUARD_BEGIN}${LIVE_GUARD_END}
|
|
143
|
+
|
|
144
|
+
describe.skipIf(!live)(${JSON.stringify(`${project.identity.name} against the real service`)}, () => {
|
|
145
|
+
it(${JSON.stringify(`${example.name} answers`)}, async () => {
|
|
146
|
+
const operation = operations.find((candidate) => candidate.name === ${JSON.stringify(example.name)});
|
|
147
|
+
if (!operation) throw new Error(${JSON.stringify(`no operation named ${example.name}; point this test at another one`)});
|
|
148
|
+
const result = await operation.handler(${JSON.stringify(example.args)} as never, context());
|
|
149
|
+
// Replace this with the assertion that proves the real service answered correctly.
|
|
150
|
+
expect(result).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
`;
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
kind: "region",
|
|
157
|
+
path: LIVE_TEST_PATH,
|
|
158
|
+
regions: [{ begin: LIVE_GUARD_BEGIN, end: LIVE_GUARD_END, content: guard }],
|
|
159
|
+
template,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* What the kernel adds when the `web` surface is selected: the static half of the router (the
|
|
165
|
+
* built page under `web/dist`), the OS opener `--open` shells out to, and — only when the
|
|
166
|
+
* project declares a secret — the `/env` route the page's Secrets panel reads and writes.
|
|
167
|
+
* `.env` is the developer's own file, resolved from the working directory: names out, one
|
|
168
|
+
* name in, never a value in a log or a response.
|
|
169
|
+
*/
|
|
170
|
+
function webRoutes(secrets) {
|
|
171
|
+
const env = !secrets.length
|
|
172
|
+
? ""
|
|
173
|
+
: `
|
|
174
|
+
/** The names this project declares; \`/env\` writes no other. */
|
|
175
|
+
const SECRETS = ${JSON.stringify(secrets)};
|
|
176
|
+
const ENV_LINE = (name: string) => new RegExp(\`^\\\\s*(export\\\\s+)?\${name}\\\\s*=\`);
|
|
177
|
+
|
|
178
|
+
function envPath(): string {
|
|
179
|
+
return join(process.cwd(), ".env");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Which declared names \`.env\` already carries a value for. Names only — never a value. */
|
|
183
|
+
function envPresent(): string[] {
|
|
184
|
+
let text = "";
|
|
185
|
+
try {
|
|
186
|
+
text = readFileSync(envPath(), "utf8");
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return SECRETS.filter((name) =>
|
|
191
|
+
text.split("\\n").some((line) => ENV_LINE(name).test(line) && line.split("=").slice(1).join("=").trim()),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Rewrite one line of \`.env\`, keeping every other line, and keep the file private. */
|
|
196
|
+
function envWrite(name: string, value: string): void {
|
|
197
|
+
let lines: string[] = [];
|
|
198
|
+
try {
|
|
199
|
+
lines = readFileSync(envPath(), "utf8").replace(/\\n$/, "").split("\\n");
|
|
200
|
+
} catch {
|
|
201
|
+
lines = [];
|
|
202
|
+
}
|
|
203
|
+
const index = lines.findIndex((line) => ENV_LINE(name).test(line));
|
|
204
|
+
if (index === -1) lines.push(\`\${name}=\${value}\`);
|
|
205
|
+
else lines[index] = \`\${name}=\${value}\`;
|
|
206
|
+
writeFileSync(envPath(), \`\${lines.join("\\n")}\\n\`, { mode: 0o600 });
|
|
207
|
+
chmodSync(envPath(), 0o600);
|
|
208
|
+
// The name, never the value: this log is the one place a secret could leak by accident.
|
|
209
|
+
console.error(\`.env: set \${name}\`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** \`GET\` the names present, \`POST {name, value}\` one of them. Writing needs the bearer token. */
|
|
213
|
+
function serveEnv(request: IncomingMessage, response: ServerResponse, token: string | undefined): void {
|
|
214
|
+
const json = (status: number, body: unknown): void => {
|
|
215
|
+
response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
216
|
+
};
|
|
217
|
+
// Names, never values: what this project declares and which of them \`.env\` already carries.
|
|
218
|
+
if (request.method === "GET") return json(200, { declared: SECRETS, present: envPresent() });
|
|
219
|
+
if (request.method !== "POST") return json(405, { error: "GET or POST" });
|
|
220
|
+
// Even on loopback: without a token in force there is no client to trust with a write.
|
|
221
|
+
if (!token) return json(403, { error: "unpaired: restart with \`mcp --http --open\`" });
|
|
222
|
+
let body = "";
|
|
223
|
+
request.on("data", (chunk: Buffer) => {
|
|
224
|
+
body += chunk;
|
|
225
|
+
});
|
|
226
|
+
request.on("end", () => {
|
|
227
|
+
let name = "";
|
|
228
|
+
let value = "";
|
|
229
|
+
try {
|
|
230
|
+
({ name, value } = JSON.parse(body) as { name: string; value: string });
|
|
231
|
+
} catch {
|
|
232
|
+
return json(400, { error: "expected {name, value}" });
|
|
233
|
+
}
|
|
234
|
+
if (!SECRETS.includes(name)) return json(400, { error: "not a declared secret" });
|
|
235
|
+
if (typeof value !== "string" || !value) return json(400, { error: "empty value" });
|
|
236
|
+
envWrite(name, value);
|
|
237
|
+
json(200, { declared: SECRETS, present: envPresent() });
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
`;
|
|
241
|
+
return `
|
|
242
|
+
/** The built page: \`npm -C ${WEB_DIR} run build\` writes it beside the kernel, and the package ships it. */
|
|
243
|
+
const WEB_DIST = fileURLToPath(new URL("../../${WEB_DIR}/dist/", import.meta.url));
|
|
244
|
+
const TYPES: Record<string, string> = {
|
|
245
|
+
".html": "text/html",
|
|
246
|
+
".js": "text/javascript",
|
|
247
|
+
".css": "text/css",
|
|
248
|
+
".json": "application/json",
|
|
249
|
+
".svg": "image/svg+xml",
|
|
250
|
+
".woff2": "font/woff2",
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/** One file under \`web/dist\`; a pathname with no extension is a client route, so the page answers. */
|
|
254
|
+
function serveWeb(pathname: string, response: ServerResponse): void {
|
|
255
|
+
const relative = normalize(pathname.replace(/^\\//, "")).replace(/^(\\.\\.[/\\\\])+/, "");
|
|
256
|
+
const file = join(WEB_DIST, extname(relative) ? relative : "index.html");
|
|
257
|
+
readFile(file).then(
|
|
258
|
+
(body) => {
|
|
259
|
+
response.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
|
|
260
|
+
response.end(body);
|
|
261
|
+
},
|
|
262
|
+
() => {
|
|
263
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
264
|
+
response.end(\`no \${file}; run \\\`npm -C ${WEB_DIR} run build\\\`\\n\`);
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Best effort, by platform, because neither Node nor any single CLI opens a URL everywhere:
|
|
271
|
+
* a container, a CI runner and an SSH session have no opener and that must not fail the command.
|
|
272
|
+
*/
|
|
273
|
+
function openBrowser(url: string): void {
|
|
274
|
+
const [command, args] =
|
|
275
|
+
process.platform === "darwin"
|
|
276
|
+
? ["open", [url]]
|
|
277
|
+
: process.platform === "win32"
|
|
278
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
279
|
+
: ["xdg-open", [url]];
|
|
280
|
+
execFile(command, args as string[], () => {});
|
|
281
|
+
}
|
|
282
|
+
${env}
|
|
283
|
+
export interface HandlerOptions {
|
|
284
|
+
/** Pathname the MCP endpoint answers on. */
|
|
285
|
+
path?: string;
|
|
286
|
+
/** Pathname the page is mounted under, when a host owns the listener (OpenClaw's plugin route). */
|
|
287
|
+
prefix?: string;
|
|
288
|
+
/** Bearer token the API routes require, when one is in force. */
|
|
289
|
+
token?: string;
|
|
290
|
+
/** Host-header guard, for a listener of our own on loopback. */
|
|
291
|
+
hostGuard?: (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
292
|
+
/**
|
|
293
|
+
* Answer \`/env\`; off where the working directory is not the developer's own checkout (the
|
|
294
|
+
* OpenClaw route). Always declared, so a host mounting this handler needs no build-time
|
|
295
|
+
* knowledge of whether the project happens to declare a secret.
|
|
296
|
+
*/
|
|
297
|
+
env?: boolean;
|
|
298
|
+
}
|
|
299
|
+
`;
|
|
300
|
+
}
|
|
301
|
+
function mcpTemplate(project) {
|
|
302
|
+
const tokenEnv = `${envName(project.identity.name)}_MCP_TOKEN`;
|
|
303
|
+
const web = has(project, "web");
|
|
304
|
+
const secrets = web ? secretsOf(project) : [];
|
|
305
|
+
return `${HEADER}/**
|
|
306
|
+
* The kernel MCP server: stdio by default; \`--http [port]\` (or \`serveHttp()\`) serves the
|
|
307
|
+
* same operations over MCP streamable HTTP instead, stateless per MCP 2026-07-28 (a fresh
|
|
308
|
+
* server instance per request, from \`createMcpHandler\`).${web
|
|
309
|
+
? `
|
|
310
|
+
*
|
|
311
|
+
* The same listener serves the built web app: \`/mcp\` is the endpoint, everything else is a
|
|
312
|
+
* file under \`web/dist\`, so the page POSTs same-origin and \`--open\` is the whole launch.
|
|
313
|
+
* \`handler()\` is that router, exported so a host with its own listener (the OpenClaw plugin's
|
|
314
|
+
* gateway route) can mount it instead of binding a port.`
|
|
315
|
+
: ""}
|
|
316
|
+
*/
|
|
317
|
+
import { localhostHostValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
318
|
+
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
|
319
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";${web
|
|
320
|
+
? `
|
|
321
|
+
import { execFile } from "node:child_process";`
|
|
322
|
+
: ""}
|
|
323
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
324
|
+
import { ${secrets.length ? "chmodSync, " : ""}mkdirSync, readFileSync, writeFileSync } from "node:fs";${web
|
|
325
|
+
? `
|
|
326
|
+
import { readFile } from "node:fs/promises";`
|
|
327
|
+
: ""}
|
|
328
|
+
import { createServer as createHttpServer${web ? ", type IncomingMessage, type ServerResponse" : ""} } from "node:http";
|
|
329
|
+
import { dirname, join${web ? ", extname, normalize" : ""} } from "node:path";${web
|
|
330
|
+
? `
|
|
331
|
+
import { fileURLToPath } from "node:url";`
|
|
332
|
+
: ""}
|
|
333
|
+
import { z } from "zod";
|
|
334
|
+
import { operations${web ? " as authored" : ""} } from "../ops.js";
|
|
335
|
+
import { context } from "./config.js";
|
|
336
|
+
import { serves } from "./types.js";${web
|
|
337
|
+
? `
|
|
338
|
+
import { web } from "./web.js";
|
|
339
|
+
|
|
340
|
+
/** The authored operations plus the generated \`web\` one; \`introspect\` snapshots this list. */
|
|
341
|
+
const operations = [...authored, web];`
|
|
342
|
+
: ""}
|
|
343
|
+
|
|
344
|
+
/** toolfactory introspects with every operation visible; a served instance lists only what MCP can run. */
|
|
345
|
+
const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
|
|
346
|
+
|
|
347
|
+
/** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
|
|
348
|
+
function createServer(): McpServer {
|
|
349
|
+
const server = new McpServer({ name: ${JSON.stringify(project.identity.name)}, version: ${JSON.stringify(project.identity.version ?? "0.0.0")} });
|
|
350
|
+
for (const op of operations) {
|
|
351
|
+
if (!introspecting && !serves(op, "mcp")) continue;
|
|
352
|
+
server.registerTool(
|
|
353
|
+
op.name,
|
|
354
|
+
{
|
|
355
|
+
description: op.description,
|
|
356
|
+
inputSchema: op.input as z.ZodObject,
|
|
357
|
+
outputSchema: (op.output ?? z.unknown()) as z.ZodType,
|
|
358
|
+
annotations: op.annotations,
|
|
359
|
+
_meta: { "dev.toolfactory": { requires: op.requires ?? [] } },
|
|
360
|
+
},
|
|
361
|
+
async (args: Record<string, unknown>) => {
|
|
362
|
+
const result = await op.handler(args as never, context());
|
|
363
|
+
return {
|
|
364
|
+
resultType: "complete" as const,
|
|
365
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
366
|
+
structuredContent: result as Record<string, unknown>,
|
|
367
|
+
};
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return server;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const server = createServer();
|
|
375
|
+
|
|
376
|
+
export async function serve(): Promise<void> {
|
|
377
|
+
await server.connect(new StdioServerTransport());
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The pairing token, so a browser extension (or anything else on loopback) can prove it is the
|
|
382
|
+
* client the user paired: the endpoint requires \`Authorization: Bearer <token>\` when
|
|
383
|
+
* \`${tokenEnv}\` is set or \`mcp --http --pair\` has written one under the data directory. With
|
|
384
|
+
* neither, nothing changes and the endpoint stays open on loopback.
|
|
385
|
+
*/
|
|
386
|
+
const TOKEN_FILE = "relay-token";
|
|
387
|
+
|
|
388
|
+
function tokenPath(): string {
|
|
389
|
+
return join(context().dataDir, TOKEN_FILE);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function pairedToken(): string | undefined {
|
|
393
|
+
const configured = process.env[${JSON.stringify(tokenEnv)}];
|
|
394
|
+
if (configured) return configured;
|
|
395
|
+
try {
|
|
396
|
+
return readFileSync(tokenPath(), "utf8").trim() || undefined;
|
|
397
|
+
} catch {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Mint a token and keep it 0600 under the data directory. Never rotates an existing one. */
|
|
403
|
+
function mintToken(): string {
|
|
404
|
+
const token = randomBytes(32).toString("base64url");
|
|
405
|
+
mkdirSync(dirname(tokenPath()), { recursive: true });
|
|
406
|
+
writeFileSync(tokenPath(), \`\${token}\\n\`, { mode: 0o600 });
|
|
407
|
+
return token;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Constant-time comparison over digests, so neither the token nor its length leaks. */
|
|
411
|
+
function authorized(header: string | undefined, token: string): boolean {
|
|
412
|
+
const digest = (value: string) => createHash("sha256").update(value).digest();
|
|
413
|
+
return timingSafeEqual(digest(header?.replace(/^Bearer /, "") ?? ""), digest(token));
|
|
414
|
+
}
|
|
415
|
+
${web ? webRoutes(secrets) : ""}
|
|
416
|
+
export interface HttpOptions {
|
|
417
|
+
port?: number;
|
|
418
|
+
host?: string;
|
|
419
|
+
path?: string;
|
|
420
|
+
/** Mint a fresh pairing token, print \`<url>#<token>\`, and require it from every client. */
|
|
421
|
+
pair?: boolean;${web
|
|
422
|
+
? `
|
|
423
|
+
/** Ensure a token, then open the page in the machine's browser. */
|
|
424
|
+
open?: boolean;`
|
|
425
|
+
: ""}
|
|
426
|
+
}
|
|
427
|
+
${web
|
|
428
|
+
? `
|
|
429
|
+
/**
|
|
430
|
+
* The whole router, so a host that owns the listener mounts the same function: \`path\` answers
|
|
431
|
+
* MCP, ${secrets.length ? "`<prefix>/env` the Secrets panel, " : ""}everything else is a file under \`web/dist\`. The token guards the
|
|
432
|
+
* ${secrets.length ? "two API routes" : "endpoint"}, never the page — a browser cannot put a header on its own document request.
|
|
433
|
+
*/
|
|
434
|
+
export function handler(options: HandlerOptions = {}): (request: IncomingMessage, response: ServerResponse) => void {
|
|
435
|
+
const { path = "/mcp", prefix = "", token, hostGuard${secrets.length ? ", env = true" : ""} } = options;
|
|
436
|
+
const handle = toNodeHandler(createMcpHandler(createServer));
|
|
437
|
+
const unauthorized = (response: ServerResponse): void => {
|
|
438
|
+
response.writeHead(401, { "WWW-Authenticate": "Bearer" }).end();
|
|
439
|
+
};
|
|
440
|
+
return (request, response) => {
|
|
441
|
+
const { pathname } = new URL(request.url ?? "/", "http://localhost");
|
|
442
|
+
if (hostGuard && !hostGuard(request, response)) return;
|
|
443
|
+
if (pathname === path) {
|
|
444
|
+
if (token && !authorized(request.headers.authorization, token)) return unauthorized(response);
|
|
445
|
+
handle(request, response).catch((error: unknown) => {
|
|
446
|
+
console.error(error);
|
|
447
|
+
if (!response.headersSent) response.writeHead(500);
|
|
448
|
+
response.end();
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}${secrets.length
|
|
452
|
+
? `
|
|
453
|
+
if (env && pathname === \`\${prefix}/env\`) {
|
|
454
|
+
if (token && !authorized(request.headers.authorization, token)) return unauthorized(response);
|
|
455
|
+
serveEnv(request, response, token);
|
|
456
|
+
return;
|
|
457
|
+
}`
|
|
458
|
+
: ""}
|
|
459
|
+
serveWeb(pathname.slice(prefix.length), response);
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
`
|
|
463
|
+
: ""}
|
|
464
|
+
/** Serve over MCP streamable HTTP instead of stdio. Opt-in; stdio stays the default transport. */
|
|
465
|
+
export async function serveHttp(options: HttpOptions = {}): Promise<void> {
|
|
466
|
+
const { port = 3000, host = "127.0.0.1", path = "/mcp", pair = false${web ? ", open = false" : ""} } = options;
|
|
467
|
+
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
468
|
+
const hostGuard = loopback ? localhostHostValidation() : undefined;
|
|
469
|
+
// \`--open\` needs a token for the page to call back with; an existing one is never rotated,
|
|
470
|
+
// because the browser extension holds it.
|
|
471
|
+
const token = pair ? mintToken() : ${web ? "(pairedToken() ?? (open ? mintToken() : undefined))" : "pairedToken()"};
|
|
472
|
+
${web
|
|
473
|
+
? " const httpServer = createHttpServer(handler({ path, token, hostGuard }));"
|
|
474
|
+
: ` const handle = toNodeHandler(createMcpHandler(createServer));
|
|
475
|
+
const httpServer = createHttpServer((request, response) => {
|
|
476
|
+
if (new URL(request.url ?? "/", "http://localhost").pathname !== path) {
|
|
477
|
+
response.writeHead(404).end();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (hostGuard && !hostGuard(request, response)) return;
|
|
481
|
+
if (token && !authorized(request.headers.authorization, token)) {
|
|
482
|
+
response.writeHead(401, { "WWW-Authenticate": "Bearer" }).end();
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
handle(request, response).catch((error: unknown) => {
|
|
486
|
+
console.error(error);
|
|
487
|
+
if (!response.headersSent) response.writeHead(500);
|
|
488
|
+
response.end();
|
|
489
|
+
});
|
|
490
|
+
});`}
|
|
491
|
+
await new Promise<void>((resolve, reject) => {
|
|
492
|
+
httpServer.once("error", reject);
|
|
493
|
+
httpServer.listen(port, host, resolve);
|
|
494
|
+
});
|
|
495
|
+
// \`--http 0\` asks the OS for a free port; the URL is only known once it is bound.
|
|
496
|
+
const base = \`http://\${host}:\${(httpServer.address() as { port: number }).port}\`;
|
|
497
|
+
if (pair) process.stdout.write(\`\${base}\${path}#\${token}\\n\`);
|
|
498
|
+
console.error(
|
|
499
|
+
\`Serving MCP streamable HTTP on \${base}\${path}\${token ? " (bearer token required)" : ""}\`,
|
|
500
|
+
);${web
|
|
501
|
+
? `
|
|
502
|
+
if (open) openBrowser(\`\${base}/#\${token}\`);`
|
|
503
|
+
: ""}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Parses a manually-run \`node mcp.ts [--http [port]]\` invocation; the CLI's \`mcp\` subcommand parses its own. */
|
|
507
|
+
function httpPortArg(argv: string[]): number | undefined {
|
|
508
|
+
const index = argv.indexOf("--http");
|
|
509
|
+
if (index === -1) return undefined;
|
|
510
|
+
const value = argv[index + 1];
|
|
511
|
+
return value && !value.startsWith("-") ? Number(value) : 3000;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (process.argv[1] && /toolfactory[\\\\/]mcp\\.[cm]?[jt]s$/.test(process.argv[1])) {
|
|
515
|
+
const port = httpPortArg(process.argv.slice(2));
|
|
516
|
+
const pair = process.argv.includes("--pair");${web
|
|
517
|
+
? `
|
|
518
|
+
const open = process.argv.includes("--open");
|
|
519
|
+
(port === undefined && !pair && !open
|
|
520
|
+
? serve()
|
|
521
|
+
: serveHttp({ port: port ?? 3000, pair, open })
|
|
522
|
+
).catch((error) => {`
|
|
523
|
+
: `
|
|
524
|
+
(port === undefined && !pair ? serve() : serveHttp({ port: port ?? 3000, pair })).catch((error) => {`}
|
|
525
|
+
console.error(error);
|
|
526
|
+
process.exit(1);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
`;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* The `web` operation, generated with the `web` surface: it reaches every surface at once
|
|
533
|
+
* (MCP `tools/call`, the skill, the CLI, the page itself) without a socket inside the stdio
|
|
534
|
+
* server — the kernel's own HTTP listener runs in a detached child, which is also what opens
|
|
535
|
+
* the browser, so the page appears on the machine running the tool and no host has to open a
|
|
536
|
+
* URL a tool returns.
|
|
537
|
+
*/
|
|
538
|
+
function webTemplate() {
|
|
539
|
+
return `${HEADER}import { spawn } from "node:child_process";
|
|
540
|
+
import { extname } from "node:path";
|
|
541
|
+
import { fileURLToPath } from "node:url";
|
|
542
|
+
import { z } from "zod";
|
|
543
|
+
import { operation } from "./types.js";
|
|
544
|
+
|
|
545
|
+
/** The kernel beside this file, in the form that is running: \`.ts\` under tsx, \`.js\` from dist. */
|
|
546
|
+
const KERNEL = fileURLToPath(
|
|
547
|
+
new URL(\`./mcp\${extname(fileURLToPath(import.meta.url))}\`, import.meta.url),
|
|
548
|
+
);
|
|
549
|
+
const SERVING = /Serving MCP streamable HTTP on (\\S+)/;
|
|
550
|
+
|
|
551
|
+
export const web = operation({
|
|
552
|
+
name: "web",
|
|
553
|
+
description:
|
|
554
|
+
"Open this tool's web app: serves the operations page and the MCP endpoint on a free local port, opens a browser there, and returns the URL.",
|
|
555
|
+
input: z.object({}),
|
|
556
|
+
output: z.object({ url: z.string() }),
|
|
557
|
+
requires: ["shell", "net"],
|
|
558
|
+
annotations: { openWorldHint: false },
|
|
559
|
+
handler: () =>
|
|
560
|
+
new Promise<{ url: string }>((resolve, reject) => {
|
|
561
|
+
const child = spawn(process.execPath, [...process.execArgv, KERNEL, "--http", "0", "--open"], {
|
|
562
|
+
detached: true,
|
|
563
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
564
|
+
});
|
|
565
|
+
let output = "";
|
|
566
|
+
child.on("error", reject);
|
|
567
|
+
child.on("exit", (code) => reject(new Error(\`the kernel exited with \${code}: \${output}\`)));
|
|
568
|
+
child.stderr.setEncoding("utf8");
|
|
569
|
+
child.stderr.on("data", (chunk: string) => {
|
|
570
|
+
output += chunk;
|
|
571
|
+
const serving = SERVING.exec(output);
|
|
572
|
+
if (!serving) return;
|
|
573
|
+
// The child outlives this process; unref both handles so neither keeps it alive, and
|
|
574
|
+
// leave the pipe open so the kernel's own stderr never writes to a closed one.
|
|
575
|
+
child.unref();
|
|
576
|
+
(child.stderr as unknown as { unref: () => void }).unref();
|
|
577
|
+
// The page, not the endpoint, and without the token the child already handed the browser:
|
|
578
|
+
// an operation's result travels through an agent, and a token is a credential.
|
|
579
|
+
resolve({ url: new URL("/", serving[1]).href });
|
|
580
|
+
});
|
|
581
|
+
}),
|
|
582
|
+
});
|
|
583
|
+
`;
|
|
584
|
+
}
|
|
585
|
+
function cliTemplate(project) {
|
|
586
|
+
const web = has(project, "web");
|
|
587
|
+
return `#!/usr/bin/env node
|
|
588
|
+
${HEADER}import { Command } from "commander";
|
|
589
|
+
import { z } from "zod";
|
|
590
|
+
import { operations${web ? " as authored" : ""} } from "../ops.js";
|
|
591
|
+
import { context } from "./config.js";
|
|
592
|
+
import { serves } from "./types.js";${web
|
|
593
|
+
? `
|
|
594
|
+
import { web } from "./web.js";
|
|
595
|
+
|
|
596
|
+
/** The authored operations plus the generated \`web\` one, exactly as the MCP kernel lists them. */
|
|
597
|
+
const operations = [...authored, web];`
|
|
598
|
+
: ""}
|
|
599
|
+
|
|
600
|
+
const program = new Command()
|
|
601
|
+
.name(${JSON.stringify(project.identity.name)})
|
|
602
|
+
.description(${JSON.stringify(project.identity.description ?? project.identity.name)})
|
|
603
|
+
.version(${JSON.stringify(project.identity.version ?? "0.0.0")});
|
|
604
|
+
|
|
605
|
+
for (const op of operations) {
|
|
606
|
+
if (!serves(op, "cli")) continue;
|
|
607
|
+
const command = program.command(op.name).description(op.description);
|
|
608
|
+
command.option("--json <arguments>", "all arguments as one JSON object");
|
|
609
|
+
const schema = z.toJSONSchema(op.input, { io: "input" }) as {
|
|
610
|
+
properties?: Record<string, { description?: string; type?: string; default?: unknown }>;
|
|
611
|
+
};
|
|
612
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
613
|
+
const flag = property.type === "boolean" ? \`--\${key}\` : \`--\${key} <value>\`;
|
|
614
|
+
const hint = property.type === "array" ? " (comma-separated)" : property.type === "object" ? " (JSON)" : "";
|
|
615
|
+
command.option(flag, \`\${property.description ?? key}\${hint}\`);
|
|
616
|
+
}
|
|
617
|
+
command.action(async (options: Record<string, string | boolean | undefined>) => {
|
|
618
|
+
const fromJson = options.json ? (JSON.parse(String(options.json)) as Record<string, unknown>) : {};
|
|
619
|
+
const fromFlags = Object.fromEntries(
|
|
620
|
+
Object.entries(options)
|
|
621
|
+
.filter(([key, value]) => key !== "json" && value !== undefined)
|
|
622
|
+
.map(([key, value]) => {
|
|
623
|
+
const type = schema.properties?.[key]?.type;
|
|
624
|
+
if (type === "number" || type === "integer") return [key, Number(value)];
|
|
625
|
+
if (type === "boolean") return [key, value === true || value === "true"];
|
|
626
|
+
if (type === "array") return [key, String(value).split(",").map((item) => item.trim()).filter(Boolean)];
|
|
627
|
+
if (type === "object") return [key, JSON.parse(String(value))];
|
|
628
|
+
return [key, value];
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
const args = op.input.parse({ ...fromJson, ...fromFlags });
|
|
632
|
+
const result = await op.handler(args as never, context());
|
|
633
|
+
process.stdout.write(\`\${JSON.stringify(result, null, 2)}\\n\`);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
${has(project, "mcp")
|
|
638
|
+
? `program
|
|
639
|
+
.command("mcp")
|
|
640
|
+
.description("Serve the operations as an MCP server over stdio, or over streamable HTTP with --http")
|
|
641
|
+
.option(
|
|
642
|
+
"--http [port]",
|
|
643
|
+
"serve over MCP streamable HTTP instead of stdio (default port 3000, host 127.0.0.1, path /mcp)",
|
|
644
|
+
)
|
|
645
|
+
.option(
|
|
646
|
+
"--pair",
|
|
647
|
+
"serve over HTTP with a fresh pairing token: prints <url>#<token> and requires it as a bearer token",
|
|
648
|
+
)${web
|
|
649
|
+
? `
|
|
650
|
+
.option("--open", "serve over HTTP and open the web app in this machine's browser")`
|
|
651
|
+
: ""}
|
|
652
|
+
.action(async (options: { http?: string | boolean; pair?: boolean${web ? "; open?: boolean" : ""} }) => {
|
|
653
|
+
const { serve, serveHttp } = await import("./mcp.js");
|
|
654
|
+
if (options.http === undefined && !options.pair${web ? " && !options.open" : ""}) await serve();
|
|
655
|
+
else {
|
|
656
|
+
const port = options.http === undefined || options.http === true ? 3000 : Number(options.http);
|
|
657
|
+
await serveHttp({ port, pair: options.pair${web ? ", open: options.open" : ""} });
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
`
|
|
662
|
+
: ""}program.parseAsync(process.argv).catch((error) => {
|
|
663
|
+
console.error(error instanceof Error ? error.message : error);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
});
|
|
666
|
+
`;
|
|
667
|
+
}
|
|
668
|
+
function opsScaffold() {
|
|
669
|
+
return `import { z } from "zod";
|
|
670
|
+
import { operation } from "./toolfactory/types.js";
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Your tool's operations: pure functions of (arguments, context.config, filesystem).
|
|
674
|
+
* Declare \`requires\` for anything a host must provide; toolfactory decides per surface
|
|
675
|
+
* whether that operation is native, bridged, degraded, or excluded.
|
|
676
|
+
*/
|
|
677
|
+
export const operations = [
|
|
678
|
+
operation({
|
|
679
|
+
name: "echo",
|
|
680
|
+
description: "Return the text you pass in.",
|
|
681
|
+
input: z.object({ text: z.string().describe("Text to echo back") }),
|
|
682
|
+
output: z.object({ text: z.string() }),
|
|
683
|
+
annotations: { readOnlyHint: true },
|
|
684
|
+
handler: async ({ text }) => ({ text }),
|
|
685
|
+
}),
|
|
686
|
+
];
|
|
687
|
+
`;
|
|
688
|
+
}
|
|
689
|
+
/** The kernel, generated on every build for every tool: the contract, config, and the MCP server toolfactory introspects. */
|
|
690
|
+
export function kernel(project) {
|
|
691
|
+
return [
|
|
692
|
+
{ kind: "file", path: `${KERNEL_DIR}/types.ts`, content: typesTemplate() },
|
|
693
|
+
{ kind: "file", path: `${KERNEL_DIR}/config.ts`, content: configTemplate(project) },
|
|
694
|
+
{ kind: "file", path: `${KERNEL_DIR}/mcp.ts`, content: mcpTemplate(project) },
|
|
695
|
+
// The `web` surface's own operation: generated here because it is the kernel's listener it
|
|
696
|
+
// starts, and appended to `operations` by the two templates above, so `introspect` sees it.
|
|
697
|
+
...(has(project, "web")
|
|
698
|
+
? [{ kind: "file", path: `${KERNEL_DIR}/web.ts`, content: webTemplate() }]
|
|
699
|
+
: []),
|
|
700
|
+
];
|
|
701
|
+
}
|
|
702
|
+
/** The CLI surface's file. */
|
|
703
|
+
export function cli(project) {
|
|
704
|
+
return [{ kind: "file", path: `${KERNEL_DIR}/cli.ts`, content: cliTemplate(project) }];
|
|
705
|
+
}
|
|
706
|
+
/** The Node floor every generated TypeScript project declares (vite 8 and the MCP SDK need it). */
|
|
707
|
+
export const NODE_ENGINES = ">=22.12";
|
|
708
|
+
/** Written by `init` only when absent; owned by the author afterwards. */
|
|
709
|
+
export function scaffold(project) {
|
|
710
|
+
const packageJson = {
|
|
711
|
+
name: npmName(project),
|
|
712
|
+
version: project.identity.version ?? "0.1.0",
|
|
713
|
+
description: project.identity.description,
|
|
714
|
+
license: project.identity.license,
|
|
715
|
+
repository: project.identity.repository,
|
|
716
|
+
homepage: project.identity.homepage,
|
|
717
|
+
keywords: project.identity.keywords,
|
|
718
|
+
type: "module",
|
|
719
|
+
engines: { node: NODE_ENGINES },
|
|
720
|
+
scripts: {
|
|
721
|
+
build: "tsc -p tsconfig.json",
|
|
722
|
+
test: "vitest run --passWithNoTests",
|
|
723
|
+
"test:live": LIVE_TEST_SCRIPT,
|
|
724
|
+
},
|
|
725
|
+
dependencies: {
|
|
726
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
727
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
728
|
+
commander: "^15.0.0",
|
|
729
|
+
zod: "^4.0.0",
|
|
730
|
+
},
|
|
731
|
+
devDependencies: {
|
|
732
|
+
"@types/node": "^22.0.0",
|
|
733
|
+
toolfactory: `^${project.toolfactoryVersion}`,
|
|
734
|
+
tsx: "^4.0.0",
|
|
735
|
+
typescript: "^6.0.0",
|
|
736
|
+
vitest: "^4.0.0",
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
const tsconfig = {
|
|
740
|
+
compilerOptions: {
|
|
741
|
+
target: "ES2022",
|
|
742
|
+
module: "NodeNext",
|
|
743
|
+
moduleResolution: "NodeNext",
|
|
744
|
+
strict: true,
|
|
745
|
+
declaration: true,
|
|
746
|
+
outDir: "dist",
|
|
747
|
+
rootDir: "src",
|
|
748
|
+
skipLibCheck: true,
|
|
749
|
+
types: ["node"],
|
|
750
|
+
},
|
|
751
|
+
include: ["src/**/*.ts"],
|
|
752
|
+
};
|
|
753
|
+
return [
|
|
754
|
+
{ kind: "file", path: OPS_PATH, content: opsScaffold() },
|
|
755
|
+
{ kind: "file", path: "package.json", content: `${JSON.stringify(packageJson, null, 2)}\n` },
|
|
756
|
+
{ kind: "file", path: "tsconfig.json", content: `${JSON.stringify(tsconfig, null, 2)}\n` },
|
|
757
|
+
];
|
|
758
|
+
}
|