tyc-cli 0.1.1 → 0.2.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,157 @@
1
+ // MCP JSON-RPC over Streamable HTTP 客户端
2
+ //
3
+ // 职责:
4
+ // 1. initialize:拿 Mcp-Session-Id(服务端在响应 header 下发)
5
+ // 2. tools/call:透传 Authorization + Mcp-Session-Id,解析 SSE/JSON 响应
6
+ // 3. Session 失效(404/410 或服务端 "session not found")→ 清 session → 重试 1 次
7
+ //
8
+ // 不做:
9
+ // - 不维护 tools/list 缓存(CLI 命令树从本地 catalog.json 构建)
10
+ // - 不做业务聚合/时间戳格式化(由 MCP Server 完成)
11
+ // - 不做 Authorization 解析/验签
12
+ import { clearSession, isSessionFresh, loadSession, saveSession } from "./session.js";
13
+ const PROTOCOL_VERSION = "2024-11-05";
14
+ function maskToken(v) {
15
+ if (!v)
16
+ return "";
17
+ if (v.length <= 8)
18
+ return "****";
19
+ return v.slice(0, 4) + "****" + v.slice(-4);
20
+ }
21
+ function logVerbose(cfg, title, extra) {
22
+ const masked = {};
23
+ for (const [k, v] of Object.entries(cfg.headers)) {
24
+ masked[k] = k.toLowerCase() === "authorization" ? maskToken(v) : v;
25
+ }
26
+ console.error(`> ${title}`);
27
+ console.error(`> headers: ${JSON.stringify(masked)}`);
28
+ if (extra)
29
+ console.error(extra);
30
+ }
31
+ /** 解析 MCP Streamable HTTP 响应:纯 JSON 或 SSE(`event:` + `data:` 行)。 */
32
+ function parseJsonRpc(raw) {
33
+ const trimmed = raw.trim();
34
+ if (trimmed.startsWith("{")) {
35
+ return JSON.parse(trimmed);
36
+ }
37
+ // SSE 解析:抓第一个解析成功的 data 行
38
+ for (const line of trimmed.split(/\r?\n/)) {
39
+ const s = line.trim();
40
+ if (!s.startsWith("data:"))
41
+ continue;
42
+ const body = s.slice(5).trim();
43
+ if (!body.startsWith("{"))
44
+ continue;
45
+ try {
46
+ return JSON.parse(body);
47
+ }
48
+ catch { /* try next */ }
49
+ }
50
+ throw new Error(`Unparseable MCP response: ${trimmed.slice(0, 200)}`);
51
+ }
52
+ async function httpPost(url, headers, body, verbose) {
53
+ const h = {
54
+ "Content-Type": "application/json",
55
+ "Accept": "application/json, text/event-stream",
56
+ ...headers,
57
+ };
58
+ if (verbose) {
59
+ console.error(`> POST ${url}`);
60
+ const masked = {};
61
+ for (const [k, v] of Object.entries(h)) {
62
+ masked[k] = k.toLowerCase() === "authorization" ? maskToken(v) : v;
63
+ }
64
+ console.error(`> headers: ${JSON.stringify(masked)}`);
65
+ console.error(`> body: ${JSON.stringify(body)}`);
66
+ }
67
+ const resp = await fetch(url, {
68
+ method: "POST",
69
+ headers: h,
70
+ body: JSON.stringify(body),
71
+ });
72
+ const text = await resp.text();
73
+ if (verbose) {
74
+ console.error(`< ${resp.status} ${resp.statusText}`);
75
+ console.error(`< body: ${text.slice(0, 2000)}`);
76
+ }
77
+ return { status: resp.status, text, headers: resp.headers };
78
+ }
79
+ async function doInitialize(cfg, verbose) {
80
+ if (verbose)
81
+ logVerbose(cfg, `MCP initialize → ${cfg.url}`);
82
+ const { status, text, headers } = await httpPost(cfg.url, cfg.headers, {
83
+ jsonrpc: "2.0",
84
+ id: 1,
85
+ method: "initialize",
86
+ params: {
87
+ protocolVersion: PROTOCOL_VERSION,
88
+ capabilities: {},
89
+ clientInfo: { name: "tyc-cli", version: "0.2.0" },
90
+ },
91
+ }, verbose);
92
+ if (status < 200 || status >= 300) {
93
+ throw new Error(`initialize 失败:HTTP ${status}\n${text.slice(0, 500)}`);
94
+ }
95
+ const sessionId = headers.get("Mcp-Session-Id") || headers.get("mcp-session-id");
96
+ if (!sessionId) {
97
+ throw new Error(`initialize 响应未包含 Mcp-Session-Id header。请确认 ${cfg.url} 是 Streamable HTTP MCP Server。`);
98
+ }
99
+ const rpc = parseJsonRpc(text);
100
+ if (rpc.error) {
101
+ throw new Error(`initialize JSON-RPC error: ${rpc.error.message}`);
102
+ }
103
+ const s = {
104
+ url: cfg.url,
105
+ sessionId,
106
+ initializedAt: Date.now(),
107
+ protocolVersion: rpc.result?.protocolVersion,
108
+ };
109
+ saveSession(s);
110
+ return s;
111
+ }
112
+ /** 获取可用 session:若缓存新鲜则复用,否则 initialize + 落盘。 */
113
+ export async function ensureSession(cfg, opts) {
114
+ const cached = loadSession();
115
+ if (isSessionFresh(cached, cfg.url))
116
+ return cached;
117
+ return doInitialize(cfg, !!opts?.verbose);
118
+ }
119
+ function isSessionInvalidResponse(status, text) {
120
+ if (status === 404 || status === 410)
121
+ return true;
122
+ const lower = text.toLowerCase();
123
+ return lower.includes("session not found") || lower.includes("invalid session");
124
+ }
125
+ /** 调用 MCP tool;透明处理 session 失效重建。 */
126
+ export async function callTool(cfg, name, args, opts) {
127
+ const verbose = !!opts?.verbose;
128
+ let session = await ensureSession(cfg, opts);
129
+ const doCall = async (s) => {
130
+ const h = { ...cfg.headers, "Mcp-Session-Id": s.sessionId };
131
+ return httpPost(cfg.url, h, {
132
+ jsonrpc: "2.0",
133
+ id: 2,
134
+ method: "tools/call",
135
+ params: { name, arguments: args },
136
+ }, verbose);
137
+ };
138
+ let resp = await doCall(session);
139
+ if (isSessionInvalidResponse(resp.status, resp.text)) {
140
+ if (verbose)
141
+ console.error("> session invalid, re-initializing …");
142
+ clearSession();
143
+ session = await doInitialize(cfg, verbose);
144
+ resp = await doCall(session);
145
+ }
146
+ if (resp.status < 200 || resp.status >= 300) {
147
+ throw new Error(`tools/call HTTP ${resp.status}\n${resp.text.slice(0, 500)}`);
148
+ }
149
+ const rpc = parseJsonRpc(resp.text);
150
+ if (rpc.error) {
151
+ throw new Error(`MCP error ${rpc.error.code}: ${rpc.error.message}`);
152
+ }
153
+ if (!rpc.result) {
154
+ throw new Error("tools/call 响应缺少 result 字段");
155
+ }
156
+ return rpc.result;
157
+ }
package/dist/registry.js CHANGED
@@ -1,25 +1,23 @@
1
+ // 从 src/catalog.json 加载命令树元数据
2
+ //
3
+ // catalog.json 由 apimcp 主仓 scripts/gen_cli_catalog 生成;仅包含 CLI
4
+ // 需要的字段(name / group / layer / cliMethod / categoryNameZh /
5
+ // description / params)。不含 sources / execution / summary 等服务端
6
+ // 内部映射,服务端实现细节不泄露。
1
7
  import { readFileSync } from "node:fs";
2
8
  import { resolve, dirname } from "node:path";
3
9
  import { fileURLToPath } from "node:url";
4
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
5
- const REGISTRY_PATH = resolve(__dirname, "generated/t1_1-registry.json");
6
- let _registry = null;
7
- let _byName = null;
11
+ const CATALOG_PATH = resolve(__dirname, "catalog.json");
12
+ let _catalog = null;
8
13
  let _byGroup = null;
14
+ let _byLayer = null;
9
15
  function load() {
10
- if (!_registry) {
11
- const raw = readFileSync(REGISTRY_PATH, "utf-8");
12
- _registry = JSON.parse(raw);
16
+ if (!_catalog) {
17
+ const raw = readFileSync(CATALOG_PATH, "utf-8");
18
+ _catalog = JSON.parse(raw);
13
19
  }
14
- return _registry;
15
- }
16
- function byName() {
17
- if (!_byName) {
18
- _byName = new Map();
19
- for (const t of load().tools)
20
- _byName.set(t.name, t);
21
- }
22
- return _byName;
20
+ return _catalog;
23
21
  }
24
22
  function byGroup() {
25
23
  if (!_byGroup) {
@@ -32,6 +30,17 @@ function byGroup() {
32
30
  }
33
31
  return _byGroup;
34
32
  }
33
+ function byLayer() {
34
+ if (!_byLayer) {
35
+ _byLayer = new Map();
36
+ for (const t of load().tools) {
37
+ const arr = _byLayer.get(t.layer) || [];
38
+ arr.push(t);
39
+ _byLayer.set(t.layer, arr);
40
+ }
41
+ }
42
+ return _byLayer;
43
+ }
35
44
  export function getTotalCount() {
36
45
  return load().tools.length;
37
46
  }
@@ -41,6 +50,15 @@ export function getCategories() {
41
50
  export function getToolsByGroup(group) {
42
51
  return byGroup().get(group) || [];
43
52
  }
44
- export function getTool(name) {
45
- return byName().get(name);
53
+ export function getToolsByLayer(layer) {
54
+ return byLayer().get(layer) || [];
55
+ }
56
+ export function getLayerSummaries() {
57
+ return load().layers;
58
+ }
59
+ export function getLayerCount(layer) {
60
+ return getLayerSummaries().find((l) => l.layer === layer)?.count ?? 0;
61
+ }
62
+ export function getCategoryName(group) {
63
+ return getCategories().find((c) => c.group === group)?.name_zh ?? group;
46
64
  }
@@ -0,0 +1,47 @@
1
+ // ~/.tyc/session.json 读写 + 24h TTL
2
+ //
3
+ // TTL 规则(按字面 24h,从 initializedAt 起):
4
+ // - 命中:复用 sessionId,跳过 initialize
5
+ // - 过期 / url 变化:强制重新 initialize
6
+ // - 服务端主动失效(4xx + session 提示):mcpClient 调 clearSession 后重建
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, chmodSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ const SESSION_DIR = join(homedir(), ".tyc");
11
+ const SESSION_FILE = join(SESSION_DIR, "session.json");
12
+ export const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h 字面
13
+ export function loadSession() {
14
+ if (!existsSync(SESSION_FILE))
15
+ return null;
16
+ try {
17
+ const raw = readFileSync(SESSION_FILE, "utf-8");
18
+ return JSON.parse(raw);
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function saveSession(s) {
25
+ if (!existsSync(SESSION_DIR))
26
+ mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
27
+ writeFileSync(SESSION_FILE, JSON.stringify(s, null, 2) + "\n", "utf-8");
28
+ try {
29
+ chmodSync(SESSION_FILE, 0o600);
30
+ }
31
+ catch { /* best effort */ }
32
+ }
33
+ export function clearSession() {
34
+ try {
35
+ rmSync(SESSION_FILE, { force: true });
36
+ }
37
+ catch { /* ignore */ }
38
+ }
39
+ export function isSessionFresh(s, url, now = Date.now()) {
40
+ if (!s)
41
+ return false;
42
+ if (s.url !== url)
43
+ return false;
44
+ if (!s.sessionId)
45
+ return false;
46
+ return now - s.initializedAt < SESSION_TTL_MS;
47
+ }
package/dist/types.js CHANGED
@@ -1 +1,9 @@
1
+ // 共享类型定义
2
+ //
3
+ // CLI 运行时模型:
4
+ // - Catalog:构建时由 apimcp 主仓生成,打包在 src/catalog.json,用于构建命令树
5
+ // - MCP 层类型:JSON-RPC 请求/响应、tools/call 结果
6
+ // - Session:本地缓存的 MCP 会话状态(~/.tyc/session.json)
7
+ // - Config:CLI 配置(~/.tyc/config.json),与主流 MCP 客户端配置风格一致
8
+ // { url, headers }
1
9
  export {};
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "tyc-cli",
3
- "version": "0.1.1",
4
- "description": "天眼查 OpenAPI 业务语义层命令行工具 — 167 个聚合工具 / 15 个分类 / 命令行 30 秒直查企业数据",
3
+ "version": "0.2.0",
4
+ "description": "天眼查 MCP CLI — 167 个业务语义聚合工具 · 命令行直达企业数据 · 通过 MCP Server 调用",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "tyc": "./dist/index.js"
8
8
  },
9
9
  "scripts": {
10
- "prebuild": "tsx scripts/build-registry.ts",
11
- "build": "npm run prebuild && tsc && mkdir -p dist/generated && cp src/generated/t1_1-registry.json dist/generated/",
10
+ "build": "tsc && cp src/catalog.json dist/catalog.json",
12
11
  "dev": "tsx src/index.ts",
13
- "lint": "eslint src/ scripts/",
14
- "lint:fix": "eslint src/ scripts/ --fix",
12
+ "lint": "eslint src/",
13
+ "lint:fix": "eslint src/ --fix",
15
14
  "test": "vitest run",
16
15
  "test:coverage": "vitest run --coverage",
17
16
  "prepublishOnly": "npm run build"
@@ -26,6 +25,7 @@
26
25
  "cli",
27
26
  "ai-agent",
28
27
  "mcp",
28
+ "mcp-client",
29
29
  "enterprise-data",
30
30
  "due-diligence",
31
31
  "kyb"
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "files": [
45
45
  "dist/",
46
- "api-registry.yaml",
47
46
  "LICENSE",
48
47
  "README.md",
49
48
  "CHANGELOG.md"
@@ -62,7 +61,6 @@
62
61
  "eslint": "^9.0.0",
63
62
  "tsx": "^4.0.0",
64
63
  "typescript": "^5.7.0",
65
- "vitest": "^2.0.0",
66
- "yaml": "^2.7.0"
64
+ "vitest": "^2.0.0"
67
65
  }
68
66
  }