Skip to content

Commit 0f50208

Browse files
committed
add mcp proxy
1 parent 20a14e4 commit 0f50208

File tree

5 files changed

+1436
-0
lines changed

5 files changed

+1436
-0
lines changed

main/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/aliyun/aliyun-cli/v3/config"
2727
go_migrate "github.com/aliyun/aliyun-cli/v3/go-migrate"
2828
"github.com/aliyun/aliyun-cli/v3/i18n"
29+
"github.com/aliyun/aliyun-cli/v3/mcpproxy"
2930
"github.com/aliyun/aliyun-cli/v3/openapi"
3031
"github.com/aliyun/aliyun-cli/v3/oss/lib"
3132
"github.com/aliyun/aliyun-cli/v3/ossutil"
@@ -76,6 +77,8 @@ func Main(args []string) {
7677
rootCmd.AddSubCommand(lib.NewOssCommand())
7778
rootCmd.AddSubCommand(cli.NewVersionCommand())
7879
rootCmd.AddSubCommand(cli.NewAutoCompleteCommand())
80+
// mcp proxy command
81+
rootCmd.AddSubCommand(mcpproxy.NewMCPProxyCommand())
7982
// go v1 to v2 migrate command
8083
rootCmd.AddSubCommand(go_migrate.NewGoMigrateCommand())
8184
// new oss command

mcpproxy/command.go

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright (c) 2009-present, Alibaba Cloud All rights reserved.
2+
//
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+
package mcpproxy
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"net/url"
21+
"os"
22+
"os/signal"
23+
"strconv"
24+
"syscall"
25+
26+
"github.com/aliyun/aliyun-cli/v3/cli"
27+
"github.com/aliyun/aliyun-cli/v3/i18n"
28+
)
29+
30+
func NewMCPProxyCommand() *cli.Command {
31+
cmd := &cli.Command{
32+
Name: "mcp-proxy",
33+
Short: i18n.T("Start MCP server proxy", "启动 MCP 服务器代理"),
34+
Long: i18n.T(
35+
"Start a local proxy server for Aliyun API MCP Servers. "+
36+
"The proxy handles OAuth authentication automatically, "+
37+
"allowing MCP clients to connect without managing credentials.",
38+
"启动阿里云 API MCP Server 的本地代理服务。"+
39+
"代理自动处理 OAuth 认证,"+
40+
"允许 MCP 客户端无需管理凭证即可连接。",
41+
),
42+
Usage: "aliyun mcp-proxy [--mcp-profile PROFILE] [--port PORT] [--host HOST] [--region REGION] [--bearer-token TOKEN]",
43+
Sample: "aliyun mcp-proxy --region CN --port 8088",
44+
Run: func(ctx *cli.Context, args []string) error {
45+
return runMCPProxy(ctx)
46+
},
47+
}
48+
49+
cmd.Flags().Add(&cli.Flag{
50+
Name: "mcp-profile",
51+
DefaultValue: "default-mcp",
52+
Short: i18n.T(
53+
"MCP profile name for MCP OAuth",
54+
"MCP OAuth 配置名称 (default-mcp)",
55+
),
56+
})
57+
58+
cmd.Flags().Add(&cli.Flag{
59+
Name: "port",
60+
DefaultValue: "8088",
61+
Short: i18n.T(
62+
"Proxy server port",
63+
"代理服务器端口",
64+
),
65+
})
66+
67+
cmd.Flags().Add(&cli.Flag{
68+
Name: "host",
69+
DefaultValue: "127.0.0.1",
70+
Short: i18n.T(
71+
"Proxy server host (use 0.0.0.0 to listen on all interfaces)",
72+
"代理服务器地址 (使用 0.0.0.0 监听所有网络接口)",
73+
),
74+
})
75+
76+
cmd.Flags().Add(&cli.Flag{
77+
Name: "region",
78+
DefaultValue: "CN",
79+
Short: i18n.T(
80+
"Region type: CN or INTL",
81+
"地域类型: CN 或 INTL",
82+
),
83+
})
84+
85+
cmd.Flags().Add(&cli.Flag{
86+
Name: "bearer-token",
87+
Short: i18n.T(
88+
"Optional static bearer token for client authentication",
89+
"可选的静态 Bearer Token 用于客户端认证",
90+
),
91+
})
92+
93+
return cmd
94+
}
95+
96+
func runMCPProxy(ctx *cli.Context) error {
97+
mcpProfileName := ctx.Flags().Get("mcp-profile").GetStringOrDefault("default-mcp")
98+
portStr := ctx.Flags().Get("port").GetStringOrDefault("8088")
99+
host := ctx.Flags().Get("host").GetStringOrDefault("127.0.0.1")
100+
regionStr := ctx.Flags().Get("region").GetStringOrDefault("CN")
101+
bearerToken := ctx.Flags().Get("bearer-token").GetStringOrDefault("")
102+
port, err := strconv.Atoi(portStr)
103+
if err != nil {
104+
return fmt.Errorf("invalid port: %s", portStr)
105+
}
106+
var region RegionType
107+
switch regionStr {
108+
case "CN":
109+
region = RegionCN
110+
case "INTL":
111+
region = RegionINTL
112+
default:
113+
return fmt.Errorf("invalid region: %s, must be CN or INTL", regionStr)
114+
}
115+
116+
mcpProfile, err := getOrCreateMCPProfile(ctx, mcpProfileName, region, host, port)
117+
if err != nil {
118+
return err
119+
}
120+
121+
return startMCPProxy(ctx, mcpProfile, region, host, port, bearerToken)
122+
}
123+
124+
func startMCPProxy(ctx *cli.Context, mcpProfile *McpProfile, region RegionType, host string, port int, bearerToken string) error {
125+
servers, err := ListMCPServers(ctx, region)
126+
if err != nil {
127+
return fmt.Errorf("failed to list MCP servers: %w", err)
128+
}
129+
130+
if len(servers) == 0 {
131+
return fmt.Errorf("no MCP servers found")
132+
}
133+
134+
manager := NewOAuthCallbackManager()
135+
136+
proxy := NewMCPProxy(host, port, region, bearerToken, mcpProfile, servers, manager)
137+
go proxy.Refresher.Start()
138+
139+
printProxyInfo(ctx, proxy)
140+
141+
// 设置信号处理,捕获 Ctrl+C (SIGINT) 和 SIGTERM
142+
sigChan := make(chan os.Signal, 1)
143+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
144+
145+
// 在 goroutine 中启动服务器
146+
serverErrChan := make(chan error, 1)
147+
go func() {
148+
if err := proxy.Start(); err != nil {
149+
serverErrChan <- err
150+
}
151+
}()
152+
153+
// 等待信号或服务器错误
154+
select {
155+
case sig := <-sigChan:
156+
cli.Printf(ctx.Stdout(), "\nReceived signal: %v, shutting down gracefully...\n", sig)
157+
// 停止 token refresher
158+
if proxy.Refresher != nil {
159+
proxy.Refresher.Stop()
160+
}
161+
// 停止代理服务器
162+
if err := proxy.Stop(); err != nil {
163+
// 如果是超时错误,记录日志但不返回错误,因为服务器已经关闭
164+
cli.Printf(ctx.Stderr(), "Warning: %v\n", err)
165+
}
166+
cli.Println(ctx.Stdout(), "MCP Proxy stopped successfully")
167+
return nil
168+
case err := <-serverErrChan:
169+
return err
170+
}
171+
}
172+
173+
func printProxyInfo(ctx *cli.Context, proxy *MCPProxy) {
174+
cli.Printf(ctx.Stdout(), "\nMCP Proxy Server Started\nListen: %s:%d\nRegion: %s\n",
175+
proxy.Host, proxy.Port, proxy.Region)
176+
177+
if proxy.BearerToken != "" {
178+
cli.Println(ctx.Stdout(), "Bearer token: Enabled")
179+
}
180+
181+
cli.Println(ctx.Stdout(), "\nAvailable Servers:")
182+
for _, server := range proxy.McpServers {
183+
cli.Printf(ctx.Stdout(), " - %s\n", server.Name)
184+
if server.Urls.MCP != "" {
185+
if upstreamURL, err := url.Parse(server.Urls.MCP); err == nil {
186+
cli.Printf(ctx.Stdout(), " MCP: http://%s:%d%s\n", proxy.Host, proxy.Port, upstreamURL.Path)
187+
}
188+
}
189+
if server.Urls.SSE != "" {
190+
if upstreamURL, err := url.Parse(server.Urls.SSE); err == nil {
191+
cli.Printf(ctx.Stdout(), " SSE: http://%s:%d%s\n", proxy.Host, proxy.Port, upstreamURL.Path)
192+
}
193+
}
194+
}
195+
196+
cli.Println(ctx.Stdout(), "\nPress Ctrl+C to stop")
197+
}
198+
199+
func GetContentFromApiResponse(response map[string]any) ([]byte, error) {
200+
responseBody := response["body"]
201+
if responseBody == nil {
202+
return nil, fmt.Errorf("response body is nil")
203+
}
204+
switch v := responseBody.(type) {
205+
case string:
206+
return []byte(v), nil
207+
case map[string]any, []any:
208+
jsonData, _ := json.Marshal(v)
209+
return jsonData, nil
210+
case []byte:
211+
return v, nil
212+
default:
213+
return []byte(fmt.Sprintf("%v", v)), nil
214+
}
215+
}

mcpproxy/mcp_profile.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Copyright (c) 2009-present, Alibaba Cloud All rights reserved.
2+
//
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+
package mcpproxy
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"log"
21+
"os"
22+
23+
"github.com/aliyun/aliyun-cli/v3/cli"
24+
"github.com/aliyun/aliyun-cli/v3/config"
25+
"github.com/aliyun/aliyun-cli/v3/util"
26+
)
27+
28+
type McpProfile struct {
29+
Name string `json:"name"`
30+
MCPOAuthAccessToken string `json:"mcp_oauth_access_token,omitempty"`
31+
MCPOAuthRefreshToken string `json:"mcp_oauth_refresh_token,omitempty"`
32+
MCPOAuthAccessTokenExpire int64 `json:"mcp_oauth_access_token_expire,omitempty"`
33+
MCPOAuthRefreshTokenValidity int `json:"mcp_oauth_refresh_token_validity,omitempty"`
34+
MCPOAuthRefreshTokenExpire int64 `json:"mcp_oauth_refresh_token_expire,omitempty"`
35+
MCPOAuthSiteType string `json:"mcp_oauth_site_type,omitempty"` // CN or INTL
36+
MCPOAuthAppId string `json:"mcp_oauth_app_id,omitempty"`
37+
}
38+
39+
func getMCPConfigPath() string {
40+
return config.GetConfigPath() + "/.mcpproxy_config"
41+
}
42+
43+
func NewMcpProfile(name string) *McpProfile {
44+
return &McpProfile{Name: name}
45+
}
46+
47+
func NewMcpProfileFromBytes(bytes []byte) (profile *McpProfile, err error) {
48+
profile = &McpProfile{Name: DefaultMcpProfileName}
49+
err = json.Unmarshal(bytes, profile)
50+
return
51+
}
52+
53+
func saveMcpProfile(profile *McpProfile) error {
54+
mcpConfigPath := getMCPConfigPath()
55+
tempFile := mcpConfigPath + ".tmp"
56+
57+
bytes, err := json.MarshalIndent(profile, "", "\t")
58+
if err != nil {
59+
return err
60+
}
61+
62+
if err = os.WriteFile(tempFile, bytes, 0600); err != nil {
63+
return err
64+
}
65+
66+
return os.Rename(tempFile, mcpConfigPath)
67+
}
68+
69+
func getOrCreateMCPProfile(ctx *cli.Context, mcpProfileName string, region RegionType, host string, port int) (*McpProfile, error) {
70+
profile, err := config.LoadProfileWithContext(ctx)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to load profile: %w", err)
73+
}
74+
mcpConfigPath := getMCPConfigPath()
75+
if bytes, err := os.ReadFile(mcpConfigPath); err == nil {
76+
if mcpProfile, err := NewMcpProfileFromBytes(bytes); err == nil {
77+
log.Println("MCP Profile loaded from file", mcpProfileName, "app id", mcpProfile.MCPOAuthAppId)
78+
err = findExistingMCPOauthApplicationById(ctx, profile, mcpProfile, region)
79+
if err == nil {
80+
return mcpProfile, nil
81+
} else {
82+
log.Println("Failed to find existing OAuth application", err.Error())
83+
}
84+
}
85+
}
86+
87+
app, err := getOrCreateMCPOAuthApplication(ctx, profile, region, host, port)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to get or create OAuth application: %w", err)
90+
}
91+
92+
cli.Printf(ctx.Stdout(), "Setting up MCPOAuth profile '%s'...\n", mcpProfileName)
93+
94+
mcpProfile := NewMcpProfile(mcpProfileName)
95+
mcpProfile.MCPOAuthSiteType = string(region)
96+
mcpProfile.MCPOAuthAppId = app.ApplicationId
97+
// refresh token 不在刷新接口返回,所以直接在这里设置
98+
currentTime := util.GetCurrentUnixTime()
99+
mcpProfile.MCPOAuthRefreshTokenValidity = app.RefreshTokenValidity
100+
mcpProfile.MCPOAuthRefreshTokenExpire = currentTime + int64(app.RefreshTokenValidity)
101+
102+
if err = startMCPOAuthFlow(ctx, mcpProfile, region, host, port); err != nil {
103+
return nil, fmt.Errorf("OAuth login failed: %w", err)
104+
}
105+
106+
if err = saveMcpProfile(mcpProfile); err != nil {
107+
return nil, fmt.Errorf("failed to save mcp profile: %w", err)
108+
}
109+
110+
cli.Printf(ctx.Stdout(), "MCP Profile '%s' configured successfully!\n", mcpProfileName)
111+
112+
return mcpProfile, nil
113+
}

0 commit comments

Comments
 (0)