feat(connectd): native-binary connectors + companion sidecar lifecycle #2
@@ -1,3 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
*.log
|
*.log
|
||||||
.skills-sync*
|
.skills-sync*
|
||||||
|
|
||||||
|
# esbuild single-file bundle + pre-bundled servers (produced by npm run bundle:all / DMG build)
|
||||||
|
dist/connectd.cjs
|
||||||
|
dist/servers/
|
||||||
|
|||||||
Vendored
+88
-3
@@ -3,8 +3,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
import { saveConfig } from "./config.js";
|
import { saveConfig } from "./config.js";
|
||||||
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
||||||
|
import { googleClientConfigured, buildGoogleAuthUrl, exchangeGoogleCode, getValidGoogleAccessToken, clearGoogleTokens, } from "./google-oauth.js";
|
||||||
import { kcGet, kcSet, kcDelete } from "./keychain.js";
|
import { kcGet, kcSet, kcDelete } from "./keychain.js";
|
||||||
// Namespacing: every external tool is exposed to the soul as
|
// Namespacing: every external tool is exposed to the soul as
|
||||||
// mcp__<serverId>__<toolName>
|
// mcp__<serverId>__<toolName>
|
||||||
@@ -14,6 +16,23 @@ const SEP = "__";
|
|||||||
export function namespacedName(serverId, tool) {
|
export function namespacedName(serverId, tool) {
|
||||||
return `${PREFIX}${serverId}${SEP}${tool}`;
|
return `${PREFIX}${serverId}${SEP}${tool}`;
|
||||||
}
|
}
|
||||||
|
// ── Self-contained runtime resolution ────────────────────────────────────────────────────────
|
||||||
|
// So connectors work on a clean Mac with no Node/npm installed, a connector config can reference:
|
||||||
|
// command "@node" → the JS runtime running this bridge (process.execPath = the bundled
|
||||||
|
// Node we ship in the .app), used to spawn pre-bundled MCP servers.
|
||||||
|
// arg "@bundled/<file>" → a pre-bundled MCP server shipped alongside the bridge (e.g. the
|
||||||
|
// filesystem server for "Your files"), so it runs offline without npx.
|
||||||
|
// The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons
|
||||||
|
// launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle).
|
||||||
|
function bundledServersDir() {
|
||||||
|
return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers");
|
||||||
|
}
|
||||||
|
function resolveRuntimeCommand(command) {
|
||||||
|
return command === "@node" ? process.execPath : command;
|
||||||
|
}
|
||||||
|
function resolveBundledArg(arg) {
|
||||||
|
return arg.startsWith("@bundled/") ? join(bundledServersDir(), arg.slice("@bundled/".length)) : arg;
|
||||||
|
}
|
||||||
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
|
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
|
||||||
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
|
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
|
||||||
export function parseName(full) {
|
export function parseName(full) {
|
||||||
@@ -88,6 +107,7 @@ export class ConnectorBridge {
|
|||||||
this.servers.delete(id);
|
this.servers.delete(id);
|
||||||
saveConfig(this.config);
|
saveConfig(this.config);
|
||||||
await kcDelete(id).catch(() => { }); // drop any stored secret for this connector
|
await kcDelete(id).catch(() => { }); // drop any stored secret for this connector
|
||||||
|
await clearGoogleTokens(id).catch(() => { }); // and any Google tokens
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
// Tear down an existing client (if any) and connect fresh from current config.
|
// Tear down an existing client (if any) and connect fresh from current config.
|
||||||
@@ -118,15 +138,52 @@ export class ConnectorBridge {
|
|||||||
this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` });
|
this.servers.set(id, { id, cfg, status: "error", tools: [], error: `unknown transport "${cfg.transport}"` });
|
||||||
}
|
}
|
||||||
async connectStdio(id, cfg) {
|
async connectStdio(id, cfg) {
|
||||||
|
// Google connectors (auth: "google") resolve a fresh access token first. Two honest pre-connect
|
||||||
|
// states replace the old "launch npx with no creds and crash" failure:
|
||||||
|
// needs_setup — the Neuron Google OAuth client isn't shipped/configured yet (pre-verification)
|
||||||
|
// needs_auth — client ready, but this user hasn't completed the one-click sign-in
|
||||||
|
let extraEnv = {};
|
||||||
|
if (cfg.auth === "google") {
|
||||||
|
if (!googleClientConfigured()) {
|
||||||
|
this.servers.set(id, {
|
||||||
|
id, cfg, status: "needs_setup", tools: [],
|
||||||
|
error: "Secure Google sign-in is coming soon — we're finishing setup. Nothing for you to do.",
|
||||||
|
});
|
||||||
|
console.error(`[connectd] ${id}: Google client not configured — needs_setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await getValidGoogleAccessToken(id);
|
||||||
|
if (!token) {
|
||||||
|
this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "sign-in required" });
|
||||||
|
console.error(`[connectd] ${id}: no Google token — needs_auth`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hand the access token to the Google MCP server via env. Which exact variable the chosen
|
||||||
|
// server reads is a Phase-4 certification detail (see google-oauth-verification-checklist.md),
|
||||||
|
// not a code change — we set the common names so swapping servers stays config-only.
|
||||||
|
extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: cfg.command,
|
command: resolveRuntimeCommand(cfg.command),
|
||||||
args: cfg.args ?? [],
|
args: (cfg.args ?? []).map(resolveBundledArg),
|
||||||
env: { ...process.env, ...(cfg.env ?? {}) },
|
env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv },
|
||||||
});
|
});
|
||||||
await this.finishConnect(id, cfg, transport);
|
await this.finishConnect(id, cfg, transport);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
// A missing runtime/command (e.g. npx not installed on a clean Mac for a not-yet-bundled
|
||||||
|
// connector) surfaces as ENOENT. Show an honest "coming soon" state, never a cryptic spawn
|
||||||
|
// error — the connector just isn't available on this device yet.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ENOENT/.test(msg)) {
|
||||||
|
this.servers.set(id, {
|
||||||
|
id, cfg, status: "needs_setup", tools: [],
|
||||||
|
error: "This connector isn't available on this device yet — we're finishing setup.",
|
||||||
|
});
|
||||||
|
console.error(`[connectd] ${id}: runtime/command unavailable (${msg}) — needs_setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setError(id, cfg, err);
|
this.setError(id, cfg, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,6 +303,34 @@ export class ConnectorBridge {
|
|||||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser.
|
||||||
|
async startGoogleOAuth(id) {
|
||||||
|
const cfg = this.config.servers[id];
|
||||||
|
if (!cfg)
|
||||||
|
return { ok: false, error: `unknown server: ${id}` };
|
||||||
|
if (cfg.auth !== "google")
|
||||||
|
return { ok: false, error: `${id} is not a Google connector` };
|
||||||
|
if (!googleClientConfigured())
|
||||||
|
return { ok: false, error: "Google sign-in is being set up" };
|
||||||
|
const url = buildGoogleAuthUrl(id, cfg.scopes ?? []);
|
||||||
|
if (!url)
|
||||||
|
return { ok: false, error: "could not build Google authorization URL" };
|
||||||
|
return { ok: true, authUrl: url };
|
||||||
|
}
|
||||||
|
// Complete a Google sign-in with the code from the callback: store tokens, reconnect, list tools.
|
||||||
|
async finishGoogleOAuth(id, code) {
|
||||||
|
try {
|
||||||
|
await exchangeGoogleCode(id, code);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
await this.reconnectServer(id);
|
||||||
|
const now = this.servers.get(id);
|
||||||
|
return now?.status === "connected"
|
||||||
|
? { ok: true }
|
||||||
|
: { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` };
|
||||||
|
}
|
||||||
// Merged, namespaced tool list across all connected servers.
|
// Merged, namespaced tool list across all connected servers.
|
||||||
allTools() {
|
allTools() {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|||||||
Vendored
+155
@@ -0,0 +1,155 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js";
|
||||||
|
import { HOST, PORT } from "./config.js";
|
||||||
|
// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ─────────────────────
|
||||||
|
//
|
||||||
|
// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a
|
||||||
|
// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the
|
||||||
|
// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the
|
||||||
|
// resulting refresh token in the macOS Keychain (account "<id>:google"). The stdio MCP server is
|
||||||
|
// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in
|
||||||
|
// the browser. No files, no terminal.
|
||||||
|
//
|
||||||
|
// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be
|
||||||
|
// present, and the app must clear Google's verification for the requested scopes (see
|
||||||
|
// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured,
|
||||||
|
// googleClientConfigured() is false and callers surface an honest "being set up" state rather than
|
||||||
|
// launching a server that will crash with no credentials.
|
||||||
|
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
||||||
|
// The loopback redirect must match exactly what's registered on the Google desktop OAuth client.
|
||||||
|
export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`;
|
||||||
|
function clientId() {
|
||||||
|
const v = process.env.GOOGLE_OAUTH_CLIENT_ID;
|
||||||
|
return v && v.trim() ? v.trim() : undefined;
|
||||||
|
}
|
||||||
|
function clientSecret() {
|
||||||
|
const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
|
||||||
|
return v && v.trim() ? v.trim() : undefined;
|
||||||
|
}
|
||||||
|
// True once the app is shipped with a real Google OAuth client. Gates the whole flow.
|
||||||
|
export function googleClientConfigured() {
|
||||||
|
return clientId() !== undefined;
|
||||||
|
}
|
||||||
|
const pending = new Map();
|
||||||
|
function base64url(buf) {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id.
|
||||||
|
// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup).
|
||||||
|
export function buildGoogleAuthUrl(id, scopes) {
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid)
|
||||||
|
return undefined;
|
||||||
|
const verifier = base64url(randomBytes(32));
|
||||||
|
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||||
|
pending.set(id, { verifier, scopes });
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||||
|
response_type: "code",
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
access_type: "offline", // ask for a refresh token
|
||||||
|
prompt: "consent", // force refresh-token issuance on re-consent
|
||||||
|
state: id, // carry the connector id through the round-trip
|
||||||
|
});
|
||||||
|
return `${AUTH_ENDPOINT}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token.
|
||||||
|
// Throws on any failure (missing flow, Google error) so the caller can report it honestly.
|
||||||
|
export async function exchangeGoogleCode(id, code) {
|
||||||
|
const flow = pending.get(id);
|
||||||
|
pending.delete(id);
|
||||||
|
if (!flow)
|
||||||
|
throw new Error(`no pending Google sign-in for ${id}`);
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid)
|
||||||
|
throw new Error("Google OAuth client not configured");
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
code,
|
||||||
|
code_verifier: flow.verifier,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||||
|
});
|
||||||
|
// Desktop clients may still carry a (non-secret) client secret; include it when present.
|
||||||
|
const secret = clientSecret();
|
||||||
|
if (secret)
|
||||||
|
body.set("client_secret", secret);
|
||||||
|
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const json = (await resp.json());
|
||||||
|
if (!resp.ok)
|
||||||
|
throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim());
|
||||||
|
if (!json.refresh_token) {
|
||||||
|
throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)");
|
||||||
|
}
|
||||||
|
const tokens = {
|
||||||
|
refresh_token: json.refresh_token,
|
||||||
|
access_token: json.access_token,
|
||||||
|
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||||
|
scope: json.scope,
|
||||||
|
};
|
||||||
|
await kcSetJson(`${id}:google`, tokens);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
// Return a currently-valid access token for this connector, refreshing if expired. Returns
|
||||||
|
// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth.
|
||||||
|
export async function getValidGoogleAccessToken(id) {
|
||||||
|
const stored = await kcGetJson(`${id}:google`);
|
||||||
|
if (!stored?.refresh_token)
|
||||||
|
return undefined;
|
||||||
|
// 60s skew so we never hand out a token that's about to expire mid-call.
|
||||||
|
if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) {
|
||||||
|
return stored.access_token;
|
||||||
|
}
|
||||||
|
return refreshGoogleToken(id, stored);
|
||||||
|
}
|
||||||
|
async function refreshGoogleToken(id, stored) {
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid)
|
||||||
|
return undefined;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
refresh_token: stored.refresh_token,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
});
|
||||||
|
const secret = clientSecret();
|
||||||
|
if (secret)
|
||||||
|
body.set("client_secret", secret);
|
||||||
|
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const json = (await resp.json());
|
||||||
|
if (!resp.ok || !json.access_token)
|
||||||
|
return undefined;
|
||||||
|
const updated = {
|
||||||
|
refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original
|
||||||
|
access_token: json.access_token,
|
||||||
|
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||||
|
scope: json.scope ?? stored.scope,
|
||||||
|
};
|
||||||
|
await kcSetJson(`${id}:google`, updated);
|
||||||
|
return updated.access_token;
|
||||||
|
}
|
||||||
|
// True once the user has completed sign-in for this connector (a refresh token is stored).
|
||||||
|
export async function hasGoogleTokens(id) {
|
||||||
|
const stored = await kcGetJson(`${id}:google`);
|
||||||
|
return !!stored?.refresh_token;
|
||||||
|
}
|
||||||
|
// Forget a connector's Google tokens (on remove / sign-out).
|
||||||
|
export async function clearGoogleTokens(id) {
|
||||||
|
await kcDelete(`${id}:google`);
|
||||||
|
}
|
||||||
|
// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the
|
||||||
|
// bridge process (unlike the El soul, this is plain Node).
|
||||||
|
function nowMs() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
Vendored
+21
@@ -85,6 +85,27 @@ export function startServer(bridge) {
|
|||||||
const result = await bridge.finishOAuth(id, code);
|
const result = await bridge.finishOAuth(id, code);
|
||||||
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
||||||
}
|
}
|
||||||
|
// POST /google/oauth/start { id } — begin a Google one-click sign-in, return the consent URL.
|
||||||
|
if (method === "POST" && url === "/google/oauth/start") {
|
||||||
|
const parsed = await parseJsonBody(req);
|
||||||
|
if (!parsed || !parsed.id)
|
||||||
|
return sendJson(res, 400, { ok: false, error: "missing id" });
|
||||||
|
const result = await bridge.startGoogleOAuth(parsed.id);
|
||||||
|
return sendJson(res, result.ok ? 200 : 400, result);
|
||||||
|
}
|
||||||
|
// GET /google/callback?code=..&state=.. — Google redirects here after consent.
|
||||||
|
if (method === "GET" && url.startsWith("/google/callback")) {
|
||||||
|
const q = new URL(url, `http://${HOST}:${PORT}`).searchParams;
|
||||||
|
const id = q.get("state"); // we pass the connector id as `state`
|
||||||
|
const code = q.get("code");
|
||||||
|
const err = q.get("error");
|
||||||
|
if (err)
|
||||||
|
return sendHtml(res, 400, authResultPage(false, err));
|
||||||
|
if (!id || !code)
|
||||||
|
return sendHtml(res, 400, authResultPage(false, "missing id or code"));
|
||||||
|
const result = await bridge.finishGoogleOAuth(id, code);
|
||||||
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
||||||
|
}
|
||||||
// GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the
|
// GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the
|
||||||
// approval card (per-connector opt-in, off by default).
|
// approval card (per-connector opt-in, off by default).
|
||||||
if (method === "GET" && url === "/mcp/auto-approved") {
|
if (method === "GET" && url === "/mcp/auto-approved") {
|
||||||
|
|||||||
Generated
+471
-1
@@ -8,13 +8,15 @@
|
|||||||
"name": "neuron-connectd",
|
"name": "neuron-connectd",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@modelcontextprotocol/server-filesystem": "^2026.1.14"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"neuron-connectd": "dist/index.js"
|
"neuron-connectd": "dist/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
}
|
}
|
||||||
@@ -473,6 +475,23 @@
|
|||||||
"hono": "^4"
|
"hono": "^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@isaacs/cliui": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^5.1.2",
|
||||||
|
"string-width-cjs": "npm:string-width@^4.2.0",
|
||||||
|
"strip-ansi": "^7.0.1",
|
||||||
|
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||||
|
"wrap-ansi": "^8.1.0",
|
||||||
|
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -513,6 +532,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/server-filesystem": {
|
||||||
|
"version": "2026.7.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-filesystem/-/server-filesystem-2026.7.10.tgz",
|
||||||
|
"integrity": "sha512-Mmjg4anFBD5OzbPnGJOA0jPPN8645ERhQk38HQLpSenx1ox9bfdPkmAzUnNjeQtqQGFLtKe13J20RtLBmUKMZA==",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
|
"glob": "^10.5.0",
|
||||||
|
"minimatch": "^10.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mcp-server-filesystem": "dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pkgjs/parseargs": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||||
@@ -569,6 +613,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-regex": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "6.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||||
|
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||||
@@ -593,6 +670,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||||
|
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -631,6 +720,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -728,6 +835,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "8.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
|
||||||
|
"integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -742,12 +858,24 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eastasianwidth": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ee-first": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "9.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||||
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -969,6 +1097,22 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/foreground-child": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.6",
|
||||||
|
"signal-exit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1048,6 +1192,57 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob": {
|
||||||
|
"version": "10.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
|
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||||
|
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"foreground-child": "^3.1.0",
|
||||||
|
"jackspeak": "^3.1.2",
|
||||||
|
"minimatch": "^9.0.4",
|
||||||
|
"minipass": "^7.1.2",
|
||||||
|
"package-json-from-dist": "^1.0.0",
|
||||||
|
"path-scurry": "^1.11.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"glob": "dist/esm/bin.mjs"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -1153,6 +1348,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -1165,6 +1369,21 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jackspeak": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@isaacs/cliui": "^8.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@pkgjs/parseargs": "^0.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jose": {
|
"node_modules/jose": {
|
||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
@@ -1186,6 +1405,12 @@
|
|||||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "10.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||||
|
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -1241,6 +1466,30 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimatch": {
|
||||||
|
"version": "10.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^5.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1298,6 +1547,12 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/package-json-from-dist": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||||
|
"license": "BlueOak-1.0.0"
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1316,6 +1571,22 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/path-scurry": {
|
||||||
|
"version": "1.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lru-cache": "^10.2.0",
|
||||||
|
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16 || 14 >=14.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
@@ -1562,6 +1833,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -1571,6 +1854,102 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eastasianwidth": "^0.2.0",
|
||||||
|
"emoji-regex": "^9.2.2",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs": {
|
||||||
|
"name": "string-width",
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/string-width-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^6.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs": {
|
||||||
|
"name": "strip-ansi",
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -1684,6 +2063,97 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^6.1.0",
|
||||||
|
"string-width": "^5.0.1",
|
||||||
|
"strip-ansi": "^7.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs": {
|
||||||
|
"name": "wrap-ansi",
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-regex": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
|||||||
+6
-1
@@ -10,12 +10,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --target=node20 --outfile=dist/connectd.cjs",
|
||||||
|
"bundle:servers": "esbuild node_modules/@modelcontextprotocol/server-filesystem/dist/index.js --bundle --platform=node --format=esm --target=node20 --banner:js=\"import{createRequire as __cr}from'module';const require=__cr(import.meta.url);\" --outfile=dist/servers/fs-server.mjs",
|
||||||
|
"bundle:all": "npm run bundle && npm run bundle:servers",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"@modelcontextprotocol/server-filesystem": "^2026.1.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"@types/node": "^24.0.0"
|
"@types/node": "^24.0.0"
|
||||||
|
|||||||
+171
-4
@@ -3,9 +3,20 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import type { ConnectorsConfig, ServerConfig } from "./config.js";
|
import type { ConnectorsConfig, ServerConfig } from "./config.js";
|
||||||
import { saveConfig } from "./config.js";
|
import { saveConfig } from "./config.js";
|
||||||
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
import { KeychainOAuthProvider, takePendingAuthUrl } from "./oauth.js";
|
||||||
|
import {
|
||||||
|
googleClientConfigured,
|
||||||
|
buildGoogleAuthUrl,
|
||||||
|
exchangeGoogleCode,
|
||||||
|
getValidGoogleAccessToken,
|
||||||
|
clearGoogleTokens,
|
||||||
|
} from "./google-oauth.js";
|
||||||
import { kcGet, kcSet, kcDelete } from "./keychain.js";
|
import { kcGet, kcSet, kcDelete } from "./keychain.js";
|
||||||
|
|
||||||
// Namespacing: every external tool is exposed to the soul as
|
// Namespacing: every external tool is exposed to the soul as
|
||||||
@@ -18,6 +29,31 @@ export function namespacedName(serverId: string, tool: string): string {
|
|||||||
return `${PREFIX}${serverId}${SEP}${tool}`;
|
return `${PREFIX}${serverId}${SEP}${tool}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Self-contained runtime resolution ────────────────────────────────────────────────────────
|
||||||
|
// So connectors work on a clean Mac with no Node/npm installed, a connector config can reference:
|
||||||
|
// command "@node" → the JS runtime running this bridge (process.execPath = the bundled
|
||||||
|
// Node we ship in the .app), used to spawn pre-bundled MCP servers.
|
||||||
|
// arg "@bundled/<file>" → a pre-bundled MCP server shipped alongside the bridge (e.g. the
|
||||||
|
// filesystem server for "Your files"), so it runs offline without npx.
|
||||||
|
// command "@bin/<file>" → a pre-bundled NATIVE MCP server binary shipped alongside the bridge
|
||||||
|
// (e.g. the Go whatsapp-mcp), spawned directly (no Node runtime).
|
||||||
|
// The bundled-servers dir is the app's resources/.../neuron-connectd/servers (set by the daemons
|
||||||
|
// launcher via NEURON_CONNECTD_BUNDLE_DIR; falls back to a path next to the running bundle).
|
||||||
|
function bundledServersDir(): string {
|
||||||
|
return process.env.NEURON_CONNECTD_BUNDLE_DIR ?? join(dirname(process.argv[1] ?? process.cwd()), "servers");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeCommand(command: string): string {
|
||||||
|
if (command === "@node") return process.execPath;
|
||||||
|
// "@bin/<file>": a bundled native binary (e.g. whatsapp-mcp). Resolve to the bundled-servers dir.
|
||||||
|
if (command.startsWith("@bin/")) return join(bundledServersDir(), command.slice("@bin/".length));
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBundledArg(arg: string): string {
|
||||||
|
return arg.startsWith("@bundled/") ? join(bundledServersDir(), arg.slice("@bundled/".length)) : arg;
|
||||||
|
}
|
||||||
|
|
||||||
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
|
// Split "mcp__filesystem__read_file" -> { serverId:"filesystem", tool:"read_file" }.
|
||||||
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
|
// Tool names may themselves contain "__", so we only split on the FIRST two seps.
|
||||||
export function parseName(full: string): { serverId: string; tool: string } | null {
|
export function parseName(full: string): { serverId: string; tool: string } | null {
|
||||||
@@ -35,7 +71,10 @@ export interface ExposedTool {
|
|||||||
input_schema: unknown;
|
input_schema: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerStatus = "connected" | "needs_auth" | "error" | "disabled";
|
// needs_setup: a Google connector whose app-level OAuth client isn't configured yet (pre-launch).
|
||||||
|
// Distinct from needs_auth (client ready, this user just hasn't signed in) so the UI can say
|
||||||
|
// "being set up" instead of "sign in", and never drops the user into a broken flow.
|
||||||
|
type ServerStatus = "connected" | "needs_auth" | "needs_setup" | "error" | "disabled";
|
||||||
|
|
||||||
interface ServerEntry {
|
interface ServerEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,6 +94,9 @@ interface ServerEntry {
|
|||||||
|
|
||||||
export class ConnectorBridge {
|
export class ConnectorBridge {
|
||||||
private servers = new Map<string, ServerEntry>();
|
private servers = new Map<string, ServerEntry>();
|
||||||
|
// Companion sidecar processes (e.g. the WhatsApp bridge), keyed by connector id. Started on
|
||||||
|
// connect, killed on disconnect/shutdown. v1 lifecycle = child-of-connectd (dies with the app).
|
||||||
|
private companions = new Map<string, ChildProcess>();
|
||||||
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
|
// The live config the bridge owns and persists. UI edits mutate this and call saveConfig.
|
||||||
private config: ConnectorsConfig = { servers: {} };
|
private config: ConnectorsConfig = { servers: {} };
|
||||||
|
|
||||||
@@ -114,6 +156,7 @@ export class ConnectorBridge {
|
|||||||
this.servers.delete(id);
|
this.servers.delete(id);
|
||||||
saveConfig(this.config);
|
saveConfig(this.config);
|
||||||
await kcDelete(id).catch(() => {}); // drop any stored secret for this connector
|
await kcDelete(id).catch(() => {}); // drop any stored secret for this connector
|
||||||
|
await clearGoogleTokens(id).catch(() => {}); // and any Google tokens
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +172,54 @@ export class ConnectorBridge {
|
|||||||
if (entry?.client) {
|
if (entry?.client) {
|
||||||
try { await entry.client.close(); } catch { /* best effort */ }
|
try { await entry.client.close(); } catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
const comp = this.companions.get(id);
|
||||||
|
if (comp) {
|
||||||
|
try { comp.kill(); } catch { /* best effort */ }
|
||||||
|
this.companions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a connector's companion sidecar is running before we spawn its MCP server.
|
||||||
|
// If healthUrl already responds, something is serving (reuse it). Otherwise spawn the companion
|
||||||
|
// as a child and poll healthUrl until it's up (or time out). Throws if it never becomes healthy.
|
||||||
|
private async ensureCompanion(id: string, comp: NonNullable<ServerConfig["companion"]>): Promise<void> {
|
||||||
|
const ping = async (): Promise<boolean> => {
|
||||||
|
if (!comp.healthUrl) return false;
|
||||||
|
try {
|
||||||
|
const r = await fetch(comp.healthUrl, { signal: AbortSignal.timeout(1500) });
|
||||||
|
return r.ok || r.status === 401; // 401 = up but unauthorized (still "running")
|
||||||
|
} catch { return false; }
|
||||||
|
};
|
||||||
|
if (await ping()) return; // already running (this session or a prior one)
|
||||||
|
if (this.companions.has(id)) return; // we already started it
|
||||||
|
let cwd: string | undefined;
|
||||||
|
if (comp.cwd) {
|
||||||
|
cwd = comp.cwd.replace(/^~(?=\/|$)/, homedir()).replace(/^\$HOME(?=\/|$)/, homedir());
|
||||||
|
try { mkdirSync(cwd, { recursive: true }); } catch { /* best effort — spawn will surface a real failure */ }
|
||||||
|
}
|
||||||
|
const child = spawn(
|
||||||
|
resolveRuntimeCommand(comp.command),
|
||||||
|
(comp.args ?? []).map(resolveBundledArg),
|
||||||
|
{ env: { ...process.env, ...(comp.env ?? {}) } as Record<string, string>, stdio: "ignore", cwd },
|
||||||
|
);
|
||||||
|
this.companions.set(id, child);
|
||||||
|
child.on("exit", () => this.companions.delete(id));
|
||||||
|
if (!comp.healthUrl) return; // no health probe configured — best-effort start
|
||||||
|
const deadline = Date.now() + 15000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
if (await ping()) return;
|
||||||
|
}
|
||||||
|
throw new Error(`companion for "${id}" did not become healthy within 15s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill every companion sidecar — called on connectd shutdown so we never orphan a child
|
||||||
|
// process (e.g. the WhatsApp bridge) when the app/daemon exits.
|
||||||
|
disposeCompanions(): void {
|
||||||
|
for (const [id, child] of this.companions) {
|
||||||
|
try { child.kill(); } catch { /* best effort */ }
|
||||||
|
this.companions.delete(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connectServer(id: string, cfg: ServerConfig): Promise<void> {
|
private async connectServer(id: string, cfg: ServerConfig): Promise<void> {
|
||||||
@@ -142,14 +233,65 @@ export class ConnectorBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async connectStdio(id: string, cfg: ServerConfig): Promise<void> {
|
private async connectStdio(id: string, cfg: ServerConfig): Promise<void> {
|
||||||
|
// Google connectors (auth: "google") resolve a fresh access token first. Two honest pre-connect
|
||||||
|
// states replace the old "launch npx with no creds and crash" failure:
|
||||||
|
// needs_setup — the Neuron Google OAuth client isn't shipped/configured yet (pre-verification)
|
||||||
|
// needs_auth — client ready, but this user hasn't completed the one-click sign-in
|
||||||
|
let extraEnv: Record<string, string> = {};
|
||||||
|
if (cfg.auth === "google") {
|
||||||
|
if (!googleClientConfigured()) {
|
||||||
|
this.servers.set(id, {
|
||||||
|
id, cfg, status: "needs_setup", tools: [],
|
||||||
|
error: "Secure Google sign-in is coming soon — we're finishing setup. Nothing for you to do.",
|
||||||
|
});
|
||||||
|
console.error(`[connectd] ${id}: Google client not configured — needs_setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await getValidGoogleAccessToken(id);
|
||||||
|
if (!token) {
|
||||||
|
this.servers.set(id, { id, cfg, status: "needs_auth", tools: [], error: "sign-in required" });
|
||||||
|
console.error(`[connectd] ${id}: no Google token — needs_auth`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Hand the access token to the Google MCP server via env. Which exact variable the chosen
|
||||||
|
// server reads is a Phase-4 certification detail (see google-oauth-verification-checklist.md),
|
||||||
|
// not a code change — we set the common names so swapping servers stays config-only.
|
||||||
|
extraEnv = { GOOGLE_ACCESS_TOKEN: token, GOOGLE_OAUTH_ACCESS_TOKEN: token };
|
||||||
|
}
|
||||||
|
// Start the companion sidecar (e.g. the WhatsApp bridge) before the MCP server that talks to it.
|
||||||
|
if (cfg.companion) {
|
||||||
|
try {
|
||||||
|
await this.ensureCompanion(id, cfg.companion);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
this.servers.set(id, {
|
||||||
|
id, cfg, status: "needs_setup", tools: [],
|
||||||
|
error: "Couldn't start the connector's helper service — try toggling it off and on.",
|
||||||
|
});
|
||||||
|
console.error(`[connectd] ${id}: companion failed (${msg}) — needs_setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: cfg.command!,
|
command: resolveRuntimeCommand(cfg.command!),
|
||||||
args: cfg.args ?? [],
|
args: (cfg.args ?? []).map(resolveBundledArg),
|
||||||
env: { ...process.env, ...(cfg.env ?? {}) } as Record<string, string>,
|
env: { ...process.env, ...(cfg.env ?? {}), ...extraEnv } as Record<string, string>,
|
||||||
});
|
});
|
||||||
await this.finishConnect(id, cfg, transport);
|
await this.finishConnect(id, cfg, transport);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// A missing runtime/command (e.g. npx not installed on a clean Mac for a not-yet-bundled
|
||||||
|
// connector) surfaces as ENOENT. Show an honest "coming soon" state, never a cryptic spawn
|
||||||
|
// error — the connector just isn't available on this device yet.
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/ENOENT/.test(msg)) {
|
||||||
|
this.servers.set(id, {
|
||||||
|
id, cfg, status: "needs_setup", tools: [],
|
||||||
|
error: "This connector isn't available on this device yet — we're finishing setup.",
|
||||||
|
});
|
||||||
|
console.error(`[connectd] ${id}: runtime/command unavailable (${msg}) — needs_setup`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setError(id, cfg, err);
|
this.setError(id, cfg, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,6 +415,31 @@ export class ConnectorBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Begin a Google one-click sign-in: returns the consent URL for the UI to open in the browser.
|
||||||
|
async startGoogleOAuth(id: string): Promise<{ ok: boolean; authUrl?: string; error?: string }> {
|
||||||
|
const cfg = this.config.servers[id];
|
||||||
|
if (!cfg) return { ok: false, error: `unknown server: ${id}` };
|
||||||
|
if (cfg.auth !== "google") return { ok: false, error: `${id} is not a Google connector` };
|
||||||
|
if (!googleClientConfigured()) return { ok: false, error: "Google sign-in is being set up" };
|
||||||
|
const url = buildGoogleAuthUrl(id, cfg.scopes ?? []);
|
||||||
|
if (!url) return { ok: false, error: "could not build Google authorization URL" };
|
||||||
|
return { ok: true, authUrl: url };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete a Google sign-in with the code from the callback: store tokens, reconnect, list tools.
|
||||||
|
async finishGoogleOAuth(id: string, code: string): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await exchangeGoogleCode(id, code);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
await this.reconnectServer(id);
|
||||||
|
const now = this.servers.get(id);
|
||||||
|
return now?.status === "connected"
|
||||||
|
? { ok: true }
|
||||||
|
: { ok: false, error: `post-auth status: ${now?.status} (${now?.error ?? ""})` };
|
||||||
|
}
|
||||||
|
|
||||||
// Merged, namespaced tool list across all connected servers.
|
// Merged, namespaced tool list across all connected servers.
|
||||||
allTools(): ExposedTool[] {
|
allTools(): ExposedTool[] {
|
||||||
const out: ExposedTool[] = [];
|
const out: ExposedTool[] = [];
|
||||||
|
|||||||
+20
-1
@@ -11,10 +11,29 @@ export interface ServerConfig {
|
|||||||
command?: string;
|
command?: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
|
// Companion sidecar: a long-running process this connector's MCP server talks to (e.g. the
|
||||||
|
// WhatsApp bridge on :8080 that holds the paired session). connectd starts it as a CHILD when the
|
||||||
|
// connector connects, waits for healthUrl to respond, then spawns the MCP server; it is killed on
|
||||||
|
// disconnect/shutdown. `command` supports @bin//@node//@bundled resolution like the server itself.
|
||||||
|
companion?: {
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
healthUrl?: string;
|
||||||
|
// Working directory for the sidecar (created if missing). The WhatsApp bridge writes its session
|
||||||
|
// store relative to cwd, so this must be writable (e.g. ~/.neuron/whatsapp) — never the read-only
|
||||||
|
// app bundle. "~" / "$HOME" at the start are expanded to the user's home.
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
// http (Phase 3)
|
// http (Phase 3)
|
||||||
url?: string;
|
url?: string;
|
||||||
auth?: "none" | "oauth" | "token";
|
auth?: "none" | "oauth" | "token" | "google";
|
||||||
scope?: string; // OAuth scope string, when auth === "oauth"
|
scope?: string; // OAuth scope string, when auth === "oauth"
|
||||||
|
// Google connectors (Drive/Gmail/Calendar): stdio servers whose Google OAuth the bridge
|
||||||
|
// performs itself (one-click), storing tokens in the Keychain. `scopes` = the Google API
|
||||||
|
// scopes to request. Requires a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID);
|
||||||
|
// until that's configured the connector reports needs_setup instead of failing cryptically.
|
||||||
|
scopes?: string[];
|
||||||
// Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning,
|
// Phase 5: per-connector opt-in to skip the soul's approval card (read-only-leaning,
|
||||||
// off by default). The soul reads this from connectors.json at approval time.
|
// off by default). The soul reads this from connectors.json at approval time.
|
||||||
autoApprove?: boolean;
|
autoApprove?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { kcGetJson, kcSetJson, kcDelete } from "./keychain.js";
|
||||||
|
import { HOST, PORT } from "./config.js";
|
||||||
|
|
||||||
|
// ── Bridge-managed Google OAuth (one-click for Drive / Gmail / Calendar) ─────────────────────
|
||||||
|
//
|
||||||
|
// Why this exists: the community Google MCP servers each want a gcp-oauth.keys.json file plus a
|
||||||
|
// one-time `npx … auth` CLI step. That's unacceptable friction for a consumer product. Instead the
|
||||||
|
// bridge runs Google's OAuth itself — desktop-app authorization-code + PKCE — and stores the
|
||||||
|
// resulting refresh token in the macOS Keychain (account "<id>:google"). The stdio MCP server is
|
||||||
|
// then handed a short-lived access token, so the user only ever clicks "Connect" and approves in
|
||||||
|
// the browser. No files, no terminal.
|
||||||
|
//
|
||||||
|
// Ships gated: a Neuron-owned Google OAuth client (GOOGLE_OAUTH_CLIENT_ID / _SECRET) must be
|
||||||
|
// present, and the app must clear Google's verification for the requested scopes (see
|
||||||
|
// docs/Tims stuff/google-oauth-verification-checklist.md). Until the client id is configured,
|
||||||
|
// googleClientConfigured() is false and callers surface an honest "being set up" state rather than
|
||||||
|
// launching a server that will crash with no credentials.
|
||||||
|
|
||||||
|
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||||
|
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
||||||
|
|
||||||
|
// The loopback redirect must match exactly what's registered on the Google desktop OAuth client.
|
||||||
|
export const GOOGLE_REDIRECT_URI = `http://${HOST}:${PORT}/google/callback`;
|
||||||
|
|
||||||
|
export interface GoogleTokens {
|
||||||
|
refresh_token: string;
|
||||||
|
access_token?: string;
|
||||||
|
expires_at?: number; // epoch ms when access_token expires
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientId(): string | undefined {
|
||||||
|
const v = process.env.GOOGLE_OAUTH_CLIENT_ID;
|
||||||
|
return v && v.trim() ? v.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientSecret(): string | undefined {
|
||||||
|
const v = process.env.GOOGLE_OAUTH_CLIENT_SECRET;
|
||||||
|
return v && v.trim() ? v.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once the app is shipped with a real Google OAuth client. Gates the whole flow.
|
||||||
|
export function googleClientConfigured(): boolean {
|
||||||
|
return clientId() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-flight PKCE state, keyed by connector id. Held in memory only for the seconds between
|
||||||
|
// /google/oauth/start and the browser redirect to /google/callback.
|
||||||
|
interface PendingFlow {
|
||||||
|
verifier: string;
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
const pending = new Map<string, PendingFlow>();
|
||||||
|
|
||||||
|
function base64url(buf: Buffer): string {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin a sign-in: produce the Google consent URL and stash the PKCE verifier for this id.
|
||||||
|
// Returns undefined if the Google client isn't configured yet (caller surfaces needs_setup).
|
||||||
|
export function buildGoogleAuthUrl(id: string, scopes: string[]): string | undefined {
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid) return undefined;
|
||||||
|
const verifier = base64url(randomBytes(32));
|
||||||
|
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
||||||
|
pending.set(id, { verifier, scopes });
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||||
|
response_type: "code",
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
code_challenge: challenge,
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
access_type: "offline", // ask for a refresh token
|
||||||
|
prompt: "consent", // force refresh-token issuance on re-consent
|
||||||
|
state: id, // carry the connector id through the round-trip
|
||||||
|
});
|
||||||
|
return `${AUTH_ENDPOINT}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete a sign-in: exchange the authorization code for tokens and persist the refresh token.
|
||||||
|
// Throws on any failure (missing flow, Google error) so the caller can report it honestly.
|
||||||
|
export async function exchangeGoogleCode(id: string, code: string): Promise<GoogleTokens> {
|
||||||
|
const flow = pending.get(id);
|
||||||
|
pending.delete(id);
|
||||||
|
if (!flow) throw new Error(`no pending Google sign-in for ${id}`);
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid) throw new Error("Google OAuth client not configured");
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
code,
|
||||||
|
code_verifier: flow.verifier,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
||||||
|
});
|
||||||
|
// Desktop clients may still carry a (non-secret) client secret; include it when present.
|
||||||
|
const secret = clientSecret();
|
||||||
|
if (secret) body.set("client_secret", secret);
|
||||||
|
|
||||||
|
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const json = (await resp.json()) as Record<string, any>;
|
||||||
|
if (!resp.ok) throw new Error(`Google token exchange failed: ${json.error ?? resp.status} ${json.error_description ?? ""}`.trim());
|
||||||
|
if (!json.refresh_token) {
|
||||||
|
throw new Error("Google did not return a refresh token (revoke prior access and retry with prompt=consent)");
|
||||||
|
}
|
||||||
|
const tokens: GoogleTokens = {
|
||||||
|
refresh_token: json.refresh_token,
|
||||||
|
access_token: json.access_token,
|
||||||
|
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||||
|
scope: json.scope,
|
||||||
|
};
|
||||||
|
await kcSetJson(`${id}:google`, tokens);
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a currently-valid access token for this connector, refreshing if expired. Returns
|
||||||
|
// undefined if the user hasn't signed in (no stored refresh token) — caller → needs_auth.
|
||||||
|
export async function getValidGoogleAccessToken(id: string): Promise<string | undefined> {
|
||||||
|
const stored = await kcGetJson<GoogleTokens>(`${id}:google`);
|
||||||
|
if (!stored?.refresh_token) return undefined;
|
||||||
|
// 60s skew so we never hand out a token that's about to expire mid-call.
|
||||||
|
if (stored.access_token && stored.expires_at && stored.expires_at - 60_000 > nowMs()) {
|
||||||
|
return stored.access_token;
|
||||||
|
}
|
||||||
|
return refreshGoogleToken(id, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshGoogleToken(id: string, stored: GoogleTokens): Promise<string | undefined> {
|
||||||
|
const cid = clientId();
|
||||||
|
if (!cid) return undefined;
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: cid,
|
||||||
|
refresh_token: stored.refresh_token,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
});
|
||||||
|
const secret = clientSecret();
|
||||||
|
if (secret) body.set("client_secret", secret);
|
||||||
|
|
||||||
|
const resp = await fetch(TOKEN_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const json = (await resp.json()) as Record<string, any>;
|
||||||
|
if (!resp.ok || !json.access_token) return undefined;
|
||||||
|
const updated: GoogleTokens = {
|
||||||
|
refresh_token: stored.refresh_token, // Google omits it on refresh; keep the original
|
||||||
|
access_token: json.access_token,
|
||||||
|
expires_at: json.expires_in ? nowMs() + json.expires_in * 1000 : undefined,
|
||||||
|
scope: json.scope ?? stored.scope,
|
||||||
|
};
|
||||||
|
await kcSetJson(`${id}:google`, updated);
|
||||||
|
return updated.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once the user has completed sign-in for this connector (a refresh token is stored).
|
||||||
|
export async function hasGoogleTokens(id: string): Promise<boolean> {
|
||||||
|
const stored = await kcGetJson<GoogleTokens>(`${id}:google`);
|
||||||
|
return !!stored?.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget a connector's Google tokens (on remove / sign-out).
|
||||||
|
export async function clearGoogleTokens(id: string): Promise<void> {
|
||||||
|
await kcDelete(`${id}:google`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapped so tests/builds without a real clock injection stay simple; Date.now is fine in the
|
||||||
|
// bridge process (unlike the El soul, this is plain Node).
|
||||||
|
function nowMs(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ async function main(): Promise<void> {
|
|||||||
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
|
console.error(`[connectd] ready — ${total} tool(s) across ${bridge.serverStatuses().length} server(s)`);
|
||||||
|
|
||||||
startServer(bridge);
|
startServer(bridge);
|
||||||
|
|
||||||
|
// Clean shutdown: kill companion sidecars (e.g. the WhatsApp bridge) so they don't orphan.
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
||||||
|
process.on(sig, () => {
|
||||||
|
bridge.disposeCompanions();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
|
|||||||
@@ -92,6 +92,26 @@ export function startServer(bridge: ConnectorBridge): void {
|
|||||||
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /google/oauth/start { id } — begin a Google one-click sign-in, return the consent URL.
|
||||||
|
if (method === "POST" && url === "/google/oauth/start") {
|
||||||
|
const parsed = await parseJsonBody<{ id?: string }>(req);
|
||||||
|
if (!parsed || !parsed.id) return sendJson(res, 400, { ok: false, error: "missing id" });
|
||||||
|
const result = await bridge.startGoogleOAuth(parsed.id);
|
||||||
|
return sendJson(res, result.ok ? 200 : 400, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /google/callback?code=..&state=.. — Google redirects here after consent.
|
||||||
|
if (method === "GET" && url.startsWith("/google/callback")) {
|
||||||
|
const q = new URL(url, `http://${HOST}:${PORT}`).searchParams;
|
||||||
|
const id = q.get("state"); // we pass the connector id as `state`
|
||||||
|
const code = q.get("code");
|
||||||
|
const err = q.get("error");
|
||||||
|
if (err) return sendHtml(res, 400, authResultPage(false, err));
|
||||||
|
if (!id || !code) return sendHtml(res, 400, authResultPage(false, "missing id or code"));
|
||||||
|
const result = await bridge.finishGoogleOAuth(id, code);
|
||||||
|
return sendHtml(res, result.ok ? 200 : 400, authResultPage(result.ok, result.error ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
// GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the
|
// GET /mcp/auto-approved — the soul reads this to decide which mcp__* calls skip the
|
||||||
// approval card (per-connector opt-in, off by default).
|
// approval card (per-connector opt-in, off by default).
|
||||||
if (method === "GET" && url === "/mcp/auto-approved") {
|
if (method === "GET" && url === "/mcp/auto-approved") {
|
||||||
|
|||||||
Reference in New Issue
Block a user