note-connector 0.2.2 → 0.2.4
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/dist/config.test.js +23 -0
- package/dist/daemon-worker.js +20 -3
- package/dist/runtime.js +19 -3
- package/dist/setup-dependencies.js +17 -1
- package/dist/shared-router-daemon.d.ts +1 -0
- package/dist/shared-router-daemon.js +6 -0
- package/dist/shared-router.d.ts +25 -0
- package/dist/shared-router.js +160 -0
- package/package.json +1 -1
package/dist/config.test.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import http from "node:http";
|
|
3
4
|
import { buildMcpEndpoint } from "./config.js";
|
|
5
|
+
import { buildRoutedMcpEndpoint, selectAvailableRouterPort } from "./shared-router.js";
|
|
4
6
|
test("buildMcpEndpoint", () => {
|
|
5
7
|
assert.equal(buildMcpEndpoint("https://machine.tail.ts.net/", "abc"), "https://machine.tail.ts.net/mcp?key=abc");
|
|
6
8
|
});
|
|
9
|
+
test("buildRoutedMcpEndpoint", () => {
|
|
10
|
+
assert.equal(buildRoutedMcpEndpoint("https://machine.tail.ts.net/", "/note-connector", "abc"), "https://machine.tail.ts.net/note-connector/mcp?key=abc");
|
|
11
|
+
});
|
|
12
|
+
test("selectAvailableRouterPort skips ports owned by another service", async () => {
|
|
13
|
+
const otherService = http.createServer((_req, res) => {
|
|
14
|
+
res.writeHead(404);
|
|
15
|
+
res.end("not the shared router");
|
|
16
|
+
});
|
|
17
|
+
const occupiedPort = await new Promise((resolve) => {
|
|
18
|
+
otherService.listen(0, "127.0.0.1", () => {
|
|
19
|
+
const address = otherService.address();
|
|
20
|
+
resolve(typeof address === "object" && address ? address.port : 0);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
assert.notEqual(await selectAvailableRouterPort(occupiedPort), occupiedPort);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await new Promise((resolve) => otherService.close(() => resolve()));
|
|
28
|
+
}
|
|
29
|
+
});
|
package/dist/daemon-worker.js
CHANGED
|
@@ -10,6 +10,7 @@ import { resolveGatewayPort } from "./net.js";
|
|
|
10
10
|
import { isNoteAuthenticated, startNoteLoginInBackground } from "./note-auth.js";
|
|
11
11
|
import { TunnelManager } from "./tunnel/manager.js";
|
|
12
12
|
import { saveRuntime, cliPidPath, loadLastMcpAccess } from "./daemon-state.js";
|
|
13
|
+
import { buildRoutedMcpEndpoint, ensureSharedRouterRoute } from "./shared-router.js";
|
|
13
14
|
async function verifyHealth(port) {
|
|
14
15
|
try {
|
|
15
16
|
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -55,11 +56,25 @@ export async function runDaemonWorker(opts, logFile) {
|
|
|
55
56
|
await new Promise((r) => setTimeout(r, 500));
|
|
56
57
|
}
|
|
57
58
|
if (!opts.noTunnel) {
|
|
58
|
-
|
|
59
|
+
if (config.tunnel.provider === "tailscale" && (config.tunnel.domain || config.tunnel.publicUrl)) {
|
|
60
|
+
await ensureSharedRouterRoute({
|
|
61
|
+
name: "note-connector",
|
|
62
|
+
prefix: "/note-connector",
|
|
63
|
+
target: `http://127.0.0.1:${port}`,
|
|
64
|
+
publicBaseUrl: config.tunnel.publicUrl || `https://${config.tunnel.domain}`,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
await tunnel.start(port, config.tunnel);
|
|
69
|
+
}
|
|
59
70
|
}
|
|
60
71
|
const t = tunnel.current();
|
|
61
72
|
const localUrl = buildMcpEndpoint(`http://127.0.0.1:${port}`, token);
|
|
62
|
-
const publicUrl =
|
|
73
|
+
const publicUrl = config.tunnel.provider === "tailscale" && (config.tunnel.domain || config.tunnel.publicUrl) && !opts.noTunnel
|
|
74
|
+
? buildRoutedMcpEndpoint(config.tunnel.publicUrl || `https://${config.tunnel.domain}`, "/note-connector", token)
|
|
75
|
+
: t.url
|
|
76
|
+
? buildMcpEndpoint(t.url, token)
|
|
77
|
+
: localUrl;
|
|
63
78
|
saveRuntime({
|
|
64
79
|
cliPid: process.pid,
|
|
65
80
|
port,
|
|
@@ -67,7 +82,9 @@ export async function runDaemonWorker(opts, logFile) {
|
|
|
67
82
|
localMcpUrl: localUrl,
|
|
68
83
|
startedAt,
|
|
69
84
|
logFile,
|
|
70
|
-
tunnelProvider:
|
|
85
|
+
tunnelProvider: config.tunnel.provider === "tailscale" && (config.tunnel.domain || config.tunnel.publicUrl) && !opts.noTunnel
|
|
86
|
+
? "tailscale-shared-router"
|
|
87
|
+
: t.provider,
|
|
71
88
|
});
|
|
72
89
|
if (!isNoteAuthenticated() && !opts.skipNoteLogin) {
|
|
73
90
|
startNoteLoginInBackground();
|
package/dist/runtime.js
CHANGED
|
@@ -10,6 +10,7 @@ import { isNoteAuthenticated, startNoteLoginInBackground } from "./note-auth.js"
|
|
|
10
10
|
import { spawnDaemon } from "./daemon.js";
|
|
11
11
|
import { setupDependencies } from "./setup-dependencies.js";
|
|
12
12
|
import { TunnelManager } from "./tunnel/manager.js";
|
|
13
|
+
import { buildRoutedMcpEndpoint, ensureSharedRouterRoute } from "./shared-router.js";
|
|
13
14
|
async function verifyHealth(port) {
|
|
14
15
|
try {
|
|
15
16
|
const res = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -94,7 +95,18 @@ export async function runStartForeground(opts) {
|
|
|
94
95
|
await new Promise((r) => setTimeout(r, 500));
|
|
95
96
|
}
|
|
96
97
|
if (!opts.noTunnel) {
|
|
97
|
-
const info =
|
|
98
|
+
const info = config.tunnel.provider === "tailscale" && (config.tunnel.domain || config.tunnel.publicUrl)
|
|
99
|
+
? await (async () => {
|
|
100
|
+
const publicBaseUrl = config.tunnel.publicUrl || `https://${config.tunnel.domain}`;
|
|
101
|
+
const route = await ensureSharedRouterRoute({
|
|
102
|
+
name: "note-connector",
|
|
103
|
+
prefix: "/note-connector",
|
|
104
|
+
target: `http://127.0.0.1:${port}`,
|
|
105
|
+
publicBaseUrl,
|
|
106
|
+
});
|
|
107
|
+
return { provider: "tailscale-shared-router", url: route.publicBaseUrl, status: "running" };
|
|
108
|
+
})()
|
|
109
|
+
: await tunnel.start(port, config.tunnel);
|
|
98
110
|
if (info.url) {
|
|
99
111
|
try {
|
|
100
112
|
tunnelHost = new URL(info.url).host;
|
|
@@ -110,11 +122,15 @@ export async function runStartForeground(opts) {
|
|
|
110
122
|
const token = fs.readFileSync(tokenFile, "utf8").trim();
|
|
111
123
|
const localUrl = buildMcpEndpoint(`http://127.0.0.1:${port}`, token);
|
|
112
124
|
const t = tunnel.current();
|
|
113
|
-
const publicUrl =
|
|
125
|
+
const publicUrl = config.tunnel.provider === "tailscale" && (config.tunnel.domain || config.tunnel.publicUrl) && !opts.noTunnel
|
|
126
|
+
? buildRoutedMcpEndpoint(config.tunnel.publicUrl || `https://${config.tunnel.domain}`, "/note-connector", token)
|
|
127
|
+
: t.url
|
|
128
|
+
? buildMcpEndpoint(t.url, token)
|
|
129
|
+
: localUrl;
|
|
114
130
|
console.log("");
|
|
115
131
|
console.log("note-connector is running");
|
|
116
132
|
console.log(`Local MCP: ${localUrl}`);
|
|
117
|
-
if (
|
|
133
|
+
if (publicUrl !== localUrl) {
|
|
118
134
|
console.log(`Public MCP: ${publicUrl}`);
|
|
119
135
|
}
|
|
120
136
|
else if (t.error) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { loadConfig, saveConfig } from "./config.js";
|
|
@@ -44,6 +44,21 @@ function cloneRepo(target) {
|
|
|
44
44
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
45
45
|
execFileSync("git", ["clone", "--depth", "1", DEFAULT_REPO, target], { stdio: "inherit" });
|
|
46
46
|
}
|
|
47
|
+
function updateRepo(repo) {
|
|
48
|
+
try {
|
|
49
|
+
const result = spawnSync("git", ["pull", "--ff-only", "origin", "main"], {
|
|
50
|
+
cwd: repo,
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
timeout: 15000,
|
|
53
|
+
});
|
|
54
|
+
if (result.status === 0 && !String(result.stdout).trim().includes("Already up to date")) {
|
|
55
|
+
console.log("note-connector を最新に更新しました");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Network issues or git not available — skip silently
|
|
60
|
+
}
|
|
61
|
+
}
|
|
47
62
|
function ensureRepoPath(config) {
|
|
48
63
|
if (config.repoPath && repoHasPython(config.repoPath)) {
|
|
49
64
|
return path.resolve(config.repoPath);
|
|
@@ -90,6 +105,7 @@ export async function setupDependencies() {
|
|
|
90
105
|
const config = loadConfig();
|
|
91
106
|
ensureUv();
|
|
92
107
|
const repo = ensureRepoPath(config);
|
|
108
|
+
updateRepo(repo);
|
|
93
109
|
runUvSync(repo);
|
|
94
110
|
ensurePlaywright(repo);
|
|
95
111
|
if (!commandExists("tailscale") && config.tunnel.provider === "tailscale" && !config.tunnel.publicUrl) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createSharedRouterServer, routerConfigPath } from "./shared-router.js";
|
|
3
|
+
const configFile = process.env.MCP_TAILSCALE_ROUTER_CONFIG || routerConfigPath();
|
|
4
|
+
const server = createSharedRouterServer(configFile);
|
|
5
|
+
const parsed = fs.existsSync(configFile) ? JSON.parse(fs.readFileSync(configFile, "utf8")) : {};
|
|
6
|
+
server.listen(parsed.port ?? 8790, "127.0.0.1");
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
export interface SharedRouterRoute {
|
|
3
|
+
name: string;
|
|
4
|
+
prefix: string;
|
|
5
|
+
target: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SharedRouterConfig {
|
|
8
|
+
port: number;
|
|
9
|
+
routes: Record<string, SharedRouterRoute>;
|
|
10
|
+
}
|
|
11
|
+
export interface EnsureSharedRouterOptions {
|
|
12
|
+
name: string;
|
|
13
|
+
prefix: string;
|
|
14
|
+
target: string;
|
|
15
|
+
publicBaseUrl: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function normalizeRoutePrefix(prefix: string): string;
|
|
18
|
+
export declare function buildRoutedMcpEndpoint(publicBaseUrl: string, prefix: string, token: string): string;
|
|
19
|
+
export declare function selectAvailableRouterPort(preferredPort: number): Promise<number>;
|
|
20
|
+
export declare function ensureSharedRouterRoute(options: EnsureSharedRouterOptions): Promise<{
|
|
21
|
+
publicBaseUrl: string;
|
|
22
|
+
routeBaseUrl: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare function createSharedRouterServer(configFile?: string): http.Server;
|
|
25
|
+
export declare function routerConfigPath(): string;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { resolveTailscaleBin, tailscaleEnv } from "./tunnel/tailscale.js";
|
|
8
|
+
const ROUTER_DIR = path.join(os.homedir(), ".mcp-tailscale-router");
|
|
9
|
+
const CONFIG_FILE = path.join(ROUTER_DIR, "config.json");
|
|
10
|
+
const PID_FILE = path.join(ROUTER_DIR, "router.pid");
|
|
11
|
+
const DEFAULT_PORT = 8790;
|
|
12
|
+
export function normalizeRoutePrefix(prefix) {
|
|
13
|
+
const trimmed = prefix.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
14
|
+
if (!trimmed || trimmed.includes("..") || /[^a-zA-Z0-9._-]/.test(trimmed)) {
|
|
15
|
+
throw new Error(`Invalid shared router prefix "${prefix}". Use letters, numbers, dot, underscore or dash.`);
|
|
16
|
+
}
|
|
17
|
+
return `/${trimmed}`;
|
|
18
|
+
}
|
|
19
|
+
export function buildRoutedMcpEndpoint(publicBaseUrl, prefix, token) {
|
|
20
|
+
return `${publicBaseUrl.replace(/\/$/, "")}${normalizeRoutePrefix(prefix)}/mcp?key=${encodeURIComponent(token)}`;
|
|
21
|
+
}
|
|
22
|
+
function loadRouterConfig() {
|
|
23
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
24
|
+
return { port: DEFAULT_PORT, routes: {} };
|
|
25
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
26
|
+
return { port: parsed.port ?? DEFAULT_PORT, routes: parsed.routes ?? {} };
|
|
27
|
+
}
|
|
28
|
+
function saveRouterConfig(config) {
|
|
29
|
+
fs.mkdirSync(ROUTER_DIR, { recursive: true });
|
|
30
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
31
|
+
}
|
|
32
|
+
async function routerHealthy(port) {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`http://127.0.0.1:${port}/__mcp_router/healthz`, { signal: AbortSignal.timeout(1000) });
|
|
35
|
+
return res.ok;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function portAvailable(port) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const server = net.createServer();
|
|
44
|
+
server.once("error", () => resolve(false));
|
|
45
|
+
server.listen(port, "127.0.0.1", () => {
|
|
46
|
+
server.close(() => resolve(true));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export async function selectAvailableRouterPort(preferredPort) {
|
|
51
|
+
if (await routerHealthy(preferredPort))
|
|
52
|
+
return preferredPort;
|
|
53
|
+
if (await portAvailable(preferredPort))
|
|
54
|
+
return preferredPort;
|
|
55
|
+
for (let port = DEFAULT_PORT + 1; port < DEFAULT_PORT + 100; port++) {
|
|
56
|
+
if (await routerHealthy(port))
|
|
57
|
+
return port;
|
|
58
|
+
if (await portAvailable(port))
|
|
59
|
+
return port;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`No available shared MCP router port found near ${DEFAULT_PORT}.`);
|
|
62
|
+
}
|
|
63
|
+
async function ensureRouterProcess(port) {
|
|
64
|
+
if (await routerHealthy(port))
|
|
65
|
+
return;
|
|
66
|
+
const child = spawn(process.execPath, [new URL("./shared-router-daemon.js", import.meta.url).pathname], {
|
|
67
|
+
detached: true,
|
|
68
|
+
stdio: "ignore",
|
|
69
|
+
env: { ...process.env, MCP_TAILSCALE_ROUTER_CONFIG: CONFIG_FILE },
|
|
70
|
+
});
|
|
71
|
+
child.unref();
|
|
72
|
+
fs.mkdirSync(ROUTER_DIR, { recursive: true });
|
|
73
|
+
fs.writeFileSync(PID_FILE, String(child.pid ?? ""));
|
|
74
|
+
for (let i = 0; i < 30; i++) {
|
|
75
|
+
if (await routerHealthy(port))
|
|
76
|
+
return;
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Shared MCP router did not start on port ${port}.`);
|
|
80
|
+
}
|
|
81
|
+
async function ensureTailscaleFunnel(port) {
|
|
82
|
+
const bin = resolveTailscaleBin();
|
|
83
|
+
if (!bin)
|
|
84
|
+
throw new Error("Tailscale CLI was not found. Install Tailscale or set a non-Tailscale tunnel provider.");
|
|
85
|
+
const env = tailscaleEnv();
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
const reset = spawn(bin, ["funnel", "reset"], { env, stdio: "ignore" });
|
|
88
|
+
reset.on("close", () => resolve());
|
|
89
|
+
reset.on("error", () => resolve());
|
|
90
|
+
});
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
const child = spawn(bin, ["funnel", "--bg", String(port)], { env, stdio: "ignore" });
|
|
93
|
+
child.on("error", reject);
|
|
94
|
+
child.on("close", (code) => {
|
|
95
|
+
if (code === 0)
|
|
96
|
+
resolve();
|
|
97
|
+
else
|
|
98
|
+
reject(new Error(`Tailscale Funnel failed to publish shared router on port ${port} (exit ${code}).`));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
export async function ensureSharedRouterRoute(options) {
|
|
103
|
+
const prefix = normalizeRoutePrefix(options.prefix);
|
|
104
|
+
const config = loadRouterConfig();
|
|
105
|
+
config.routes[options.name] = {
|
|
106
|
+
name: options.name,
|
|
107
|
+
prefix,
|
|
108
|
+
target: options.target.replace(/\/$/, ""),
|
|
109
|
+
};
|
|
110
|
+
config.port = await selectAvailableRouterPort(config.port);
|
|
111
|
+
saveRouterConfig(config);
|
|
112
|
+
await ensureRouterProcess(config.port);
|
|
113
|
+
await ensureTailscaleFunnel(config.port);
|
|
114
|
+
const publicBaseUrl = options.publicBaseUrl.replace(/\/$/, "");
|
|
115
|
+
return { publicBaseUrl, routeBaseUrl: `${publicBaseUrl}${prefix}` };
|
|
116
|
+
}
|
|
117
|
+
export function createSharedRouterServer(configFile = CONFIG_FILE) {
|
|
118
|
+
const readConfig = () => {
|
|
119
|
+
if (!fs.existsSync(configFile))
|
|
120
|
+
return { port: DEFAULT_PORT, routes: {} };
|
|
121
|
+
const parsed = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
|
122
|
+
return { port: parsed.port ?? DEFAULT_PORT, routes: parsed.routes ?? {} };
|
|
123
|
+
};
|
|
124
|
+
return http.createServer((req, res) => {
|
|
125
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
126
|
+
if (url.pathname === "/__mcp_router/healthz") {
|
|
127
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
128
|
+
res.end(JSON.stringify({ ok: true, service: "mcp-tailscale-router" }));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const config = readConfig();
|
|
132
|
+
const route = Object.values(config.routes).find((candidate) => {
|
|
133
|
+
return url.pathname === candidate.prefix || url.pathname.startsWith(`${candidate.prefix}/`);
|
|
134
|
+
});
|
|
135
|
+
if (!route) {
|
|
136
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
137
|
+
res.end(JSON.stringify({ error: "route_not_found" }));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const target = new URL(route.target);
|
|
141
|
+
const stripped = url.pathname.slice(route.prefix.length) || "/";
|
|
142
|
+
target.pathname = `${target.pathname.replace(/\/$/, "")}${stripped}`;
|
|
143
|
+
target.search = url.search;
|
|
144
|
+
const headers = { ...req.headers };
|
|
145
|
+
headers.host = target.host;
|
|
146
|
+
const proxy = http.request(target, { method: req.method, headers }, (upstream) => {
|
|
147
|
+
res.writeHead(upstream.statusCode ?? 502, upstream.headers);
|
|
148
|
+
upstream.pipe(res);
|
|
149
|
+
});
|
|
150
|
+
proxy.on("error", (err) => {
|
|
151
|
+
if (!res.headersSent)
|
|
152
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
153
|
+
res.end(JSON.stringify({ error: "upstream_error", message: err.message }));
|
|
154
|
+
});
|
|
155
|
+
req.pipe(proxy);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export function routerConfigPath() {
|
|
159
|
+
return CONFIG_FILE;
|
|
160
|
+
}
|