Browse Source

feat(mcp): MCP 服务配置支持自定义请求头鉴权

- mcp_server_config 新增 headers JSONB 字段,SSE 模式通过 customizeRequest 注入请求头
- 敏感头(Authorization 等)返回前脱敏,更新时未改动保留原值
- DatabaseInitConfig 与 init-database.sql 同步新增 headers 列迁移
- McpClientManager 增加 @DependsOn(databaseInitConfig) 保证列迁移先于客户端加载
- 前端 MCP 配置弹窗新增请求头输入框
master
wanghanlin 5 days ago
parent
commit
a1328994a5
  1. 1
      frontend/src/types/models.ts
  2. 16
      frontend/src/views/McpServerManager.vue
  3. 25
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  4. 18
      src/main/java/com/wok/supportbot/config/McpClientManager.java
  5. 6
      src/main/java/com/wok/supportbot/entity/McpServerConfig.java
  6. 102
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  7. 2
      src/main/resources/init-database.sql

1
frontend/src/types/models.ts

@ -112,6 +112,7 @@ export interface McpServerConfig {
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
url?: string
enabled: boolean
createTime?: string

16
frontend/src/views/McpServerManager.vue

@ -40,6 +40,10 @@
</t-radio-group>
</t-form-item>
<t-form-item v-if="form.transportType==='sse'" label="Server URL *"><t-input v-model="form.url" placeholder="如:http://localhost:3001/sse" /></t-form-item>
<t-form-item v-if="form.transportType==='sse'" label="请求头(可选)">
<t-textarea v-model="form.headersText" :autosize="{minRows:2,maxRows:4}" placeholder="Authorization: Bearer sk-xxx\nX-API-Key: abc123" />
<div style="font-size:11px;color:var(--color-text-secondary);margin-top:4px;">💡 敏感头Authorization 已脱敏未修改则保留原值</div>
</t-form-item>
<template v-if="form.transportType==='stdio'">
<t-form-item label="Command *"><t-input v-model="form.command" placeholder="如:npx, python, node" /></t-form-item>
<t-form-item label="Args(每行一个)"><t-textarea v-model="form.argsText" :autosize="{minRows:2,maxRows:4}" placeholder="-m\nmcp-server-fetch" /></t-form-item>
@ -66,7 +70,7 @@ const { confirm } = useConfirm()
const servers = ref<any[]>([]); const currentPage=ref(1); const pageSize=ref(10); const total=ref(0)
const showModal=ref(false); const modalMode=ref('add'); const editId=ref<any>(null); const saving=ref(false)
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''})
const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''})
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const refreshLoading=ref(false)
@ -79,19 +83,21 @@ async function load(p=1){currentPage.value=p
try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',description:''}}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}}
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}
function openEditModal(s:any){modalMode.value='edit';editId.value=s.id
let at='',et=''
let at='',et='',ht=''
if(s.args){if(Array.isArray(s.args))at=s.args.join('\n');else{try{const p=JSON.parse(s.args);if(Array.isArray(p))at=p.join('\n');else at=s.args}catch{at=s.args}}}
if(s.env_vars){if(typeof s.env_vars==='object')et=Object.entries(s.env_vars).map(([k,v])=>k+'='+v).join('\n');else{try{const p=JSON.parse(s.env_vars);if(typeof p==='object')et=Object.entries(p).map(([k,v]:any)=>k+'='+v).join('\n');else et=s.env_vars}catch{et=s.env_vars}}}
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,description:s.description||''};showModal.value=true}
if(s.headers){if(typeof s.headers==='object')ht=Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n');else{try{const p=JSON.parse(s.headers);if(typeof p==='object')ht=Object.entries(p).map(([k,v]:any)=>k+': '+v).join('\n');else ht=s.headers}catch{ht=s.headers}}}
form.value={name:s.name||'',transportType:s.transport_type||'sse',url:s.server_url||'',command:s.command||'',argsText:at,envText:et,headersText:ht,description:s.description||''};showModal.value=true}
async function save(){
const f=form.value;if(!f.name.trim()){toast('请填写服务名称','error');return}
if(f.transportType==='sse'){if(!f.url.trim()){toast('请填写 URL','error');return}}else{if(!f.command.trim()){toast('请填写 Command','error');return}}
const d:any={name:f.name,transportType:f.transportType,description:f.description}
if(f.transportType==='sse')d.serverUrl=f.url;else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
if(f.transportType==='sse'){d.serverUrl=f.url;if(f.headersText.trim()){const h:any={};f.headersText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf(':');if(i>0)h[t.substring(0,i).trim()]=t.substring(i+1).trim()});d.headers=h}}
else{d.command=f.command;d.args=f.argsText.trim()?f.argsText.split('\n').map(s=>s.trim()).filter(Boolean):[]}
if(f.envText.trim()){const e:any={};f.envText.split('\n').forEach(l=>{const t=l.trim();if(!t)return;const i=t.indexOf('=');if(i>0)e[t.substring(0,i)]=t.substring(i+1)});d.envVars=e}
saving.value=true
try{let r;if(modalMode.value==='add')r=await api.createMcpServer(d);else r=await api.updateMcpServer(editId.value,d)

25
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -211,6 +211,8 @@ public class DatabaseInitConfig {
createMcpServerConfigTable();
}
});
// 迁移 mcp_server_config.headers 旧表补加自定义请求头列用于鉴权场景
safeInit("迁移 mcp_server_config.headers 列", this::addMcpServerConfigHeadersColumn);
// P0-001: 混合检索 - vector_store 添加全文检索列
safeInit("初始化 vector_store 全文检索", this::initVectorStoreFullTextSearch);
@ -1251,6 +1253,7 @@ public class DatabaseInitConfig {
command VARCHAR(500),
args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL,
headers JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -1262,6 +1265,27 @@ public class DatabaseInitConfig {
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_mcp_server_config_active ON mcp_server_config (is_active) WHERE is_delete = FALSE");
}
/**
* 为已存在的 mcp_server_config 表补加 headers JSONB自定义请求头用于鉴权场景
* 幂等仅在列不存在时执行 ALTER保证旧库升级不丢失数据
*/
private void addMcpServerConfigHeadersColumn() {
if (!checkTableExists("mcp_server_config")) {
log.debug("mcp_server_config 表尚未创建,跳过 headers 迁移");
return;
}
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'mcp_server_config' AND column_name = 'headers'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
log.info("添加 mcp_server_config.headers 列");
jdbcTemplate.execute("ALTER TABLE mcp_server_config ADD COLUMN headers JSONB DEFAULT '{}' NOT NULL");
}
} catch (Exception e) {
log.error("添加 mcp_server_config.headers 列失败", e);
}
}
/**
* 创建系统配置表 system_configkey-value 模式支持灵活扩展配置项
*/
@ -1562,6 +1586,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN mcp_server_config.command", "stdio 模式的启动命令");
executeComment("COLUMN mcp_server_config.args", "stdio 模式的命令参数(多个用逗号分隔)");
executeComment("COLUMN mcp_server_config.env_vars", "环境变量(JSONB 键值对)");
executeComment("COLUMN mcp_server_config.headers", "自定义请求头(JSONB 键值对,仅 SSE/HTTP 模式生效,用于鉴权)");
executeComment("COLUMN mcp_server_config.description", "描述说明");
executeComment("COLUMN mcp_server_config.is_active", "是否启用");
executeComment("COLUMN mcp_server_config.create_time", "创建时间");

18
src/main/java/com/wok/supportbot/config/McpClientManager.java

@ -15,6 +15,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.DependsOn;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@ -33,6 +34,9 @@ import java.util.concurrent.ConcurrentHashMap;
*/
@Component
@Slf4j
// 依赖 DatabaseInitConfig确保建表/列迁移 mcp_server_config.headers先于本类 @PostConstruct 执行
// 否则 init() 首次查询 mcp_server_config 时会因新增列尚未迁移而报column does not exist
@DependsOn("databaseInitConfig")
public class McpClientManager {
@Autowired
@ -464,10 +468,20 @@ public class McpClientManager {
String serverUrl = config.getServerUrl().trim();
// 如果 URL /sse 结尾去掉后缀作为 baseUritransport 层会自动附加
String baseUri = serverUrl.replaceAll("/sse/?$", "");
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder(baseUri).build();
HttpClientSseClientTransport.Builder transportBuilder = HttpClientSseClientTransport.builder(baseUri);
// 注入自定义请求头 Authorization / X-API-Key适配需要鉴权的远程 MCP 服务
Map<String, Object> headers = config.getHeaders();
if (headers != null && !headers.isEmpty()) {
transportBuilder.customizeRequest(req -> headers.forEach((k, v) -> {
if (v != null) {
req.header(k, v.toString());
}
}));
}
HttpClientSseClientTransport transport = transportBuilder.build();
client = buildSyncClient(transport);
log.info("正在初始化 SSE MCP 客户端: id={}, url={}", config.getId(), baseUri);
log.info("正在初始化 SSE MCP 客户端: id={}, url={}, headers={}", config.getId(), baseUri, headers != null ? headers.keySet() : "无");
} else if ("stdio".equals(config.getTransportType())) {
// 创建 stdio 传输层启动本地 MCP Server 进程

6
src/main/java/com/wok/supportbot/entity/McpServerConfig.java

@ -71,6 +71,12 @@ public class McpServerConfig implements Serializable {
@TableField(value = "env_vars", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> envVars;
/**
* 自定义请求头JSONB键值对形式 SSE/HTTP 模式生效用于鉴权等场景
*/
@TableField(value = "headers", typeHandler = PostgresJsonTypeHandler.class)
private Map<String, Object> headers;
/**
* 描述说明
*/

102
src/main/java/com/wok/supportbot/service/McpServerConfigService.java

@ -98,6 +98,7 @@ public class McpServerConfigService {
mcpServerConfigMapper.insert(config);
// JSONB 字段显式持久化避免 MyBatis Plus typeHandler 不触发的问题
persistEnvVars(config.getId(), config.getEnvVars());
persistHeaders(config.getId(), config.getHeaders());
log.info("新建 MCP Server 配置: name={}, transportType={}", config.getName(), config.getTransportType());
return formatConfig(mcpServerConfigMapper.selectById(config.getId()));
@ -140,6 +141,10 @@ public class McpServerConfigService {
// null 或空 Map 均执行持久化清空语义 LambdaUpdateWrapper 行为一致
persistEnvVars(id, config.getEnvVars());
// headers 同为 JSONB显式持久化敏感头未修改值含 ****时保留数据库原值避免用脱敏值覆盖真实密钥
Map<String, Object> mergedHeaders = resolveHeadersForUpdate(existing.getHeaders(), config.getHeaders());
persistHeaders(id, mergedHeaders);
log.info("更新 MCP Server 配置: id={}", id);
return formatConfig(mcpServerConfigMapper.selectById(id));
}
@ -282,6 +287,7 @@ public class McpServerConfigService {
formatted.put("command", config.getCommand());
formatted.put("args", config.getArgs());
formatted.put("env_vars", config.getEnvVars());
formatted.put("headers", maskHeaders(config.getHeaders()));
formatted.put("description", config.getDescription());
formatted.put("is_active", config.getIsActive());
formatted.put("create_time", config.getCreateTime());
@ -317,4 +323,100 @@ public class McpServerConfigService {
throw new RuntimeException("持久化环境变量失败: " + e.getMessage(), e);
}
}
/**
* 敏感请求头名称集合统一小写这些头的值在返回前端时需脱敏
*/
private static final Set<String> SENSITIVE_HEADERS = Set.of(
"authorization", "x-api-key", "api-key", "apikey", "token",
"cookie", "set-cookie", "x-auth-token", "secret", "password");
/**
* 显式持久化 headers JSONB 字段 persistEnvVars 同理规避 MyBatis Plus typeHandler 不触发的问题
*
* @param id 配置ID
* @param headers 请求头 Map
*/
private void persistHeaders(Long id, Map<String, Object> headers) {
if (id == null) {
return;
}
try {
String json;
if (headers == null || headers.isEmpty()) {
json = "{}";
} else {
json = OBJECT_MAPPER.writeValueAsString(headers);
}
jdbcTemplate.update(
"UPDATE mcp_server_config SET headers = ?::jsonb WHERE id = ?",
json, id);
log.debug("持久化 headers: id={}, headers={}", id, json);
} catch (Exception e) {
log.error("持久化 headers 失败: id={}, error={}", id, e.getMessage());
throw new RuntimeException("持久化请求头失败: " + e.getMessage(), e);
}
}
/**
* 脱敏请求头敏感头名 AuthorizationX-API-Key的值做前4+****+后4 脱敏非敏感原样返回
*
* @param headers 原始请求头 Map
* @return 脱敏后的请求头 Map保持原 key 顺序
*/
private Map<String, Object> maskHeaders(Map<String, Object> headers) {
if (headers == null || headers.isEmpty()) {
return headers;
}
Map<String, Object> masked = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : headers.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (isSensitiveHeader(key) && value != null) {
masked.put(key, AiModelConfigService.maskApiKey(value.toString()));
} else {
masked.put(key, value);
}
}
return masked;
}
/**
* 合并更新时的 headers敏感头且值含脱敏标记****即前端未改动时保留数据库原值否则用新值
* incoming null 时视为清空所有请求头
*
* @param existing 数据库已有的请求头 Map
* @param incoming 前端提交的请求头 Map
* @return 合并后的请求头 Map
*/
private Map<String, Object> resolveHeadersForUpdate(Map<String, Object> existing, Map<String, Object> incoming) {
if (incoming == null) {
return new LinkedHashMap<>();
}
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : incoming.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (isSensitiveHeader(key) && value != null && value.toString().contains("****")
&& existing != null && existing.containsKey(key)) {
result.put(key, existing.get(key));
} else {
result.put(key, value);
}
}
return result;
}
/**
* 判断请求头名是否为敏感头大小写不敏感
*
* @param name 请求头名
* @return true 表示敏感
*/
private boolean isSensitiveHeader(String name) {
if (name == null) {
return false;
}
return SENSITIVE_HEADERS.contains(name.trim().toLowerCase());
}
}

2
src/main/resources/init-database.sql

@ -279,6 +279,7 @@ CREATE TABLE IF NOT EXISTS mcp_server_config (
command VARCHAR(500),
args VARCHAR(1000),
env_vars JSONB DEFAULT '{}' NOT NULL,
headers JSONB DEFAULT '{}' NOT NULL,
description VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE NOT NULL,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -296,6 +297,7 @@ COMMENT ON COLUMN mcp_server_config.server_url IS 'SSE 模式的 MCP Server URL'
COMMENT ON COLUMN mcp_server_config.command IS 'stdio 模式的启动命令';
COMMENT ON COLUMN mcp_server_config.args IS 'stdio 模式的命令参数(多个用逗号分隔)';
COMMENT ON COLUMN mcp_server_config.env_vars IS '环境变量(JSONB 键值对)';
COMMENT ON COLUMN mcp_server_config.headers IS '自定义请求头(JSONB 键值对,仅 SSE/HTTP 模式生效,用于鉴权)';
COMMENT ON COLUMN mcp_server_config.description IS '描述说明';
COMMENT ON COLUMN mcp_server_config.is_active IS '是否启用';
COMMENT ON COLUMN mcp_server_config.create_time IS '创建时间';

Loading…
Cancel
Save