Skip to content

Commit a1a4949

Browse files
authored
{mcp} Add MCP Proxy Server with OAuth Authentication (#1279)
* add mcp proxy * refine token profile * refine tokenrefresher * fix codes * refine token accessing * refine code * add loading page and no browser mode * add health check * refine oauth app * add log * add tests * refine profile validation * add user oauth * adjust config and reauth * refine * fix stderr * add log * adjust log * adjust tests * adjust retry
1 parent 4348fa2 commit a1a4949

File tree

12 files changed

+3964
-1
lines changed

12 files changed

+3964
-1
lines changed

.github/workflows/go.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
build:
1212
strategy:
1313
matrix:
14-
os: [ubuntu-latest, macos-13, windows-latest]
14+
os: [ubuntu-latest, macos-latest, windows-latest]
1515
runs-on: ${{ matrix.os }}
1616
environment: CI
1717
steps:

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: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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 [--port PORT] [--host HOST] [--region-type REGION_TYPE] [--upstream-url URL] [--oauth-app-name NAME]",
43+
Sample: "aliyun mcp-proxy --region-type 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: "port",
51+
DefaultValue: "8088",
52+
Short: i18n.T(
53+
"Proxy server port",
54+
"代理服务器端口",
55+
),
56+
})
57+
58+
cmd.Flags().Add(&cli.Flag{
59+
Name: "host",
60+
DefaultValue: "127.0.0.1",
61+
Short: i18n.T(
62+
"Proxy server host (use 0.0.0.0 to listen on all interfaces)",
63+
"代理服务器地址 (使用 0.0.0.0 监听所有网络接口)",
64+
),
65+
})
66+
67+
cmd.Flags().Add(&cli.Flag{
68+
Name: "region-type",
69+
DefaultValue: "CN",
70+
Short: i18n.T(
71+
"Region type: CN or INTL",
72+
"地域类型: CN 或 INTL",
73+
),
74+
})
75+
76+
cmd.Flags().Add(&cli.Flag{
77+
Name: "no-browser",
78+
Short: i18n.T(
79+
"Disable automatic browser opening. Use manual code input mode instead",
80+
"使用手动输入授权码模式,不自动打开浏览器",
81+
),
82+
})
83+
84+
cmd.Flags().Add(&cli.Flag{
85+
Name: "scope",
86+
DefaultValue: "/acs/mcp-server",
87+
Short: i18n.T(
88+
"OAuth predefined scope (default: /acs/mcp-server)",
89+
"OAuth 预定义权限范围(默认: /acs/mcp-server)",
90+
),
91+
})
92+
93+
cmd.Flags().Add(&cli.Flag{
94+
Name: "upstream-url",
95+
Short: i18n.T(
96+
"Custom upstream MCP server URL (overrides EndpointMap configuration)",
97+
"自定义上游 MCP 服务器地址(覆盖 EndpointMap 配置)",
98+
),
99+
})
100+
101+
cmd.Flags().Add(&cli.Flag{
102+
Name: "oauth-app-name",
103+
Short: i18n.T(
104+
"Use existing OAuth application by name (for users without create permission)",
105+
"使用已存在的 OAuth 应用名称(适用于没有创建权限的用户)",
106+
),
107+
})
108+
109+
return cmd
110+
}
111+
112+
// ProxyConfig 封装了启动 MCP Proxy 所需的所有配置参数
113+
type StartProxyConfig struct {
114+
McpProfile *McpProfile
115+
RegionType RegionType
116+
Host string
117+
Port int
118+
NoBrowser bool
119+
Scope string
120+
UpstreamURL string
121+
}
122+
123+
func runMCPProxy(ctx *cli.Context) error {
124+
portStr := ctx.Flags().Get("port").GetStringOrDefault("8088")
125+
host := ctx.Flags().Get("host").GetStringOrDefault("127.0.0.1")
126+
regionStr := ctx.Flags().Get("region-type").GetStringOrDefault("CN")
127+
port, err := strconv.Atoi(portStr)
128+
if err != nil {
129+
return fmt.Errorf("invalid port: %s", portStr)
130+
}
131+
var regionType RegionType
132+
switch regionStr {
133+
case "CN":
134+
regionType = RegionCN
135+
case "INTL":
136+
regionType = RegionINTL
137+
default:
138+
return fmt.Errorf("invalid region type: %s, must be CN or INTL", regionStr)
139+
}
140+
141+
noBrowser := ctx.Flags().Get("no-browser").IsAssigned()
142+
scope := ctx.Flags().Get("scope").GetStringOrDefault("/acs/mcp-server")
143+
upstreamURL := ctx.Flags().Get("upstream-url").GetStringOrDefault("")
144+
oauthAppName := ctx.Flags().Get("oauth-app-name").GetStringOrDefault("")
145+
146+
proxyConfig := ProxyConfig{
147+
Host: host,
148+
Port: port,
149+
RegionType: regionType,
150+
Scope: scope,
151+
AutoOpenBrowser: !noBrowser,
152+
UpstreamBaseURL: upstreamURL,
153+
OAuthAppName: oauthAppName,
154+
}
155+
156+
mcpProfile, err := getOrCreateMCPProfile(ctx, proxyConfig)
157+
if err != nil {
158+
return err
159+
}
160+
proxyConfig.McpProfile = mcpProfile
161+
return startMCPProxy(ctx, proxyConfig)
162+
}
163+
164+
func startMCPProxy(ctx *cli.Context, config ProxyConfig) error {
165+
servers, err := ListMCPServers(ctx, config.RegionType)
166+
if err != nil {
167+
return fmt.Errorf("failed to list MCP servers: %w", err)
168+
}
169+
170+
if len(servers) == 0 {
171+
return fmt.Errorf("no MCP servers found")
172+
}
173+
174+
config.CallbackManager = NewOAuthCallbackManager()
175+
config.ExistMcpServers = servers
176+
177+
proxy := NewMCPProxy(config)
178+
go proxy.TokenRefresher.Start()
179+
180+
printProxyInfo(ctx, proxy)
181+
182+
// 设置信号处理,捕获 Ctrl+C (SIGINT) 和 SIGTERM
183+
sigChan := make(chan os.Signal, 1)
184+
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
185+
186+
// 在 goroutine 中启动服务器
187+
serverErrChan := make(chan error, 1)
188+
go func() {
189+
if err := proxy.Start(); err != nil {
190+
serverErrChan <- err
191+
}
192+
}()
193+
194+
// 等待信号、服务器错误或致命错误
195+
select {
196+
case sig := <-sigChan:
197+
cli.Printf(ctx.Stdout(), "\nReceived signal: %v, shutting down gracefully...\n", sig)
198+
if proxy.TokenRefresher != nil {
199+
proxy.TokenRefresher.Stop()
200+
}
201+
if err := proxy.Stop(); err != nil {
202+
// 如果是超时错误,记录日志但不返回错误,因为服务器已经关闭
203+
cli.Printf(ctx.Stderr(), "Warning: %v\n", err)
204+
}
205+
cli.Println(ctx.Stdout(), "MCP Proxy stopped successfully")
206+
return nil
207+
case err := <-serverErrChan:
208+
return err
209+
case fatalErr := <-proxy.TokenRefresher.fatalErrCh:
210+
cli.Printf(ctx.Stderr(), "\nFatal error: %v\n", fatalErr)
211+
cli.Printf(ctx.Stdout(), "Shutting down gracefully...\n")
212+
if proxy.TokenRefresher != nil {
213+
proxy.TokenRefresher.Stop()
214+
}
215+
if err := proxy.Stop(); err != nil {
216+
cli.Printf(ctx.Stderr(), "Warning: %v\n", err)
217+
}
218+
return fatalErr
219+
}
220+
}
221+
222+
func printProxyInfo(ctx *cli.Context, proxy *MCPProxy) {
223+
cli.Printf(ctx.Stdout(), "\nMCP Proxy Server Started\nListen: %s:%d\nRegion: %s\n",
224+
proxy.Host, proxy.Port, proxy.RegionType)
225+
226+
cli.Println(ctx.Stdout(), "\nAvailable Servers:")
227+
for _, server := range proxy.ExistMcpServers {
228+
cli.Printf(ctx.Stdout(), " - %s\n", server.Name)
229+
if server.Urls.MCP != "" {
230+
if upstreamURL, err := url.Parse(server.Urls.MCP); err == nil {
231+
cli.Printf(ctx.Stdout(), " MCP: http://%s:%d%s\n", proxy.Host, proxy.Port, upstreamURL.Path)
232+
}
233+
}
234+
if server.Urls.SSE != "" {
235+
if upstreamURL, err := url.Parse(server.Urls.SSE); err == nil {
236+
cli.Printf(ctx.Stdout(), " SSE: http://%s:%d%s\n", proxy.Host, proxy.Port, upstreamURL.Path)
237+
}
238+
}
239+
}
240+
241+
cli.Println(ctx.Stdout(), "\nPress Ctrl+C to stop")
242+
}
243+
244+
func GetContentFromApiResponse(response map[string]any) ([]byte, error) {
245+
responseBody := response["body"]
246+
if responseBody == nil {
247+
return nil, fmt.Errorf("response body is nil")
248+
}
249+
switch v := responseBody.(type) {
250+
case string:
251+
return []byte(v), nil
252+
case map[string]any, []any:
253+
jsonData, _ := json.Marshal(v)
254+
return jsonData, nil
255+
case []byte:
256+
return v, nil
257+
default:
258+
return []byte(fmt.Sprintf("%v", v)), nil
259+
}
260+
}

0 commit comments

Comments
 (0)