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 \
|
ssh -i ~/.ssh/zy_key \
|
||||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }} \
|
${{ 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 \
|
rsync -avz --delete \
|
||||||
|
|
@ -152,6 +152,29 @@ jobs:
|
||||||
server/app/ \
|
server/app/ \
|
||||||
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/zhuyuan/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: 🌐 同步前端内容到站点
|
- name: 🌐 同步前端内容到站点
|
||||||
run: |
|
run: |
|
||||||
TARGET="${{ github.event.inputs.deploy_target || 'preview' }}"
|
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
|
${{ secrets.ZY_SERVER_USER }}@${{ secrets.ZY_SERVER_HOST }}:/opt/zhuyuan/config/nginx/zhuyuan-sovereign.conf
|
||||||
|
|
||||||
- name: 📥 安装依赖 & 重启
|
- 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: |
|
run: |
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
REF_NAME="${{ github.ref_name }}"
|
REF_NAME="${{ github.ref_name }}"
|
||||||
|
|
@ -236,16 +266,43 @@ jobs:
|
||||||
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
# PM2 重启
|
# PM2 重启 · 主应用
|
||||||
pm2 delete zhuyuan-server 2>/dev/null || true
|
pm2 delete zhuyuan-server 2>/dev/null || true
|
||||||
pm2 start /opt/zhuyuan/config/pm2/ecosystem.config.js
|
pm2 start /opt/zhuyuan/config/pm2/ecosystem.config.js
|
||||||
pm2 save
|
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
|
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
|
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 \
|
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 进程管理
|
* PM2 配置 · AGE OS 进程管理
|
||||||
* 铸渊 · ICE-GL-ZY001
|
* 铸渊 · 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 = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
|
|
@ -10,7 +36,9 @@ module.exports = {
|
||||||
cwd: '/opt/age-os',
|
cwd: '/opt/age-os',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
MCP_PORT: 3100
|
MCP_PORT: 3100,
|
||||||
|
MCP_BIND_HOST: '127.0.0.1',
|
||||||
|
...envFromFile
|
||||||
},
|
},
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|
@ -24,7 +52,8 @@ module.exports = {
|
||||||
script: 'agents/scheduler.js',
|
script: 'agents/scheduler.js',
|
||||||
cwd: '/opt/age-os',
|
cwd: '/opt/age-os',
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production'
|
NODE_ENV: 'production',
|
||||||
|
...envFromFile
|
||||||
},
|
},
|
||||||
instances: 1,
|
instances: 1,
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ const COS_CONFIG = {
|
||||||
region: process.env.ZY_COS_REGION || 'ap-singapore',
|
region: process.env.ZY_COS_REGION || 'ap-singapore',
|
||||||
buckets: {
|
buckets: {
|
||||||
hot: process.env.COS_BUCKET_HOT || 'zy-core-bucket-1317346199',
|
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`;
|
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 请求
|
* 发起 COS HTTP 请求
|
||||||
*/
|
*/
|
||||||
|
|
@ -91,7 +155,7 @@ function cosRequest(bucketName, objectKey, method, body, contentType) {
|
||||||
// ─── 公开 API ───
|
// ─── 公开 API ───
|
||||||
|
|
||||||
async function write(bucket, key, content, contentType) {
|
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');
|
const result = await cosRequest(bucketName, key, 'PUT', content, contentType || 'text/markdown');
|
||||||
return {
|
return {
|
||||||
url: `https://${getBucketHost(bucketName)}/${key}`,
|
url: `https://${getBucketHost(bucketName)}/${key}`,
|
||||||
|
|
@ -100,7 +164,7 @@ async function write(bucket, key, content, contentType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function read(bucket, key) {
|
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');
|
const result = await cosRequest(bucketName, key, 'GET');
|
||||||
return {
|
return {
|
||||||
content: result.body,
|
content: result.body,
|
||||||
|
|
@ -110,13 +174,13 @@ async function read(bucket, key) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del(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');
|
await cosRequest(bucketName, key, 'DELETE');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function list(bucket, prefix, limit) {
|
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 host = getBucketHost(bucketName);
|
||||||
const queryStr = `prefix=${encodeURIComponent(prefix)}&max-keys=${limit || 100}`;
|
const queryStr = `prefix=${encodeURIComponent(prefix)}&max-keys=${limit || 100}`;
|
||||||
const result = await cosRequest(bucketName, `?${queryStr}`, 'GET');
|
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
|
* 签发: 铸渊 · ICE-GL-ZY001
|
||||||
* 版权: 国作登字-2026-A-00037559
|
* 版权: 国作登字-2026-A-00037559
|
||||||
*
|
*
|
||||||
* 统一暴露 27 个 MCP 工具,供网站 AI 交互后端和 Agent 调用。
|
* 统一暴露 MCP 工具,供网站 AI 交互后端和 Agent 调用。
|
||||||
* 不对外暴露 — 通过 3800 主服务网关转发访问。
|
* 外部访问通过 Nginx /api/mcp/ 反向代理,需 API Key 鉴权。
|
||||||
|
* 内部访问(127.0.0.1)免鉴权。
|
||||||
|
*
|
||||||
|
* 鉴权方式: Authorization: Bearer <ZHUYUAN_API_KEY>
|
||||||
|
* 免鉴权端点: /health(监控探针)
|
||||||
*
|
*
|
||||||
* 工具清单:
|
* 工具清单:
|
||||||
* 节点: createNode / updateNode / deleteNode / queryNodes / getNode
|
* 节点: createNode / updateNode / deleteNode / queryNodes / getNode
|
||||||
|
|
@ -28,6 +32,7 @@
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const cos = require('./cos');
|
const cos = require('./cos');
|
||||||
|
|
@ -81,6 +86,10 @@ const TOOLS = {
|
||||||
cosDelete: cosOps.cosDelete,
|
cosDelete: cosOps.cosDelete,
|
||||||
cosList: cosOps.cosList,
|
cosList: cosOps.cosList,
|
||||||
cosArchive: cosOps.cosArchive,
|
cosArchive: cosOps.cosArchive,
|
||||||
|
// 人格体COS隔离路径操作(限定 /{persona_id}/ 目录)
|
||||||
|
personaCosWrite: cosOps.personaCosWrite,
|
||||||
|
personaCosRead: cosOps.personaCosRead,
|
||||||
|
personaCosList: cosOps.personaCosList,
|
||||||
// 人格体操作 · S15
|
// 人格体操作 · S15
|
||||||
registerPersona: personaOps.registerPersona,
|
registerPersona: personaOps.registerPersona,
|
||||||
getPersona: personaOps.getPersona,
|
getPersona: personaOps.getPersona,
|
||||||
|
|
@ -128,8 +137,74 @@ const TOOLS = {
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.MCP_PORT || 3100;
|
const PORT = process.env.MCP_PORT || 3100;
|
||||||
|
|
||||||
|
// ─── API Key 配置 ───
|
||||||
|
const ZHUYUAN_API_KEY = process.env.ZHUYUAN_API_KEY || '';
|
||||||
|
|
||||||
app.use(express.json({ limit: '5mb' }));
|
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) => {
|
app.get('/health', async (_req, res) => {
|
||||||
const dbStatus = await db.checkConnection();
|
const dbStatus = await db.checkConnection();
|
||||||
|
|
@ -147,6 +222,10 @@ app.get('/health', async (_req, res) => {
|
||||||
status: dbStatus.connected ? 'alive' : 'degraded',
|
status: dbStatus.connected ? 'alive' : 'degraded',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
|
auth: {
|
||||||
|
api_key_configured: !!ZHUYUAN_API_KEY,
|
||||||
|
external_access: !!ZHUYUAN_API_KEY ? 'enabled' : 'disabled'
|
||||||
|
},
|
||||||
tools: Object.keys(TOOLS),
|
tools: Object.keys(TOOLS),
|
||||||
tools_count: Object.keys(TOOLS).length,
|
tools_count: Object.keys(TOOLS).length,
|
||||||
database: dbStatus,
|
database: dbStatus,
|
||||||
|
|
@ -346,6 +425,7 @@ function getCategoryForTool(name) {
|
||||||
if (['createNode','updateNode','deleteNode','queryNodes','getNode'].includes(name)) return 'node';
|
if (['createNode','updateNode','deleteNode','queryNodes','getNode'].includes(name)) return 'node';
|
||||||
if (['linkNodes','unlinkNodes','getRelations'].includes(name)) return 'relation';
|
if (['linkNodes','unlinkNodes','getRelations'].includes(name)) return 'relation';
|
||||||
if (['buildPath','scanStructure','classify'].includes(name)) return 'structure';
|
if (['buildPath','scanStructure','classify'].includes(name)) return 'structure';
|
||||||
|
if (name.startsWith('personaCos')) return 'persona-cos';
|
||||||
if (name.startsWith('cos')) return 'cos';
|
if (name.startsWith('cos')) return 'cos';
|
||||||
if (['registerPersona','getPersona','updatePersona','listPersonas',
|
if (['registerPersona','getPersona','updatePersona','listPersonas',
|
||||||
'getNotebook','updateNotebookPage','addMemoryAnchor','queryMemoryAnchors',
|
'getNotebook','updateNotebookPage','addMemoryAnchor','queryMemoryAnchors',
|
||||||
|
|
@ -377,9 +457,13 @@ async function logToolCall(tool, caller, status, durationMs, errorMsg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 启动 ───
|
// ─── 启动 ───
|
||||||
app.listen(PORT, '127.0.0.1', () => {
|
// 默认监听 127.0.0.1(安全默认值)
|
||||||
console.log(`[MCP] AGE OS MCP Server 启动 · 端口 ${PORT}`);
|
// 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] 工具数量: ${Object.keys(TOOLS).length}`);
|
||||||
|
console.log(`[MCP] API Key 鉴权: ${ZHUYUAN_API_KEY ? '已启用' : '未配置(仅内部访问)'}`);
|
||||||
console.log(`[MCP] 铸渊 · ICE-GL-ZY001 · 版权: 国作登字-2026-A-00037559`);
|
console.log(`[MCP] 铸渊 · ICE-GL-ZY001 · 版权: 国作登字-2026-A-00037559`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* ═══════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════
|
||||||
* MCP 工具 · COS 双桶操作(cosWrite / cosRead / cosDelete / cosList / cosArchive)
|
* MCP 工具 · COS 双桶操作(cosWrite / cosRead / cosDelete / cosList / cosArchive)
|
||||||
|
* + 人格体COS隔离路径操作(personaCosWrite / personaCosRead / personaCosList)
|
||||||
* ═══════════════════════════════════════════════════════════
|
* ═══════════════════════════════════════════════════════════
|
||||||
*
|
*
|
||||||
* 签发: 铸渊 · ICE-GL-ZY001
|
* 签发: 铸渊 · ICE-GL-ZY001
|
||||||
|
|
@ -36,4 +37,31 @@ async function cosArchive(input) {
|
||||||
return cos.archive(source_key, version_tag);
|
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;
|
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 流式 ───
|
# ─── AI 聊天 API 代理 (端口 3721) · SSE 流式 ───
|
||||||
location /api/chat {
|
location /api/chat {
|
||||||
proxy_pass http://127.0.0.1:3721/api/chat;
|
proxy_pass http://127.0.0.1:3721/api/chat;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue