# 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 | ```