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