Terminal-based AI coding agent with interactive TUI for autonomous code generation.

Features:
  - Interactive TUI with React/Ink
  - Autonomous agent with tool calls (bash, read, write, edit, glob, grep)
  - Permission system with pattern-based rules
  - Session management with auto-compaction
  - Dual providers: GitHub Copilot and Ollama
  - MCP server integration
  - Todo panel and theme system
  - Streaming responses
  - GitHub-compatible project context
This commit is contained in:
2026-01-27 23:33:06 -05:00
commit 0062e5d9d9
521 changed files with 66418 additions and 0 deletions

373
src/services/mcp/client.ts Normal file
View File

@@ -0,0 +1,373 @@
/**
* MCP Client - Manages connection to a single MCP server
*/
import { spawn, type ChildProcess } from "child_process";
import type {
MCPServerConfig,
MCPServerInstance,
MCPToolDefinition,
MCPResourceDefinition,
MCPToolCallResult,
MCPConnectionState,
} from "@/types/mcp";
/**
* JSON-RPC message types
*/
interface JsonRpcRequest {
jsonrpc: "2.0";
id: number;
method: string;
params?: unknown;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
/**
* MCP Client class for managing a single server connection
*/
export class MCPClient {
private config: MCPServerConfig;
private process: ChildProcess | null = null;
private state: MCPConnectionState = "disconnected";
private tools: MCPToolDefinition[] = [];
private resources: MCPResourceDefinition[] = [];
private error: string | undefined;
private requestId = 0;
private pendingRequests: Map<
number,
{
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
> = new Map();
private buffer = "";
constructor(config: MCPServerConfig) {
this.config = config;
}
/**
* Get server instance state
*/
getInstance(): MCPServerInstance {
return {
config: this.config,
state: this.state,
tools: this.tools,
resources: this.resources,
error: this.error,
pid: this.process?.pid,
};
}
/**
* Connect to the MCP server
*/
async connect(): Promise<void> {
if (this.state === "connected" || this.state === "connecting") {
return;
}
this.state = "connecting";
this.error = undefined;
try {
if (this.config.transport === "stdio" || !this.config.transport) {
await this.connectStdio();
} else {
throw new Error(
`Transport type '${this.config.transport}' not yet supported`,
);
}
// Initialize the connection
await this.initialize();
// Discover tools and resources
await this.discoverCapabilities();
this.state = "connected";
} catch (err) {
this.state = "error";
this.error = err instanceof Error ? err.message : String(err);
throw err;
}
}
/**
* Connect via stdio transport
*/
private async connectStdio(): Promise<void> {
return new Promise((resolve, reject) => {
const env = {
...process.env,
...this.config.env,
};
this.process = spawn(this.config.command, this.config.args || [], {
stdio: ["pipe", "pipe", "pipe"],
env,
});
if (!this.process.stdout || !this.process.stdin) {
reject(new Error("Failed to create stdio pipes"));
return;
}
this.process.stdout.on("data", (data: Buffer) => {
this.handleData(data.toString());
});
this.process.stderr?.on("data", (data: Buffer) => {
console.error(`[MCP ${this.config.name}] stderr:`, data.toString());
});
this.process.on("error", (err) => {
this.state = "error";
this.error = err.message;
reject(err);
});
this.process.on("exit", (code) => {
this.state = "disconnected";
if (code !== 0) {
this.error = `Process exited with code ${code}`;
}
});
// Give the process a moment to start
setTimeout(resolve, 100);
});
}
/**
* Handle incoming data from the server
*/
private handleData(data: string): void {
this.buffer += data;
// Process complete JSON-RPC messages (newline-delimited)
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
for (const line of lines) {
if (line.trim()) {
try {
const message = JSON.parse(line) as JsonRpcResponse;
this.handleMessage(message);
} catch {
// Ignore parse errors for incomplete messages
}
}
}
}
/**
* Handle a JSON-RPC message
*/
private handleMessage(message: JsonRpcResponse): void {
const pending = this.pendingRequests.get(message.id);
if (pending) {
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
}
/**
* Send a JSON-RPC request
*/
private async sendRequest(
method: string,
params?: unknown,
): Promise<unknown> {
if (!this.process?.stdin) {
throw new Error("Not connected");
}
const id = ++this.requestId;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
const timeout = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error("Request timeout"));
}, 30000);
this.process!.stdin!.write(JSON.stringify(request) + "\n", (err) => {
if (err) {
clearTimeout(timeout);
this.pendingRequests.delete(id);
reject(err);
}
});
});
}
/**
* Initialize the MCP connection
*/
private async initialize(): Promise<void> {
await this.sendRequest("initialize", {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
resources: {},
},
clientInfo: {
name: "codetyper",
version: "0.1.0",
},
});
// Send initialized notification
if (this.process?.stdin) {
this.process.stdin.write(
JSON.stringify({
jsonrpc: "2.0",
method: "notifications/initialized",
}) + "\n",
);
}
}
/**
* Discover available tools and resources
*/
private async discoverCapabilities(): Promise<void> {
// Get tools
try {
const toolsResult = (await this.sendRequest("tools/list")) as {
tools?: MCPToolDefinition[];
};
this.tools = toolsResult?.tools || [];
} catch {
this.tools = [];
}
// Get resources
try {
const resourcesResult = (await this.sendRequest("resources/list")) as {
resources?: MCPResourceDefinition[];
};
this.resources = resourcesResult?.resources || [];
} catch {
this.resources = [];
}
}
/**
* Call a tool on the server
*/
async callTool(
toolName: string,
args: Record<string, unknown>,
): Promise<MCPToolCallResult> {
if (this.state !== "connected") {
return {
success: false,
error: "Not connected to server",
};
}
try {
const result = await this.sendRequest("tools/call", {
name: toolName,
arguments: args,
});
return {
success: true,
content: result,
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Read a resource from the server
*/
async readResource(
uri: string,
): Promise<{ content: string; mimeType?: string } | null> {
if (this.state !== "connected") {
return null;
}
try {
const result = (await this.sendRequest("resources/read", { uri })) as {
contents?: Array<{ text?: string; mimeType?: string }>;
};
if (result?.contents?.[0]) {
return {
content: result.contents[0].text || "",
mimeType: result.contents[0].mimeType,
};
}
return null;
} catch {
return null;
}
}
/**
* Disconnect from the server
*/
async disconnect(): Promise<void> {
if (this.process) {
this.process.kill();
this.process = null;
}
this.state = "disconnected";
this.tools = [];
this.resources = [];
this.pendingRequests.clear();
}
/**
* Get connection state
*/
getState(): MCPConnectionState {
return this.state;
}
/**
* Get available tools
*/
getTools(): MCPToolDefinition[] {
return this.tools;
}
/**
* Get available resources
*/
getResources(): MCPResourceDefinition[] {
return this.resources;
}
}

37
src/services/mcp/index.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* MCP Service - Model Context Protocol integration
*
* Provides connectivity to MCP servers for extensible tool integration.
*/
export { MCPClient } from "@services/mcp/client";
export {
initializeMCP,
loadMCPConfig,
saveMCPConfig,
connectServer,
disconnectServer,
connectAllServers,
disconnectAllServers,
getServerInstances,
getAllTools,
callTool,
addServer,
removeServer,
getMCPConfig,
isMCPAvailable,
} from "@services/mcp/manager";
export type {
MCPConfig,
MCPServerConfig,
MCPServerInstance,
MCPToolDefinition,
MCPResourceDefinition,
MCPToolCallRequest,
MCPToolCallResult,
MCPConnectionState,
MCPTransportType,
MCPManagerState,
} from "@/types/mcp";

297
src/services/mcp/manager.ts Normal file
View File

@@ -0,0 +1,297 @@
/**
* MCP Manager - Manages multiple MCP server connections
*/
import fs from "fs/promises";
import path from "path";
import os from "os";
import { MCPClient } from "@services/mcp/client";
import type {
MCPConfig,
MCPServerConfig,
MCPServerInstance,
MCPToolDefinition,
MCPToolCallResult,
} from "@/types/mcp";
/**
* MCP Configuration file locations
*/
const CONFIG_LOCATIONS = {
global: path.join(os.homedir(), ".codetyper", "mcp.json"),
local: path.join(process.cwd(), ".codetyper", "mcp.json"),
};
/**
* MCP Manager State
*/
interface MCPManagerState {
clients: Map<string, MCPClient>;
config: MCPConfig;
initialized: boolean;
}
/**
* MCP Manager singleton state
*/
const state: MCPManagerState = {
clients: new Map(),
config: { servers: {} },
initialized: false,
};
/**
* Load MCP configuration from file
*/
const loadConfigFile = async (filePath: string): Promise<MCPConfig | null> => {
try {
const content = await fs.readFile(filePath, "utf-8");
return JSON.parse(content) as MCPConfig;
} catch {
return null;
}
};
/**
* Load MCP configuration (merges global + local)
*/
export const loadMCPConfig = async (): Promise<MCPConfig> => {
const globalConfig = await loadConfigFile(CONFIG_LOCATIONS.global);
const localConfig = await loadConfigFile(CONFIG_LOCATIONS.local);
const merged: MCPConfig = {
servers: {
...(globalConfig?.servers || {}),
...(localConfig?.servers || {}),
},
};
return merged;
};
/**
* Save MCP configuration
*/
export const saveMCPConfig = async (
config: MCPConfig,
global = false,
): Promise<void> => {
const filePath = global ? CONFIG_LOCATIONS.global : CONFIG_LOCATIONS.local;
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, JSON.stringify(config, null, 2), "utf-8");
};
/**
* Initialize MCP Manager
*/
export const initializeMCP = async (): Promise<void> => {
if (state.initialized) return;
state.config = await loadMCPConfig();
state.initialized = true;
};
/**
* Connect to a specific MCP server
*/
export const connectServer = async (
serverName: string,
): Promise<MCPServerInstance> => {
await initializeMCP();
const serverConfig = state.config.servers[serverName];
if (!serverConfig) {
throw new Error(`Server '${serverName}' not found in configuration`);
}
// Check if already connected
let client = state.clients.get(serverName);
if (client && client.getState() === "connected") {
return client.getInstance();
}
// Create new client
client = new MCPClient({
...serverConfig,
name: serverName,
});
state.clients.set(serverName, client);
await client.connect();
return client.getInstance();
};
/**
* Disconnect from a specific MCP server
*/
export const disconnectServer = async (serverName: string): Promise<void> => {
const client = state.clients.get(serverName);
if (client) {
await client.disconnect();
state.clients.delete(serverName);
}
};
/**
* Connect to all enabled servers
*/
export const connectAllServers = async (): Promise<
Map<string, MCPServerInstance>
> => {
await initializeMCP();
const results = new Map<string, MCPServerInstance>();
for (const [name, config] of Object.entries(state.config.servers)) {
if (config.enabled === false) continue;
try {
const instance = await connectServer(name);
results.set(name, instance);
} catch (err) {
results.set(name, {
config: { ...config, name },
state: "error",
tools: [],
resources: [],
error: err instanceof Error ? err.message : String(err),
});
}
}
return results;
};
/**
* Disconnect from all servers
*/
export const disconnectAllServers = async (): Promise<void> => {
for (const [name] of state.clients) {
await disconnectServer(name);
}
};
/**
* Get all server instances
*/
export const getServerInstances = (): Map<string, MCPServerInstance> => {
const instances = new Map<string, MCPServerInstance>();
for (const [name, client] of state.clients) {
instances.set(name, client.getInstance());
}
// Include configured but not connected servers
for (const [name, config] of Object.entries(state.config.servers)) {
if (!instances.has(name)) {
instances.set(name, {
config: { ...config, name },
state: "disconnected",
tools: [],
resources: [],
});
}
}
return instances;
};
/**
* Get all available tools from all connected servers
*/
export const getAllTools = (): Array<{
server: string;
tool: MCPToolDefinition;
}> => {
const tools: Array<{ server: string; tool: MCPToolDefinition }> = [];
for (const [name, client] of state.clients) {
if (client.getState() === "connected") {
for (const tool of client.getTools()) {
tools.push({ server: name, tool });
}
}
}
return tools;
};
/**
* Call a tool on a specific server
*/
export const callTool = async (
serverName: string,
toolName: string,
args: Record<string, unknown>,
): Promise<MCPToolCallResult> => {
const client = state.clients.get(serverName);
if (!client) {
return {
success: false,
error: `Server '${serverName}' not connected`,
};
}
return client.callTool(toolName, args);
};
/**
* Add a server to configuration
*/
export const addServer = async (
name: string,
config: Omit<MCPServerConfig, "name">,
global = false,
): Promise<void> => {
await initializeMCP();
const targetConfig = global
? (await loadConfigFile(CONFIG_LOCATIONS.global)) || { servers: {} }
: (await loadConfigFile(CONFIG_LOCATIONS.local)) || { servers: {} };
targetConfig.servers[name] = { ...config, name };
await saveMCPConfig(targetConfig, global);
// Update in-memory config
state.config.servers[name] = { ...config, name };
};
/**
* Remove a server from configuration
*/
export const removeServer = async (
name: string,
global = false,
): Promise<void> => {
await disconnectServer(name);
const filePath = global ? CONFIG_LOCATIONS.global : CONFIG_LOCATIONS.local;
const config = await loadConfigFile(filePath);
if (config?.servers[name]) {
delete config.servers[name];
await saveMCPConfig(config, global);
}
delete state.config.servers[name];
};
/**
* Get MCP configuration
*/
export const getMCPConfig = async (): Promise<MCPConfig> => {
await initializeMCP();
return state.config;
};
/**
* Check if MCP is available (has any configured servers)
*/
export const isMCPAvailable = async (): Promise<boolean> => {
await initializeMCP();
return Object.keys(state.config.servers).length > 0;
};

171
src/services/mcp/tools.ts Normal file
View File

@@ -0,0 +1,171 @@
/**
* MCP Tools Integration
*
* Wraps MCP tools to work with the codetyper tool system.
*/
import { z } from "zod";
import {
getAllTools,
callTool,
connectAllServers,
} from "@services/mcp/manager";
import type { MCPToolDefinition } from "@/types/mcp";
/**
* Tool definition compatible with codetyper's tool system
*/
export interface ToolDefinition {
name: string;
description: string;
parameters: z.ZodSchema;
execute: (
args: Record<string, unknown>,
ctx: unknown,
) => Promise<{
success: boolean;
output: string;
error?: string;
title: string;
}>;
}
/**
* Convert JSON Schema to Zod schema (simplified)
*/
const jsonSchemaToZod = (
_schema: MCPToolDefinition["inputSchema"],
): z.ZodSchema => {
// Create a passthrough object schema that accepts any properties
// In a full implementation, we'd parse the JSON Schema properly
return z.object({}).passthrough();
};
/**
* Create a codetyper tool from an MCP tool definition
*/
const createToolFromMCP = (
serverName: string,
mcpTool: MCPToolDefinition,
): ToolDefinition => {
const fullName = `mcp_${serverName}_${mcpTool.name}`;
return {
name: fullName,
description:
mcpTool.description || `MCP tool: ${mcpTool.name} from ${serverName}`,
parameters: jsonSchemaToZod(mcpTool.inputSchema),
execute: async (args) => {
const result = await callTool(serverName, mcpTool.name, args);
if (result.success) {
return {
success: true,
output:
typeof result.content === "string"
? result.content
: JSON.stringify(result.content, null, 2),
title: `${serverName}/${mcpTool.name}`,
};
}
return {
success: false,
output: "",
error: result.error || "Unknown error",
title: `${serverName}/${mcpTool.name}`,
};
},
};
};
/**
* Get all MCP tools as codetyper tool definitions
*/
export const getMCPTools = async (): Promise<ToolDefinition[]> => {
// Ensure servers are connected
await connectAllServers();
const mcpTools = getAllTools();
return mcpTools.map(({ server, tool }) => createToolFromMCP(server, tool));
};
/**
* Get MCP tools for API (OpenAI function format)
*/
export const getMCPToolsForApi = async (): Promise<
Array<{
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties: Record<string, unknown>;
required?: string[];
};
};
}>
> => {
await connectAllServers();
const mcpTools = getAllTools();
return mcpTools.map(({ server, tool }) => ({
type: "function" as const,
function: {
name: `mcp_${server}_${tool.name}`,
description: tool.description || `MCP tool: ${tool.name} from ${server}`,
parameters: {
type: "object" as const,
properties: tool.inputSchema.properties || {},
required: tool.inputSchema.required,
},
},
}));
};
/**
* Execute an MCP tool by full name (mcp_server_toolname)
*/
export const executeMCPTool = async (
fullName: string,
args: Record<string, unknown>,
): Promise<{ success: boolean; output: string; error?: string }> => {
// Parse the full name: mcp_servername_toolname
const match = fullName.match(/^mcp_([^_]+)_(.+)$/);
if (!match) {
return {
success: false,
output: "",
error: `Invalid MCP tool name: ${fullName}`,
};
}
const [, serverName, toolName] = match;
const result = await callTool(serverName, toolName, args);
if (result.success) {
return {
success: true,
output:
typeof result.content === "string"
? result.content
: JSON.stringify(result.content, null, 2),
};
}
return {
success: false,
output: "",
error: result.error,
};
};
/**
* Check if a tool name is an MCP tool
*/
export const isMCPTool = (toolName: string): boolean => {
return toolName.startsWith("mcp_");
};