Add read-only MCP support

This commit is contained in:
Sean Goedecke
2025-07-16 02:19:49 +00:00
parent 86c0691fbf
commit 4fd6464105
13 changed files with 182 additions and 802 deletions

View File

@@ -133,6 +133,7 @@ describe('helpers.ts', () => {
it('handles undefined inputs correctly', () => {
const defaultValue = 'Default content'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
core.getInput.mockImplementation(() => undefined as any)
const result = loadContentFromFileOrInput(

View File

@@ -5,6 +5,7 @@ import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
// Mock Azure AI Inference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockPost = jest.fn() as jest.MockedFunction<any>
const mockPath = jest.fn(() => ({ post: mockPost }))
const mockClient = jest.fn(() => ({ path: mockPath }))
@@ -19,6 +20,7 @@ jest.unstable_mockModule('@azure/core-auth', () => ({
}))
// Mock MCP functions
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockExecuteToolCalls = jest.fn() as jest.MockedFunction<any>
jest.unstable_mockModule('../src/mcp.js', () => ({
executeToolCalls: mockExecuteToolCalls
@@ -112,10 +114,11 @@ describe('inference.ts', () => {
describe('mcpInference', () => {
const mockMcpClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: {} as any,
tools: [
{
type: 'function',
type: 'function' as const,
function: {
name: 'test-tool',
description: 'A test tool',
@@ -144,15 +147,18 @@ describe('inference.ts', () => {
const result = await mcpInference(mockRequest, mockMcpClient)
expect(result).toBe('Hello, user!')
expect(core.info).toHaveBeenCalledWith('Running MCP inference with tools')
expect(core.info).toHaveBeenCalledWith(
'Running GitHub MCP inference with tools'
)
expect(core.info).toHaveBeenCalledWith('MCP inference iteration 1')
expect(core.info).toHaveBeenCalledWith(
'No tool calls requested, ending MCP inference loop'
'No tool calls requested, ending GitHub MCP inference loop'
)
// The MCP inference loop will always add the assistant message, even when there are no tool calls
// So we don't check the exact messages, just that tools were included
expect(mockPost).toHaveBeenCalledTimes(1)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const callArgs = mockPost.mock.calls[0][0] as any
expect(callArgs.body.tools).toEqual(mockMcpClient.tools)
expect(callArgs.body.model).toBe('gpt-4')
@@ -223,6 +229,7 @@ describe('inference.ts', () => {
expect(mockPost).toHaveBeenCalledTimes(2)
// Verify the second call includes the conversation history
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const secondCall = mockPost.mock.calls[1][0] as any
expect(secondCall.body.messages).toHaveLength(5) // system, user, assistant, tool, assistant
expect(secondCall.body.messages[2].role).toBe('assistant')
@@ -271,7 +278,7 @@ describe('inference.ts', () => {
expect(mockPost).toHaveBeenCalledTimes(5) // Max iterations reached
expect(core.warning).toHaveBeenCalledWith(
'MCP inference loop exceeded maximum iterations (5)'
'GitHub MCP inference loop exceeded maximum iterations (5)'
)
expect(result).toBe('Using tool again.') // Last assistant message
})
@@ -296,7 +303,7 @@ describe('inference.ts', () => {
expect(result).toBe('Hello, user!')
expect(core.info).toHaveBeenCalledWith(
'No tool calls requested, ending MCP inference loop'
'No tool calls requested, ending GitHub MCP inference loop'
)
expect(mockExecuteToolCalls).not.toHaveBeenCalled()
})

View File

@@ -1,264 +0,0 @@
/**
* Unit tests for the action's main functionality, src/main.ts
*/
import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
// Default to throwing errors to catch unexpected calls
const mockExistsSync = jest.fn().mockImplementation(() => {
throw new Error(
'Unexpected call to existsSync - test should override this implementation'
)
})
const mockReadFileSync = jest.fn().mockImplementation(() => {
throw new Error(
'Unexpected call to readFileSync - test should override this implementation'
)
})
const mockWriteFileSync = jest.fn()
/**
* Helper function to mock file system operations for one or more files
* @param fileContents - Object mapping file paths to their contents
* @param nonExistentFiles - Array of file paths that should be treated as non-existent
*/
function mockFileContent(
fileContents: Record<string, string> = {},
nonExistentFiles: string[] = []
): void {
// Mock existsSync to return true for files that exist, false for those that don't
mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
const [path] = args as [string]
if (nonExistentFiles.includes(path)) {
return false
}
return path in fileContents || true
})
// Mock readFileSync to return the content for known files
mockReadFileSync.mockImplementation((...args: unknown[]): string => {
const [path, options] = args as [string, BufferEncoding]
if (options === 'utf-8' && path in fileContents) {
return fileContents[path]
}
throw new Error(`Unexpected file read: ${path}`)
})
}
/**
* Helper function to mock action inputs
* @param inputs - Object mapping input names to their values
*/
function mockInputs(inputs: Record<string, string> = {}): void {
// Default values that are applied unless overridden
const defaultInputs: Record<string, string> = {
token: 'fake-token',
model: 'gpt-4',
'max-tokens': '100',
endpoint: 'https://api.test.com'
}
// Combine defaults with user-provided inputs
const allInputs: Record<string, string> = { ...defaultInputs, ...inputs }
core.getInput.mockImplementation((name: string) => {
return allInputs[name] || ''
})
core.getBooleanInput.mockImplementation((name: string) => {
const value = allInputs[name]
return value === 'true'
})
}
/**
* Helper function to verify common response assertions
*/
function verifyStandardResponse(): void {
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'response', 'Hello, user!')
expect(core.setOutput).toHaveBeenNthCalledWith(
2,
'response-file',
expect.stringContaining('modelResponse.txt')
)
}
jest.unstable_mockModule('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync
}))
// Mock MCP and inference modules
const mockConnectToMCP = jest.fn() as jest.MockedFunction<any>
const mockSimpleInference = jest.fn() as jest.MockedFunction<any>
const mockMcpInference = jest.fn() as jest.MockedFunction<any>
jest.unstable_mockModule('../src/mcp.js', () => ({
connectToMCP: mockConnectToMCP
}))
jest.unstable_mockModule('../src/inference.js', () => ({
simpleInference: mockSimpleInference,
mcpInference: mockMcpInference
}))
jest.unstable_mockModule('@actions/core', () => core)
// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
const { run } = await import('../src/main.js')
describe('main.ts', () => {
// Reset all mocks before each test
beforeEach(() => {
jest.clearAllMocks()
// Set up default mock responses
mockSimpleInference.mockResolvedValue('Hello, user!')
mockMcpInference.mockResolvedValue('Hello, user!')
})
it('Sets the response output', async () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.'
})
await run()
expect(core.setOutput).toHaveBeenCalled()
verifyStandardResponse()
})
it('Sets a failed status when no prompt is set', async () => {
mockInputs({
prompt: '',
'prompt-file': ''
})
await run()
expect(core.setFailed).toHaveBeenNthCalledWith(
1,
'Neither prompt-file nor prompt was set'
)
})
it('uses simple inference when MCP is disabled', async () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'false'
})
await run()
expect(mockSimpleInference).toHaveBeenCalledWith({
systemPrompt: 'You are a test assistant.',
prompt: 'Hello, AI!',
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'fake-token'
})
expect(mockConnectToMCP).not.toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
verifyStandardResponse()
})
it('uses MCP inference when enabled and connection succeeds', async () => {
const mockMcpClient = {
client: {} as any,
tools: [{ type: 'function', function: { name: 'test-tool' } }]
}
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'true'
})
mockConnectToMCP.mockResolvedValue(mockMcpClient)
await run()
expect(mockConnectToMCP).toHaveBeenCalledWith('fake-token')
expect(mockMcpInference).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a test assistant.',
prompt: 'Hello, AI!',
token: 'fake-token'
}),
mockMcpClient
)
expect(mockSimpleInference).not.toHaveBeenCalled()
verifyStandardResponse()
})
it('falls back to simple inference when MCP connection fails', async () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'true'
})
mockConnectToMCP.mockResolvedValue(null)
await run()
expect(mockConnectToMCP).toHaveBeenCalledWith('fake-token')
expect(mockSimpleInference).toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
expect(core.warning).toHaveBeenCalledWith(
'MCP connection failed, falling back to simple inference'
)
verifyStandardResponse()
})
it('properly integrates with loadContentFromFileOrInput', async () => {
const promptFile = 'prompt.txt'
const systemPromptFile = 'system-prompt.txt'
const promptContent = 'File-based prompt'
const systemPromptContent = 'File-based system prompt'
mockFileContent({
[promptFile]: promptContent,
[systemPromptFile]: systemPromptContent
})
mockInputs({
'prompt-file': promptFile,
'system-prompt-file': systemPromptFile,
'enable-mcp': 'false'
})
await run()
expect(mockSimpleInference).toHaveBeenCalledWith({
systemPrompt: systemPromptContent,
prompt: promptContent,
modelName: 'gpt-4',
maxTokens: 100,
endpoint: 'https://api.test.com',
token: 'fake-token'
})
verifyStandardResponse()
})
it('handles non-existent prompt-file with an error', async () => {
const promptFile = 'non-existent-prompt.txt'
mockFileContent({}, [promptFile])
mockInputs({
'prompt-file': promptFile
})
await run()
expect(core.setFailed).toHaveBeenCalledWith(
`File for prompt-file was not found: ${promptFile}`
)
})
})

View File

@@ -1,383 +0,0 @@
/**
* Unit tests for the action's main functionality, src/main.ts
*/
import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
// Default to throwing errors to catch unexpected calls
const mockExistsSync = jest.fn().mockImplementation(() => {
throw new Error(
'Unexpected call to existsSync - test should override this implementation'
)
})
const mockReadFileSync = jest.fn().mockImplementation(() => {
throw new Error(
'Unexpected call to readFileSync - test should override this implementation'
)
})
const mockWriteFileSync = jest.fn()
/**
* Helper function to mock file system operations for one or more files
* @param fileContents - Object mapping file paths to their contents
* @param nonExistentFiles - Array of file paths that should be treated as non-existent
*/
function mockFileContent(
fileContents: Record<string, string> = {},
nonExistentFiles: string[] = []
): void {
// Mock existsSync to return true for files that exist, false for those that don't
mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
const [path] = args as [string]
if (nonExistentFiles.includes(path)) {
return false
}
return path in fileContents || true
})
// Mock readFileSync to return the content for known files
mockReadFileSync.mockImplementation((...args: unknown[]): string => {
const [path, options] = args as [string, BufferEncoding]
if (options === 'utf-8' && path in fileContents) {
return fileContents[path]
}
throw new Error(`Unexpected file read: ${path}`)
})
}
/**
* Helper function to mock action inputs
* @param inputs - Object mapping input names to their values
*/
function mockInputs(inputs: Record<string, string> = {}): void {
// Default values that are applied unless overridden
const defaultInputs: Record<string, string> = {
token: 'fake-token',
model: 'gpt-4',
'max-tokens': '100',
endpoint: 'https://api.test.com'
}
// Combine defaults with user-provided inputs
const allInputs: Record<string, string> = { ...defaultInputs, ...inputs }
core.getInput.mockImplementation((name: string) => {
return allInputs[name] || ''
})
core.getBooleanInput.mockImplementation((name: string) => {
const value = allInputs[name]
return value === 'true'
})
}
/**
* Helper function to verify common response assertions
*/
function verifyStandardResponse(): void {
expect(core.setOutput).toHaveBeenNthCalledWith(1, 'response', 'Hello, user!')
expect(core.setOutput).toHaveBeenNthCalledWith(
2,
'response-file',
expect.stringContaining('modelResponse.txt')
)
}
jest.unstable_mockModule('fs', () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync
}))
// Mock MCP and inference modules
const mockConnectToMCP = jest.fn() as jest.MockedFunction<any>
const mockSimpleInference = jest.fn() as jest.MockedFunction<any>
const mockMcpInference = jest.fn() as jest.MockedFunction<any>
jest.unstable_mockModule('../src/mcp.js', () => ({
connectToMCP: mockConnectToMCP
}))
jest.unstable_mockModule('../src/inference.js', () => ({
simpleInference: mockSimpleInference,
mcpInference: mockMcpInference
}))
jest.unstable_mockModule('@actions/core', () => core)
// The module being tested should be imported dynamically. This ensures that the
// mocks are used in place of any actual dependencies.
const { run } = await import('../src/main.js')
describe('main.ts', () => {
// Reset all mocks before each test
beforeEach(() => {
jest.clearAllMocks()
// Set up default mock responses
mockSimpleInference.mockResolvedValue('Hello, user!')
mockMcpInference.mockResolvedValue('Hello, user!')
})
it('Sets the response output', async () => {
// Set the action's inputs as return values from core.getInput().
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.'
})
await run()
expect(core.setOutput).toHaveBeenCalled()
verifyStandardResponse()
})
it('Sets a failed status when no prompt is set', async () => {
// Clear the getInput mock and simulate no prompt or prompt-file input
mockInputs({
prompt: '',
'prompt-file': ''
})
await run()
// Verify that the action was marked as failed.
expect(core.setFailed).toHaveBeenNthCalledWith(
1,
'Neither prompt-file nor prompt was set'
)
})
it('uses prompt-file', async () => {
const promptFile = 'prompt.txt'
const promptContent = 'This is a prompt from a file'
// Set up mock to return specific content for the prompt file
mockFileContent({
[promptFile]: promptContent
})
// Set up input mocks
mockInputs({
'prompt-file': promptFile,
'system-prompt': 'You are a test assistant.'
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
verifyStandardResponse()
})
it('handles non-existent prompt-file with an error', async () => {
const promptFile = 'non-existent-prompt.txt'
// Mock the file not existing
mockFileContent({}, [promptFile])
// Set up input mocks
mockInputs({
'prompt-file': promptFile
})
await run()
// Verify that the error was correctly reported
expect(core.setFailed).toHaveBeenCalledWith(
`File for prompt-file was not found: ${promptFile}`
)
})
it('prefers prompt-file over prompt when both are provided', async () => {
const promptFile = 'prompt.txt'
const promptFileContent = 'This is a prompt from a file that should be used'
const promptString = 'This is a direct prompt that should be ignored'
// Set up mock to return specific content for the prompt file
mockFileContent({
[promptFile]: promptFileContent
})
// Set up input mocks
mockInputs({
prompt: promptString,
'prompt-file': promptFile,
'system-prompt': 'You are a test assistant.'
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
// Check that the post call was made with the prompt from the file, not the input parameter
expect(mockPost).toHaveBeenCalledWith({
body: {
messages: [
{
role: 'system',
content: expect.any(String)
},
{ role: 'user', content: promptFileContent } // Should use the file content, not the string input
],
max_tokens: expect.any(Number),
model: expect.any(String)
}
})
verifyStandardResponse()
})
it('uses system-prompt-file', async () => {
const systemPromptFile = 'system-prompt.txt'
const systemPromptContent =
'You are a specialized system assistant for testing'
// Set up mock to return specific content for the system prompt file
mockFileContent({
[systemPromptFile]: systemPromptContent
})
// Set up input mocks
mockInputs({
prompt: 'Hello, AI!',
'system-prompt-file': systemPromptFile
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
verifyStandardResponse()
})
it('handles non-existent system-prompt-file with an error', async () => {
const systemPromptFile = 'non-existent-system-prompt.txt'
// Mock the file not existing
mockFileContent({}, [systemPromptFile])
// Set up input mocks
mockInputs({
prompt: 'Hello, AI!',
'system-prompt-file': systemPromptFile
})
await run()
// Verify that the error was correctly reported
expect(core.setFailed).toHaveBeenCalledWith(
`File for system-prompt-file was not found: ${systemPromptFile}`
)
})
it('prefers system-prompt-file over system-prompt when both are provided', async () => {
const systemPromptFile = 'system-prompt.txt'
const systemPromptFileContent =
'You are a specialized system assistant from file'
const systemPromptString =
'You are a basic system assistant from input parameter'
// Set up mock to return specific content for the system prompt file
mockFileContent({
[systemPromptFile]: systemPromptFileContent
})
// Set up input mocks
mockInputs({
prompt: 'Hello, AI!',
'system-prompt-file': systemPromptFile,
'system-prompt': systemPromptString
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
// Check that the post call was made with the system prompt from the file, not the input parameter
expect(mockPost).toHaveBeenCalledWith({
body: {
messages: [
{
role: 'system',
content: systemPromptFileContent // Should use the file content, not the string input
},
{ role: 'user', content: 'Hello, AI!' }
],
max_tokens: expect.any(Number),
model: expect.any(String)
}
})
verifyStandardResponse()
})
it('uses both prompt-file and system-prompt-file together', async () => {
const promptFile = 'prompt.txt'
const promptContent = 'This is a prompt from a file'
const systemPromptFile = 'system-prompt.txt'
const systemPromptContent =
'You are a specialized system assistant from file'
// Set up mock to return specific content for both files
mockFileContent({
[promptFile]: promptContent,
[systemPromptFile]: systemPromptContent
})
// Set up input mocks
mockInputs({
'prompt-file': promptFile,
'system-prompt-file': systemPromptFile
})
await run()
expect(mockExistsSync).toHaveBeenCalledWith(promptFile)
expect(mockExistsSync).toHaveBeenCalledWith(systemPromptFile)
expect(mockReadFileSync).toHaveBeenCalledWith(promptFile, 'utf-8')
expect(mockReadFileSync).toHaveBeenCalledWith(systemPromptFile, 'utf-8')
// Check that the post call was made with both the prompt and system prompt from files
expect(mockPost).toHaveBeenCalledWith({
body: {
messages: [
{
role: 'system',
content: systemPromptContent
},
{ role: 'user', content: promptContent }
],
max_tokens: expect.any(Number),
model: expect.any(String)
}
})
verifyStandardResponse()
})
it('passes custom max-tokens parameter to the model', async () => {
const customMaxTokens = 500
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'max-tokens': customMaxTokens.toString()
})
await run()
// Check that the post call was made with the correct max_tokens parameter
expect(mockPost).toHaveBeenCalledWith({
body: {
messages: expect.any(Array),
max_tokens: customMaxTokens,
model: expect.any(String)
}
})
verifyStandardResponse()
})
})

View File

@@ -90,12 +90,15 @@ jest.unstable_mockModule('fs', () => ({
}))
// Mock MCP and inference modules
const mockConnectToMCP = jest.fn() as jest.MockedFunction<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockConnectToGitHubMCP = jest.fn() as jest.MockedFunction<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockSimpleInference = jest.fn() as jest.MockedFunction<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockMcpInference = jest.fn() as jest.MockedFunction<any>
jest.unstable_mockModule('../src/mcp.js', () => ({
connectToMCP: mockConnectToMCP
connectToGitHubMCP: mockConnectToGitHubMCP
}))
jest.unstable_mockModule('../src/inference.js', () => ({
@@ -152,7 +155,7 @@ describe('main.ts', () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'false'
'enable-github-mcp': 'false'
})
await run()
@@ -165,13 +168,14 @@ describe('main.ts', () => {
endpoint: 'https://api.test.com',
token: 'fake-token'
})
expect(mockConnectToMCP).not.toHaveBeenCalled()
expect(mockConnectToGitHubMCP).not.toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
verifyStandardResponse()
})
it('uses MCP inference when enabled and connection succeeds', async () => {
const mockMcpClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: {} as any,
tools: [{ type: 'function', function: { name: 'test-tool' } }]
}
@@ -179,14 +183,14 @@ describe('main.ts', () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'true'
'enable-github-mcp': 'true'
})
mockConnectToMCP.mockResolvedValue(mockMcpClient)
mockConnectToGitHubMCP.mockResolvedValue(mockMcpClient)
await run()
expect(mockConnectToMCP).toHaveBeenCalledWith('fake-token')
expect(mockConnectToGitHubMCP).toHaveBeenCalledWith('fake-token')
expect(mockMcpInference).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a test assistant.',
@@ -203,14 +207,14 @@ describe('main.ts', () => {
mockInputs({
prompt: 'Hello, AI!',
'system-prompt': 'You are a test assistant.',
'enable-mcp': 'true'
'enable-github-mcp': 'true'
})
mockConnectToMCP.mockResolvedValue(null)
mockConnectToGitHubMCP.mockResolvedValue(null)
await run()
expect(mockConnectToMCP).toHaveBeenCalledWith('fake-token')
expect(mockConnectToGitHubMCP).toHaveBeenCalledWith('fake-token')
expect(mockSimpleInference).toHaveBeenCalled()
expect(mockMcpInference).not.toHaveBeenCalled()
expect(core.warning).toHaveBeenCalledWith(
@@ -233,7 +237,7 @@ describe('main.ts', () => {
mockInputs({
'prompt-file': promptFile,
'system-prompt-file': systemPromptFile,
'enable-mcp': 'false'
'enable-github-mcp': 'false'
})
await run()

View File

@@ -5,14 +5,18 @@ import { jest } from '@jest/globals'
import * as core from '../__fixtures__/core.js'
// Mock MCP SDK
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockConnect = jest.fn() as jest.MockedFunction<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockListTools = jest.fn() as jest.MockedFunction<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockCallTool = jest.fn() as jest.MockedFunction<any>
const mockClient = {
connect: mockConnect,
listTools: mockListTools,
callTool: mockCallTool
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
jest.unstable_mockModule('@modelcontextprotocol/sdk/client/index.js', () => ({
@@ -29,7 +33,7 @@ jest.unstable_mockModule(
jest.unstable_mockModule('@actions/core', () => core)
// Import the module being tested
const { connectToMCP, executeToolCall, executeToolCalls } = await import(
const { connectToGitHubMCP, executeToolCall, executeToolCalls } = await import(
'../src/mcp.js'
)
@@ -38,7 +42,7 @@ describe('mcp.ts', () => {
jest.clearAllMocks()
})
describe('connectToMCP', () => {
describe('connectToGitHubMCP', () => {
it('successfully connects to MCP server and retrieves tools', async () => {
const token = 'test-token'
const mockTools = [
@@ -60,7 +64,7 @@ describe('mcp.ts', () => {
mockConnect.mockResolvedValue(undefined)
mockListTools.mockResolvedValue({ tools: mockTools })
const result = await connectToMCP(token)
const result = await connectToGitHubMCP(token)
expect(result).not.toBeNull()
expect(result?.client).toBe(mockClient)
@@ -77,13 +81,13 @@ describe('mcp.ts', () => {
'Connecting to GitHub MCP server...'
)
expect(core.info).toHaveBeenCalledWith(
'Successfully connected to MCP server'
'Successfully connected to GitHub MCP server'
)
expect(core.info).toHaveBeenCalledWith(
'Retrieved 2 tools from MCP server'
'Retrieved 2 tools from GitHub MCP server'
)
expect(core.info).toHaveBeenCalledWith(
'Mapped 2 tools for Azure AI Inference'
'Mapped 2 GitHub MCP tools for Azure AI Inference'
)
})
@@ -93,11 +97,11 @@ describe('mcp.ts', () => {
mockConnect.mockRejectedValue(connectionError)
const result = await connectToMCP(token)
const result = await connectToGitHubMCP(token)
expect(result).toBeNull()
expect(core.warning).toHaveBeenCalledWith(
'Failed to connect to MCP server: Error: Connection failed'
'Failed to connect to GitHub MCP server: Error: Connection failed'
)
})
@@ -107,15 +111,15 @@ describe('mcp.ts', () => {
mockConnect.mockResolvedValue(undefined)
mockListTools.mockResolvedValue({ tools: [] })
const result = await connectToMCP(token)
const result = await connectToGitHubMCP(token)
expect(result).not.toBeNull()
expect(result?.tools).toHaveLength(0)
expect(core.info).toHaveBeenCalledWith(
'Retrieved 0 tools from MCP server'
'Retrieved 0 tools from GitHub MCP server'
)
expect(core.info).toHaveBeenCalledWith(
'Mapped 0 tools for Azure AI Inference'
'Mapped 0 GitHub MCP tools for Azure AI Inference'
)
})
@@ -125,12 +129,12 @@ describe('mcp.ts', () => {
mockConnect.mockResolvedValue(undefined)
mockListTools.mockResolvedValue({})
const result = await connectToMCP(token)
const result = await connectToGitHubMCP(token)
expect(result).not.toBeNull()
expect(result?.tools).toHaveLength(0)
expect(core.info).toHaveBeenCalledWith(
'Retrieved 0 tools from MCP server'
'Retrieved 0 tools from GitHub MCP server'
)
})
})
@@ -139,6 +143,7 @@ describe('mcp.ts', () => {
it('successfully executes a tool call', async () => {
const toolCall = {
id: 'call-123',
type: 'function',
function: {
name: 'test-tool',
arguments: '{"param": "value"}'
@@ -163,16 +168,17 @@ describe('mcp.ts', () => {
content: JSON.stringify(toolResult.content)
})
expect(core.info).toHaveBeenCalledWith(
'Executing tool: test-tool with args: {"param": "value"}'
'Executing GitHub MCP tool: test-tool with args: {"param": "value"}'
)
expect(core.info).toHaveBeenCalledWith(
'Tool test-tool executed successfully'
'GitHub MCP tool test-tool executed successfully'
)
})
it('handles tool execution errors gracefully', async () => {
const toolCall = {
id: 'call-456',
type: 'function',
function: {
name: 'failing-tool',
arguments: '{"param": "value"}'
@@ -191,13 +197,14 @@ describe('mcp.ts', () => {
content: 'Error: Error: Tool execution failed'
})
expect(core.warning).toHaveBeenCalledWith(
'Failed to execute tool failing-tool: Error: Tool execution failed'
'Failed to execute GitHub MCP tool failing-tool: Error: Tool execution failed'
)
})
it('handles invalid JSON arguments', async () => {
const toolCall = {
id: 'call-789',
type: 'function',
function: {
name: 'test-tool',
arguments: 'invalid-json'
@@ -211,7 +218,7 @@ describe('mcp.ts', () => {
expect(result.name).toBe('test-tool')
expect(result.content).toContain('Error:')
expect(core.warning).toHaveBeenCalledWith(
expect.stringContaining('Failed to execute tool test-tool:')
expect.stringContaining('Failed to execute GitHub MCP tool test-tool:')
)
})
})
@@ -221,10 +228,12 @@ describe('mcp.ts', () => {
const toolCalls = [
{
id: 'call-1',
type: 'function',
function: { name: 'tool-1', arguments: '{}' }
},
{
id: 'call-2',
type: 'function',
function: { name: 'tool-2', arguments: '{"param": "value"}' }
}
]
@@ -256,10 +265,12 @@ describe('mcp.ts', () => {
const toolCalls = [
{
id: 'call-1',
type: 'function',
function: { name: 'tool-1', arguments: '{}' }
},
{
id: 'call-2',
type: 'function',
function: { name: 'tool-2', arguments: '{}' }
}
]