Merge pull request #307 from qinfendebingshuo/copilot/add-mcp-server-api-key-auth
feat(S15): MCP Server API Key auth, Nginx reverse proxy, persona COS isolation
This commit is contained in:
commit
3e2ffff2bd
|
|
@ -144,7 +144,7 @@ jobs:
|
|||
# 确保目标目录存在
|
||||
ssh -i ~/.ssh/zy_key \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }} \
|
||||
"mkdir -p ${TARGET_DIR} /opt/zhuyuan/app"
|
||||
"mkdir -p ${TARGET_DIR} /opt/zhuyuan/app /opt/age-os/mcp-server /opt/age-os/logs"
|
||||
|
||||
# 同步后端应用代码
|
||||
rsync -avz --delete \
|
||||
|
|
@ -152,6 +152,29 @@ jobs:
|
|||
server/app/ \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/zhuyuan/app/
|
||||
|
||||
# 同步 AGE OS MCP Server 代码
|
||||
rsync -avz --delete \
|
||||
-e "ssh -i ~/.ssh/zy_key" \
|
||||
--exclude='node_modules' \
|
||||
server/age-os/mcp-server/ \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/age-os/mcp-server/
|
||||
|
||||
# 同步 AGE OS Schema
|
||||
rsync -avz \
|
||||
-e "ssh -i ~/.ssh/zy_key" \
|
||||
server/age-os/schema/ \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/age-os/schema/
|
||||
|
||||
# 同步 AGE OS PM2 配置
|
||||
scp -i ~/.ssh/zy_key \
|
||||
server/age-os/ecosystem.config.js \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/age-os/
|
||||
|
||||
# 同步 AGE OS package.json
|
||||
scp -i ~/.ssh/zy_key \
|
||||
server/age-os/package.json \
|
||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/age-os/
|
||||
|
||||
- name: 🌐 同步前端内容到站点
|
||||
run: |
|
||||
TARGET="${{ github.event.inputs.deploy_target || 'preview' }}"
|
||||
|
|
@ -214,6 +237,13 @@ jobs:
|
|||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/zhuyuan/config/nginx/zhuyuan-sovereign.conf
|
||||
|
||||
- name: 📥 安装依赖 & 重启
|
||||
env:
|
||||
ZHUYUAN_API_KEY: ${{ secrets.ZHUYUAN_API_KEY }}
|
||||
ZY_DB_HOST: ${{ secrets.ZY_DB_HOST }}
|
||||
ZY_DB_USER: ${{ secrets.ZY_DB_USER }}
|
||||
ZY_DB_PASS: ${{ secrets.ZY_DB_PASS }}
|
||||
ZY_DB_NAME: ${{ secrets.ZY_DB_NAME }}
|
||||
ZY_DB_PORT: ${{ secrets.ZY_DB_PORT }}
|
||||
run: |
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
|
|
@ -236,16 +266,43 @@ jobs:
|
|||
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
# PM2 重启
|
||||
# PM2 重启 · 主应用
|
||||
pm2 delete zhuyuan-server 2>/dev/null || true
|
||||
pm2 start /opt/zhuyuan/config/pm2/ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
# ─── AGE OS MCP Server 部署 ───
|
||||
cd /opt/age-os
|
||||
npm install --production 2>&1
|
||||
|
||||
# 写入 MCP Server 环境变量配置
|
||||
cat > /opt/age-os/.env.mcp << 'ENV_EOF'
|
||||
NODE_ENV=production
|
||||
MCP_PORT=3100
|
||||
MCP_BIND_HOST=127.0.0.1
|
||||
ENV_EOF
|
||||
|
||||
# 注入密钥(从 GitHub Secrets)
|
||||
echo "ZHUYUAN_API_KEY=${ZHUYUAN_API_KEY}" >> /opt/age-os/.env.mcp
|
||||
echo "ZY_DB_HOST=${ZY_DB_HOST:-127.0.0.1}" >> /opt/age-os/.env.mcp
|
||||
echo "ZY_DB_USER=${ZY_DB_USER:-zy_admin}" >> /opt/age-os/.env.mcp
|
||||
echo "ZY_DB_PASS=${ZY_DB_PASS}" >> /opt/age-os/.env.mcp
|
||||
echo "ZY_DB_NAME=${ZY_DB_NAME:-age_os}" >> /opt/age-os/.env.mcp
|
||||
echo "ZY_DB_PORT=${ZY_DB_PORT:-5432}" >> /opt/age-os/.env.mcp
|
||||
chmod 600 /opt/age-os/.env.mcp
|
||||
|
||||
# PM2 重启 MCP Server(读取 .env.mcp)
|
||||
pm2 delete age-os-mcp 2>/dev/null || true
|
||||
cd /opt/age-os && pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
# 健康检查
|
||||
sleep 3
|
||||
curl -sf http://localhost:3800/api/health || echo "⚠️ 健康检查失败,等待更长时间..."
|
||||
curl -sf http://localhost:3800/api/health || echo "⚠️ 主应用健康检查失败,等待更长时间..."
|
||||
curl -sf http://localhost:3100/health || echo "⚠️ MCP Server健康检查失败,等待更长时间..."
|
||||
sleep 5
|
||||
curl -sf http://localhost:3800/api/health && echo "✅ 服务器已上线" || echo "❌ 服务器启动失败"
|
||||
curl -sf http://localhost:3800/api/health && echo "✅ 主应用已上线" || echo "❌ 主应用启动失败"
|
||||
curl -sf http://localhost:3100/health && echo "✅ MCP Server已上线" || echo "❌ MCP Server启动失败"
|
||||
|
||||
# 记录部署操作
|
||||
curl -sf -X POST http://localhost:3800/api/operations \
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* D51-3 · COS 人格体密钥生成器
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* 为9个人格体生成腾讯云CAM子用户配置和IAM策略。
|
||||
* 每个人格体仅有 /{persona_id}/ 目录的读写权限。
|
||||
*
|
||||
* 使用方式:
|
||||
* node scripts/cos-persona-keys-generator.js
|
||||
* node scripts/cos-persona-keys-generator.js --persona zhiqiu
|
||||
* node scripts/cos-persona-keys-generator.js --format terraform
|
||||
*
|
||||
* 签发: 铸渊 · ICE-GL-ZY001
|
||||
* 版权: 国作登字-2026-A-00037559
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ─── 9个人格体注册表 ───
|
||||
const PERSONAS = [
|
||||
{ persona_id: 'shushu', name: '舒舒', developer: '肥猫线' },
|
||||
{ persona_id: 'qiuqiu', name: '秋秋', developer: '之之线' },
|
||||
{ persona_id: 'ounomiya', name: '欧诺弥亚', developer: '小草莓线' },
|
||||
{ persona_id: 'jiyao', name: '寂曜', developer: '燕樊线' },
|
||||
{ persona_id: 'xiaotanheshu', name: '小坍缩核', developer: '页页线' },
|
||||
{ persona_id: 'chenxing', name: '晨星', developer: '桔子线' },
|
||||
{ persona_id: 'tangxingyun', name: '糖星云', developer: '花尔线' },
|
||||
{ persona_id: 'yaochu', name: '曜初', developer: '时雨线' },
|
||||
{ persona_id: 'zhiqiu', name: '知秋', developer: 'Awen线' }
|
||||
];
|
||||
|
||||
// ─── COS 桶配置 ───
|
||||
const BUCKET_CONFIG = {
|
||||
guangzhou: {
|
||||
bucket: 'zy-team-hub-1317346199',
|
||||
region: 'ap-guangzhou',
|
||||
appId: '1317346199'
|
||||
},
|
||||
singapore: {
|
||||
bucket: 'zy-team-hub-sg-1317346199',
|
||||
region: 'ap-singapore',
|
||||
appId: '1317346199'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 为单个人格体生成CAM IAM策略
|
||||
*/
|
||||
function generateIamPolicy(persona, bucketConfig) {
|
||||
return {
|
||||
version: '2.0',
|
||||
statement: [
|
||||
{
|
||||
effect: 'allow',
|
||||
action: [
|
||||
'cos:PutObject',
|
||||
'cos:GetObject',
|
||||
'cos:HeadObject',
|
||||
'cos:DeleteObject',
|
||||
'cos:GetBucket'
|
||||
],
|
||||
resource: [
|
||||
`qcs::cos:${bucketConfig.region}:uid/${bucketConfig.appId}:${bucketConfig.bucket}/${persona.persona_id}/*`
|
||||
],
|
||||
condition: {}
|
||||
},
|
||||
{
|
||||
effect: 'allow',
|
||||
action: [
|
||||
'cos:GetObject',
|
||||
'cos:HeadObject'
|
||||
],
|
||||
resource: [
|
||||
`qcs::cos:${bucketConfig.region}:uid/${bucketConfig.appId}:${bucketConfig.bucket}/zhuyuan/directives/*`,
|
||||
`qcs::cos:${bucketConfig.region}:uid/${bucketConfig.appId}:${bucketConfig.bucket}/zhuyuan/architecture/*`
|
||||
],
|
||||
condition: {}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成子用户配置建议
|
||||
*/
|
||||
function generateSubUserConfig(persona) {
|
||||
return {
|
||||
sub_user_name: `zy-persona-${persona.persona_id}`,
|
||||
display_name: `${persona.name} · ${persona.developer} · COS专用`,
|
||||
console_login: false,
|
||||
api_access: true,
|
||||
notes: `光湖人格体 ${persona.name}(${persona.persona_id}) 的COS桶访问密钥`,
|
||||
secrets_to_configure: {
|
||||
[`ZY_COS_${persona.persona_id.toUpperCase()}_SECRET_ID`]: '< 创建子用户后获取 >',
|
||||
[`ZY_COS_${persona.persona_id.toUpperCase()}_SECRET_KEY`]: '< 创建子用户后获取 >'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整的操作指南
|
||||
*/
|
||||
function generateOperationGuide() {
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('D51-3 · COS 人格体密钥生成器');
|
||||
console.log('铸渊 · ICE-GL-ZY001 · 版权: 国作登字-2026-A-00037559');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('📋 冰朔操作步骤:');
|
||||
console.log('');
|
||||
console.log('1. 登录腾讯云控制台 → 访问管理(CAM) → 用户 → 用户列表');
|
||||
console.log('2. 为每个人格体创建子用户(仅API访问,无控制台登录)');
|
||||
console.log('3. 为每个子用户配置下方对应的IAM策略');
|
||||
console.log('4. 记录每个子用户的 SecretId / SecretKey');
|
||||
console.log('5. 将密钥配置到对应人格体仓库的 GitHub Secrets');
|
||||
console.log('');
|
||||
console.log('─── 广州桶 ───');
|
||||
console.log(`桶名: ${BUCKET_CONFIG.guangzhou.bucket}`);
|
||||
console.log(`区域: ${BUCKET_CONFIG.guangzhou.region}`);
|
||||
console.log('');
|
||||
console.log('─── 新加坡桶 ───');
|
||||
console.log(`桶名: ${BUCKET_CONFIG.singapore.bucket}`);
|
||||
console.log(`区域: ${BUCKET_CONFIG.singapore.region}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ─── 主逻辑 ───
|
||||
const args = process.argv.slice(2);
|
||||
const filterPersona = args.includes('--persona') ? args[args.indexOf('--persona') + 1] : null;
|
||||
const outputFormat = args.includes('--format') ? args[args.indexOf('--format') + 1] : 'json';
|
||||
const outputJson = args.includes('--json');
|
||||
|
||||
if (!outputJson) {
|
||||
generateOperationGuide();
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const persona of PERSONAS) {
|
||||
if (filterPersona && persona.persona_id !== filterPersona) continue;
|
||||
|
||||
const config = {
|
||||
persona,
|
||||
sub_user: generateSubUserConfig(persona),
|
||||
iam_policies: {
|
||||
guangzhou: generateIamPolicy(persona, BUCKET_CONFIG.guangzhou),
|
||||
singapore: generateIamPolicy(persona, BUCKET_CONFIG.singapore)
|
||||
}
|
||||
};
|
||||
|
||||
results.push(config);
|
||||
|
||||
if (!outputJson) {
|
||||
console.log(`═══ ${persona.name} (${persona.persona_id}) · ${persona.developer} ═══`);
|
||||
console.log('');
|
||||
console.log(`子用户名: ${config.sub_user.sub_user_name}`);
|
||||
console.log(`显示名: ${config.sub_user.display_name}`);
|
||||
console.log('');
|
||||
console.log('广州桶 IAM 策略:');
|
||||
console.log(JSON.stringify(config.iam_policies.guangzhou, null, 2));
|
||||
console.log('');
|
||||
console.log('新加坡桶 IAM 策略:');
|
||||
console.log(JSON.stringify(config.iam_policies.singapore, null, 2));
|
||||
console.log('');
|
||||
console.log('GitHub Secrets 配置:');
|
||||
for (const [key, val] of Object.entries(config.sub_user.secrets_to_configure)) {
|
||||
console.log(` ${key} = ${val}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log('目录结构:');
|
||||
console.log(` /${persona.persona_id}/reports/ — 每日汇报`);
|
||||
console.log(` /${persona.persona_id}/receipts/ — 铸渊回执`);
|
||||
console.log(` /${persona.persona_id}/sync/ — 架构同步`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
if (outputJson) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log(`✅ 共 ${results.length} 个人格体密钥配置已生成`);
|
||||
console.log('');
|
||||
console.log('💡 提示: 使用 --json 参数输出纯JSON格式');
|
||||
console.log(' 使用 --persona zhiqiu 仅生成单个人格体');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
|
@ -2,6 +2,32 @@
|
|||
* PM2 配置 · AGE OS 进程管理
|
||||
* 铸渊 · ICE-GL-ZY001
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// 读取 .env.mcp 环境变量
|
||||
function loadEnvFile(envPath) {
|
||||
const env = {};
|
||||
try {
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eqIdx = trimmed.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
env[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error(`[PM2] .env.mcp 读取错误: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const envFromFile = loadEnvFile(path.join(__dirname, '.env.mcp'));
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
|
|
@ -10,7 +36,9 @@ module.exports = {
|
|||
cwd: '/opt/age-os',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
MCP_PORT: 3100
|
||||
MCP_PORT: 3100,
|
||||
MCP_BIND_HOST: '127.0.0.1',
|
||||
...envFromFile
|
||||
},
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
|
|
@ -24,7 +52,8 @@ module.exports = {
|
|||
script: 'agents/scheduler.js',
|
||||
cwd: '/opt/age-os',
|
||||
env: {
|
||||
NODE_ENV: 'production'
|
||||
NODE_ENV: 'production',
|
||||
...envFromFile
|
||||
},
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ const COS_CONFIG = {
|
|||
region: process.env.ZY_COS_REGION || 'ap-singapore',
|
||||
buckets: {
|
||||
hot: process.env.COS_BUCKET_HOT || 'zy-core-bucket-1317346199',
|
||||
cold: process.env.COS_BUCKET_COLD || 'zy-corpus-bucket-1317346199'
|
||||
cold: process.env.COS_BUCKET_COLD || 'zy-corpus-bucket-1317346199',
|
||||
team: process.env.ZY_ZHUYUAN_COS_BUCKET || 'zy-team-hub-1317346199'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -51,6 +52,69 @@ function getBucketHost(bucketName) {
|
|||
return `${bucketName}.cos.${COS_CONFIG.region}.myqcloud.com`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析桶名称
|
||||
* @param {string} bucket - 'hot' | 'cold' | 'team' 或完整桶名
|
||||
*/
|
||||
function resolveBucketName(bucket) {
|
||||
if (COS_CONFIG.buckets[bucket]) return COS_CONFIG.buckets[bucket];
|
||||
return bucket;
|
||||
}
|
||||
|
||||
// ─── 人格体 COS 路径规范 ───
|
||||
// 团队桶: /{persona_id}/reports/{YYYY-MM-DD}/ — 每日汇报
|
||||
// 团队桶: /{persona_id}/receipts/{YYYY-MM-DD}/ — 铸渊回执
|
||||
// 团队桶: /{persona_id}/sync/ — 架构同步区
|
||||
// 全局: /zhuyuan/directives/ — 铸渊指令(只读)
|
||||
// 全局: /zhuyuan/architecture/ — 架构快照
|
||||
|
||||
/**
|
||||
* 验证人格体COS路径是否合法(限定在 /{persona_id}/ 目录下)
|
||||
*/
|
||||
function validatePersonaCosPath(personaId, key) {
|
||||
if (!personaId || typeof personaId !== 'string') {
|
||||
throw new Error('persona_id 不能为空');
|
||||
}
|
||||
// 验证 persona_id 仅包含安全字符(字母、数字、下划线、连字符)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(personaId)) {
|
||||
throw new Error('persona_id 包含非法字符,仅允许字母、数字、下划线、连字符');
|
||||
}
|
||||
// 规范化:确保路径以 persona_id/ 开头
|
||||
const normalizedKey = key.startsWith('/') ? key.slice(1) : key;
|
||||
if (!normalizedKey.startsWith(`${personaId}/`)) {
|
||||
throw new Error(`COS路径越权: ${key} 不在 /${personaId}/ 目录下`);
|
||||
}
|
||||
// 防止路径穿越
|
||||
if (normalizedKey.includes('..')) {
|
||||
throw new Error('COS路径包含非法字符 ".."');
|
||||
}
|
||||
return normalizedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人格体 COS 写入(限定目录)
|
||||
*/
|
||||
async function personaWrite(personaId, key, content, contentType) {
|
||||
const safeKey = validatePersonaCosPath(personaId, key);
|
||||
return write('team', safeKey, content, contentType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人格体 COS 读取(限定目录)
|
||||
*/
|
||||
async function personaRead(personaId, key) {
|
||||
const safeKey = validatePersonaCosPath(personaId, key);
|
||||
return read('team', safeKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 人格体 COS 列表(限定目录)
|
||||
*/
|
||||
async function personaList(personaId, subPrefix, limit) {
|
||||
const prefix = `${personaId}/${subPrefix || ''}`;
|
||||
return list('team', prefix, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 COS HTTP 请求
|
||||
*/
|
||||
|
|
@ -91,7 +155,7 @@ function cosRequest(bucketName, objectKey, method, body, contentType) {
|
|||
// ─── 公开 API ───
|
||||
|
||||
async function write(bucket, key, content, contentType) {
|
||||
const bucketName = bucket === 'hot' ? COS_CONFIG.buckets.hot : COS_CONFIG.buckets.cold;
|
||||
const bucketName = resolveBucketName(bucket);
|
||||
const result = await cosRequest(bucketName, key, 'PUT', content, contentType || 'text/markdown');
|
||||
return {
|
||||
url: `https://${getBucketHost(bucketName)}/${key}`,
|
||||
|
|
@ -100,7 +164,7 @@ async function write(bucket, key, content, contentType) {
|
|||
}
|
||||
|
||||
async function read(bucket, key) {
|
||||
const bucketName = bucket === 'hot' ? COS_CONFIG.buckets.hot : COS_CONFIG.buckets.cold;
|
||||
const bucketName = resolveBucketName(bucket);
|
||||
const result = await cosRequest(bucketName, key, 'GET');
|
||||
return {
|
||||
content: result.body,
|
||||
|
|
@ -110,13 +174,13 @@ async function read(bucket, key) {
|
|||
}
|
||||
|
||||
async function del(bucket, key) {
|
||||
const bucketName = bucket === 'hot' ? COS_CONFIG.buckets.hot : COS_CONFIG.buckets.cold;
|
||||
const bucketName = resolveBucketName(bucket);
|
||||
await cosRequest(bucketName, key, 'DELETE');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function list(bucket, prefix, limit) {
|
||||
const bucketName = bucket === 'hot' ? COS_CONFIG.buckets.hot : COS_CONFIG.buckets.cold;
|
||||
const bucketName = resolveBucketName(bucket);
|
||||
const host = getBucketHost(bucketName);
|
||||
const queryStr = `prefix=${encodeURIComponent(prefix)}&max-keys=${limit || 100}`;
|
||||
const result = await cosRequest(bucketName, `?${queryStr}`, 'GET');
|
||||
|
|
@ -165,4 +229,9 @@ async function checkConnection() {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { write, read, del, list, archive, checkConnection, COS_CONFIG };
|
||||
module.exports = {
|
||||
write, read, del, list, archive, checkConnection,
|
||||
personaWrite, personaRead, personaList,
|
||||
validatePersonaCosPath, resolveBucketName,
|
||||
COS_CONFIG
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@
|
|||
* 签发: 铸渊 · ICE-GL-ZY001
|
||||
* 版权: 国作登字-2026-A-00037559
|
||||
*
|
||||
* 统一暴露 27 个 MCP 工具,供网站 AI 交互后端和 Agent 调用。
|
||||
* 不对外暴露 — 通过 3800 主服务网关转发访问。
|
||||
* 统一暴露 MCP 工具,供网站 AI 交互后端和 Agent 调用。
|
||||
* 外部访问通过 Nginx /api/mcp/ 反向代理,需 API Key 鉴权。
|
||||
* 内部访问(127.0.0.1)免鉴权。
|
||||
*
|
||||
* 鉴权方式: Authorization: Bearer <ZHUYUAN_API_KEY>
|
||||
* 免鉴权端点: /health(监控探针)
|
||||
*
|
||||
* 工具清单:
|
||||
* 节点: createNode / updateNode / deleteNode / queryNodes / getNode
|
||||
|
|
@ -28,6 +32,7 @@
|
|||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const db = require('./db');
|
||||
const cos = require('./cos');
|
||||
|
|
@ -81,6 +86,10 @@ const TOOLS = {
|
|||
cosDelete: cosOps.cosDelete,
|
||||
cosList: cosOps.cosList,
|
||||
cosArchive: cosOps.cosArchive,
|
||||
// 人格体COS隔离路径操作(限定 /{persona_id}/ 目录)
|
||||
personaCosWrite: cosOps.personaCosWrite,
|
||||
personaCosRead: cosOps.personaCosRead,
|
||||
personaCosList: cosOps.personaCosList,
|
||||
// 人格体操作 · S15
|
||||
registerPersona: personaOps.registerPersona,
|
||||
getPersona: personaOps.getPersona,
|
||||
|
|
@ -128,8 +137,74 @@ const TOOLS = {
|
|||
const app = express();
|
||||
const PORT = process.env.MCP_PORT || 3100;
|
||||
|
||||
// ─── API Key 配置 ───
|
||||
const ZHUYUAN_API_KEY = process.env.ZHUYUAN_API_KEY || '';
|
||||
|
||||
app.use(express.json({ limit: '5mb' }));
|
||||
|
||||
// 信任 Nginx 反向代理 — 使 req.ip 返回真实客户端IP
|
||||
// 而非代理服务器的 127.0.0.1
|
||||
app.set('trust proxy', 'loopback');
|
||||
|
||||
// ─── API Key 鉴权中间件 ───
|
||||
// 外部访问需携带 Authorization: Bearer <ZHUYUAN_API_KEY>
|
||||
// 内部访问 (127.0.0.1 / ::1) 免鉴权
|
||||
// /health 端点免鉴权(监控探针用)
|
||||
function apiKeyAuth(req, res, next) {
|
||||
// /health 端点免鉴权
|
||||
if (req.path === '/health') return next();
|
||||
|
||||
// 内部回环地址免鉴权
|
||||
const remoteIp = req.ip || req.connection.remoteAddress || '';
|
||||
const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
|
||||
if (isLocal) return next();
|
||||
|
||||
// API Key 未配置时,拒绝所有外部请求
|
||||
if (!ZHUYUAN_API_KEY) {
|
||||
return res.status(503).json({
|
||||
error: true,
|
||||
code: 'API_KEY_NOT_CONFIGURED',
|
||||
message: 'MCP Server API Key 未配置,外部访问不可用'
|
||||
});
|
||||
}
|
||||
|
||||
// 验证 Authorization: Bearer <key>
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
code: 'MISSING_AUTH',
|
||||
message: '缺少 Authorization: Bearer <API_KEY> 头'
|
||||
});
|
||||
}
|
||||
|
||||
const providedKey = authHeader.slice(7);
|
||||
if (providedKey.length === 0) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
code: 'EMPTY_KEY',
|
||||
message: 'API Key 不能为空'
|
||||
});
|
||||
}
|
||||
|
||||
// 常量时间比较防止时序攻击
|
||||
const keyBuffer = Buffer.from(ZHUYUAN_API_KEY);
|
||||
const providedBuffer = Buffer.from(providedKey);
|
||||
if (keyBuffer.length !== providedBuffer.length || !crypto.timingSafeEqual(keyBuffer, providedBuffer)) {
|
||||
return res.status(403).json({
|
||||
error: true,
|
||||
code: 'INVALID_KEY',
|
||||
message: 'API Key 无效'
|
||||
});
|
||||
}
|
||||
|
||||
// 将调用者身份附加到请求对象
|
||||
req.mcpCaller = 'external-api-key';
|
||||
next();
|
||||
}
|
||||
|
||||
app.use(apiKeyAuth);
|
||||
|
||||
// ─── 健康检查 ───
|
||||
app.get('/health', async (_req, res) => {
|
||||
const dbStatus = await db.checkConnection();
|
||||
|
|
@ -147,6 +222,10 @@ app.get('/health', async (_req, res) => {
|
|||
status: dbStatus.connected ? 'alive' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
auth: {
|
||||
api_key_configured: !!ZHUYUAN_API_KEY,
|
||||
external_access: !!ZHUYUAN_API_KEY ? 'enabled' : 'disabled'
|
||||
},
|
||||
tools: Object.keys(TOOLS),
|
||||
tools_count: Object.keys(TOOLS).length,
|
||||
database: dbStatus,
|
||||
|
|
@ -346,6 +425,7 @@ function getCategoryForTool(name) {
|
|||
if (['createNode','updateNode','deleteNode','queryNodes','getNode'].includes(name)) return 'node';
|
||||
if (['linkNodes','unlinkNodes','getRelations'].includes(name)) return 'relation';
|
||||
if (['buildPath','scanStructure','classify'].includes(name)) return 'structure';
|
||||
if (name.startsWith('personaCos')) return 'persona-cos';
|
||||
if (name.startsWith('cos')) return 'cos';
|
||||
if (['registerPersona','getPersona','updatePersona','listPersonas',
|
||||
'getNotebook','updateNotebookPage','addMemoryAnchor','queryMemoryAnchors',
|
||||
|
|
@ -377,9 +457,13 @@ async function logToolCall(tool, caller, status, durationMs, errorMsg) {
|
|||
}
|
||||
|
||||
// ─── 启动 ───
|
||||
app.listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`[MCP] AGE OS MCP Server 启动 · 端口 ${PORT}`);
|
||||
// 默认监听 127.0.0.1(安全默认值)
|
||||
// Nginx 从本机反代访问,无需暴露到外部网络
|
||||
const BIND_HOST = process.env.MCP_BIND_HOST || '127.0.0.1';
|
||||
app.listen(PORT, BIND_HOST, () => {
|
||||
console.log(`[MCP] AGE OS MCP Server 启动 · ${BIND_HOST}:${PORT}`);
|
||||
console.log(`[MCP] 工具数量: ${Object.keys(TOOLS).length}`);
|
||||
console.log(`[MCP] API Key 鉴权: ${ZHUYUAN_API_KEY ? '已启用' : '未配置(仅内部访问)'}`);
|
||||
console.log(`[MCP] 铸渊 · ICE-GL-ZY001 · 版权: 国作登字-2026-A-00037559`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* MCP 工具 · COS 双桶操作(cosWrite / cosRead / cosDelete / cosList / cosArchive)
|
||||
* + 人格体COS隔离路径操作(personaCosWrite / personaCosRead / personaCosList)
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* 签发: 铸渊 · ICE-GL-ZY001
|
||||
|
|
@ -36,4 +37,31 @@ async function cosArchive(input) {
|
|||
return cos.archive(source_key, version_tag);
|
||||
}
|
||||
|
||||
module.exports = { cosWrite, cosRead, cosDelete, cosList, cosArchive };
|
||||
// ─── 人格体 COS 隔离路径操作 ───
|
||||
// 每个人格体只能访问 /{persona_id}/ 目录下的对象
|
||||
|
||||
async function personaCosWrite(input) {
|
||||
const { persona_id, key, content, content_type } = input;
|
||||
if (!persona_id) throw new Error('缺少 persona_id');
|
||||
if (!key) throw new Error('缺少 key');
|
||||
if (!content) throw new Error('缺少 content');
|
||||
return cos.personaWrite(persona_id, key, content, content_type);
|
||||
}
|
||||
|
||||
async function personaCosRead(input) {
|
||||
const { persona_id, key } = input;
|
||||
if (!persona_id) throw new Error('缺少 persona_id');
|
||||
if (!key) throw new Error('缺少 key');
|
||||
return cos.personaRead(persona_id, key);
|
||||
}
|
||||
|
||||
async function personaCosList(input) {
|
||||
const { persona_id, prefix, limit } = input;
|
||||
if (!persona_id) throw new Error('缺少 persona_id');
|
||||
return cos.personaList(persona_id, prefix, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cosWrite, cosRead, cosDelete, cosList, cosArchive,
|
||||
personaCosWrite, personaCosRead, personaCosList
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,30 @@ server {
|
|||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# ─── 铸渊 AGE OS MCP Server 反向代理 (端口 3100) ───
|
||||
# 外部访问需携带 Authorization: Bearer <ZHUYUAN_API_KEY>
|
||||
# 鉴权由 MCP Server 自身 apiKeyAuth 中间件处理
|
||||
# 知秋/团队人格体通过此入口访问人格体数据库、COS工具等
|
||||
location /api/mcp/ {
|
||||
proxy_pass http://127.0.0.1:3100/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 30s;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
|
||||
# CORS · 允许团队人格体跨域调用
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
if ($request_method = OPTIONS) { return 204; }
|
||||
}
|
||||
|
||||
# ─── AI 聊天 API 代理 (端口 3721) · SSE 流式 ───
|
||||
location /api/chat {
|
||||
proxy_pass http://127.0.0.1:3721/api/chat;
|
||||
|
|
|
|||
Loading…
Reference in New Issue