Skip to content

feat: interactive OAuth flow added #368

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { AzureCliCredential, ChainedTokenCredential, DefaultAzureCredential, TokenCredential } from "@azure/identity";
import { AccountInfo, AuthenticationResult, PublicClientApplication } from "@azure/msal-node";
import open from "open";

const scopes = ["499b84ac-1321-427f-aa17-267ca6975798/.default"];

class OAuthAuthenticator {
static clientId = "ac9c72b1-86e4-4849-be22-eaae7731117a";
static authority = "https://login.microsoftonline.com/common";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should use common only if user didn't provide any specific tenant and use that tenant instead - that would make it possible for guest users to use the MCP server


private accountId: AccountInfo | null;
private publicClientApp: PublicClientApplication;

constructor() {
this.accountId = null;
this.publicClientApp = new PublicClientApplication({
auth: {
clientId: OAuthAuthenticator.clientId,
authority: OAuthAuthenticator.authority,
},
});
}

public async getToken(): Promise<string> {
let authResult: AuthenticationResult | null = null;
if (this.accountId) {
try {
authResult = await this.publicClientApp.acquireTokenSilent({
scopes,
account: this.accountId,
});
} catch (error) {
authResult = null;
}
}
if (!authResult) {
authResult = await this.publicClientApp.acquireTokenInteractive({
scopes,
openBrowser: async (url) => {
open(url);
},
});
this.accountId = authResult.account;
}

if (!authResult.accessToken) {
throw new Error("Failed to obtain Azure DevOps OAuth token.");
}
return authResult.accessToken;
}
}

function createAuthenticator(type: string, tenantId?: string): () => Promise<string> {
switch (type) {
case "azcli":
case "env":
if (type !== "env") {
process.env.AZURE_TOKEN_CREDENTIALS = "dev";
}
let credential: TokenCredential = new DefaultAzureCredential(); // CodeQL [SM05138] resolved by explicitly setting AZURE_TOKEN_CREDENTIALS
if (tenantId) {
// Use Azure CLI credential if tenantId is provided for multi-tenant scenarios
const azureCliCredential = new AzureCliCredential({ tenantId });
credential = new ChainedTokenCredential(azureCliCredential, credential);
}
return async () => {
const result = await credential.getToken(scopes);
if (!result) {
throw new Error("Failed to obtain Azure DevOps token. Ensure you have Azure CLI logged or use interactive type of authentication.");
}
return result.token;
};

default:
const authenticator = new OAuthAuthenticator();
return () => {
return authenticator.getToken();
};
}
}
export { createAuthenticator };
41 changes: 14 additions & 27 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import * as azdev from "azure-devops-node-api";
import { AccessToken, AzureCliCredential, ChainedTokenCredential, DefaultAzureCredential, TokenCredential } from "@azure/identity";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";

import { createAuthenticator } from "./auth.js";
import { configurePrompts } from "./prompts.js";
import { configureAllTools } from "./tools.js";
import { UserAgentComposer } from "./useragent.js";
Expand All @@ -26,42 +26,28 @@ const argv = yargs(hideBin(process.argv))
type: "string",
});
})
.option("authentication", {
alias: "a",
describe: "Type of authentication to use. Supported values are 'interactive', 'azcli' and 'env' (default: 'interactive')",
type: "string",
choices: ["interactive", "azcli", "env"],
default: "interactive",
})
.option("tenant", {
alias: "t",
describe: "Azure tenant ID (optional, required for multi-tenant scenarios)",
describe: "Azure tenant ID (optional, applied only when using 'azcli' type of authentication)",
type: "string",
})
.help()
.parseSync();

export const orgName = argv.organization as string;
const tenantId = argv.tenant;
const orgUrl = "https://dev.azure.com/" + orgName;

async function getAzureDevOpsToken(): Promise<AccessToken> {
if (process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS) {
process.env.AZURE_TOKEN_CREDENTIALS = process.env.ADO_MCP_AZURE_TOKEN_CREDENTIALS;
} else {
process.env.AZURE_TOKEN_CREDENTIALS = "dev";
}
let credential: TokenCredential = new DefaultAzureCredential(); // CodeQL [SM05138] resolved by explicitly setting AZURE_TOKEN_CREDENTIALS
if (tenantId) {
// Use Azure CLI credential if tenantId is provided for multi-tenant scenarios
const azureCliCredential = new AzureCliCredential({ tenantId });
credential = new ChainedTokenCredential(azureCliCredential, credential);
}

const token = await credential.getToken("499b84ac-1321-427f-aa17-267ca6975798/.default");
if (!token) {
throw new Error("Failed to obtain Azure DevOps token. Ensure you have Azure CLI logged in or another token source setup correctly.");
}
return token;
}

function getAzureDevOpsClient(userAgentComposer: UserAgentComposer): () => Promise<azdev.WebApi> {
function getAzureDevOpsClient(getAzureDevOpsToken: () => Promise<string>, userAgentComposer: UserAgentComposer): () => Promise<azdev.WebApi> {
return async () => {
const token = await getAzureDevOpsToken();
const authHandler = azdev.getBearerHandler(token.token);
const accessToken = await getAzureDevOpsToken();
const authHandler = azdev.getBearerHandler(accessToken);
const connection = new azdev.WebApi(orgUrl, authHandler, undefined, {
productName: "AzureDevOps.MCP",
productVersion: packageVersion,
Expand All @@ -81,10 +67,11 @@ async function main() {
server.server.oninitialized = () => {
userAgentComposer.appendMcpClientInfo(server.server.getClientVersion());
};
const authenticator = createAuthenticator(argv.authentication, argv.tenant);

configurePrompts(server);

configureAllTools(server, getAzureDevOpsToken, getAzureDevOpsClient(userAgentComposer), () => userAgentComposer.userAgent);
configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer), () => userAgentComposer.userAgent);

const transport = new StdioServerTransport();
await server.connect(transport);
Expand Down
3 changes: 1 addition & 2 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT License.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { AccessToken } from "@azure/identity";
import { WebApi } from "azure-devops-node-api";

import { configureCoreTools } from "./tools/core.js";
Expand All @@ -15,7 +14,7 @@ import { configureWikiTools } from "./tools/wiki.js";
import { configureTestPlanTools } from "./tools/testplans.js";
import { configureSearchTools } from "./tools/search.js";

function configureAllTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
function configureAllTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
configureCoreTools(server, tokenProvider, connectionProvider);
configureWorkTools(server, tokenProvider, connectionProvider);
configureBuildTools(server, tokenProvider, connectionProvider);
Expand Down
4 changes: 2 additions & 2 deletions src/tools/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
import { AccessToken } from "@azure/identity";
import { WebApi } from "azure-devops-node-api";

async function getCurrentUserDetails(tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
async function getCurrentUserDetails(tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
const connection = await connectionProvider();
const url = `${connection.serverUrl}/_apis/connectionData`;
const token = (await tokenProvider()).token;
const token = await tokenProvider();
const response = await fetch(url, {
method: "GET",
headers: {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const BUILD_TOOLS = {
update_build_stage: "build_update_build_stage",
};

function configureBuildTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureBuildTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
BUILD_TOOLS.get_definitions,
"Retrieves a list of build definitions for a given project.",
Expand Down Expand Up @@ -339,7 +339,7 @@ function configureBuildTools(server: McpServer, tokenProvider: () => Promise<Acc
method: "PATCH",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token.token}`,
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify(body),
});
Expand Down
4 changes: 2 additions & 2 deletions src/tools/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function filterProjectsByName(projects: ProjectInfo[], projectNameFilter: string
return projects.filter((project) => project.name?.toLowerCase().includes(lowerCaseFilter));
}

function configureCoreTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureCoreTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
CORE_TOOLS.list_project_teams,
"Retrieve a list of teams for the specified Azure DevOps project.",
Expand Down Expand Up @@ -112,7 +112,7 @@ function configureCoreTools(server: McpServer, tokenProvider: () => Promise<Acce

const response = await fetch(`${baseUrl}?${params}`, {
headers: {
"Authorization": `Bearer ${token.token}`,
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/tools/releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const RELEASE_TOOLS = {
get_releases: "release_get_releases",
};

function configureReleaseTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureReleaseTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
RELEASE_TOOLS.get_release_definitions,
"Retrieves list of release definitions for a given project.",
Expand Down
2 changes: 1 addition & 1 deletion src/tools/repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function filterReposByName(repositories: GitRepository[], repoNameFilter: string
return filteredByName;
}

function configureRepoTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureRepoTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
REPO_TOOLS.create_pull_request,
"Create a new pull request.",
Expand Down
8 changes: 4 additions & 4 deletions src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const SEARCH_TOOLS = {
search_workitem: "search_workitem",
};

function configureSearchTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
function configureSearchTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
server.tool(
SEARCH_TOOLS.search_code,
"Search Azure DevOps Repositories for a given search text",
Expand Down Expand Up @@ -57,7 +57,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"User-Agent": userAgentProvider(),
},
body: JSON.stringify(requestBody),
Expand Down Expand Up @@ -113,7 +113,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"User-Agent": userAgentProvider(),
},
body: JSON.stringify(requestBody),
Expand Down Expand Up @@ -170,7 +170,7 @@ function configureSearchTools(server: McpServer, tokenProvider: () => Promise<Ac
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"User-Agent": userAgentProvider(),
},
body: JSON.stringify(requestBody),
Expand Down
2 changes: 1 addition & 1 deletion src/tools/testplans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const Test_Plan_Tools = {
list_test_plans: "testplan_list_test_plans",
};

function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureTestPlanTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
/*
LIST OF TEST PLANS
get list of test plans by project
Expand Down
2 changes: 1 addition & 1 deletion src/tools/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const WIKI_TOOLS = {
get_wiki_page_content: "wiki_get_page_content",
};

function configureWikiTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureWikiTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
WIKI_TOOLS.get_wiki,
"Get the wiki by wikiIdentifier",
Expand Down
2 changes: 1 addition & 1 deletion src/tools/work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const WORK_TOOLS = {
assign_iterations: "work_assign_iterations",
};

function configureWorkTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>) {
function configureWorkTools(server: McpServer, _: () => Promise<string>, connectionProvider: () => Promise<WebApi>) {
server.tool(
WORK_TOOLS.list_team_iterations,
"Retrieve a list of iterations for a specific team in a project.",
Expand Down
10 changes: 5 additions & 5 deletions src/tools/workitems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function getLinkTypeFromName(name: string) {
}
}

function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<AccessToken>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<string>, connectionProvider: () => Promise<WebApi>, userAgentProvider: () => string) {
server.tool(
WORKITEM_TOOLS.list_backlogs,
"Revieve a list of backlogs for a given project and team.",
Expand Down Expand Up @@ -205,7 +205,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
const response = await fetch(`${orgUrl}/${project}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": userAgentProvider(),
},
Expand Down Expand Up @@ -329,7 +329,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": userAgentProvider(),
},
Expand Down Expand Up @@ -668,7 +668,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": userAgentProvider(),
},
Expand Down Expand Up @@ -740,7 +740,7 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise<
const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, {
method: "PATCH",
headers: {
"Authorization": `Bearer ${accessToken.token}`,
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": userAgentProvider(),
},
Expand Down
Loading