Skip to content

Commit 6bc705e

Browse files
committed
feat: enhance MCP server functionality with version support and fixed file URI handling
1 parent 75e6db6 commit 6bc705e

File tree

5 files changed

+160
-19
lines changed

5 files changed

+160
-19
lines changed

cmd/azqr/commands/mcpserver.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,6 @@ Examples:
3636
Args: cobra.NoArgs,
3737
Run: func(cmd *cobra.Command, args []string) {
3838
mode := mcpserver.ServerMode(mcpMode)
39-
mcpserver.StartWithMode(mode, mcpAddr)
39+
mcpserver.StartWithMode(mode, mcpAddr, version)
4040
},
4141
}

internal/mcpserver/roots.go

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,36 +6,58 @@ package mcpserver
66
import (
77
"context"
88
"fmt"
9+
"net/url"
910
"os"
1011
"strings"
1112

1213
"github.com/mark3labs/mcp-go/mcp"
1314
)
1415

15-
func currentWorkspace(ctx context.Context) string {
16-
result, err := s.RequestRoots(ctx, mcp.ListRootsRequest{})
17-
if err == nil {
18-
for _, root := range result.Roots {
19-
uri := root.URI
20-
if strings.HasPrefix(uri, "file://") {
21-
return strings.TrimPrefix(uri, "file://")
22-
}
23-
}
16+
// fileURIToPath converts a file:// URI to a local filesystem path.
17+
// It handles both Unix paths (file:///home/user) and Windows paths
18+
// (file:///C:/Users, file:///c%3A/Users), decoding percent-encoded characters.
19+
func fileURIToPath(uri string) string {
20+
if !strings.HasPrefix(uri, "file://") {
21+
return uri
22+
}
23+
24+
u, err := url.Parse(uri)
25+
if err != nil {
26+
// Fallback: strip "file://" and hope for the best
27+
return strings.TrimPrefix(uri, "file://")
2428
}
2529

26-
return ""
30+
// url.Parse decodes percent-encoded characters (e.g. %3A → :) in u.Path.
31+
path := u.Path
32+
33+
// On Windows, MCP clients emit file:///C:/... so the parsed path is /C:/...
34+
// Strip the leading "/" before a Windows drive letter to get a valid path.
35+
if len(path) >= 3 && path[0] == '/' && isASCIILetter(path[1]) && path[2] == ':' {
36+
path = path[1:]
37+
}
38+
39+
return path
2740
}
2841

29-
func getCurrentFolder(ctx context.Context) (string, error) {
30-
currentDir := currentWorkspace(ctx)
42+
// isASCIILetter reports whether b is an ASCII letter (a–z or A–Z).
43+
func isASCIILetter(b byte) bool {
44+
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
45+
}
3146

32-
if currentDir != "" {
33-
return currentDir, nil
47+
// getCurrentFolder returns the first file:// root reported by the MCP client,
48+
// or falls back to the process working directory.
49+
func getCurrentFolder(ctx context.Context) (string, error) {
50+
if result, err := s.RequestRoots(ctx, mcp.ListRootsRequest{}); err == nil {
51+
for _, root := range result.Roots {
52+
if strings.HasPrefix(root.URI, "file://") {
53+
return fileURIToPath(root.URI), nil
54+
}
55+
}
3456
}
3557

36-
currentDir, err := os.Getwd()
58+
dir, err := os.Getwd()
3759
if err != nil {
3860
return "", fmt.Errorf("failed to get current working directory: %w", err)
3961
}
40-
return currentDir, nil
62+
return dir, nil
4163
}

internal/mcpserver/roots_tests.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
package mcpserver
5+
6+
import "testing"
7+
8+
func TestFileURIToPath(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
want string
13+
}{
14+
// ── Unix paths ──────────────────────────────────────────────────────
15+
{
16+
name: "unix three-slash URI",
17+
input: "file:///home/user/workspace",
18+
want: "/home/user/workspace",
19+
},
20+
{
21+
name: "unix three-slash URI with subdirectory",
22+
input: "file:///var/log/ghqr",
23+
want: "/var/log/ghqr",
24+
},
25+
26+
// ── Windows paths (uppercase drive letter) ──────────────────────────
27+
{
28+
name: "windows three-slash URI uppercase drive",
29+
input: "file:///C:/Users/gh/workspace",
30+
want: "C:/Users/gh/workspace",
31+
},
32+
{
33+
name: "windows three-slash URI uppercase Z drive",
34+
input: "file:///Z:/Projects",
35+
want: "Z:/Projects",
36+
},
37+
38+
// ── Windows paths (lowercase drive letter) ──────────────────────────
39+
{
40+
name: "windows three-slash URI lowercase drive",
41+
input: "file:///c:/Users/gh/workspace",
42+
want: "c:/Users/gh/workspace",
43+
},
44+
45+
// ── Windows paths with percent-encoded colon (%3A) ──────────────────
46+
// VS Code sends file:///c%3A/Users/... per Issue 1.
47+
{
48+
name: "windows percent-encoded colon lowercase",
49+
input: "file:///c%3A/Users/gh/workspace",
50+
want: "c:/Users/gh/workspace",
51+
},
52+
{
53+
name: "windows percent-encoded colon uppercase",
54+
input: "file:///C%3A/Users/gh/workspace",
55+
want: "C:/Users/gh/workspace",
56+
},
57+
58+
// ── Paths that are not file URIs ──────────────────────────────────
59+
{
60+
name: "plain path passthrough",
61+
input: "C:/Users/gh",
62+
want: "C:/Users/gh",
63+
},
64+
{
65+
name: "unix plain path passthrough",
66+
input: "/home/user",
67+
want: "/home/user",
68+
},
69+
70+
// ── Edge cases ───────────────────────────────────────────────────────
71+
{
72+
name: "empty string",
73+
input: "",
74+
want: "",
75+
},
76+
{
77+
name: "file URI with spaces (percent-encoded)",
78+
input: "file:///home/user/my%20project",
79+
want: "/home/user/my project",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
got := fileURIToPath(tt.input)
86+
if got != tt.want {
87+
t.Errorf("fileURIToPath(%q) = %q, want %q", tt.input, got, tt.want)
88+
}
89+
})
90+
}
91+
}
92+
93+
func TestIsASCIILetter(t *testing.T) {
94+
for b := byte('a'); b <= 'z'; b++ {
95+
if !isASCIILetter(b) {
96+
t.Errorf("isASCIILetter(%q) = false, want true", b)
97+
}
98+
}
99+
100+
for b := byte('A'); b <= 'Z'; b++ {
101+
if !isASCIILetter(b) {
102+
t.Errorf("isASCIILetter(%q) = false, want true", b)
103+
}
104+
}
105+
106+
nonLetters := []byte{'0', '9', ':', '/', '\\', ' ', '-', '_'}
107+
for _, b := range nonLetters {
108+
if isASCIILetter(b) {
109+
t.Errorf("isASCIILetter(%q) = true, want false", b)
110+
}
111+
}
112+
}

internal/mcpserver/server.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,15 @@ const (
2222
// StartWithMode starts the MCP server with the specified mode and address
2323
// mode: "stdio" for standard input/output, "http" for HTTP/SSE
2424
// addr: address to listen on (only used for HTTP mode, e.g., ":8080")
25-
func StartWithMode(mode ServerMode, addr string) {
25+
// version: version of the MCP server
26+
func StartWithMode(mode ServerMode, addr, version string) {
27+
if version == "" {
28+
version = "dev"
29+
}
30+
2631
s = server.NewMCPServer(
2732
"Azure Quick Review 🚀",
28-
"0.1.0",
33+
version,
2934
server.WithToolCapabilities(true), // Enable tool notifications
3035
server.WithResourceCapabilities(true, true),
3136
server.WithPromptCapabilities(true),

internal/mcpserver/tools.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ func withBasicOptions(opts ...mcp.ToolOption) []mcp.ToolOption {
135135
mcp.DefaultBool(true),
136136
mcp.Description("Mask sensitive data in output (default: true)."),
137137
),
138+
mcp.WithReadOnlyHintAnnotation(false),
139+
mcp.WithDestructiveHintAnnotation(false),
138140
}
139141
return append(opts, basicOpts...)
140142
}

0 commit comments

Comments
 (0)