tyc-cli 0.3.4 → 0.3.6
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/README.md +65 -50
- package/dist/catalog.json +176 -60
- package/dist/commands/capabilities.js +32 -0
- package/dist/commands/category.js +15 -72
- package/dist/commands/coreCall.js +23 -0
- package/dist/commands/init.js +27 -18
- package/dist/commands/layers.js +13 -1
- package/dist/commands/login.js +290 -0
- package/dist/commands/output.js +60 -0
- package/dist/config.js +131 -7
- package/dist/coreClient.js +121 -0
- package/dist/index.js +75 -82
- package/dist/logo.js +24 -0
- package/dist/oauth.js +430 -0
- package/dist/oauthPending.js +41 -0
- package/dist/types.js +3 -4
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/mcpClient.js +0 -146
- package/dist/session.js +0 -47
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
function trimRightSlash(v) {
|
|
5
|
+
return v.replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function joinURL(base, path) {
|
|
8
|
+
const normalizedBase = base.endsWith("/") ? base : `${base}/`;
|
|
9
|
+
return new URL(path.replace(/^\/+/, ""), normalizedBase).toString();
|
|
10
|
+
}
|
|
11
|
+
async function fetchJSON(url, init) {
|
|
12
|
+
const resp = await fetch(url, {
|
|
13
|
+
...init,
|
|
14
|
+
headers: {
|
|
15
|
+
Accept: "application/json",
|
|
16
|
+
...(init?.headers || {}),
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const text = await resp.text();
|
|
20
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
21
|
+
throw new Error(`HTTP ${resp.status} from ${url}: ${text.slice(0, 300)}`);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
28
|
+
throw new Error(`Invalid JSON from ${url}: ${msg}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function protectedResourceMetadataCandidates(mcpURL) {
|
|
32
|
+
const u = new URL(mcpURL);
|
|
33
|
+
const path = trimRightSlash(u.pathname);
|
|
34
|
+
const out = [
|
|
35
|
+
`${u.origin}/.well-known/oauth-protected-resource${path === "" ? "" : path}`,
|
|
36
|
+
`${u.origin}/.well-known/oauth-protected-resource`,
|
|
37
|
+
];
|
|
38
|
+
return Array.from(new Set(out));
|
|
39
|
+
}
|
|
40
|
+
function authServerMetadataCandidates(issuer) {
|
|
41
|
+
const u = new URL(issuer);
|
|
42
|
+
const path = trimRightSlash(u.pathname);
|
|
43
|
+
const pathSuffix = path === "" ? "" : path;
|
|
44
|
+
const out = [
|
|
45
|
+
`${u.origin}/.well-known/oauth-authorization-server${pathSuffix}`,
|
|
46
|
+
`${u.origin}/.well-known/openid-configuration${pathSuffix}`,
|
|
47
|
+
joinURL(issuer, ".well-known/oauth-authorization-server"),
|
|
48
|
+
joinURL(issuer, ".well-known/openid-configuration"),
|
|
49
|
+
];
|
|
50
|
+
return Array.from(new Set(out));
|
|
51
|
+
}
|
|
52
|
+
function parseWWWAuthenticateParam(header, key) {
|
|
53
|
+
if (!header)
|
|
54
|
+
return undefined;
|
|
55
|
+
const re = new RegExp(`${key}\\s*=\\s*"((?:[^"\\\\]|\\\\.)*)"`, "i");
|
|
56
|
+
const quoted = header.match(re);
|
|
57
|
+
if (quoted)
|
|
58
|
+
return quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
59
|
+
const bare = header.match(new RegExp(`${key}\\s*=\\s*([^,\\s]+)`, "i"));
|
|
60
|
+
return bare?.[1];
|
|
61
|
+
}
|
|
62
|
+
async function discoverMetadataURLFromChallenge(mcpURL) {
|
|
63
|
+
const resp = await fetch(mcpURL, {
|
|
64
|
+
method: "POST",
|
|
65
|
+
headers: {
|
|
66
|
+
Accept: "application/json",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
},
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
jsonrpc: "2.0",
|
|
71
|
+
id: 1,
|
|
72
|
+
method: "tools/call",
|
|
73
|
+
params: { name: "search_companies", arguments: { searchKey: "test" } },
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
return parseWWWAuthenticateParam(resp.headers.get("www-authenticate"), "resource_metadata");
|
|
77
|
+
}
|
|
78
|
+
export async function discoverProtectedResourceMetadata(mcpURL, overrideURL) {
|
|
79
|
+
const candidates = overrideURL
|
|
80
|
+
? [overrideURL]
|
|
81
|
+
: protectedResourceMetadataCandidates(mcpURL);
|
|
82
|
+
const errors = [];
|
|
83
|
+
for (const url of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
return { metadata: await fetchJSON(url), metadataURL: url };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!overrideURL) {
|
|
92
|
+
try {
|
|
93
|
+
const challengedURL = await discoverMetadataURLFromChallenge(mcpURL);
|
|
94
|
+
if (challengedURL) {
|
|
95
|
+
return {
|
|
96
|
+
metadata: await fetchJSON(challengedURL),
|
|
97
|
+
metadataURL: challengedURL,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
throw new Error(`无法发现 OAuth protected resource metadata。请确认 MCP Server 已部署该端点,或使用 --resource-metadata-url 指定。\n${errors.join("\n")}`);
|
|
106
|
+
}
|
|
107
|
+
export async function discoverAuthorizationServer(issuer) {
|
|
108
|
+
const errors = [];
|
|
109
|
+
for (const url of authServerMetadataCandidates(issuer)) {
|
|
110
|
+
try {
|
|
111
|
+
const metadata = await fetchJSON(url);
|
|
112
|
+
// RFC 8414 §3.3:metadata 的 issuer 必须与发现所用 issuer 一致,
|
|
113
|
+
// 不一致说明文档不属于这个授权服务器,不能采信。
|
|
114
|
+
if (trimRightSlash(metadata.issuer || "") !== trimRightSlash(issuer)) {
|
|
115
|
+
errors.push(`metadata issuer mismatch: got ${metadata.issuer || "(empty)"} from ${url}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (metadata.authorization_endpoint && metadata.token_endpoint) {
|
|
119
|
+
if (!metadata.code_challenge_methods_supported?.includes("S256")) {
|
|
120
|
+
errors.push(`metadata missing PKCE S256 support: ${url}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
authorizationEndpoint: metadata.authorization_endpoint,
|
|
125
|
+
tokenEndpoint: metadata.token_endpoint,
|
|
126
|
+
registrationEndpoint: typeof metadata.registration_endpoint === "string"
|
|
127
|
+
? metadata.registration_endpoint
|
|
128
|
+
: undefined,
|
|
129
|
+
issuer,
|
|
130
|
+
resourceDocumentation: typeof metadata.resource_documentation === "string"
|
|
131
|
+
? metadata.resource_documentation
|
|
132
|
+
: undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
errors.push(`metadata missing endpoints: ${url}`);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// MCP 2025-11-25:metadata 不可用或缺 PKCE 声明时必须拒绝继续,
|
|
142
|
+
// 不允许猜测端点。可用 --authorization-endpoint/--token-endpoint 手动指定。
|
|
143
|
+
throw new Error(`无法发现可信的 authorization server metadata(issuer=${issuer})。` +
|
|
144
|
+
`如需绕过自动发现,请同时指定 --authorization-endpoint 与 --token-endpoint。\n${errors.join("\n")}`);
|
|
145
|
+
}
|
|
146
|
+
export function knownOAuthDefaultsForMcpURL(mcpURL) {
|
|
147
|
+
const u = new URL(mcpURL);
|
|
148
|
+
const path = trimRightSlash(u.pathname);
|
|
149
|
+
const resourcePath = path === "/v1" ? "/v1" : "/mcp";
|
|
150
|
+
switch (u.hostname) {
|
|
151
|
+
case "ai-mcp-pre.tianyancha.com":
|
|
152
|
+
return {
|
|
153
|
+
issuer: "https://ai-pre.tianyancha.com/oauth",
|
|
154
|
+
resource: `https://ai-mcp-pre.tianyancha.com${resourcePath}`,
|
|
155
|
+
};
|
|
156
|
+
case "mcp.tianyancha.com":
|
|
157
|
+
return {
|
|
158
|
+
issuer: "https://ai.tianyancha.com/oauth",
|
|
159
|
+
resource: `https://mcp.tianyancha.com${resourcePath}`,
|
|
160
|
+
};
|
|
161
|
+
default:
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
export function createPKCE() {
|
|
166
|
+
const codeVerifier = randomBytes(32).toString("base64url");
|
|
167
|
+
const codeChallenge = createHash("sha256")
|
|
168
|
+
.update(codeVerifier)
|
|
169
|
+
.digest("base64url");
|
|
170
|
+
return { codeVerifier, codeChallenge };
|
|
171
|
+
}
|
|
172
|
+
export function randomState() {
|
|
173
|
+
return randomBytes(24).toString("base64url");
|
|
174
|
+
}
|
|
175
|
+
function callbackHTML(title, body) {
|
|
176
|
+
return `<!doctype html>
|
|
177
|
+
<meta charset="utf-8">
|
|
178
|
+
<title>${title}</title>
|
|
179
|
+
<body style="font-family: system-ui, -apple-system, sans-serif; line-height: 1.5; padding: 32px;">
|
|
180
|
+
<h1>${title}</h1>
|
|
181
|
+
<p>${body}</p>
|
|
182
|
+
</body>`;
|
|
183
|
+
}
|
|
184
|
+
export async function startCallbackServer(opts) {
|
|
185
|
+
let server;
|
|
186
|
+
let settled = false;
|
|
187
|
+
let timer;
|
|
188
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
189
|
+
timer = setTimeout(() => {
|
|
190
|
+
if (settled)
|
|
191
|
+
return;
|
|
192
|
+
settled = true;
|
|
193
|
+
reject(new Error("等待 OAuth 回调超时"));
|
|
194
|
+
server?.close();
|
|
195
|
+
}, opts.timeoutMs);
|
|
196
|
+
server = createServer((req, res) => {
|
|
197
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || opts.host}`);
|
|
198
|
+
if (url.pathname !== opts.path) {
|
|
199
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
200
|
+
res.end("Not found");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (settled) {
|
|
204
|
+
res.writeHead(409, { "Content-Type": "text/html; charset=utf-8" });
|
|
205
|
+
res.end(callbackHTML("OAuth 已处理", "可以关闭这个窗口。"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const gotState = url.searchParams.get("state") || "";
|
|
209
|
+
const error = url.searchParams.get("error");
|
|
210
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
211
|
+
const code = url.searchParams.get("code");
|
|
212
|
+
if (gotState !== opts.state) {
|
|
213
|
+
settled = true;
|
|
214
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
215
|
+
res.end(callbackHTML("OAuth 登录失败", "state 校验失败,请回到终端重新运行 tyc login。"));
|
|
216
|
+
reject(new Error("OAuth state mismatch"));
|
|
217
|
+
server?.close();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (error) {
|
|
221
|
+
settled = true;
|
|
222
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
223
|
+
res.end(callbackHTML("OAuth 登录失败", errorDescription || error));
|
|
224
|
+
reject(new Error(`OAuth error: ${errorDescription || error}`));
|
|
225
|
+
server?.close();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!code) {
|
|
229
|
+
settled = true;
|
|
230
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
231
|
+
res.end(callbackHTML("OAuth 登录失败", "授权服务未返回 code。"));
|
|
232
|
+
reject(new Error("OAuth callback missing code"));
|
|
233
|
+
server?.close();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
settled = true;
|
|
237
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
238
|
+
res.end(callbackHTML("OAuth 登录成功", "tyc CLI 已收到授权结果,可以关闭这个窗口。"));
|
|
239
|
+
resolve(code);
|
|
240
|
+
server?.close();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
if (!server) {
|
|
245
|
+
reject(new Error("callback server not initialized"));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const onError = (err) => reject(err);
|
|
249
|
+
server.once("error", onError);
|
|
250
|
+
server.listen(opts.port, opts.host, () => {
|
|
251
|
+
server?.off("error", onError);
|
|
252
|
+
resolve();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
const address = server?.address();
|
|
256
|
+
if (!address || typeof address.port !== "number") {
|
|
257
|
+
server?.close();
|
|
258
|
+
throw new Error("无法启动 OAuth 本地回调服务");
|
|
259
|
+
}
|
|
260
|
+
const redirectUri = `http://${opts.host}:${address.port}${opts.path}`;
|
|
261
|
+
return {
|
|
262
|
+
redirectUri,
|
|
263
|
+
close: () => {
|
|
264
|
+
if (timer)
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
server?.close();
|
|
267
|
+
},
|
|
268
|
+
waitForCode: async () => {
|
|
269
|
+
try {
|
|
270
|
+
return await codePromise;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (timer)
|
|
274
|
+
clearTimeout(timer);
|
|
275
|
+
server?.close();
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
export function buildAuthorizationURL(opts) {
|
|
281
|
+
const url = new URL(opts.authorizationEndpoint);
|
|
282
|
+
url.searchParams.set("response_type", "code");
|
|
283
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
284
|
+
url.searchParams.set("redirect_uri", opts.redirectUri);
|
|
285
|
+
url.searchParams.set("scope", opts.scope);
|
|
286
|
+
url.searchParams.set("state", opts.state);
|
|
287
|
+
url.searchParams.set("code_challenge", opts.codeChallenge);
|
|
288
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
289
|
+
if (opts.resource)
|
|
290
|
+
url.searchParams.set("resource", opts.resource);
|
|
291
|
+
return url.toString();
|
|
292
|
+
}
|
|
293
|
+
export async function registerDynamicOAuthClient(opts) {
|
|
294
|
+
const registered = await fetchJSON(opts.registrationEndpoint, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
headers: { "Content-Type": "application/json" },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
client_name: opts.clientName,
|
|
299
|
+
redirect_uris: opts.redirectUris,
|
|
300
|
+
grant_types: opts.grantTypes || ["authorization_code", "refresh_token"],
|
|
301
|
+
response_types: opts.responseTypes || ["code"],
|
|
302
|
+
token_endpoint_auth_method: opts.tokenEndpointAuthMethod || "none",
|
|
303
|
+
application_type: "native",
|
|
304
|
+
}),
|
|
305
|
+
});
|
|
306
|
+
if (!registered.client_id) {
|
|
307
|
+
throw new Error("DCR response missing client_id");
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
clientId: registered.client_id,
|
|
311
|
+
clientSecret: registered.client_secret,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
export async function exchangeAuthorizationCode(opts) {
|
|
315
|
+
const body = new URLSearchParams();
|
|
316
|
+
body.set("grant_type", "authorization_code");
|
|
317
|
+
body.set("client_id", opts.clientId);
|
|
318
|
+
body.set("code", opts.code);
|
|
319
|
+
body.set("redirect_uri", opts.redirectUri);
|
|
320
|
+
body.set("code_verifier", opts.codeVerifier);
|
|
321
|
+
if (opts.clientSecret)
|
|
322
|
+
body.set("client_secret", opts.clientSecret);
|
|
323
|
+
if (opts.resource)
|
|
324
|
+
body.set("resource", opts.resource);
|
|
325
|
+
const resp = await fetch(opts.tokenEndpoint, {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: {
|
|
328
|
+
Accept: "application/json",
|
|
329
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
330
|
+
},
|
|
331
|
+
body,
|
|
332
|
+
});
|
|
333
|
+
const text = await resp.text();
|
|
334
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
335
|
+
throw new Error(`token endpoint HTTP ${resp.status}: ${text.slice(0, 500)}`);
|
|
336
|
+
}
|
|
337
|
+
const token = JSON.parse(text);
|
|
338
|
+
if (!token.access_token) {
|
|
339
|
+
throw new Error("token endpoint response missing access_token");
|
|
340
|
+
}
|
|
341
|
+
return token;
|
|
342
|
+
}
|
|
343
|
+
export async function exchangeRefreshToken(opts) {
|
|
344
|
+
const body = new URLSearchParams();
|
|
345
|
+
body.set("grant_type", "refresh_token");
|
|
346
|
+
body.set("client_id", opts.clientId);
|
|
347
|
+
body.set("refresh_token", opts.refreshToken);
|
|
348
|
+
if (opts.clientSecret)
|
|
349
|
+
body.set("client_secret", opts.clientSecret);
|
|
350
|
+
if (opts.resource)
|
|
351
|
+
body.set("resource", opts.resource);
|
|
352
|
+
const resp = await fetch(opts.tokenEndpoint, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: {
|
|
355
|
+
Accept: "application/json",
|
|
356
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
357
|
+
},
|
|
358
|
+
body,
|
|
359
|
+
});
|
|
360
|
+
const text = await resp.text();
|
|
361
|
+
if (resp.status < 200 || resp.status >= 300) {
|
|
362
|
+
throw new Error(`token refresh HTTP ${resp.status}: ${text.slice(0, 500)}`);
|
|
363
|
+
}
|
|
364
|
+
const token = JSON.parse(text);
|
|
365
|
+
if (!token.access_token) {
|
|
366
|
+
throw new Error("token refresh response missing access_token");
|
|
367
|
+
}
|
|
368
|
+
return token;
|
|
369
|
+
}
|
|
370
|
+
function callbackSearchParams(raw) {
|
|
371
|
+
try {
|
|
372
|
+
const url = new URL(raw);
|
|
373
|
+
if (url.search)
|
|
374
|
+
return url.searchParams;
|
|
375
|
+
if (url.hash.includes("="))
|
|
376
|
+
return new URLSearchParams(url.hash.replace(/^#/, ""));
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
if (raw.startsWith("?") || raw.includes("code=") || raw.includes("error=")) {
|
|
381
|
+
return new URLSearchParams(raw.replace(/^[?#]/, ""));
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
export function parseAuthorizationCallbackCode(raw, expectedState) {
|
|
387
|
+
const trimmed = raw.trim();
|
|
388
|
+
if (!trimmed) {
|
|
389
|
+
throw new Error("empty callback token");
|
|
390
|
+
}
|
|
391
|
+
const params = callbackSearchParams(trimmed);
|
|
392
|
+
if (!params) {
|
|
393
|
+
return trimmed;
|
|
394
|
+
}
|
|
395
|
+
const error = params.get("error");
|
|
396
|
+
if (error) {
|
|
397
|
+
throw new Error(`OAuth error: ${params.get("error_description") || error}`);
|
|
398
|
+
}
|
|
399
|
+
const state = params.get("state");
|
|
400
|
+
if (expectedState && state && state !== expectedState) {
|
|
401
|
+
throw new Error("OAuth state mismatch");
|
|
402
|
+
}
|
|
403
|
+
const code = params.get("code");
|
|
404
|
+
if (!code) {
|
|
405
|
+
throw new Error("callback URL missing code");
|
|
406
|
+
}
|
|
407
|
+
return code;
|
|
408
|
+
}
|
|
409
|
+
export function openBrowser(url) {
|
|
410
|
+
const platform = process.platform;
|
|
411
|
+
let command;
|
|
412
|
+
let args;
|
|
413
|
+
if (platform === "darwin") {
|
|
414
|
+
command = "open";
|
|
415
|
+
args = [url];
|
|
416
|
+
}
|
|
417
|
+
else if (platform === "win32") {
|
|
418
|
+
command = "cmd";
|
|
419
|
+
args = ["/c", "start", "", url];
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
command = "xdg-open";
|
|
423
|
+
args = [url];
|
|
424
|
+
}
|
|
425
|
+
const child = spawn(command, args, {
|
|
426
|
+
detached: true,
|
|
427
|
+
stdio: "ignore",
|
|
428
|
+
});
|
|
429
|
+
child.unref();
|
|
430
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ~/.tyc/oauth_pending.json 读写
|
|
2
|
+
//
|
|
3
|
+
// 非阻塞 OAuth 登录会先保存 PKCE code_verifier、state、client_id 等一次性上下文;
|
|
4
|
+
// 用户完成浏览器授权后,再用 tyc login --callback-token <code_or_callback_url> 继续换取 access token。
|
|
5
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
const PENDING_DIR = join(homedir(), ".tyc");
|
|
9
|
+
export const PENDING_FILE = join(PENDING_DIR, "oauth_pending.json");
|
|
10
|
+
export function loadPendingOAuthLogin() {
|
|
11
|
+
if (!existsSync(PENDING_FILE))
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(PENDING_FILE, "utf-8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
19
|
+
throw new Error(`读取 OAuth pending 状态失败:${PENDING_FILE}:${msg}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function savePendingOAuthLogin(pending) {
|
|
23
|
+
if (!existsSync(PENDING_DIR))
|
|
24
|
+
mkdirSync(PENDING_DIR, { recursive: true, mode: 0o700 });
|
|
25
|
+
writeFileSync(PENDING_FILE, JSON.stringify(pending, null, 2) + "\n", "utf-8");
|
|
26
|
+
try {
|
|
27
|
+
chmodSync(PENDING_FILE, 0o600);
|
|
28
|
+
}
|
|
29
|
+
catch { /* best effort */ }
|
|
30
|
+
}
|
|
31
|
+
export function clearPendingOAuthLogin(verbose = false) {
|
|
32
|
+
try {
|
|
33
|
+
rmSync(PENDING_FILE, { force: true });
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (verbose) {
|
|
37
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
38
|
+
console.error(`> 清理 OAuth pending 状态失败(忽略):${PENDING_FILE}:${msg}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// CLI 运行时模型:
|
|
4
4
|
// - Catalog:构建时由 apimcp 主仓生成,打包在 src/catalog.json,用于构建命令树
|
|
5
|
-
// - MCP
|
|
6
|
-
// -
|
|
7
|
-
//
|
|
8
|
-
// { url, headers }
|
|
5
|
+
// - Tool call 结果:沿用 MCP content shape,便于共享输出逻辑
|
|
6
|
+
// - Config:CLI 配置(~/.tyc/config.json),{ url, coreUrl, headers };
|
|
7
|
+
// OAuth 登录会额外保存 oauth 刷新上下文
|
|
9
8
|
export {};
|
package/dist/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// CLI 版本号单一来源:从 package.json 读取
|
|
2
2
|
//
|
|
3
|
-
// 历史上 index.ts 与
|
|
3
|
+
// 历史上 index.ts 与 client 实现各自硬编码版本字符串,多处漂移。
|
|
4
4
|
// 收敛到 package.json 一处后,bump 版本只需 npm version <x>。
|
|
5
5
|
// 运行时 fs 读:dev (`tsx src/version.ts`) 与 dist (`dist/version.js`) 与
|
|
6
6
|
// package.json 都同级 (cli/t1_1/{src,dist} ↔ cli/t1_1/package.json),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tyc-cli",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "天眼查
|
|
3
|
+
"version": "0.3.6",
|
|
4
|
+
"description": "天眼查 AI CLI — 162 个业务语义聚合工具 · 默认 Shared Core 调用 · 兼容 MCP transport",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tyc": "./dist/index.js"
|
package/dist/mcpClient.js
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
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
|
-
import { VERSION } from "./version.js";
|
|
14
|
-
const PROTOCOL_VERSION = "2024-11-05";
|
|
15
|
-
function maskToken(v) {
|
|
16
|
-
if (!v)
|
|
17
|
-
return "";
|
|
18
|
-
if (v.length <= 8)
|
|
19
|
-
return "****";
|
|
20
|
-
return v.slice(0, 4) + "****" + v.slice(-4);
|
|
21
|
-
}
|
|
22
|
-
function logVerbose(cfg, title, extra) {
|
|
23
|
-
const masked = {};
|
|
24
|
-
for (const [k, v] of Object.entries(cfg.headers)) {
|
|
25
|
-
masked[k] = k.toLowerCase() === "authorization" ? maskToken(v) : v;
|
|
26
|
-
}
|
|
27
|
-
console.error(`> ${title}`);
|
|
28
|
-
console.error(`> headers: ${JSON.stringify(masked)}`);
|
|
29
|
-
if (extra)
|
|
30
|
-
console.error(extra);
|
|
31
|
-
}
|
|
32
|
-
/** 解析 MCP Streamable HTTP 响应——server 端实测固定回 application/json 单包,
|
|
33
|
-
* 无论 client Accept 头声明什么。SSE 路径不存在,所以只解析 JSON。 */
|
|
34
|
-
function parseJsonRpc(raw) {
|
|
35
|
-
const trimmed = raw.trim();
|
|
36
|
-
if (!trimmed.startsWith("{")) {
|
|
37
|
-
throw new Error(`Unparseable MCP response: ${trimmed.slice(0, 200)}`);
|
|
38
|
-
}
|
|
39
|
-
return JSON.parse(trimmed);
|
|
40
|
-
}
|
|
41
|
-
async function httpPost(url, headers, body, verbose) {
|
|
42
|
-
const h = {
|
|
43
|
-
"Content-Type": "application/json",
|
|
44
|
-
"Accept": "application/json",
|
|
45
|
-
...headers,
|
|
46
|
-
};
|
|
47
|
-
if (verbose) {
|
|
48
|
-
console.error(`> POST ${url}`);
|
|
49
|
-
const masked = {};
|
|
50
|
-
for (const [k, v] of Object.entries(h)) {
|
|
51
|
-
masked[k] = k.toLowerCase() === "authorization" ? maskToken(v) : v;
|
|
52
|
-
}
|
|
53
|
-
console.error(`> headers: ${JSON.stringify(masked)}`);
|
|
54
|
-
console.error(`> body: ${JSON.stringify(body)}`);
|
|
55
|
-
}
|
|
56
|
-
const resp = await fetch(url, {
|
|
57
|
-
method: "POST",
|
|
58
|
-
headers: h,
|
|
59
|
-
body: JSON.stringify(body),
|
|
60
|
-
});
|
|
61
|
-
const text = await resp.text();
|
|
62
|
-
if (verbose) {
|
|
63
|
-
console.error(`< ${resp.status} ${resp.statusText}`);
|
|
64
|
-
console.error(`< body: ${text.slice(0, 2000)}`);
|
|
65
|
-
}
|
|
66
|
-
return { status: resp.status, text, headers: resp.headers };
|
|
67
|
-
}
|
|
68
|
-
async function doInitialize(cfg, verbose) {
|
|
69
|
-
if (verbose)
|
|
70
|
-
logVerbose(cfg, `MCP initialize → ${cfg.url}`);
|
|
71
|
-
const { status, text, headers } = await httpPost(cfg.url, cfg.headers, {
|
|
72
|
-
jsonrpc: "2.0",
|
|
73
|
-
id: 1,
|
|
74
|
-
method: "initialize",
|
|
75
|
-
params: {
|
|
76
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
77
|
-
capabilities: {},
|
|
78
|
-
clientInfo: { name: "tyc-cli", version: VERSION },
|
|
79
|
-
},
|
|
80
|
-
}, verbose);
|
|
81
|
-
if (status < 200 || status >= 300) {
|
|
82
|
-
throw new Error(`initialize 失败:HTTP ${status}\n${text.slice(0, 500)}`);
|
|
83
|
-
}
|
|
84
|
-
const sessionId = headers.get("Mcp-Session-Id") || headers.get("mcp-session-id");
|
|
85
|
-
if (!sessionId) {
|
|
86
|
-
throw new Error(`initialize 响应未包含 Mcp-Session-Id header。请确认 ${cfg.url} 是 Streamable HTTP MCP Server。`);
|
|
87
|
-
}
|
|
88
|
-
const rpc = parseJsonRpc(text);
|
|
89
|
-
if (rpc.error) {
|
|
90
|
-
throw new Error(`initialize JSON-RPC error: ${rpc.error.message}`);
|
|
91
|
-
}
|
|
92
|
-
const s = {
|
|
93
|
-
url: cfg.url,
|
|
94
|
-
sessionId,
|
|
95
|
-
initializedAt: Date.now(),
|
|
96
|
-
protocolVersion: rpc.result?.protocolVersion,
|
|
97
|
-
};
|
|
98
|
-
saveSession(s);
|
|
99
|
-
return s;
|
|
100
|
-
}
|
|
101
|
-
/** 获取可用 session:若缓存新鲜则复用,否则 initialize + 落盘。 */
|
|
102
|
-
export async function ensureSession(cfg, opts) {
|
|
103
|
-
const cached = loadSession();
|
|
104
|
-
if (isSessionFresh(cached, cfg.url))
|
|
105
|
-
return cached;
|
|
106
|
-
return doInitialize(cfg, !!opts?.verbose);
|
|
107
|
-
}
|
|
108
|
-
function isSessionInvalidResponse(status, text) {
|
|
109
|
-
if (status === 404 || status === 410)
|
|
110
|
-
return true;
|
|
111
|
-
const lower = text.toLowerCase();
|
|
112
|
-
return lower.includes("session not found") || lower.includes("invalid session");
|
|
113
|
-
}
|
|
114
|
-
/** 调用 MCP tool;透明处理 session 失效重建。 */
|
|
115
|
-
export async function callTool(cfg, name, args, opts) {
|
|
116
|
-
const verbose = !!opts?.verbose;
|
|
117
|
-
let session = await ensureSession(cfg, opts);
|
|
118
|
-
const doCall = async (s) => {
|
|
119
|
-
const h = { ...cfg.headers, "Mcp-Session-Id": s.sessionId };
|
|
120
|
-
return httpPost(cfg.url, h, {
|
|
121
|
-
jsonrpc: "2.0",
|
|
122
|
-
id: 2,
|
|
123
|
-
method: "tools/call",
|
|
124
|
-
params: { name, arguments: args },
|
|
125
|
-
}, verbose);
|
|
126
|
-
};
|
|
127
|
-
let resp = await doCall(session);
|
|
128
|
-
if (isSessionInvalidResponse(resp.status, resp.text)) {
|
|
129
|
-
if (verbose)
|
|
130
|
-
console.error("> session invalid, re-initializing …");
|
|
131
|
-
clearSession();
|
|
132
|
-
session = await doInitialize(cfg, verbose);
|
|
133
|
-
resp = await doCall(session);
|
|
134
|
-
}
|
|
135
|
-
if (resp.status < 200 || resp.status >= 300) {
|
|
136
|
-
throw new Error(`tools/call HTTP ${resp.status}\n${resp.text.slice(0, 500)}`);
|
|
137
|
-
}
|
|
138
|
-
const rpc = parseJsonRpc(resp.text);
|
|
139
|
-
if (rpc.error) {
|
|
140
|
-
throw new Error(`MCP error ${rpc.error.code}: ${rpc.error.message}`);
|
|
141
|
-
}
|
|
142
|
-
if (!rpc.result) {
|
|
143
|
-
throw new Error("tools/call 响应缺少 result 字段");
|
|
144
|
-
}
|
|
145
|
-
return rpc.result;
|
|
146
|
-
}
|