- Rename all user-facing and technical identifiers from OpenPets/Pet to FamiliarOS/Familiar. - Rename packages from @open-pets/* to @familiaros/*; rename install-pet/pet-format packages. - Rename plugin IDs and directories from openpets.* to familiaros.*. - Rename IPC namespace from openpets:* to familiaros:* and state filenames from openpets-* to familiaros-* with legacy migration. - Rename source files (pet-window, built-in-pet, default-pet-controller, etc.) to familiar equivalents. - Update locales (en, es-419, ja, ko, pt-BR, zh-Hans, zh-Hant) and tray/pet context menu strings. - Add Familiar naming feature: preference, settings input, tray menu display. - Update assets and packaging config; all desktop tests pass.
244 lines
7.1 KiB
TypeScript
244 lines
7.1 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { app } from "electron";
|
|
|
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
import { CallToolResultSchema, type Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
|
|
import { info, warn, error as logError } from "./logger.js";
|
|
|
|
export interface McpChatToolDefinition {
|
|
readonly type: "function";
|
|
readonly function: {
|
|
readonly name: string;
|
|
readonly description: string;
|
|
readonly parameters: Record<string, unknown>;
|
|
};
|
|
}
|
|
|
|
interface ActiveServer {
|
|
readonly id: string;
|
|
readonly client: Client;
|
|
readonly transport: StdioClientTransport;
|
|
readonly tools: Tool[];
|
|
}
|
|
|
|
interface ServerSpawnConfig {
|
|
readonly command: string;
|
|
readonly args: readonly string[];
|
|
readonly env?: Record<string, string>;
|
|
}
|
|
|
|
// Map of catalog entry IDs to spawn configurations for vanilla chat.
|
|
// These are the servers FamiliarOS can spawn and manage internally.
|
|
function getVanillaChatServerConfigs(homeDir: string): Record<string, ServerSpawnConfig> {
|
|
return {
|
|
filesystem: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-filesystem", homeDir],
|
|
},
|
|
shell: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-terminal"],
|
|
},
|
|
memory: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-memory"],
|
|
},
|
|
"fetch-web": {
|
|
command: "uvx",
|
|
args: ["mcp-server-fetch"],
|
|
},
|
|
"sequential-thinking": {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
|
},
|
|
playwright: {
|
|
command: "npx",
|
|
args: ["-y", "@playwright/mcp@latest"],
|
|
},
|
|
git: {
|
|
command: "uvx",
|
|
args: ["mcp-server-git", "--repository", homeDir],
|
|
},
|
|
github: {
|
|
command: "npx",
|
|
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GITHUB_PERSONAL_ACCESS_TOKEN ?? "" },
|
|
},
|
|
docker: {
|
|
command: "uvx",
|
|
args: ["mcp-server-docker"],
|
|
},
|
|
sqlite: {
|
|
command: "uvx",
|
|
args: ["mcp-server-sqlite"],
|
|
},
|
|
};
|
|
}
|
|
|
|
class McpChatClientManager {
|
|
private activeServers = new Map<string, ActiveServer>();
|
|
private serverConfigs: Record<string, ServerSpawnConfig>;
|
|
private homeDir: string;
|
|
|
|
constructor() {
|
|
this.homeDir = app.getPath("home");
|
|
this.serverConfigs = getVanillaChatServerConfigs(this.homeDir);
|
|
}
|
|
|
|
async startEnabledServers(enabledIds: readonly string[]): Promise<void> {
|
|
const toStop = new Set(this.activeServers.keys());
|
|
const toStart: string[] = [];
|
|
|
|
for (const id of enabledIds) {
|
|
if (this.activeServers.has(id)) {
|
|
toStop.delete(id);
|
|
} else {
|
|
toStart.push(id);
|
|
}
|
|
}
|
|
|
|
for (const id of toStop) {
|
|
await this.stopServer(id);
|
|
}
|
|
|
|
for (const id of toStart) {
|
|
await this.startServer(id).catch((err) => {
|
|
logError("app", `Failed to start MCP server ${id}`, { error: err instanceof Error ? err.message : String(err) });
|
|
});
|
|
}
|
|
}
|
|
|
|
async startServer(id: string): Promise<void> {
|
|
if (this.activeServers.has(id)) return;
|
|
|
|
const config = this.serverConfigs[id];
|
|
if (!config) {
|
|
throw new Error(`No spawn configuration for MCP server: ${id}`);
|
|
}
|
|
|
|
info("app", `Starting MCP server`, { id, command: config.command });
|
|
|
|
const transport = new StdioClientTransport({
|
|
command: config.command,
|
|
args: [...config.args],
|
|
env: config.env,
|
|
});
|
|
|
|
const client = new Client({ name: "familiaros-vanilla-chat", version: app.getVersion() });
|
|
await client.connect(transport);
|
|
|
|
const toolsResult = await client.listTools();
|
|
const tools = toolsResult.tools ?? [];
|
|
|
|
this.activeServers.set(id, { id, client, transport, tools });
|
|
info("app", `MCP server ready`, { id, toolCount: tools.length });
|
|
}
|
|
|
|
async stopServer(id: string): Promise<void> {
|
|
const server = this.activeServers.get(id);
|
|
if (!server) return;
|
|
|
|
info("app", `Stopping MCP server`, { id });
|
|
try {
|
|
await server.client.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
this.activeServers.delete(id);
|
|
}
|
|
|
|
async stopAll(): Promise<void> {
|
|
for (const id of Array.from(this.activeServers.keys())) {
|
|
await this.stopServer(id);
|
|
}
|
|
}
|
|
|
|
listTools(): McpChatToolDefinition[] {
|
|
const openAiTools: McpChatToolDefinition[] = [];
|
|
const seenNames = new Set<string>();
|
|
|
|
for (const server of this.activeServers.values()) {
|
|
for (const tool of server.tools) {
|
|
let name = tool.name;
|
|
// De-duplicate by prefixing with server id if needed
|
|
if (seenNames.has(name)) {
|
|
name = `${server.id}_${name}`;
|
|
}
|
|
seenNames.add(name);
|
|
|
|
openAiTools.push({
|
|
type: "function",
|
|
function: {
|
|
name,
|
|
description: tool.description ?? `${server.id} tool`,
|
|
parameters: (tool.inputSchema ?? { type: "object" }) as Record<string, unknown>,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return openAiTools;
|
|
}
|
|
|
|
async callTool(name: string, args: unknown): Promise<string> {
|
|
// Find which server owns this tool
|
|
for (const server of this.activeServers.values()) {
|
|
const hasTool = server.tools.some((t) => t.name === name || `${server.id}_${t.name}` === name);
|
|
if (!hasTool) continue;
|
|
|
|
const actualName = server.tools.some((t) => t.name === name) ? name : name.replace(`${server.id}_`, "");
|
|
|
|
info("app", `Calling tool`, { serverId: server.id, tool: actualName });
|
|
const result = await server.client.callTool({ name: actualName, arguments: args as Record<string, unknown> }, CallToolResultSchema);
|
|
|
|
if (result.isError) {
|
|
return `Error: ${this.extractResultText(result)}`;
|
|
}
|
|
return this.extractResultText(result);
|
|
}
|
|
|
|
throw new Error(`Tool not found in any active MCP server: ${name}`);
|
|
}
|
|
|
|
getActiveServerIds(): string[] {
|
|
return Array.from(this.activeServers.keys());
|
|
}
|
|
|
|
private extractResultText(result: unknown): string {
|
|
const r = result as { content?: Array<Record<string, unknown>>; isError?: boolean } | undefined;
|
|
if (!r?.content || r.content.length === 0) {
|
|
return r?.isError ? "The tool returned an error with no details." : "";
|
|
}
|
|
return r.content
|
|
.map((item) => {
|
|
if (item.type === "text") return item.text ?? "";
|
|
if (item.type === "image") return `[image: ${item.mimeType ?? "unknown"}]`;
|
|
if (item.type === "audio") return `[audio: ${item.mimeType ?? "unknown"}]`;
|
|
return JSON.stringify(item);
|
|
})
|
|
.join("\n");
|
|
}
|
|
}
|
|
|
|
let globalManager: McpChatClientManager | null = null;
|
|
|
|
export function getMcpChatClientManager(): McpChatClientManager {
|
|
if (!globalManager) {
|
|
globalManager = new McpChatClientManager();
|
|
}
|
|
return globalManager;
|
|
}
|
|
|
|
export function destroyMcpChatClientManager(): void {
|
|
if (globalManager) {
|
|
globalManager.stopAll().catch(() => undefined);
|
|
globalManager = null;
|
|
}
|
|
}
|
|
|
|
export function listMcpChatVanillaServerIds(): string[] {
|
|
const homeDir = app.getPath("home");
|
|
return Object.keys(getVanillaChatServerConfigs(homeDir));
|
|
}
|