gangtise-mcp 0.1.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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/core/asyncContent.js +41 -0
- package/dist/core/auth.js +36 -0
- package/dist/core/client.js +354 -0
- package/dist/core/config.js +21 -0
- package/dist/core/download.js +69 -0
- package/dist/core/endpoints.js +476 -0
- package/dist/core/errors.js +53 -0
- package/dist/core/lookupData/announcement-categories.js +554 -0
- package/dist/core/lookupData/broker-orgs.js +722 -0
- package/dist/core/lookupData/index.js +22 -0
- package/dist/core/lookupData/industries.js +157 -0
- package/dist/core/lookupData/industry-codes.js +126 -0
- package/dist/core/lookupData/meeting-orgs.js +522 -0
- package/dist/core/lookupData/regions.js +78 -0
- package/dist/core/lookupData/research-areas.js +266 -0
- package/dist/core/lookupData/theme-ids.js +1614 -0
- package/dist/core/lookupData/types.js +1 -0
- package/dist/core/normalize.js +33 -0
- package/dist/core/quoteSharding.js +81 -0
- package/dist/core/transport.js +91 -0
- package/dist/index.js +9 -0
- package/dist/server.js +20 -0
- package/dist/tools/ai.js +206 -0
- package/dist/tools/fundamental.js +140 -0
- package/dist/tools/insight.js +255 -0
- package/dist/tools/lookup.js +29 -0
- package/dist/tools/quote.js +86 -0
- package/dist/tools/reference.js +19 -0
- package/dist/tools/registry.js +52 -0
- package/dist/tools/vault.js +110 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gangtiser
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# gangtise-mcp
|
|
2
|
+
|
|
3
|
+
基于 [Gangtise OpenAPI](https://open.gangtise.com) 的 MCP(Model Context Protocol)服务,让 Claude 等 AI 助手直接访问 Gangtise 投研平台数据。
|
|
4
|
+
|
|
5
|
+
## 功能覆盖
|
|
6
|
+
|
|
7
|
+
| 类别 | 工具 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| 参考数据 | `gangtise_lookup` — 研究方向、券商、行业、地区、公告类别、申万行业代码、主题 ID |
|
|
10
|
+
| 证券检索 | `gangtise_securities_search` |
|
|
11
|
+
| 观点/研报 | 国内首席观点、纪要、券商研报、外资研报、外资独立观点、公告(A股/港股) |
|
|
12
|
+
| 路演/调研 | 路演、调研、策略会、论坛 |
|
|
13
|
+
| 行情 | A 股/港股日 K、A 股分钟 K、指数日 K |
|
|
14
|
+
| 基本面 | 利润表、资产负债表、现金流量表(累计/单季)、主营业务、估值、股东、盈利预测 |
|
|
15
|
+
| AI 能力 | 知识库检索、一页通、投资逻辑、同业对比、线索、主题跟踪、业绩点评、观点辩证 |
|
|
16
|
+
| 云盘/语音 | 网盘文件、录音转写、我的会议、微信群消息 |
|
|
17
|
+
|
|
18
|
+
## 前置要求
|
|
19
|
+
|
|
20
|
+
- Node.js ≥ 20
|
|
21
|
+
- Gangtise 开放平台账号([申请地址](https://open.gangtise.com)),获取 `accessKey` / `secretKey`
|
|
22
|
+
|
|
23
|
+
## 快速开始
|
|
24
|
+
|
|
25
|
+
### 方式一:npx(推荐,无需安装)
|
|
26
|
+
|
|
27
|
+
在 Claude Code 或 Claude Desktop 配置文件中直接使用:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"gangtise": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "gangtise-mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"GANGTISE_ACCESS_KEY": "your_access_key",
|
|
37
|
+
"GANGTISE_SECRET_KEY": "your_secret_key"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 方式二:克隆仓库本地运行
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/gangtiser/gangtise-mcp
|
|
48
|
+
cd gangtise-mcp
|
|
49
|
+
npm install
|
|
50
|
+
npm run build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
配置文件:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"gangtise": {
|
|
59
|
+
"command": "node",
|
|
60
|
+
"args": ["/path/to/gangtise-mcp/dist/index.js"],
|
|
61
|
+
"env": {
|
|
62
|
+
"GANGTISE_ACCESS_KEY": "your_access_key",
|
|
63
|
+
"GANGTISE_SECRET_KEY": "your_secret_key"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Claude Code 一键添加
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
claude mcp add gangtise -e GANGTISE_ACCESS_KEY=your_key -e GANGTISE_SECRET_KEY=your_secret -- npx -y gangtise-mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 环境变量
|
|
77
|
+
|
|
78
|
+
| 变量 | 默认值 | 说明 |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| `GANGTISE_ACCESS_KEY` | — | 开放平台 Access Key(与 SECRET_KEY 配对使用) |
|
|
81
|
+
| `GANGTISE_SECRET_KEY` | — | 开放平台 Secret Key |
|
|
82
|
+
| `GANGTISE_TOKEN` | — | 直接传 Bearer Token(优先于 Key/Secret,适合临时使用) |
|
|
83
|
+
| `GANGTISE_BASE_URL` | `https://open.gangtise.com` | API 基础地址 |
|
|
84
|
+
| `GANGTISE_TIMEOUT_MS` | `30000` | 单次请求超时(毫秒) |
|
|
85
|
+
| `GANGTISE_MCP_ASYNC_TIMEOUT_MS` | `180000` | 异步 AI 任务等待超时(毫秒) |
|
|
86
|
+
| `GANGTISE_TOKEN_CACHE_PATH` | `~/.config/gangtise/token.json` | Token 缓存文件路径 |
|
|
87
|
+
| `GANGTISE_PAGE_CONCURRENCY` | `5` | 分页并发数 |
|
|
88
|
+
| `GANGTISE_VERBOSE` | — | 设为 `1` 开启请求耗时日志(输出到 stderr) |
|
|
89
|
+
|
|
90
|
+
认证优先级:`GANGTISE_TOKEN` > Token 缓存文件 > `GANGTISE_ACCESS_KEY` + `GANGTISE_SECRET_KEY`(自动换取并缓存 Token)。
|
|
91
|
+
|
|
92
|
+
## 开发
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npm run dev # 直接运行源码(tsx,无需 build)
|
|
96
|
+
npm run build # 编译 TypeScript → dist/
|
|
97
|
+
npm test # 运行测试
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ApiError } from "./errors.js";
|
|
2
|
+
import { AsyncTimeoutError } from "./errors.js";
|
|
3
|
+
export const POLL_INITIAL_DELAY_MS = 5_000;
|
|
4
|
+
export const POLL_MAX_DELAY_MS = 30_000;
|
|
5
|
+
function nextDelayMs(attempt) {
|
|
6
|
+
// 5s, 8s, 13s, 20s, 30s, 30s, ...
|
|
7
|
+
const grown = POLL_INITIAL_DELAY_MS * 1.6 ** (attempt - 1);
|
|
8
|
+
return Math.min(POLL_MAX_DELAY_MS, Math.round(grown));
|
|
9
|
+
}
|
|
10
|
+
function isAsyncPending(error) {
|
|
11
|
+
return error instanceof ApiError && error.code === "410110";
|
|
12
|
+
}
|
|
13
|
+
export async function pollAsyncContent(client, getContentEndpoint, dataId, timeoutMs = 60_000) {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
let attempt = 0;
|
|
16
|
+
while (true) {
|
|
17
|
+
attempt++;
|
|
18
|
+
try {
|
|
19
|
+
const result = await client.call(getContentEndpoint, { dataId });
|
|
20
|
+
if (result?.content != null) {
|
|
21
|
+
return { content: result.content };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error instanceof ApiError && error.code === "410111") {
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
if (!isAsyncPending(error))
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
if (now >= deadline) {
|
|
33
|
+
throw new AsyncTimeoutError(dataId);
|
|
34
|
+
}
|
|
35
|
+
const delay = Math.min(nextDelayMs(attempt), deadline - now);
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
37
|
+
if (Date.now() >= deadline) {
|
|
38
|
+
throw new AsyncTimeoutError(dataId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ConfigError } from "./errors.js";
|
|
4
|
+
export async function readTokenCache(filePath) {
|
|
5
|
+
try {
|
|
6
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
7
|
+
const parsed = JSON.parse(content);
|
|
8
|
+
if (parsed && typeof parsed === "object" && typeof parsed.accessToken === "string" && typeof parsed.expiresAt === "number") {
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function writeTokenCache(filePath, cache) {
|
|
18
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
19
|
+
await fs.writeFile(filePath, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
export function isTokenCacheValid(cache, bufferSeconds = 300) {
|
|
22
|
+
if (!cache?.accessToken || !cache.expiresAt) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
return cache.expiresAt - bufferSeconds > now;
|
|
27
|
+
}
|
|
28
|
+
export function normalizeToken(token) {
|
|
29
|
+
return token.startsWith("Bearer ") ? token : `Bearer ${token}`;
|
|
30
|
+
}
|
|
31
|
+
export function requireAccessCredentials(accessKey, secretKey) {
|
|
32
|
+
if (!accessKey || !secretKey) {
|
|
33
|
+
throw new ConfigError("Missing GANGTISE_ACCESS_KEY or GANGTISE_SECRET_KEY");
|
|
34
|
+
}
|
|
35
|
+
return { accessKey, secretKey };
|
|
36
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { request } from "undici";
|
|
6
|
+
import { isTokenCacheValid, normalizeToken, readTokenCache, requireAccessCredentials, writeTokenCache } from "./auth.js";
|
|
7
|
+
import { ApiError, ValidationError } from "./errors.js";
|
|
8
|
+
import { ENDPOINTS } from "./endpoints.js";
|
|
9
|
+
import { getLookupData } from "./lookupData/index.js";
|
|
10
|
+
import { getDispatcher, isVerbose, logTiming, markRetryable, runWithConcurrency, withRetry } from "./transport.js";
|
|
11
|
+
const PAGINATION_CONCURRENCY = Number(process.env.GANGTISE_PAGE_CONCURRENCY ?? 5) || 5;
|
|
12
|
+
const AUTH_RETRY_CODES = new Set(["8000014", "8000015"]);
|
|
13
|
+
export class GangtiseClient {
|
|
14
|
+
config;
|
|
15
|
+
refreshPromise = null;
|
|
16
|
+
memoCache = null;
|
|
17
|
+
constructor(config) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
}
|
|
20
|
+
async getAuthorizationHeader(forceRefresh = false) {
|
|
21
|
+
if (this.config.token && !forceRefresh) {
|
|
22
|
+
return normalizeToken(this.config.token);
|
|
23
|
+
}
|
|
24
|
+
if (!forceRefresh) {
|
|
25
|
+
if (isTokenCacheValid(this.memoCache)) {
|
|
26
|
+
return normalizeToken(this.memoCache.accessToken);
|
|
27
|
+
}
|
|
28
|
+
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
29
|
+
if (isTokenCacheValid(cache)) {
|
|
30
|
+
this.memoCache = cache;
|
|
31
|
+
return normalizeToken(cache.accessToken);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!this.refreshPromise) {
|
|
35
|
+
this.refreshPromise = this.doTokenRefresh().finally(() => { this.refreshPromise = null; });
|
|
36
|
+
}
|
|
37
|
+
return this.refreshPromise;
|
|
38
|
+
}
|
|
39
|
+
async doTokenRefresh() {
|
|
40
|
+
const credentials = requireAccessCredentials(this.config.accessKey, this.config.secretKey);
|
|
41
|
+
const envelope = await this.requestJson(ENDPOINTS["auth.login"], {
|
|
42
|
+
accessKey: credentials.accessKey,
|
|
43
|
+
secretKey: credentials.secretKey,
|
|
44
|
+
}, false);
|
|
45
|
+
const accessToken = normalizeToken(envelope.accessToken);
|
|
46
|
+
const expiresAt = Math.floor(Date.now() / 1000) + envelope.expiresIn;
|
|
47
|
+
const cache = { ...envelope, accessToken, expiresAt };
|
|
48
|
+
this.memoCache = cache;
|
|
49
|
+
await writeTokenCache(this.config.tokenCachePath, cache);
|
|
50
|
+
return accessToken;
|
|
51
|
+
}
|
|
52
|
+
isEnvelope(parsed) {
|
|
53
|
+
if (!parsed || typeof parsed !== 'object')
|
|
54
|
+
return false;
|
|
55
|
+
const obj = parsed;
|
|
56
|
+
if (!('code' in obj))
|
|
57
|
+
return false;
|
|
58
|
+
return 'msg' in obj || 'data' in obj || 'success' in obj || 'status' in obj;
|
|
59
|
+
}
|
|
60
|
+
throwHttpError(parsed, statusCode) {
|
|
61
|
+
if (this.isEnvelope(parsed)) {
|
|
62
|
+
const code = parsed.code === undefined ? undefined : String(parsed.code);
|
|
63
|
+
throw new ApiError(parsed.msg || `API request failed (HTTP ${statusCode})`, code, statusCode, parsed);
|
|
64
|
+
}
|
|
65
|
+
throw new ApiError(`API request failed (HTTP ${statusCode})`, undefined, statusCode, parsed);
|
|
66
|
+
}
|
|
67
|
+
unwrapEnvelope(parsed, statusCode) {
|
|
68
|
+
if (!this.isEnvelope(parsed)) {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
const code = parsed.code === undefined ? undefined : String(parsed.code);
|
|
72
|
+
const ok = parsed.status === true || parsed.success === true || code === "000000" || code === "0";
|
|
73
|
+
if (!ok) {
|
|
74
|
+
throw new ApiError(parsed.msg || "API request failed", code, statusCode, parsed);
|
|
75
|
+
}
|
|
76
|
+
if ('data' in parsed) {
|
|
77
|
+
return parsed.data;
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
async readLocalLookup(endpoint) {
|
|
82
|
+
const keyMapping = {
|
|
83
|
+
"lookup.research-areas.list": "research-areas",
|
|
84
|
+
"lookup.broker-orgs.list": "broker-orgs",
|
|
85
|
+
"lookup.meeting-orgs.list": "meeting-orgs",
|
|
86
|
+
"lookup.industries.list": "industries",
|
|
87
|
+
"lookup.regions.list": "regions",
|
|
88
|
+
"lookup.announcement-categories.list": "announcement-categories",
|
|
89
|
+
"lookup.industry-codes.list": "industry-codes",
|
|
90
|
+
"lookup.theme-ids.list": "theme-ids",
|
|
91
|
+
};
|
|
92
|
+
const lookupKey = keyMapping[endpoint.key];
|
|
93
|
+
if (lookupKey) {
|
|
94
|
+
return getLookupData(lookupKey);
|
|
95
|
+
}
|
|
96
|
+
throw new ApiError(`Unsupported local lookup endpoint: ${endpoint.key}`);
|
|
97
|
+
}
|
|
98
|
+
isPaginatedListResponse(value) {
|
|
99
|
+
return Boolean(value
|
|
100
|
+
&& typeof value === 'object'
|
|
101
|
+
&& typeof value.total === 'number'
|
|
102
|
+
&& Array.isArray(value.list));
|
|
103
|
+
}
|
|
104
|
+
async requestPaginated(endpoint, body) {
|
|
105
|
+
const initialBody = body && typeof body === 'object' ? { ...body } : {};
|
|
106
|
+
if ('from' in initialBody && (typeof initialBody.from !== 'number' || !Number.isFinite(initialBody.from) || initialBody.from < 0)) {
|
|
107
|
+
throw new ValidationError('Invalid from: expected a non-negative number');
|
|
108
|
+
}
|
|
109
|
+
if ('size' in initialBody && initialBody.size !== undefined && (typeof initialBody.size !== 'number' || !Number.isFinite(initialBody.size) || initialBody.size <= 0)) {
|
|
110
|
+
throw new ValidationError('Invalid size: expected a positive number');
|
|
111
|
+
}
|
|
112
|
+
const startFrom = typeof initialBody.from === 'number' && Number.isFinite(initialBody.from) ? initialBody.from : 0;
|
|
113
|
+
const requestedSize = typeof initialBody.size === 'number' && Number.isFinite(initialBody.size) ? initialBody.size : undefined;
|
|
114
|
+
const maxPageSize = endpoint.pagination?.maxPageSize ?? requestedSize ?? 20;
|
|
115
|
+
// First page: serial — we need total before deciding how many more requests to fan out.
|
|
116
|
+
const firstPageSize = requestedSize === undefined ? maxPageSize : Math.min(maxPageSize, requestedSize);
|
|
117
|
+
const firstPage = await this.requestJson(endpoint, {
|
|
118
|
+
...initialBody,
|
|
119
|
+
from: startFrom,
|
|
120
|
+
size: firstPageSize,
|
|
121
|
+
});
|
|
122
|
+
if (!this.isPaginatedListResponse(firstPage))
|
|
123
|
+
return firstPage;
|
|
124
|
+
const total = firstPage.total;
|
|
125
|
+
const collected = [...firstPage.list];
|
|
126
|
+
// Last page reached on first request
|
|
127
|
+
if (firstPage.list.length < firstPageSize) {
|
|
128
|
+
return {
|
|
129
|
+
...firstPage,
|
|
130
|
+
total,
|
|
131
|
+
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const available = Math.max(total - startFrom, 0);
|
|
135
|
+
const target = requestedSize === undefined ? available : Math.min(requestedSize, available);
|
|
136
|
+
if (collected.length >= target) {
|
|
137
|
+
return {
|
|
138
|
+
...firstPage,
|
|
139
|
+
total,
|
|
140
|
+
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const pageRequests = [];
|
|
144
|
+
let nextFrom = startFrom + firstPage.list.length;
|
|
145
|
+
const endFrom = startFrom + target;
|
|
146
|
+
while (nextFrom < endFrom) {
|
|
147
|
+
const remaining = endFrom - nextFrom;
|
|
148
|
+
const size = Math.min(maxPageSize, remaining);
|
|
149
|
+
pageRequests.push({ from: nextFrom, size });
|
|
150
|
+
nextFrom += size;
|
|
151
|
+
}
|
|
152
|
+
const MAX_PAGES = 1000;
|
|
153
|
+
if (pageRequests.length + 1 > MAX_PAGES) {
|
|
154
|
+
pageRequests.length = MAX_PAGES - 1;
|
|
155
|
+
}
|
|
156
|
+
let unexpectedShape = false;
|
|
157
|
+
let totalDrift = false;
|
|
158
|
+
const pages = await runWithConcurrency(pageRequests, PAGINATION_CONCURRENCY, async (req) => {
|
|
159
|
+
const page = await this.requestJson(endpoint, {
|
|
160
|
+
...initialBody,
|
|
161
|
+
from: req.from,
|
|
162
|
+
size: req.size,
|
|
163
|
+
});
|
|
164
|
+
if (!this.isPaginatedListResponse(page)) {
|
|
165
|
+
unexpectedShape = true;
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
if (page.total !== total)
|
|
169
|
+
totalDrift = true;
|
|
170
|
+
return page.list;
|
|
171
|
+
});
|
|
172
|
+
for (const list of pages) {
|
|
173
|
+
if (list.length === 0)
|
|
174
|
+
continue;
|
|
175
|
+
collected.push(...list);
|
|
176
|
+
}
|
|
177
|
+
if (unexpectedShape && isVerbose()) {
|
|
178
|
+
process.stderr.write(`[gangtise] warning: a page response had unexpected shape; results may be incomplete\n`);
|
|
179
|
+
}
|
|
180
|
+
if (totalDrift && isVerbose()) {
|
|
181
|
+
process.stderr.write(`[gangtise] warning: 'total' changed across pages (data shifted during fetch)\n`);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
...firstPage,
|
|
185
|
+
total,
|
|
186
|
+
list: requestedSize === undefined ? collected : collected.slice(0, requestedSize),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async login() {
|
|
190
|
+
const authorization = await this.getAuthorizationHeader();
|
|
191
|
+
const cache = await readTokenCache(this.config.tokenCachePath);
|
|
192
|
+
return {
|
|
193
|
+
authorization,
|
|
194
|
+
cache,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async requestJson(endpoint, body, useAuth = true) {
|
|
198
|
+
if (endpoint.path.startsWith('/guide/')) {
|
|
199
|
+
return this.readLocalLookup(endpoint);
|
|
200
|
+
}
|
|
201
|
+
const dispatcher = getDispatcher();
|
|
202
|
+
const url = new URL(endpoint.path, this.config.baseUrl);
|
|
203
|
+
let authRetried = false;
|
|
204
|
+
return withRetry(async () => {
|
|
205
|
+
const headers = {
|
|
206
|
+
'content-type': 'application/json',
|
|
207
|
+
};
|
|
208
|
+
if (useAuth) {
|
|
209
|
+
headers.Authorization = await this.getAuthorizationHeader();
|
|
210
|
+
}
|
|
211
|
+
const startedAt = Date.now();
|
|
212
|
+
const response = await request(url, {
|
|
213
|
+
method: endpoint.method,
|
|
214
|
+
headers,
|
|
215
|
+
body: endpoint.method === 'GET' ? undefined : JSON.stringify(body ?? {}),
|
|
216
|
+
headersTimeout: this.config.timeoutMs,
|
|
217
|
+
bodyTimeout: this.config.timeoutMs,
|
|
218
|
+
dispatcher,
|
|
219
|
+
});
|
|
220
|
+
const text = await response.body.text();
|
|
221
|
+
logTiming(`${endpoint.method} ${endpoint.path}`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(text);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
const message = response.statusCode >= 400
|
|
228
|
+
? `API request failed (HTTP ${response.statusCode})`
|
|
229
|
+
: 'Failed to parse API response';
|
|
230
|
+
throw new ApiError(message, undefined, response.statusCode, text.slice(0, 500));
|
|
231
|
+
}
|
|
232
|
+
if (response.statusCode >= 400) {
|
|
233
|
+
this.throwHttpError(parsed, response.statusCode);
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
return this.unwrapEnvelope(parsed, response.statusCode);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
// Auto-recover from auth errors by forcing a token refresh once.
|
|
240
|
+
if (useAuth
|
|
241
|
+
&& !authRetried
|
|
242
|
+
&& error instanceof ApiError
|
|
243
|
+
&& error.code
|
|
244
|
+
&& AUTH_RETRY_CODES.has(error.code)
|
|
245
|
+
&& (this.config.accessKey && this.config.secretKey)) {
|
|
246
|
+
authRetried = true;
|
|
247
|
+
this.memoCache = null;
|
|
248
|
+
await this.getAuthorizationHeader(true);
|
|
249
|
+
throw markRetryable(new ApiError(error.message, error.code, error.statusCode, error.details));
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}, {
|
|
254
|
+
onRetry: (attempt, error, delay) => {
|
|
255
|
+
if (!isVerbose())
|
|
256
|
+
return;
|
|
257
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
258
|
+
process.stderr.write(`[gangtise] retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
async download(endpoint, query, options) {
|
|
263
|
+
const dispatcher = getDispatcher();
|
|
264
|
+
const url = new URL(endpoint.path, this.config.baseUrl);
|
|
265
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
266
|
+
url.searchParams.set(key, String(value));
|
|
267
|
+
});
|
|
268
|
+
return withRetry(async () => {
|
|
269
|
+
const authorization = await this.getAuthorizationHeader();
|
|
270
|
+
const startedAt = Date.now();
|
|
271
|
+
const response = await request(url, {
|
|
272
|
+
method: endpoint.method,
|
|
273
|
+
headers: { Authorization: authorization },
|
|
274
|
+
headersTimeout: this.config.timeoutMs,
|
|
275
|
+
bodyTimeout: this.config.timeoutMs,
|
|
276
|
+
dispatcher,
|
|
277
|
+
});
|
|
278
|
+
const contentType = Array.isArray(response.headers['content-type']) ? response.headers['content-type'][0] : response.headers['content-type'];
|
|
279
|
+
if (contentType?.includes('application/json')) {
|
|
280
|
+
const text = await response.body.text();
|
|
281
|
+
logTiming(`GET ${endpoint.path} (json)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
282
|
+
let parsed;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(text);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
if (response.statusCode >= 400) {
|
|
288
|
+
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
289
|
+
}
|
|
290
|
+
return { text, contentType };
|
|
291
|
+
}
|
|
292
|
+
if (response.statusCode >= 400) {
|
|
293
|
+
this.throwHttpError(parsed, response.statusCode);
|
|
294
|
+
}
|
|
295
|
+
const data = this.unwrapEnvelope(parsed, response.statusCode);
|
|
296
|
+
if (data && typeof data === 'object' && 'url' in data && typeof data.url === 'string') {
|
|
297
|
+
return { url: String(data.url), contentType };
|
|
298
|
+
}
|
|
299
|
+
return { text: JSON.stringify(data, null, 2), contentType };
|
|
300
|
+
}
|
|
301
|
+
if (contentType?.includes('text/plain') || contentType?.includes('text/html')) {
|
|
302
|
+
const text = await response.body.text();
|
|
303
|
+
logTiming(`GET ${endpoint.path} (text)`, Date.now() - startedAt, `${response.statusCode}, ${text.length}B`);
|
|
304
|
+
if (response.statusCode >= 400) {
|
|
305
|
+
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
306
|
+
}
|
|
307
|
+
return { text, contentType };
|
|
308
|
+
}
|
|
309
|
+
if (response.statusCode >= 400) {
|
|
310
|
+
const text = await response.body.text();
|
|
311
|
+
throw new ApiError('Download failed', undefined, response.statusCode, text);
|
|
312
|
+
}
|
|
313
|
+
const contentDisposition = response.headers['content-disposition'];
|
|
314
|
+
const filenameMatch = Array.isArray(contentDisposition)
|
|
315
|
+
? contentDisposition[0]?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
|
|
316
|
+
: contentDisposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
|
|
317
|
+
const filename = filenameMatch ? decodeURIComponent(filenameMatch[1] || filenameMatch[2]) : undefined;
|
|
318
|
+
// Stream directly to disk when caller already knows the destination
|
|
319
|
+
if (options?.streamTo) {
|
|
320
|
+
await fs.mkdir(path.dirname(options.streamTo), { recursive: true });
|
|
321
|
+
await pipeline(response.body, createWriteStream(options.streamTo));
|
|
322
|
+
logTiming(`GET ${endpoint.path} (stream)`, Date.now() - startedAt, `${response.statusCode}`);
|
|
323
|
+
return { contentType, filename, savedPath: options.streamTo };
|
|
324
|
+
}
|
|
325
|
+
const buffer = await response.body.arrayBuffer();
|
|
326
|
+
logTiming(`GET ${endpoint.path} (binary)`, Date.now() - startedAt, `${response.statusCode}, ${buffer.byteLength}B`);
|
|
327
|
+
return {
|
|
328
|
+
data: new Uint8Array(buffer),
|
|
329
|
+
contentType,
|
|
330
|
+
filename,
|
|
331
|
+
};
|
|
332
|
+
}, {
|
|
333
|
+
onRetry: (attempt, error, delay) => {
|
|
334
|
+
if (!isVerbose())
|
|
335
|
+
return;
|
|
336
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
337
|
+
process.stderr.write(`[gangtise] download retry ${attempt} after ${delay.toFixed(0)}ms: ${msg.slice(0, 120)}\n`);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
async call(endpointKey, body, query, options) {
|
|
342
|
+
const endpoint = ENDPOINTS[endpointKey];
|
|
343
|
+
if (!endpoint) {
|
|
344
|
+
throw new ApiError(`Unknown endpoint key: ${endpointKey}`);
|
|
345
|
+
}
|
|
346
|
+
if (endpoint.kind === 'download') {
|
|
347
|
+
return this.download(endpoint, query ?? {}, options);
|
|
348
|
+
}
|
|
349
|
+
if (endpoint.kind === 'json' && endpoint.pagination?.enabled) {
|
|
350
|
+
return this.requestPaginated(endpoint, body);
|
|
351
|
+
}
|
|
352
|
+
return this.requestJson(endpoint, body);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const DEFAULT_BASE_URL = "https://open.gangtise.com";
|
|
4
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
export const DEFAULT_TOKEN_CACHE_PATH = path.join(os.homedir(), ".config", "gangtise", "token.json");
|
|
6
|
+
export const DEFAULT_ASYNC_TIMEOUT_MS = 180_000;
|
|
7
|
+
export function loadConfig() {
|
|
8
|
+
const timeoutValue = process.env.GANGTISE_TIMEOUT_MS;
|
|
9
|
+
const timeoutMs = timeoutValue ? Number(timeoutValue) : DEFAULT_TIMEOUT_MS;
|
|
10
|
+
const asyncTimeoutValue = process.env.GANGTISE_MCP_ASYNC_TIMEOUT_MS;
|
|
11
|
+
const asyncTimeoutMs = asyncTimeoutValue ? Number(asyncTimeoutValue) : DEFAULT_ASYNC_TIMEOUT_MS;
|
|
12
|
+
return {
|
|
13
|
+
baseUrl: process.env.GANGTISE_BASE_URL ?? DEFAULT_BASE_URL,
|
|
14
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
15
|
+
accessKey: process.env.GANGTISE_ACCESS_KEY,
|
|
16
|
+
secretKey: process.env.GANGTISE_SECRET_KEY,
|
|
17
|
+
token: process.env.GANGTISE_TOKEN,
|
|
18
|
+
tokenCachePath: process.env.GANGTISE_TOKEN_CACHE_PATH ?? DEFAULT_TOKEN_CACHE_PATH,
|
|
19
|
+
asyncTimeoutMs: Number.isFinite(asyncTimeoutMs) && asyncTimeoutMs > 0 ? asyncTimeoutMs : DEFAULT_ASYNC_TIMEOUT_MS,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { DownloadError } from "./errors.js";
|
|
5
|
+
const MIME_EXT = {
|
|
6
|
+
"application/pdf": ".pdf",
|
|
7
|
+
"application/msword": ".doc",
|
|
8
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
|
9
|
+
"application/vnd.ms-excel": ".xls",
|
|
10
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
|
11
|
+
"application/vnd.ms-powerpoint": ".ppt",
|
|
12
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
|
13
|
+
"application/zip": ".zip",
|
|
14
|
+
"application/json": ".json",
|
|
15
|
+
"text/plain": ".txt",
|
|
16
|
+
"text/html": ".html",
|
|
17
|
+
"text/csv": ".csv",
|
|
18
|
+
"image/png": ".png",
|
|
19
|
+
"image/jpeg": ".jpg",
|
|
20
|
+
"application/octet-stream": ".bin",
|
|
21
|
+
};
|
|
22
|
+
function extFromContentType(contentType) {
|
|
23
|
+
if (!contentType)
|
|
24
|
+
return ".bin";
|
|
25
|
+
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
26
|
+
return MIME_EXT[mime] ?? ".bin";
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Downloads a file via the Gangtise client and returns a structured result.
|
|
30
|
+
* For binary files, streams to a unique temp directory (not auto-cleaned).
|
|
31
|
+
*/
|
|
32
|
+
export async function downloadToResult(client, endpoint, query) {
|
|
33
|
+
// For binary downloads, generate a unique temp dir first
|
|
34
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gangtise-mcp-"));
|
|
35
|
+
const tempPath = path.join(tempDir, "download.bin");
|
|
36
|
+
const raw = await client.download(endpoint, query, { streamTo: tempPath });
|
|
37
|
+
// Case 1: API returned a redirect/presigned URL
|
|
38
|
+
if (raw.url) {
|
|
39
|
+
// Clean up the unused temp file
|
|
40
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
41
|
+
return { url: raw.url, filename: raw.filename };
|
|
42
|
+
}
|
|
43
|
+
// Case 2: Text content (Markdown, HTML, plain text)
|
|
44
|
+
if (raw.text != null) {
|
|
45
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
46
|
+
return { text: raw.text, filename: raw.filename, contentType: raw.contentType };
|
|
47
|
+
}
|
|
48
|
+
// Case 3: Streamed to disk (binary)
|
|
49
|
+
if (raw.savedPath) {
|
|
50
|
+
const ext = extFromContentType(raw.contentType);
|
|
51
|
+
const filename = raw.filename ?? `download${ext}`;
|
|
52
|
+
// Rename to meaningful extension if needed
|
|
53
|
+
const finalPath = path.join(tempDir, filename);
|
|
54
|
+
if (finalPath !== raw.savedPath) {
|
|
55
|
+
await fs.rename(raw.savedPath, finalPath);
|
|
56
|
+
}
|
|
57
|
+
return { savedPath: finalPath, filename, contentType: raw.contentType };
|
|
58
|
+
}
|
|
59
|
+
// Case 4: In-memory binary (fallback for small files)
|
|
60
|
+
if (raw.data) {
|
|
61
|
+
const ext = extFromContentType(raw.contentType);
|
|
62
|
+
const filename = raw.filename ?? `download${ext}`;
|
|
63
|
+
const finalPath = path.join(tempDir, filename);
|
|
64
|
+
await fs.writeFile(finalPath, raw.data);
|
|
65
|
+
return { savedPath: finalPath, filename, contentType: raw.contentType };
|
|
66
|
+
}
|
|
67
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
68
|
+
throw new DownloadError("Unexpected download response: no url, text, or binary data");
|
|
69
|
+
}
|