Skip to content

Commit b126407

Browse files
author
Eric Oliver
committed
feat: implement MCP server support for CLI utility
- Add MCP type definitions and configuration types - Implement stdio and SSE connection classes - Create CLIMcpService for server management and tool execution - Add comprehensive CLI commands for MCP operations (list, connect, tools, etc.) - Integrate MCP options into main CLI interface - Add extensive unit tests for all MCP functionality - Support for server discovery, health checking, and lifecycle management - Configuration file support with validation and error handling Resolves story 15: Integrate MCP Server Support
1 parent 29626b5 commit b126407

File tree

10 files changed

+2644
-0
lines changed

10 files changed

+2644
-0
lines changed

src/cli/commands/mcp-commands.ts

Lines changed: 616 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
2+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
3+
import { McpServerConfig, McpConnection, ServerStatus, McpConnectionError } from "../types/mcp-types"
4+
5+
export class SseMcpConnection implements McpConnection {
6+
public id: string
7+
public config: McpServerConfig
8+
public client?: Client
9+
public transport?: SSEClientTransport
10+
public status: ServerStatus = "disconnected"
11+
public lastActivity: number = Date.now()
12+
public errorCount: number = 0
13+
14+
private isConnecting: boolean = false
15+
private isDisconnecting: boolean = false
16+
17+
constructor(config: McpServerConfig) {
18+
this.id = config.id
19+
this.config = config
20+
}
21+
22+
async connect(): Promise<void> {
23+
if (this.isConnecting || this.status === "connected") {
24+
return
25+
}
26+
27+
if (!this.config.url) {
28+
throw new McpConnectionError("URL is required for SSE connection", this.id)
29+
}
30+
31+
this.isConnecting = true
32+
this.status = "connecting"
33+
34+
try {
35+
// Create transport and client
36+
this.transport = new SSEClientTransport(new URL(this.config.url), this.config.headers || {})
37+
38+
this.client = new Client(
39+
{
40+
name: `cli-client-${this.id}`,
41+
version: "1.0.0",
42+
},
43+
{
44+
capabilities: {
45+
tools: {},
46+
resources: {},
47+
},
48+
},
49+
)
50+
51+
// Set up error handlers
52+
this.setupErrorHandlers()
53+
54+
// Connect the client
55+
await this.client.connect(this.transport)
56+
57+
this.status = "connected"
58+
this.lastActivity = Date.now()
59+
this.errorCount = 0
60+
} catch (error) {
61+
this.status = "error"
62+
this.errorCount++
63+
throw new McpConnectionError(`Failed to connect to ${this.config.name}: ${error.message}`, this.id)
64+
} finally {
65+
this.isConnecting = false
66+
}
67+
}
68+
69+
async disconnect(): Promise<void> {
70+
if (this.isDisconnecting || this.status === "disconnected") {
71+
return
72+
}
73+
74+
this.isDisconnecting = true
75+
76+
try {
77+
// Close client connection
78+
if (this.client) {
79+
await this.client.close()
80+
}
81+
82+
// Close transport
83+
if (this.transport) {
84+
await this.transport.close()
85+
}
86+
87+
this.status = "disconnected"
88+
} catch (error) {
89+
console.error(`Error disconnecting from ${this.config.name}:`, error)
90+
} finally {
91+
this.isDisconnecting = false
92+
}
93+
}
94+
95+
async isHealthy(): Promise<boolean> {
96+
try {
97+
if (this.status !== "connected" || !this.client) {
98+
return false
99+
}
100+
101+
// Try to ping the server by listing tools
102+
await this.client.listTools()
103+
this.lastActivity = Date.now()
104+
return true
105+
} catch (error) {
106+
this.errorCount++
107+
return false
108+
}
109+
}
110+
111+
private setupErrorHandlers(): void {
112+
if (this.transport) {
113+
this.transport.onclose = () => {
114+
if (this.status !== "disconnected") {
115+
this.status = "disconnected"
116+
}
117+
}
118+
119+
this.transport.onerror = (error) => {
120+
console.error(`Transport error for ${this.config.name}:`, error)
121+
this.status = "error"
122+
this.errorCount++
123+
}
124+
}
125+
}
126+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
2+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
3+
import { McpServerConfig, McpConnection, ServerStatus, McpConnectionError } from "../types/mcp-types"
4+
5+
export class StdioMcpConnection implements McpConnection {
6+
public id: string
7+
public config: McpServerConfig
8+
public client?: Client
9+
public transport?: StdioClientTransport
10+
public status: ServerStatus = "disconnected"
11+
public lastActivity: number = Date.now()
12+
public errorCount: number = 0
13+
14+
private isConnecting: boolean = false
15+
private isDisconnecting: boolean = false
16+
17+
constructor(config: McpServerConfig) {
18+
this.id = config.id
19+
this.config = config
20+
}
21+
22+
async connect(): Promise<void> {
23+
if (this.isConnecting || this.status === "connected") {
24+
return
25+
}
26+
27+
if (!this.config.command) {
28+
throw new McpConnectionError("Command is required for stdio connection", this.id)
29+
}
30+
31+
this.isConnecting = true
32+
this.status = "connecting"
33+
34+
try {
35+
// Create transport and client
36+
this.transport = new StdioClientTransport({
37+
command: this.config.command,
38+
args: this.config.args || [],
39+
cwd: this.config.cwd,
40+
env: {
41+
...(Object.fromEntries(
42+
Object.entries(process.env).filter(([, value]) => value !== undefined),
43+
) as Record<string, string>),
44+
...this.config.env,
45+
},
46+
stderr: "pipe",
47+
})
48+
49+
this.client = new Client(
50+
{
51+
name: `cli-client-${this.id}`,
52+
version: "1.0.0",
53+
},
54+
{
55+
capabilities: {
56+
tools: {},
57+
resources: {},
58+
},
59+
},
60+
)
61+
62+
// Set up error handlers
63+
this.setupErrorHandlers()
64+
65+
// Start transport and connect client
66+
await this.transport.start()
67+
await this.client.connect(this.transport)
68+
69+
this.status = "connected"
70+
this.lastActivity = Date.now()
71+
this.errorCount = 0
72+
} catch (error) {
73+
this.status = "error"
74+
this.errorCount++
75+
throw new McpConnectionError(`Failed to connect to ${this.config.name}: ${error.message}`, this.id)
76+
} finally {
77+
this.isConnecting = false
78+
}
79+
}
80+
81+
async disconnect(): Promise<void> {
82+
if (this.isDisconnecting || this.status === "disconnected") {
83+
return
84+
}
85+
86+
this.isDisconnecting = true
87+
88+
try {
89+
// Close client connection
90+
if (this.client) {
91+
await this.client.close()
92+
}
93+
94+
// Close transport
95+
if (this.transport) {
96+
await this.transport.close()
97+
}
98+
99+
this.status = "disconnected"
100+
} catch (error) {
101+
console.error(`Error disconnecting from ${this.config.name}:`, error)
102+
} finally {
103+
this.isDisconnecting = false
104+
}
105+
}
106+
107+
async isHealthy(): Promise<boolean> {
108+
try {
109+
if (this.status !== "connected" || !this.client) {
110+
return false
111+
}
112+
113+
// Try to ping the server by listing tools
114+
await this.client.listTools()
115+
this.lastActivity = Date.now()
116+
return true
117+
} catch (error) {
118+
this.errorCount++
119+
return false
120+
}
121+
}
122+
123+
private setupErrorHandlers(): void {
124+
if (this.transport) {
125+
this.transport.onclose = () => {
126+
if (this.status !== "disconnected") {
127+
this.status = "disconnected"
128+
}
129+
}
130+
131+
this.transport.onerror = (error) => {
132+
console.error(`Transport error for ${this.config.name}:`, error)
133+
this.status = "error"
134+
this.errorCount++
135+
}
136+
}
137+
}
138+
}

0 commit comments

Comments
 (0)