openpetswithchatandmcp/apps/desktop/src/mcp-chat-client.ts
OpenPets Dev 6ab3bb64d8 feat(rebrand): rename OpenPets to FamiliarOS and pets to familiars
- 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.
2026-06-17 01:42:08 +00:00

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));
}