tyc-cli 0.3.4 → 0.3.5
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 +20 -12
- package/dist/catalog.json +176 -60
- package/dist/commands/category.js +4 -1
- package/dist/commands/init.js +32 -6
- package/dist/commands/login.js +210 -0
- package/dist/config.js +30 -6
- package/dist/coreClient.js +121 -0
- package/dist/index.js +9 -4
- package/dist/logo.js +24 -0
- package/dist/oauth.js +364 -0
- package/package.json +2 -2
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
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 function openBrowser(url) {
|
|
344
|
+
const platform = process.platform;
|
|
345
|
+
let command;
|
|
346
|
+
let args;
|
|
347
|
+
if (platform === "darwin") {
|
|
348
|
+
command = "open";
|
|
349
|
+
args = [url];
|
|
350
|
+
}
|
|
351
|
+
else if (platform === "win32") {
|
|
352
|
+
command = "cmd";
|
|
353
|
+
args = ["/c", "start", "", url];
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
command = "xdg-open";
|
|
357
|
+
args = [url];
|
|
358
|
+
}
|
|
359
|
+
const child = spawn(command, args, {
|
|
360
|
+
detached: true,
|
|
361
|
+
stdio: "ignore",
|
|
362
|
+
});
|
|
363
|
+
child.unref();
|
|
364
|
+
}
|
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.5",
|
|
4
|
+
"description": "天眼查 AI CLI — 162 个业务语义聚合工具 · 默认 Shared Core 调用 · 兼容 MCP transport",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"tyc": "./dist/index.js"
|