Add read-only MCP support
This commit is contained in:
@@ -82,9 +82,9 @@ steps:
|
||||
|
||||
### GitHub MCP Integration (Model Context Protocol)
|
||||
|
||||
This action now supports integration with the GitHub-hosted Model Context
|
||||
Protocol (MCP) server, which provides access to GitHub tools like repository
|
||||
management, issue tracking, and pull request operations.
|
||||
This action now supports **read-only** integration with the GitHub-hosted Model
|
||||
Context Protocol (MCP) server, which provides access to GitHub tools like
|
||||
repository management, issue tracking, and pull request operations.
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
@@ -93,39 +93,31 @@ steps:
|
||||
uses: actions/ai-inference@v1
|
||||
with:
|
||||
prompt: 'List my open pull requests and create a summary'
|
||||
enable-mcp: true
|
||||
mcp-server-url: 'https://github-mcp-server.fly.dev/mcp' # Optional, this is the default
|
||||
enable-github-mcp: true
|
||||
```
|
||||
|
||||
When MCP is enabled, the AI model will have access to GitHub tools and can
|
||||
perform actions like:
|
||||
perform actions like searching issues and PRs.
|
||||
|
||||
- Listing and managing repositories
|
||||
- Creating, reading, and updating issues
|
||||
- Managing pull requests
|
||||
- Searching code and repositories
|
||||
- And more GitHub operations
|
||||
|
||||
**Note:** MCP integration requires appropriate GitHub permissions for the
|
||||
operations the AI will perform.
|
||||
**Note:** MCP integration requires your workflow token to have appropriate
|
||||
GitHub permissions for the operations the AI will perform.
|
||||
|
||||
## Inputs
|
||||
|
||||
Various inputs are defined in [`action.yml`](action.yml) to let you configure
|
||||
the action:
|
||||
|
||||
| Name | Description | Default |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
||||
| `prompt` | The prompt to send to the model | N/A |
|
||||
| `prompt-file` | Path to a file containing the prompt. If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
|
||||
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
|
||||
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
|
||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
||||
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
|
||||
| `max-tokens` | The max number of tokens to generate | 200 |
|
||||
| `enable-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
|
||||
| `mcp-server-url` | URL of the MCP server to connect to for GitHub tools | `https://github-mcp-server.fly.dev/mcp` |
|
||||
| Name | Description | Default |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `token` | Token to use for inference. Typically the GITHUB_TOKEN secret | `github.token` |
|
||||
| `prompt` | The prompt to send to the model | N/A |
|
||||
| `prompt-file` | Path to a file containing the prompt. If both `prompt` and `prompt-file` are provided, `prompt-file` takes precedence | `""` |
|
||||
| `system-prompt` | The system prompt to send to the model | `"You are a helpful assistant"` |
|
||||
| `system-prompt-file` | Path to a file containing the system prompt. If both `system-prompt` and `system-prompt-file` are provided, `system-prompt-file` takes precedence | `""` |
|
||||
| `model` | The model to use for inference. Must be available in the [GitHub Models](https://github.com/marketplace?type=models) catalog | `gpt-4o` |
|
||||
| `endpoint` | The endpoint to use for inference. If you're running this as part of an org, you should probably use the org-specific Models endpoint | `https://models.github.ai/inference` |
|
||||
| `max-tokens` | The max number of tokens to generate | 200 |
|
||||
| `enable-github-mcp` | Enable Model Context Protocol integration with GitHub tools | `false` |
|
||||
|
||||
## Outputs
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+15
-11
@@ -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()
|
||||
|
||||
+28
-17
@@ -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: '{}' }
|
||||
}
|
||||
]
|
||||
|
||||
+1
-5
@@ -41,14 +41,10 @@ inputs:
|
||||
description: The token to use
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
enable-mcp:
|
||||
enable-github-mcp:
|
||||
description: Enable Model Context Protocol integration with GitHub tools
|
||||
required: false
|
||||
default: 'false'
|
||||
mcp-server-url:
|
||||
description: URL of the MCP server to connect to
|
||||
required: false
|
||||
default: 'https://github-mcp-server.fly.dev/mcp'
|
||||
|
||||
# Define your outputs here.
|
||||
outputs:
|
||||
|
||||
+34
-47
@@ -41803,15 +41803,16 @@ class StreamableHTTPClientTransport {
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the MCP server and retrieve available tools
|
||||
* Connect to the GitHub MCP server and retrieve available tools
|
||||
*/
|
||||
async function connectToMCP(token) {
|
||||
const mcpServerUrl = 'https://api.githubcopilot.com/mcp/';
|
||||
async function connectToGitHubMCP(token) {
|
||||
const githubMcpUrl = 'https://api.githubcopilot.com/mcp/';
|
||||
coreExports.info('Connecting to GitHub MCP server...');
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl), {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(githubMcpUrl), {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-MCP-Readonly': 'true'
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -41824,14 +41825,13 @@ async function connectToMCP(token) {
|
||||
await client.connect(transport);
|
||||
}
|
||||
catch (mcpError) {
|
||||
coreExports.warning(`Failed to connect to MCP server: ${mcpError}`);
|
||||
coreExports.warning(`Failed to connect to GitHub MCP server: ${mcpError}`);
|
||||
return null;
|
||||
}
|
||||
coreExports.info('Successfully connected to MCP server');
|
||||
// Pull tool metadata
|
||||
coreExports.info('Successfully connected to GitHub MCP server');
|
||||
const toolsResponse = await client.listTools();
|
||||
coreExports.info(`Retrieved ${toolsResponse.tools?.length || 0} tools from MCP server`);
|
||||
// Map MCP → Azure tool definitions
|
||||
coreExports.info(`Retrieved ${toolsResponse.tools?.length || 0} tools from GitHub MCP server`);
|
||||
// Map GitHub MCP tools → Azure AI Inference tool definitions
|
||||
const tools = (toolsResponse.tools || []).map((t) => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -41840,24 +41840,21 @@ async function connectToMCP(token) {
|
||||
parameters: t.inputSchema
|
||||
}
|
||||
}));
|
||||
coreExports.info(`Mapped ${tools.length} tools for Azure AI Inference`);
|
||||
coreExports.info(`Mapped ${tools.length} GitHub MCP tools for Azure AI Inference`);
|
||||
return { client, tools };
|
||||
}
|
||||
/**
|
||||
* Execute a single tool call via MCP
|
||||
* Execute a single tool call via GitHub MCP
|
||||
*/
|
||||
async function executeToolCall(mcpClient, toolCall) {
|
||||
coreExports.info(`Executing tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`);
|
||||
async function executeToolCall(githubMcpClient, toolCall) {
|
||||
coreExports.info(`Executing GitHub MCP tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`);
|
||||
try {
|
||||
// Parse the arguments from JSON string
|
||||
const args = JSON.parse(toolCall.function.arguments);
|
||||
// Call the tool via MCP
|
||||
const result = await mcpClient.callTool({
|
||||
const result = await githubMcpClient.callTool({
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
});
|
||||
coreExports.info(`Tool ${toolCall.function.name} executed successfully`);
|
||||
// Return the result formatted for the conversation
|
||||
coreExports.info(`GitHub MCP tool ${toolCall.function.name} executed successfully`);
|
||||
return {
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
@@ -41866,8 +41863,7 @@ async function executeToolCall(mcpClient, toolCall) {
|
||||
};
|
||||
}
|
||||
catch (toolError) {
|
||||
coreExports.warning(`Failed to execute tool ${toolCall.function.name}: ${toolError}`);
|
||||
// Return error result to continue conversation
|
||||
coreExports.warning(`Failed to execute GitHub MCP tool ${toolCall.function.name}: ${toolError}`);
|
||||
return {
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
@@ -41877,12 +41873,12 @@ async function executeToolCall(mcpClient, toolCall) {
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Execute all tool calls from a response
|
||||
* Execute all tool calls from a response via GitHub MCP
|
||||
*/
|
||||
async function executeToolCalls(mcpClient, toolCalls) {
|
||||
async function executeToolCalls(githubMcpClient, toolCalls) {
|
||||
const toolResults = [];
|
||||
for (const toolCall of toolCalls) {
|
||||
const result = await executeToolCall(mcpClient, toolCall);
|
||||
const result = await executeToolCall(githubMcpClient, toolCall);
|
||||
toolResults.push(result);
|
||||
}
|
||||
return toolResults;
|
||||
@@ -49008,15 +49004,15 @@ async function simpleInference(request) {
|
||||
return modelResponse;
|
||||
}
|
||||
/**
|
||||
* MCP-enabled inference with tool execution loop
|
||||
* GitHub MCP-enabled inference with tool execution loop
|
||||
*/
|
||||
async function mcpInference(request, mcpClient) {
|
||||
coreExports.info('Running MCP inference with tools');
|
||||
async function mcpInference(request, githubMcpClient) {
|
||||
coreExports.info('Running GitHub MCP inference with tools');
|
||||
const client = createClient(request.endpoint, new AzureKeyCredential(request.token), {
|
||||
userAgentOptions: { userAgentPrefix: 'github-actions-ai-inference' }
|
||||
});
|
||||
// Start with the initial conversation
|
||||
let messages = [
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: request.systemPrompt
|
||||
@@ -49032,7 +49028,7 @@ async function mcpInference(request, mcpClient) {
|
||||
messages: messages,
|
||||
max_tokens: request.maxTokens,
|
||||
model: request.modelName,
|
||||
tools: mcpClient.tools
|
||||
tools: githubMcpClient.tools
|
||||
};
|
||||
const response = await client.path('/chat/completions').post({
|
||||
body: requestBody
|
||||
@@ -49047,25 +49043,23 @@ async function mcpInference(request, mcpClient) {
|
||||
const modelResponse = assistantMessage.content;
|
||||
const toolCalls = assistantMessage.tool_calls;
|
||||
coreExports.info(`Model response: ${modelResponse || 'No response content'}`);
|
||||
// Add the assistant's response to the conversation
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: modelResponse,
|
||||
content: modelResponse || '',
|
||||
...(toolCalls && { tool_calls: toolCalls })
|
||||
});
|
||||
// Check if there are tool calls to execute
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
coreExports.info('No tool calls requested, ending MCP inference loop');
|
||||
coreExports.info('No tool calls requested, ending GitHub MCP inference loop');
|
||||
return modelResponse;
|
||||
}
|
||||
coreExports.info(`Model requested ${toolCalls.length} tool calls`);
|
||||
// Execute all tool calls
|
||||
const toolResults = await executeToolCalls(mcpClient.client, toolCalls);
|
||||
// Execute all tool calls via GitHub MCP
|
||||
const toolResults = await executeToolCalls(githubMcpClient.client, toolCalls);
|
||||
// Add tool results to the conversation
|
||||
messages.push(...toolResults);
|
||||
coreExports.info('Tool results added, continuing conversation...');
|
||||
}
|
||||
coreExports.warning(`MCP inference loop exceeded maximum iterations (${maxIterations})`);
|
||||
coreExports.warning(`GitHub MCP inference loop exceeded maximum iterations (${maxIterations})`);
|
||||
// Return the last assistant message content
|
||||
const lastAssistantMessage = messages
|
||||
.slice()
|
||||
@@ -49074,7 +49068,6 @@ async function mcpInference(request, mcpClient) {
|
||||
return lastAssistantMessage?.content || null;
|
||||
}
|
||||
|
||||
const RESPONSE_FILE = 'modelResponse.txt';
|
||||
/**
|
||||
* Helper function to load content from a file or use fallback input
|
||||
* @param filePathInput - Input name for the file path
|
||||
@@ -49101,6 +49094,8 @@ function loadContentFromFileOrInput(filePathInput, contentInput, defaultValue) {
|
||||
throw new Error(`Neither ${filePathInput} nor ${contentInput} was set`);
|
||||
}
|
||||
}
|
||||
|
||||
const RESPONSE_FILE = 'modelResponse.txt';
|
||||
/**
|
||||
* The main function for the action.
|
||||
*
|
||||
@@ -49108,9 +49103,7 @@ function loadContentFromFileOrInput(filePathInput, contentInput, defaultValue) {
|
||||
*/
|
||||
async function run() {
|
||||
try {
|
||||
// Load prompt content - required
|
||||
const prompt = loadContentFromFileOrInput('prompt-file', 'prompt');
|
||||
// Load system prompt with default value
|
||||
const systemPrompt = loadContentFromFileOrInput('system-prompt-file', 'system-prompt', 'You are a helpful assistant');
|
||||
const modelName = coreExports.getInput('model');
|
||||
const maxTokens = parseInt(coreExports.getInput('max-tokens'), 10);
|
||||
@@ -49119,8 +49112,7 @@ async function run() {
|
||||
throw new Error('GITHUB_TOKEN is not set');
|
||||
}
|
||||
const endpoint = coreExports.getInput('endpoint');
|
||||
const enableMcp = coreExports.getBooleanInput('enable-mcp') || false;
|
||||
// Build the inference request
|
||||
const enableMcp = coreExports.getBooleanInput('enable-github-mcp') || false;
|
||||
const inferenceRequest = {
|
||||
systemPrompt,
|
||||
prompt,
|
||||
@@ -49131,8 +49123,7 @@ async function run() {
|
||||
};
|
||||
let modelResponse = null;
|
||||
if (enableMcp) {
|
||||
// MCP-enabled inference path
|
||||
const mcpClient = await connectToMCP(token);
|
||||
const mcpClient = await connectToGitHubMCP(token);
|
||||
if (mcpClient) {
|
||||
modelResponse = await mcpInference(inferenceRequest, mcpClient);
|
||||
}
|
||||
@@ -49142,12 +49133,9 @@ async function run() {
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Simple inference path
|
||||
modelResponse = await simpleInference(inferenceRequest);
|
||||
}
|
||||
// Set outputs for other workflow steps to use
|
||||
coreExports.setOutput('response', modelResponse || '');
|
||||
// Save the response to a file in case the response overflow the output limit
|
||||
const responseFilePath = require$$1.join(tempDir(), RESPONSE_FILE);
|
||||
coreExports.setOutput('response-file', responseFilePath);
|
||||
if (modelResponse && modelResponse !== '') {
|
||||
@@ -49155,7 +49143,6 @@ async function run() {
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// Fail the workflow run if an error occurs
|
||||
if (error instanceof Error) {
|
||||
coreExports.setFailed(error.message);
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+24
-11
@@ -1,7 +1,7 @@
|
||||
import * as core from '@actions/core'
|
||||
import ModelClient, { isUnexpected } from '@azure-rest/ai-inference'
|
||||
import { AzureKeyCredential } from '@azure/core-auth'
|
||||
import { MCPClient, executeToolCalls } from './mcp.js'
|
||||
import { GitHubMCPClient, executeToolCalls } from './mcp.js'
|
||||
|
||||
export interface InferenceRequest {
|
||||
systemPrompt: string
|
||||
@@ -14,7 +14,14 @@ export interface InferenceRequest {
|
||||
|
||||
export interface InferenceResponse {
|
||||
content: string | null
|
||||
toolCalls?: any[]
|
||||
toolCalls?: Array<{
|
||||
id: string
|
||||
type: string
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,13 +72,13 @@ export async function simpleInference(
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP-enabled inference with tool execution loop
|
||||
* GitHub MCP-enabled inference with tool execution loop
|
||||
*/
|
||||
export async function mcpInference(
|
||||
request: InferenceRequest,
|
||||
mcpClient: MCPClient
|
||||
githubMcpClient: GitHubMCPClient
|
||||
): Promise<string | null> {
|
||||
core.info('Running MCP inference with tools')
|
||||
core.info('Running GitHub MCP inference with tools')
|
||||
|
||||
const client = ModelClient(
|
||||
request.endpoint,
|
||||
@@ -82,7 +89,7 @@ export async function mcpInference(
|
||||
)
|
||||
|
||||
// Start with the initial conversation
|
||||
let messages: any[] = [
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: request.systemPrompt
|
||||
@@ -101,7 +108,7 @@ export async function mcpInference(
|
||||
messages: messages,
|
||||
max_tokens: request.maxTokens,
|
||||
model: request.modelName,
|
||||
tools: mcpClient.tools
|
||||
tools: githubMcpClient.tools
|
||||
}
|
||||
|
||||
const response = await client.path('/chat/completions').post({
|
||||
@@ -125,25 +132,31 @@ export async function mcpInference(
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: modelResponse,
|
||||
content: modelResponse || '',
|
||||
...(toolCalls && { tool_calls: toolCalls })
|
||||
})
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
core.info('No tool calls requested, ending MCP inference loop')
|
||||
core.info('No tool calls requested, ending GitHub MCP inference loop')
|
||||
return modelResponse
|
||||
}
|
||||
|
||||
core.info(`Model requested ${toolCalls.length} tool calls`)
|
||||
|
||||
const toolResults = await executeToolCalls(mcpClient.client, toolCalls)
|
||||
// Execute all tool calls via GitHub MCP
|
||||
const toolResults = await executeToolCalls(
|
||||
githubMcpClient.client,
|
||||
toolCalls
|
||||
)
|
||||
|
||||
// Add tool results to the conversation
|
||||
messages.push(...toolResults)
|
||||
|
||||
core.info('Tool results added, continuing conversation...')
|
||||
}
|
||||
|
||||
core.warning(
|
||||
`MCP inference loop exceeded maximum iterations (${maxIterations})`
|
||||
`GitHub MCP inference loop exceeded maximum iterations (${maxIterations})`
|
||||
)
|
||||
|
||||
// Return the last assistant message content
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import * as core from '@actions/core'
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { connectToMCP } from './mcp.js'
|
||||
import { connectToGitHubMCP } from './mcp.js'
|
||||
import { simpleInference, mcpInference, InferenceRequest } from './inference.js'
|
||||
import { loadContentFromFileOrInput } from './helpers.js'
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function run(): Promise<void> {
|
||||
}
|
||||
|
||||
const endpoint = core.getInput('endpoint')
|
||||
const enableMcp = core.getBooleanInput('enable-mcp') || false
|
||||
const enableMcp = core.getBooleanInput('enable-github-mcp') || false
|
||||
|
||||
const inferenceRequest: InferenceRequest = {
|
||||
systemPrompt,
|
||||
@@ -46,7 +46,7 @@ export async function run(): Promise<void> {
|
||||
let modelResponse: string | null = null
|
||||
|
||||
if (enableMcp) {
|
||||
const mcpClient = await connectToMCP(token)
|
||||
const mcpClient = await connectToGitHubMCP(token)
|
||||
|
||||
if (mcpClient) {
|
||||
modelResponse = await mcpInference(inferenceRequest, mcpClient)
|
||||
|
||||
+45
-29
@@ -9,23 +9,44 @@ export interface ToolResult {
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MCPClient {
|
||||
export interface MCPTool {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string
|
||||
type: string
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubMCPClient {
|
||||
client: Client
|
||||
tools: any[]
|
||||
tools: Array<MCPTool>
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the MCP server and retrieve available tools
|
||||
* Connect to the GitHub MCP server and retrieve available tools
|
||||
*/
|
||||
export async function connectToMCP(token: string): Promise<MCPClient | null> {
|
||||
const mcpServerUrl = 'https://api.githubcopilot.com/mcp/'
|
||||
export async function connectToGitHubMCP(
|
||||
token: string
|
||||
): Promise<GitHubMCPClient | null> {
|
||||
const githubMcpUrl = 'https://api.githubcopilot.com/mcp/'
|
||||
|
||||
core.info('Connecting to GitHub MCP server...')
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl), {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(githubMcpUrl), {
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-MCP-Readonly': 'true'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -39,21 +60,20 @@ export async function connectToMCP(token: string): Promise<MCPClient | null> {
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (mcpError) {
|
||||
core.warning(`Failed to connect to MCP server: ${mcpError}`)
|
||||
core.warning(`Failed to connect to GitHub MCP server: ${mcpError}`)
|
||||
return null
|
||||
}
|
||||
|
||||
core.info('Successfully connected to MCP server')
|
||||
core.info('Successfully connected to GitHub MCP server')
|
||||
|
||||
// Pull tool metadata
|
||||
const toolsResponse = await client.listTools()
|
||||
core.info(
|
||||
`Retrieved ${toolsResponse.tools?.length || 0} tools from MCP server`
|
||||
`Retrieved ${toolsResponse.tools?.length || 0} tools from GitHub MCP server`
|
||||
)
|
||||
|
||||
// Map MCP → Azure tool definitions
|
||||
// Map GitHub MCP tools → Azure AI Inference tool definitions
|
||||
const tools = (toolsResponse.tools || []).map((t) => ({
|
||||
type: 'function',
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
@@ -61,35 +81,32 @@ export async function connectToMCP(token: string): Promise<MCPClient | null> {
|
||||
}
|
||||
}))
|
||||
|
||||
core.info(`Mapped ${tools.length} tools for Azure AI Inference`)
|
||||
core.info(`Mapped ${tools.length} GitHub MCP tools for Azure AI Inference`)
|
||||
|
||||
return { client, tools }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single tool call via MCP
|
||||
* Execute a single tool call via GitHub MCP
|
||||
*/
|
||||
export async function executeToolCall(
|
||||
mcpClient: Client,
|
||||
toolCall: any
|
||||
githubMcpClient: Client,
|
||||
toolCall: ToolCall
|
||||
): Promise<ToolResult> {
|
||||
core.info(
|
||||
`Executing tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`
|
||||
`Executing GitHub MCP tool: ${toolCall.function.name} with args: ${toolCall.function.arguments}`
|
||||
)
|
||||
|
||||
try {
|
||||
// Parse the arguments from JSON string
|
||||
const args = JSON.parse(toolCall.function.arguments)
|
||||
|
||||
// Call the tool via MCP
|
||||
const result = await mcpClient.callTool({
|
||||
const result = await githubMcpClient.callTool({
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
})
|
||||
|
||||
core.info(`Tool ${toolCall.function.name} executed successfully`)
|
||||
core.info(`GitHub MCP tool ${toolCall.function.name} executed successfully`)
|
||||
|
||||
// Return the result formatted for the conversation
|
||||
return {
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
@@ -98,10 +115,9 @@ export async function executeToolCall(
|
||||
}
|
||||
} catch (toolError) {
|
||||
core.warning(
|
||||
`Failed to execute tool ${toolCall.function.name}: ${toolError}`
|
||||
`Failed to execute GitHub MCP tool ${toolCall.function.name}: ${toolError}`
|
||||
)
|
||||
|
||||
// Return error result to continue conversation
|
||||
return {
|
||||
tool_call_id: toolCall.id,
|
||||
role: 'tool',
|
||||
@@ -112,16 +128,16 @@ export async function executeToolCall(
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all tool calls from a response
|
||||
* Execute all tool calls from a response via GitHub MCP
|
||||
*/
|
||||
export async function executeToolCalls(
|
||||
mcpClient: Client,
|
||||
toolCalls: any[]
|
||||
githubMcpClient: Client,
|
||||
toolCalls: ToolCall[]
|
||||
): Promise<ToolResult[]> {
|
||||
const toolResults: ToolResult[] = []
|
||||
|
||||
for (const toolCall of toolCalls) {
|
||||
const result = await executeToolCall(mcpClient, toolCall)
|
||||
const result = await executeToolCall(githubMcpClient, toolCall)
|
||||
toolResults.push(result)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user