@infimind-next/ai-hive-mcp 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.
- package/dist/api-client.d.ts +11 -7
- package/dist/api-client.js +45 -15
- package/dist/auth-token-provider.d.ts +34 -0
- package/dist/auth-token-provider.js +115 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +320 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +40 -7
- package/dist/index.js +15 -5
- package/dist/server.d.ts +11 -2
- package/dist/server.js +35 -8
- package/dist/workbuddy-credentials.d.ts +32 -0
- package/dist/workbuddy-credentials.js +340 -0
- package/dist/workbuddy-loopback.d.ts +10 -0
- package/dist/workbuddy-loopback.js +214 -0
- package/dist/workbuddy-oauth-client.d.ts +31 -0
- package/dist/workbuddy-oauth-client.js +126 -0
- package/dist/workbuddy-pkce.d.ts +6 -0
- package/dist/workbuddy-pkce.js +11 -0
- package/package.json +3 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import type { BearerTokenProvider } from "./auth-token-provider.js";
|
|
1
2
|
type FetchImpl = typeof fetch;
|
|
3
|
+
type AiHiveApiClientInput = {
|
|
4
|
+
endpoint: string;
|
|
5
|
+
tokenProvider: BearerTokenProvider;
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
fetchImpl?: FetchImpl;
|
|
8
|
+
};
|
|
2
9
|
export declare class AiHiveApiClient {
|
|
3
10
|
private readonly endpoint;
|
|
4
|
-
private readonly
|
|
11
|
+
private readonly tokenProvider;
|
|
5
12
|
private readonly timeoutMs;
|
|
6
13
|
private readonly fetchImpl;
|
|
7
|
-
constructor(input:
|
|
8
|
-
endpoint: string;
|
|
9
|
-
key: string;
|
|
10
|
-
timeoutMs: number;
|
|
11
|
-
fetchImpl?: FetchImpl;
|
|
12
|
-
});
|
|
14
|
+
constructor(input: AiHiveApiClientInput);
|
|
13
15
|
private withTimeout;
|
|
14
16
|
private request;
|
|
17
|
+
private send;
|
|
18
|
+
private readResponse;
|
|
15
19
|
getUserInfo(): Promise<unknown>;
|
|
16
20
|
listModels(input: Record<string, unknown>): Promise<unknown>;
|
|
17
21
|
createMediaUpload(input: unknown): Promise<unknown>;
|
package/dist/api-client.js
CHANGED
|
@@ -33,14 +33,22 @@ function errorMessage(body) {
|
|
|
33
33
|
function timeoutError() {
|
|
34
34
|
return new Error("请求超时,请稍后重试");
|
|
35
35
|
}
|
|
36
|
+
async function discardResponseBody(response) {
|
|
37
|
+
try {
|
|
38
|
+
await response.body?.cancel();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
36
44
|
export class AiHiveApiClient {
|
|
37
45
|
endpoint;
|
|
38
|
-
|
|
46
|
+
tokenProvider;
|
|
39
47
|
timeoutMs;
|
|
40
48
|
fetchImpl;
|
|
41
49
|
constructor(input) {
|
|
42
50
|
this.endpoint = input.endpoint.replace(/\/+$/, "");
|
|
43
|
-
this.
|
|
51
|
+
this.tokenProvider = input.tokenProvider;
|
|
44
52
|
this.timeoutMs = input.timeoutMs;
|
|
45
53
|
this.fetchImpl = input.fetchImpl ?? fetch;
|
|
46
54
|
}
|
|
@@ -70,22 +78,44 @@ export class AiHiveApiClient {
|
|
|
70
78
|
}
|
|
71
79
|
async request(path, init = {}) {
|
|
72
80
|
return this.withTimeout(async (signal) => {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
81
|
+
const rejectedToken = await this.tokenProvider.getToken();
|
|
82
|
+
let response = await this.send(path, init, signal, rejectedToken);
|
|
83
|
+
if (response.status === 401) {
|
|
84
|
+
let refreshedToken;
|
|
85
|
+
try {
|
|
86
|
+
refreshedToken =
|
|
87
|
+
await this.tokenProvider.refreshAfterUnauthorized(rejectedToken);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
await discardResponseBody(response);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (refreshedToken && refreshedToken !== rejectedToken) {
|
|
94
|
+
await discardResponseBody(response);
|
|
95
|
+
response = await this.send(path, init, signal, refreshedToken);
|
|
96
|
+
}
|
|
85
97
|
}
|
|
86
|
-
return
|
|
98
|
+
return this.readResponse(response);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
send(path, init, signal, token) {
|
|
102
|
+
return this.fetchImpl(`${this.endpoint}${path}`, {
|
|
103
|
+
...init,
|
|
104
|
+
signal,
|
|
105
|
+
headers: {
|
|
106
|
+
Authorization: `Bearer ${token}`,
|
|
107
|
+
...(init.body ? { "Content-Type": "application/json" } : {}),
|
|
108
|
+
...init.headers,
|
|
109
|
+
},
|
|
87
110
|
});
|
|
88
111
|
}
|
|
112
|
+
async readResponse(response) {
|
|
113
|
+
const body = await response.json().catch(() => ({}));
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(errorMessage(body));
|
|
116
|
+
}
|
|
117
|
+
return body;
|
|
118
|
+
}
|
|
89
119
|
getUserInfo() {
|
|
90
120
|
return this.request("/mcp/user-info");
|
|
91
121
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type WorkBuddyOAuthClient } from "./workbuddy-oauth-client.js";
|
|
2
|
+
import type { WorkBuddyCredentialStore } from "./workbuddy-credentials.js";
|
|
3
|
+
export type BearerTokenProvider = {
|
|
4
|
+
getToken(): Promise<string>;
|
|
5
|
+
refreshAfterUnauthorized(rejectedToken: string): Promise<string | null>;
|
|
6
|
+
};
|
|
7
|
+
export declare function normalizeApiEndpoint(endpoint: string): string;
|
|
8
|
+
export declare class StaticBearerTokenProvider implements BearerTokenProvider {
|
|
9
|
+
private readonly token;
|
|
10
|
+
constructor(token: string);
|
|
11
|
+
getToken(): Promise<string>;
|
|
12
|
+
refreshAfterUnauthorized(): Promise<string | null>;
|
|
13
|
+
}
|
|
14
|
+
export declare class WorkBuddyTokenProvider implements BearerTokenProvider {
|
|
15
|
+
private readonly store;
|
|
16
|
+
private readonly oauthClientFor;
|
|
17
|
+
private readonly now;
|
|
18
|
+
private readonly refreshSkewMs;
|
|
19
|
+
private readonly expectedApiEndpoint;
|
|
20
|
+
constructor(input: {
|
|
21
|
+
store: WorkBuddyCredentialStore;
|
|
22
|
+
oauthClientFor: (apiEndpoint: string) => WorkBuddyOAuthClient;
|
|
23
|
+
expectedApiEndpoint: string;
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
refreshSkewMs?: number;
|
|
26
|
+
});
|
|
27
|
+
getToken(): Promise<string>;
|
|
28
|
+
refreshAfterUnauthorized(rejectedToken: string): Promise<string | null>;
|
|
29
|
+
private requireCredentials;
|
|
30
|
+
private readCredentials;
|
|
31
|
+
private withStoreLock;
|
|
32
|
+
private needsRefresh;
|
|
33
|
+
private refresh;
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isWorkBuddyOAuthError, } from "./workbuddy-oauth-client.js";
|
|
2
|
+
class WorkBuddyTokenProviderError extends Error {
|
|
3
|
+
}
|
|
4
|
+
function credentialStoreError() {
|
|
5
|
+
return new WorkBuddyTokenProviderError("WorkBuddy 凭证操作失败,请在 WorkBuddy 中重新连接");
|
|
6
|
+
}
|
|
7
|
+
function endpointMismatchError() {
|
|
8
|
+
return new WorkBuddyTokenProviderError("API endpoint 与 WorkBuddy 凭证不一致,请在 WorkBuddy 中重新认证");
|
|
9
|
+
}
|
|
10
|
+
export function normalizeApiEndpoint(endpoint) {
|
|
11
|
+
return new URL(endpoint).href.replace(/\/+$/, "");
|
|
12
|
+
}
|
|
13
|
+
export class StaticBearerTokenProvider {
|
|
14
|
+
token;
|
|
15
|
+
constructor(token) {
|
|
16
|
+
this.token = token;
|
|
17
|
+
}
|
|
18
|
+
async getToken() {
|
|
19
|
+
return this.token;
|
|
20
|
+
}
|
|
21
|
+
async refreshAfterUnauthorized() {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export class WorkBuddyTokenProvider {
|
|
26
|
+
store;
|
|
27
|
+
oauthClientFor;
|
|
28
|
+
now;
|
|
29
|
+
refreshSkewMs;
|
|
30
|
+
expectedApiEndpoint;
|
|
31
|
+
constructor(input) {
|
|
32
|
+
this.store = input.store;
|
|
33
|
+
this.oauthClientFor = input.oauthClientFor;
|
|
34
|
+
this.expectedApiEndpoint = normalizeApiEndpoint(input.expectedApiEndpoint);
|
|
35
|
+
this.now = input.now ?? (() => new Date());
|
|
36
|
+
this.refreshSkewMs = input.refreshSkewMs ?? 60_000;
|
|
37
|
+
}
|
|
38
|
+
async getToken() {
|
|
39
|
+
const credentials = this.requireCredentials(await this.readCredentials());
|
|
40
|
+
if (!this.needsRefresh(credentials)) {
|
|
41
|
+
return credentials.accessToken;
|
|
42
|
+
}
|
|
43
|
+
return this.withStoreLock(async () => {
|
|
44
|
+
const current = this.requireCredentials(await this.readCredentials());
|
|
45
|
+
if (!this.needsRefresh(current)) {
|
|
46
|
+
return current.accessToken;
|
|
47
|
+
}
|
|
48
|
+
return this.refresh(current);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async refreshAfterUnauthorized(rejectedToken) {
|
|
52
|
+
return this.withStoreLock(async () => {
|
|
53
|
+
const current = this.requireCredentials(await this.readCredentials());
|
|
54
|
+
if (current.accessToken !== rejectedToken) {
|
|
55
|
+
return current.accessToken;
|
|
56
|
+
}
|
|
57
|
+
return this.refresh(current);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
requireCredentials(credentials) {
|
|
61
|
+
if (!credentials) {
|
|
62
|
+
throw new WorkBuddyTokenProviderError("尚未连接 AI-HIVE,请在 WorkBuddy 中重新连接");
|
|
63
|
+
}
|
|
64
|
+
return credentials;
|
|
65
|
+
}
|
|
66
|
+
async readCredentials() {
|
|
67
|
+
let credentials;
|
|
68
|
+
try {
|
|
69
|
+
credentials = await this.store.read();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw credentialStoreError();
|
|
73
|
+
}
|
|
74
|
+
if (credentials &&
|
|
75
|
+
normalizeApiEndpoint(credentials.apiEndpoint) !== this.expectedApiEndpoint) {
|
|
76
|
+
throw endpointMismatchError();
|
|
77
|
+
}
|
|
78
|
+
return credentials;
|
|
79
|
+
}
|
|
80
|
+
async withStoreLock(action) {
|
|
81
|
+
try {
|
|
82
|
+
return await this.store.withLock(action);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error instanceof WorkBuddyTokenProviderError ||
|
|
86
|
+
isWorkBuddyOAuthError(error)) {
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
throw credentialStoreError();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
needsRefresh(credentials) {
|
|
93
|
+
return (new Date(credentials.accessExpiresAt).getTime() - this.now().getTime() <
|
|
94
|
+
this.refreshSkewMs);
|
|
95
|
+
}
|
|
96
|
+
async refresh(credentials) {
|
|
97
|
+
if (normalizeApiEndpoint(credentials.apiEndpoint) !== this.expectedApiEndpoint) {
|
|
98
|
+
throw endpointMismatchError();
|
|
99
|
+
}
|
|
100
|
+
const payload = await this.oauthClientFor(credentials.apiEndpoint).refreshToken(credentials.refreshToken);
|
|
101
|
+
const refreshedAt = this.now();
|
|
102
|
+
if (normalizeApiEndpoint(credentials.apiEndpoint) !== this.expectedApiEndpoint) {
|
|
103
|
+
throw endpointMismatchError();
|
|
104
|
+
}
|
|
105
|
+
await this.store.write({
|
|
106
|
+
...credentials,
|
|
107
|
+
accessToken: payload.accessToken,
|
|
108
|
+
refreshToken: payload.refreshToken,
|
|
109
|
+
accessExpiresAt: new Date(refreshedAt.getTime() + payload.expiresIn * 1_000).toISOString(),
|
|
110
|
+
refreshExpiresAt: new Date(refreshedAt.getTime() + payload.refreshExpiresIn * 1_000).toISOString(),
|
|
111
|
+
updatedAt: refreshedAt.toISOString(),
|
|
112
|
+
});
|
|
113
|
+
return payload.accessToken;
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { McpClientConfig } from "./config.js";
|
|
2
|
+
import { runServer as runMcpServer } from "./server.js";
|
|
3
|
+
import { WorkBuddyCredentialStore } from "./workbuddy-credentials.js";
|
|
4
|
+
import { WorkBuddyOAuthClient } from "./workbuddy-oauth-client.js";
|
|
5
|
+
import { listenForWorkBuddyCallback } from "./workbuddy-loopback.js";
|
|
6
|
+
export type CliDependencies = {
|
|
7
|
+
stdout(line: string): void;
|
|
8
|
+
stderr(line: string): void;
|
|
9
|
+
createStore(file: string): WorkBuddyCredentialStore;
|
|
10
|
+
createOAuthClient(endpoint: string): WorkBuddyOAuthClient;
|
|
11
|
+
listenForCallback: typeof listenForWorkBuddyCallback;
|
|
12
|
+
runServer: typeof runMcpServer;
|
|
13
|
+
};
|
|
14
|
+
export declare function runCli(config: McpClientConfig, dependencies?: Partial<CliDependencies>): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { normalizeApiEndpoint } from "./auth-token-provider.js";
|
|
2
|
+
import { resolveServerAuth, runServer as runMcpServer } from "./server.js";
|
|
3
|
+
import { WorkBuddyCredentialStore, } from "./workbuddy-credentials.js";
|
|
4
|
+
import { WorkBuddyOAuthClient } from "./workbuddy-oauth-client.js";
|
|
5
|
+
import { listenForWorkBuddyCallback, } from "./workbuddy-loopback.js";
|
|
6
|
+
import { createPkcePair } from "./workbuddy-pkce.js";
|
|
7
|
+
const AUTH_URL_DEADLINE_MS = 10_000;
|
|
8
|
+
const AUTH_CALLBACK_DEADLINE_MS = 5 * 60_000;
|
|
9
|
+
const STATUS_DEADLINE_MS = 8_000;
|
|
10
|
+
const LOGOUT_DEADLINE_MS = 30_000;
|
|
11
|
+
const SAFE_SERVER_ERRORS = new Set([
|
|
12
|
+
"MCP API Key 未配置,请在配置中传入 --key 或 AI_HIVE_MCP_KEY",
|
|
13
|
+
"尚未连接 AI-HIVE,请在 WorkBuddy 中重新连接",
|
|
14
|
+
"API endpoint 与 WorkBuddy 凭证不一致,请在 WorkBuddy 中重新认证",
|
|
15
|
+
"凭证文件读取失败",
|
|
16
|
+
"凭证文件无效",
|
|
17
|
+
]);
|
|
18
|
+
class DeadlineError extends Error {
|
|
19
|
+
}
|
|
20
|
+
export async function runCli(config, dependencies) {
|
|
21
|
+
const resolved = resolveDependencies(config, dependencies);
|
|
22
|
+
switch (config.command) {
|
|
23
|
+
case "auth":
|
|
24
|
+
return runAuth(config, resolved);
|
|
25
|
+
case "status":
|
|
26
|
+
return runStatus(config, resolved);
|
|
27
|
+
case "logout":
|
|
28
|
+
return runLogout(config, resolved);
|
|
29
|
+
case "server":
|
|
30
|
+
return runMcp(config, resolved);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function resolveDependencies(config, overrides) {
|
|
34
|
+
const createOAuthClientWithTimeout = (endpoint, timeoutMs) => new WorkBuddyOAuthClient({
|
|
35
|
+
apiEndpoint: endpoint,
|
|
36
|
+
timeoutMs,
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
stdout: overrides?.stdout ?? ((line) => console.log(line)),
|
|
40
|
+
stderr: overrides?.stderr ?? ((line) => console.error(line)),
|
|
41
|
+
createStore: overrides?.createStore ?? ((file) => new WorkBuddyCredentialStore(file)),
|
|
42
|
+
createOAuthClient: overrides?.createOAuthClient ??
|
|
43
|
+
((endpoint) => createOAuthClientWithTimeout(endpoint, config.timeoutMs)),
|
|
44
|
+
createOAuthClientWithTimeout: overrides?.createOAuthClient
|
|
45
|
+
? (endpoint) => overrides.createOAuthClient(endpoint)
|
|
46
|
+
: createOAuthClientWithTimeout,
|
|
47
|
+
listenForCallback: overrides?.listenForCallback ?? listenForWorkBuddyCallback,
|
|
48
|
+
runServer: overrides?.runServer ?? runMcpServer,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function runAuth(config, dependencies) {
|
|
52
|
+
const store = dependencies.createStore(config.credentialsFile);
|
|
53
|
+
const oauth = dependencies.createOAuthClient(config.endpoint);
|
|
54
|
+
const { state, codeVerifier, codeChallenge } = createPkcePair();
|
|
55
|
+
let redirectUri = "";
|
|
56
|
+
let listenerPromise;
|
|
57
|
+
try {
|
|
58
|
+
listenerPromise = Promise.resolve(dependencies.listenForCallback({
|
|
59
|
+
expectedState: state,
|
|
60
|
+
timeoutMs: AUTH_CALLBACK_DEADLINE_MS,
|
|
61
|
+
onCode: async (code) => {
|
|
62
|
+
const payload = await oauth.exchangeAuthorizationCode({
|
|
63
|
+
code,
|
|
64
|
+
redirectUri,
|
|
65
|
+
codeVerifier,
|
|
66
|
+
});
|
|
67
|
+
const updatedAt = new Date();
|
|
68
|
+
await store.write({
|
|
69
|
+
version: 1,
|
|
70
|
+
apiEndpoint: config.endpoint,
|
|
71
|
+
webOrigin: config.webOrigin,
|
|
72
|
+
accessToken: payload.accessToken,
|
|
73
|
+
refreshToken: payload.refreshToken,
|
|
74
|
+
accessExpiresAt: new Date(updatedAt.getTime() + payload.expiresIn * 1_000).toISOString(),
|
|
75
|
+
refreshExpiresAt: new Date(updatedAt.getTime() + payload.refreshExpiresIn * 1_000).toISOString(),
|
|
76
|
+
updatedAt: updatedAt.toISOString(),
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
dependencies.stderr("连接 AI-HIVE 失败,请重试");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
let listener;
|
|
86
|
+
try {
|
|
87
|
+
listener = await withDeadline(listenerPromise, AUTH_URL_DEADLINE_MS);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
void listenerPromise.then(async (lateListener) => {
|
|
91
|
+
await lateListener.close().catch(() => undefined);
|
|
92
|
+
}, () => undefined);
|
|
93
|
+
dependencies.stderr("连接 AI-HIVE 失败,请重试");
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
redirectUri = listener.redirectUri;
|
|
97
|
+
try {
|
|
98
|
+
const authorizeUrl = new URL("/workbuddy/authorize", config.webOrigin);
|
|
99
|
+
authorizeUrl.searchParams.set("redirect_uri", listener.redirectUri);
|
|
100
|
+
authorizeUrl.searchParams.set("state", state);
|
|
101
|
+
authorizeUrl.searchParams.set("code_challenge", codeChallenge);
|
|
102
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
103
|
+
dependencies.stdout(authorizeUrl.href);
|
|
104
|
+
await listener.completion;
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
dependencies.stderr("连接 AI-HIVE 失败,请重试");
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await listener.close().catch(() => undefined);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function runStatus(config, dependencies) {
|
|
116
|
+
const store = dependencies.createStore(config.credentialsFile);
|
|
117
|
+
const deadlineAt = Date.now() + STATUS_DEADLINE_MS;
|
|
118
|
+
try {
|
|
119
|
+
await checkStatus(config, dependencies, store, deadlineAt);
|
|
120
|
+
dependencies.stdout("Authenticated");
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
dependencies.stderr("Not authenticated");
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function checkStatus(config, dependencies, store, deadlineAt) {
|
|
129
|
+
let endpoint;
|
|
130
|
+
let token;
|
|
131
|
+
if (config.key !== null) {
|
|
132
|
+
if (!config.key) {
|
|
133
|
+
throw new Error("MCP API Key 未配置");
|
|
134
|
+
}
|
|
135
|
+
endpoint = config.endpoint;
|
|
136
|
+
token = config.key;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
const credentials = await readBeforeDeadline(store, deadlineAt);
|
|
140
|
+
if (!credentials) {
|
|
141
|
+
throw new Error("尚未连接 AI-HIVE");
|
|
142
|
+
}
|
|
143
|
+
assertEndpointMatches(config, credentials);
|
|
144
|
+
const resolved = await resolveStatusCredentials(config, dependencies, store, credentials, deadlineAt);
|
|
145
|
+
endpoint = resolved.apiEndpoint;
|
|
146
|
+
token = resolved.accessToken;
|
|
147
|
+
}
|
|
148
|
+
await checkUserInfo(endpoint, token, deadlineAt);
|
|
149
|
+
}
|
|
150
|
+
function assertEndpointMatches(config, credentials) {
|
|
151
|
+
if (config.endpointExplicit &&
|
|
152
|
+
normalizeApiEndpoint(config.endpoint) !==
|
|
153
|
+
normalizeApiEndpoint(credentials.apiEndpoint)) {
|
|
154
|
+
throw new Error("API endpoint 与 WorkBuddy 凭证不一致");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function resolveStatusCredentials(config, dependencies, store, credentials, deadlineAt) {
|
|
158
|
+
if (!needsRefresh(credentials)) {
|
|
159
|
+
return credentials;
|
|
160
|
+
}
|
|
161
|
+
const lifecycle = { active: true };
|
|
162
|
+
let markCommitStarted;
|
|
163
|
+
const commitStarted = new Promise((resolve) => {
|
|
164
|
+
markCommitStarted = resolve;
|
|
165
|
+
});
|
|
166
|
+
const lockedOperation = store.withLock(async () => {
|
|
167
|
+
assertPreCommitActive(lifecycle, deadlineAt);
|
|
168
|
+
const current = await readBeforeDeadline(store, deadlineAt, () => {
|
|
169
|
+
lifecycle.active = false;
|
|
170
|
+
});
|
|
171
|
+
assertPreCommitActive(lifecycle, deadlineAt);
|
|
172
|
+
if (!current) {
|
|
173
|
+
throw new Error("尚未连接 AI-HIVE");
|
|
174
|
+
}
|
|
175
|
+
assertEndpointMatches(config, current);
|
|
176
|
+
if (normalizeApiEndpoint(current.apiEndpoint) !==
|
|
177
|
+
normalizeApiEndpoint(credentials.apiEndpoint)) {
|
|
178
|
+
throw new Error("API endpoint 与 WorkBuddy 凭证不一致");
|
|
179
|
+
}
|
|
180
|
+
if (!needsRefresh(current)) {
|
|
181
|
+
return current;
|
|
182
|
+
}
|
|
183
|
+
const networkTimeoutMs = remainingMs(deadlineAt);
|
|
184
|
+
const oauth = dependencies.createOAuthClientWithTimeout(current.apiEndpoint, Math.min(config.timeoutMs, networkTimeoutMs));
|
|
185
|
+
const payload = await withDeadline(oauth.refreshToken(current.refreshToken), networkTimeoutMs, () => {
|
|
186
|
+
lifecycle.active = false;
|
|
187
|
+
});
|
|
188
|
+
assertPreCommitActive(lifecycle, deadlineAt);
|
|
189
|
+
const updatedAt = new Date();
|
|
190
|
+
const refreshed = {
|
|
191
|
+
...current,
|
|
192
|
+
accessToken: payload.accessToken,
|
|
193
|
+
refreshToken: payload.refreshToken,
|
|
194
|
+
accessExpiresAt: new Date(updatedAt.getTime() + payload.expiresIn * 1_000).toISOString(),
|
|
195
|
+
refreshExpiresAt: new Date(updatedAt.getTime() + payload.refreshExpiresIn * 1_000).toISOString(),
|
|
196
|
+
updatedAt: updatedAt.toISOString(),
|
|
197
|
+
};
|
|
198
|
+
markCommitStarted();
|
|
199
|
+
await store.write(refreshed);
|
|
200
|
+
return refreshed;
|
|
201
|
+
});
|
|
202
|
+
const preCommitOperation = Promise.race([
|
|
203
|
+
lockedOperation.then(() => undefined),
|
|
204
|
+
commitStarted,
|
|
205
|
+
]);
|
|
206
|
+
try {
|
|
207
|
+
await withDeadline(preCommitOperation, remainingMs(deadlineAt), () => {
|
|
208
|
+
lifecycle.active = false;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
lifecycle.active = false;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
return lockedOperation;
|
|
216
|
+
}
|
|
217
|
+
async function checkUserInfo(endpoint, token, deadlineAt) {
|
|
218
|
+
const response = await requestUserInfo(endpoint, token, deadlineAt);
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
await discardResponse(response);
|
|
221
|
+
throw new Error("WorkBuddy 凭证无效");
|
|
222
|
+
}
|
|
223
|
+
await discardResponse(response);
|
|
224
|
+
}
|
|
225
|
+
async function requestUserInfo(endpoint, token, deadlineAt) {
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
const timeoutMs = remainingMs(deadlineAt);
|
|
228
|
+
return withDeadline(fetch(`${endpoint.replace(/\/+$/, "")}/mcp/user-info`, {
|
|
229
|
+
method: "GET",
|
|
230
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
}), timeoutMs, () => controller.abort());
|
|
233
|
+
}
|
|
234
|
+
async function discardResponse(response) {
|
|
235
|
+
try {
|
|
236
|
+
await response.body?.cancel();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function runLogout(config, dependencies) {
|
|
243
|
+
const store = dependencies.createStore(config.credentialsFile);
|
|
244
|
+
const deadlineAt = Date.now() + LOGOUT_DEADLINE_MS;
|
|
245
|
+
try {
|
|
246
|
+
await revokeAndClear(config, dependencies, store, deadlineAt);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
dependencies.stderr("退出 AI-HIVE 失败,请重试");
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function revokeAndClear(config, dependencies, store, deadlineAt) {
|
|
255
|
+
const credentials = await readBeforeDeadline(store, deadlineAt);
|
|
256
|
+
if (!credentials) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
assertEndpointMatches(config, credentials);
|
|
260
|
+
const networkTimeoutMs = remainingMs(deadlineAt);
|
|
261
|
+
const oauth = dependencies.createOAuthClientWithTimeout(credentials.apiEndpoint, Math.min(config.timeoutMs, networkTimeoutMs));
|
|
262
|
+
await withDeadline(oauth.revoke(credentials.refreshToken), networkTimeoutMs);
|
|
263
|
+
await store.clear();
|
|
264
|
+
}
|
|
265
|
+
async function runMcp(config, dependencies) {
|
|
266
|
+
try {
|
|
267
|
+
const resolved = await resolveServerAuth(config, dependencies.createStore(config.credentialsFile));
|
|
268
|
+
await dependencies.runServer({
|
|
269
|
+
...resolved,
|
|
270
|
+
timeoutMs: config.timeoutMs,
|
|
271
|
+
});
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
dependencies.stderr(safeServerError(error));
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function safeServerError(error) {
|
|
280
|
+
if (error instanceof Error && SAFE_SERVER_ERRORS.has(error.message)) {
|
|
281
|
+
return error.message;
|
|
282
|
+
}
|
|
283
|
+
return "MCP 启动失败";
|
|
284
|
+
}
|
|
285
|
+
async function withDeadline(operation, timeoutMs, onTimeout) {
|
|
286
|
+
let timeout;
|
|
287
|
+
const deadline = new Promise((_, reject) => {
|
|
288
|
+
timeout = setTimeout(() => {
|
|
289
|
+
onTimeout?.();
|
|
290
|
+
reject(new DeadlineError("命令执行超时"));
|
|
291
|
+
}, timeoutMs);
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
return await Promise.race([operation, deadline]);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
if (timeout) {
|
|
298
|
+
clearTimeout(timeout);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function remainingMs(deadlineAt) {
|
|
303
|
+
const remaining = deadlineAt - Date.now();
|
|
304
|
+
if (remaining <= 0) {
|
|
305
|
+
throw new DeadlineError("命令执行超时");
|
|
306
|
+
}
|
|
307
|
+
return remaining;
|
|
308
|
+
}
|
|
309
|
+
function readBeforeDeadline(store, deadlineAt, onTimeout) {
|
|
310
|
+
return withDeadline(store.read(), remainingMs(deadlineAt), onTimeout);
|
|
311
|
+
}
|
|
312
|
+
function assertPreCommitActive(lifecycle, deadlineAt) {
|
|
313
|
+
if (!lifecycle.active) {
|
|
314
|
+
throw new DeadlineError("命令执行超时");
|
|
315
|
+
}
|
|
316
|
+
remainingMs(deadlineAt);
|
|
317
|
+
}
|
|
318
|
+
function needsRefresh(credentials) {
|
|
319
|
+
return new Date(credentials.accessExpiresAt).getTime() - Date.now() < 60_000;
|
|
320
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
export type CliCommand = "server" | "auth" | "status" | "logout";
|
|
1
2
|
export type McpClientConfig = {
|
|
3
|
+
command: CliCommand;
|
|
2
4
|
endpoint: string;
|
|
5
|
+
endpointExplicit: boolean;
|
|
6
|
+
webOrigin: string;
|
|
3
7
|
key: string | null;
|
|
8
|
+
credentialsFile: string;
|
|
4
9
|
timeoutMs: number;
|
|
5
10
|
verbose: boolean;
|
|
6
11
|
};
|
package/dist/config.js
CHANGED
|
@@ -1,30 +1,63 @@
|
|
|
1
|
+
import { resolveWorkBuddyCredentialsFile } from "./workbuddy-credentials.js";
|
|
1
2
|
const DEFAULT_ENDPOINT = "https://ai-hive.iclip.cn/api";
|
|
3
|
+
const DEFAULT_WEB_ORIGIN = "https://ai-hive.iclip.cn";
|
|
4
|
+
const CLI_COMMANDS = new Set([
|
|
5
|
+
"server",
|
|
6
|
+
"auth",
|
|
7
|
+
"status",
|
|
8
|
+
"logout",
|
|
9
|
+
]);
|
|
10
|
+
const FLAG_OPTIONS = new Set(["verbose"]);
|
|
11
|
+
const VALUE_OPTIONS = new Set([
|
|
12
|
+
"endpoint",
|
|
13
|
+
"web-origin",
|
|
14
|
+
"key",
|
|
15
|
+
"timeout-ms",
|
|
16
|
+
"credentials-file",
|
|
17
|
+
]);
|
|
2
18
|
export function parseConfig(input) {
|
|
3
19
|
const args = input.argv.slice(2);
|
|
4
20
|
const values = new Map();
|
|
5
21
|
const flags = new Set();
|
|
22
|
+
let command = "server";
|
|
23
|
+
let commandSeen = false;
|
|
6
24
|
for (let index = 0; index < args.length; index += 1) {
|
|
7
25
|
const arg = args[index];
|
|
8
26
|
if (!arg?.startsWith("--")) {
|
|
27
|
+
if (commandSeen || !CLI_COMMANDS.has(arg)) {
|
|
28
|
+
throw new Error("未知命令");
|
|
29
|
+
}
|
|
30
|
+
command = arg;
|
|
31
|
+
commandSeen = true;
|
|
9
32
|
continue;
|
|
10
33
|
}
|
|
11
34
|
const name = arg.slice(2);
|
|
12
|
-
if (name
|
|
35
|
+
if (FLAG_OPTIONS.has(name)) {
|
|
13
36
|
flags.add(name);
|
|
14
37
|
continue;
|
|
15
38
|
}
|
|
39
|
+
if (!VALUE_OPTIONS.has(name)) {
|
|
40
|
+
throw new Error("未知选项");
|
|
41
|
+
}
|
|
16
42
|
const value = args[index + 1];
|
|
17
|
-
if (value
|
|
18
|
-
|
|
19
|
-
index += 1;
|
|
43
|
+
if (!value || value.startsWith("--")) {
|
|
44
|
+
throw new Error("选项缺少值");
|
|
20
45
|
}
|
|
46
|
+
values.set(name, value);
|
|
47
|
+
index += 1;
|
|
21
48
|
}
|
|
22
49
|
const timeoutMs = Number.parseInt(values.get("timeout-ms") ?? input.env.AI_HIVE_MCP_TIMEOUT_MS ?? "120000", 10);
|
|
50
|
+
const endpointOverride = values.get("endpoint") ?? input.env.AI_HIVE_API_ENDPOINT;
|
|
23
51
|
return {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
52
|
+
command,
|
|
53
|
+
endpoint: endpointOverride ?? DEFAULT_ENDPOINT,
|
|
54
|
+
endpointExplicit: endpointOverride !== undefined,
|
|
55
|
+
webOrigin: values.get("web-origin") ??
|
|
56
|
+
input.env.AI_HIVE_WEB_ORIGIN ??
|
|
57
|
+
DEFAULT_WEB_ORIGIN,
|
|
27
58
|
key: values.get("key") ?? input.env.AI_HIVE_MCP_KEY ?? null,
|
|
59
|
+
credentialsFile: values.get("credentials-file") ??
|
|
60
|
+
resolveWorkBuddyCredentialsFile(input.env),
|
|
28
61
|
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000,
|
|
29
62
|
verbose: flags.has("verbose") || input.env.AI_HIVE_MCP_VERBOSE === "true",
|
|
30
63
|
};
|