tfs-mcp-server
# tfs-mcp-server
MCP server (stdio) for **Microsoft TFS / Azure DevOps Server** using REST API **6.0**.
Gives an AI agent tools for work items, Git repository files (read and write: branches, commits, pull requests) and identity lookup.
Work items and repositories may live in different team projects: every tool accepts an
optional `project` argument that overrides the configured default.
## Requirements
- Node.js 20+
- A TFS / Azure DevOps Server collection reachable over HTTPS
- A Personal Access Token with scopes: Work Items (read & write), Code (read & write; write is needed for the Git write tools - branches, commits and pull requests), Identity (read)
## Install & build
```bash
npm install
npm run build # -> dist/index.js
npm test # vitest (all TFS calls are mocked)
```
## Configuration (environment variables)
| Variable | Required | Description |
| --- | --- | --- |
| `TFS_BASE_URL` | yes | Collection URL, e.g. `https://tfs.corp.local/tfs/DefaultCollection` |
| `TFS_PAT` | yes | Personal access token (sent as Basic auth) |
| `TFS_DEFAULT_PROJECT` | no | Project used when a call omits `project` |
| `TFS_API_VERSION` | no | Default `6.0` |
| `TFS_BATCH_CONCURRENCY` | no | Parallelism for fan-out batch tools (default `5`) |
| `TFS_INSECURE_TLS` | no | `true` to accept self-signed certificates |
See [.env.example](.env.example).
### Cursor (`~/.cursor/mcp.json` or `.cursor/mcp.json`)
```json
{
"mcpServers": {
"tfs": {
"command": "node",
"args": ["/absolute/path/to/tfs-mcp-server/dist/index.js"],
"env": {
"TFS_BASE_URL": "https://tfs.corp.local/tfs/DefaultCollection",
"TFS_PAT": "<your PAT>",
"TFS_DEFAULT_PROJECT": "MyProject"
}
}
}
}
```
Claude Desktop uses the same shape in `claude_desktop_config.json`.
### Interactive testing
```bash
TFS_BASE_URL=... TFS_PAT=... npm run inspect # opens the MCP Inspector against dist/index.js
```
## Tools
All tools return a short text summary plus a JSON payload (`structuredContent`).
Errors are returned as tool errors with the TFS message, HTTP status and request URL.
### Work items
| Tool | Purpose |
| --- | --- |
| `get_work_item` | Metadata, rich-text content (Description / Repro Steps / Acceptance Criteria, HTML converted to text by default), relations, optional comments |
| `get_work_items` | Same for many ids in one call (`workitemsbatch`, 200 ids per request); missing ids listed in `failed` |
| `get_work_item_comments` | Paged comments (ids needed for updates) |
| `update_work_item` | JSON-Patch update. **`state` and `assignedTo` are mandatory**; optional title, description, tags, area/iteration, priority, arbitrary `fields`, `comment`, `expectedRev`, `bypassRules`, `validateOnly` |
| `update_work_items` | Same update applied to many ids via `wit/$batch` (falls back to parallel PATCH if `$batch` is unavailable); per-item results |
| `add_work_item_comment` | Add a comment (plain text is wrapped into HTML) |
| `add_comment_to_work_items` | Same comment on many work items (bounded-concurrency fan-out) |
| `update_work_item_comment` | Replace the text of an existing comment |
| `get_query` | Saved query by GUID or path: WIQL, columns, type; folders list children |
| `run_query` | Execute a saved query; optional `extraWhere` (raw WIQL condition AND-ed into the query) |
| `run_wiql` | Execute an arbitrary WIQL string as-is |
`assignedTo` accepts a display name, `DOMAIN\user`, an e-mail, `Display Name <DOMAIN\user>`, or `""` to unassign.
Use `get_current_identity` / `search_identities` to obtain valid values.
### Files (Git)
| Tool | Purpose |
| --- | --- |
| `list_repositories` | Repositories of a project (or the whole collection) |
| `get_file_content` | File content at `branch` / `commit` / `tag` (default: repo default branch). Text inline, binaries base64. A missing file produces a verbose diagnosis: does the repository exist, does the branch exist (with a branch list), does the parent directory exist and what does it contain, plus "did you mean" suggestions |
| `get_files_content` | Several files from one repo/version in one call (`itemsbatch` + parallel content download), per-path diagnostics |
| `list_directory` | Entries under a directory (one level or recursive) with object ids, content type and latest commit |
### Git write (branches, commits, pull requests)
| Tool | Purpose |
| --- | --- |
| `create_branch` | Create `name` from `fromBranch` (default: repo default branch) or `fromCommit`. Fails if it exists unless `ifExists: "reuse"` |
| `commit_file_changes` | One commit on an existing `branch` with up to 50 `changes` (`add` / `edit` / `delete`). Each add/edit carries either full `content` (`encoding` utf-8 or base64) or exact find/replace `edits` applied to the current file (each `find` must match exactly once unless `replaceAll`). Optional `expectedBranchTip` guards against concurrent pushes |
| `create_pull_request` | Open `sourceBranch` -> `targetBranch` with title, description, `workItemIds`, `reviewers` (identity ids), `isDraft`. Work items are sent as `workItemRefs`; ids TFS did not record are linked from the work item side with an `ArtifactLink` relation. Per-id link results are returned |
| `create_branch_commit_and_pull_request` | One-shot workflow: create `newBranch` from `targetBranch`, commit `changes`, open the PR (`title` defaults to the commit `message`) linked to `workItemIds`. On failure the error names the failed step, lists completed steps and tells you which granular tool to continue with. `reuseExistingBranch: true` continues on an existing branch |
### Identity
| Tool | Purpose |
| --- | --- |
| `get_current_identity` | Who the PAT belongs to (`connectionData`, enriched via the identities API): id, display name, account, e-mail and a ready-to-use `assignedToValue` |
| `search_identities` | Find users/groups by name, account or e-mail |
## Example flows
```text
get_current_identity -> assignedToValue = "Jane Doe <CORP\\jdoe>"
run_query { queryId: "Shared Queries/Team/Triage", extraWhere: "[System.Tags] CONTAINS 'hotfix'" }
update_work_items { ids: [...], state: "Active", assignedTo: "Jane Doe <CORP\\jdoe>" }
add_comment_to_work_items { ids: [...], text: "Picked up in sprint 12" }
get_files_content { project: "Infra", repository: "tools", branch: "develop", paths: ["/README.md", "/src/main.ts"] }
create_branch_commit_and_pull_request {
project: "Infra", repository: "tools", newBranch: "feature/1234-bump-timeout", targetBranch: "develop",
message: "Bump request timeout to 60s",
changes: [{ path: "/src/config.ts", changeType: "edit", edits: [{ find: "timeoutMs: 30_000", replace: "timeoutMs: 60_000" }] }],
workItemIds: [1234]
}
```
## Notes on TFS API versions
- Work item, WIQL, Git and repository endpoints use `api-version=6.0`.
- The work item **comments** API only exists as a preview in 6.0; the server uses `6.0-preview.3`.
- `connectionData` (`6.0-preview`) and `identities` (`6.0-preview.1`) are collection-scoped; the identities call is optional and failures are tolerated.
- `wit/$batch` is used for bulk updates; if a server rejects it (404/405/400) the tool transparently falls back to individual PATCH requests.
- Branches are created with `POST git/repositories/{repo}/refs` (old object id `000…0`), commits with `POST .../pushes` (one commit, `rawtext` or `base64encoded` content), pull requests with `POST .../pullrequests`. Work items are linked via `workItemRefs` on creation; ids that `GET .../pullRequests/{id}/workitems` does not report afterwards are linked with an `ArtifactLink` relation to `vstfs:///Git/PullRequestId/{projectId}%2F{repositoryId}%2F{pullRequestId}`.
- If TFS answers with an HTML sign-in page or HTTP 203 instead of JSON, the PAT is invalid or the base URL does not point at a collection; the error message says so.
## Project layout
```
src/
index.ts stdio entrypoint
server.ts McpServer factory + tool registration
config.ts env parsing, project resolution
client.ts REST client (Basic PAT auth, URL builder, TfsApiError)
services/ TFS API wrappers (workitems, git (read), gitwrite (branches/pushes/PRs), identity)
tools/ MCP tool definitions (zod schemas)
util/ html->text, batch helpers, tool result helpers
tests/ vitest suites with a mocked fetch
```
TDQS
Scored across 17 tools
Most tools target clearly distinct resources and actions, such as fetching a single work item vs. many, or reading file content vs. listing a directory. The main area of potential confusion is between the singular/plural comment tools, especially add_work_item_comment and add_comment_to_work_items, though their descriptions clarify the difference.
Tool names mostly follow a consistent snake_case verb_noun pattern: list_, get_, update_, add_, run_, and search_. Minor deviations exist, notably add_comment_to_work_items vs. add_work_item_comment and get_current_identity, but overall the naming is predictable and readable.
17 tools is slightly above the ideal range but reasonable for a server covering both work item management and Git repository file access. Each tool serves a distinct purpose, and the count reflects the breadth of the domain without feeling bloated.
The work item surface is strong for reading and updating, including bulk operations and comments, but there is no create_work_item or delete_work_item, which is a notable lifecycle gap. The Git side is also read-only, covering file/directory access but no branch, commit, or repository management operations.