sciverse-mcp-server 0.5.3 → 0.7.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.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { type Config } from "./config.js";
3
+ export interface HttpServerHandle {
4
+ port: number;
5
+ close: () => Promise<void>;
6
+ }
7
+ export declare function startHttpServer(config: Config, options?: {
8
+ port?: number;
9
+ host?: string;
10
+ }): Promise<HttpServerHandle>;
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // 入口:以 Streamable-HTTP 形态启动 MCP server,供 SCP Hub 等公网调用方使用。
3
+ //
4
+ // 设计要点:
5
+ // 1. 与 stdio 入口 (cli.ts) 共享 createServer(config),业务逻辑零改动;transport 解耦。
6
+ // 2. 每个 MCP session 独立一份 Server + Transport,避免不同会话间状态串扰;
7
+ // session id 由 SDK 在 initialize 阶段通过 crypto.randomUUID() 分配,
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 使用。
12
+ // 5. 配置错误 (loadConfig 抛 ConfigError) 时 stderr 输出并 exit(2),
13
+ // 与 cli.ts 行为一致。
14
+ import { randomUUID } from "node:crypto";
15
+ import { createServer as createHttpServer } from "node:http";
16
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
18
+ import { ConfigError, loadConfig } from "./config.js";
19
+ import { createServer } from "./server.js";
20
+ const DEFAULT_PORT = 8080;
21
+ const MCP_PATH = "/mcp";
22
+ const HEALTHZ_PATH = "/healthz";
23
+ // 读完整请求体到 Buffer;遇到客户端断开等错误会 reject。
24
+ function readBody(req) {
25
+ return new Promise((resolve, reject) => {
26
+ const chunks = [];
27
+ req.on("data", (chunk) => chunks.push(chunk));
28
+ req.on("end", () => resolve(Buffer.concat(chunks)));
29
+ req.on("error", reject);
30
+ });
31
+ }
32
+ // 把 raw body 解析为 JSON(POST /mcp 必为 JSON-RPC payload);空 body 返回 undefined。
33
+ function parseJsonBody(buf) {
34
+ if (buf.length === 0)
35
+ return undefined;
36
+ try {
37
+ return JSON.parse(buf.toString("utf8"));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ // 用 405 + JSON-RPC error 拒绝非 POST /mcp 之外的请求里 transport 不接受的方法。
44
+ function writeJsonRpcError(res, status, message) {
45
+ res.statusCode = status;
46
+ res.setHeader("content-type", "application/json");
47
+ res.end(JSON.stringify({
48
+ jsonrpc: "2.0",
49
+ error: { code: -32000, message },
50
+ id: null,
51
+ }));
52
+ }
53
+ // 启动 HTTP server,返回句柄。导出供测试复用,避免 spawn 子进程。
54
+ export async function startHttpServer(config, options = {}) {
55
+ const port = options.port ?? DEFAULT_PORT;
56
+ const host = options.host ?? "0.0.0.0";
57
+ // session id → { transport, server } 映射。Phase 1 进程内 Map 即可。
58
+ const sessions = new Map();
59
+ const handleMcp = async (req, res) => {
60
+ const method = req.method ?? "GET";
61
+ const sessionId = req.headers["mcp-session-id"];
62
+ const sessionIdStr = Array.isArray(sessionId) ? sessionId[0] : sessionId;
63
+ // POST:初始化 or 后续 JSON-RPC 调用
64
+ if (method === "POST") {
65
+ const raw = await readBody(req);
66
+ const body = parseJsonBody(raw);
67
+ // 已有 session:复用 transport
68
+ if (sessionIdStr && sessions.has(sessionIdStr)) {
69
+ const entry = sessions.get(sessionIdStr);
70
+ await entry.transport.handleRequest(req, res, body);
71
+ return;
72
+ }
73
+ // 无 session + 是 initialize:新建 transport + Server
74
+ if (!sessionIdStr && isInitializeRequest(body)) {
75
+ const transport = new StreamableHTTPServerTransport({
76
+ sessionIdGenerator: () => randomUUID(),
77
+ onsessioninitialized: (sid) => {
78
+ // 在 initialize 完成时把 transport 注册进表;规避请求先于 sessionId 落表的竞态。
79
+ sessions.set(sid, {
80
+ transport,
81
+ close: async () => {
82
+ await transport.close();
83
+ await server.close();
84
+ },
85
+ });
86
+ },
87
+ });
88
+ transport.onclose = () => {
89
+ const sid = transport.sessionId;
90
+ if (sid && sessions.has(sid)) {
91
+ sessions.delete(sid);
92
+ }
93
+ };
94
+ const server = createServer(config);
95
+ await server.connect(transport);
96
+ await transport.handleRequest(req, res, body);
97
+ return;
98
+ }
99
+ // 既无 session 又不是 initialize:按 SDK 规范返 400
100
+ writeJsonRpcError(res, 400, "Bad Request: No valid session ID provided");
101
+ return;
102
+ }
103
+ // GET (SSE 长轮询) / DELETE (session 终止):必须带 session id
104
+ if (method === "GET" || method === "DELETE") {
105
+ if (!sessionIdStr || !sessions.has(sessionIdStr)) {
106
+ res.statusCode = 400;
107
+ res.setHeader("content-type", "text/plain");
108
+ res.end("Invalid or missing session ID");
109
+ return;
110
+ }
111
+ const entry = sessions.get(sessionIdStr);
112
+ await entry.transport.handleRequest(req, res);
113
+ return;
114
+ }
115
+ res.statusCode = 405;
116
+ res.setHeader("allow", "POST, GET, DELETE");
117
+ res.end();
118
+ };
119
+ const httpServer = createHttpServer((req, res) => {
120
+ const url = req.url ?? "/";
121
+ // K8s probe
122
+ if (url === HEALTHZ_PATH || url.startsWith(`${HEALTHZ_PATH}?`)) {
123
+ res.statusCode = 200;
124
+ res.setHeader("content-type", "text/plain; charset=utf-8");
125
+ res.end("ok");
126
+ return;
127
+ }
128
+ if (url === MCP_PATH || url.startsWith(`${MCP_PATH}?`) || url.startsWith(`${MCP_PATH}/`)) {
129
+ handleMcp(req, res).catch((err) => {
130
+ process.stderr.write(`[sciverse-mcp] handleRequest error: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
131
+ if (!res.headersSent) {
132
+ writeJsonRpcError(res, 500, "Internal server error");
133
+ }
134
+ else {
135
+ res.end();
136
+ }
137
+ });
138
+ return;
139
+ }
140
+ res.statusCode = 404;
141
+ res.setHeader("content-type", "text/plain");
142
+ res.end("Not Found");
143
+ });
144
+ await new Promise((resolve, reject) => {
145
+ httpServer.once("error", reject);
146
+ httpServer.listen(port, host, () => {
147
+ httpServer.off("error", reject);
148
+ resolve();
149
+ });
150
+ });
151
+ const address = httpServer.address();
152
+ const actualPort = typeof address === "object" && address ? address.port : port;
153
+ return {
154
+ port: actualPort,
155
+ close: async () => {
156
+ // 先关 HTTP server(停止接受新连接),再清理所有活动 session。
157
+ await new Promise((resolve, reject) => {
158
+ httpServer.close((err) => (err ? reject(err) : resolve()));
159
+ });
160
+ const entries = Array.from(sessions.values());
161
+ sessions.clear();
162
+ await Promise.all(entries.map((e) => e.close().catch(() => undefined)));
163
+ },
164
+ };
165
+ }
166
+ async function main() {
167
+ // HTTP 入口默认 channel = "scp"(区分 stdio 入口的 "mcp"),用于下游 X-Sciverse-Source 归因。
168
+ // 显式设了 SCIVERSE_MCP_CHANNEL 则不覆盖(允许部署侧自定义)。
169
+ if (!process.env.SCIVERSE_MCP_CHANNEL?.trim()) {
170
+ process.env.SCIVERSE_MCP_CHANNEL = "scp";
171
+ }
172
+ let config;
173
+ try {
174
+ config = loadConfig();
175
+ }
176
+ catch (err) {
177
+ if (err instanceof ConfigError) {
178
+ process.stderr.write(`[sciverse-mcp] ${err.message}\n`);
179
+ process.exit(2);
180
+ }
181
+ throw err;
182
+ }
183
+ const portEnv = process.env.PORT;
184
+ const port = portEnv ? Number.parseInt(portEnv, 10) : DEFAULT_PORT;
185
+ if (!Number.isFinite(port) || port < 0 || port > 65535) {
186
+ process.stderr.write(`[sciverse-mcp] invalid PORT: ${portEnv}\n`);
187
+ process.exit(2);
188
+ }
189
+ const handle = await startHttpServer(config, { port });
190
+ process.stderr.write(`[sciverse-mcp] listening on :${handle.port}${MCP_PATH}\n`);
191
+ // 收到信号时优雅退出
192
+ const shutdown = async (signal) => {
193
+ process.stderr.write(`[sciverse-mcp] received ${signal}, shutting down\n`);
194
+ try {
195
+ await handle.close();
196
+ }
197
+ finally {
198
+ process.exit(0);
199
+ }
200
+ };
201
+ process.on("SIGINT", () => void shutdown("SIGINT"));
202
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
203
+ }
204
+ // 只有作为入口执行时才 main();被 import(如测试)时不自动起进程。
205
+ const isEntrypoint = typeof process.argv[1] === "string" &&
206
+ import.meta.url === new URL(`file://${process.argv[1]}`).href;
207
+ if (isEntrypoint) {
208
+ main().catch((err) => {
209
+ process.stderr.write(`[sciverse-mcp] fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
210
+ process.exit(1);
211
+ });
212
+ }
package/dist/config.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  export interface Config {
2
2
  token: string;
3
3
  baseUrl: string;
4
+ /**
5
+ * 调用方标识,拼入下游 X-Sciverse-Source 头部(`{platform}-{channel}`),
6
+ * 用于 SLS 日志归因;X-Request-Id 仅承载 uuid。默认 "mcp"(stdio 入口);
7
+ * cli-http.ts 启动时覆盖为 "scp",也可由环境变量 SCIVERSE_MCP_CHANNEL 显式指定。
8
+ */
9
+ channel: string;
4
10
  }
5
11
  export declare class ConfigError extends Error {
6
12
  constructor(message: string);
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ export class ConfigError extends Error {
8
8
  this.name = "ConfigError";
9
9
  }
10
10
  }
11
+ const DEFAULT_CHANNEL = "mcp";
11
12
  export function loadConfig(env = process.env) {
12
13
  const token = resolveToken(undefined, env);
13
14
  if (!token) {
@@ -26,5 +27,6 @@ export function loadConfig(env = process.env) {
26
27
  if (host !== "sciverse.space" && !host.endsWith(".sciverse.space")) {
27
28
  throw new ConfigError(`SCIVERSE_BASE_URL must point to a *.sciverse.space domain (got: ${host}). This guards against accidental token leakage.`);
28
29
  }
29
- return { token, baseUrl };
30
+ const channel = env.SCIVERSE_MCP_CHANNEL?.trim() || DEFAULT_CHANNEL;
31
+ return { token, baseUrl, channel };
30
32
  }
@@ -1,4 +1,4 @@
1
- export declare const TOOLS_VERSION = "0.5.3";
1
+ export declare const TOOLS_VERSION = "0.7.0";
2
2
  export interface ToolDef {
3
3
  name: string;
4
4
  description: string;
@@ -1,10 +1,10 @@
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.5.3";
3
+ export const TOOLS_VERSION = "0.7.0";
4
4
  export const TOOLS = [
5
5
  {
6
6
  "name": "search_papers",
7
- "description": "Search academic papers by structured filters (title, authors, journal,\nyear, subjects, etc.).\nUse when: \"find Hinton's papers from 2020-2023\", \"Nature papers on\nCRISPR\".\nNot for: natural-language Q&A retrieval (use semantic_search) or\nfull-text snippets (use read_content).\nReturns: list of papers; each entry has doc_id, title, author, abstract,\npublication_venue_name, publication_published_year.",
7
+ "description": "Search academic papers by structured filters (title, authors, journal,\nyear, subjects, etc.).\nUse when: \"find Hinton's papers from 2020-2023\", \"Nature papers on\nCRISPR\".\nNot for: natural-language Q&A retrieval (use semantic_search) or\nfull-text snippets (use read_content).\nReturns: list of papers; each entry has unique_id (always present),\ndoc_id (only when full text exists), title, author, abstract,\npublication_venue_name_unified, publication_published_year.",
8
8
  "inputSchema": {
9
9
  "type": "object",
10
10
  "properties": {
@@ -40,7 +40,7 @@ export const TOOLS = [
40
40
  "items": {
41
41
  "type": "string"
42
42
  },
43
- "description": "期刊名(任一命中即可)。SDK 内部映射到后端 `publication_venue_name` 字段(FILTER_OP_IN)。"
43
+ "description": "期刊名(任一命中即可)。SDK 内部映射到后端 `publication_venue_name_unified` 字段(FILTER_OP_IN,规范化后的载体名)。"
44
44
  },
45
45
  "subjects": {
46
46
  "type": "array",
@@ -90,6 +90,16 @@ export const TOOLS = [
90
90
  ],
91
91
  "default": "desc"
92
92
  },
93
+ "freshness_boost": {
94
+ "type": "string",
95
+ "enum": [
96
+ "NONE",
97
+ "MILD",
98
+ "STRONG"
99
+ ],
100
+ "default": "NONE",
101
+ "description": "模糊搜索新鲜度加权(仅 query 非空时生效;与 sort_by_year 互斥)。\nMILD: 近 10 年加权,适合日常查文献;STRONG: 近 3 年加权,适合跟踪\n研究方向 / 追最新进展。底层为 function_score + gauss decay over\npublication_published_date。\n"
102
+ },
93
103
  "page": {
94
104
  "type": "integer",
95
105
  "default": 1,
package/dist/tools.js CHANGED
@@ -29,7 +29,7 @@ function toBackendPayload(args) {
29
29
  if (args.year_to != null)
30
30
  addFilter("publication_published_year", "FILTER_OP_LTE", args.year_to);
31
31
  if (Array.isArray(args.journals) && args.journals.length > 0) {
32
- addFilter("publication_venue_name", "FILTER_OP_IN", args.journals);
32
+ addFilter("publication_venue_name_unified", "FILTER_OP_IN", args.journals);
33
33
  }
34
34
  if (Array.isArray(args.subjects) && args.subjects.length > 0) {
35
35
  addFilter("subjects", "FILTER_OP_IN", args.subjects);
@@ -52,8 +52,6 @@ function toBackendPayload(args) {
52
52
  out.sort = sort;
53
53
  return out;
54
54
  }
55
- const SKILL_NAME = "sciverse";
56
- const CHANNEL = "mcp";
57
55
  const PLATFORM = process.platform;
58
56
  async function call(config, method, path, options = {}) {
59
57
  const url = new URL(`${config.baseUrl}${path}`);
@@ -68,7 +66,8 @@ async function call(config, method, path, options = {}) {
68
66
  headers: {
69
67
  authorization: `Bearer ${config.token}`,
70
68
  "content-type": "application/json",
71
- "x-request-id": `${SKILL_NAME}-${PLATFORM}-${CHANNEL}-${randomUUID()}`,
69
+ "x-request-id": randomUUID(),
70
+ "x-sciverse-source": `${PLATFORM}-${config.channel}`,
72
71
  },
73
72
  };
74
73
  if (options.body !== undefined) {
@@ -111,7 +110,8 @@ async function callBinary(config, path, query) {
111
110
  headers: {
112
111
  authorization: `Bearer ${config.token}`,
113
112
  accept: "image/*",
114
- "x-request-id": `${SKILL_NAME}-${PLATFORM}-${CHANNEL}-${randomUUID()}`,
113
+ "x-request-id": randomUUID(),
114
+ "x-sciverse-source": `${PLATFORM}-${config.channel}`,
115
115
  },
116
116
  });
117
117
  if (!res.ok) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sciverse-mcp-server",
3
- "version": "0.5.3",
3
+ "version": "0.7.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",
@@ -17,7 +17,8 @@
17
17
  "main": "./dist/server.js",
18
18
  "types": "./dist/server.d.ts",
19
19
  "bin": {
20
- "sciverse-mcp": "./dist/cli.js"
20
+ "sciverse-mcp": "./dist/cli.js",
21
+ "sciverse-mcp-http": "./dist/cli-http.js"
21
22
  },
22
23
  "files": [
23
24
  "dist",