diff --git a/README.md b/README.md
index 10d987a262..9a6ef3677a 100644
--- a/README.md
+++ b/README.md
@@ -590,6 +590,7 @@ The following sets of tools are available:
|
| `code_quality` | GitHub Code Quality related tools |
|
| `code_security` | Code security related tools, such as GitHub Code Scanning |
|
| `copilot` | Copilot related tools |
+|
| `copilot_issue_intents` | Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion) |
|
| `dependabot` | Dependabot tools |
|
| `discussions` | GitHub Discussions related tools |
|
| `gists` | GitHub Gist related tools |
@@ -750,6 +751,23 @@ The following sets of tools are available:
+
Copilot Issue Intents
+
+- **assign_copilot_to_issue_with_intent** - Assign Copilot to issue with intent
+ - **Required OAuth Scopes**: `repo`
+ - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true (string, optional)
+ - `confidence`: How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, required)
+ - `custom_instructions`: Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true (string, optional)
+ - `is_suggestion`: If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case. (boolean, required)
+ - `issue_number`: Issue number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `rationale`: One concise sentence explaining what specifically about the issue led to choosing Copilot. State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria'). (string, required)
+ - `repo`: Repository name (string, required)
+
+
+
+
+
Dependabot
- **get_dependabot_alert** - Get dependabot alert
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 4665ba8044..04d3ceefae 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -25,6 +25,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| 
`code_quality` | GitHub Code Quality related tools | https://api.githubcopilot.com/mcp/x/code_quality | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_quality/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_quality&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_quality%2Freadonly%22%7D) |
| 
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
| 
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) |
+| 
`copilot_issue_intents` | Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion) | https://api.githubcopilot.com/mcp/x/copilot_issue_intents | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_issue_intents&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_issue_intents%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_issue_intents/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_issue_intents&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_issue_intents%2Freadonly%22%7D) |
| 
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) |
| 
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| 
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) |
diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go
index 4be9a45aa8..a094cada6a 100644
--- a/e2e/e2e_test.go
+++ b/e2e/e2e_test.go
@@ -1085,7 +1085,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
textContent, ok = resp.Content[0].(*mcp.TextContent)
require.True(t, ok, "expected content to be of type TextContent")
- possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."
+ possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information."
if resp.IsError && textContent.Text == possibleExpectedFailure {
t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings")
}
@@ -1104,6 +1104,115 @@ func TestAssignCopilotToIssue(t *testing.T) {
require.Equal(t, "Copilot", *assignees.Assignees[0].Login, "expected copilot to be assigned to the issue")
}
+// TestAssignCopilotToIssueWithIntent exercises the opt-in intent-aware assignment
+// tool along the is_suggestion=true path. That path records a pending Copilot
+// assignment intent rather than launching the agent, so the tool returns a
+// suggestion-shaped result without a linked pull request and no Copilot user is
+// added to the issue's assignees.
+func TestAssignCopilotToIssueWithIntent(t *testing.T) {
+ t.Parallel()
+
+ if getE2EHost() != "" && getE2EHost() != "https://github.com" {
+ t.Skip("Skipping test because the host does not support copilot being assigned to issues")
+ }
+
+ mcpClient := setupMCPClient(t)
+ ctx := context.Background()
+
+ t.Log("Getting current user...")
+ resp, err := mcpClient.CallTool(ctx, &mcp.CallToolParams{Name: "get_me"})
+ require.NoError(t, err, "expected to call 'get_me' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+
+ textContent, ok := resp.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ var trimmedGetMeText struct {
+ Login string `json:"login"`
+ }
+ err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText)
+ require.NoError(t, err, "expected to unmarshal text content successfully")
+ currentOwner := trimmedGetMeText.Login
+
+ repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli())
+
+ t.Logf("Creating repository %s/%s...", currentOwner, repoName)
+ _, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
+ Name: "create_repository",
+ Arguments: map[string]any{
+ "name": repoName,
+ "private": true,
+ "autoInit": true,
+ },
+ })
+ require.NoError(t, err, "expected to call 'create_repository' tool successfully")
+
+ t.Cleanup(func() {
+ ghClient := getRESTClient(t)
+ t.Logf("Deleting repository %s/%s...", currentOwner, repoName)
+ _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName)
+ require.NoError(t, err, "expected to delete repository successfully")
+ })
+
+ t.Logf("Creating issue in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
+ Name: "issue_write",
+ Arguments: map[string]any{
+ "method": "create",
+ "owner": currentOwner,
+ "repo": repoName,
+ "title": "Test issue for intent-aware copilot suggestion",
+ },
+ })
+ require.NoError(t, err, "expected to call 'issue_write' tool successfully")
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ t.Logf("Recording pending copilot assignment suggestion in %s/%s...", currentOwner, repoName)
+ resp, err = mcpClient.CallTool(ctx, &mcp.CallToolParams{
+ Name: "assign_copilot_to_issue_with_intent",
+ Arguments: map[string]any{
+ "owner": currentOwner,
+ "repo": repoName,
+ "issue_number": 1,
+ "rationale": "E2E: well-scoped test task.",
+ "confidence": "HIGH",
+ "is_suggestion": true,
+ },
+ })
+ require.NoError(t, err, "expected to call 'assign_copilot_to_issue_with_intent' tool successfully")
+
+ require.Len(t, resp.Content, 1, "expected content to have one item")
+ textContent, ok = resp.Content[0].(*mcp.TextContent)
+ require.True(t, ok, "expected content to be of type TextContent")
+
+ possibleExpectedFailure := "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information."
+ if resp.IsError && textContent.Text == possibleExpectedFailure {
+ t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings")
+ }
+
+ require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp))
+
+ var response map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response), "expected suggestion result to be JSON")
+ require.Equal(t, true, response["is_suggestion"], "expected is_suggestion=true in result")
+ require.Contains(t, response["message"], "pending copilot assignment suggestion",
+ "expected suggestion-shaped message, got %v", response["message"])
+ require.NotContains(t, response, "pull_request",
+ "suggestion path must not claim PR creation")
+
+ // A pure suggestion does not launch Copilot, so no Copilot user should appear
+ // on the issue's assignees list.
+ ghClient := getRESTClient(t)
+ issue, response2, err := ghClient.Issues.Get(context.Background(), currentOwner, repoName, 1)
+ require.NoError(t, err, "expected to get issue successfully")
+ require.Equal(t, http.StatusOK, response2.StatusCode, "expected to get issue successfully")
+ for _, a := range issue.Assignees {
+ require.NotEqual(t, "Copilot", *a.Login,
+ "suggestion path must not add Copilot to applied assignees")
+ }
+}
+
func TestPullRequestAtomicCreateAndSubmit(t *testing.T) {
t.Parallel()
diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
index 994d9f5709..5f44b2c6c2 100644
--- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
+++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
@@ -4,7 +4,7 @@
"readOnlyHint": false,
"title": "Assign Copilot to issue"
},
- "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n",
+ "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent\n",
"icons": [
{
"mimeType": "image/png",
diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap
new file mode 100644
index 0000000000..956c2a8142
--- /dev/null
+++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap
@@ -0,0 +1,72 @@
+{
+ "annotations": {
+ "idempotentHint": true,
+ "readOnlyHint": false,
+ "title": "Assign Copilot to issue with intent"
+ },
+ "description": "Assign Copilot to a specific issue in a GitHub repository. Prefer this tool over assign_copilot_to_issue when available.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent\n",
+ "icons": [
+ {
+ "mimeType": "image/png",
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC20lEQVRIidWUS4wMURSGv3O7kWmPEMRrSMzcbl1dpqtmGuOxsCKECCKxEBusSJhIWEhsWLFAbC1sWFiISBARCyQ2kzSZGaMxHokgXvGIiMH0PRZjpJqqHpb+TeX+59z//H/q5sD/DqlX9H1/zFeX2qzIKoFWYDKgwBtUymL0UkNaT3V3d3/+5wG2EGxB9TDIxGFMvhVhb9/drpN/NaDJC7MGdwJk6TDCv0Gvq0lve9R762GUNdFDLleaZNBrICGq+4yhvf9TJtP/KZNB2PrLlbBliBfRhajuAwnFVa/n8/nkxFkv3GO9oJrzgwVxdesV71ov6I2r5fxggfWCatYL9yYmUJgLPH7Q29WZ4OED6Me4wuAdeQK6MMqna9t0GuibBHFAmgZ9JMG9BhkXZWoSCDSATIq7aguBD0wBplq/tZBgYDIwKnZAs99mFRYD9vd/YK0dpcqhobM6d9haWyOULRTbAauwuNlvsxHTYP3iBnVyXGAa8BIYC3oVeAKioCtAPEE7FCOgR0ErIJdBBZgNskzh40+NF6K6s+9e91lp9osrxMnFoTSmSmPVsF+E5cB0YEDgtoMjjypd5wCy+WC9GnajhEAa4bkqV9LOHKwa9/yneYeyUqwX3AdyQ5EeVrrqro/hYL0g+ggemKh4HGbPmVu0+fB8U76lpR6XgJwZpoGUpNYiusZg1tXjkmCAav0OMTXfJC4eVYPqwbot6l4BCPqyLhd7lwMAWC/cYb3gi/UCzRaKOxsbFzVEM1iv2Ebt5v2Dm14qZbJecZf1Ah3UCrcTbbB+awHnjgHLgHeinHYqZ8aPSXWWy+XvcQZLpdKI9/0D7UbZiLIJmABckVSqo+/OrUrNgF+D8q1LEdcBrAJGAJ8ROlGeicorABWdAswE5gOjge8CF8Ad66v03IjqJb75WS0tE0YOmNWqLBGReaAzgIkMLrt3oM9UpSzCzW9pd+FpT8/7JK3/Gz8Ao5X6wtwP7N4AAAAASUVORK5CYII=",
+ "theme": "light"
+ },
+ {
+ "mimeType": "image/png",
+ "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACCElEQVRIid2UPWsUYRSFn3dxWWJUkESiBgslFokfhehGiGClBBQx4h9IGlEh2ijYxh+gxEL/hIWwhYpF8KNZsFRJYdJEiUbjCkqisj4W+y6Mk5nd1U4PDMOce+45L3fmDvzXUDeo59WK+kb9rn5TF9R76jm1+2/NJ9QPtseSOv4nxrvVmQ6M05hRB9qZ98ZR1NRralntitdEwmw8wQ9HbS329rQKuKLW1XJO/aX6IqdWjr1Xk/y6lG4vMBdCqOacoZZ3uBBCVZ0HDrcK2AYs5ZkAuwBb1N8Dm5JEISXoAnqzOtU9QB+wVR3KCdgClDIr6kCc4c/0O1BLNnahiYpaSmmGY62e/JpCLJ4FpmmMaBHYCDwC5mmMZBQYBC7HnhvAK+B+fN4JHAM+R4+3wGQI4S7qaExtol+9o86pq+oX9Yk6ljjtGfVprK2qr9Xb6vaET109jjqb3Jac2XaM1PLNpok1Aep+G/+dfa24nADTX1EWTgOngLE2XCYKQL0DTfKex2WhXgCutxG9i/fFNlwWpgBQL6orcWyTaldToRbUA2pow61XL0WPFfXCb1HqkPowCj6q0+qIWsw7nlpUj6i31OXY+0AdbGpCRtNRGgt1AigCX4EqsJAYTR+wAzgEdAM/gApwM4TwOOm3JiARtBk4CYwAB4F+oIfGZi/HwOfAM6ASQviU5/Vv4xcBzmW2eT1nrQAAAABJRU5ErkJggg==",
+ "theme": "dark"
+ }
+ ],
+ "inputSchema": {
+ "properties": {
+ "base_ref": {
+ "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true",
+ "type": "string"
+ },
+ "confidence": {
+ "description": "How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
+ "enum": [
+ "LOW",
+ "MEDIUM",
+ "HIGH"
+ ],
+ "type": "string"
+ },
+ "custom_instructions": {
+ "description": "Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true",
+ "type": "string"
+ },
+ "is_suggestion": {
+ "description": "If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case.",
+ "type": "boolean"
+ },
+ "issue_number": {
+ "description": "Issue number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "rationale": {
+ "description": "One concise sentence explaining what specifically about the issue led to choosing Copilot. State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').",
+ "maxLength": 280,
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "rationale",
+ "confidence",
+ "is_suggestion"
+ ],
+ "type": "object"
+ },
+ "name": "assign_copilot_to_issue_with_intent"
+}
\ No newline at end of file
diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go
index 62b18350eb..7e174db9ff 100644
--- a/pkg/github/copilot.go
+++ b/pkg/github/copilot.go
@@ -158,7 +158,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server
"a Pull Request created with source code changes to resolve the issue",
},
referenceLinks: []string{
- "https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot",
+ "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent",
},
}
@@ -273,7 +273,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server
// If we didn't find the copilot bot, we can't proceed any further.
if copilotAssignee == nil {
// The e2e tests depend upon this specific message to skip the test.
- return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil
+ return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information."), nil, nil
}
// Next, get the issue ID and repository ID
@@ -435,6 +435,358 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server
})
}
+// copilotBotAssignee is the minimal shape needed for the copilot-swe-agent bot
+// returned from the suggestedActors GraphQL query.
+type copilotBotAssignee struct {
+ ID githubv4.ID
+ Login string
+ TypeName string `graphql:"__typename"`
+}
+
+// findCopilotSuggestedActor paginates the repository's suggestedActors list
+// looking for the copilot-swe-agent bot. Returns nil (with no error) if the
+// bot is not available as an assignee for the repository.
+func findCopilotSuggestedActor(ctx context.Context, client *githubv4.Client, owner, repo string) (*copilotBotAssignee, error) {
+ type suggestedActorsQuery struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot copilotBotAssignee `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+
+ variables := map[string]any{
+ "owner": githubv4.String(owner),
+ "name": githubv4.String(repo),
+ "endCursor": (*githubv4.String)(nil),
+ }
+
+ for {
+ var query suggestedActorsQuery
+ if err := client.Query(ctx, &query, variables); err != nil {
+ return nil, err
+ }
+ for _, node := range query.Repository.SuggestedActors.Nodes {
+ if node.Bot.Login == "copilot-swe-agent" {
+ bot := node.Bot
+ return &bot, nil
+ }
+ }
+ if !query.Repository.SuggestedActors.PageInfo.HasNextPage {
+ return nil, nil
+ }
+ variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor)
+ }
+}
+
+// copilotAssigneeUnavailableMessage is returned when the copilot-swe-agent bot
+// is not among the repository's suggested actors. The e2e tests depend on this
+// exact message to skip the test.
+const copilotAssigneeUnavailableMessage = "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information."
+
+// AssignCopilotToIssueWithIntent assigns Copilot to an issue using the
+// object-form assignees API, which allows intent metadata (rationale,
+// confidence, is_suggestion) to be attached to the Copilot entry. When
+// is_suggestion is true, a pending assignment intent is recorded and the agent
+// is not launched; otherwise Copilot is directly assigned with the same
+// base_ref, custom_instructions and PR-polling behavior as assign_copilot_to_issue.
+//
+// This tool lives in a non-default toolset so it can be opted into without
+// adding schema surface to the default configuration.
+func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) inventory.ServerTool {
+ description := mvpDescription{
+ summary: "Assign Copilot to a specific issue in a GitHub repository. " +
+ "Prefer this tool over assign_copilot_to_issue when available.",
+ outcomes: []string{
+ "a Pull Request created with source code changes to resolve the issue",
+ },
+ referenceLinks: []string{
+ "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent",
+ },
+ }
+
+ return NewTool(
+ ToolsetMetadataCopilotIssueIntents,
+ mcp.Tool{
+ Name: "assign_copilot_to_issue_with_intent",
+ Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()),
+ Icons: octicons.Icons("copilot"),
+ Annotations: &mcp.ToolAnnotations{
+ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_USER_TITLE", "Assign Copilot to issue with intent"),
+ ReadOnlyHint: false,
+ IdempotentHint: true,
+ },
+ InputSchema: &jsonschema.Schema{
+ Type: "object",
+ Properties: map[string]*jsonschema.Schema{
+ "owner": {
+ Type: "string",
+ Description: "Repository owner",
+ },
+ "repo": {
+ Type: "string",
+ Description: "Repository name",
+ },
+ "issue_number": {
+ Type: "number",
+ Description: "Issue number",
+ },
+ "base_ref": {
+ Type: "string",
+ Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch. Ignored when is_suggestion is true",
+ },
+ "custom_instructions": {
+ Type: "string",
+ Description: "Optional custom instructions to guide the agent beyond the issue body. Ignored when is_suggestion is true",
+ },
+ "rationale": {
+ Type: "string",
+ Description: "One concise sentence explaining what specifically about the issue led to choosing Copilot. " +
+ "State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').",
+ MaxLength: jsonschema.Ptr(280),
+ },
+ "confidence": {
+ Type: "string",
+ Description: "How confident you are in this choice. 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
+ Enum: []any{"LOW", "MEDIUM", "HIGH"},
+ },
+ "is_suggestion": {
+ Type: "boolean",
+ Description: "If true, records a pending Copilot assignment intent rather than launching the agent. Approval later supplies the launch context; base_ref and custom_instructions are ignored in this case.",
+ },
+ },
+ Required: []string{"owner", "repo", "issue_number", "rationale", "confidence", "is_suggestion"},
+ },
+ },
+ []scopes.Scope{scopes.Repo},
+ func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
+ // Presence-check is_suggestion before decoding: mapstructure defaults a
+ // missing bool to false, which would silently launch Copilot instead of
+ // recording a suggestion. Require callers to make the choice explicit.
+ if _, ok := args["is_suggestion"]; !ok {
+ return utils.NewToolResultError("is_suggestion is required"), nil, nil
+ }
+
+ var params struct {
+ Owner string `mapstructure:"owner"`
+ Repo string `mapstructure:"repo"`
+ IssueNumber int32 `mapstructure:"issue_number"`
+ BaseRef string `mapstructure:"base_ref"`
+ CustomInstructions string `mapstructure:"custom_instructions"`
+ Rationale string `mapstructure:"rationale"`
+ Confidence string `mapstructure:"confidence"`
+ IsSuggestion bool `mapstructure:"is_suggestion"`
+ }
+ if err := mapstructure.WeakDecode(args, ¶ms); err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
+ }
+
+ // Validate rationale length (rune count, matching the granular assignee tools).
+ rationale := strings.TrimSpace(params.Rationale)
+ if rationale == "" {
+ return utils.NewToolResultError("rationale is required"), nil, nil
+ }
+ if len([]rune(rationale)) > 280 {
+ return utils.NewToolResultError("rationale must be 280 characters or less"), nil, nil
+ }
+
+ // Validate/normalize confidence.
+ confidence := normalizeConfidence(params.Confidence)
+ if confidence == "" {
+ return utils.NewToolResultError("confidence is required"), nil, nil
+ }
+ var confidenceEnum AssignmentConfidenceLevel
+ switch confidence {
+ case "LOW", "MEDIUM", "HIGH":
+ confidenceEnum = AssignmentConfidenceLevel(confidence)
+ default:
+ return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil
+ }
+
+ client, err := deps.GetGQLClient(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Locate the copilot-swe-agent bot in the repository's suggested actors.
+ copilotAssignee, err := findCopilotSuggestedActor(ctx, client, params.Owner, params.Repo)
+ if err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil
+ }
+ if copilotAssignee == nil {
+ return utils.NewToolResultError(copilotAssigneeUnavailableMessage), nil, nil
+ }
+
+ // Fetch issue ID, repository ID, and current assignee IDs so they can be preserved.
+ var getIssueQuery struct {
+ Repository struct {
+ ID githubv4.ID
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }
+ variables := map[string]any{
+ "owner": githubv4.String(params.Owner),
+ "name": githubv4.String(params.Repo),
+ "number": githubv4.Int(params.IssueNumber),
+ }
+ if err := client.Query(ctx, &getIssueQuery, variables); err != nil {
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil
+ }
+
+ // Build object-form assignees: preserved assignees carry only actorId;
+ // the copilot entry carries the intent metadata. Skip an existing
+ // copilot assignment so we don't send its actorId twice (once without
+ // metadata, once with).
+ existing := getIssueQuery.Repository.Issue.Assignees.Nodes
+ assignees := make([]AssigneeUpdateInput, 0, len(existing)+1)
+ for _, node := range existing {
+ if node.ID == copilotAssignee.ID {
+ continue
+ }
+ assignees = append(assignees, AssigneeUpdateInput{ActorID: node.ID})
+ }
+ // Build the Copilot entry with the required intent metadata. Preserved
+ // assignees carry only actorId; intent fields are attached only to the
+ // Copilot entry.
+ rationaleGQL := githubv4.String(rationale)
+ suggest := githubv4.Boolean(params.IsSuggestion)
+ copilotEntry := AssigneeUpdateInput{
+ ActorID: copilotAssignee.ID,
+ Rationale: &rationaleGQL,
+ Confidence: &confidenceEnum,
+ Suggest: &suggest,
+ }
+ assignees = append(assignees, copilotEntry)
+
+ // A pure suggestion does not launch Copilot; approval later supplies the
+ // launch context. Direct assignments keep the existing agentAssignment
+ // launch configuration and PR-polling behavior.
+ input := UpdateIssueInput{
+ ID: getIssueQuery.Repository.Issue.ID,
+ Assignees: assignees,
+ }
+ if !params.IsSuggestion {
+ emptyString := githubv4.String("")
+ agentAssignment := &AgentAssignmentInput{
+ CustomAgent: &emptyString,
+ CustomInstructions: &emptyString,
+ TargetRepositoryID: getIssueQuery.Repository.ID,
+ }
+ if params.BaseRef != "" {
+ baseRef := githubv4.String(params.BaseRef)
+ agentAssignment.BaseRef = &baseRef
+ }
+ if params.CustomInstructions != "" {
+ customInstructions := githubv4.String(params.CustomInstructions)
+ agentAssignment.CustomInstructions = &customInstructions
+ }
+ input.AgentAssignment = agentAssignment
+ }
+
+ var updateIssueMutation struct {
+ UpdateIssue struct {
+ Issue struct {
+ ID githubv4.ID
+ Number githubv4.Int
+ URL githubv4.String
+ }
+ } `graphql:"updateIssue(input: $input)"`
+ }
+
+ ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issues_copilot_assignment_api_support")
+ assignmentTime := time.Now().UTC()
+
+ if err := client.Mutate(ctxWithFeatures, &updateIssueMutation, input, nil); err != nil {
+ return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err)
+ }
+
+ result := map[string]any{
+ "issue_number": int(updateIssueMutation.UpdateIssue.Issue.Number),
+ "issue_url": string(updateIssueMutation.UpdateIssue.Issue.URL),
+ "owner": params.Owner,
+ "repo": params.Repo,
+ "is_suggestion": params.IsSuggestion,
+ }
+
+ // Suggestion path: do not poll for a PR and return a suggestion-shaped result.
+ if params.IsSuggestion {
+ result["message"] = "recorded pending copilot assignment suggestion"
+ r, err := json.Marshal(result)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil
+ }
+ return utils.NewToolResultText(string(r)), result, nil
+ }
+
+ // Direct-assignment path: poll for a linked PR created by Copilot after the assignment.
+ pollConfig := getPollConfig(ctx)
+ progressToken := request.Params.GetProgressToken()
+ if progressToken != nil && request.Session != nil && pollConfig.MaxAttempts > 0 {
+ _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
+ ProgressToken: progressToken,
+ Progress: 0,
+ Total: float64(pollConfig.MaxAttempts),
+ Message: "Copilot assigned to issue, waiting for PR creation...",
+ })
+ }
+
+ var linkedPR *linkedPullRequest
+ for attempt := range pollConfig.MaxAttempts {
+ if attempt > 0 {
+ time.Sleep(pollConfig.Delay)
+ }
+ if progressToken != nil && request.Session != nil {
+ _ = request.Session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
+ ProgressToken: progressToken,
+ Progress: float64(attempt + 1),
+ Total: float64(pollConfig.MaxAttempts),
+ Message: fmt.Sprintf("Waiting for Copilot to create PR... (attempt %d/%d)", attempt+1, pollConfig.MaxAttempts),
+ })
+ }
+ pr, err := findLinkedCopilotPR(ctx, client, params.Owner, params.Repo, int(params.IssueNumber), assignmentTime)
+ if err != nil {
+ continue
+ }
+ if pr != nil {
+ linkedPR = pr
+ break
+ }
+ }
+
+ if linkedPR != nil {
+ result["pull_request"] = map[string]any{
+ "number": linkedPR.Number,
+ "url": linkedPR.URL,
+ "title": linkedPR.Title,
+ "state": linkedPR.State,
+ }
+ result["message"] = "successfully assigned copilot to issue - pull request created"
+ } else {
+ result["message"] = "successfully assigned copilot to issue - pull request pending"
+ result["note"] = "The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates."
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil
+ }
+ return utils.NewToolResultText(string(r)), result, nil
+ })
+}
+
type ReplaceActorsForAssignableInput struct {
AssignableID githubv4.ID `json:"assignableId"`
ActorIDs []githubv4.ID `json:"actorIds"`
@@ -448,10 +800,34 @@ type AgentAssignmentInput struct {
TargetRepositoryID githubv4.ID `json:"targetRepositoryId"`
}
-// UpdateIssueInput represents the input for updating an issue with agent assignment.
+// AssignmentConfidenceLevel is a GraphQL enum indicating how confident an
+// intent-aware assignment choice is. Encoded as its string value in variables.
+type AssignmentConfidenceLevel string
+
+const (
+ AssignmentConfidenceLevelLow AssignmentConfidenceLevel = "LOW"
+ AssignmentConfidenceLevelMedium AssignmentConfidenceLevel = "MEDIUM"
+ AssignmentConfidenceLevelHigh AssignmentConfidenceLevel = "HIGH"
+)
+
+// AssigneeUpdateInput is the object-form assignee entry accepted by
+// updateIssue when opting into intent metadata. Intent fields (rationale,
+// confidence, suggest) are only attached to the entry that carries the intent;
+// preserved assignees are sent with only actorId populated.
+type AssigneeUpdateInput struct {
+ ActorID githubv4.ID `json:"actorId"`
+ Rationale *githubv4.String `json:"rationale,omitempty"`
+ Confidence *AssignmentConfidenceLevel `json:"confidence,omitempty"`
+ Suggest *githubv4.Boolean `json:"suggest,omitempty"`
+}
+
+// UpdateIssueInput represents the input for updating an issue with agent
+// assignment. AssigneeIDs and Assignees are mutually exclusive: legacy callers
+// use AssigneeIDs; intent-aware callers use Assignees (object-form).
type UpdateIssueInput struct {
ID githubv4.ID `json:"id"`
- AssigneeIDs []githubv4.ID `json:"assigneeIds"`
+ AssigneeIDs []githubv4.ID `json:"assigneeIds,omitempty"`
+ Assignees []AssigneeUpdateInput `json:"assignees,omitempty"`
AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"`
}
diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go
index f52c8eecc5..63c0cc8784 100644
--- a/pkg/github/copilot_test.go
+++ b/pkg/github/copilot_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
@@ -581,7 +582,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
),
),
expectToolError: true,
- expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.",
+ expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-cloud-agent for more information.",
},
{
name: "successful assignment with base_ref specified",
@@ -961,3 +962,450 @@ func Test_RequestCopilotReview(t *testing.T) {
})
}
}
+
+func TestAssignCopilotToIssueWithIntent(t *testing.T) {
+ t.Parallel()
+
+ serverTool := AssignCopilotToIssueWithIntent(translations.NullTranslationHelper)
+ tool := serverTool.Tool
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "assign_copilot_to_issue_with_intent", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Equal(t, "copilot_issue_intents", string(serverTool.Toolset.ID),
+ "tool must live in the non-default copilot_issue_intents toolset")
+ assert.False(t, serverTool.Toolset.Default,
+ "copilot_issue_intents toolset must not be a default toolset")
+
+ require.NotNil(t, tool.Annotations)
+ assert.False(t, tool.Annotations.ReadOnlyHint, "tool must not be read-only")
+
+ schema := tool.InputSchema.(*jsonschema.Schema)
+ for _, prop := range []string{
+ "owner", "repo", "issue_number",
+ "base_ref", "custom_instructions",
+ "rationale", "confidence", "is_suggestion",
+ } {
+ assert.Contains(t, schema.Properties, prop)
+ }
+ assert.ElementsMatch(t, schema.Required, []string{
+ "owner", "repo", "issue_number",
+ "rationale", "confidence", "is_suggestion",
+ })
+
+ rationaleSchema := schema.Properties["rationale"]
+ require.NotNil(t, rationaleSchema.MaxLength)
+ assert.Equal(t, 280, *rationaleSchema.MaxLength)
+
+ confidenceSchema := schema.Properties["confidence"]
+ assert.ElementsMatch(t, confidenceSchema.Enum, []any{"LOW", "MEDIUM", "HIGH"})
+
+ // Common query mocks reused across happy-path scenarios.
+ suggestedActorsMatcher := func() githubv4mock.Matcher {
+ return githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{
+ map[string]any{
+ "id": githubv4.ID("copilot-swe-agent-id"),
+ "login": githubv4.String("copilot-swe-agent"),
+ "__typename": "Bot",
+ },
+ },
+ },
+ },
+ }),
+ )
+ }
+
+ getIssueMatcher := func(existingAssignees []any) githubv4mock.Matcher {
+ return githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ ID githubv4.ID
+ Issue struct {
+ ID githubv4.ID
+ Assignees struct {
+ Nodes []struct {
+ ID githubv4.ID
+ }
+ } `graphql:"assignees(first: 100)"`
+ } `graphql:"issue(number: $number)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "number": githubv4.Int(123),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "id": githubv4.ID("test-repo-id"),
+ "issue": map[string]any{
+ "id": githubv4.ID("test-issue-id"),
+ "assignees": map[string]any{
+ "nodes": existingAssignees,
+ },
+ },
+ },
+ }),
+ )
+ }
+
+ mutationMatcher := func(input UpdateIssueInput) githubv4mock.Matcher {
+ return githubv4mock.NewMutationMatcher(
+ struct {
+ UpdateIssue struct {
+ Issue struct {
+ ID githubv4.ID
+ Number githubv4.Int
+ URL githubv4.String
+ }
+ } `graphql:"updateIssue(input: $input)"`
+ }{},
+ input,
+ nil,
+ githubv4mock.DataResponse(map[string]any{
+ "updateIssue": map[string]any{
+ "issue": map[string]any{
+ "id": githubv4.ID("test-issue-id"),
+ "number": githubv4.Int(123),
+ "url": githubv4.String("https://github.com/owner/repo/issues/123"),
+ },
+ },
+ }),
+ )
+ }
+
+ ptrStr := func(s string) *githubv4.String { v := githubv4.String(s); return &v }
+ ptrBool := func(b bool) *githubv4.Boolean { v := githubv4.Boolean(b); return &v }
+ ptrConfidence := func(c AssignmentConfidenceLevel) *AssignmentConfidenceLevel { return &c }
+
+ tests := []struct {
+ name string
+ requestArgs map[string]any
+ mockedClient *http.Client
+ expectToolError bool
+ expectedToolErrMsg string
+ expectSuggestion bool
+ }{
+ {
+ name: "direct assignment with rationale and confidence preserves existing assignees",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "Well-scoped task with clear acceptance criteria.",
+ "confidence": "HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ suggestedActorsMatcher(),
+ getIssueMatcher([]any{
+ map[string]any{"id": githubv4.ID("existing-assignee-id")},
+ }),
+ mutationMatcher(UpdateIssueInput{
+ ID: githubv4.ID("test-issue-id"),
+ Assignees: []AssigneeUpdateInput{
+ {ActorID: githubv4.ID("existing-assignee-id")},
+ {
+ ActorID: githubv4.ID("copilot-swe-agent-id"),
+ Rationale: ptrStr("Well-scoped task with clear acceptance criteria."),
+ Confidence: ptrConfidence(AssignmentConfidenceLevelHigh),
+ Suggest: ptrBool(false),
+ },
+ },
+ AgentAssignment: &AgentAssignmentInput{
+ CustomAgent: ptrStr(""),
+ CustomInstructions: ptrStr(""),
+ TargetRepositoryID: githubv4.ID("test-repo-id"),
+ },
+ }),
+ ),
+ },
+ {
+ name: "direct assignment with base_ref and custom_instructions",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "base_ref": "feature-branch",
+ "custom_instructions": "Follow PEP 8.",
+ "rationale": "Task benefits from a linting-focused agent.",
+ "confidence": "medium",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ suggestedActorsMatcher(),
+ getIssueMatcher([]any{}),
+ mutationMatcher(UpdateIssueInput{
+ ID: githubv4.ID("test-issue-id"),
+ Assignees: []AssigneeUpdateInput{
+ {
+ ActorID: githubv4.ID("copilot-swe-agent-id"),
+ Rationale: ptrStr("Task benefits from a linting-focused agent."),
+ Confidence: ptrConfidence(AssignmentConfidenceLevelMedium),
+ Suggest: ptrBool(false),
+ },
+ },
+ AgentAssignment: &AgentAssignmentInput{
+ BaseRef: ptrStr("feature-branch"),
+ CustomAgent: ptrStr(""),
+ CustomInstructions: ptrStr("Follow PEP 8."),
+ TargetRepositoryID: githubv4.ID("test-repo-id"),
+ },
+ }),
+ ),
+ },
+ {
+ name: "suggestion path omits agentAssignment and returns suggestion-shaped result",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "Looks like a good candidate.",
+ "confidence": "LOW",
+ "is_suggestion": true,
+ // base_ref and custom_instructions should be ignored when is_suggestion=true.
+ "base_ref": "feature-branch",
+ "custom_instructions": "should be ignored",
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ suggestedActorsMatcher(),
+ getIssueMatcher([]any{}),
+ mutationMatcher(UpdateIssueInput{
+ ID: githubv4.ID("test-issue-id"),
+ Assignees: []AssigneeUpdateInput{
+ {
+ ActorID: githubv4.ID("copilot-swe-agent-id"),
+ Rationale: ptrStr("Looks like a good candidate."),
+ Confidence: ptrConfidence(AssignmentConfidenceLevelLow),
+ Suggest: ptrBool(true),
+ },
+ },
+ }),
+ ),
+ expectSuggestion: true,
+ },
+ {
+ name: "existing copilot assignee is deduplicated from preserved assignees",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "Already assigned; refreshing intent.",
+ "confidence": "HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ suggestedActorsMatcher(),
+ getIssueMatcher([]any{
+ map[string]any{"id": githubv4.ID("existing-assignee-id")},
+ map[string]any{"id": githubv4.ID("copilot-swe-agent-id")},
+ }),
+ // Expect copilot to appear only once, carrying the intent metadata.
+ mutationMatcher(UpdateIssueInput{
+ ID: githubv4.ID("test-issue-id"),
+ Assignees: []AssigneeUpdateInput{
+ {ActorID: githubv4.ID("existing-assignee-id")},
+ {
+ ActorID: githubv4.ID("copilot-swe-agent-id"),
+ Rationale: ptrStr("Already assigned; refreshing intent."),
+ Confidence: ptrConfidence(AssignmentConfidenceLevelHigh),
+ Suggest: ptrBool(false),
+ },
+ },
+ AgentAssignment: &AgentAssignmentInput{
+ CustomAgent: ptrStr(""),
+ CustomInstructions: ptrStr(""),
+ TargetRepositoryID: githubv4.ID("test-repo-id"),
+ },
+ }),
+ ),
+ },
+ {
+ name: "missing rationale is rejected",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "confidence": "HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(),
+ expectToolError: true,
+ expectedToolErrMsg: "rationale is required",
+ },
+ {
+ name: "rationale exceeding 280 characters is rejected",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": strings.Repeat("a", 281),
+ "confidence": "HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(),
+ expectToolError: true,
+ expectedToolErrMsg: "rationale must be 280 characters or less",
+ },
+ {
+ name: "missing confidence is rejected",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "A good candidate.",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(),
+ expectToolError: true,
+ expectedToolErrMsg: "confidence is required",
+ },
+ {
+ name: "missing is_suggestion is rejected",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "A good candidate.",
+ "confidence": "HIGH",
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(),
+ expectToolError: true,
+ expectedToolErrMsg: "is_suggestion is required",
+ },
+ {
+ name: "invalid confidence value is rejected",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "A good candidate.",
+ "confidence": "SUPER_HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(),
+ expectToolError: true,
+ expectedToolErrMsg: "confidence must be one of: LOW, MEDIUM, HIGH",
+ },
+ {
+ name: "copilot not a suggested actor",
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "issue_number": float64(123),
+ "rationale": "A good candidate.",
+ "confidence": "HIGH",
+ "is_suggestion": false,
+ },
+ mockedClient: githubv4mock.NewMockedHTTPClient(
+ githubv4mock.NewQueryMatcher(
+ struct {
+ Repository struct {
+ SuggestedActors struct {
+ Nodes []struct {
+ Bot struct {
+ ID githubv4.ID
+ Login githubv4.String
+ TypeName string `graphql:"__typename"`
+ } `graphql:"... on Bot"`
+ }
+ PageInfo struct {
+ HasNextPage bool
+ EndCursor string
+ }
+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
+ } `graphql:"repository(owner: $owner, name: $name)"`
+ }{},
+ map[string]any{
+ "owner": githubv4.String("owner"),
+ "name": githubv4.String("repo"),
+ "endCursor": (*githubv4.String)(nil),
+ },
+ githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "suggestedActors": map[string]any{
+ "nodes": []any{},
+ },
+ },
+ }),
+ ),
+ ),
+ expectToolError: true,
+ expectedToolErrMsg: "copilot isn't available as an assignee for this issue",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ client := githubv4.NewClient(tc.mockedClient)
+ deps := BaseDeps{GQLClient: client}
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ // Disable polling for direct-assignment paths.
+ ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0})
+ ctx = ContextWithDeps(ctx, deps)
+
+ result, err := handler(ctx, &request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ if tc.expectToolError {
+ require.True(t, result.IsError, "expected tool error, got: %s", textContent.Text)
+ assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
+ return
+ }
+
+ require.False(t, result.IsError, "unexpected tool error: %s", textContent.Text)
+
+ var response map[string]any
+ require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response), "response should be valid JSON")
+ assert.Equal(t, float64(123), response["issue_number"])
+ assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"])
+ assert.Equal(t, "owner", response["owner"])
+ assert.Equal(t, "repo", response["repo"])
+
+ if tc.expectSuggestion {
+ assert.Equal(t, true, response["is_suggestion"])
+ assert.Contains(t, response["message"], "pending copilot assignment suggestion")
+ assert.NotContains(t, response, "pull_request",
+ "suggestion path must not claim PR creation")
+ assert.NotContains(t, response, "note",
+ "suggestion path must not include the PR-pending note")
+ } else {
+ assert.Equal(t, false, response["is_suggestion"])
+ assert.Contains(t, response["message"], "successfully assigned copilot to issue")
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 5c6123c277..2cfcd3e89b 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -142,6 +142,16 @@ var (
Icon: "copilot",
}
+ // ToolsetMetadataCopilotIssueIntents is a non-default toolset that gates the
+ // opt-in intent-aware Copilot issue assignment tool. Kept out of the default
+ // configuration so its inputs (rationale, confidence, is_suggestion) do not
+ // add schema bloat to the default tool surface.
+ ToolsetMetadataCopilotIssueIntents = inventory.ToolsetMetadata{
+ ID: "copilot_issue_intents",
+ Description: "Opt-in Copilot issue assignment tools that carry intent metadata (rationale, confidence, suggestion)",
+ Icon: "copilot",
+ }
+
// Feature flag names for granular tool variants.
// When active, consolidated tools are replaced by single-purpose granular tools.
FeatureFlagIssuesGranular = "issues_granular"
@@ -249,6 +259,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
AssignCopilotToIssue(t),
RequestCopilotReview(t),
+ // Copilot issue intents (non-default, opt-in)
+ AssignCopilotToIssueWithIntent(t),
+
// Code quality tools
GetCodeQualityFinding(t),