Skip to content

Commit 626b126

Browse files
authored
feat: add mcp client (#1034)
* feat: add mcp client
1 parent c965db2 commit 626b126

File tree

12 files changed

+916
-3
lines changed

12 files changed

+916
-3
lines changed

app/aws-lsp-codewhisperer-runtimes/src/agent-standalone.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const props: RuntimeProps = {
2828
FsToolsServer,
2929
BashToolsServer,
3030
QLocalProjectContextServerTokenProxy,
31+
// McpToolsServer,
3132
// LspToolsServer,
3233
],
3334
name: 'AWS CodeWhisperer',

package-lock.json

Lines changed: 395 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/aws-lsp-codewhisperer/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"picomatch": "^4.0.2",
5252
"shlex": "2.1.2",
5353
"uuid": "^11.0.5",
54-
"vscode-uri": "^3.1.0"
54+
"vscode-uri": "^3.1.0",
55+
"@modelcontextprotocol/sdk": "^1.9.0"
5556
},
5657
"devDependencies": {
5758
"@types/adm-zip": "^0.5.5",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { expect } from 'chai'
7+
import { McpManager } from './mcpManager'
8+
9+
describe('McpManager (singleton & empty‑config)', () => {
10+
const fakeLogging = {
11+
log: (_: string) => {},
12+
info: (_: string) => {},
13+
warn: (_: string) => {},
14+
error: (_: string) => {},
15+
debug: (_: string) => {},
16+
}
17+
18+
// Minimal stub for Features.workspace
19+
const fakeWorkspace = {
20+
fs: {
21+
exists: (_: string) => Promise.resolve(false),
22+
readFile: (_: string) => Promise.resolve(Buffer.from('')),
23+
},
24+
getUserHomeDir: () => '',
25+
}
26+
27+
const fakeLsp = {}
28+
29+
const features = {
30+
logging: fakeLogging,
31+
workspace: fakeWorkspace,
32+
lsp: fakeLsp,
33+
} as unknown as Pick<
34+
import('@aws/language-server-runtimes/server-interface/server').Features,
35+
'logging' | 'workspace' | 'lsp'
36+
>
37+
38+
afterEach(async () => {
39+
try {
40+
await McpManager.instance.close()
41+
} catch {
42+
/* ignore */
43+
}
44+
})
45+
46+
it('init() always returns the same instance', async () => {
47+
const m1 = await McpManager.init([], features)
48+
const m2 = await McpManager.init([], features)
49+
expect(m1).to.equal(m2)
50+
})
51+
52+
it('with no config paths it discovers zero tools', async () => {
53+
const mgr = await McpManager.init([], features)
54+
expect(mgr.getAllTools()).to.be.an('array').that.is.empty
55+
})
56+
57+
it('callTool() on non‑existent server throws', async () => {
58+
const mgr = await McpManager.init([], features)
59+
try {
60+
await mgr.callTool('nope', 'foo', {})
61+
throw new Error('Expected callTool to throw, but it did not')
62+
} catch (err: any) {
63+
expect(err).to.be.instanceOf(Error)
64+
expect(err.message).to.equal(`MCP: server 'nope' not connected`)
65+
}
66+
})
67+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import type { Features } from '@aws/language-server-runtimes/server-interface/server'
7+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
8+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
9+
import type { MCPServerConfig, McpToolDefinition, ListToolsResponse } from './mcpTypes'
10+
import { loadMcpServerConfigs } from './mcpUtils'
11+
12+
export class McpManager {
13+
static #instance?: McpManager
14+
private clients = new Map<string, Client>()
15+
private mcpTools: McpToolDefinition[] = []
16+
17+
private constructor(
18+
private configPaths: string[],
19+
private features: Pick<Features, 'logging' | 'workspace' | 'lsp'>
20+
) {}
21+
22+
public static async init(
23+
configPaths: string[],
24+
features: Pick<Features, 'logging' | 'workspace' | 'lsp'>
25+
): Promise<McpManager> {
26+
if (!McpManager.#instance) {
27+
const mgr = new McpManager(configPaths, features)
28+
McpManager.#instance = mgr
29+
await mgr.discoverAllServers()
30+
features.logging.info(`MCP: discovered ${mgr.mcpTools.length} tools across all servers`)
31+
}
32+
return McpManager.#instance
33+
}
34+
35+
public static get instance(): McpManager {
36+
if (!McpManager.#instance) {
37+
throw new Error('McpManager not initialized—call McpManager.init(...) first')
38+
}
39+
return McpManager.#instance
40+
}
41+
42+
public async close(): Promise<void> {
43+
this.features.logging.info('MCP: closing all clients')
44+
for (const [name, client] of this.clients.entries()) {
45+
try {
46+
await client.close()
47+
this.features.logging.info(`MCP: closed client for ${name}`)
48+
} catch (e: any) {
49+
this.features.logging.error(`MCP: error closing client ${name}: ${e.message}`)
50+
}
51+
}
52+
this.clients.clear()
53+
this.mcpTools = []
54+
McpManager.#instance = undefined
55+
}
56+
57+
private async discoverAllServers(): Promise<void> {
58+
const configs = await loadMcpServerConfigs(this.features.workspace, this.features.logging, this.configPaths)
59+
60+
for (const [name, cfg] of configs.entries()) {
61+
if (cfg.disabled) {
62+
this.features.logging.info(`MCP: server '${name}' is disabled, skipping`)
63+
continue
64+
}
65+
await this.initOneServer(name, cfg)
66+
}
67+
}
68+
69+
private async initOneServer(serverName: string, cfg: MCPServerConfig): Promise<void> {
70+
try {
71+
this.features.logging.debug(`MCP: initializing server [${serverName}]`)
72+
const mergedEnv = {
73+
...(process.env as Record<string, string>),
74+
...(cfg.env ?? {}),
75+
}
76+
const transport = new StdioClientTransport({
77+
command: cfg.command,
78+
args: cfg.args ?? [],
79+
env: mergedEnv,
80+
})
81+
const client = new Client({
82+
name: `mcp-client-${serverName}`,
83+
version: '1.0.0',
84+
})
85+
await client.connect(transport)
86+
this.clients.set(serverName, client)
87+
88+
const resp = (await client.listTools()) as ListToolsResponse
89+
for (const t of resp.tools) {
90+
const toolName = t.name!
91+
this.features.logging.info(`MCP: discovered tool ${serverName}::${toolName}`)
92+
this.mcpTools.push({
93+
serverName,
94+
toolName,
95+
description: t.description ?? '',
96+
inputSchema: t.inputSchema ?? {},
97+
})
98+
}
99+
} catch (e: any) {
100+
this.features.logging.warn(`MCP: server [${serverName}] init failed: ${e.message}`)
101+
}
102+
}
103+
104+
public getAllTools(): McpToolDefinition[] {
105+
return [...this.mcpTools]
106+
}
107+
108+
public async callTool(server: string, tool: string, args: any): Promise<any> {
109+
const c = this.clients.get(server)
110+
if (!c) throw new Error(`MCP: server '${server}' not connected`)
111+
return c.callTool({ name: tool, arguments: args })
112+
}
113+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { expect } from 'chai'
7+
import { McpTool } from './mcpTool'
8+
import { McpManager } from './mcpManager'
9+
import type { McpToolDefinition } from './mcpTypes'
10+
11+
describe('McpTool', () => {
12+
const fakeFeatures = {
13+
logging: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, log: () => {} },
14+
workspace: {
15+
fs: { exists: () => Promise.resolve(false), readFile: () => Promise.resolve(Buffer.from('')) },
16+
getUserHomeDir: () => '',
17+
},
18+
lsp: {},
19+
} as unknown as Pick<
20+
import('@aws/language-server-runtimes/server-interface/server').Features,
21+
'logging' | 'workspace' | 'lsp'
22+
>
23+
24+
const definition: McpToolDefinition = {
25+
serverName: 'nope',
26+
toolName: 'doesNotExist',
27+
description: 'desc',
28+
inputSchema: {},
29+
}
30+
31+
afterEach(async () => {
32+
try {
33+
await McpManager.instance.close()
34+
} catch {
35+
// ignore
36+
}
37+
})
38+
39+
it('invoke() throws when server is not connected', async () => {
40+
// Initialize McpManager with no config paths → zero servers
41+
await McpManager.init([], fakeFeatures)
42+
43+
const tool = new McpTool(fakeFeatures, definition)
44+
45+
try {
46+
await tool.invoke({}) // should throw
47+
throw new Error('Expected invoke() to throw')
48+
} catch (err: any) {
49+
expect(err).to.be.instanceOf(Error)
50+
expect(err.message).to.equal(`Failed to invoke MCP tool: MCP: server 'nope' not connected`)
51+
}
52+
})
53+
})
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates.
3+
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { CommandValidation, InvokeOutput, OutputKind } from '../toolShared'
7+
import type { McpToolDefinition } from './mcpTypes'
8+
import type { Features } from '@aws/language-server-runtimes/server-interface/server'
9+
import { McpManager } from './mcpManager'
10+
11+
export class McpTool {
12+
constructor(
13+
private readonly features: Pick<Features, 'logging' | 'workspace' | 'lsp'>,
14+
private readonly def: McpToolDefinition
15+
) {}
16+
17+
public getSpec() {
18+
return {
19+
name: this.def.toolName,
20+
description: this.def.description,
21+
inputSchema: this.def.inputSchema,
22+
} as const
23+
}
24+
25+
public validate(_input: any): Promise<void> {
26+
return Promise.resolve()
27+
}
28+
29+
public async queueDescription(command: string, updates: WritableStream) {
30+
const writer = updates.getWriter()
31+
await writer.write(`Invoking MCP tool: ${this.def.toolName} on server ${this.def.serverName}`)
32+
await writer.close()
33+
writer.releaseLock()
34+
}
35+
36+
public requiresAcceptance(_input: any): CommandValidation {
37+
return { requiresAcceptance: false }
38+
}
39+
40+
public async invoke(input: any): Promise<InvokeOutput> {
41+
try {
42+
const result = await McpManager.instance.callTool(this.def.serverName, this.def.toolName, input)
43+
const content = typeof result === 'object' ? JSON.stringify(result) : String(result)
44+
return {
45+
output: {
46+
kind: OutputKind.Text,
47+
content: content,
48+
},
49+
}
50+
} catch (err: any) {
51+
this.features.logging.error(`MCP tool ${this.def.toolName} failed: ${err.message}`)
52+
throw new Error(`Failed to invoke MCP tool: ${err.message}`)
53+
}
54+
}
55+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export interface McpToolDefinition {
7+
serverName: string
8+
toolName: string
9+
description: string
10+
inputSchema: any // schema from the server
11+
}
12+
13+
export interface MCPServerConfig {
14+
command: string
15+
args?: string[]
16+
env?: Record<string, string>
17+
disabled?: boolean
18+
autoApprove?: string[]
19+
}
20+
21+
export interface MCPConfig {
22+
mcpServers: Record<string, MCPServerConfig>
23+
}
24+
25+
export interface ListToolsResponse {
26+
tools: {
27+
name?: string
28+
description?: string
29+
inputSchema?: object
30+
[key: string]: any
31+
}[]
32+
}

0 commit comments

Comments
 (0)