# Directory Structure
```
├── .gitignore
├── attachment.go
├── go.mod
├── go.sum
├── LICENSE
├── main.go
├── README.md
├── sprint.go
├── tag.go
├── template.go
├── wiki.go
└── workitems.go
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | mcp-azuredevops-bridge
2 | mcp-azuredevops-bridge.exe
3 | start.sh
4 | test-wiki-api.ps1
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP Azure DevOps Bridge
2 |
3 | A Model Context Protocol (MCP) integration server for Azure DevOps. This focused integration allows you to manage work items, wiki documentation, sprint planning, and handle attachments and discussions seamlessly.
4 |
5 | ## 🌉 Azure DevOps Integration
6 |
7 | Connect with Azure DevOps for comprehensive project management:
8 |
9 | - **Work Items** - Create, update, query, and manage work items
10 | - **Wiki Documentation** - Create, update, and retrieve wiki pages
11 | - **Sprint Planning** - Retrieve current sprint information and list sprints
12 | - **Attachments & Discussions** - Add and retrieve attachments and comments to/from work items
13 |
14 | ## 🚀 Getting Started
15 |
16 | ### Prerequisites
17 |
18 | - Go 1.23 or later
19 | - Azure DevOps Personal Access Token (PAT)
20 |
21 | ### Installation
22 |
23 | #### Installing Go 1.23 or above
24 |
25 | ##### Windows
26 | Install Go using one of these package managers:
27 |
28 | 1. Using **winget**:
29 | ```
30 | winget install GoLang.Go
31 | ```
32 |
33 | 2. Using **Chocolatey**:
34 | ```
35 | choco install golang
36 | ```
37 |
38 | 3. Using **Scoop**:
39 | ```
40 | scoop install go
41 | ```
42 |
43 | After installation, verify with:
44 | ```
45 | go version
46 | ```
47 |
48 | ##### macOS
49 | Install Go using Homebrew:
50 |
51 | ```
52 | brew install go
53 | ```
54 |
55 | Verify the installation:
56 | ```
57 | go version
58 | ```
59 |
60 | #### Building the Project
61 |
62 | 1. Clone and build:
63 |
64 | ```bash
65 | git clone https://github.com/krishh-amilineni/mcp-azuredevops-bridge.git
66 | cd mcp-azuredevops-bridge
67 | go build
68 | ```
69 |
70 | 2. Configure your environment:
71 |
72 | ```bash
73 | export AZURE_DEVOPS_ORG="your-org"
74 | export AZDO_PAT="your-pat-token"
75 | export AZURE_DEVOPS_PROJECT="your-project"
76 | ```
77 |
78 | 3. Add to your Windsurf / Cursor configuration:
79 |
80 | ```json
81 | {
82 | "mcpServers": {
83 | "azuredevops-bridge": {
84 | "command": "/full/path/to/mcp-azuredevops-bridge/mcp-azuredevops-bridge",
85 | "args": [],
86 | "env": {
87 | "AZURE_DEVOPS_ORG": "organization",
88 | "AZDO_PAT": "personal_access_token",
89 | "AZURE_DEVOPS_PROJECT": "project"
90 | }
91 | }
92 | }
93 | }
94 | ```
95 |
96 | ## 💡 Example Workflows
97 |
98 | ### Work Item Management
99 |
100 | ```txt
101 | "Create a user story for the new authentication feature in Azure DevOps"
102 | ```
103 |
104 | ### Wiki Documentation
105 |
106 | ```txt
107 | "Create a wiki page documenting the API endpoints for our service"
108 | "List all wiki pages in our project wiki"
109 | "Get the content of the 'Getting Started' page from the wiki"
110 | "Show me all available wikis in my Azure DevOps project"
111 | ```
112 |
113 | ### Sprint Planning
114 |
115 | ```txt
116 | "Show me the current sprint's work items and their status"
117 | ```
118 |
119 | ### Attachments and Comments
120 |
121 | ```txt
122 | "Add this screenshot as an attachment to work item #123"
123 | ```
124 |
125 | ## 🔧 Features
126 |
127 | ### Work Item Management
128 | - Create new work items (user stories, bugs, tasks, etc.)
129 | - Update existing work items
130 | - Query work items by various criteria
131 | - Link work items to each other
132 |
133 | ### Wiki Management
134 | - Create and update wiki pages
135 | - Search wiki content
136 | - Retrieve page content and subpages
137 | - Automatic wiki discovery - dynamically finds all available wikis for your project
138 | - Smart wiki selection - selects the most appropriate wiki based on the project context
139 | - Get list of available wikis for debugging and exploration
140 |
141 | ### Sprint Management
142 | - Get current sprint information
143 | - List all sprints
144 | - View sprint statistics
145 |
146 | ### Attachments and Comments
147 | - Add attachments to work items
148 | - Retrieve attachments from work items
149 | - Add comments to work items
150 | - View comments on work items
151 |
152 | ## 📋 Advanced Wiki Usage
153 |
154 | The DevOps Bridge includes enhanced wiki functionality that can help you access documentation more effectively:
155 |
156 | ### Available Wiki Tools
157 |
158 | - `list_wiki_pages` - Lists all wiki pages, optionally from a specific path
159 | - `get_wiki_page` - Retrieves the content of a specific wiki page
160 | - `manage_wiki_page` - Creates or updates a wiki page
161 | - `search_wiki` - Searches for content across wiki pages
162 | - `get_available_wikis` - Lists all available wikis in your Azure DevOps organization
163 |
164 | ### Wiki Troubleshooting
165 |
166 | If you're having trouble accessing wiki content:
167 |
168 | 1. Use the `get_available_wikis` tool to see all available wikis and their IDs
169 | 2. Check that your PAT token has appropriate permissions for wiki access
170 | 3. Verify that the wiki path is correct - wiki paths are case-sensitive
171 | 4. Enable verbose logging to see detailed request and response information
172 |
173 | ## 🔒 Security
174 |
175 | This integration uses Personal Access Tokens (PAT) for authenticating with Azure DevOps. Ensure your PAT has the appropriate permissions for the operations you want to perform.
176 |
177 | ## 📝 Credits
178 |
179 | This project was inspired by and draws from the original work at [TheApeMachine/mcp-server-devops-bridge](https://github.com/TheApeMachine/mcp-server-devops-bridge). We appreciate their contribution to the open source community.
180 |
181 | ## 📝 License
182 |
183 | This project is licensed under the MIT License - see the LICENSE file for details.
184 |
185 | ## 🤝 Contributing
186 |
187 | Contributions are welcome! Please feel free to submit a Pull Request.
188 |
189 | 1. Fork the repository
190 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
191 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
192 | 4. Push to the branch (`git push origin feature/amazing-feature`)
193 | 5. Open a Pull Request
194 |
```
--------------------------------------------------------------------------------
/tag.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "strings"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi"
10 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
11 | )
12 |
13 | // Handler for managing work item tags
14 | func handleManageWorkItemTags(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
15 | id := int(request.Params.Arguments["id"].(float64))
16 | operation := request.Params.Arguments["operation"].(string)
17 | tagsStr := request.Params.Arguments["tags"].(string)
18 | tags := strings.Split(tagsStr, ",")
19 |
20 | // Get current work item to get existing tags
21 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
22 | Id: &id,
23 | Project: &config.Project,
24 | })
25 | if err != nil {
26 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
27 | }
28 |
29 | fields := *workItem.Fields
30 | var currentTags []string
31 | if tags, ok := fields["System.Tags"].(string); ok && tags != "" {
32 | currentTags = strings.Split(tags, "; ")
33 | }
34 |
35 | var newTags []string
36 | switch operation {
37 | case "add":
38 | // Add new tags while avoiding duplicates
39 | tagMap := make(map[string]bool)
40 | for _, tag := range currentTags {
41 | tagMap[strings.TrimSpace(tag)] = true
42 | }
43 | for _, tag := range tags {
44 | tagMap[strings.TrimSpace(tag)] = true
45 | }
46 | for tag := range tagMap {
47 | newTags = append(newTags, tag)
48 | }
49 | case "remove":
50 | // Remove specified tags
51 | tagMap := make(map[string]bool)
52 | for _, tag := range tags {
53 | tagMap[strings.TrimSpace(tag)] = true
54 | }
55 | for _, tag := range currentTags {
56 | if !tagMap[strings.TrimSpace(tag)] {
57 | newTags = append(newTags, tag)
58 | }
59 | }
60 | }
61 |
62 | // Update work item with new tags
63 | updateArgs := workitemtracking.UpdateWorkItemArgs{
64 | Id: &id,
65 | Project: &config.Project,
66 | Document: &[]webapi.JsonPatchOperation{
67 | {
68 | Op: &webapi.OperationValues.Replace,
69 | Path: stringPtr("/fields/System.Tags"),
70 | Value: strings.Join(newTags, "; "),
71 | },
72 | },
73 | }
74 |
75 | _, err = workItemClient.UpdateWorkItem(ctx, updateArgs)
76 | if err != nil {
77 | return mcp.NewToolResultError(fmt.Sprintf("Failed to update tags: %v", err)), nil
78 | }
79 |
80 | return mcp.NewToolResultText(fmt.Sprintf("Successfully %sd tags for work item #%d", operation, id)), nil
81 | }
82 |
83 | // Handler for getting work item tags
84 | func handleGetWorkItemTags(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
85 | id := int(request.Params.Arguments["id"].(float64))
86 |
87 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
88 | Id: &id,
89 | Project: &config.Project,
90 | })
91 | if err != nil {
92 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
93 | }
94 |
95 | fields := *workItem.Fields
96 | if tags, ok := fields["System.Tags"].(string); ok && tags != "" {
97 | return mcp.NewToolResultText(fmt.Sprintf("Tags for work item #%d:\n%s", id, tags)), nil
98 | }
99 |
100 | return mcp.NewToolResultText(fmt.Sprintf("No tags found for work item #%d", id)), nil
101 | }
102 |
```
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "os"
8 | "strings"
9 |
10 | "github.com/mark3labs/mcp-go/server"
11 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7"
12 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core"
13 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/wiki"
14 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
15 | )
16 |
17 | // AzureDevOpsConfig holds the configuration for Azure DevOps connection
18 | type AzureDevOpsConfig struct {
19 | OrganizationURL string
20 | PersonalAccessToken string
21 | Project string
22 | }
23 |
24 | // Global clients and config
25 | var (
26 | connection *azuredevops.Connection
27 | workItemClient workitemtracking.Client
28 | wikiClient wiki.Client
29 | coreClient core.Client
30 | config AzureDevOpsConfig
31 | )
32 |
33 | func main() {
34 | // Main function for the MCP server - handles initialization and startup
35 | // Load configuration from environment variables
36 | config = AzureDevOpsConfig{
37 | OrganizationURL: "https://dev.azure.com/" + os.Getenv("AZURE_DEVOPS_ORG"),
38 | PersonalAccessToken: os.Getenv("AZDO_PAT"),
39 | Project: os.Getenv("AZURE_DEVOPS_PROJECT"),
40 | }
41 |
42 | // Validate configuration
43 | if config.OrganizationURL == "" || config.PersonalAccessToken == "" || config.Project == "" {
44 | log.Fatal("Missing required environment variables: AZURE_DEVOPS_ORG, AZDO_PAT, AZURE_DEVOPS_PROJECT")
45 | }
46 |
47 | // Initialize Azure DevOps clients
48 | if err := initializeClients(config); err != nil {
49 | log.Fatalf("Failed to initialize Azure DevOps clients: %v", err)
50 | }
51 |
52 | // Create MCP server
53 | s := server.NewMCPServer(
54 | "MCP Azure DevOps Bridge",
55 | "1.0.0",
56 | server.WithResourceCapabilities(false, false),
57 | server.WithPromptCapabilities(true),
58 | server.WithLogging(),
59 | )
60 |
61 | // Configure custom error handling
62 | log.SetFlags(log.LstdFlags | log.Lshortfile)
63 | log.SetOutput(&logWriter{})
64 |
65 | // Add Work Item tools
66 | addWorkItemTools(s)
67 |
68 | // Add Wiki tools
69 | addWikiTools(s)
70 |
71 | // Start the server
72 | if err := server.ServeStdio(s); err != nil {
73 | log.Fatalf("Server error: %v\n", err)
74 | }
75 | }
76 |
77 | func max(a, b int) int {
78 | if a > b {
79 | return a
80 | }
81 | return b
82 | }
83 |
84 | func min(a, b int) int {
85 | if a < b {
86 | return a
87 | }
88 | return b
89 | }
90 |
91 | func stringPtr(s string) *string {
92 | return &s
93 | }
94 |
95 | // Initialize Azure DevOps clients
96 | func initializeClients(config AzureDevOpsConfig) error {
97 | connection = azuredevops.NewPatConnection(config.OrganizationURL, config.PersonalAccessToken)
98 |
99 | ctx := context.Background()
100 |
101 | var err error
102 |
103 | // Initialize Work Item Tracking client
104 | workItemClient, err = workitemtracking.NewClient(ctx, connection)
105 | if err != nil {
106 | return fmt.Errorf("failed to create work item client: %v", err)
107 | }
108 |
109 | // Initialize Wiki client
110 | wikiClient, err = wiki.NewClient(ctx, connection)
111 | if err != nil {
112 | return fmt.Errorf("failed to create wiki client: %v", err)
113 | }
114 |
115 | // Initialize Core client
116 | coreClient, err = core.NewClient(ctx, connection)
117 | if err != nil {
118 | return fmt.Errorf("failed to create core client: %v", err)
119 | }
120 |
121 | return nil
122 | }
123 |
124 | type logWriter struct{}
125 |
126 | func (w *logWriter) Write(bytes []byte) (int, error) {
127 | // Skip logging "Prompts not supported" errors
128 | if strings.Contains(string(bytes), "Prompts not supported") {
129 | return len(bytes), nil
130 | }
131 | return fmt.Print(string(bytes))
132 | }
133 |
```
--------------------------------------------------------------------------------
/template.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/google/uuid"
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi"
12 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
13 | )
14 |
15 | // Handler for getting work item templates
16 | func handleGetWorkItemTemplates(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
17 | workItemType := request.Params.Arguments["type"].(string)
18 |
19 | templates, err := workItemClient.GetTemplates(ctx, workitemtracking.GetTemplatesArgs{
20 | Project: &config.Project,
21 | Team: nil, // Get templates for entire project
22 | Workitemtypename: &workItemType,
23 | })
24 | if err != nil {
25 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get templates: %v", err)), nil
26 | }
27 |
28 | var results []string
29 | for _, template := range *templates {
30 | results = append(results, fmt.Sprintf("Template ID: %s\nName: %s\nDescription: %s\n---",
31 | *template.Id,
32 | *template.Name,
33 | *template.Description))
34 | }
35 |
36 | if len(results) == 0 {
37 | return mcp.NewToolResultText(fmt.Sprintf("No templates found for type: %s", workItemType)), nil
38 | }
39 |
40 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
41 | }
42 |
43 | // Handler for creating work item from template
44 | func handleCreateFromTemplate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
45 | templateID := request.Params.Arguments["template_id"].(string)
46 | fieldValuesJSON := request.Params.Arguments["field_values"].(string)
47 |
48 | var fieldValues map[string]interface{}
49 | if err := json.Unmarshal([]byte(fieldValuesJSON), &fieldValues); err != nil {
50 | return mcp.NewToolResultError(fmt.Sprintf("Invalid field values JSON: %v", err)), nil
51 | }
52 |
53 | // Convert template ID to UUID
54 | templateUUID, err := uuid.Parse(templateID)
55 | if err != nil {
56 | return mcp.NewToolResultError(fmt.Sprintf("Invalid template ID format: %v", err)), nil
57 | }
58 |
59 | // Get template
60 | template, err := workItemClient.GetTemplate(ctx, workitemtracking.GetTemplateArgs{
61 | Project: &config.Project,
62 | Team: nil,
63 | TemplateId: &templateUUID,
64 | })
65 | if err != nil {
66 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get template: %v", err)), nil
67 | }
68 |
69 | // Create work item from template
70 | createArgs := workitemtracking.CreateWorkItemArgs{
71 | Type: template.WorkItemTypeName,
72 | Project: &config.Project,
73 | }
74 |
75 | // Add template fields
76 | var operations []webapi.JsonPatchOperation
77 | for field, value := range *template.Fields {
78 | operations = append(operations, webapi.JsonPatchOperation{
79 | Op: &webapi.OperationValues.Add,
80 | Path: stringPtr("/fields/" + field),
81 | Value: value,
82 | })
83 | }
84 |
85 | // Override with provided field values
86 | for field, value := range fieldValues {
87 | operations = append(operations, webapi.JsonPatchOperation{
88 | Op: &webapi.OperationValues.Add,
89 | Path: stringPtr("/fields/" + field),
90 | Value: value,
91 | })
92 | }
93 |
94 | createArgs.Document = &operations
95 |
96 | workItem, err := workItemClient.CreateWorkItem(ctx, createArgs)
97 | if err != nil {
98 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create work item from template: %v", err)), nil
99 | }
100 |
101 | return mcp.NewToolResultText(fmt.Sprintf("Created work item #%d from template", *workItem.Id)), nil
102 | }
103 |
```
--------------------------------------------------------------------------------
/sprint.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "strings"
10 | "time"
11 |
12 | "github.com/mark3labs/mcp-go/mcp"
13 | )
14 |
15 | func handleGetCurrentSprint(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
16 | team, _ := request.Params.Arguments["team"].(string)
17 | if team == "" {
18 | team = config.Project + " Team" // Default team name
19 | }
20 |
21 | // Build the URL for the current iteration
22 | baseURL := fmt.Sprintf("%s/%s/_apis/work/teamsettings/iterations",
23 | config.OrganizationURL,
24 | config.Project)
25 |
26 | queryParams := url.Values{}
27 | queryParams.Add("$timeframe", "current")
28 | queryParams.Add("api-version", "7.2-preview")
29 |
30 | fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
31 |
32 | // Create request
33 | req, err := http.NewRequest("GET", fullURL, nil)
34 | if err != nil {
35 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
36 | }
37 |
38 | // Add authentication
39 | req.SetBasicAuth("", config.PersonalAccessToken)
40 |
41 | // Send request
42 | client := &http.Client{}
43 | resp, err := client.Do(req)
44 | if err != nil {
45 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get current sprint: %v", err)), nil
46 | }
47 | defer resp.Body.Close()
48 |
49 | if resp.StatusCode != http.StatusOK {
50 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get current sprint. Status: %d", resp.StatusCode)), nil
51 | }
52 |
53 | // Parse response
54 | var sprintResponse struct {
55 | Value []struct {
56 | Name string `json:"name"`
57 | StartDate time.Time `json:"startDate"`
58 | EndDate time.Time `json:"finishDate"`
59 | } `json:"value"`
60 | }
61 |
62 | if err := json.NewDecoder(resp.Body).Decode(&sprintResponse); err != nil {
63 | return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
64 | }
65 |
66 | if len(sprintResponse.Value) == 0 {
67 | return mcp.NewToolResultText("No active sprint found"), nil
68 | }
69 |
70 | sprint := sprintResponse.Value[0]
71 | result := fmt.Sprintf("Current Sprint: %s\nStart Date: %s\nEnd Date: %s",
72 | sprint.Name,
73 | sprint.StartDate.Format("2006-01-02"),
74 | sprint.EndDate.Format("2006-01-02"))
75 |
76 | return mcp.NewToolResultText(result), nil
77 | }
78 |
79 | func handleGetSprints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
80 | team, _ := request.Params.Arguments["team"].(string)
81 | includeCompleted, _ := request.Params.Arguments["include_completed"].(bool)
82 | if team == "" {
83 | team = config.Project + " Team"
84 | }
85 |
86 | // Build the URL for iterations
87 | baseURL := fmt.Sprintf("%s/%s/_apis/work/teamsettings/iterations",
88 | config.OrganizationURL,
89 | config.Project)
90 |
91 | queryParams := url.Values{}
92 | if !includeCompleted {
93 | queryParams.Add("$timeframe", "current,future")
94 | }
95 | queryParams.Add("api-version", "7.2-preview")
96 |
97 | fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
98 |
99 | req, err := http.NewRequest("GET", fullURL, nil)
100 | if err != nil {
101 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
102 | }
103 |
104 | req.SetBasicAuth("", config.PersonalAccessToken)
105 |
106 | client := &http.Client{}
107 | resp, err := client.Do(req)
108 | if err != nil {
109 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get sprints: %v", err)), nil
110 | }
111 | defer resp.Body.Close()
112 |
113 | if resp.StatusCode != http.StatusOK {
114 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get sprints. Status: %d", resp.StatusCode)), nil
115 | }
116 |
117 | var sprintResponse struct {
118 | Value []struct {
119 | Name string `json:"name"`
120 | StartDate time.Time `json:"startDate"`
121 | EndDate time.Time `json:"finishDate"`
122 | } `json:"value"`
123 | }
124 |
125 | if err := json.NewDecoder(resp.Body).Decode(&sprintResponse); err != nil {
126 | return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
127 | }
128 |
129 | var results []string
130 | for _, sprint := range sprintResponse.Value {
131 | results = append(results, fmt.Sprintf("Sprint: %s\nStart: %s\nEnd: %s\n---",
132 | sprint.Name,
133 | sprint.StartDate.Format("2006-01-02"),
134 | sprint.EndDate.Format("2006-01-02")))
135 | }
136 |
137 | if len(results) == 0 {
138 | return mcp.NewToolResultText("No sprints found"), nil
139 | }
140 |
141 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
142 | }
143 |
```
--------------------------------------------------------------------------------
/attachment.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/base64"
7 | "fmt"
8 | "strings"
9 |
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi"
12 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
13 | )
14 |
15 | // Handler for adding attachment to work item
16 | func handleAddWorkItemAttachment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
17 | id := int(request.Params.Arguments["id"].(float64))
18 | fileName := request.Params.Arguments["file_name"].(string)
19 | content := request.Params.Arguments["content"].(string)
20 |
21 | // Decode base64 content
22 | fileContent, err := base64.StdEncoding.DecodeString(content)
23 | if err != nil {
24 | return mcp.NewToolResultError(fmt.Sprintf("Invalid base64 content: %v", err)), nil
25 | }
26 |
27 | // Create upload stream
28 | stream := bytes.NewReader(fileContent)
29 |
30 | // Upload attachment
31 | attachment, err := workItemClient.CreateAttachment(ctx, workitemtracking.CreateAttachmentArgs{
32 | UploadStream: stream,
33 | FileName: &fileName,
34 | Project: &config.Project,
35 | })
36 | if err != nil {
37 | return mcp.NewToolResultError(fmt.Sprintf("Failed to upload attachment: %v", err)), nil
38 | }
39 |
40 | // Add attachment reference to work item
41 | updateArgs := workitemtracking.UpdateWorkItemArgs{
42 | Id: &id,
43 | Project: &config.Project,
44 | Document: &[]webapi.JsonPatchOperation{
45 | {
46 | Op: &webapi.OperationValues.Add,
47 | Path: stringPtr("/relations/-"),
48 | Value: map[string]interface{}{
49 | "rel": "AttachedFile",
50 | "url": *attachment.Url,
51 | "attributes": map[string]interface{}{
52 | "name": fileName,
53 | },
54 | },
55 | },
56 | },
57 | }
58 |
59 | _, err = workItemClient.UpdateWorkItem(ctx, updateArgs)
60 | if err != nil {
61 | return mcp.NewToolResultError(fmt.Sprintf("Failed to add attachment to work item: %v", err)), nil
62 | }
63 |
64 | return mcp.NewToolResultText(fmt.Sprintf("Added attachment '%s' to work item #%d", fileName, id)), nil
65 | }
66 |
67 | // Handler for getting work item attachments
68 | func handleGetWorkItemAttachments(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
69 | id := int(request.Params.Arguments["id"].(float64))
70 |
71 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
72 | Id: &id,
73 | Project: &config.Project,
74 | Expand: &workitemtracking.WorkItemExpandValues.Relations,
75 | })
76 | if err != nil {
77 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
78 | }
79 |
80 | if workItem.Relations == nil {
81 | return mcp.NewToolResultText(fmt.Sprintf("No attachments found for work item #%d", id)), nil
82 | }
83 |
84 | var results []string
85 | for _, relation := range *workItem.Relations {
86 | if *relation.Rel == "AttachedFile" {
87 | name := (*relation.Attributes)["name"].(string)
88 | results = append(results, fmt.Sprintf("ID: %s\nName: %s\nURL: %s\n---",
89 | *relation.Url,
90 | name,
91 | *relation.Url))
92 | }
93 | }
94 |
95 | if len(results) == 0 {
96 | return mcp.NewToolResultText(fmt.Sprintf("No attachments found for work item #%d", id)), nil
97 | }
98 |
99 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
100 | }
101 |
102 | // Handler for removing attachment from work item
103 | func handleRemoveWorkItemAttachment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
104 | id := int(request.Params.Arguments["id"].(float64))
105 | attachmentID := request.Params.Arguments["attachment_id"].(string)
106 |
107 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
108 | Id: &id,
109 | Project: &config.Project,
110 | Expand: &workitemtracking.WorkItemExpandValues.Relations,
111 | })
112 | if err != nil {
113 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
114 | }
115 |
116 | if workItem.Relations == nil {
117 | return mcp.NewToolResultError("Work item has no attachments"), nil
118 | }
119 |
120 | // Find the attachment relation index
121 | var relationIndex int = -1
122 | for i, relation := range *workItem.Relations {
123 | if *relation.Rel == "AttachedFile" && strings.Contains(*relation.Url, attachmentID) {
124 | relationIndex = i
125 | break
126 | }
127 | }
128 |
129 | if relationIndex == -1 {
130 | return mcp.NewToolResultError("Attachment not found"), nil
131 | }
132 |
133 | // Remove the attachment relation
134 | updateArgs := workitemtracking.UpdateWorkItemArgs{
135 | Id: &id,
136 | Project: &config.Project,
137 | Document: &[]webapi.JsonPatchOperation{
138 | {
139 | Op: &webapi.OperationValues.Remove,
140 | Path: stringPtr(fmt.Sprintf("/relations/%d", relationIndex)),
141 | },
142 | },
143 | }
144 |
145 | _, err = workItemClient.UpdateWorkItem(ctx, updateArgs)
146 | if err != nil {
147 | return mcp.NewToolResultError(fmt.Sprintf("Failed to remove attachment: %v", err)), nil
148 | }
149 |
150 | return mcp.NewToolResultText(fmt.Sprintf("Removed attachment from work item #%d", id)), nil
151 | }
152 |
```
--------------------------------------------------------------------------------
/wiki.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "log"
9 | "net/http"
10 | "net/url"
11 | "strings"
12 |
13 | "github.com/mark3labs/mcp-go/mcp"
14 | "github.com/mark3labs/mcp-go/server"
15 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/wiki"
16 | )
17 |
18 | func addWikiTools(s *server.MCPServer) {
19 | // Wiki Page Management
20 | manageWikiTool := mcp.NewTool("manage_wiki_page",
21 | mcp.WithDescription("Create or update a wiki page"),
22 | mcp.WithString("path",
23 | mcp.Required(),
24 | mcp.Description("Path of the wiki page"),
25 | ),
26 | mcp.WithString("content",
27 | mcp.Required(),
28 | mcp.Description("Content of the wiki page in markdown format"),
29 | ),
30 | )
31 | s.AddTool(manageWikiTool, handleManageWikiPage)
32 |
33 | // Get Wiki Page
34 | getWikiTool := mcp.NewTool("get_wiki_page",
35 | mcp.WithDescription("Get content of a wiki page"),
36 | mcp.WithString("path",
37 | mcp.Required(),
38 | mcp.Description("Path of the wiki page to retrieve"),
39 | ),
40 | mcp.WithBoolean("include_children",
41 | mcp.Description("Whether to include child pages"),
42 | ),
43 | )
44 | s.AddTool(getWikiTool, handleGetWikiPage)
45 |
46 | // List Wiki Pages
47 | listWikiTool := mcp.NewTool("list_wiki_pages",
48 | mcp.WithDescription("List wiki pages in a directory"),
49 | mcp.WithString("path",
50 | mcp.Description("Path to list pages from (optional)"),
51 | ),
52 | mcp.WithBoolean("recursive",
53 | mcp.Description("Whether to list pages recursively"),
54 | ),
55 | )
56 | s.AddTool(listWikiTool, handleListWikiPages)
57 |
58 | // Search Wiki
59 | searchWikiTool := mcp.NewTool("search_wiki",
60 | mcp.WithDescription("Search wiki pages"),
61 | mcp.WithString("query",
62 | mcp.Required(),
63 | mcp.Description("Search query"),
64 | ),
65 | mcp.WithString("path",
66 | mcp.Description("Path to limit search to (optional)"),
67 | ),
68 | )
69 | s.AddTool(searchWikiTool, handleSearchWiki)
70 |
71 | // Get Available Wikis
72 | getWikisTool := mcp.NewTool("get_available_wikis",
73 | mcp.WithDescription("Get information about available wikis"),
74 | )
75 | s.AddTool(getWikisTool, handleGetWikis)
76 | }
77 |
78 | func handleManageWikiPage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
79 | path := request.Params.Arguments["path"].(string)
80 | content := request.Params.Arguments["content"].(string)
81 | // Note: Comments are not supported by the Azure DevOps Wiki API
82 | _, _ = request.Params.Arguments["comment"].(string)
83 |
84 | // Get all available wikis for the project
85 | wikis, err := getWikisForProject(ctx)
86 | if err != nil {
87 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil
88 | }
89 |
90 | if len(wikis) == 0 {
91 | return mcp.NewToolResultError("No wikis found for this project"), nil
92 | }
93 |
94 | // Use the first wiki by default, or try to match by project name
95 | wikiId := *wikis[0].Id
96 | for _, wiki := range wikis {
97 | if strings.Contains(*wiki.Name, config.Project) {
98 | wikiId = *wiki.Id
99 | break
100 | }
101 | }
102 |
103 | // Convert wiki ID to the format expected by the API
104 | wikiIdentifier := fmt.Sprintf("%s", wikiId)
105 |
106 | _, err = wikiClient.CreateOrUpdatePage(ctx, wiki.CreateOrUpdatePageArgs{
107 | WikiIdentifier: &wikiIdentifier,
108 | Path: &path,
109 | Project: &config.Project,
110 | Parameters: &wiki.WikiPageCreateOrUpdateParameters{
111 | Content: &content,
112 | },
113 | })
114 |
115 | if err != nil {
116 | return mcp.NewToolResultError(fmt.Sprintf("Failed to manage wiki page: %v", err)), nil
117 | }
118 |
119 | return mcp.NewToolResultText(fmt.Sprintf("Successfully managed wiki page: %s", path)), nil
120 | }
121 |
122 | func handleGetWikiPage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
123 | path := request.Params.Arguments["path"].(string)
124 | includeChildren, _ := request.Params.Arguments["include_children"].(bool)
125 |
126 | // Ensure path starts with a forward slash
127 | if !strings.HasPrefix(path, "/") {
128 | path = "/" + path
129 | }
130 |
131 | log.Printf("Wiki page path: %s", path)
132 |
133 | recursionLevel := "none"
134 | if includeChildren {
135 | recursionLevel = "oneLevel"
136 | }
137 |
138 | // Get all available wikis for the project
139 | wikis, err := getWikisForProject(ctx)
140 | if err != nil {
141 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil
142 | }
143 |
144 | log.Printf("Found %d wikis for project", len(wikis))
145 | for i, wiki := range wikis {
146 | log.Printf("Wiki %d: %s (ID: %s)", i+1, *wiki.Name, *wiki.Id)
147 | }
148 |
149 | if len(wikis) == 0 {
150 | return mcp.NewToolResultError("No wikis found for this project"), nil
151 | }
152 |
153 | // Use the first wiki by default
154 | wikiId := *wikis[0].Id
155 |
156 | // Try to find a wiki with a name that matches or contains the project name
157 | projectName := strings.Replace(config.Project, " ", "", -1)
158 | projectName = strings.ToLower(projectName)
159 |
160 | for _, wiki := range wikis {
161 | wikiName := strings.ToLower(*wiki.Name)
162 | if strings.Contains(wikiName, projectName) || strings.Contains(wikiName, "documentation") {
163 | wikiId = *wiki.Id
164 | log.Printf("Selected wiki: %s (ID: %s)", *wiki.Name, wikiId)
165 | break
166 | }
167 | }
168 |
169 | // Build the URL with query parameters
170 | baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages",
171 | config.OrganizationURL,
172 | url.PathEscape(config.Project),
173 | wikiId)
174 |
175 | queryParams := url.Values{}
176 | queryParams.Add("path", path)
177 | queryParams.Add("recursionLevel", recursionLevel)
178 | queryParams.Add("includeContent", "true")
179 | queryParams.Add("api-version", "7.2-preview")
180 |
181 | fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
182 | log.Printf("Requesting wiki page from URL: %s", fullURL)
183 |
184 | // Create request
185 | req, err := http.NewRequest("GET", fullURL, nil)
186 | if err != nil {
187 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
188 | }
189 |
190 | // Add authentication
191 | req.SetBasicAuth("", config.PersonalAccessToken)
192 |
193 | // Send request
194 | client := &http.Client{}
195 | resp, err := client.Do(req)
196 | if err != nil {
197 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wiki page: %v", err)), nil
198 | }
199 | defer resp.Body.Close()
200 |
201 | // Read the response body
202 | responseBody, err := io.ReadAll(resp.Body)
203 | if err != nil {
204 | return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil
205 | }
206 |
207 | if resp.StatusCode != http.StatusOK {
208 | // Log more details about the error
209 | log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody))
210 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wiki page. Status: %d", resp.StatusCode)), nil
211 | }
212 |
213 | // Parse response
214 | var wikiResponse struct {
215 | Content string `json:"content"`
216 | SubPages []struct {
217 | Path string `json:"path"`
218 | Content string `json:"content"`
219 | } `json:"subPages"`
220 | }
221 |
222 | log.Printf("Wiki API Response: %s", string(responseBody))
223 |
224 | if err := json.Unmarshal(responseBody, &wikiResponse); err != nil {
225 | return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
226 | }
227 |
228 | // Format result
229 | var result strings.Builder
230 | result.WriteString(fmt.Sprintf("=== %s ===\n\n", path))
231 | result.WriteString(wikiResponse.Content)
232 |
233 | if includeChildren && len(wikiResponse.SubPages) > 0 {
234 | result.WriteString("\n\nSub-pages:\n")
235 | for _, subPage := range wikiResponse.SubPages {
236 | result.WriteString(fmt.Sprintf("\n=== %s ===\n", subPage.Path))
237 | result.WriteString(subPage.Content)
238 | result.WriteString("\n")
239 | }
240 | }
241 |
242 | return mcp.NewToolResultText(result.String()), nil
243 | }
244 |
245 | func handleListWikiPages(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
246 | path, _ := request.Params.Arguments["path"].(string)
247 | recursive, _ := request.Params.Arguments["recursive"].(bool)
248 |
249 | recursionLevel := "oneLevel"
250 | if recursive {
251 | recursionLevel = "full"
252 | }
253 |
254 | // Get all available wikis for the project
255 | wikis, err := getWikisForProject(ctx)
256 | if err != nil {
257 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil
258 | }
259 |
260 | if len(wikis) == 0 {
261 | return mcp.NewToolResultError("No wikis found for this project"), nil
262 | }
263 |
264 | // Use the first wiki by default, or try to match by project name
265 | wikiId := *wikis[0].Id
266 | for _, wiki := range wikis {
267 | if strings.Contains(*wiki.Name, config.Project) {
268 | wikiId = *wiki.Id
269 | break
270 | }
271 | }
272 |
273 | // Build the URL with query parameters
274 | baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages",
275 | config.OrganizationURL,
276 | url.PathEscape(config.Project),
277 | wikiId)
278 |
279 | queryParams := url.Values{}
280 | if path != "" {
281 | queryParams.Add("path", path)
282 | }
283 | queryParams.Add("recursionLevel", recursionLevel)
284 | queryParams.Add("api-version", "7.2-preview")
285 |
286 | fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
287 |
288 | // Create request
289 | req, err := http.NewRequest("GET", fullURL, nil)
290 | if err != nil {
291 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
292 | }
293 |
294 | // Add authentication
295 | req.SetBasicAuth("", config.PersonalAccessToken)
296 |
297 | // Send request
298 | client := &http.Client{}
299 | resp, err := client.Do(req)
300 | if err != nil {
301 | return mcp.NewToolResultError(fmt.Sprintf("Failed to list wiki pages: %v", err)), nil
302 | }
303 | defer resp.Body.Close()
304 |
305 | // Read the response body
306 | responseBody, err := io.ReadAll(resp.Body)
307 | if err != nil {
308 | return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil
309 | }
310 |
311 | if resp.StatusCode != http.StatusOK {
312 | // Log error details
313 | log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody))
314 | return mcp.NewToolResultError(fmt.Sprintf("Failed to list wiki pages. Status: %d", resp.StatusCode)), nil
315 | }
316 |
317 | // Parse response
318 | var listResponse struct {
319 | Value []struct {
320 | Path string `json:"path"`
321 | RemotePath string `json:"remotePath"`
322 | IsFolder bool `json:"isFolder"`
323 | } `json:"value"`
324 | }
325 |
326 | log.Printf("Wiki API Response: %s", string(responseBody))
327 |
328 | if err := json.Unmarshal(responseBody, &listResponse); err != nil {
329 | return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
330 | }
331 |
332 | // Format result
333 | var result strings.Builder
334 | var locationText string
335 | if path != "" {
336 | locationText = " in " + path
337 | }
338 | result.WriteString(fmt.Sprintf("Wiki pages%s:\n\n", locationText))
339 |
340 | for _, item := range listResponse.Value {
341 | prefix := "📄 "
342 | if item.IsFolder {
343 | prefix = "📁 "
344 | }
345 | result.WriteString(fmt.Sprintf("%s%s\n", prefix, item.Path))
346 | }
347 |
348 | return mcp.NewToolResultText(result.String()), nil
349 | }
350 |
351 | func handleSearchWiki(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
352 | query := request.Params.Arguments["query"].(string)
353 | path, hasPath := request.Params.Arguments["path"].(string)
354 |
355 | // Get all available wikis for the project
356 | wikis, err := getWikisForProject(ctx)
357 | if err != nil {
358 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil
359 | }
360 |
361 | if len(wikis) == 0 {
362 | return mcp.NewToolResultError("No wikis found for this project"), nil
363 | }
364 |
365 | // Use the first wiki by default, or try to match by project name
366 | wikiId := *wikis[0].Id
367 | for _, wiki := range wikis {
368 | if strings.Contains(*wiki.Name, config.Project) {
369 | wikiId = *wiki.Id
370 | break
371 | }
372 | }
373 |
374 | // First, get all pages (potentially under the specified path)
375 | baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages",
376 | config.OrganizationURL,
377 | url.PathEscape(config.Project),
378 | wikiId)
379 |
380 | queryParams := url.Values{}
381 | queryParams.Add("recursionLevel", "full")
382 | if hasPath {
383 | queryParams.Add("path", path)
384 | }
385 | queryParams.Add("includeContent", "true")
386 | queryParams.Add("api-version", "7.2-preview")
387 |
388 | fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
389 |
390 | // Create request
391 | req, err := http.NewRequest("GET", fullURL, nil)
392 | if err != nil {
393 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
394 | }
395 |
396 | // Add authentication
397 | req.SetBasicAuth("", config.PersonalAccessToken)
398 |
399 | // Send request
400 | client := &http.Client{}
401 | resp, err := client.Do(req)
402 | if err != nil {
403 | return mcp.NewToolResultError(fmt.Sprintf("Failed to search wiki: %v", err)), nil
404 | }
405 | defer resp.Body.Close()
406 |
407 | // Read the response body
408 | responseBody, err := io.ReadAll(resp.Body)
409 | if err != nil {
410 | return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil
411 | }
412 |
413 | if resp.StatusCode != http.StatusOK {
414 | // Log error details
415 | log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody))
416 | return mcp.NewToolResultError(fmt.Sprintf("Failed to search wiki. Status: %d", resp.StatusCode)), nil
417 | }
418 |
419 | // Parse response
420 | var searchResponse struct {
421 | Count int `json:"count"`
422 | Results []struct {
423 | FileName string `json:"fileName"`
424 | Path string `json:"path"`
425 | MatchCount int `json:"hitCount"`
426 | Repository struct {
427 | ID string `json:"id"`
428 | } `json:"repository"`
429 | Hits []struct {
430 | Content string `json:"content"`
431 | LineNumber int `json:"startLine"`
432 | } `json:"hits"`
433 | } `json:"results"`
434 | }
435 |
436 | log.Printf("Wiki API Search Response: %s", string(responseBody))
437 |
438 | if err := json.Unmarshal(responseBody, &searchResponse); err != nil {
439 | return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
440 | }
441 |
442 | // Search through the pages
443 | var results []string
444 | queryLower := strings.ToLower(query)
445 | for _, page := range searchResponse.Results {
446 | if strings.Contains(strings.ToLower(page.FileName), queryLower) {
447 | // Extract a snippet of context around the match
448 | contentLower := strings.ToLower(page.FileName)
449 | index := strings.Index(contentLower, queryLower)
450 | start := 0
451 | if index > 100 {
452 | start = index - 100
453 | }
454 | end := len(page.FileName)
455 | if index+len(query)+100 < len(page.FileName) {
456 | end = index + len(query) + 100
457 | }
458 |
459 | snippet := page.FileName[start:end]
460 | if start > 0 {
461 | snippet = "..." + snippet
462 | }
463 | if end < len(page.FileName) {
464 | snippet = snippet + "..."
465 | }
466 |
467 | results = append(results, fmt.Sprintf("Page: %s\nMatch: %s\n---\n", page.Path, snippet))
468 | }
469 | }
470 |
471 | if len(results) == 0 {
472 | return mcp.NewToolResultText(fmt.Sprintf("No matches found for '%s'", query)), nil
473 | }
474 |
475 | return mcp.NewToolResultText(fmt.Sprintf("Found %d matches:\n\n%s", len(results), strings.Join(results, "\n"))), nil
476 | }
477 |
478 | func handleGetWikis(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
479 | wikis, err := getWikisForProject(ctx)
480 | if err != nil {
481 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil
482 | }
483 |
484 | if len(wikis) == 0 {
485 | return mcp.NewToolResultError("No wikis found for this project"), nil
486 | }
487 |
488 | var result strings.Builder
489 | result.WriteString(fmt.Sprintf("Found %d wikis for project %s:\n\n", len(wikis), config.Project))
490 |
491 | for i, wiki := range wikis {
492 | result.WriteString(fmt.Sprintf("%d. Wiki Name: %s\n Wiki ID: %s\n\n",
493 | i+1, *wiki.Name, *wiki.Id))
494 | }
495 |
496 | return mcp.NewToolResultText(result.String()), nil
497 | }
498 |
499 | func getWikisForProject(ctx context.Context) ([]*wiki.Wiki, error) {
500 | // Create request
501 | wikiApiUrl := fmt.Sprintf("%s/%s/_apis/wiki/wikis?api-version=7.2-preview",
502 | config.OrganizationURL,
503 | url.PathEscape(config.Project))
504 | log.Printf("Getting wikis from URL: %s", wikiApiUrl)
505 |
506 | req, err := http.NewRequest("GET", wikiApiUrl, nil)
507 | if err != nil {
508 | return nil, err
509 | }
510 |
511 | // Add authentication
512 | req.SetBasicAuth("", config.PersonalAccessToken)
513 |
514 | // Send request
515 | client := &http.Client{}
516 | resp, err := client.Do(req)
517 | if err != nil {
518 | return nil, err
519 | }
520 | defer resp.Body.Close()
521 |
522 | log.Printf("Wiki API Status Code: %d", resp.StatusCode)
523 |
524 | // Read the response body
525 | bodyBytes, err := io.ReadAll(resp.Body)
526 | if err != nil {
527 | return nil, fmt.Errorf("Failed to read response body: %v", err)
528 | }
529 |
530 | if resp.StatusCode != http.StatusOK {
531 | log.Printf("Error response: %s", string(bodyBytes))
532 | return nil, fmt.Errorf("Failed to get wikis. Status: %d", resp.StatusCode)
533 | }
534 |
535 | // Parse response
536 | var wikisResponse struct {
537 | Value []*wiki.Wiki `json:"value"`
538 | }
539 |
540 | log.Printf("Wiki API Response: %s", string(bodyBytes))
541 |
542 | // Unmarshal JSON directly from the bytes
543 | if err := json.Unmarshal(bodyBytes, &wikisResponse); err != nil {
544 | return nil, fmt.Errorf("Failed to parse wikis response: %v", err)
545 | }
546 |
547 | log.Printf("Found %d wikis in total", len(wikisResponse.Value))
548 |
549 | // For now, return all wikis since we don't have a reliable way to filter
550 | // If needed, we can add more specific filtering later
551 | if len(wikisResponse.Value) > 0 {
552 | log.Printf("First wiki: Name=%s, ID=%s", *wikisResponse.Value[0].Name, *wikisResponse.Value[0].Id)
553 | }
554 |
555 | return wikisResponse.Value, nil
556 | }
557 |
```
--------------------------------------------------------------------------------
/workitems.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/mark3labs/mcp-go/server"
12 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi"
13 | "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking"
14 | )
15 |
16 | func addWorkItemTools(s *server.MCPServer) {
17 | // Add WIQL Query Format Prompt
18 | s.AddPrompt(mcp.NewPrompt("wiql_query_format",
19 | mcp.WithPromptDescription("Helper for formatting WIQL queries for common scenarios"),
20 | mcp.WithArgument("query_type",
21 | mcp.ArgumentDescription("Type of query to format (current_sprint, assigned_to_me, etc)"),
22 | mcp.RequiredArgument(),
23 | ),
24 | mcp.WithArgument("additional_fields",
25 | mcp.ArgumentDescription("Additional fields to include in the SELECT clause"),
26 | ),
27 | ), handleWiqlQueryFormatPrompt)
28 |
29 | // Create Work Item
30 | createWorkItemTool := mcp.NewTool("create_work_item",
31 | mcp.WithDescription("Create a new work item in Azure DevOps"),
32 | mcp.WithString("type",
33 | mcp.Required(),
34 | mcp.Description("Type of work item (Epic, Feature, User Story, Task, Bug)"),
35 | mcp.Enum("Epic", "Feature", "User Story", "Task", "Bug"),
36 | ),
37 | mcp.WithString("title",
38 | mcp.Required(),
39 | mcp.Description("Title of the work item"),
40 | ),
41 | mcp.WithString("description",
42 | mcp.Required(),
43 | mcp.Description("Description of the work item"),
44 | ),
45 | mcp.WithString("priority",
46 | mcp.Description("Priority of the work item (1-4)"),
47 | mcp.Enum("1", "2", "3", "4"),
48 | ),
49 | )
50 |
51 | s.AddTool(createWorkItemTool, handleCreateWorkItem)
52 |
53 | // Update Work Item
54 | updateWorkItemTool := mcp.NewTool("update_work_item",
55 | mcp.WithDescription("Update an existing work item in Azure DevOps"),
56 | mcp.WithNumber("id",
57 | mcp.Required(),
58 | mcp.Description("ID of the work item to update"),
59 | ),
60 | mcp.WithString("field",
61 | mcp.Required(),
62 | mcp.Description("Field to update (Title, Description, State, Priority)"),
63 | mcp.Enum("Title", "Description", "State", "Priority"),
64 | ),
65 | mcp.WithString("value",
66 | mcp.Required(),
67 | mcp.Description("New value for the field"),
68 | ),
69 | )
70 |
71 | s.AddTool(updateWorkItemTool, handleUpdateWorkItem)
72 |
73 | // Query Work Items
74 | queryWorkItemsTool := mcp.NewTool("query_work_items",
75 | mcp.WithDescription("Query work items using WIQL"),
76 | mcp.WithString("query",
77 | mcp.Required(),
78 | mcp.Description("WIQL query string"),
79 | ),
80 | )
81 |
82 | s.AddTool(queryWorkItemsTool, handleQueryWorkItems)
83 |
84 | // Get Work Item Details
85 | getWorkItemTool := mcp.NewTool("get_work_item_details",
86 | mcp.WithDescription("Get detailed information about work items"),
87 | mcp.WithString("ids",
88 | mcp.Required(),
89 | mcp.Description("Comma-separated list of work item IDs"),
90 | ),
91 | )
92 | s.AddTool(getWorkItemTool, handleGetWorkItemDetails)
93 |
94 | // Manage Work Item Relations
95 | manageRelationsTool := mcp.NewTool("manage_work_item_relations",
96 | mcp.WithDescription("Manage relationships between work items"),
97 | mcp.WithNumber("source_id",
98 | mcp.Required(),
99 | mcp.Description("ID of the source work item"),
100 | ),
101 | mcp.WithNumber("target_id",
102 | mcp.Required(),
103 | mcp.Description("ID of the target work item"),
104 | ),
105 | mcp.WithString("relation_type",
106 | mcp.Required(),
107 | mcp.Description("Type of relationship to manage"),
108 | mcp.Enum("parent", "child", "related"),
109 | ),
110 | mcp.WithString("operation",
111 | mcp.Required(),
112 | mcp.Description("Operation to perform"),
113 | mcp.Enum("add", "remove"),
114 | ),
115 | )
116 | s.AddTool(manageRelationsTool, handleManageWorkItemRelations)
117 |
118 | // Get Related Work Items
119 | getRelatedItemsTool := mcp.NewTool("get_related_work_items",
120 | mcp.WithDescription("Get related work items"),
121 | mcp.WithNumber("id",
122 | mcp.Required(),
123 | mcp.Description("ID of the work item to get relations for"),
124 | ),
125 | mcp.WithString("relation_type",
126 | mcp.Required(),
127 | mcp.Description("Type of relationships to get"),
128 | mcp.Enum("parent", "children", "related", "all"),
129 | ),
130 | )
131 | s.AddTool(getRelatedItemsTool, handleGetRelatedWorkItems)
132 |
133 | // Comment Management Tool (as Discussion)
134 | addCommentTool := mcp.NewTool("add_work_item_comment",
135 | mcp.WithDescription("Add a comment to a work item as a discussion"),
136 | mcp.WithNumber("id",
137 | mcp.Required(),
138 | mcp.Description("ID of the work item"),
139 | ),
140 | mcp.WithString("text",
141 | mcp.Required(),
142 | mcp.Description("Comment text"),
143 | ),
144 | )
145 | s.AddTool(addCommentTool, handleAddWorkItemComment)
146 |
147 | getCommentsTool := mcp.NewTool("get_work_item_comments",
148 | mcp.WithDescription("Get comments for a work item"),
149 | mcp.WithNumber("id",
150 | mcp.Required(),
151 | mcp.Description("ID of the work item"),
152 | ),
153 | )
154 | s.AddTool(getCommentsTool, handleGetWorkItemComments)
155 |
156 | // Field Management Tool
157 | getFieldsTool := mcp.NewTool("get_work_item_fields",
158 | mcp.WithDescription("Get available work item fields and their current values"),
159 | mcp.WithNumber("work_item_id",
160 | mcp.Required(),
161 | mcp.Description("ID of the work item to examine fields from"),
162 | ),
163 | mcp.WithString("field_name",
164 | mcp.Description("Optional field name to filter (case-insensitive partial match)"),
165 | ),
166 | )
167 | s.AddTool(getFieldsTool, handleGetWorkItemFields)
168 |
169 | // Batch Operations Tools
170 | batchCreateTool := mcp.NewTool("batch_create_work_items",
171 | mcp.WithDescription("Create multiple work items in a single operation"),
172 | mcp.WithString("items",
173 | mcp.Required(),
174 | mcp.Description("JSON array of work items to create, each containing type, title, and description"),
175 | ),
176 | )
177 | s.AddTool(batchCreateTool, handleBatchCreateWorkItems)
178 |
179 | batchUpdateTool := mcp.NewTool("batch_update_work_items",
180 | mcp.WithDescription("Update multiple work items in a single operation"),
181 | mcp.WithString("updates",
182 | mcp.Required(),
183 | mcp.Description("JSON array of updates, each containing id, field, and value"),
184 | ),
185 | )
186 | s.AddTool(batchUpdateTool, handleBatchUpdateWorkItems)
187 |
188 | // Tag Management Tools
189 | manageTags := mcp.NewTool("manage_work_item_tags",
190 | mcp.WithDescription("Add or remove tags from a work item"),
191 | mcp.WithNumber("id",
192 | mcp.Required(),
193 | mcp.Description("ID of the work item"),
194 | ),
195 | mcp.WithString("operation",
196 | mcp.Required(),
197 | mcp.Description("Operation to perform"),
198 | mcp.Enum("add", "remove"),
199 | ),
200 | mcp.WithString("tags",
201 | mcp.Required(),
202 | mcp.Description("Comma-separated list of tags"),
203 | ),
204 | )
205 | s.AddTool(manageTags, handleManageWorkItemTags)
206 |
207 | getTagsTool := mcp.NewTool("get_work_item_tags",
208 | mcp.WithDescription("Get tags for a work item"),
209 | mcp.WithNumber("id",
210 | mcp.Required(),
211 | mcp.Description("ID of the work item"),
212 | ),
213 | )
214 | s.AddTool(getTagsTool, handleGetWorkItemTags)
215 |
216 | // Work Item Template Tools
217 | getTemplatesTool := mcp.NewTool("get_work_item_templates",
218 | mcp.WithDescription("Get available work item templates"),
219 | mcp.WithString("type",
220 | mcp.Required(),
221 | mcp.Description("Type of work item to get templates for"),
222 | mcp.Enum("Epic", "Feature", "User Story", "Task", "Bug"),
223 | ),
224 | )
225 | s.AddTool(getTemplatesTool, handleGetWorkItemTemplates)
226 |
227 | createFromTemplateTool := mcp.NewTool("create_from_template",
228 | mcp.WithDescription("Create a work item from a template"),
229 | mcp.WithString("template_id",
230 | mcp.Required(),
231 | mcp.Description("ID of the template to use"),
232 | ),
233 | mcp.WithString("field_values",
234 | mcp.Required(),
235 | mcp.Description("JSON object of field values to override template defaults"),
236 | ),
237 | )
238 | s.AddTool(createFromTemplateTool, handleCreateFromTemplate)
239 |
240 | // Attachment Management Tools
241 | addAttachmentTool := mcp.NewTool("add_work_item_attachment",
242 | mcp.WithDescription("Add an attachment to a work item"),
243 | mcp.WithNumber("id",
244 | mcp.Required(),
245 | mcp.Description("ID of the work item"),
246 | ),
247 | mcp.WithString("file_name",
248 | mcp.Required(),
249 | mcp.Description("Name of the file to attach"),
250 | ),
251 | mcp.WithString("content",
252 | mcp.Required(),
253 | mcp.Description("Base64 encoded content of the file"),
254 | ),
255 | )
256 | s.AddTool(addAttachmentTool, handleAddWorkItemAttachment)
257 |
258 | getAttachmentsTool := mcp.NewTool("get_work_item_attachments",
259 | mcp.WithDescription("Get attachments for a work item"),
260 | mcp.WithNumber("id",
261 | mcp.Required(),
262 | mcp.Description("ID of the work item"),
263 | ),
264 | )
265 | s.AddTool(getAttachmentsTool, handleGetWorkItemAttachments)
266 |
267 | removeAttachmentTool := mcp.NewTool("remove_work_item_attachment",
268 | mcp.WithDescription("Remove an attachment from a work item"),
269 | mcp.WithNumber("id",
270 | mcp.Required(),
271 | mcp.Description("ID of the work item"),
272 | ),
273 | mcp.WithString("attachment_id",
274 | mcp.Required(),
275 | mcp.Description("ID of the attachment to remove"),
276 | ),
277 | )
278 | s.AddTool(removeAttachmentTool, handleRemoveWorkItemAttachment)
279 |
280 | // Sprint Management Tools
281 | getCurrentSprintTool := mcp.NewTool("get_current_sprint",
282 | mcp.WithDescription("Get details about the current sprint"),
283 | mcp.WithString("team",
284 | mcp.Description("Team name (optional, defaults to project's default team)"),
285 | ),
286 | )
287 | s.AddTool(getCurrentSprintTool, handleGetCurrentSprint)
288 |
289 | getSprintsTool := mcp.NewTool("get_sprints",
290 | mcp.WithDescription("Get list of sprints"),
291 | mcp.WithString("team",
292 | mcp.Description("Team name (optional, defaults to project's default team)"),
293 | ),
294 | mcp.WithBoolean("include_completed",
295 | mcp.Description("Whether to include completed sprints"),
296 | ),
297 | )
298 | s.AddTool(getSprintsTool, handleGetSprints)
299 |
300 | // Add a new prompt for work item descriptions
301 | s.AddPrompt(mcp.NewPrompt("format_work_item_description",
302 | mcp.WithPromptDescription("Format a work item description using proper HTML for Azure DevOps"),
303 | mcp.WithArgument("description",
304 | mcp.ArgumentDescription("The description text to format"),
305 | mcp.RequiredArgument(),
306 | ),
307 | ), handleFormatWorkItemDescription)
308 | }
309 |
310 | func handleUpdateWorkItem(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
311 | id := int(request.Params.Arguments["id"].(float64))
312 | field := request.Params.Arguments["field"].(string)
313 | value := request.Params.Arguments["value"].(string)
314 |
315 | // Instead of using a fixed map, directly use the field name
316 | // This allows any valid Azure DevOps field to be used
317 | updateArgs := workitemtracking.UpdateWorkItemArgs{
318 | Id: &id,
319 | Project: &config.Project,
320 | Document: &[]webapi.JsonPatchOperation{
321 | {
322 | Op: &webapi.OperationValues.Replace,
323 | Path: stringPtr("/fields/" + field),
324 | Value: value,
325 | },
326 | },
327 | }
328 |
329 | workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs)
330 | if err != nil {
331 | return mcp.NewToolResultError(fmt.Sprintf("Failed to update work item: %v", err)), nil
332 | }
333 |
334 | return mcp.NewToolResultText(fmt.Sprintf("Updated work item #%d", *workItem.Id)), nil
335 | }
336 |
337 | func handleCreateWorkItem(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
338 | workItemType := request.Params.Arguments["type"].(string)
339 | title := request.Params.Arguments["title"].(string)
340 | description := request.Params.Arguments["description"].(string)
341 | priority, hasPriority := request.Params.Arguments["priority"].(string)
342 |
343 | // Create the work item
344 | createArgs := workitemtracking.CreateWorkItemArgs{
345 | Type: &workItemType,
346 | Project: &config.Project,
347 | Document: &[]webapi.JsonPatchOperation{
348 | {
349 | Op: &webapi.OperationValues.Add,
350 | Path: stringPtr("/fields/System.Title"),
351 | Value: title,
352 | },
353 | {
354 | Op: &webapi.OperationValues.Add,
355 | Path: stringPtr("/fields/System.Description"),
356 | Value: description,
357 | },
358 | },
359 | }
360 |
361 | if hasPriority {
362 | doc := append(*createArgs.Document, webapi.JsonPatchOperation{
363 | Op: &webapi.OperationValues.Add,
364 | Path: stringPtr("/fields/Microsoft.VSTS.Common.Priority"),
365 | Value: priority,
366 | })
367 | createArgs.Document = &doc
368 | }
369 |
370 | workItem, err := workItemClient.CreateWorkItem(ctx, createArgs)
371 | if err != nil {
372 | return mcp.NewToolResultError(fmt.Sprintf("Failed to create work item: %v", err)), nil
373 | }
374 |
375 | fields := *workItem.Fields
376 | var extractedTitle string
377 | if t, ok := fields["System.Title"].(string); ok {
378 | extractedTitle = t
379 | }
380 | return mcp.NewToolResultText(fmt.Sprintf("Created work item #%d: %s", *workItem.Id, extractedTitle)), nil
381 | }
382 |
383 | func handleQueryWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
384 | query := request.Params.Arguments["query"].(string)
385 |
386 | // Create WIQL query
387 | wiqlArgs := workitemtracking.QueryByWiqlArgs{
388 | Wiql: &workitemtracking.Wiql{
389 | Query: &query,
390 | },
391 | // Ensure we pass the project context
392 | Project: &config.Project,
393 | // If you have a specific team, you can add it here
394 | // Team: &teamName,
395 | }
396 |
397 | queryResult, err := workItemClient.QueryByWiql(ctx, wiqlArgs)
398 | if err != nil {
399 | return mcp.NewToolResultError(fmt.Sprintf("Failed to query work items: %v", err)), nil
400 | }
401 |
402 | // If no work items found, return a message
403 | if queryResult.WorkItems == nil || len(*queryResult.WorkItems) == 0 {
404 | return mcp.NewToolResultText("No work items found matching the query."), nil
405 | }
406 |
407 | // Format results
408 | var results []string
409 |
410 | // If there are many work items, we should limit how many we retrieve details for
411 | maxDetailsToFetch := 20
412 | if len(*queryResult.WorkItems) > 0 {
413 | // Get the first few work item IDs
414 | count := len(*queryResult.WorkItems)
415 | if count > maxDetailsToFetch {
416 | count = maxDetailsToFetch
417 | }
418 |
419 | // Create a list of IDs to fetch
420 | var ids []int
421 | for i := 0; i < count; i++ {
422 | ids = append(ids, *(*queryResult.WorkItems)[i].Id)
423 | }
424 |
425 | // Get the work item details
426 | if len(ids) > 0 {
427 | // First add a header line with the total count
428 | results = append(results, fmt.Sprintf("Found %d work items. Showing details for the first %d:",
429 | len(*queryResult.WorkItems), count))
430 | results = append(results, "")
431 |
432 | // Fetch details for these work items
433 | getArgs := workitemtracking.GetWorkItemsArgs{
434 | Ids: &ids,
435 | }
436 | workItems, err := workItemClient.GetWorkItems(ctx, getArgs)
437 | if err == nil && workItems != nil && len(*workItems) > 0 {
438 | for _, item := range *workItems {
439 | id := *item.Id
440 | var title, state, workItemType string
441 |
442 | if item.Fields != nil {
443 | if titleVal, ok := (*item.Fields)["System.Title"]; ok {
444 | title = fmt.Sprintf("%v", titleVal)
445 | }
446 | if stateVal, ok := (*item.Fields)["System.State"]; ok {
447 | state = fmt.Sprintf("%v", stateVal)
448 | }
449 | if typeVal, ok := (*item.Fields)["System.WorkItemType"]; ok {
450 | workItemType = fmt.Sprintf("%v", typeVal)
451 | }
452 | }
453 |
454 | results = append(results, fmt.Sprintf("ID: %d - [%s] %s (%s)",
455 | id, workItemType, title, state))
456 | }
457 | } else {
458 | // Fallback to just listing the IDs if we couldn't get details
459 | for _, itemRef := range *queryResult.WorkItems {
460 | results = append(results, fmt.Sprintf("ID: %d", *itemRef.Id))
461 | }
462 | }
463 | }
464 | }
465 |
466 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
467 | }
468 |
469 | func handleWiqlQueryFormatPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
470 | queryType, exists := request.Params.Arguments["query_type"]
471 | if !exists {
472 | return nil, fmt.Errorf("query_type is required")
473 | }
474 |
475 | additionalFields := request.Params.Arguments["additional_fields"]
476 |
477 | baseFields := "[System.Id], [System.Title], [System.WorkItemType], [System.State], [System.AssignedTo]"
478 | if additionalFields != "" {
479 | baseFields += ", " + additionalFields
480 | }
481 |
482 | var template string
483 | var explanation string
484 |
485 | switch queryType {
486 | case "current_sprint":
487 | template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.IterationPath] = @currentIteration('fanapp')", baseFields)
488 | explanation = "This query gets all work items in the current sprint. The @currentIteration macro automatically resolves to the current sprint path."
489 |
490 | case "assigned_to_me":
491 | template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.AssignedTo] = @me AND [System.State] <> 'Closed'", baseFields)
492 | explanation = "This query gets all active work items assigned to the current user. The @me macro automatically resolves to the current user."
493 |
494 | case "active_bugs":
495 | template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.State] <> 'Closed' ORDER BY [Microsoft.VSTS.Common.Priority]", baseFields)
496 | explanation = "This query gets all active bugs, ordered by priority."
497 |
498 | case "blocked_items":
499 | template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.State] <> 'Closed' AND [Microsoft.VSTS.Common.Blocked] = 'Yes'", baseFields)
500 | explanation = "This query gets all work items that are marked as blocked."
501 |
502 | case "recent_activity":
503 | template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.ChangedDate] > @today-7 ORDER BY [System.ChangedDate] DESC", baseFields)
504 | explanation = "This query gets all work items modified in the last 7 days, ordered by most recent first."
505 | }
506 |
507 | return mcp.NewGetPromptResult(
508 | "WIQL Query Format Helper",
509 | []mcp.PromptMessage{
510 | mcp.NewPromptMessage(
511 | "system",
512 | mcp.NewTextContent("You are a WIQL query expert. Help format queries for Azure DevOps work items."),
513 | ),
514 | mcp.NewPromptMessage(
515 | "assistant",
516 | mcp.NewTextContent(fmt.Sprintf("Here's a template for a %s query:\n\n```sql\n%s\n```\n\n%s\n\nCommon WIQL Tips:\n- Use square brackets [] around field names\n- Common macros: @me, @today, @currentIteration\n- Date arithmetic: @today+/-n\n- String comparison is case-insensitive\n- Use 'Contains' for partial matches", queryType, template, explanation)),
517 | ),
518 | },
519 | ), nil
520 | }
521 |
522 | func handleFormatWorkItemDescription(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
523 | description := request.Params.Arguments["description"]
524 | return mcp.NewGetPromptResult(
525 | "Azure DevOps Work Item Description Formatter",
526 | []mcp.PromptMessage{
527 | mcp.NewPromptMessage(
528 | "system",
529 | mcp.NewTextContent("You format work item descriptions for Azure DevOps. Use proper HTML formatting with <ul>, <li> for bullet points, <p> for paragraphs, and <br> for line breaks."),
530 | ),
531 | mcp.NewPromptMessage(
532 | "assistant",
533 | mcp.NewTextContent(fmt.Sprintf("Here's your description formatted with HTML:\n\n<ul>\n%s\n</ul>",
534 | strings.Join(strings.Split(description, "-"), "</li>\n<li>"))),
535 | ),
536 | },
537 | ), nil
538 | }
539 |
540 | // Handler for getting detailed work item information
541 | func handleGetWorkItemDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
542 | idsStr := request.Params.Arguments["ids"].(string)
543 | idStrs := strings.Split(idsStr, ",")
544 |
545 | var ids []int
546 | for _, idStr := range idStrs {
547 | id, err := strconv.Atoi(strings.TrimSpace(idStr))
548 | if err != nil {
549 | return mcp.NewToolResultError(fmt.Sprintf("Invalid ID format: %s", idStr)), nil
550 | }
551 | ids = append(ids, id)
552 | }
553 |
554 | workItems, err := workItemClient.GetWorkItems(ctx, workitemtracking.GetWorkItemsArgs{
555 | Ids: &ids,
556 | Project: &config.Project,
557 | Expand: &workitemtracking.WorkItemExpandValues.All,
558 | })
559 |
560 | if err != nil {
561 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work items: %v", err)), nil
562 | }
563 |
564 | var results []string
565 | for _, item := range *workItems {
566 | fields := *item.Fields
567 | title, _ := fields["System.Title"].(string)
568 | description, _ := fields["System.Description"].(string)
569 | state, _ := fields["System.State"].(string)
570 |
571 | result := fmt.Sprintf("ID: %d\nTitle: %s\nState: %s\nDescription: %s\n---\n",
572 | *item.Id, title, state, description)
573 | results = append(results, result)
574 | }
575 |
576 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
577 | }
578 |
579 | // Handler for managing work item relationships
580 | func handleManageWorkItemRelations(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
581 | sourceID := int(request.Params.Arguments["source_id"].(float64))
582 | targetID := int(request.Params.Arguments["target_id"].(float64))
583 | relationType, ok := request.Params.Arguments["relation_type"].(string)
584 | if !ok {
585 | return mcp.NewToolResultError("Invalid relation_type"), nil
586 | }
587 | operation := request.Params.Arguments["operation"].(string)
588 |
589 | // Map relation types to Azure DevOps relation types
590 | relationTypeMap := map[string]string{
591 | "parent": "System.LinkTypes.Hierarchy-Reverse",
592 | "child": "System.LinkTypes.Hierarchy-Forward",
593 | "related": "System.LinkTypes.Related",
594 | }
595 |
596 | azureRelationType := relationTypeMap[relationType]
597 |
598 | var ops []webapi.JsonPatchOperation
599 | if operation == "add" {
600 | ops = []webapi.JsonPatchOperation{
601 | {
602 | Op: &webapi.OperationValues.Add,
603 | Path: stringPtr("/relations/-"),
604 | Value: map[string]interface{}{
605 | "rel": azureRelationType,
606 | "url": fmt.Sprintf("%s/_apis/wit/workItems/%d", config.OrganizationURL, targetID),
607 | "attributes": map[string]interface{}{
608 | "comment": "Added via MCP",
609 | },
610 | },
611 | },
612 | }
613 | } else {
614 | // For remove, we need to first get the work item to find the relation index
615 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
616 | Id: &sourceID,
617 | Project: &config.Project,
618 | })
619 | if err != nil {
620 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
621 | }
622 |
623 | if workItem.Relations == nil {
624 | return mcp.NewToolResultError("Work item has no relations"), nil
625 | }
626 |
627 | for i, relation := range *workItem.Relations {
628 | if *relation.Rel == azureRelationType {
629 | targetUrl := fmt.Sprintf("%s/_apis/wit/workItems/%d", config.OrganizationURL, targetID)
630 | if *relation.Url == targetUrl {
631 | ops = []webapi.JsonPatchOperation{
632 | {
633 | Op: &webapi.OperationValues.Remove,
634 | Path: stringPtr(fmt.Sprintf("/relations/%d", i)),
635 | },
636 | }
637 | break
638 | }
639 | }
640 | }
641 |
642 | if len(ops) == 0 {
643 | return mcp.NewToolResultError("Specified relation not found"), nil
644 | }
645 | }
646 |
647 | updateArgs := workitemtracking.UpdateWorkItemArgs{
648 | Id: &sourceID,
649 | Project: &config.Project,
650 | Document: &ops,
651 | }
652 |
653 | _, err := workItemClient.UpdateWorkItem(ctx, updateArgs)
654 | if err != nil {
655 | return mcp.NewToolResultError(fmt.Sprintf("Failed to update work item relations: %v", err)), nil
656 | }
657 |
658 | return mcp.NewToolResultText(fmt.Sprintf("Successfully %sd %s relationship", operation, relationType)), nil
659 | }
660 |
661 | // Handler for getting related work items
662 | func handleGetRelatedWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
663 | id := int(request.Params.Arguments["id"].(float64))
664 | relationType := request.Params.Arguments["relation_type"].(string)
665 |
666 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
667 | Id: &id,
668 | Project: &config.Project,
669 | Expand: &workitemtracking.WorkItemExpandValues.Relations,
670 | })
671 | if err != nil {
672 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil
673 | }
674 |
675 | if workItem.Relations == nil {
676 | return mcp.NewToolResultText("No related items found"), nil
677 | }
678 |
679 | relationTypeMap := map[string]string{
680 | "parent": "System.LinkTypes.Hierarchy-Reverse",
681 | "children": "System.LinkTypes.Hierarchy-Forward",
682 | "related": "System.LinkTypes.Related",
683 | }
684 |
685 | // Debug information
686 | var debugInfo []string
687 | debugInfo = append(debugInfo, fmt.Sprintf("Looking for relation type: %s (mapped to: %s)",
688 | relationType, relationTypeMap[relationType]))
689 |
690 | var relatedIds []int
691 | for _, relation := range *workItem.Relations {
692 | debugInfo = append(debugInfo, fmt.Sprintf("Found relation of type: %s", *relation.Rel))
693 |
694 | if relationType == "all" || *relation.Rel == relationTypeMap[relationType] {
695 | parts := strings.Split(*relation.Url, "/")
696 | if relatedID, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
697 | relatedIds = append(relatedIds, relatedID)
698 | }
699 | }
700 | }
701 |
702 | if len(relatedIds) == 0 {
703 | return mcp.NewToolResultText(fmt.Sprintf("Debug info:\n%s\n\nNo matching related items found",
704 | strings.Join(debugInfo, "\n"))), nil
705 | }
706 |
707 | // Get details of related items
708 | relatedItems, err := workItemClient.GetWorkItems(ctx, workitemtracking.GetWorkItemsArgs{
709 | Ids: &relatedIds,
710 | Project: &config.Project,
711 | })
712 | if err != nil {
713 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get related items: %v", err)), nil
714 | }
715 |
716 | var results []string
717 | for _, item := range *relatedItems {
718 | fields := *item.Fields
719 | title, _ := fields["System.Title"].(string)
720 | result := fmt.Sprintf("ID: %d, Title: %s", *item.Id, title)
721 | results = append(results, result)
722 | }
723 |
724 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
725 | }
726 |
727 | // Handler for adding a comment to a work item
728 | func handleAddWorkItemComment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
729 | id := int(request.Params.Arguments["id"].(float64))
730 | text := request.Params.Arguments["text"].(string)
731 |
732 | // Add comment as a discussion by updating the Discussion field
733 | updateArgs := workitemtracking.UpdateWorkItemArgs{
734 | Id: &id,
735 | Project: &config.Project,
736 | Document: &[]webapi.JsonPatchOperation{
737 | {
738 | Op: &webapi.OperationValues.Add,
739 | Path: stringPtr("/fields/System.History"),
740 | Value: text,
741 | },
742 | },
743 | }
744 |
745 | workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs)
746 | if err != nil {
747 | return mcp.NewToolResultError(fmt.Sprintf("Failed to add comment: %v", err)), nil
748 | }
749 |
750 | return mcp.NewToolResultText(fmt.Sprintf("Added comment to work item #%d", *workItem.Id)), nil
751 | }
752 |
753 | // Handler for getting work item comments
754 | func handleGetWorkItemComments(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
755 | id := int(request.Params.Arguments["id"].(float64))
756 |
757 | comments, err := workItemClient.GetComments(ctx, workitemtracking.GetCommentsArgs{
758 | Project: &config.Project,
759 | WorkItemId: &id,
760 | })
761 |
762 | if err != nil {
763 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get comments: %v", err)), nil
764 | }
765 |
766 | var results []string
767 | for _, comment := range *comments.Comments {
768 | results = append(results, fmt.Sprintf("Comment by %s at %s:\n%s\n---",
769 | *comment.CreatedBy.DisplayName,
770 | comment.CreatedDate.String(),
771 | *comment.Text))
772 | }
773 |
774 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
775 | }
776 |
777 | // Handler for getting work item fields
778 | func handleGetWorkItemFields(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
779 | id := int(request.Params.Arguments["work_item_id"].(float64))
780 |
781 | // Get the work item's details
782 | workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{
783 | Id: &id,
784 | Project: &config.Project,
785 | })
786 |
787 | if err != nil {
788 | return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item details: %v", err)), nil
789 | }
790 |
791 | // Extract and format field information
792 | var results []string
793 | fieldName, hasFieldFilter := request.Params.Arguments["field_name"].(string)
794 |
795 | for fieldRef, value := range *workItem.Fields {
796 | if hasFieldFilter && !strings.Contains(strings.ToLower(fieldRef), strings.ToLower(fieldName)) {
797 | continue
798 | }
799 |
800 | results = append(results, fmt.Sprintf("Field: %s\nValue: %v\nType: %T\n---",
801 | fieldRef,
802 | value,
803 | value))
804 | }
805 |
806 | if len(results) == 0 {
807 | if hasFieldFilter {
808 | return mcp.NewToolResultText(fmt.Sprintf("No fields found matching: %s", fieldName)), nil
809 | }
810 | return mcp.NewToolResultText("No fields found"), nil
811 | }
812 |
813 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
814 | }
815 |
816 | // Handler for batch creating work items
817 | func handleBatchCreateWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
818 | itemsJSON := request.Params.Arguments["items"].(string)
819 | var items []struct {
820 | Type string `json:"type"`
821 | Title string `json:"title"`
822 | Description string `json:"description"`
823 | Priority string `json:"priority,omitempty"`
824 | }
825 |
826 | if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
827 | return mcp.NewToolResultError(fmt.Sprintf("Invalid JSON format: %v", err)), nil
828 | }
829 |
830 | var results []string
831 | for _, item := range items {
832 | createArgs := workitemtracking.CreateWorkItemArgs{
833 | Type: &item.Type,
834 | Project: &config.Project,
835 | Document: &[]webapi.JsonPatchOperation{
836 | {
837 | Op: &webapi.OperationValues.Add,
838 | Path: stringPtr("/fields/System.Title"),
839 | Value: item.Title,
840 | },
841 | {
842 | Op: &webapi.OperationValues.Add,
843 | Path: stringPtr("/fields/System.Description"),
844 | Value: item.Description,
845 | },
846 | },
847 | }
848 |
849 | if item.Priority != "" {
850 | doc := append(*createArgs.Document, webapi.JsonPatchOperation{
851 | Op: &webapi.OperationValues.Add,
852 | Path: stringPtr("/fields/Microsoft.VSTS.Common.Priority"),
853 | Value: item.Priority,
854 | })
855 | createArgs.Document = &doc
856 | }
857 |
858 | workItem, err := workItemClient.CreateWorkItem(ctx, createArgs)
859 | if err != nil {
860 | results = append(results, fmt.Sprintf("Failed to create '%s': %v", item.Title, err))
861 | continue
862 | }
863 | results = append(results, fmt.Sprintf("Created work item #%d: %s", *workItem.Id, item.Title))
864 | }
865 |
866 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
867 | }
868 |
869 | // Handler for batch updating work items
870 | func handleBatchUpdateWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
871 | updatesJSON := request.Params.Arguments["updates"].(string)
872 | var updates []struct {
873 | ID int `json:"id"`
874 | Field string `json:"field"`
875 | Value string `json:"value"`
876 | }
877 |
878 | if err := json.Unmarshal([]byte(updatesJSON), &updates); err != nil {
879 | return mcp.NewToolResultError(fmt.Sprintf("Invalid JSON format: %v", err)), nil
880 | }
881 |
882 | // Map field names to their System.* equivalents
883 | fieldMap := map[string]string{
884 | "Title": "System.Title",
885 | "Description": "System.Description",
886 | "State": "System.State",
887 | "Priority": "Microsoft.VSTS.Common.Priority",
888 | }
889 |
890 | var results []string
891 | for _, update := range updates {
892 | systemField, ok := fieldMap[update.Field]
893 | if !ok {
894 | results = append(results, fmt.Sprintf("Invalid field for #%d: %s", update.ID, update.Field))
895 | continue
896 | }
897 |
898 | updateArgs := workitemtracking.UpdateWorkItemArgs{
899 | Id: &update.ID,
900 | Project: &config.Project,
901 | Document: &[]webapi.JsonPatchOperation{
902 | {
903 | Op: &webapi.OperationValues.Replace,
904 | Path: stringPtr("/fields/" + systemField),
905 | Value: update.Value,
906 | },
907 | },
908 | }
909 |
910 | workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs)
911 | if err != nil {
912 | results = append(results, fmt.Sprintf("Failed to update #%d: %v", update.ID, err))
913 | continue
914 | }
915 | results = append(results, fmt.Sprintf("Updated work item #%d", *workItem.Id))
916 | }
917 |
918 | return mcp.NewToolResultText(strings.Join(results, "\n")), nil
919 | }
920 |
```