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,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { operations as authored } from "../ops.js";
|
|
6
|
+
import { context } from "./config.js";
|
|
7
|
+
import { serves } from "./types.js";
|
|
8
|
+
import { web } from "./web.js";
|
|
9
|
+
|
|
10
|
+
/** The authored operations plus the generated `web` one, exactly as the MCP kernel lists them. */
|
|
11
|
+
const operations = [...authored, web];
|
|
12
|
+
|
|
13
|
+
const program = new Command()
|
|
14
|
+
.name("toolfactory")
|
|
15
|
+
.description("Build an agent tool once; ship it as Agent Skills, MCP servers, Agent Plugins / Claude / Codex / Cursor bundles, OpenClaw and Hermes plugins, CLIs, and packages, with the tests and releases to match.")
|
|
16
|
+
.version("0.1.1");
|
|
17
|
+
|
|
18
|
+
for (const op of operations) {
|
|
19
|
+
if (!serves(op, "cli")) continue;
|
|
20
|
+
const command = program.command(op.name).description(op.description);
|
|
21
|
+
command.option("--json <arguments>", "all arguments as one JSON object");
|
|
22
|
+
const schema = z.toJSONSchema(op.input, { io: "input" }) as {
|
|
23
|
+
properties?: Record<string, { description?: string; type?: string; default?: unknown }>;
|
|
24
|
+
};
|
|
25
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
26
|
+
const flag = property.type === "boolean" ? `--${key}` : `--${key} <value>`;
|
|
27
|
+
const hint = property.type === "array" ? " (comma-separated)" : property.type === "object" ? " (JSON)" : "";
|
|
28
|
+
command.option(flag, `${property.description ?? key}${hint}`);
|
|
29
|
+
}
|
|
30
|
+
command.action(async (options: Record<string, string | boolean | undefined>) => {
|
|
31
|
+
const fromJson = options.json ? (JSON.parse(String(options.json)) as Record<string, unknown>) : {};
|
|
32
|
+
const fromFlags = Object.fromEntries(
|
|
33
|
+
Object.entries(options)
|
|
34
|
+
.filter(([key, value]) => key !== "json" && value !== undefined)
|
|
35
|
+
.map(([key, value]) => {
|
|
36
|
+
const type = schema.properties?.[key]?.type;
|
|
37
|
+
if (type === "number" || type === "integer") return [key, Number(value)];
|
|
38
|
+
if (type === "boolean") return [key, value === true || value === "true"];
|
|
39
|
+
if (type === "array") return [key, String(value).split(",").map((item) => item.trim()).filter(Boolean)];
|
|
40
|
+
if (type === "object") return [key, JSON.parse(String(value))];
|
|
41
|
+
return [key, value];
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
const args = op.input.parse({ ...fromJson, ...fromFlags });
|
|
45
|
+
const result = await op.handler(args as never, context());
|
|
46
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
program
|
|
51
|
+
.command("mcp")
|
|
52
|
+
.description("Serve the operations as an MCP server over stdio, or over streamable HTTP with --http")
|
|
53
|
+
.option(
|
|
54
|
+
"--http [port]",
|
|
55
|
+
"serve over MCP streamable HTTP instead of stdio (default port 3000, host 127.0.0.1, path /mcp)",
|
|
56
|
+
)
|
|
57
|
+
.option(
|
|
58
|
+
"--pair",
|
|
59
|
+
"serve over HTTP with a fresh pairing token: prints <url>#<token> and requires it as a bearer token",
|
|
60
|
+
)
|
|
61
|
+
.option("--open", "serve over HTTP and open the web app in this machine's browser")
|
|
62
|
+
.action(async (options: { http?: string | boolean; pair?: boolean; open?: boolean }) => {
|
|
63
|
+
const { serve, serveHttp } = await import("./mcp.js");
|
|
64
|
+
if (options.http === undefined && !options.pair && !options.open) await serve();
|
|
65
|
+
else {
|
|
66
|
+
const port = options.http === undefined || options.http === true ? 3000 : Number(options.http);
|
|
67
|
+
await serveHttp({ port, pair: options.pair, open: options.open });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program.parseAsync(process.argv).catch((error) => {
|
|
72
|
+
console.error(error instanceof Error ? error.message : error);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Context } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where this tool may keep its own state: TOOLFACTORY_DATA_DIR when a host sets it, else the
|
|
8
|
+
* platform's own data location (XDG on POSIX, %LOCALAPPDATA% on Windows).
|
|
9
|
+
*/
|
|
10
|
+
function dataDir(): string {
|
|
11
|
+
// Hosts that launch the kernel export their own name for it: Agent Plugins clients and
|
|
12
|
+
// OpenClaw set PLUGIN_DATA, Claude Code sets CLAUDE_PLUGIN_DATA.
|
|
13
|
+
const configured =
|
|
14
|
+
process.env["TOOLFACTORY_DATA_DIR"] ??
|
|
15
|
+
process.env.PLUGIN_DATA ??
|
|
16
|
+
process.env.CLAUDE_PLUGIN_DATA;
|
|
17
|
+
if (configured) return configured;
|
|
18
|
+
const base =
|
|
19
|
+
process.platform === "win32"
|
|
20
|
+
? (process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"))
|
|
21
|
+
: (process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"));
|
|
22
|
+
return join(base, "toolfactory");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Configuration is read from the environment; hosts inject it from their own settings UI. */
|
|
26
|
+
export function context(): Context {
|
|
27
|
+
return {
|
|
28
|
+
config: {
|
|
29
|
+
|
|
30
|
+
},
|
|
31
|
+
dataDir: dataDir(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
|
|
2
|
+
/**
|
|
3
|
+
* The kernel MCP server: stdio by default; `--http [port]` (or `serveHttp()`) serves the
|
|
4
|
+
* same operations over MCP streamable HTTP instead, stateless per MCP 2026-07-28 (a fresh
|
|
5
|
+
* server instance per request, from `createMcpHandler`).
|
|
6
|
+
*
|
|
7
|
+
* The same listener serves the built web app: `/mcp` is the endpoint, everything else is a
|
|
8
|
+
* file under `web/dist`, so the page POSTs same-origin and `--open` is the whole launch.
|
|
9
|
+
* `handler()` is that router, exported so a host with its own listener (the OpenClaw plugin's
|
|
10
|
+
* gateway route) can mount it instead of binding a port.
|
|
11
|
+
*/
|
|
12
|
+
import { localhostHostValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
13
|
+
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
|
14
|
+
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
20
|
+
import { dirname, join, extname, normalize } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { operations as authored } from "../ops.js";
|
|
24
|
+
import { context } from "./config.js";
|
|
25
|
+
import { serves } from "./types.js";
|
|
26
|
+
import { web } from "./web.js";
|
|
27
|
+
|
|
28
|
+
/** The authored operations plus the generated `web` one; `introspect` snapshots this list. */
|
|
29
|
+
const operations = [...authored, web];
|
|
30
|
+
|
|
31
|
+
/** toolfactory introspects with every operation visible; a served instance lists only what MCP can run. */
|
|
32
|
+
const introspecting = Boolean(process.env.TOOLFACTORY_INTROSPECT);
|
|
33
|
+
|
|
34
|
+
/** Builds one server instance with every servable operation registered; called once for stdio, once per HTTP request. */
|
|
35
|
+
function createServer(): McpServer {
|
|
36
|
+
const server = new McpServer({ name: "toolfactory", version: "0.1.1" });
|
|
37
|
+
for (const op of operations) {
|
|
38
|
+
if (!introspecting && !serves(op, "mcp")) continue;
|
|
39
|
+
server.registerTool(
|
|
40
|
+
op.name,
|
|
41
|
+
{
|
|
42
|
+
description: op.description,
|
|
43
|
+
inputSchema: op.input as z.ZodObject,
|
|
44
|
+
outputSchema: (op.output ?? z.unknown()) as z.ZodType,
|
|
45
|
+
annotations: op.annotations,
|
|
46
|
+
_meta: { "dev.toolfactory": { requires: op.requires ?? [] } },
|
|
47
|
+
},
|
|
48
|
+
async (args: Record<string, unknown>) => {
|
|
49
|
+
const result = await op.handler(args as never, context());
|
|
50
|
+
return {
|
|
51
|
+
resultType: "complete" as const,
|
|
52
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
53
|
+
structuredContent: result as Record<string, unknown>,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return server;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const server = createServer();
|
|
62
|
+
|
|
63
|
+
export async function serve(): Promise<void> {
|
|
64
|
+
await server.connect(new StdioServerTransport());
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The pairing token, so a browser extension (or anything else on loopback) can prove it is the
|
|
69
|
+
* client the user paired: the endpoint requires `Authorization: Bearer <token>` when
|
|
70
|
+
* `TOOLFACTORY_MCP_TOKEN` is set or `mcp --http --pair` has written one under the data directory. With
|
|
71
|
+
* neither, nothing changes and the endpoint stays open on loopback.
|
|
72
|
+
*/
|
|
73
|
+
const TOKEN_FILE = "relay-token";
|
|
74
|
+
|
|
75
|
+
function tokenPath(): string {
|
|
76
|
+
return join(context().dataDir, TOKEN_FILE);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pairedToken(): string | undefined {
|
|
80
|
+
const configured = process.env["TOOLFACTORY_MCP_TOKEN"];
|
|
81
|
+
if (configured) return configured;
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(tokenPath(), "utf8").trim() || undefined;
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Mint a token and keep it 0600 under the data directory. Never rotates an existing one. */
|
|
90
|
+
function mintToken(): string {
|
|
91
|
+
const token = randomBytes(32).toString("base64url");
|
|
92
|
+
mkdirSync(dirname(tokenPath()), { recursive: true });
|
|
93
|
+
writeFileSync(tokenPath(), `${token}\n`, { mode: 0o600 });
|
|
94
|
+
return token;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Constant-time comparison over digests, so neither the token nor its length leaks. */
|
|
98
|
+
function authorized(header: string | undefined, token: string): boolean {
|
|
99
|
+
const digest = (value: string) => createHash("sha256").update(value).digest();
|
|
100
|
+
return timingSafeEqual(digest(header?.replace(/^Bearer /, "") ?? ""), digest(token));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The built page: `npm -C web run build` writes it beside the kernel, and the package ships it. */
|
|
104
|
+
const WEB_DIST = fileURLToPath(new URL("../../web/dist/", import.meta.url));
|
|
105
|
+
const TYPES: Record<string, string> = {
|
|
106
|
+
".html": "text/html",
|
|
107
|
+
".js": "text/javascript",
|
|
108
|
+
".css": "text/css",
|
|
109
|
+
".json": "application/json",
|
|
110
|
+
".svg": "image/svg+xml",
|
|
111
|
+
".woff2": "font/woff2",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** One file under `web/dist`; a pathname with no extension is a client route, so the page answers. */
|
|
115
|
+
function serveWeb(pathname: string, response: ServerResponse): void {
|
|
116
|
+
const relative = normalize(pathname.replace(/^\//, "")).replace(/^(\.\.[/\\])+/, "");
|
|
117
|
+
const file = join(WEB_DIST, extname(relative) ? relative : "index.html");
|
|
118
|
+
readFile(file).then(
|
|
119
|
+
(body) => {
|
|
120
|
+
response.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" });
|
|
121
|
+
response.end(body);
|
|
122
|
+
},
|
|
123
|
+
() => {
|
|
124
|
+
response.writeHead(404, { "content-type": "text/plain" });
|
|
125
|
+
response.end(`no ${file}; run \`npm -C web run build\`\n`);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Best effort, by platform, because neither Node nor any single CLI opens a URL everywhere:
|
|
132
|
+
* a container, a CI runner and an SSH session have no opener and that must not fail the command.
|
|
133
|
+
*/
|
|
134
|
+
function openBrowser(url: string): void {
|
|
135
|
+
const [command, args] =
|
|
136
|
+
process.platform === "darwin"
|
|
137
|
+
? ["open", [url]]
|
|
138
|
+
: process.platform === "win32"
|
|
139
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
140
|
+
: ["xdg-open", [url]];
|
|
141
|
+
execFile(command, args as string[], () => {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The names this project declares; `/env` writes no other. */
|
|
145
|
+
const SECRETS = ["NPM_TOKEN"];
|
|
146
|
+
const ENV_LINE = (name: string) => new RegExp(`^\\s*(export\\s+)?${name}\\s*=`);
|
|
147
|
+
|
|
148
|
+
function envPath(): string {
|
|
149
|
+
return join(process.cwd(), ".env");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Which declared names `.env` already carries a value for. Names only — never a value. */
|
|
153
|
+
function envPresent(): string[] {
|
|
154
|
+
let text = "";
|
|
155
|
+
try {
|
|
156
|
+
text = readFileSync(envPath(), "utf8");
|
|
157
|
+
} catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return SECRETS.filter((name) =>
|
|
161
|
+
text.split("\n").some((line) => ENV_LINE(name).test(line) && line.split("=").slice(1).join("=").trim()),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Rewrite one line of `.env`, keeping every other line, and keep the file private. */
|
|
166
|
+
function envWrite(name: string, value: string): void {
|
|
167
|
+
let lines: string[] = [];
|
|
168
|
+
try {
|
|
169
|
+
lines = readFileSync(envPath(), "utf8").replace(/\n$/, "").split("\n");
|
|
170
|
+
} catch {
|
|
171
|
+
lines = [];
|
|
172
|
+
}
|
|
173
|
+
const index = lines.findIndex((line) => ENV_LINE(name).test(line));
|
|
174
|
+
if (index === -1) lines.push(`${name}=${value}`);
|
|
175
|
+
else lines[index] = `${name}=${value}`;
|
|
176
|
+
writeFileSync(envPath(), `${lines.join("\n")}\n`, { mode: 0o600 });
|
|
177
|
+
chmodSync(envPath(), 0o600);
|
|
178
|
+
// The name, never the value: this log is the one place a secret could leak by accident.
|
|
179
|
+
console.error(`.env: set ${name}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `GET` the names present, `POST {name, value}` one of them. Writing needs the bearer token. */
|
|
183
|
+
function serveEnv(request: IncomingMessage, response: ServerResponse, token: string | undefined): void {
|
|
184
|
+
const json = (status: number, body: unknown): void => {
|
|
185
|
+
response.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
186
|
+
};
|
|
187
|
+
// Names, never values: what this project declares and which of them `.env` already carries.
|
|
188
|
+
if (request.method === "GET") return json(200, { declared: SECRETS, present: envPresent() });
|
|
189
|
+
if (request.method !== "POST") return json(405, { error: "GET or POST" });
|
|
190
|
+
// Even on loopback: without a token in force there is no client to trust with a write.
|
|
191
|
+
if (!token) return json(403, { error: "unpaired: restart with `mcp --http --open`" });
|
|
192
|
+
let body = "";
|
|
193
|
+
request.on("data", (chunk: Buffer) => {
|
|
194
|
+
body += chunk;
|
|
195
|
+
});
|
|
196
|
+
request.on("end", () => {
|
|
197
|
+
let name = "";
|
|
198
|
+
let value = "";
|
|
199
|
+
try {
|
|
200
|
+
({ name, value } = JSON.parse(body) as { name: string; value: string });
|
|
201
|
+
} catch {
|
|
202
|
+
return json(400, { error: "expected {name, value}" });
|
|
203
|
+
}
|
|
204
|
+
if (!SECRETS.includes(name)) return json(400, { error: "not a declared secret" });
|
|
205
|
+
if (typeof value !== "string" || !value) return json(400, { error: "empty value" });
|
|
206
|
+
envWrite(name, value);
|
|
207
|
+
json(200, { declared: SECRETS, present: envPresent() });
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface HandlerOptions {
|
|
212
|
+
/** Pathname the MCP endpoint answers on. */
|
|
213
|
+
path?: string;
|
|
214
|
+
/** Pathname the page is mounted under, when a host owns the listener (OpenClaw's plugin route). */
|
|
215
|
+
prefix?: string;
|
|
216
|
+
/** Bearer token the API routes require, when one is in force. */
|
|
217
|
+
token?: string;
|
|
218
|
+
/** Host-header guard, for a listener of our own on loopback. */
|
|
219
|
+
hostGuard?: (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
220
|
+
/**
|
|
221
|
+
* Answer `/env`; off where the working directory is not the developer's own checkout (the
|
|
222
|
+
* OpenClaw route). Always declared, so a host mounting this handler needs no build-time
|
|
223
|
+
* knowledge of whether the project happens to declare a secret.
|
|
224
|
+
*/
|
|
225
|
+
env?: boolean;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface HttpOptions {
|
|
229
|
+
port?: number;
|
|
230
|
+
host?: string;
|
|
231
|
+
path?: string;
|
|
232
|
+
/** Mint a fresh pairing token, print `<url>#<token>`, and require it from every client. */
|
|
233
|
+
pair?: boolean;
|
|
234
|
+
/** Ensure a token, then open the page in the machine's browser. */
|
|
235
|
+
open?: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The whole router, so a host that owns the listener mounts the same function: `path` answers
|
|
240
|
+
* MCP, `<prefix>/env` the Secrets panel, everything else is a file under `web/dist`. The token guards the
|
|
241
|
+
* two API routes, never the page — a browser cannot put a header on its own document request.
|
|
242
|
+
*/
|
|
243
|
+
export function handler(options: HandlerOptions = {}): (request: IncomingMessage, response: ServerResponse) => void {
|
|
244
|
+
const { path = "/mcp", prefix = "", token, hostGuard, env = true } = options;
|
|
245
|
+
const handle = toNodeHandler(createMcpHandler(createServer));
|
|
246
|
+
const unauthorized = (response: ServerResponse): void => {
|
|
247
|
+
response.writeHead(401, { "WWW-Authenticate": "Bearer" }).end();
|
|
248
|
+
};
|
|
249
|
+
return (request, response) => {
|
|
250
|
+
const { pathname } = new URL(request.url ?? "/", "http://localhost");
|
|
251
|
+
if (hostGuard && !hostGuard(request, response)) return;
|
|
252
|
+
if (pathname === path) {
|
|
253
|
+
if (token && !authorized(request.headers.authorization, token)) return unauthorized(response);
|
|
254
|
+
handle(request, response).catch((error: unknown) => {
|
|
255
|
+
console.error(error);
|
|
256
|
+
if (!response.headersSent) response.writeHead(500);
|
|
257
|
+
response.end();
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (env && pathname === `${prefix}/env`) {
|
|
262
|
+
if (token && !authorized(request.headers.authorization, token)) return unauthorized(response);
|
|
263
|
+
serveEnv(request, response, token);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
serveWeb(pathname.slice(prefix.length), response);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Serve over MCP streamable HTTP instead of stdio. Opt-in; stdio stays the default transport. */
|
|
271
|
+
export async function serveHttp(options: HttpOptions = {}): Promise<void> {
|
|
272
|
+
const { port = 3000, host = "127.0.0.1", path = "/mcp", pair = false, open = false } = options;
|
|
273
|
+
const loopback = host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
274
|
+
const hostGuard = loopback ? localhostHostValidation() : undefined;
|
|
275
|
+
// `--open` needs a token for the page to call back with; an existing one is never rotated,
|
|
276
|
+
// because the browser extension holds it.
|
|
277
|
+
const token = pair ? mintToken() : (pairedToken() ?? (open ? mintToken() : undefined));
|
|
278
|
+
const httpServer = createHttpServer(handler({ path, token, hostGuard }));
|
|
279
|
+
await new Promise<void>((resolve, reject) => {
|
|
280
|
+
httpServer.once("error", reject);
|
|
281
|
+
httpServer.listen(port, host, resolve);
|
|
282
|
+
});
|
|
283
|
+
// `--http 0` asks the OS for a free port; the URL is only known once it is bound.
|
|
284
|
+
const base = `http://${host}:${(httpServer.address() as { port: number }).port}`;
|
|
285
|
+
if (pair) process.stdout.write(`${base}${path}#${token}\n`);
|
|
286
|
+
console.error(
|
|
287
|
+
`Serving MCP streamable HTTP on ${base}${path}${token ? " (bearer token required)" : ""}`,
|
|
288
|
+
);
|
|
289
|
+
if (open) openBrowser(`${base}/#${token}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Parses a manually-run `node mcp.ts [--http [port]]` invocation; the CLI's `mcp` subcommand parses its own. */
|
|
293
|
+
function httpPortArg(argv: string[]): number | undefined {
|
|
294
|
+
const index = argv.indexOf("--http");
|
|
295
|
+
if (index === -1) return undefined;
|
|
296
|
+
const value = argv[index + 1];
|
|
297
|
+
return value && !value.startsWith("-") ? Number(value) : 3000;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (process.argv[1] && /toolfactory[\\/]mcp\.[cm]?[jt]s$/.test(process.argv[1])) {
|
|
301
|
+
const port = httpPortArg(process.argv.slice(2));
|
|
302
|
+
const pair = process.argv.includes("--pair");
|
|
303
|
+
const open = process.argv.includes("--open");
|
|
304
|
+
(port === undefined && !pair && !open
|
|
305
|
+
? serve()
|
|
306
|
+
: serveHttp({ port: port ?? 3000, pair, open })
|
|
307
|
+
).catch((error) => {
|
|
308
|
+
console.error(error);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/** Host capabilities an operation may declare. Portable: net, fs, shell, secret. */
|
|
5
|
+
export type Capability =
|
|
6
|
+
| "net"
|
|
7
|
+
| "fs"
|
|
8
|
+
| "shell"
|
|
9
|
+
| "secret"
|
|
10
|
+
| "browser"
|
|
11
|
+
| "model"
|
|
12
|
+
| "user-input"
|
|
13
|
+
| "channel";
|
|
14
|
+
|
|
15
|
+
export interface Context {
|
|
16
|
+
/** Configuration values read from the environment, keyed as declared in dev.toolfactory/tool.json. */
|
|
17
|
+
config: Record<string, string | undefined>;
|
|
18
|
+
/** Directory this tool may keep its own state in. Created by the tool, not by the kernel. */
|
|
19
|
+
dataDir: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Operation<Input extends z.ZodObject = z.ZodObject, Output extends z.ZodType = z.ZodType> {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
input: Input;
|
|
26
|
+
output?: Output;
|
|
27
|
+
requires?: Capability[];
|
|
28
|
+
annotations?: {
|
|
29
|
+
readOnlyHint?: boolean;
|
|
30
|
+
destructiveHint?: boolean;
|
|
31
|
+
idempotentHint?: boolean;
|
|
32
|
+
openWorldHint?: boolean;
|
|
33
|
+
};
|
|
34
|
+
handler: (args: z.infer<Input>, context: Context) => Promise<z.infer<Output>> | z.infer<Output>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Identity function that pins the generic parameters so handlers are typed. */
|
|
38
|
+
export function operation<Input extends z.ZodObject, Output extends z.ZodType>(
|
|
39
|
+
definition: Operation<Input, Output>,
|
|
40
|
+
): Operation<Input, Output> {
|
|
41
|
+
return definition;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Capabilities every surface can satisfy on its own. */
|
|
45
|
+
export const PORTABLE: ReadonlySet<Capability> = new Set(["net", "fs", "shell", "secret"]);
|
|
46
|
+
|
|
47
|
+
/** Whether a surface can run an operation: MCP needs a portable one; a CLI also has a human for user-input. */
|
|
48
|
+
export function serves(operation: { requires?: Capability[] }, surface: "mcp" | "cli"): boolean {
|
|
49
|
+
return (operation.requires ?? []).every(
|
|
50
|
+
(capability) => PORTABLE.has(capability) || (surface === "cli" && capability === "user-input"),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Generated by toolfactory. Do not edit; run `toolfactory build` to regenerate.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { operation } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** The kernel beside this file, in the form that is running: `.ts` under tsx, `.js` from dist. */
|
|
9
|
+
const KERNEL = fileURLToPath(
|
|
10
|
+
new URL(`./mcp${extname(fileURLToPath(import.meta.url))}`, import.meta.url),
|
|
11
|
+
);
|
|
12
|
+
const SERVING = /Serving MCP streamable HTTP on (\S+)/;
|
|
13
|
+
|
|
14
|
+
export const web = operation({
|
|
15
|
+
name: "web",
|
|
16
|
+
description:
|
|
17
|
+
"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.",
|
|
18
|
+
input: z.object({}),
|
|
19
|
+
output: z.object({ url: z.string() }),
|
|
20
|
+
requires: ["shell", "net"],
|
|
21
|
+
annotations: { openWorldHint: false },
|
|
22
|
+
handler: () =>
|
|
23
|
+
new Promise<{ url: string }>((resolve, reject) => {
|
|
24
|
+
const child = spawn(process.execPath, [...process.execArgv, KERNEL, "--http", "0", "--open"], {
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
27
|
+
});
|
|
28
|
+
let output = "";
|
|
29
|
+
child.on("error", reject);
|
|
30
|
+
child.on("exit", (code) => reject(new Error(`the kernel exited with ${code}: ${output}`)));
|
|
31
|
+
child.stderr.setEncoding("utf8");
|
|
32
|
+
child.stderr.on("data", (chunk: string) => {
|
|
33
|
+
output += chunk;
|
|
34
|
+
const serving = SERVING.exec(output);
|
|
35
|
+
if (!serving) return;
|
|
36
|
+
// The child outlives this process; unref both handles so neither keeps it alive, and
|
|
37
|
+
// leave the pipe open so the kernel's own stderr never writes to a closed one.
|
|
38
|
+
child.unref();
|
|
39
|
+
(child.stderr as unknown as { unref: () => void }).unref();
|
|
40
|
+
// The page, not the endpoint, and without the token the child already handed the browser:
|
|
41
|
+
// an operation's result travels through an agent, and a token is a credential.
|
|
42
|
+
resolve({ url: new URL("/", serving[1]).href });
|
|
43
|
+
});
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Plugins has no CLI validator; the spec forbids fetching schemas at load time, so
|
|
3
|
+
* the two official 1.0.0 schemas are vendored and checked here with Ajv (draft 2020-12).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
9
|
+
|
|
10
|
+
const SCHEMA_DIR = fileURLToPath(new URL("../../schemas/agent-plugins/", import.meta.url));
|
|
11
|
+
|
|
12
|
+
export interface SchemaProblem {
|
|
13
|
+
file: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
18
|
+
return JSON.parse(readFileSync(join(SCHEMA_DIR, name), "utf8")) as Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateAgentPlugin(root: string): SchemaProblem[] {
|
|
22
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
23
|
+
const problems: SchemaProblem[] = [];
|
|
24
|
+
const targets: Array<[string, string]> = [
|
|
25
|
+
["plugin.json", "plugin.schema.json"],
|
|
26
|
+
["mcp.json", "mcp.schema.json"],
|
|
27
|
+
];
|
|
28
|
+
for (const [file, schemaName] of targets) {
|
|
29
|
+
const path = join(root, file);
|
|
30
|
+
if (!existsSync(path)) {
|
|
31
|
+
if (file === "plugin.json")
|
|
32
|
+
problems.push({ file, message: "missing (required for an Agent Plugins bundle)" });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
let document: unknown;
|
|
36
|
+
try {
|
|
37
|
+
document = JSON.parse(readFileSync(path, "utf8"));
|
|
38
|
+
} catch (error) {
|
|
39
|
+
problems.push({ file, message: `not valid JSON: ${(error as Error).message}` });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const validate = ajv.compile(loadSchema(schemaName));
|
|
43
|
+
if (!validate(document)) {
|
|
44
|
+
for (const error of validate.errors ?? []) {
|
|
45
|
+
problems.push({
|
|
46
|
+
file,
|
|
47
|
+
message: `${error.instancePath || "/"} ${error.message ?? "invalid"}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return problems;
|
|
53
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|