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,329 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
symlinkSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { createServer as createNetServer } from "node:http";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
import type { FullFile, PlannedFile, Project, SurfaceId } from "../model.js";
|
|
16
|
+
import { ToolConfigSchema } from "../model.js";
|
|
17
|
+
import {
|
|
18
|
+
cliCommand,
|
|
19
|
+
cli as cliFiles,
|
|
20
|
+
kernel,
|
|
21
|
+
kernelCommand,
|
|
22
|
+
liveTest,
|
|
23
|
+
scaffold,
|
|
24
|
+
} from "./typescript.js";
|
|
25
|
+
|
|
26
|
+
function project(surfaces: SurfaceId[], overrides: Partial<Project> = {}): Project {
|
|
27
|
+
return {
|
|
28
|
+
root: "/tmp/none",
|
|
29
|
+
tool: ToolConfigSchema.parse({
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
identity: "plugin.json",
|
|
32
|
+
binding: "typescript",
|
|
33
|
+
surfaces,
|
|
34
|
+
config: { type: "object", properties: { apiKey: { type: "string" } } },
|
|
35
|
+
}),
|
|
36
|
+
identity: {
|
|
37
|
+
name: "hello.ts",
|
|
38
|
+
version: "0.2.0",
|
|
39
|
+
description: "Say hello.",
|
|
40
|
+
license: "MIT",
|
|
41
|
+
repository: "https://github.com/acme/hello",
|
|
42
|
+
author: { name: "Ada", email: "ada@example.com" },
|
|
43
|
+
},
|
|
44
|
+
identityExtra: {},
|
|
45
|
+
operations: [],
|
|
46
|
+
toolfactoryVersion: "0.1.0",
|
|
47
|
+
...overrides,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A project whose config declares one required, sensitive credential: what gates the T4 tier. */
|
|
52
|
+
function withCredential(surfaces: SurfaceId[]): Project {
|
|
53
|
+
const base = project(surfaces);
|
|
54
|
+
base.tool.config = {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: { passkey: { type: "string", "x-toolfactory": { sensitive: true } } },
|
|
57
|
+
required: ["passkey"],
|
|
58
|
+
};
|
|
59
|
+
base.tool.tests = { examples: { echo: { text: "hi" } } };
|
|
60
|
+
base.operations = [{ name: "echo", inputSchema: { type: "object" }, requires: [] }];
|
|
61
|
+
return base;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const paths = (files: PlannedFile[]) => files.map((file) => file.path);
|
|
65
|
+
const text = (files: PlannedFile[], path: string) =>
|
|
66
|
+
(files.find((file) => file.path === path) as FullFile).content;
|
|
67
|
+
|
|
68
|
+
describe("typescript binding", () => {
|
|
69
|
+
it("names every artifact from the canonical name", () => {
|
|
70
|
+
const both = project(["mcp", "cli"]);
|
|
71
|
+
expect(kernelCommand().args).toEqual(["--import", "tsx", "src/toolfactory/mcp.ts"]);
|
|
72
|
+
expect(cliCommand().args.at(-1)).toBe("src/toolfactory/cli.ts");
|
|
73
|
+
expect(paths([...kernel(both), ...cliFiles(both)])).toEqual([
|
|
74
|
+
"src/toolfactory/types.ts",
|
|
75
|
+
"src/toolfactory/config.ts",
|
|
76
|
+
"src/toolfactory/mcp.ts",
|
|
77
|
+
"src/toolfactory/cli.ts",
|
|
78
|
+
]);
|
|
79
|
+
// A file exists iff a selected surface owns it, and config keys become environment reads.
|
|
80
|
+
// The kernel is the same for every surface set; only the cli surface adds cli.ts.
|
|
81
|
+
expect(paths(kernel(project(["mcp"])))).not.toContain("src/toolfactory/cli.ts");
|
|
82
|
+
expect(text(kernel(both), "src/toolfactory/config.ts")).toContain(
|
|
83
|
+
'"apiKey": process.env["APIKEY"]',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reads the data directory from <N>_DATA_DIR, else the platform default", () => {
|
|
88
|
+
const files = kernel(project(["mcp"]));
|
|
89
|
+
expect(text(files, "src/toolfactory/types.ts")).toContain("dataDir: string;");
|
|
90
|
+
const config = text(files, "src/toolfactory/config.ts");
|
|
91
|
+
expect(config).toContain('process.env["HELLO_TS_DATA_DIR"]');
|
|
92
|
+
expect(config).toContain('process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")');
|
|
93
|
+
expect(config).toContain('process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")');
|
|
94
|
+
expect(config).toContain('return join(base, "hello.ts");');
|
|
95
|
+
expect(config).toContain("dataDir: dataDir(),");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("emits the T4 live test, and its scaffold script, only for a required sensitive credential", () => {
|
|
99
|
+
// No config key is both required and sensitive: nothing gates a live run, so no live tier.
|
|
100
|
+
expect(liveTest(project(["cli"]))).toEqual([]);
|
|
101
|
+
|
|
102
|
+
const live = liveTest(withCredential(["cli"]))[0];
|
|
103
|
+
if (live?.kind !== "region") throw new Error("expected a region file");
|
|
104
|
+
expect(live.path).toBe("tests/live.test.ts");
|
|
105
|
+
// The generated region is the guard; the example test around it is the author's.
|
|
106
|
+
expect(live.regions[0]?.content).toContain('const CREDENTIALS = ["PASSKEY"];');
|
|
107
|
+
expect(live.regions[0]?.content).toContain("CREDENTIALS.every((name) => process.env[name])");
|
|
108
|
+
expect(live.template).toContain("describe.skipIf(!live)");
|
|
109
|
+
// The example arguments come from tool.json tests.examples, so the stub is a real call.
|
|
110
|
+
expect(live.template).toContain('operation.handler({"text":"hi"} as never, context())');
|
|
111
|
+
|
|
112
|
+
const scripts = (
|
|
113
|
+
JSON.parse(text(scaffold(project(["cli"])), "package.json")) as {
|
|
114
|
+
scripts: Record<string, string>;
|
|
115
|
+
}
|
|
116
|
+
).scripts;
|
|
117
|
+
expect(scripts["test:live"]).toBe(
|
|
118
|
+
"node --env-file-if-exists=.env node_modules/vitest/vitest.mjs run tests/live.test.ts",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("emits opt-in --http and --pair flags, defaulting to stdio, on the mcp module and the cli", () => {
|
|
123
|
+
const files = [...kernel(project(["mcp", "cli"])), ...cliFiles(project(["mcp", "cli"]))];
|
|
124
|
+
const mcp = text(files, "src/toolfactory/mcp.ts");
|
|
125
|
+
expect(mcp).toContain(
|
|
126
|
+
'import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"',
|
|
127
|
+
);
|
|
128
|
+
expect(mcp).toContain("export async function serveHttp(");
|
|
129
|
+
expect(mcp).toContain('port = 3000, host = "127.0.0.1", path = "/mcp"');
|
|
130
|
+
expect(mcp).toContain("toNodeHandler(createMcpHandler(createServer))");
|
|
131
|
+
// The CLI's mcp subcommand exists only when the mcp surface does (the file it imports).
|
|
132
|
+
expect(text(cliFiles(project(["cli"])), "src/toolfactory/cli.ts")).not.toContain(
|
|
133
|
+
'command("mcp")',
|
|
134
|
+
);
|
|
135
|
+
// Stdio is still the default: the standalone entrypoint only switches transport when --http is present.
|
|
136
|
+
expect(mcp).toContain("const port = httpPortArg(process.argv.slice(2));");
|
|
137
|
+
expect(mcp).toContain("port === undefined && !pair ? serve() : serveHttp({ port: port ?? 3000");
|
|
138
|
+
|
|
139
|
+
// The pairing token: `<N>_MCP_TOKEN` or a `relay-token` file under the data directory, and
|
|
140
|
+
// `--pair` to mint one. Neither present means no token and no behaviour change.
|
|
141
|
+
expect(mcp).toContain('process.env["HELLO_TS_MCP_TOKEN"]');
|
|
142
|
+
expect(mcp).toContain("join(context().dataDir, TOKEN_FILE)");
|
|
143
|
+
expect(mcp).toContain('timingSafeEqual(digest(header?.replace(/^Bearer /, "") ?? "")');
|
|
144
|
+
|
|
145
|
+
const cli = text(files, "src/toolfactory/cli.ts");
|
|
146
|
+
expect(cli).toContain('"--http [port]"');
|
|
147
|
+
expect(cli).toContain("serve, serveHttp");
|
|
148
|
+
expect(cli).toContain("if (options.http === undefined && !options.pair) await serve();");
|
|
149
|
+
expect(cli).toContain('"--pair"');
|
|
150
|
+
// Without the `web` surface there is no page to serve, open or write secrets for.
|
|
151
|
+
expect(mcp).not.toContain("WEB_DIST");
|
|
152
|
+
expect(mcp).not.toContain("SECRETS");
|
|
153
|
+
expect(paths(kernel(project(["mcp", "cli"])))).not.toContain("src/toolfactory/web.ts");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("serves the page, opens it and takes a secret when the web surface is selected", () => {
|
|
157
|
+
const target = withCredential(["mcp", "cli", "web"]);
|
|
158
|
+
const files = [...kernel(target), ...cliFiles(target)];
|
|
159
|
+
const mcp = text(files, "src/toolfactory/mcp.ts");
|
|
160
|
+
// One router, exported, so a host with its own listener mounts the same function.
|
|
161
|
+
expect(mcp).toContain("export function handler(options: HandlerOptions = {})");
|
|
162
|
+
expect(mcp).toContain('const { path = "/mcp", prefix = "", token, hostGuard, env = true }');
|
|
163
|
+
expect(mcp).toContain('fileURLToPath(new URL("../../web/dist/", import.meta.url))');
|
|
164
|
+
expect(mcp).toContain("run \\`npm -C web run build\\`");
|
|
165
|
+
// The token guards the API routes only — a browser cannot put a header on its own document.
|
|
166
|
+
expect(mcp).toContain("serveWeb(pathname.slice(prefix.length), response);");
|
|
167
|
+
expect(mcp).toContain('const SECRETS = ["PASSKEY"];');
|
|
168
|
+
expect(mcp).toContain("json(200, { declared: SECRETS, present: envPresent() })");
|
|
169
|
+
expect(mcp).toContain("chmodSync(envPath(), 0o600)");
|
|
170
|
+
expect(mcp).toContain("console.error(`.env: set ${name}`)");
|
|
171
|
+
expect(mcp).toContain("openBrowser(`${base}/#${token}`)");
|
|
172
|
+
expect(mcp).toContain("pairedToken() ?? (open ? mintToken() : undefined)");
|
|
173
|
+
|
|
174
|
+
// The `web` operation: a detached child holds the socket, never this process.
|
|
175
|
+
const web = text(files, "src/toolfactory/web.ts");
|
|
176
|
+
expect(web).toContain('[...process.execArgv, KERNEL, "--http", "0", "--open"]');
|
|
177
|
+
expect(web).toContain("detached: true");
|
|
178
|
+
expect(web).toContain("const SERVING = /Serving MCP streamable HTTP on (\\S+)/;");
|
|
179
|
+
expect(mcp).toContain("const operations = [...authored, web];");
|
|
180
|
+
expect(text(files, "src/toolfactory/cli.ts")).toContain(
|
|
181
|
+
"const operations = [...authored, web];",
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const repoRoot = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
|
|
187
|
+
const repoNodeModules = join(repoRoot, "node_modules");
|
|
188
|
+
|
|
189
|
+
function freePort(): Promise<number> {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const probe = createNetServer();
|
|
192
|
+
probe.on("error", reject);
|
|
193
|
+
probe.listen(0, "127.0.0.1", () => {
|
|
194
|
+
const address = probe.address();
|
|
195
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
196
|
+
probe.close((error) => (error ? reject(error) : resolve(port)));
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The streamable HTTP transport requires `Accept: application/json, text/event-stream` on every
|
|
202
|
+
// POST, and `MCP-Protocol-Version` on every request *after* the one that negotiates it in `initialize`.
|
|
203
|
+
async function postRpc(
|
|
204
|
+
url: string,
|
|
205
|
+
body: unknown,
|
|
206
|
+
protocolVersion?: string,
|
|
207
|
+
token?: string,
|
|
208
|
+
): Promise<{ status: number; json: unknown }> {
|
|
209
|
+
const headers: Record<string, string> = {
|
|
210
|
+
"Content-Type": "application/json",
|
|
211
|
+
Accept: "application/json, text/event-stream",
|
|
212
|
+
};
|
|
213
|
+
if (protocolVersion) headers["MCP-Protocol-Version"] = protocolVersion;
|
|
214
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
215
|
+
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
|
|
216
|
+
const raw = await response.text();
|
|
217
|
+
const isEventStream =
|
|
218
|
+
response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
219
|
+
const payload = isEventStream
|
|
220
|
+
? JSON.parse(
|
|
221
|
+
raw
|
|
222
|
+
.split("\n")
|
|
223
|
+
.find((line) => line.startsWith("data: "))
|
|
224
|
+
?.slice("data: ".length) ?? "{}",
|
|
225
|
+
)
|
|
226
|
+
: JSON.parse(raw || "{}");
|
|
227
|
+
return { status: response.status, json: payload };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Retries `initialize` until the freshly-spawned server starts accepting connections. */
|
|
231
|
+
async function waitForInitialize(
|
|
232
|
+
url: string,
|
|
233
|
+
deadlineMs: number,
|
|
234
|
+
token?: string,
|
|
235
|
+
): Promise<{ status: number; json: unknown }> {
|
|
236
|
+
const deadline = Date.now() + deadlineMs;
|
|
237
|
+
for (;;) {
|
|
238
|
+
try {
|
|
239
|
+
return await postRpc(
|
|
240
|
+
url,
|
|
241
|
+
{
|
|
242
|
+
jsonrpc: "2.0",
|
|
243
|
+
id: 1,
|
|
244
|
+
method: "initialize",
|
|
245
|
+
params: {
|
|
246
|
+
protocolVersion: "2026-07-28",
|
|
247
|
+
capabilities: {},
|
|
248
|
+
clientInfo: { name: "toolfactory-test", version: "0.0.0" },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
undefined,
|
|
252
|
+
token,
|
|
253
|
+
);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (Date.now() >= deadline) throw error;
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
describe.skipIf(!existsSync(repoNodeModules))("typescript kernel, really run over http", () => {
|
|
262
|
+
it("serves tools/list over --http, and requires the token minted by --pair", {
|
|
263
|
+
timeout: 60_000,
|
|
264
|
+
}, async () => {
|
|
265
|
+
const root = mkdtempSync(join(tmpdir(), "toolfactory-typescript-"));
|
|
266
|
+
symlinkSync(repoNodeModules, join(root, "node_modules"), "dir");
|
|
267
|
+
const real = project(["mcp", "cli"], { root, identity: { name: "probe", version: "0.1.0" } });
|
|
268
|
+
for (const file of [...scaffold(real), ...kernel(real), ...cliFiles(real)]) {
|
|
269
|
+
const filePath = join(root, file.path);
|
|
270
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
271
|
+
writeFileSync(filePath, (file as FullFile).content);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const dataDir = join(root, "data");
|
|
275
|
+
const env = { ...process.env, PROBE_DATA_DIR: dataDir };
|
|
276
|
+
const port = await freePort();
|
|
277
|
+
const { command, args } = cliCommand();
|
|
278
|
+
const child = spawn(command, [...args, "mcp", "--http", String(port)], {
|
|
279
|
+
cwd: root,
|
|
280
|
+
env,
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
282
|
+
});
|
|
283
|
+
const stderr: string[] = [];
|
|
284
|
+
child.stderr?.on("data", (chunk: Buffer) => stderr.push(String(chunk)));
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const url = `http://127.0.0.1:${port}/mcp`;
|
|
288
|
+
const init = await waitForInitialize(url, 20_000).catch((error) => {
|
|
289
|
+
throw new Error(`${error instanceof Error ? error.message : error}\n${stderr.join("")}`);
|
|
290
|
+
});
|
|
291
|
+
expect(init.status).toBe(200);
|
|
292
|
+
const negotiated = (init.json as { result: { protocolVersion: string } }).result
|
|
293
|
+
.protocolVersion;
|
|
294
|
+
|
|
295
|
+
const list = await postRpc(url, { jsonrpc: "2.0", id: 2, method: "tools/list" }, negotiated);
|
|
296
|
+
expect(list.status).toBe(200);
|
|
297
|
+
const tools = (list.json as { result: { tools: { name: string }[] } }).result.tools;
|
|
298
|
+
expect(tools.map((tool) => tool.name)).toEqual(["echo"]);
|
|
299
|
+
} finally {
|
|
300
|
+
child.kill();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The same server started with `--pair`: it prints the pairing string the extension's options
|
|
304
|
+
// page takes, keeps the token under the data directory, and refuses a request without it.
|
|
305
|
+
const pairPort = await freePort();
|
|
306
|
+
const paired = spawn(command, [...args, "mcp", "--http", String(pairPort), "--pair"], {
|
|
307
|
+
cwd: root,
|
|
308
|
+
env,
|
|
309
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
310
|
+
});
|
|
311
|
+
const stdout: string[] = [];
|
|
312
|
+
paired.stdout?.on("data", (chunk: Buffer) => stdout.push(String(chunk)));
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const url = `http://127.0.0.1:${pairPort}/mcp`;
|
|
316
|
+
const anonymous = await waitForInitialize(url, 20_000);
|
|
317
|
+
expect(anonymous.status).toBe(401);
|
|
318
|
+
|
|
319
|
+
const [printed, token] = stdout.join("").trim().split("#");
|
|
320
|
+
expect(printed).toBe(url);
|
|
321
|
+
expect(readFileSync(join(dataDir, "relay-token"), "utf8").trim()).toBe(token);
|
|
322
|
+
|
|
323
|
+
const init = await waitForInitialize(url, 20_000, token);
|
|
324
|
+
expect(init.status).toBe(200);
|
|
325
|
+
} finally {
|
|
326
|
+
paired.kill();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|