Skip to content

Commit 89beb0c

Browse files
Expose APIView functionality to tools/mcp agents (#12111)
* Initial effort for mcp apiview * fix command name * clean white lines * Feedback * feedback: split api view changes * Feedback * feedback * feedback * split * add tests * feedback * nit * Address feedback after team conversation * feedback * Reduce evision use * Conflict * feedback * additional changes to apiviewresponse
1 parent ed81fb8 commit 89beb0c

12 files changed

Lines changed: 709 additions & 0 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using Azure.Sdk.Tools.Cli.Models.Responses;
2+
using Azure.Sdk.Tools.Cli.Services.APIView;
3+
using Azure.Sdk.Tools.Cli.Tests.TestHelpers;
4+
using Azure.Sdk.Tools.Cli.Tools.APIView;
5+
using Moq;
6+
7+
namespace Azure.Sdk.Tools.Cli.Tests.Tools;
8+
9+
[TestFixture]
10+
public class ApiViewReviewToolTests
11+
{
12+
[SetUp]
13+
public void Setup()
14+
{
15+
_logger = new TestLogger<APIViewReviewTool>();
16+
_mockApiViewService = new Mock<IAPIViewService>();
17+
apiViewReviewTool = new APIViewReviewTool(_logger, _mockApiViewService.Object);
18+
}
19+
20+
private APIViewReviewTool apiViewReviewTool;
21+
private Mock<IAPIViewService> _mockApiViewService;
22+
private TestLogger<APIViewReviewTool> _logger;
23+
24+
[Test]
25+
public async Task GetRevisionComments_WithValidRevisionIdAndEnvironment_ReturnsSuccess()
26+
{
27+
string revisionId = "test-revision-123";
28+
string apiViewUrl = $"https://apiview.dev/review/123?activeApiRevisionId={revisionId}";
29+
string expectedComments = "[{\"lineNo\":10,\"commentText\":\"Staging comment\",\"isResolved\":true}]";
30+
_mockApiViewService
31+
.Setup(x => x.GetCommentsByRevisionAsync(revisionId))
32+
.ReturnsAsync(expectedComments);
33+
34+
APIViewResponse response = await apiViewReviewTool.GetComments(apiViewUrl);
35+
36+
Assert.That(response.Result, Is.EqualTo(expectedComments));
37+
Assert.That(response.ResponseError, Is.Null);
38+
}
39+
40+
[Test]
41+
public async Task GetRevisionComments_WithValidUrl_ExtractsRevisionIdAndReturnsSuccess()
42+
{
43+
string apiViewUrl = "https://apiview.dev/review/123?activeApiRevisionId=test-revision-456";
44+
string expectedComments = "[{\"lineNo\":5,\"commentText\":\"URL comment\",\"isResolved\":false}]";
45+
_mockApiViewService
46+
.Setup(x => x.GetCommentsByRevisionAsync("test-revision-456"))
47+
.ReturnsAsync(expectedComments);
48+
49+
APIViewResponse response = await apiViewReviewTool.GetComments(apiViewUrl);
50+
51+
Assert.That(response.Result, Is.EqualTo(expectedComments));
52+
Assert.That(response.ResponseError, Is.Null);
53+
}
54+
55+
[Test]
56+
public async Task GetRevisionComments_WithInvalidUrl_ReturnsError()
57+
{
58+
string invalidUrl = "https://apiview.dev/review/123"; // Missing activeApiRevisionId
59+
60+
APIViewResponse result = await apiViewReviewTool.GetComments(invalidUrl);
61+
62+
Assert.That(result.ResponseError, Does.Contain("activeApiRevisionId"));
63+
}
64+
65+
[Test]
66+
public async Task GetRevisionComments_WithMalformedUrl_ReturnsError()
67+
{
68+
string malformedUrl = "not-a-valid-url";
69+
70+
APIViewResponse result = await apiViewReviewTool.GetComments(malformedUrl);
71+
72+
_mockApiViewService
73+
.Setup(x => x.GetCommentsByRevisionAsync(malformedUrl))
74+
.ReturnsAsync((string?)null);
75+
76+
result = await apiViewReviewTool.GetComments(malformedUrl);
77+
Assert.That(result.ResponseError, Does.Contain("Failed to get comments: Input needs to be a valid APIView URL"));
78+
}
79+
80+
[Test]
81+
public async Task GetRevisionComments_WhenServiceReturnsNull_ReturnsError()
82+
{
83+
string revisionId = "test-revision-123";
84+
string apiViewUrl = $"https://apiview.dev/review/123?activeApiRevisionId={revisionId}";
85+
_mockApiViewService
86+
.Setup(x => x.GetCommentsByRevisionAsync(revisionId))
87+
.ReturnsAsync((string?)null);
88+
89+
APIViewResponse result = await apiViewReviewTool.GetComments(apiViewUrl);
90+
91+
Assert.That(result.ResponseError, Does.Contain("Failed to retrieve comments"));
92+
}
93+
94+
[Test]
95+
public async Task GetRevisionComments_WhenServiceThrowsException_ReturnsError()
96+
{
97+
string revisionId = "test-revision-123";
98+
string apiViewUrl = $"https://apiview.dev/review/123?activeApiRevisionId={revisionId}";
99+
_mockApiViewService
100+
.Setup(x => x.GetCommentsByRevisionAsync(revisionId))
101+
.ThrowsAsync(new Exception("Service error"));
102+
103+
APIViewResponse result = await apiViewReviewTool.GetComments(apiViewUrl);
104+
105+
Assert.That(result.ResponseError, Does.Contain("Failed to get comments: Service error"));
106+
}
107+
108+
[Test]
109+
public async Task GetRevisionComments_WithEmptyRevisionId_ReturnsError()
110+
{
111+
string emptyRevisionId = "";
112+
APIViewResponse result = await apiViewReviewTool.GetComments(emptyRevisionId);
113+
114+
Assert.That(result.ResponseError, Does.Contain("cannot be null or empty"));
115+
}
116+
117+
[Test]
118+
public async Task UrlExtraction_WithValidApiViewUrl_ShouldExtractCorrectRevisionId()
119+
{
120+
string apiViewUrl =
121+
"https://spa.apiviewstagingtest.com/review/96672963bb5747db9d65d32df5f82d5a?activeApiRevisionId=2668702652604dad92d75feb1321c7a4";
122+
string expectedRevisionId = "2668702652604dad92d75feb1321c7a4";
123+
string expectedComments = "Test comments";
124+
125+
_mockApiViewService
126+
.Setup(x => x.GetCommentsByRevisionAsync(expectedRevisionId))
127+
.ReturnsAsync(expectedComments);
128+
129+
await apiViewReviewTool.GetComments(apiViewUrl);
130+
_mockApiViewService.Verify(x => x.GetCommentsByRevisionAsync(expectedRevisionId), Times.Once);
131+
}
132+
}

tools/azsdk-cli/Azure.Sdk.Tools.Cli/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Features Added
66

7+
- Added APIView tools to expose APIView functionality to MCP agents (`get-comments`) and CLI (`get-content`)
8+
79
### Breaking Changes
810

911
### Bugs Fixed

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedCommandGroups.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ public static class SharedCommandGroups
7474
Options: []
7575
);
7676

77+
public static readonly CommandGroup APIView = new(
78+
Verb: "apiview",
79+
Description: "Commands for interacting with APIView services and functionality",
80+
Options: []
81+
);
82+
7783
#if DEBUG
7884
public static readonly CommandGroup Example = new(
7985
Verb: "example",

tools/azsdk-cli/Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Azure.Sdk.Tools.Cli.Tools.Example;
99
using Azure.Sdk.Tools.Cli.Tools.TypeSpec;
1010
using Azure.Sdk.Tools.Cli.Tools.Verify;
11+
using Azure.Sdk.Tools.Cli.Tools.APIView;
1112
using Azure.Sdk.Tools.Cli.Tools.Package.Samples;
1213
using Azure.Sdk.Tools.Cli.Tools.Core;
1314

@@ -44,6 +45,7 @@ public static class SharedOptions
4445
typeof(TypeSpecInitTool),
4546
typeof(CustomizedCodeUpdateTool),
4647
typeof(TypeSpecPublicRepoValidationTool),
48+
typeof(APIViewReviewTool),
4749
typeof(VerifySetupTool),
4850
typeof(TestTool),
4951
typeof(ListCommandTool),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace Azure.Sdk.Tools.Cli.Configuration;
2+
3+
public static class APIViewConfiguration
4+
{
5+
public const string UserAgent = "Azure-SDK-Tools-MCP";
6+
7+
public static readonly Dictionary<string, string> BaseUrlEndpoints = new()
8+
{
9+
{ "production", "https://apiview.dev" },
10+
{ "staging", "https://apiviewstagingtest.com" },
11+
{ "local", "http://localhost:5000" }
12+
};
13+
14+
public static readonly Dictionary<string, string> ApiViewScopes = new()
15+
{
16+
{ "production", "api://apiview/.default" },
17+
{ "staging", "api://apiviewstaging/.default" },
18+
{ "local", "api://apiviewstaging/.default" }
19+
};
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Azure.Sdk.Tools.Cli.Models.APIView;
2+
3+
4+
public class AuthenticationErrorResponse
5+
{
6+
public string Error { get; set; } = string.Empty;
7+
public string Message { get; set; } = string.Empty;
8+
public string Guidance { get; set; } = string.Empty;
9+
public string? RevisionId { get; set; }
10+
public string? ActiveRevisionId { get; set; }
11+
public string? DiffRevisionId { get; set; }
12+
public string? LoginUrl { get; set; }
13+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
using System.Text.RegularExpressions;
5+
6+
namespace Azure.Sdk.Tools.Cli.Models.Responses;
7+
8+
public class APIViewResponse : CommandResponse
9+
{
10+
[JsonPropertyName("message")]
11+
public string? Message { get; set; }
12+
13+
[JsonPropertyName("result")]
14+
public object? Result { get; set; }
15+
16+
[JsonPropertyName("language")]
17+
public object? Language { get; set; }
18+
19+
[JsonPropertyName("package_name")]
20+
public object? PackageName { get; set; }
21+
22+
protected override string Format()
23+
{
24+
var output = new StringBuilder();
25+
26+
if (!string.IsNullOrEmpty(Message))
27+
{
28+
output.AppendLine(Message);
29+
}
30+
31+
if (Result == null)
32+
{
33+
return output.ToString();
34+
}
35+
36+
string resultString = Result.ToString()!;
37+
38+
// Check if the result is a JSON string (starts and ends with quotes)
39+
if (resultString.StartsWith("\"") && resultString.EndsWith("\""))
40+
{
41+
try
42+
{
43+
string? parsed = JsonSerializer.Deserialize<string>(resultString);
44+
if (parsed != null)
45+
{
46+
output.AppendLine(parsed);
47+
return output.ToString();
48+
}
49+
}
50+
catch (JsonException)
51+
{
52+
}
53+
}
54+
55+
output.AppendLine(resultString);
56+
return output.ToString();
57+
}
58+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.Net.Http.Headers;
2+
using Azure.Core;
3+
using Azure.Identity;
4+
using Azure.Sdk.Tools.Cli.Configuration;
5+
using Azure.Sdk.Tools.Cli.Models.APIView;
6+
7+
namespace Azure.Sdk.Tools.Cli.Services.APIView;
8+
9+
public interface IAPIViewAuthenticationService
10+
{
11+
Task ConfigureAuthenticationAsync(HttpClient httpClient, string environment = "production");
12+
AuthenticationErrorResponse CreateAuthenticationErrorResponse(string message, string revisionId = null, string activeRevisionId = null,
13+
string diffRevisionId = null, string baseUrl = null);
14+
}
15+
16+
public class APIViewAuthenticationService : IAPIViewAuthenticationService
17+
{
18+
private readonly ILogger<APIViewAuthenticationService> _logger;
19+
private readonly IAzureService _azureService;
20+
21+
public APIViewAuthenticationService(IAzureService azureService,
22+
ILogger<APIViewAuthenticationService> logger)
23+
{
24+
_azureService = azureService;
25+
_logger = logger;
26+
}
27+
28+
public async Task<string?> GetAuthenticationTokenAsync(string environment = "production")
29+
{
30+
try
31+
{
32+
var credential = _azureService.GetCredential();
33+
34+
string scope = APIViewConfiguration.ApiViewScopes[environment];
35+
if (string.IsNullOrEmpty(scope))
36+
{
37+
_logger.LogWarning("Environment '{Environment}' not valid.", environment);
38+
return null;
39+
}
40+
41+
TokenRequestContext tokenRequest = new([scope]);
42+
AccessToken? tokenResponse = await credential.GetTokenAsync(tokenRequest, CancellationToken.None);
43+
44+
_logger.LogInformation("Successfully obtained Azure token with scope {Scope}", scope);
45+
return tokenResponse?.Token;
46+
}
47+
catch (Exception ex)
48+
{
49+
_logger.LogWarning(ex, "Failed to get Azure token with scope {Scope}", APIViewConfiguration.ApiViewScopes.GetValueOrDefault(environment, "unknown"));
50+
return null;
51+
}
52+
}
53+
54+
public async Task ConfigureAuthenticationAsync(HttpClient httpClient, string environment = "production")
55+
{
56+
string? token = await GetAuthenticationTokenAsync(environment);
57+
if (!string.IsNullOrEmpty(token))
58+
{
59+
httpClient.DefaultRequestHeaders.Authorization =
60+
new AuthenticationHeaderValue("Bearer", token);
61+
62+
httpClient.DefaultRequestHeaders.Add("X-API-Key", token);
63+
}
64+
65+
httpClient.DefaultRequestHeaders.Add("User-Agent", APIViewConfiguration.UserAgent);
66+
httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
67+
68+
_logger.LogDebug("Request headers configured - Authorization: {HasAuth}, User-Agent: {UserAgent}",
69+
!string.IsNullOrEmpty(token) ? "Bearer [TOKEN]" : "None", APIViewConfiguration.UserAgent);
70+
}
71+
72+
private string GetAuthenticationGuidance()
73+
{
74+
return @"Authentication is required to access APIView data. Please authenticate using Azure credentials:
75+
76+
**Azure CLI**:
77+
- Run: az login
78+
79+
The authentication service will automatically try Azure CLI";
80+
}
81+
82+
public static bool IsAuthenticationFailure(string content)
83+
{
84+
return content.Contains("Account/Login") ||
85+
(content.Contains("login") && content.Contains("<html")) ||
86+
content.Contains("authentication required", StringComparison.OrdinalIgnoreCase);
87+
}
88+
89+
public AuthenticationErrorResponse CreateAuthenticationErrorResponse(string message, string revisionId = null,
90+
string activeRevisionId = null, string diffRevisionId = null, string baseUrl = null)
91+
{
92+
return new AuthenticationErrorResponse
93+
{
94+
Error = "Authentication Required",
95+
Message = message,
96+
Guidance = GetAuthenticationGuidance(),
97+
RevisionId = revisionId,
98+
ActiveRevisionId = activeRevisionId,
99+
DiffRevisionId = diffRevisionId,
100+
LoginUrl = !string.IsNullOrEmpty(baseUrl) ? $"{baseUrl}/Account/Login" : null
101+
};
102+
}
103+
}

0 commit comments

Comments
 (0)