Skip to content

Commit a8b1dc8

Browse files
shizhMSFTCopilot
andauthored
refactor: reimplement rest of tools (#20)
Signed-off-by: Shiwei Zhang <shizh@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 4b757eb commit a8b1dc8

22 files changed

Lines changed: 1801 additions & 282 deletions

go.mod

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ module github.com/oras-project/oras-mcp
33
go 1.25.0
44

55
require (
6-
github.com/modelcontextprotocol/go-sdk v0.6.0
6+
github.com/modelcontextprotocol/go-sdk v0.7.0
7+
github.com/opencontainers/go-digest v1.0.0
8+
github.com/opencontainers/image-spec v1.1.1
79
github.com/spf13/cobra v1.10.1
810
oras.land/oras-go/v2 v2.6.0
911
)
1012

1113
require (
12-
github.com/google/jsonschema-go v0.2.3 // indirect
14+
github.com/google/jsonschema-go v0.3.0 // indirect
1315
github.com/inconshreveable/mousetrap v1.1.0 // indirect
14-
github.com/opencontainers/go-digest v1.0.0 // indirect
15-
github.com/opencontainers/image-spec v1.1.1 // indirect
1616
github.com/spf13/pflag v1.0.10 // indirect
1717
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
18-
golang.org/x/sync v0.14.0 // indirect
18+
golang.org/x/sync v0.17.0 // indirect
1919
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
33
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
44
github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
55
github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
6+
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
7+
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
68
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
79
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
810
github.com/modelcontextprotocol/go-sdk v0.6.0 h1:cmtMYfRAUtEtCiuorOWPj7ygcypfuB2FgFEDBqZqgy4=
911
github.com/modelcontextprotocol/go-sdk v0.6.0/go.mod h1:djQKZ74bEV+UMAmyG/L0coVhV0HM3fpVtGuUPls0znc=
12+
github.com/modelcontextprotocol/go-sdk v0.7.0 h1:XEQfn3bDx2cAdSUKty3tYEMll5dtRgBUDX88Q65fai0=
13+
github.com/modelcontextprotocol/go-sdk v0.7.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
1014
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
1115
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
1216
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -21,6 +25,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
2125
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
2226
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
2327
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
28+
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
29+
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
2430
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
2531
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
2632
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/remote/client.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package remote
17+
18+
import (
19+
"net"
20+
"net/http"
21+
22+
"github.com/oras-project/oras-mcp/internal/version"
23+
"oras.land/oras-go/v2/registry/remote/auth"
24+
"oras.land/oras-go/v2/registry/remote/credentials"
25+
"oras.land/oras-go/v2/registry/remote/retry"
26+
)
27+
28+
// DefaultClient is the default oras-mcp auth client shared by all repositories.
29+
// This is intended for performance optimization to reduce repeated
30+
// authentication and token exchange requests for the MCP server in the stdio
31+
// mode.
32+
var DefaultClient *auth.Client
33+
34+
func init() {
35+
var err error
36+
DefaultClient, err = authClient()
37+
if err != nil {
38+
panic(err)
39+
}
40+
}
41+
42+
// isPlainHttp determines whether to use plain HTTP for the given registry.
43+
func isPlainHttp(registry string) bool {
44+
host, _, _ := net.SplitHostPort(registry)
45+
return host == "localhost" || registry == "localhost"
46+
}
47+
48+
// authClient assembles an oras-mcp auth client.
49+
func authClient() (*auth.Client, error) {
50+
client := &auth.Client{
51+
Client: &http.Client{
52+
// http.RoundTripper with a retry using the DefaultPolicy
53+
// see: https://pkg.go.dev/oras.land/oras-go/v2/registry/remote/retry#Policy
54+
Transport: retry.NewTransport(http.DefaultTransport),
55+
},
56+
Cache: auth.NewCache(),
57+
}
58+
client.SetUserAgent("oras-mcp/" + version.GetVersion())
59+
60+
store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{})
61+
if err != nil {
62+
return nil, err
63+
}
64+
client.Credential = credentials.Credential(store)
65+
return client, nil
66+
}

internal/remote/client_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package remote
17+
18+
import (
19+
"net/http"
20+
"net/http/httptest"
21+
"testing"
22+
23+
"github.com/oras-project/oras-mcp/internal/version"
24+
"oras.land/oras-go/v2/registry/remote/auth"
25+
)
26+
27+
// TestIsPlainHttp tests the isPlainHttp function.
28+
func TestIsPlainHttp(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
registry string
32+
want bool
33+
}{
34+
{
35+
name: "localhost",
36+
registry: "localhost",
37+
want: true,
38+
},
39+
{
40+
name: "localhost with port",
41+
registry: "localhost:5000",
42+
want: true,
43+
},
44+
{
45+
name: "non-localhost registry",
46+
registry: "example.com",
47+
want: false,
48+
},
49+
{
50+
name: "non-localhost registry with port",
51+
registry: "example.com:5000",
52+
want: false,
53+
},
54+
{
55+
name: "IP with port",
56+
registry: "192.168.1.1:5000",
57+
want: false,
58+
},
59+
}
60+
61+
for _, tt := range tests {
62+
t.Run(tt.name, func(t *testing.T) {
63+
got := isPlainHttp(tt.registry)
64+
if got != tt.want {
65+
t.Errorf("isPlainHttp() = %v, want %v", got, tt.want)
66+
}
67+
})
68+
}
69+
}
70+
71+
// TestAuthClient tests the authClient function.
72+
func TestAuthClient(t *testing.T) {
73+
// Save the original version values to restore after test.
74+
originalVersion := version.Version
75+
originalBuildMetadata := version.BuildMetadata
76+
defer func() {
77+
version.Version = originalVersion
78+
version.BuildMetadata = originalBuildMetadata
79+
}()
80+
81+
// Set test values.
82+
version.Version = "1.0.0"
83+
version.BuildMetadata = "test"
84+
85+
// Test creating the auth client.
86+
client, err := authClient()
87+
if err != nil {
88+
t.Fatalf("authClient() error = %v", err)
89+
}
90+
91+
// Check client properties.
92+
if client == nil {
93+
t.Fatal("authClient() returned nil client")
94+
}
95+
96+
// Check if UserAgent is set correctly.
97+
expectedUserAgent := "oras-mcp/1.0.0+test"
98+
userAgentSet := false
99+
100+
// Since we can't directly access the client's user-agent, let's make a test request.
101+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+
if r.Header.Get("User-Agent") == expectedUserAgent {
103+
userAgentSet = true
104+
}
105+
w.WriteHeader(http.StatusOK)
106+
}))
107+
defer ts.Close()
108+
109+
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
110+
if err != nil {
111+
t.Fatalf("failed to create request: %v", err)
112+
}
113+
_, err = client.Do(req)
114+
if err != nil {
115+
t.Fatalf("Failed to make test request: %v", err)
116+
}
117+
118+
if !userAgentSet {
119+
t.Errorf("Expected User-Agent header to be set to %s", expectedUserAgent)
120+
}
121+
122+
// Check if cache is initialized.
123+
if client.Cache == nil {
124+
t.Error("Expected Cache to be initialized")
125+
}
126+
127+
// Check if credential store is set.
128+
if client.Credential == nil {
129+
t.Error("Expected Credential function to be set")
130+
}
131+
}
132+
133+
// TestDefaultClient checks that the DefaultClient is properly initialized.
134+
func TestDefaultClient(t *testing.T) {
135+
if DefaultClient == nil {
136+
t.Fatal("DefaultClient should not be nil")
137+
}
138+
139+
// Check that it's an auth.Client.
140+
if _, ok := interface{}(DefaultClient).(*auth.Client); !ok {
141+
t.Errorf("DefaultClient is not of type *auth.Client")
142+
}
143+
144+
// Test basic functionality of the client.
145+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+
w.WriteHeader(http.StatusOK)
147+
}))
148+
defer ts.Close()
149+
150+
req, err := http.NewRequest(http.MethodGet, ts.URL, nil)
151+
if err != nil {
152+
t.Fatalf("failed to create request: %v", err)
153+
}
154+
resp, err := DefaultClient.Do(req)
155+
if err != nil {
156+
t.Fatalf("Failed to use DefaultClient: %v", err)
157+
}
158+
defer resp.Body.Close()
159+
160+
if resp.StatusCode != http.StatusOK {
161+
t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.StatusCode)
162+
}
163+
}

internal/remote/registry.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package remote
17+
18+
import "oras.land/oras-go/v2/registry/remote"
19+
20+
// NewRegistry assembles an oras-mcp remote registry client.
21+
func NewRegistry(name string) (*remote.Registry, error) {
22+
reg, err := remote.NewRegistry(name)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
reg.Client = DefaultClient
28+
reg.PlainHTTP = isPlainHttp(name)
29+
30+
return reg, nil
31+
}

internal/remote/registry_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
package remote
17+
18+
import "testing"
19+
20+
func TestNewRegistry(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
registry string
24+
wantPlainHTTP bool
25+
}{
26+
{
27+
name: "localhost uses plain HTTP",
28+
registry: "localhost:5000",
29+
wantPlainHTTP: true,
30+
},
31+
{
32+
name: "remote host uses HTTPS",
33+
registry: "example.com",
34+
wantPlainHTTP: false,
35+
},
36+
}
37+
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
reg, err := NewRegistry(tt.registry)
41+
if err != nil {
42+
t.Fatalf("NewRegistry() error = %v", err)
43+
}
44+
if reg == nil {
45+
t.Fatal("NewRegistry() returned nil registry")
46+
}
47+
if got := reg.PlainHTTP; got != tt.wantPlainHTTP {
48+
t.Errorf("PlainHTTP = %v, want %v", got, tt.wantPlainHTTP)
49+
}
50+
if reg.Client != DefaultClient {
51+
t.Errorf("Client = %v, want DefaultClient", reg.Client)
52+
}
53+
if reg.Reference.Registry != tt.registry {
54+
t.Errorf("Registry = %q, want %q", reg.Reference.Registry, tt.registry)
55+
}
56+
})
57+
}
58+
}
59+
60+
func TestNewRegistryInvalid(t *testing.T) {
61+
if _, err := NewRegistry("https://example.com"); err == nil {
62+
t.Fatal("expected error for registry with scheme, got nil")
63+
}
64+
}

0 commit comments

Comments
 (0)