Skip to content

Mcp poc discussion #7082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: feature/agentic-chat
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
716 changes: 630 additions & 86 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/amazonq/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"env": {
"SSMDOCUMENT_LANGUAGESERVER_PORT": "6010",
"WEBPACK_DEVELOPER_SERVER": "http://localhost:8080"
"WEBPACK_DEVELOPER_SERVER": "http://localhost:8080",
"PATH": "/Users/bywang/.toolbox/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
// "__AMAZONQLSP_PATH": "${workspaceFolder}/../../../language-servers/app/aws-lsp-codewhisperer-runtimes/out/token-standalone.js",
// "__AMAZONQLSP_UI": "${workspaceFolder}/../../../language-servers/chat-client/build/amazonq-ui.js"
},
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,8 @@
"winston": "^3.11.0",
"winston-transport": "^4.6.0",
"xml2js": "^0.6.1",
"yaml-cfn": "^0.3.2"
"yaml-cfn": "^0.3.2",
"@modelcontextprotocol/sdk": "^1.9.0"
},
"overrides": {
"webfont": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/amazonq/webview/ui/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,11 @@ export const createMynahUI = (
feedbackOptions: feedbackOptions,
texts: uiComponentsTexts,
tabBarButtons: [
{
id: 'mcp_configuration',
icon: MynahIcons.MAGIC,
description: 'MCP configuration',
},
{
id: 'history_sheet',
icon: MynahIcons.HISTORY,
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/codewhisperer/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ import { SecurityIssueTreeViewProvider } from './service/securityIssueTreeViewPr
import { setContext } from '../shared/vscode/setContext'
import { syncSecurityIssueWebview } from './views/securityIssue/securityIssueWebview'
import { detectCommentAboveLine } from '../shared/utilities/commentUtils'
import { globalMcpConfigPath } from '../codewhispererChat/constants'
import { McpManager } from '../codewhispererChat/tools/mcp/mcpManager'

let localize: nls.LocalizeFunc

Expand Down Expand Up @@ -373,6 +375,12 @@ export async function activate(context: ExtContext): Promise<void> {

setSubscriptionsForCodeIssues()

/**
* MCP client initialization
*/
await McpManager.initMcpManager(globalMcpConfigPath)
setSubscriptionsForMcp()

function shouldRunAutoScan(editor: vscode.TextEditor | undefined, isScansEnabled?: boolean) {
return (
(isScansEnabled ?? CodeScansState.instance.isScansEnabled()) &&
Expand Down Expand Up @@ -499,6 +507,29 @@ export async function activate(context: ExtContext): Promise<void> {
})
)
}

function setSubscriptionsForMcp() {
let lastMcpContent: string | undefined
const updateLastContent = (document: vscode.TextDocument) => {
lastMcpContent = document.getText()
}
const mcpOpenListener = vscode.workspace.onDidOpenTextDocument((document: vscode.TextDocument) => {
if (document.uri.fsPath === globalMcpConfigPath) {
updateLastContent(document)
}
})
context.extensionContext.subscriptions.push(mcpOpenListener)
const mcpSaveListener = vscode.workspace.onDidSaveTextDocument(async (doc) => {
if (doc.uri.fsPath === globalMcpConfigPath) {
const newContent = doc.getText()
if (lastMcpContent === undefined || newContent !== lastMcpContent) {
await McpManager.initMcpManager(globalMcpConfigPath)
lastMcpContent = newContent
}
}
})
context.extensionContext.subscriptions.push(mcpSaveListener)
}
}

export async function shutdown() {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/codewhispererChat/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ export const ignoredDirectoriesAndFiles = [
// OS specific files
'.DS_Store',
]

export const globalMcpConfigPath = path.join(process.env.HOME ?? '', '.aws', 'amazonq', 'mcp.json')
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Database } from '../../../shared/db/chatDb/chatDb'
import { TabBarButtonClick, SaveChatMessage } from './model'
import { Conversation, messageToChatItem, Tab } from '../../../shared/db/chatDb/util'
import { DetailedListItemGroup, MynahIconsType } from '@aws/mynah-ui'
import { globalMcpConfigPath } from '../../constants'

export class TabBarController {
private readonly messenger: Messenger
Expand Down Expand Up @@ -146,11 +147,29 @@ export class TabBarController {
case 'history_sheet':
await this.historyButtonClicked(message)
break
case 'mcp_configuration':
await this.mcpButtonClicked(message)
break
case 'export_chat':
await this.exportChatButtonClicked(message)
break
}
}
private async mcpButtonClicked(message: TabBarButtonClick) {
let fileExists = false
try {
await fs.stat(globalMcpConfigPath)
fileExists = true
} catch (error) {
fileExists = false
}
if (!fileExists) {
const defaultContent = JSON.stringify({ mcpServers: {} }, undefined, 2)
await fs.writeFile(globalMcpConfigPath, defaultContent, { encoding: 'utf8' })
}
const document = await vscode.workspace.openTextDocument(globalMcpConfigPath)
await vscode.window.showTextDocument(document, { preview: false })
}

private async exportChatButtonClicked(message: TabBarButtonClick) {
const defaultFileName = `q-dev-chat-${new Date().toISOString().split('T')[0]}.md`
Expand Down
162 changes: 162 additions & 0 deletions packages/core/src/codewhispererChat/tools/mcp/mcpManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

import { ListToolsResponse, MCPConfig, MCPServerConfig } from './mcpTypes'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import fs from '../../../shared/fs/fs'
import { getLogger } from '../../../shared/logger/logger'
import { tools } from '../../constants'

export interface McpToolDefinition {
serverName: string
toolName: string
description: string
inputSchema: any // schema from the server
}

export class McpManager {
static #instance: McpManager | undefined
private mcpServers: Record<string, MCPServerConfig> = {}
private clients: Map<string, Client> = new Map() // key: serverName, val: MCP client
private mcpTools: McpToolDefinition[] = []

private constructor(private readonly configPath: string) {}

public static get instance(): McpManager {
if (!McpManager.#instance) {
throw new Error('McpManager not initialized—call initMcpManager() first')
}
return McpManager.#instance
}

public async loadConfig(): Promise<void> {
if (!(await fs.exists(this.configPath))) {
throw new Error(`Could not load the MCP config at ${this.configPath}`)
}
const raw = await fs.readFileText(this.configPath)
const json = JSON.parse(raw) as MCPConfig
if (!json.mcpServers) {
throw new Error(`No "mcpServers" field found in config: ${this.configPath}`)
}
this.mcpServers = json.mcpServers
}

public async initAllServers(): Promise<void> {
this.mcpTools = []
for (const [serverName, serverConfig] of Object.entries(this.mcpServers)) {
if (serverConfig.disabled) {
getLogger().info(`MCP server [${serverName}] is disabled, skipping.`)
continue
}
await this.initOneServer(serverName, serverConfig)
}
}

private async initOneServer(serverName: string, serverConfig: MCPServerConfig): Promise<void> {
try {
getLogger().debug(`Initializing MCP server [${serverName}] with command: ${serverConfig.command}`)
const transport = new StdioClientTransport({
command: serverConfig.command,
args: serverConfig.args ?? [],
env: process.env as Record<string, string>,
})
const client = new Client({
name: `q-agentic-chat-mcp-client-${serverName}`,
version: '1.0.0',
})
await client.connect(transport)
this.clients.set(serverName, client)

const toolsResult = (await client.listTools()) as ListToolsResponse
for (const toolInfo of toolsResult.tools) {
const toolDef: McpToolDefinition = {
serverName,
toolName: toolInfo.name ?? 'unknown',
description: toolInfo.description ?? '',
inputSchema: toolInfo.inputSchema ?? {},
}
this.mcpTools.push(toolDef)
getLogger().info(`Found MCP tool [${toolDef.toolName}] from server [${serverName}]`)
}
} catch (err) {
// Log the error for this server but allow the initialization of others to continue.
getLogger().error(`Failed to init server [${serverName}]: ${(err as Error).message}`)
}
}

public getAllMcpTools(): McpToolDefinition[] {
return [...this.mcpTools]
}

public async callTool(serverName: string, toolName: string, args: any): Promise<any> {
const client = this.clients.get(serverName)
if (!client) {
throw new Error(`MCP server [${serverName}] not connected or not found in clients.`)
}
return await client.callTool({
name: toolName,
arguments: args,
})
}

public findTool(toolName: string): McpToolDefinition | undefined {
return this.mcpTools.find((t) => t.toolName === toolName)
}

public static async initMcpManager(configPath: string): Promise<McpManager | undefined> {
try {
if (!McpManager.#instance) {
const mgr = new McpManager(configPath)
McpManager.#instance = mgr
}
await McpManager.#instance.loadConfig()
await McpManager.#instance.initAllServers()
const discovered = McpManager.#instance.getAllMcpTools()
const builtInToolNames = new Set<string>(['fsRead', 'fsWrite', 'executeBash', 'listDirectory'])
const discoveredNames = new Set(discovered.map((d) => d.toolName))

for (const def of discovered) {
const spec = {
toolSpecification: {
name: def.toolName,
description: def.description,
inputSchema: { json: def.inputSchema },
},
}
const idx = tools.findIndex((t) => t.toolSpecification!.name === def.toolName)
if (idx >= 0) {
// replace existing entry
tools[idx] = spec
} else {
// append new entry
tools.push(spec)
}
}

// Prune stale _dynamic_ tools (leave built‑ins intact)
for (let i = tools.length - 1; i >= 0; --i) {
const name = tools[i].toolSpecification!.name
if (!name || builtInToolNames.has(name)) {
continue
}
// if it wasn’t rediscovered in new MCP config, remove it
if (!discoveredNames.has(name)) {
tools.splice(i, 1)
}
}
getLogger().info(`MCP: successfully discovered ${discovered.length} new tools.`)
return McpManager.instance
} catch (err) {
getLogger().error(`Failed to init MCP manager: ${(err as Error).message}`)
return undefined
}
}

// public async dispose(): Promise<void> {
// this.clients.clear()
// this.mcpTools = []
// }
}
55 changes: 55 additions & 0 deletions packages/core/src/codewhispererChat/tools/mcp/mcpTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { Writable } from 'stream'
import { getLogger } from '../../../shared/logger/logger'
import { CommandValidation, InvokeOutput, OutputKind } from '../toolShared'
import { McpManager } from './mcpManager'

export interface McpToolParams {
serverName: string
toolName: string
input?: any
}

export class McpTool {
private readonly logger = getLogger('mcp')
private serverName: string
private toolName: string
private input: any

public constructor(params: McpToolParams) {
this.serverName = params.serverName
this.toolName = params.toolName
this.input = params.input
}

public async validate(): Promise<void> {}

public queueDescription(updates: Writable): void {
updates.write(`Invoking remote MCP tool: ${this.toolName} on server ${this.serverName}`)
updates.end()
}

public requiresAcceptance(): CommandValidation {
return { requiresAcceptance: true }
}

public async invoke(updates?: Writable): Promise<InvokeOutput> {
try {
const result = await McpManager.instance.callTool(this.serverName, this.toolName, this.input)
const content = typeof result === 'object' ? JSON.stringify(result) : String(result)

return {
output: {
kind: OutputKind.Text,
content,
},
}
} catch (error: any) {
this.logger.error(`Failed to invoke MCP tool: ${error.message ?? error}`)
throw new Error(`Failed to invoke MCP tool: ${error.message ?? error}`)
}
}
}
25 changes: 25 additions & 0 deletions packages/core/src/codewhispererChat/tools/mcp/mcpTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

export interface MCPServerConfig {
command: string
args?: string[]
env?: Record<string, string>
disabled?: boolean
autoApprove?: string[]
}

export interface MCPConfig {
mcpServers: Record<string, MCPServerConfig>
}

export interface ListToolsResponse {
tools: {
name?: string
description?: string
inputSchema?: object
[key: string]: any
}[]
}
Loading