sciverse-mcp-server 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { type Config } from "./config.js";
3
+ import { type AuthOptions } from "./http-auth.js";
3
4
  export interface HttpServerHandle {
4
5
  port: number;
5
6
  close: () => Promise<void>;
@@ -7,4 +8,5 @@ export interface HttpServerHandle {
7
8
  export declare function startHttpServer(config: Config, options?: {
8
9
  port?: number;
9
10
  host?: string;
11
+ auth?: AuthOptions;
10
12
  }): Promise<HttpServerHandle>;
package/dist/cli-http.js CHANGED
@@ -6,16 +6,21 @@
6
6
  // 2. 每个 MCP session 独立一份 Server + Transport,避免不同会话间状态串扰;
7
7
  // session id 由 SDK 在 initialize 阶段通过 crypto.randomUUID() 分配,
8
8
  // 存到内存 Map,容器重启即清空。Phase 1 单副本部署可接受。
9
- // 3. 边缘信任模型:本进程不校验 SCP-HUB-API-KEY,由 Ingress IP 白名单兜底;
10
- // Phase 1.5 再补 header 校验。
11
- // 4. /healthz 返回 200 纯文本,供 K8s readiness/liveness probe 使用。
9
+ // 3. 鉴权双通道(Phase 1.5,http-auth.ts):Authorization: Bearer 透传(channel
10
+ // "remote")或来源 IP ∈ 可信 IP 白名单走内置 token(channel 沿用配置);
11
+ // 两者皆无 → 401。session 与首次判定的凭据绑定,后续请求不一致同样 401。
12
+ // XFF 仅在 TCP 对端命中信任代理网段时采信(默认永不),防直连伪造。
13
+ // 4. /healthz 返回 200 纯文本,供 K8s readiness/liveness probe 使用(不鉴权、不记日志)。
12
14
  // 5. 配置错误 (loadConfig 抛 ConfigError) 时 stderr 输出并 exit(2),
13
15
  // 与 cli.ts 行为一致。
16
+ // 6. 每个 /mcp 请求向 stdout 写一行结构化日志(SLS 经 aliyun_logs_app_logs=stdout
17
+ // 采集);恒不落 token。
14
18
  import { randomUUID } from "node:crypto";
15
19
  import { createServer as createHttpServer } from "node:http";
16
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
21
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
18
22
  import { ConfigError, loadConfig } from "./config.js";
23
+ import { loadAuthOptions, resolveLane } from "./http-auth.js";
19
24
  import { createServer } from "./server.js";
20
25
  const DEFAULT_PORT = 8080;
21
26
  const MCP_PATH = "/mcp";
@@ -50,27 +55,62 @@ function writeJsonRpcError(res, status, message) {
50
55
  id: null,
51
56
  }));
52
57
  }
58
+ // 从 JSON-RPC payload 提取方法名(batch 用 + 连接),仅用于日志。
59
+ function rpcMethodOf(body) {
60
+ if (Array.isArray(body)) {
61
+ const names = body
62
+ .map((m) => (m && typeof m === "object" ? m.method : undefined))
63
+ .filter((s) => typeof s === "string");
64
+ return names.length > 0 ? names.join("+") : "batch";
65
+ }
66
+ if (body && typeof body === "object") {
67
+ const method = body.method;
68
+ if (typeof method === "string")
69
+ return method;
70
+ }
71
+ return "-";
72
+ }
73
+ function writeUnauthorized(res) {
74
+ res.setHeader("www-authenticate", "Bearer");
75
+ writeJsonRpcError(res, 401, "Unauthorized: provide `Authorization: Bearer <sciverse token>` (get one at https://sciverse.space)");
76
+ }
53
77
  // 启动 HTTP server,返回句柄。导出供测试复用,避免 spawn 子进程。
54
78
  export async function startHttpServer(config, options = {}) {
55
79
  const port = options.port ?? DEFAULT_PORT;
56
80
  const host = options.host ?? "0.0.0.0";
57
- // session id → { transport, server } 映射。Phase 1 进程内 Map 即可。
81
+ const auth = options.auth ?? loadAuthOptions();
82
+ // session id → transport + 首次判定的凭据(lane/token 绑定,防 session id 被盗用)。
58
83
  const sessions = new Map();
59
- const handleMcp = async (req, res) => {
84
+ const handleMcp = async (req, res, meta) => {
60
85
  const method = req.method ?? "GET";
61
86
  const sessionId = req.headers["mcp-session-id"];
62
87
  const sessionIdStr = Array.isArray(sessionId) ? sessionId[0] : sessionId;
88
+ if (sessionIdStr)
89
+ meta.session = sessionIdStr.slice(0, 8);
90
+ // 鉴权判定先于一切处理;不通过的请求不进 MCP 协议层。
91
+ const decision = resolveLane(req, config, auth);
92
+ if (!decision) {
93
+ writeUnauthorized(res);
94
+ return;
95
+ }
96
+ meta.lane = decision.lane;
97
+ // 复用 session 时校验凭据与首次判定一致;不一致按未授权处理(不泄露 session 存在性)。
98
+ const boundEntry = sessionIdStr ? sessions.get(sessionIdStr) : undefined;
99
+ if (boundEntry && (boundEntry.lane !== decision.lane || boundEntry.token !== decision.token)) {
100
+ writeUnauthorized(res);
101
+ return;
102
+ }
63
103
  // POST:初始化 or 后续 JSON-RPC 调用
64
104
  if (method === "POST") {
65
105
  const raw = await readBody(req);
66
106
  const body = parseJsonBody(raw);
107
+ meta.rpc = rpcMethodOf(body);
67
108
  // 已有 session:复用 transport
68
- if (sessionIdStr && sessions.has(sessionIdStr)) {
69
- const entry = sessions.get(sessionIdStr);
70
- await entry.transport.handleRequest(req, res, body);
109
+ if (boundEntry) {
110
+ await boundEntry.transport.handleRequest(req, res, body);
71
111
  return;
72
112
  }
73
- // 无 session + 是 initialize:新建 transport + Server
113
+ // 无 session + 是 initialize:新建 transport + Server(携带本 session 的凭据)
74
114
  if (!sessionIdStr && isInitializeRequest(body)) {
75
115
  const transport = new StreamableHTTPServerTransport({
76
116
  sessionIdGenerator: () => randomUUID(),
@@ -78,6 +118,8 @@ export async function startHttpServer(config, options = {}) {
78
118
  // 在 initialize 完成时把 transport 注册进表;规避请求先于 sessionId 落表的竞态。
79
119
  sessions.set(sid, {
80
120
  transport,
121
+ lane: decision.lane,
122
+ token: decision.token,
81
123
  close: async () => {
82
124
  await transport.close();
83
125
  await server.close();
@@ -91,7 +133,8 @@ export async function startHttpServer(config, options = {}) {
91
133
  sessions.delete(sid);
92
134
  }
93
135
  };
94
- const server = createServer(config);
136
+ // 每 session 一份 config:token/channel 来自通道判定,其余沿用进程配置。
137
+ const server = createServer({ ...config, token: decision.token, channel: decision.channel });
95
138
  await server.connect(transport);
96
139
  await transport.handleRequest(req, res, body);
97
140
  return;
@@ -102,14 +145,13 @@ export async function startHttpServer(config, options = {}) {
102
145
  }
103
146
  // GET (SSE 长轮询) / DELETE (session 终止):必须带 session id
104
147
  if (method === "GET" || method === "DELETE") {
105
- if (!sessionIdStr || !sessions.has(sessionIdStr)) {
148
+ if (!boundEntry) {
106
149
  res.statusCode = 400;
107
150
  res.setHeader("content-type", "text/plain");
108
151
  res.end("Invalid or missing session ID");
109
152
  return;
110
153
  }
111
- const entry = sessions.get(sessionIdStr);
112
- await entry.transport.handleRequest(req, res);
154
+ await boundEntry.transport.handleRequest(req, res);
113
155
  return;
114
156
  }
115
157
  res.statusCode = 405;
@@ -126,7 +168,15 @@ export async function startHttpServer(config, options = {}) {
126
168
  return;
127
169
  }
128
170
  if (url === MCP_PATH || url.startsWith(`${MCP_PATH}?`) || url.startsWith(`${MCP_PATH}/`)) {
129
- handleMcp(req, res).catch((err) => {
171
+ const startedAt = Date.now();
172
+ const meta = { lane: "anon", rpc: "-", session: "-" };
173
+ // "close" 同时覆盖正常结束与客户端中断;一行 key=value 结构化日志(恒不含 token)。
174
+ res.once("close", () => {
175
+ process.stdout.write(`[sciverse-mcp] req method=${req.method ?? "-"} path=${MCP_PATH} rpc=${meta.rpc} ` +
176
+ `lane=${meta.lane} session=${meta.session} status=${res.statusCode} ` +
177
+ `dur_ms=${Date.now() - startedAt}\n`);
178
+ });
179
+ handleMcp(req, res, meta).catch((err) => {
130
180
  process.stderr.write(`[sciverse-mcp] handleRequest error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
131
181
  if (!res.headersSent) {
132
182
  writeJsonRpcError(res, 500, "Internal server error");
@@ -186,8 +236,14 @@ async function main() {
186
236
  process.stderr.write(`[sciverse-mcp] invalid PORT: ${portEnv}\n`);
187
237
  process.exit(2);
188
238
  }
189
- const handle = await startHttpServer(config, { port });
190
- process.stderr.write(`[sciverse-mcp] listening on :${handle.port}${MCP_PATH}\n`);
239
+ const auth = loadAuthOptions();
240
+ if (auth.trustedIps.size === 0) {
241
+ process.stderr.write("[sciverse-mcp] SCIVERSE_MCP_TRUSTED_IPS 未配置:可信 IP 通道关闭,所有请求都需要 Authorization\n");
242
+ }
243
+ const handle = await startHttpServer(config, { port, auth });
244
+ const proxyCount = auth.trustedProxies.v4.length + auth.trustedProxies.exact.size;
245
+ process.stderr.write(`[sciverse-mcp] listening on :${handle.port}${MCP_PATH} ` +
246
+ `(trusted_ips=${auth.trustedIps.size} trusted_proxies=${proxyCount} hops=${auth.trustedHops})\n`);
191
247
  // 收到信号时优雅退出
192
248
  const shutdown = async (signal) => {
193
249
  process.stderr.write(`[sciverse-mcp] received ${signal}, shutting down\n`);
@@ -1,4 +1,4 @@
1
- export declare const TOOLS_VERSION = "0.10.0";
1
+ export declare const TOOLS_VERSION = "0.11.0";
2
2
  export interface ToolDef {
3
3
  name: string;
4
4
  description: string;
@@ -1,6 +1,6 @@
1
1
  // Auto-generated by scripts/gen-tools.mjs. Do not edit.
2
2
  // Source of truth: openapi.yaml
3
- export const TOOLS_VERSION = "0.10.0";
3
+ export const TOOLS_VERSION = "0.11.0";
4
4
  export const TOOLS = [
5
5
  {
6
6
  "name": "search_papers",
@@ -0,0 +1,31 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Config } from "./config.js";
3
+ export type Lane = "trusted" | "direct";
4
+ export interface LaneDecision {
5
+ lane: Lane;
6
+ token: string;
7
+ channel: string;
8
+ }
9
+ /** 信任代理集合:IPv4 支持 CIDR;其余条目(IPv6 等)精确匹配。 */
10
+ export interface TrustedProxies {
11
+ v4: {
12
+ base: number;
13
+ bits: number;
14
+ }[];
15
+ exact: Set<string>;
16
+ }
17
+ export interface AuthOptions {
18
+ /** 可信来源 IP(精确匹配,已归一化)。空集 = trusted 通道关闭。 */
19
+ trustedIps: Set<string>;
20
+ /** 信任代理网段:TCP 对端命中才解析 XFF。空 = 转发头永不采信。 */
21
+ trustedProxies: TrustedProxies;
22
+ /** 解析 XFF 时取右起第几个条目(≥1);需与代理真实跳数一致。 */
23
+ trustedHops: number;
24
+ }
25
+ /** 解析逗号/空白分隔的代理列表;IPv4 条目可带 /bits,非法 bits 的条目降级为精确匹配。 */
26
+ export declare function parseTrustedProxies(raw: string | undefined): TrustedProxies;
27
+ /** 取参与鉴权判定的来源 IP;null = 信任链不完整(调用方应 401,fail-closed)。 */
28
+ export declare function clientIp(req: IncomingMessage, opts: AuthOptions): string | null;
29
+ export declare function loadAuthOptions(env?: NodeJS.ProcessEnv): AuthOptions;
30
+ /** 判定请求走哪条通道;null = 无法鉴权(调用方应返回 401)。 */
31
+ export declare function resolveLane(req: IncomingMessage, config: Config, opts: AuthOptions): LaneDecision | null;
@@ -0,0 +1,97 @@
1
+ const DIRECT_CHANNEL = "remote";
2
+ const DEFAULT_TRUSTED_HOPS = 1;
3
+ /** 统一小写;去掉 IPv4-mapped IPv6 前缀与 /32 后缀。 */
4
+ function normalizeIp(raw) {
5
+ let ip = raw.trim().toLowerCase();
6
+ if (ip.startsWith("::ffff:"))
7
+ ip = ip.slice("::ffff:".length);
8
+ if (ip.endsWith("/32"))
9
+ ip = ip.slice(0, -"/32".length);
10
+ return ip;
11
+ }
12
+ function ipv4ToInt(ip) {
13
+ const parts = ip.split(".");
14
+ if (parts.length !== 4)
15
+ return null;
16
+ let v = 0;
17
+ for (const p of parts) {
18
+ if (!/^\d{1,3}$/.test(p))
19
+ return null;
20
+ const n = Number(p);
21
+ if (n > 255)
22
+ return null;
23
+ v = ((v << 8) | n) >>> 0;
24
+ }
25
+ return v;
26
+ }
27
+ /** 解析逗号/空白分隔的代理列表;IPv4 条目可带 /bits,非法 bits 的条目降级为精确匹配。 */
28
+ export function parseTrustedProxies(raw) {
29
+ const out = { v4: [], exact: new Set() };
30
+ for (const entry of (raw ?? "").split(/[\s,]+/)) {
31
+ if (!entry.trim())
32
+ continue;
33
+ const [ipPart, bitsPart] = entry.trim().toLowerCase().split("/");
34
+ const ip = normalizeIp(ipPart);
35
+ const asV4 = ipv4ToInt(ip);
36
+ if (asV4 !== null) {
37
+ const bits = bitsPart === undefined ? 32 : Number.parseInt(bitsPart, 10);
38
+ if (Number.isFinite(bits) && bits >= 0 && bits <= 32) {
39
+ out.v4.push({ base: asV4, bits });
40
+ continue;
41
+ }
42
+ }
43
+ out.exact.add(ip);
44
+ }
45
+ return out;
46
+ }
47
+ function isTrustedProxy(ip, proxies) {
48
+ if (proxies.exact.has(ip))
49
+ return true;
50
+ const v4 = ipv4ToInt(ip);
51
+ if (v4 === null)
52
+ return false;
53
+ // bits=0(0.0.0.0/0)需特判:JS 位移量按 mod 32 取,>>> 32 等价 >>> 0。
54
+ return proxies.v4.some(({ base, bits }) => (bits === 0 ? true : ((v4 ^ base) >>> (32 - bits)) === 0));
55
+ }
56
+ /** 取参与鉴权判定的来源 IP;null = 信任链不完整(调用方应 401,fail-closed)。 */
57
+ export function clientIp(req, opts) {
58
+ const peer = req.socket.remoteAddress ? normalizeIp(req.socket.remoteAddress) : null;
59
+ if (!peer)
60
+ return null;
61
+ // 对端不是信任代理:XFF 一律不采信,直接用 socket 地址。
62
+ if (!isTrustedProxy(peer, opts.trustedProxies))
63
+ return peer;
64
+ const hops = Math.max(1, Math.floor(opts.trustedHops));
65
+ const header = req.headers["x-forwarded-for"];
66
+ const joined = Array.isArray(header) ? header.join(",") : (header ?? "");
67
+ const entries = joined
68
+ .split(",")
69
+ .map((s) => s.trim())
70
+ .filter((s) => s.length > 0);
71
+ if (entries.length < hops)
72
+ return null;
73
+ return normalizeIp(entries[entries.length - hops]);
74
+ }
75
+ export function loadAuthOptions(env = process.env) {
76
+ const trustedIps = new Set((env.SCIVERSE_MCP_TRUSTED_IPS ?? "")
77
+ .split(/[\s,]+/)
78
+ .map(normalizeIp)
79
+ .filter((s) => s.length > 0));
80
+ const trustedProxies = parseTrustedProxies(env.SCIVERSE_MCP_TRUSTED_PROXIES);
81
+ const hopsRaw = env.SCIVERSE_MCP_TRUSTED_PROXY_HOPS?.trim();
82
+ const parsed = hopsRaw ? Number.parseInt(hopsRaw, 10) : DEFAULT_TRUSTED_HOPS;
83
+ const trustedHops = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_TRUSTED_HOPS;
84
+ return { trustedIps, trustedProxies, trustedHops };
85
+ }
86
+ /** 判定请求走哪条通道;null = 无法鉴权(调用方应返回 401)。 */
87
+ export function resolveLane(req, config, opts) {
88
+ const m = req.headers.authorization?.match(/^Bearer\s+(.+)$/i);
89
+ if (m) {
90
+ return { lane: "direct", token: m[1].trim(), channel: DIRECT_CHANNEL };
91
+ }
92
+ const ip = clientIp(req, opts);
93
+ if (ip && opts.trustedIps.has(ip)) {
94
+ return { lane: "trusted", token: config.token, channel: config.channel };
95
+ }
96
+ return null;
97
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sciverse-mcp-server",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Sciverse MCP server — exposes academic paper retrieval (search_papers / semantic_search / read_content) to MCP-compatible coding agents (Claude Code, Cursor, Codex CLI, Windsurf, ...).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/opendatalab/Sciverse-Agent-Tools/tree/main/packages/mcp",