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.
@@ -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
+ });
@@ -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
- await tunnel.start(port, config.tunnel);
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 = t.url ? buildMcpEndpoint(t.url, token) : localUrl;
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: t.provider,
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 = await tunnel.start(port, config.tunnel);
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 = t.url ? buildMcpEndpoint(t.url, token) : localUrl;
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 (t.url) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "note-connector",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },