Files
dingtalk-workspace-cli/internal/plugin/converter.go
T
ybc ea5859b92b feat(plugin): support custom auth headers for third-party MCP servers
Add Auth Token Registry pattern (inspired by stdio_registry.go) to allow
plugins to declare per-server HTTP headers in plugin.json. This enables
third-party streamable-http MCP servers (e.g. Alibaba Cloud Bailian) to
use their own API keys independently from the default DingTalk OAuth token.

Changes:
- New: internal/app/auth_registry.go — per-productID auth credential store
- manifest.go: MCPServer gains Headers field for custom HTTP headers
- registry.go: ServerDescriptor gains AuthHeaders field
- converter.go: resolve and pass headers through ToServerDescriptors
- root.go: inject plugin auth at discovery time + auto-generate ToolOverrides
- runner.go: route plugin-auth servers to their own Bearer token at runtime

Multi-token isolation: each server is keyed by its CLI.ID (productID),
so different servers can use independent tokens without interfering with
each other or with the default DingTalk OAuth token.
2026-04-16 09:38:18 +08:00

140 lines
4.1 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugin
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// StdioServerClient pairs a transport.StdioClient with its server key.
type StdioServerClient struct {
Key string
Client *transport.StdioClient
}
// StdioClients returns StdioClient instances for all stdio-type MCP
// servers declared by this plugin.
func (p *Plugin) StdioClients() []StdioServerClient {
var clients []StdioServerClient
for key, srv := range p.Manifest.MCPServers {
if srv.Type != "stdio" {
continue
}
command := srv.Command
if command == "" {
slog.Warn("plugin: stdio server missing command",
"plugin", p.Manifest.Name, "server", key)
continue
}
// Expand ${DWS_PLUGIN_ROOT} in command and args.
command = expandPluginVars(command, p.Root)
args := make([]string, len(srv.Args))
for i, a := range srv.Args {
args[i] = expandPluginVars(a, p.Root)
}
env := make(map[string]string)
for k, v := range srv.Env {
env[k] = expandPluginVars(v, p.Root)
}
env["DWS_PLUGIN_ROOT"] = p.Root
env["DWS_PLUGIN_DATA"] = filepath.Join(filepath.Dir(filepath.Dir(p.Root)), "data", p.Manifest.Name)
sc := transport.NewStdioClient(command, args, env)
clients = append(clients, StdioServerClient{Key: key, Client: sc})
}
return clients
}
// expandPluginVars replaces ${DWS_PLUGIN_ROOT} with the actual plugin
// root path and ${DWS_PLUGIN_DATA} with the data directory.
func expandPluginVars(s, root string) string {
s = strings.ReplaceAll(s, "${DWS_PLUGIN_ROOT}", root)
dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
s = strings.ReplaceAll(s, "${DWS_PLUGIN_DATA}", dataDir)
return os.Expand(s, os.Getenv)
}
// ToServerDescriptors converts a loaded plugin's MCP servers into
// market.ServerDescriptor values suitable for SetDynamicServers.
// Only streamable-http servers are converted; stdio servers are
// skipped (they require the stdio transport extension).
func (p *Plugin) ToServerDescriptors() []market.ServerDescriptor {
var descriptors []market.ServerDescriptor
for key, srv := range p.Manifest.MCPServers {
if srv.Type != "streamable-http" {
slog.Debug("plugin: skipping non-http server",
"plugin", p.Manifest.Name,
"server", key,
"type", srv.Type,
)
continue
}
overlay := market.CLIOverlay{}
if len(srv.CLI) > 0 {
if err := json.Unmarshal(srv.CLI, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLIOverlay",
"plugin", p.Manifest.Name,
"server", key,
"error", err,
)
}
}
// Ensure the overlay has an ID — fall back to server key.
if overlay.ID == "" {
overlay.ID = key
}
if overlay.Command == "" {
overlay.Command = key
}
source := "plugin"
if p.IsManaged {
source = "plugin-managed"
}
// Resolve headers: expand environment variable references (e.g. ${DASHSCOPE_API_KEY}).
var resolvedHeaders map[string]string
if len(srv.Headers) > 0 {
resolvedHeaders = make(map[string]string, len(srv.Headers))
for headerKey, headerVal := range srv.Headers {
resolvedHeaders[headerKey] = expandPluginVars(headerVal, p.Root)
}
}
descriptors = append(descriptors, market.ServerDescriptor{
Key: key,
DisplayName: p.Manifest.Name + "/" + key,
Description: p.Manifest.Description,
Endpoint: srv.Endpoint,
Source: source,
CLI: overlay,
HasCLIMeta: len(srv.CLI) > 0,
AuthHeaders: resolvedHeaders,
})
}
return descriptors
}