df172ac3d5
In OpenCode web multi-workspace mode, skill_mcp spawned stdio child processes inherited process.cwd() instead of the session's workspace directory. Pass toolContext.directory through SkillMcpClientInfo to StdioClientTransport's cwd parameter.
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
|
import { createCleanMcpEnvironment } from "./env-cleaner"
|
|
import { registerProcessCleanup, startCleanupTimer } from "./cleanup"
|
|
import { redactSensitiveData } from "./error-redaction"
|
|
import type { ManagedClient, McpClient, McpTransport, SkillMcpClientConnectionParams } from "./types"
|
|
|
|
type StdioClientFactory = (
|
|
clientInfo: { name: string; version: string },
|
|
options: { capabilities: Record<string, never> }
|
|
) => McpClient
|
|
|
|
type StdioTransportFactory = (
|
|
options: ConstructorParameters<typeof StdioClientTransport>[0]
|
|
) => McpTransport
|
|
|
|
interface StdioClientDependencies {
|
|
createClient: StdioClientFactory
|
|
createTransport: StdioTransportFactory
|
|
}
|
|
|
|
const defaultStdioClientDependencies: StdioClientDependencies = {
|
|
createClient: (clientInfo, options) => new Client(clientInfo, options),
|
|
createTransport: (options) => new StdioClientTransport(options),
|
|
}
|
|
|
|
let stdioClientDependencies: StdioClientDependencies = defaultStdioClientDependencies
|
|
|
|
export function setStdioClientDependenciesForTesting(
|
|
dependencies?: Partial<StdioClientDependencies>
|
|
): void {
|
|
stdioClientDependencies = dependencies
|
|
? {
|
|
...defaultStdioClientDependencies,
|
|
...dependencies,
|
|
}
|
|
: defaultStdioClientDependencies
|
|
}
|
|
|
|
function getStdioCommand(config: ClaudeCodeMcpServer, serverName: string): string {
|
|
if (!config.command) {
|
|
throw new Error(`MCP server "${serverName}" is configured for stdio but missing 'command' field.`)
|
|
}
|
|
return config.command
|
|
}
|
|
|
|
export async function createStdioClient(params: SkillMcpClientConnectionParams): Promise<McpClient> {
|
|
const { state, clientKey, info, config } = params
|
|
const shutdownGenAtStart = state.shutdownGeneration
|
|
|
|
const command = getStdioCommand(config, info.serverName)
|
|
const args = config.args ?? []
|
|
const mergedEnv = createCleanMcpEnvironment(config.env)
|
|
|
|
registerProcessCleanup(state)
|
|
|
|
const transport: McpTransport = stdioClientDependencies.createTransport({
|
|
command,
|
|
args,
|
|
env: mergedEnv,
|
|
stderr: "ignore",
|
|
...(info.directory ? { cwd: info.directory } : {}),
|
|
})
|
|
|
|
const client: McpClient = stdioClientDependencies.createClient(
|
|
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
|
|
{ capabilities: {} }
|
|
)
|
|
|
|
try {
|
|
await client.connect(transport)
|
|
} catch (error) {
|
|
// Close transport to prevent orphaned MCP process on connection failure
|
|
try {
|
|
await transport.close()
|
|
} catch {
|
|
// Process may already be terminated
|
|
}
|
|
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
const fullCommand = `${command} ${args.join(" ")}`
|
|
const safeCommand = redactSensitiveData(fullCommand)
|
|
const safeErrorMessage = redactSensitiveData(errorMessage)
|
|
throw new Error(
|
|
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
|
`Command: ${safeCommand}\n` +
|
|
`Reason: ${safeErrorMessage}\n\n` +
|
|
`Hints:\n` +
|
|
` - Ensure the command is installed and available in PATH\n` +
|
|
` - Check if the MCP server package exists\n` +
|
|
` - Verify the args are correct for this server`
|
|
)
|
|
}
|
|
|
|
if (state.shutdownGeneration !== shutdownGenAtStart) {
|
|
try { await client.close() } catch {}
|
|
try { await transport.close() } catch {}
|
|
throw new Error(`MCP server "${info.serverName}" connection completed after shutdown`)
|
|
}
|
|
|
|
const managedClient = {
|
|
client,
|
|
transport,
|
|
skillName: info.skillName,
|
|
lastUsedAt: Date.now(),
|
|
connectionType: "stdio",
|
|
} satisfies ManagedClient
|
|
|
|
state.clients.set(clientKey, managedClient)
|
|
startCleanupTimer(state)
|
|
return client
|
|
}
|