# 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: -------------------------------------------------------------------------------- ``` mcp-azuredevops-bridge mcp-azuredevops-bridge.exe start.sh test-wiki-api.ps1 ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Azure DevOps Bridge 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. ## 🌉 Azure DevOps Integration Connect with Azure DevOps for comprehensive project management: - **Work Items** - Create, update, query, and manage work items - **Wiki Documentation** - Create, update, and retrieve wiki pages - **Sprint Planning** - Retrieve current sprint information and list sprints - **Attachments & Discussions** - Add and retrieve attachments and comments to/from work items ## 🚀 Getting Started ### Prerequisites - Go 1.23 or later - Azure DevOps Personal Access Token (PAT) ### Installation #### Installing Go 1.23 or above ##### Windows Install Go using one of these package managers: 1. Using **winget**: ``` winget install GoLang.Go ``` 2. Using **Chocolatey**: ``` choco install golang ``` 3. Using **Scoop**: ``` scoop install go ``` After installation, verify with: ``` go version ``` ##### macOS Install Go using Homebrew: ``` brew install go ``` Verify the installation: ``` go version ``` #### Building the Project 1. Clone and build: ```bash git clone https://github.com/krishh-amilineni/mcp-azuredevops-bridge.git cd mcp-azuredevops-bridge go build ``` 2. Configure your environment: ```bash export AZURE_DEVOPS_ORG="your-org" export AZDO_PAT="your-pat-token" export AZURE_DEVOPS_PROJECT="your-project" ``` 3. Add to your Windsurf / Cursor configuration: ```json { "mcpServers": { "azuredevops-bridge": { "command": "/full/path/to/mcp-azuredevops-bridge/mcp-azuredevops-bridge", "args": [], "env": { "AZURE_DEVOPS_ORG": "organization", "AZDO_PAT": "personal_access_token", "AZURE_DEVOPS_PROJECT": "project" } } } } ``` ## 💡 Example Workflows ### Work Item Management ```txt "Create a user story for the new authentication feature in Azure DevOps" ``` ### Wiki Documentation ```txt "Create a wiki page documenting the API endpoints for our service" "List all wiki pages in our project wiki" "Get the content of the 'Getting Started' page from the wiki" "Show me all available wikis in my Azure DevOps project" ``` ### Sprint Planning ```txt "Show me the current sprint's work items and their status" ``` ### Attachments and Comments ```txt "Add this screenshot as an attachment to work item #123" ``` ## 🔧 Features ### Work Item Management - Create new work items (user stories, bugs, tasks, etc.) - Update existing work items - Query work items by various criteria - Link work items to each other ### Wiki Management - Create and update wiki pages - Search wiki content - Retrieve page content and subpages - Automatic wiki discovery - dynamically finds all available wikis for your project - Smart wiki selection - selects the most appropriate wiki based on the project context - Get list of available wikis for debugging and exploration ### Sprint Management - Get current sprint information - List all sprints - View sprint statistics ### Attachments and Comments - Add attachments to work items - Retrieve attachments from work items - Add comments to work items - View comments on work items ## 📋 Advanced Wiki Usage The DevOps Bridge includes enhanced wiki functionality that can help you access documentation more effectively: ### Available Wiki Tools - `list_wiki_pages` - Lists all wiki pages, optionally from a specific path - `get_wiki_page` - Retrieves the content of a specific wiki page - `manage_wiki_page` - Creates or updates a wiki page - `search_wiki` - Searches for content across wiki pages - `get_available_wikis` - Lists all available wikis in your Azure DevOps organization ### Wiki Troubleshooting If you're having trouble accessing wiki content: 1. Use the `get_available_wikis` tool to see all available wikis and their IDs 2. Check that your PAT token has appropriate permissions for wiki access 3. Verify that the wiki path is correct - wiki paths are case-sensitive 4. Enable verbose logging to see detailed request and response information ## 🔒 Security 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. ## 📝 Credits 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. ## 📝 License This project is licensed under the MIT License - see the LICENSE file for details. ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. 1. Fork the repository 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 4. Push to the branch (`git push origin feature/amazing-feature`) 5. Open a Pull Request ``` -------------------------------------------------------------------------------- /tag.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "fmt" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" ) // Handler for managing work item tags func handleManageWorkItemTags(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) operation := request.Params.Arguments["operation"].(string) tagsStr := request.Params.Arguments["tags"].(string) tags := strings.Split(tagsStr, ",") // Get current work item to get existing tags workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } fields := *workItem.Fields var currentTags []string if tags, ok := fields["System.Tags"].(string); ok && tags != "" { currentTags = strings.Split(tags, "; ") } var newTags []string switch operation { case "add": // Add new tags while avoiding duplicates tagMap := make(map[string]bool) for _, tag := range currentTags { tagMap[strings.TrimSpace(tag)] = true } for _, tag := range tags { tagMap[strings.TrimSpace(tag)] = true } for tag := range tagMap { newTags = append(newTags, tag) } case "remove": // Remove specified tags tagMap := make(map[string]bool) for _, tag := range tags { tagMap[strings.TrimSpace(tag)] = true } for _, tag := range currentTags { if !tagMap[strings.TrimSpace(tag)] { newTags = append(newTags, tag) } } } // Update work item with new tags updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &id, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Replace, Path: stringPtr("/fields/System.Tags"), Value: strings.Join(newTags, "; "), }, }, } _, err = workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update tags: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Successfully %sd tags for work item #%d", operation, id)), nil } // Handler for getting work item tags func handleGetWorkItemTags(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } fields := *workItem.Fields if tags, ok := fields["System.Tags"].(string); ok && tags != "" { return mcp.NewToolResultText(fmt.Sprintf("Tags for work item #%d:\n%s", id, tags)), nil } return mcp.NewToolResultText(fmt.Sprintf("No tags found for work item #%d", id)), nil } ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "fmt" "log" "os" "strings" "github.com/mark3labs/mcp-go/server" "github.com/microsoft/azure-devops-go-api/azuredevops/v7" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/wiki" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" ) // AzureDevOpsConfig holds the configuration for Azure DevOps connection type AzureDevOpsConfig struct { OrganizationURL string PersonalAccessToken string Project string } // Global clients and config var ( connection *azuredevops.Connection workItemClient workitemtracking.Client wikiClient wiki.Client coreClient core.Client config AzureDevOpsConfig ) func main() { // Main function for the MCP server - handles initialization and startup // Load configuration from environment variables config = AzureDevOpsConfig{ OrganizationURL: "https://dev.azure.com/" + os.Getenv("AZURE_DEVOPS_ORG"), PersonalAccessToken: os.Getenv("AZDO_PAT"), Project: os.Getenv("AZURE_DEVOPS_PROJECT"), } // Validate configuration if config.OrganizationURL == "" || config.PersonalAccessToken == "" || config.Project == "" { log.Fatal("Missing required environment variables: AZURE_DEVOPS_ORG, AZDO_PAT, AZURE_DEVOPS_PROJECT") } // Initialize Azure DevOps clients if err := initializeClients(config); err != nil { log.Fatalf("Failed to initialize Azure DevOps clients: %v", err) } // Create MCP server s := server.NewMCPServer( "MCP Azure DevOps Bridge", "1.0.0", server.WithResourceCapabilities(false, false), server.WithPromptCapabilities(true), server.WithLogging(), ) // Configure custom error handling log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetOutput(&logWriter{}) // Add Work Item tools addWorkItemTools(s) // Add Wiki tools addWikiTools(s) // Start the server if err := server.ServeStdio(s); err != nil { log.Fatalf("Server error: %v\n", err) } } func max(a, b int) int { if a > b { return a } return b } func min(a, b int) int { if a < b { return a } return b } func stringPtr(s string) *string { return &s } // Initialize Azure DevOps clients func initializeClients(config AzureDevOpsConfig) error { connection = azuredevops.NewPatConnection(config.OrganizationURL, config.PersonalAccessToken) ctx := context.Background() var err error // Initialize Work Item Tracking client workItemClient, err = workitemtracking.NewClient(ctx, connection) if err != nil { return fmt.Errorf("failed to create work item client: %v", err) } // Initialize Wiki client wikiClient, err = wiki.NewClient(ctx, connection) if err != nil { return fmt.Errorf("failed to create wiki client: %v", err) } // Initialize Core client coreClient, err = core.NewClient(ctx, connection) if err != nil { return fmt.Errorf("failed to create core client: %v", err) } return nil } type logWriter struct{} func (w *logWriter) Write(bytes []byte) (int, error) { // Skip logging "Prompts not supported" errors if strings.Contains(string(bytes), "Prompts not supported") { return len(bytes), nil } return fmt.Print(string(bytes)) } ``` -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "encoding/json" "fmt" "strings" "github.com/google/uuid" "github.com/mark3labs/mcp-go/mcp" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" ) // Handler for getting work item templates func handleGetWorkItemTemplates(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { workItemType := request.Params.Arguments["type"].(string) templates, err := workItemClient.GetTemplates(ctx, workitemtracking.GetTemplatesArgs{ Project: &config.Project, Team: nil, // Get templates for entire project Workitemtypename: &workItemType, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get templates: %v", err)), nil } var results []string for _, template := range *templates { results = append(results, fmt.Sprintf("Template ID: %s\nName: %s\nDescription: %s\n---", *template.Id, *template.Name, *template.Description)) } if len(results) == 0 { return mcp.NewToolResultText(fmt.Sprintf("No templates found for type: %s", workItemType)), nil } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for creating work item from template func handleCreateFromTemplate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { templateID := request.Params.Arguments["template_id"].(string) fieldValuesJSON := request.Params.Arguments["field_values"].(string) var fieldValues map[string]interface{} if err := json.Unmarshal([]byte(fieldValuesJSON), &fieldValues); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid field values JSON: %v", err)), nil } // Convert template ID to UUID templateUUID, err := uuid.Parse(templateID) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid template ID format: %v", err)), nil } // Get template template, err := workItemClient.GetTemplate(ctx, workitemtracking.GetTemplateArgs{ Project: &config.Project, Team: nil, TemplateId: &templateUUID, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get template: %v", err)), nil } // Create work item from template createArgs := workitemtracking.CreateWorkItemArgs{ Type: template.WorkItemTypeName, Project: &config.Project, } // Add template fields var operations []webapi.JsonPatchOperation for field, value := range *template.Fields { operations = append(operations, webapi.JsonPatchOperation{ Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/" + field), Value: value, }) } // Override with provided field values for field, value := range fieldValues { operations = append(operations, webapi.JsonPatchOperation{ Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/" + field), Value: value, }) } createArgs.Document = &operations workItem, err := workItemClient.CreateWorkItem(ctx, createArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create work item from template: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Created work item #%d from template", *workItem.Id)), nil } ``` -------------------------------------------------------------------------------- /sprint.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "github.com/mark3labs/mcp-go/mcp" ) func handleGetCurrentSprint(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { team, _ := request.Params.Arguments["team"].(string) if team == "" { team = config.Project + " Team" // Default team name } // Build the URL for the current iteration baseURL := fmt.Sprintf("%s/%s/_apis/work/teamsettings/iterations", config.OrganizationURL, config.Project) queryParams := url.Values{} queryParams.Add("$timeframe", "current") queryParams.Add("api-version", "7.2-preview") fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) // Create request req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil } // Add authentication req.SetBasicAuth("", config.PersonalAccessToken) // Send request client := &http.Client{} resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get current sprint: %v", err)), nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return mcp.NewToolResultError(fmt.Sprintf("Failed to get current sprint. Status: %d", resp.StatusCode)), nil } // Parse response var sprintResponse struct { Value []struct { Name string `json:"name"` StartDate time.Time `json:"startDate"` EndDate time.Time `json:"finishDate"` } `json:"value"` } if err := json.NewDecoder(resp.Body).Decode(&sprintResponse); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil } if len(sprintResponse.Value) == 0 { return mcp.NewToolResultText("No active sprint found"), nil } sprint := sprintResponse.Value[0] result := fmt.Sprintf("Current Sprint: %s\nStart Date: %s\nEnd Date: %s", sprint.Name, sprint.StartDate.Format("2006-01-02"), sprint.EndDate.Format("2006-01-02")) return mcp.NewToolResultText(result), nil } func handleGetSprints(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { team, _ := request.Params.Arguments["team"].(string) includeCompleted, _ := request.Params.Arguments["include_completed"].(bool) if team == "" { team = config.Project + " Team" } // Build the URL for iterations baseURL := fmt.Sprintf("%s/%s/_apis/work/teamsettings/iterations", config.OrganizationURL, config.Project) queryParams := url.Values{} if !includeCompleted { queryParams.Add("$timeframe", "current,future") } queryParams.Add("api-version", "7.2-preview") fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil } req.SetBasicAuth("", config.PersonalAccessToken) client := &http.Client{} resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get sprints: %v", err)), nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return mcp.NewToolResultError(fmt.Sprintf("Failed to get sprints. Status: %d", resp.StatusCode)), nil } var sprintResponse struct { Value []struct { Name string `json:"name"` StartDate time.Time `json:"startDate"` EndDate time.Time `json:"finishDate"` } `json:"value"` } if err := json.NewDecoder(resp.Body).Decode(&sprintResponse); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil } var results []string for _, sprint := range sprintResponse.Value { results = append(results, fmt.Sprintf("Sprint: %s\nStart: %s\nEnd: %s\n---", sprint.Name, sprint.StartDate.Format("2006-01-02"), sprint.EndDate.Format("2006-01-02"))) } if len(results) == 0 { return mcp.NewToolResultText("No sprints found"), nil } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } ``` -------------------------------------------------------------------------------- /attachment.go: -------------------------------------------------------------------------------- ```go package main import ( "bytes" "context" "encoding/base64" "fmt" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" ) // Handler for adding attachment to work item func handleAddWorkItemAttachment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) fileName := request.Params.Arguments["file_name"].(string) content := request.Params.Arguments["content"].(string) // Decode base64 content fileContent, err := base64.StdEncoding.DecodeString(content) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid base64 content: %v", err)), nil } // Create upload stream stream := bytes.NewReader(fileContent) // Upload attachment attachment, err := workItemClient.CreateAttachment(ctx, workitemtracking.CreateAttachmentArgs{ UploadStream: stream, FileName: &fileName, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to upload attachment: %v", err)), nil } // Add attachment reference to work item updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &id, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Add, Path: stringPtr("/relations/-"), Value: map[string]interface{}{ "rel": "AttachedFile", "url": *attachment.Url, "attributes": map[string]interface{}{ "name": fileName, }, }, }, }, } _, err = workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to add attachment to work item: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Added attachment '%s' to work item #%d", fileName, id)), nil } // Handler for getting work item attachments func handleGetWorkItemAttachments(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, Expand: &workitemtracking.WorkItemExpandValues.Relations, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } if workItem.Relations == nil { return mcp.NewToolResultText(fmt.Sprintf("No attachments found for work item #%d", id)), nil } var results []string for _, relation := range *workItem.Relations { if *relation.Rel == "AttachedFile" { name := (*relation.Attributes)["name"].(string) results = append(results, fmt.Sprintf("ID: %s\nName: %s\nURL: %s\n---", *relation.Url, name, *relation.Url)) } } if len(results) == 0 { return mcp.NewToolResultText(fmt.Sprintf("No attachments found for work item #%d", id)), nil } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for removing attachment from work item func handleRemoveWorkItemAttachment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) attachmentID := request.Params.Arguments["attachment_id"].(string) workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, Expand: &workitemtracking.WorkItemExpandValues.Relations, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } if workItem.Relations == nil { return mcp.NewToolResultError("Work item has no attachments"), nil } // Find the attachment relation index var relationIndex int = -1 for i, relation := range *workItem.Relations { if *relation.Rel == "AttachedFile" && strings.Contains(*relation.Url, attachmentID) { relationIndex = i break } } if relationIndex == -1 { return mcp.NewToolResultError("Attachment not found"), nil } // Remove the attachment relation updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &id, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Remove, Path: stringPtr(fmt.Sprintf("/relations/%d", relationIndex)), }, }, } _, err = workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to remove attachment: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Removed attachment from work item #%d", id)), nil } ``` -------------------------------------------------------------------------------- /wiki.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/wiki" ) func addWikiTools(s *server.MCPServer) { // Wiki Page Management manageWikiTool := mcp.NewTool("manage_wiki_page", mcp.WithDescription("Create or update a wiki page"), mcp.WithString("path", mcp.Required(), mcp.Description("Path of the wiki page"), ), mcp.WithString("content", mcp.Required(), mcp.Description("Content of the wiki page in markdown format"), ), ) s.AddTool(manageWikiTool, handleManageWikiPage) // Get Wiki Page getWikiTool := mcp.NewTool("get_wiki_page", mcp.WithDescription("Get content of a wiki page"), mcp.WithString("path", mcp.Required(), mcp.Description("Path of the wiki page to retrieve"), ), mcp.WithBoolean("include_children", mcp.Description("Whether to include child pages"), ), ) s.AddTool(getWikiTool, handleGetWikiPage) // List Wiki Pages listWikiTool := mcp.NewTool("list_wiki_pages", mcp.WithDescription("List wiki pages in a directory"), mcp.WithString("path", mcp.Description("Path to list pages from (optional)"), ), mcp.WithBoolean("recursive", mcp.Description("Whether to list pages recursively"), ), ) s.AddTool(listWikiTool, handleListWikiPages) // Search Wiki searchWikiTool := mcp.NewTool("search_wiki", mcp.WithDescription("Search wiki pages"), mcp.WithString("query", mcp.Required(), mcp.Description("Search query"), ), mcp.WithString("path", mcp.Description("Path to limit search to (optional)"), ), ) s.AddTool(searchWikiTool, handleSearchWiki) // Get Available Wikis getWikisTool := mcp.NewTool("get_available_wikis", mcp.WithDescription("Get information about available wikis"), ) s.AddTool(getWikisTool, handleGetWikis) } func handleManageWikiPage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { path := request.Params.Arguments["path"].(string) content := request.Params.Arguments["content"].(string) // Note: Comments are not supported by the Azure DevOps Wiki API _, _ = request.Params.Arguments["comment"].(string) // Get all available wikis for the project wikis, err := getWikisForProject(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil } if len(wikis) == 0 { return mcp.NewToolResultError("No wikis found for this project"), nil } // Use the first wiki by default, or try to match by project name wikiId := *wikis[0].Id for _, wiki := range wikis { if strings.Contains(*wiki.Name, config.Project) { wikiId = *wiki.Id break } } // Convert wiki ID to the format expected by the API wikiIdentifier := fmt.Sprintf("%s", wikiId) _, err = wikiClient.CreateOrUpdatePage(ctx, wiki.CreateOrUpdatePageArgs{ WikiIdentifier: &wikiIdentifier, Path: &path, Project: &config.Project, Parameters: &wiki.WikiPageCreateOrUpdateParameters{ Content: &content, }, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to manage wiki page: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Successfully managed wiki page: %s", path)), nil } func handleGetWikiPage(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { path := request.Params.Arguments["path"].(string) includeChildren, _ := request.Params.Arguments["include_children"].(bool) // Ensure path starts with a forward slash if !strings.HasPrefix(path, "/") { path = "/" + path } log.Printf("Wiki page path: %s", path) recursionLevel := "none" if includeChildren { recursionLevel = "oneLevel" } // Get all available wikis for the project wikis, err := getWikisForProject(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil } log.Printf("Found %d wikis for project", len(wikis)) for i, wiki := range wikis { log.Printf("Wiki %d: %s (ID: %s)", i+1, *wiki.Name, *wiki.Id) } if len(wikis) == 0 { return mcp.NewToolResultError("No wikis found for this project"), nil } // Use the first wiki by default wikiId := *wikis[0].Id // Try to find a wiki with a name that matches or contains the project name projectName := strings.Replace(config.Project, " ", "", -1) projectName = strings.ToLower(projectName) for _, wiki := range wikis { wikiName := strings.ToLower(*wiki.Name) if strings.Contains(wikiName, projectName) || strings.Contains(wikiName, "documentation") { wikiId = *wiki.Id log.Printf("Selected wiki: %s (ID: %s)", *wiki.Name, wikiId) break } } // Build the URL with query parameters baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages", config.OrganizationURL, url.PathEscape(config.Project), wikiId) queryParams := url.Values{} queryParams.Add("path", path) queryParams.Add("recursionLevel", recursionLevel) queryParams.Add("includeContent", "true") queryParams.Add("api-version", "7.2-preview") fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) log.Printf("Requesting wiki page from URL: %s", fullURL) // Create request req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil } // Add authentication req.SetBasicAuth("", config.PersonalAccessToken) // Send request client := &http.Client{} resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wiki page: %v", err)), nil } defer resp.Body.Close() // Read the response body responseBody, err := io.ReadAll(resp.Body) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil } if resp.StatusCode != http.StatusOK { // Log more details about the error log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody)) return mcp.NewToolResultError(fmt.Sprintf("Failed to get wiki page. Status: %d", resp.StatusCode)), nil } // Parse response var wikiResponse struct { Content string `json:"content"` SubPages []struct { Path string `json:"path"` Content string `json:"content"` } `json:"subPages"` } log.Printf("Wiki API Response: %s", string(responseBody)) if err := json.Unmarshal(responseBody, &wikiResponse); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil } // Format result var result strings.Builder result.WriteString(fmt.Sprintf("=== %s ===\n\n", path)) result.WriteString(wikiResponse.Content) if includeChildren && len(wikiResponse.SubPages) > 0 { result.WriteString("\n\nSub-pages:\n") for _, subPage := range wikiResponse.SubPages { result.WriteString(fmt.Sprintf("\n=== %s ===\n", subPage.Path)) result.WriteString(subPage.Content) result.WriteString("\n") } } return mcp.NewToolResultText(result.String()), nil } func handleListWikiPages(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { path, _ := request.Params.Arguments["path"].(string) recursive, _ := request.Params.Arguments["recursive"].(bool) recursionLevel := "oneLevel" if recursive { recursionLevel = "full" } // Get all available wikis for the project wikis, err := getWikisForProject(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil } if len(wikis) == 0 { return mcp.NewToolResultError("No wikis found for this project"), nil } // Use the first wiki by default, or try to match by project name wikiId := *wikis[0].Id for _, wiki := range wikis { if strings.Contains(*wiki.Name, config.Project) { wikiId = *wiki.Id break } } // Build the URL with query parameters baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages", config.OrganizationURL, url.PathEscape(config.Project), wikiId) queryParams := url.Values{} if path != "" { queryParams.Add("path", path) } queryParams.Add("recursionLevel", recursionLevel) queryParams.Add("api-version", "7.2-preview") fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) // Create request req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil } // Add authentication req.SetBasicAuth("", config.PersonalAccessToken) // Send request client := &http.Client{} resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to list wiki pages: %v", err)), nil } defer resp.Body.Close() // Read the response body responseBody, err := io.ReadAll(resp.Body) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil } if resp.StatusCode != http.StatusOK { // Log error details log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody)) return mcp.NewToolResultError(fmt.Sprintf("Failed to list wiki pages. Status: %d", resp.StatusCode)), nil } // Parse response var listResponse struct { Value []struct { Path string `json:"path"` RemotePath string `json:"remotePath"` IsFolder bool `json:"isFolder"` } `json:"value"` } log.Printf("Wiki API Response: %s", string(responseBody)) if err := json.Unmarshal(responseBody, &listResponse); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil } // Format result var result strings.Builder var locationText string if path != "" { locationText = " in " + path } result.WriteString(fmt.Sprintf("Wiki pages%s:\n\n", locationText)) for _, item := range listResponse.Value { prefix := "📄 " if item.IsFolder { prefix = "📁 " } result.WriteString(fmt.Sprintf("%s%s\n", prefix, item.Path)) } return mcp.NewToolResultText(result.String()), nil } func handleSearchWiki(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { query := request.Params.Arguments["query"].(string) path, hasPath := request.Params.Arguments["path"].(string) // Get all available wikis for the project wikis, err := getWikisForProject(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil } if len(wikis) == 0 { return mcp.NewToolResultError("No wikis found for this project"), nil } // Use the first wiki by default, or try to match by project name wikiId := *wikis[0].Id for _, wiki := range wikis { if strings.Contains(*wiki.Name, config.Project) { wikiId = *wiki.Id break } } // First, get all pages (potentially under the specified path) baseURL := fmt.Sprintf("%s/%s/_apis/wiki/wikis/%s/pages", config.OrganizationURL, url.PathEscape(config.Project), wikiId) queryParams := url.Values{} queryParams.Add("recursionLevel", "full") if hasPath { queryParams.Add("path", path) } queryParams.Add("includeContent", "true") queryParams.Add("api-version", "7.2-preview") fullURL := fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) // Create request req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil } // Add authentication req.SetBasicAuth("", config.PersonalAccessToken) // Send request client := &http.Client{} resp, err := client.Do(req) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to search wiki: %v", err)), nil } defer resp.Body.Close() // Read the response body responseBody, err := io.ReadAll(resp.Body) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read response body: %v", err)), nil } if resp.StatusCode != http.StatusOK { // Log error details log.Printf("Wiki API Error - Status: %d, Response: %s", resp.StatusCode, string(responseBody)) return mcp.NewToolResultError(fmt.Sprintf("Failed to search wiki. Status: %d", resp.StatusCode)), nil } // Parse response var searchResponse struct { Count int `json:"count"` Results []struct { FileName string `json:"fileName"` Path string `json:"path"` MatchCount int `json:"hitCount"` Repository struct { ID string `json:"id"` } `json:"repository"` Hits []struct { Content string `json:"content"` LineNumber int `json:"startLine"` } `json:"hits"` } `json:"results"` } log.Printf("Wiki API Search Response: %s", string(responseBody)) if err := json.Unmarshal(responseBody, &searchResponse); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil } // Search through the pages var results []string queryLower := strings.ToLower(query) for _, page := range searchResponse.Results { if strings.Contains(strings.ToLower(page.FileName), queryLower) { // Extract a snippet of context around the match contentLower := strings.ToLower(page.FileName) index := strings.Index(contentLower, queryLower) start := 0 if index > 100 { start = index - 100 } end := len(page.FileName) if index+len(query)+100 < len(page.FileName) { end = index + len(query) + 100 } snippet := page.FileName[start:end] if start > 0 { snippet = "..." + snippet } if end < len(page.FileName) { snippet = snippet + "..." } results = append(results, fmt.Sprintf("Page: %s\nMatch: %s\n---\n", page.Path, snippet)) } } if len(results) == 0 { return mcp.NewToolResultText(fmt.Sprintf("No matches found for '%s'", query)), nil } return mcp.NewToolResultText(fmt.Sprintf("Found %d matches:\n\n%s", len(results), strings.Join(results, "\n"))), nil } func handleGetWikis(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { wikis, err := getWikisForProject(ctx) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get wikis: %v", err)), nil } if len(wikis) == 0 { return mcp.NewToolResultError("No wikis found for this project"), nil } var result strings.Builder result.WriteString(fmt.Sprintf("Found %d wikis for project %s:\n\n", len(wikis), config.Project)) for i, wiki := range wikis { result.WriteString(fmt.Sprintf("%d. Wiki Name: %s\n Wiki ID: %s\n\n", i+1, *wiki.Name, *wiki.Id)) } return mcp.NewToolResultText(result.String()), nil } func getWikisForProject(ctx context.Context) ([]*wiki.Wiki, error) { // Create request wikiApiUrl := fmt.Sprintf("%s/%s/_apis/wiki/wikis?api-version=7.2-preview", config.OrganizationURL, url.PathEscape(config.Project)) log.Printf("Getting wikis from URL: %s", wikiApiUrl) req, err := http.NewRequest("GET", wikiApiUrl, nil) if err != nil { return nil, err } // Add authentication req.SetBasicAuth("", config.PersonalAccessToken) // Send request client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() log.Printf("Wiki API Status Code: %d", resp.StatusCode) // Read the response body bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("Failed to read response body: %v", err) } if resp.StatusCode != http.StatusOK { log.Printf("Error response: %s", string(bodyBytes)) return nil, fmt.Errorf("Failed to get wikis. Status: %d", resp.StatusCode) } // Parse response var wikisResponse struct { Value []*wiki.Wiki `json:"value"` } log.Printf("Wiki API Response: %s", string(bodyBytes)) // Unmarshal JSON directly from the bytes if err := json.Unmarshal(bodyBytes, &wikisResponse); err != nil { return nil, fmt.Errorf("Failed to parse wikis response: %v", err) } log.Printf("Found %d wikis in total", len(wikisResponse.Value)) // For now, return all wikis since we don't have a reliable way to filter // If needed, we can add more specific filtering later if len(wikisResponse.Value) > 0 { log.Printf("First wiki: Name=%s, ID=%s", *wikisResponse.Value[0].Name, *wikisResponse.Value[0].Id) } return wikisResponse.Value, nil } ``` -------------------------------------------------------------------------------- /workitems.go: -------------------------------------------------------------------------------- ```go package main import ( "context" "encoding/json" "fmt" "strconv" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" ) func addWorkItemTools(s *server.MCPServer) { // Add WIQL Query Format Prompt s.AddPrompt(mcp.NewPrompt("wiql_query_format", mcp.WithPromptDescription("Helper for formatting WIQL queries for common scenarios"), mcp.WithArgument("query_type", mcp.ArgumentDescription("Type of query to format (current_sprint, assigned_to_me, etc)"), mcp.RequiredArgument(), ), mcp.WithArgument("additional_fields", mcp.ArgumentDescription("Additional fields to include in the SELECT clause"), ), ), handleWiqlQueryFormatPrompt) // Create Work Item createWorkItemTool := mcp.NewTool("create_work_item", mcp.WithDescription("Create a new work item in Azure DevOps"), mcp.WithString("type", mcp.Required(), mcp.Description("Type of work item (Epic, Feature, User Story, Task, Bug)"), mcp.Enum("Epic", "Feature", "User Story", "Task", "Bug"), ), mcp.WithString("title", mcp.Required(), mcp.Description("Title of the work item"), ), mcp.WithString("description", mcp.Required(), mcp.Description("Description of the work item"), ), mcp.WithString("priority", mcp.Description("Priority of the work item (1-4)"), mcp.Enum("1", "2", "3", "4"), ), ) s.AddTool(createWorkItemTool, handleCreateWorkItem) // Update Work Item updateWorkItemTool := mcp.NewTool("update_work_item", mcp.WithDescription("Update an existing work item in Azure DevOps"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item to update"), ), mcp.WithString("field", mcp.Required(), mcp.Description("Field to update (Title, Description, State, Priority)"), mcp.Enum("Title", "Description", "State", "Priority"), ), mcp.WithString("value", mcp.Required(), mcp.Description("New value for the field"), ), ) s.AddTool(updateWorkItemTool, handleUpdateWorkItem) // Query Work Items queryWorkItemsTool := mcp.NewTool("query_work_items", mcp.WithDescription("Query work items using WIQL"), mcp.WithString("query", mcp.Required(), mcp.Description("WIQL query string"), ), ) s.AddTool(queryWorkItemsTool, handleQueryWorkItems) // Get Work Item Details getWorkItemTool := mcp.NewTool("get_work_item_details", mcp.WithDescription("Get detailed information about work items"), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of work item IDs"), ), ) s.AddTool(getWorkItemTool, handleGetWorkItemDetails) // Manage Work Item Relations manageRelationsTool := mcp.NewTool("manage_work_item_relations", mcp.WithDescription("Manage relationships between work items"), mcp.WithNumber("source_id", mcp.Required(), mcp.Description("ID of the source work item"), ), mcp.WithNumber("target_id", mcp.Required(), mcp.Description("ID of the target work item"), ), mcp.WithString("relation_type", mcp.Required(), mcp.Description("Type of relationship to manage"), mcp.Enum("parent", "child", "related"), ), mcp.WithString("operation", mcp.Required(), mcp.Description("Operation to perform"), mcp.Enum("add", "remove"), ), ) s.AddTool(manageRelationsTool, handleManageWorkItemRelations) // Get Related Work Items getRelatedItemsTool := mcp.NewTool("get_related_work_items", mcp.WithDescription("Get related work items"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item to get relations for"), ), mcp.WithString("relation_type", mcp.Required(), mcp.Description("Type of relationships to get"), mcp.Enum("parent", "children", "related", "all"), ), ) s.AddTool(getRelatedItemsTool, handleGetRelatedWorkItems) // Comment Management Tool (as Discussion) addCommentTool := mcp.NewTool("add_work_item_comment", mcp.WithDescription("Add a comment to a work item as a discussion"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), mcp.WithString("text", mcp.Required(), mcp.Description("Comment text"), ), ) s.AddTool(addCommentTool, handleAddWorkItemComment) getCommentsTool := mcp.NewTool("get_work_item_comments", mcp.WithDescription("Get comments for a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), ) s.AddTool(getCommentsTool, handleGetWorkItemComments) // Field Management Tool getFieldsTool := mcp.NewTool("get_work_item_fields", mcp.WithDescription("Get available work item fields and their current values"), mcp.WithNumber("work_item_id", mcp.Required(), mcp.Description("ID of the work item to examine fields from"), ), mcp.WithString("field_name", mcp.Description("Optional field name to filter (case-insensitive partial match)"), ), ) s.AddTool(getFieldsTool, handleGetWorkItemFields) // Batch Operations Tools batchCreateTool := mcp.NewTool("batch_create_work_items", mcp.WithDescription("Create multiple work items in a single operation"), mcp.WithString("items", mcp.Required(), mcp.Description("JSON array of work items to create, each containing type, title, and description"), ), ) s.AddTool(batchCreateTool, handleBatchCreateWorkItems) batchUpdateTool := mcp.NewTool("batch_update_work_items", mcp.WithDescription("Update multiple work items in a single operation"), mcp.WithString("updates", mcp.Required(), mcp.Description("JSON array of updates, each containing id, field, and value"), ), ) s.AddTool(batchUpdateTool, handleBatchUpdateWorkItems) // Tag Management Tools manageTags := mcp.NewTool("manage_work_item_tags", mcp.WithDescription("Add or remove tags from a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), mcp.WithString("operation", mcp.Required(), mcp.Description("Operation to perform"), mcp.Enum("add", "remove"), ), mcp.WithString("tags", mcp.Required(), mcp.Description("Comma-separated list of tags"), ), ) s.AddTool(manageTags, handleManageWorkItemTags) getTagsTool := mcp.NewTool("get_work_item_tags", mcp.WithDescription("Get tags for a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), ) s.AddTool(getTagsTool, handleGetWorkItemTags) // Work Item Template Tools getTemplatesTool := mcp.NewTool("get_work_item_templates", mcp.WithDescription("Get available work item templates"), mcp.WithString("type", mcp.Required(), mcp.Description("Type of work item to get templates for"), mcp.Enum("Epic", "Feature", "User Story", "Task", "Bug"), ), ) s.AddTool(getTemplatesTool, handleGetWorkItemTemplates) createFromTemplateTool := mcp.NewTool("create_from_template", mcp.WithDescription("Create a work item from a template"), mcp.WithString("template_id", mcp.Required(), mcp.Description("ID of the template to use"), ), mcp.WithString("field_values", mcp.Required(), mcp.Description("JSON object of field values to override template defaults"), ), ) s.AddTool(createFromTemplateTool, handleCreateFromTemplate) // Attachment Management Tools addAttachmentTool := mcp.NewTool("add_work_item_attachment", mcp.WithDescription("Add an attachment to a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), mcp.WithString("file_name", mcp.Required(), mcp.Description("Name of the file to attach"), ), mcp.WithString("content", mcp.Required(), mcp.Description("Base64 encoded content of the file"), ), ) s.AddTool(addAttachmentTool, handleAddWorkItemAttachment) getAttachmentsTool := mcp.NewTool("get_work_item_attachments", mcp.WithDescription("Get attachments for a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), ) s.AddTool(getAttachmentsTool, handleGetWorkItemAttachments) removeAttachmentTool := mcp.NewTool("remove_work_item_attachment", mcp.WithDescription("Remove an attachment from a work item"), mcp.WithNumber("id", mcp.Required(), mcp.Description("ID of the work item"), ), mcp.WithString("attachment_id", mcp.Required(), mcp.Description("ID of the attachment to remove"), ), ) s.AddTool(removeAttachmentTool, handleRemoveWorkItemAttachment) // Sprint Management Tools getCurrentSprintTool := mcp.NewTool("get_current_sprint", mcp.WithDescription("Get details about the current sprint"), mcp.WithString("team", mcp.Description("Team name (optional, defaults to project's default team)"), ), ) s.AddTool(getCurrentSprintTool, handleGetCurrentSprint) getSprintsTool := mcp.NewTool("get_sprints", mcp.WithDescription("Get list of sprints"), mcp.WithString("team", mcp.Description("Team name (optional, defaults to project's default team)"), ), mcp.WithBoolean("include_completed", mcp.Description("Whether to include completed sprints"), ), ) s.AddTool(getSprintsTool, handleGetSprints) // Add a new prompt for work item descriptions s.AddPrompt(mcp.NewPrompt("format_work_item_description", mcp.WithPromptDescription("Format a work item description using proper HTML for Azure DevOps"), mcp.WithArgument("description", mcp.ArgumentDescription("The description text to format"), mcp.RequiredArgument(), ), ), handleFormatWorkItemDescription) } func handleUpdateWorkItem(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) field := request.Params.Arguments["field"].(string) value := request.Params.Arguments["value"].(string) // Instead of using a fixed map, directly use the field name // This allows any valid Azure DevOps field to be used updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &id, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Replace, Path: stringPtr("/fields/" + field), Value: value, }, }, } workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update work item: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Updated work item #%d", *workItem.Id)), nil } func handleCreateWorkItem(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { workItemType := request.Params.Arguments["type"].(string) title := request.Params.Arguments["title"].(string) description := request.Params.Arguments["description"].(string) priority, hasPriority := request.Params.Arguments["priority"].(string) // Create the work item createArgs := workitemtracking.CreateWorkItemArgs{ Type: &workItemType, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/System.Title"), Value: title, }, { Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/System.Description"), Value: description, }, }, } if hasPriority { doc := append(*createArgs.Document, webapi.JsonPatchOperation{ Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/Microsoft.VSTS.Common.Priority"), Value: priority, }) createArgs.Document = &doc } workItem, err := workItemClient.CreateWorkItem(ctx, createArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to create work item: %v", err)), nil } fields := *workItem.Fields var extractedTitle string if t, ok := fields["System.Title"].(string); ok { extractedTitle = t } return mcp.NewToolResultText(fmt.Sprintf("Created work item #%d: %s", *workItem.Id, extractedTitle)), nil } func handleQueryWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { query := request.Params.Arguments["query"].(string) // Create WIQL query wiqlArgs := workitemtracking.QueryByWiqlArgs{ Wiql: &workitemtracking.Wiql{ Query: &query, }, // Ensure we pass the project context Project: &config.Project, // If you have a specific team, you can add it here // Team: &teamName, } queryResult, err := workItemClient.QueryByWiql(ctx, wiqlArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to query work items: %v", err)), nil } // If no work items found, return a message if queryResult.WorkItems == nil || len(*queryResult.WorkItems) == 0 { return mcp.NewToolResultText("No work items found matching the query."), nil } // Format results var results []string // If there are many work items, we should limit how many we retrieve details for maxDetailsToFetch := 20 if len(*queryResult.WorkItems) > 0 { // Get the first few work item IDs count := len(*queryResult.WorkItems) if count > maxDetailsToFetch { count = maxDetailsToFetch } // Create a list of IDs to fetch var ids []int for i := 0; i < count; i++ { ids = append(ids, *(*queryResult.WorkItems)[i].Id) } // Get the work item details if len(ids) > 0 { // First add a header line with the total count results = append(results, fmt.Sprintf("Found %d work items. Showing details for the first %d:", len(*queryResult.WorkItems), count)) results = append(results, "") // Fetch details for these work items getArgs := workitemtracking.GetWorkItemsArgs{ Ids: &ids, } workItems, err := workItemClient.GetWorkItems(ctx, getArgs) if err == nil && workItems != nil && len(*workItems) > 0 { for _, item := range *workItems { id := *item.Id var title, state, workItemType string if item.Fields != nil { if titleVal, ok := (*item.Fields)["System.Title"]; ok { title = fmt.Sprintf("%v", titleVal) } if stateVal, ok := (*item.Fields)["System.State"]; ok { state = fmt.Sprintf("%v", stateVal) } if typeVal, ok := (*item.Fields)["System.WorkItemType"]; ok { workItemType = fmt.Sprintf("%v", typeVal) } } results = append(results, fmt.Sprintf("ID: %d - [%s] %s (%s)", id, workItemType, title, state)) } } else { // Fallback to just listing the IDs if we couldn't get details for _, itemRef := range *queryResult.WorkItems { results = append(results, fmt.Sprintf("ID: %d", *itemRef.Id)) } } } } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } func handleWiqlQueryFormatPrompt(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { queryType, exists := request.Params.Arguments["query_type"] if !exists { return nil, fmt.Errorf("query_type is required") } additionalFields := request.Params.Arguments["additional_fields"] baseFields := "[System.Id], [System.Title], [System.WorkItemType], [System.State], [System.AssignedTo]" if additionalFields != "" { baseFields += ", " + additionalFields } var template string var explanation string switch queryType { case "current_sprint": template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.IterationPath] = @currentIteration('fanapp')", baseFields) explanation = "This query gets all work items in the current sprint. The @currentIteration macro automatically resolves to the current sprint path." case "assigned_to_me": template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.AssignedTo] = @me AND [System.State] <> 'Closed'", baseFields) explanation = "This query gets all active work items assigned to the current user. The @me macro automatically resolves to the current user." case "active_bugs": template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.WorkItemType] = 'Bug' AND [System.State] <> 'Closed' ORDER BY [Microsoft.VSTS.Common.Priority]", baseFields) explanation = "This query gets all active bugs, ordered by priority." case "blocked_items": template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.State] <> 'Closed' AND [Microsoft.VSTS.Common.Blocked] = 'Yes'", baseFields) explanation = "This query gets all work items that are marked as blocked." case "recent_activity": template = fmt.Sprintf("SELECT %s FROM WorkItems WHERE [System.ChangedDate] > @today-7 ORDER BY [System.ChangedDate] DESC", baseFields) explanation = "This query gets all work items modified in the last 7 days, ordered by most recent first." } return mcp.NewGetPromptResult( "WIQL Query Format Helper", []mcp.PromptMessage{ mcp.NewPromptMessage( "system", mcp.NewTextContent("You are a WIQL query expert. Help format queries for Azure DevOps work items."), ), mcp.NewPromptMessage( "assistant", 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)), ), }, ), nil } func handleFormatWorkItemDescription(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { description := request.Params.Arguments["description"] return mcp.NewGetPromptResult( "Azure DevOps Work Item Description Formatter", []mcp.PromptMessage{ mcp.NewPromptMessage( "system", 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."), ), mcp.NewPromptMessage( "assistant", mcp.NewTextContent(fmt.Sprintf("Here's your description formatted with HTML:\n\n<ul>\n%s\n</ul>", strings.Join(strings.Split(description, "-"), "</li>\n<li>"))), ), }, ), nil } // Handler for getting detailed work item information func handleGetWorkItemDetails(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { idsStr := request.Params.Arguments["ids"].(string) idStrs := strings.Split(idsStr, ",") var ids []int for _, idStr := range idStrs { id, err := strconv.Atoi(strings.TrimSpace(idStr)) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid ID format: %s", idStr)), nil } ids = append(ids, id) } workItems, err := workItemClient.GetWorkItems(ctx, workitemtracking.GetWorkItemsArgs{ Ids: &ids, Project: &config.Project, Expand: &workitemtracking.WorkItemExpandValues.All, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work items: %v", err)), nil } var results []string for _, item := range *workItems { fields := *item.Fields title, _ := fields["System.Title"].(string) description, _ := fields["System.Description"].(string) state, _ := fields["System.State"].(string) result := fmt.Sprintf("ID: %d\nTitle: %s\nState: %s\nDescription: %s\n---\n", *item.Id, title, state, description) results = append(results, result) } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for managing work item relationships func handleManageWorkItemRelations(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { sourceID := int(request.Params.Arguments["source_id"].(float64)) targetID := int(request.Params.Arguments["target_id"].(float64)) relationType, ok := request.Params.Arguments["relation_type"].(string) if !ok { return mcp.NewToolResultError("Invalid relation_type"), nil } operation := request.Params.Arguments["operation"].(string) // Map relation types to Azure DevOps relation types relationTypeMap := map[string]string{ "parent": "System.LinkTypes.Hierarchy-Reverse", "child": "System.LinkTypes.Hierarchy-Forward", "related": "System.LinkTypes.Related", } azureRelationType := relationTypeMap[relationType] var ops []webapi.JsonPatchOperation if operation == "add" { ops = []webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Add, Path: stringPtr("/relations/-"), Value: map[string]interface{}{ "rel": azureRelationType, "url": fmt.Sprintf("%s/_apis/wit/workItems/%d", config.OrganizationURL, targetID), "attributes": map[string]interface{}{ "comment": "Added via MCP", }, }, }, } } else { // For remove, we need to first get the work item to find the relation index workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &sourceID, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } if workItem.Relations == nil { return mcp.NewToolResultError("Work item has no relations"), nil } for i, relation := range *workItem.Relations { if *relation.Rel == azureRelationType { targetUrl := fmt.Sprintf("%s/_apis/wit/workItems/%d", config.OrganizationURL, targetID) if *relation.Url == targetUrl { ops = []webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Remove, Path: stringPtr(fmt.Sprintf("/relations/%d", i)), }, } break } } } if len(ops) == 0 { return mcp.NewToolResultError("Specified relation not found"), nil } } updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &sourceID, Project: &config.Project, Document: &ops, } _, err := workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to update work item relations: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Successfully %sd %s relationship", operation, relationType)), nil } // Handler for getting related work items func handleGetRelatedWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) relationType := request.Params.Arguments["relation_type"].(string) workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, Expand: &workitemtracking.WorkItemExpandValues.Relations, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item: %v", err)), nil } if workItem.Relations == nil { return mcp.NewToolResultText("No related items found"), nil } relationTypeMap := map[string]string{ "parent": "System.LinkTypes.Hierarchy-Reverse", "children": "System.LinkTypes.Hierarchy-Forward", "related": "System.LinkTypes.Related", } // Debug information var debugInfo []string debugInfo = append(debugInfo, fmt.Sprintf("Looking for relation type: %s (mapped to: %s)", relationType, relationTypeMap[relationType])) var relatedIds []int for _, relation := range *workItem.Relations { debugInfo = append(debugInfo, fmt.Sprintf("Found relation of type: %s", *relation.Rel)) if relationType == "all" || *relation.Rel == relationTypeMap[relationType] { parts := strings.Split(*relation.Url, "/") if relatedID, err := strconv.Atoi(parts[len(parts)-1]); err == nil { relatedIds = append(relatedIds, relatedID) } } } if len(relatedIds) == 0 { return mcp.NewToolResultText(fmt.Sprintf("Debug info:\n%s\n\nNo matching related items found", strings.Join(debugInfo, "\n"))), nil } // Get details of related items relatedItems, err := workItemClient.GetWorkItems(ctx, workitemtracking.GetWorkItemsArgs{ Ids: &relatedIds, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get related items: %v", err)), nil } var results []string for _, item := range *relatedItems { fields := *item.Fields title, _ := fields["System.Title"].(string) result := fmt.Sprintf("ID: %d, Title: %s", *item.Id, title) results = append(results, result) } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for adding a comment to a work item func handleAddWorkItemComment(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) text := request.Params.Arguments["text"].(string) // Add comment as a discussion by updating the Discussion field updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &id, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/System.History"), Value: text, }, }, } workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to add comment: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Added comment to work item #%d", *workItem.Id)), nil } // Handler for getting work item comments func handleGetWorkItemComments(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["id"].(float64)) comments, err := workItemClient.GetComments(ctx, workitemtracking.GetCommentsArgs{ Project: &config.Project, WorkItemId: &id, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get comments: %v", err)), nil } var results []string for _, comment := range *comments.Comments { results = append(results, fmt.Sprintf("Comment by %s at %s:\n%s\n---", *comment.CreatedBy.DisplayName, comment.CreatedDate.String(), *comment.Text)) } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for getting work item fields func handleGetWorkItemFields(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int(request.Params.Arguments["work_item_id"].(float64)) // Get the work item's details workItem, err := workItemClient.GetWorkItem(ctx, workitemtracking.GetWorkItemArgs{ Id: &id, Project: &config.Project, }) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to get work item details: %v", err)), nil } // Extract and format field information var results []string fieldName, hasFieldFilter := request.Params.Arguments["field_name"].(string) for fieldRef, value := range *workItem.Fields { if hasFieldFilter && !strings.Contains(strings.ToLower(fieldRef), strings.ToLower(fieldName)) { continue } results = append(results, fmt.Sprintf("Field: %s\nValue: %v\nType: %T\n---", fieldRef, value, value)) } if len(results) == 0 { if hasFieldFilter { return mcp.NewToolResultText(fmt.Sprintf("No fields found matching: %s", fieldName)), nil } return mcp.NewToolResultText("No fields found"), nil } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for batch creating work items func handleBatchCreateWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { itemsJSON := request.Params.Arguments["items"].(string) var items []struct { Type string `json:"type"` Title string `json:"title"` Description string `json:"description"` Priority string `json:"priority,omitempty"` } if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid JSON format: %v", err)), nil } var results []string for _, item := range items { createArgs := workitemtracking.CreateWorkItemArgs{ Type: &item.Type, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/System.Title"), Value: item.Title, }, { Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/System.Description"), Value: item.Description, }, }, } if item.Priority != "" { doc := append(*createArgs.Document, webapi.JsonPatchOperation{ Op: &webapi.OperationValues.Add, Path: stringPtr("/fields/Microsoft.VSTS.Common.Priority"), Value: item.Priority, }) createArgs.Document = &doc } workItem, err := workItemClient.CreateWorkItem(ctx, createArgs) if err != nil { results = append(results, fmt.Sprintf("Failed to create '%s': %v", item.Title, err)) continue } results = append(results, fmt.Sprintf("Created work item #%d: %s", *workItem.Id, item.Title)) } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } // Handler for batch updating work items func handleBatchUpdateWorkItems(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { updatesJSON := request.Params.Arguments["updates"].(string) var updates []struct { ID int `json:"id"` Field string `json:"field"` Value string `json:"value"` } if err := json.Unmarshal([]byte(updatesJSON), &updates); err != nil { return mcp.NewToolResultError(fmt.Sprintf("Invalid JSON format: %v", err)), nil } // Map field names to their System.* equivalents fieldMap := map[string]string{ "Title": "System.Title", "Description": "System.Description", "State": "System.State", "Priority": "Microsoft.VSTS.Common.Priority", } var results []string for _, update := range updates { systemField, ok := fieldMap[update.Field] if !ok { results = append(results, fmt.Sprintf("Invalid field for #%d: %s", update.ID, update.Field)) continue } updateArgs := workitemtracking.UpdateWorkItemArgs{ Id: &update.ID, Project: &config.Project, Document: &[]webapi.JsonPatchOperation{ { Op: &webapi.OperationValues.Replace, Path: stringPtr("/fields/" + systemField), Value: update.Value, }, }, } workItem, err := workItemClient.UpdateWorkItem(ctx, updateArgs) if err != nil { results = append(results, fmt.Sprintf("Failed to update #%d: %v", update.ID, err)) continue } results = append(results, fmt.Sprintf("Updated work item #%d", *workItem.Id)) } return mcp.NewToolResultText(strings.Join(results, "\n")), nil } ```