@pi-archimedes/mcp 2.3.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 +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP SDK OAuth client provider for the archimedes mcp package.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the SDK's `auth()` driver (see
|
|
5
|
+
* `@modelcontextprotocol/sdk/client/auth.js` — `OAuthClientProvider`) to
|
|
6
|
+
* archimedes' keyring-based credential storage and local callback server:
|
|
7
|
+
*
|
|
8
|
+
* - tokens and client info persist in the OS credential store via
|
|
9
|
+
* {@link getAuthEntry}/{@link saveAuthEntry} (one `AuthEntry` per server);
|
|
10
|
+
* - interactive browser flows arrive via `onAuthorizationUrl` so the host
|
|
11
|
+
* decides how the user is taken to the authorization page;
|
|
12
|
+
* - `client_credentials` is a non-interactive grant: no redirect URL, no
|
|
13
|
+
* PKCE state (an empty `state()` is intentional — the flow never uses it).
|
|
14
|
+
*
|
|
15
|
+
* Storage shapes (`StoredTokens`/`StoredClientInfo`) are mapped to/from the
|
|
16
|
+
* SDK's wire shapes (`OAuthTokens`/`OAuthClientInformationMixed`) at this
|
|
17
|
+
* boundary only — the keyring never holds SDK wire shapes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
21
|
+
import type {
|
|
22
|
+
OAuthClientInformationMixed,
|
|
23
|
+
OAuthClientMetadata,
|
|
24
|
+
OAuthTokens,
|
|
25
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
26
|
+
|
|
27
|
+
import { getAuthEntry, saveAuthEntry } from "./auth-storage.js";
|
|
28
|
+
import { getCallbackPath, getCallbackPort } from "./callback-server.js";
|
|
29
|
+
import type { McpOAuthConfig } from "./types.js";
|
|
30
|
+
|
|
31
|
+
/** Optional hooks so the host can observe or drive the interactive parts of the flow. */
|
|
32
|
+
export interface OAuthCallbacks {
|
|
33
|
+
/** Called once when the SDK builds the authorization URL. */
|
|
34
|
+
onAuthorizationUrl?: (url: URL) => void | Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `OAuthClientProvider` backed by the per-server `AuthEntry` in the OS
|
|
39
|
+
* credential store. Constructor arguments identify the server (`serverName`
|
|
40
|
+
* is the storage key, `serverUrl` keeps `entry.serverUrl` in sync) and the
|
|
41
|
+
* optional `csrfState` is the state the callback validation will expect.
|
|
42
|
+
* `callbackPort` (interactive authorization_code flows only) is the actual
|
|
43
|
+
* port the callback server bound to — it pins the advertised redirect URL to
|
|
44
|
+
* the listening server when `config.redirectUri` is absent (dynamic clients
|
|
45
|
+
* bind an OS-assigned port, not the `MCP_OAUTH_CALLBACK_PORT` default).
|
|
46
|
+
*/
|
|
47
|
+
export class McpOAuthProvider implements OAuthClientProvider {
|
|
48
|
+
constructor(
|
|
49
|
+
private serverName: string,
|
|
50
|
+
private serverUrl: string,
|
|
51
|
+
private config: McpOAuthConfig,
|
|
52
|
+
private callbacks: OAuthCallbacks,
|
|
53
|
+
private csrfState?: string,
|
|
54
|
+
private callbackPort?: number,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
/** Current time in unix seconds (floored) — used for expires_in ↔ expiresAt math. */
|
|
58
|
+
private get nowSeconds(): number {
|
|
59
|
+
return Math.floor(Date.now() / 1000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private get usesClientCredentials(): boolean {
|
|
63
|
+
return this.config.grantType === "client_credentials";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private get clientName(): string {
|
|
67
|
+
return this.config.clientName ?? this.serverName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Loopback callback URL for interactive flows; undefined for client_credentials. */
|
|
71
|
+
get redirectUrl(): string | URL | undefined {
|
|
72
|
+
if (this.usesClientCredentials) return undefined;
|
|
73
|
+
return (
|
|
74
|
+
this.config.redirectUri ??
|
|
75
|
+
`http://localhost:${this.callbackPort ?? getCallbackPort()}${getCallbackPath()}`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** DCR / well-known metadata, per grant type. */
|
|
80
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
81
|
+
const authMethod = this.config.clientSecret ? "client_secret_post" : "none";
|
|
82
|
+
|
|
83
|
+
if (this.usesClientCredentials) {
|
|
84
|
+
return {
|
|
85
|
+
redirect_uris: [],
|
|
86
|
+
client_name: this.clientName,
|
|
87
|
+
grant_types: ["client_credentials"],
|
|
88
|
+
token_endpoint_auth_method: authMethod,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const redirectUrl = this.redirectUrl;
|
|
93
|
+
if (redirectUrl === undefined) {
|
|
94
|
+
throw new Error("redirectUrl is required for the authorization_code flow");
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
redirect_uris: [String(redirectUrl)],
|
|
98
|
+
client_name: this.clientName,
|
|
99
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
100
|
+
response_types: ["code"],
|
|
101
|
+
token_endpoint_auth_method: authMethod,
|
|
102
|
+
...(this.config.scope ? { scope: this.config.scope } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* CSRF state for the authorization request. Empty when no state was
|
|
108
|
+
* pre-generated: only the never-redirected client_credentials flow can
|
|
109
|
+
* reach this, so the empty value is intentional (not an error).
|
|
110
|
+
*/
|
|
111
|
+
state(): string {
|
|
112
|
+
return this.csrfState ?? "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Pre-registered client from config wins; otherwise the stored (DCR) client. */
|
|
116
|
+
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
|
117
|
+
if (this.config.clientId) {
|
|
118
|
+
return {
|
|
119
|
+
client_id: this.config.clientId,
|
|
120
|
+
...(this.config.clientSecret ? { client_secret: this.config.clientSecret } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stored = getAuthEntry(this.serverName)?.clientInfo;
|
|
125
|
+
if (stored === undefined) return undefined;
|
|
126
|
+
return {
|
|
127
|
+
client_id: stored.clientId,
|
|
128
|
+
...(stored.clientSecret ? { client_secret: stored.clientSecret } : {}),
|
|
129
|
+
...(stored.redirectUris ? { redirect_uris: stored.redirectUris } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Persist DCR-registered client info into the entry, preserving tokens/verifier. */
|
|
134
|
+
async saveClientInformation(info: OAuthClientInformationMixed): Promise<void> {
|
|
135
|
+
const existing = getAuthEntry(this.serverName) ?? {};
|
|
136
|
+
const clientInfo: { clientId: string; clientSecret?: string; redirectUris?: string[] } = {
|
|
137
|
+
clientId: info.client_id,
|
|
138
|
+
};
|
|
139
|
+
if (info.client_secret) clientInfo.clientSecret = info.client_secret;
|
|
140
|
+
// `redirect_uris` only exists on the full-registration member of the union.
|
|
141
|
+
if ("redirect_uris" in info) clientInfo.redirectUris = info.redirect_uris;
|
|
142
|
+
|
|
143
|
+
saveAuthEntry(this.serverName, { ...existing, clientInfo }, this.serverUrl);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Map stored tokens to the SDK wire shape; undefined when none are stored. */
|
|
147
|
+
async tokens(): Promise<OAuthTokens | undefined> {
|
|
148
|
+
const stored = getAuthEntry(this.serverName)?.tokens;
|
|
149
|
+
if (stored === undefined) return undefined;
|
|
150
|
+
return {
|
|
151
|
+
access_token: stored.accessToken,
|
|
152
|
+
token_type: "Bearer",
|
|
153
|
+
...(stored.refreshToken ? { refresh_token: stored.refreshToken } : {}),
|
|
154
|
+
// Expired tokens are still returned (expires_in 0) so the SDK's auth()
|
|
155
|
+
// driver takes its refresh path — do not filter here.
|
|
156
|
+
...(stored.expiresAt !== undefined
|
|
157
|
+
? { expires_in: Math.max(0, stored.expiresAt - this.nowSeconds) }
|
|
158
|
+
: {}),
|
|
159
|
+
...(stored.scope ? { scope: stored.scope } : {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Map SDK wire tokens to the stored shape and persist into the entry. */
|
|
164
|
+
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
165
|
+
const existing = getAuthEntry(this.serverName) ?? {};
|
|
166
|
+
const stored = {
|
|
167
|
+
accessToken: tokens.access_token,
|
|
168
|
+
...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}),
|
|
169
|
+
...(tokens.expires_in != null
|
|
170
|
+
? { expiresAt: this.nowSeconds + tokens.expires_in }
|
|
171
|
+
: {}),
|
|
172
|
+
...(tokens.scope ? { scope: tokens.scope } : {}),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
saveAuthEntry(this.serverName, { ...existing, tokens: stored }, this.serverUrl);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Hand the authorization URL to the host (browser open / prompt). */
|
|
179
|
+
async redirectToAuthorization(url: URL): Promise<void> {
|
|
180
|
+
await this.callbacks.onAuthorizationUrl?.(url);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Persist the PKCE verifier before the authorization redirect. */
|
|
184
|
+
async saveCodeVerifier(verifier: string): Promise<void> {
|
|
185
|
+
const existing = getAuthEntry(this.serverName) ?? {};
|
|
186
|
+
saveAuthEntry(this.serverName, { ...existing, codeVerifier: verifier }, this.serverUrl);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Read the PKCE verifier back for the token exchange. */
|
|
190
|
+
async codeVerifier(): Promise<string> {
|
|
191
|
+
const verifier = getAuthEntry(this.serverName)?.codeVerifier;
|
|
192
|
+
if (verifier === undefined) {
|
|
193
|
+
throw new Error("Missing OAuth code verifier");
|
|
194
|
+
}
|
|
195
|
+
return verifier;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth token & client storage types (persisted in the OS credential store).
|
|
3
|
+
*
|
|
4
|
+
* These are the on-disk shapes under the keyring — independent of the
|
|
5
|
+
* MCP SDK's wire shapes (@modelcontextprotocol/sdk's OAuthTokens, etc.),
|
|
6
|
+
* which are mapped to/from these at the provider boundary.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Stored OAuth tokens for one server (keyring value, chunked if large) */
|
|
10
|
+
export interface StoredTokens {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken?: string;
|
|
13
|
+
/** Unix seconds; absent when the token does not expire */
|
|
14
|
+
expiresAt?: number;
|
|
15
|
+
scope?: string;
|
|
16
|
+
/** Authorization server issuer for multi-tenant providers */
|
|
17
|
+
issuer?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stored OAuth client information — either the provider-registered
|
|
22
|
+
* (dynamic registration) client or a pre-registered client from config.
|
|
23
|
+
* `configPreRegistered` marks the latter so refresh honors ADR 0001
|
|
24
|
+
* (client-stub guard: no auto-refresh for a public client with no secret).
|
|
25
|
+
*/
|
|
26
|
+
export interface StoredClientInfo {
|
|
27
|
+
clientId: string;
|
|
28
|
+
clientSecret?: string;
|
|
29
|
+
redirectUris?: string[];
|
|
30
|
+
issuer?: string;
|
|
31
|
+
/** True when the client id/secret came from mcp.json `auth` config;
|
|
32
|
+
* reserved: the ADR 0001 guard keys off config.clientId/clientSecret
|
|
33
|
+
* (see auth-flow.ts); kept for port-compat with the reference adapter */
|
|
34
|
+
configPreRegistered?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Full per-server auth entry in the keyring. All fields optional so an
|
|
39
|
+
* entry may accumulate state incrementally (code verifier during the
|
|
40
|
+
* flow, client info after registration, tokens after the exchange).
|
|
41
|
+
*/
|
|
42
|
+
export interface AuthEntry {
|
|
43
|
+
tokens?: StoredTokens;
|
|
44
|
+
clientInfo?: StoredClientInfo;
|
|
45
|
+
/** PKCE code verifier persisted before the authorization request */
|
|
46
|
+
codeVerifier?: string;
|
|
47
|
+
/** Reserved: not set or read — the live CSRF state lives in
|
|
48
|
+
* callback-server memory for the flow duration; kept for port-compat
|
|
49
|
+
* with the reference adapter */
|
|
50
|
+
oauthState?: string;
|
|
51
|
+
/** Server URL the credentials were obtained for; persisted for debugging +
|
|
52
|
+
* future url-binding validation — not read by current code */
|
|
53
|
+
serverUrl?: string;
|
|
54
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, stateless row helpers for the `/mcp panel` management panel.
|
|
3
|
+
*
|
|
4
|
+
* Everything in this module is a pure function or type definition — no
|
|
5
|
+
* side effects, no closure over mutable state. Extracted from panel.ts so
|
|
6
|
+
* that unit tests can import them directly and so that panel.ts can focus
|
|
7
|
+
* on the stateful component logic.
|
|
8
|
+
*/
|
|
9
|
+
import { isHttpDef, resolveServerSettings } from "./config.js";
|
|
10
|
+
import type { ServerManager } from "./server-manager.js";
|
|
11
|
+
import type { ServerStatus } from "./server-client.js";
|
|
12
|
+
import type { CachedTool, McpConfig, ServerDef, ServerOutcomeRecord } from "./types.js";
|
|
13
|
+
|
|
14
|
+
// ── State shapes ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface ToolRow {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/** Current direct-tools selection state (what ctrl+s would write). */
|
|
20
|
+
isDirect: boolean;
|
|
21
|
+
/** Direct state as resolved from config when the panel opened (dirty baseline). */
|
|
22
|
+
wasDirect: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ServerRowStatus = "connected" | "cached" | "needs-auth" | "disabled" | "error";
|
|
26
|
+
|
|
27
|
+
export interface ServerRow {
|
|
28
|
+
name: string;
|
|
29
|
+
expanded: boolean;
|
|
30
|
+
status: ServerRowStatus;
|
|
31
|
+
/** First-line error text for needs-auth/error rows. */
|
|
32
|
+
failureMessage?: string;
|
|
33
|
+
/** Timestamp of the persisted outcome that drove this row ("X ago" suffix). */
|
|
34
|
+
statusAt?: number;
|
|
35
|
+
tools: ToolRow[];
|
|
36
|
+
/** True when valid cached tool metadata exists for this server. */
|
|
37
|
+
hasCachedData: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One navigable line in the flat visible list. */
|
|
41
|
+
export type VisibleRow =
|
|
42
|
+
| { kind: "server"; server: ServerRow }
|
|
43
|
+
| { kind: "tool"; server: ServerRow; tool: ToolRow };
|
|
44
|
+
|
|
45
|
+
/** Dependencies injected by commands.ts — the seams the panel touches. */
|
|
46
|
+
export interface McpPanelDeps {
|
|
47
|
+
/** loadAllServerDefs() — INCLUDES disabled servers (they have status). */
|
|
48
|
+
getServerDefs: () => Record<string, ServerDef>;
|
|
49
|
+
getCachedTools: (serverName: string, def: ServerDef) => CachedTool[] | undefined;
|
|
50
|
+
/** Module-level singleton via getter (session-resilient across /reload). */
|
|
51
|
+
getManager: () => ServerManager;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Row-seeding support ───────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export interface RowSources {
|
|
57
|
+
globalConfig: McpConfig;
|
|
58
|
+
outcomes: Record<string, ServerOutcomeRecord>;
|
|
59
|
+
manager: ServerManager;
|
|
60
|
+
getCachedTools: (name: string, def: ServerDef) => CachedTool[] | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Flat list of visible rows: every server in order; an EXPANDED server's
|
|
67
|
+
* tool rows interleave immediately after its own row (in tool order).
|
|
68
|
+
* Collapsed servers contribute only their server row.
|
|
69
|
+
*/
|
|
70
|
+
export function buildVisibleRows(servers: ServerRow[]): VisibleRow[] {
|
|
71
|
+
const rows: VisibleRow[] = [];
|
|
72
|
+
for (const s of servers) {
|
|
73
|
+
rows.push({ kind: "server", server: s });
|
|
74
|
+
if (s.expanded) {
|
|
75
|
+
for (const t of s.tools) {
|
|
76
|
+
rows.push({ kind: "tool", server: s, tool: t });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return rows;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Filter servers for the `[/] search` box: case-insensitive substring over
|
|
85
|
+
* the server name AND every tool's name/description (a server is kept when
|
|
86
|
+
* any of those matches). Empty query passes the original array through
|
|
87
|
+
* unchanged (same reference).
|
|
88
|
+
*/
|
|
89
|
+
export function filterRows(servers: ServerRow[], query: string): ServerRow[] {
|
|
90
|
+
if (query.length === 0) return servers;
|
|
91
|
+
const q = query.toLowerCase();
|
|
92
|
+
return servers.filter(
|
|
93
|
+
(s) =>
|
|
94
|
+
s.name.toLowerCase().includes(q) ||
|
|
95
|
+
s.tools.some(
|
|
96
|
+
(t) => t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Flip one tool's direct selection without touching wasDirect. */
|
|
102
|
+
export function toggleTool(tool: ToolRow): void {
|
|
103
|
+
tool.isDirect = !tool.isDirect;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The per-server save value derived from tool isDirect states (same rule as
|
|
108
|
+
* the agent-manager tool picker): all direct → `true`, none direct →
|
|
109
|
+
* `false`, otherwise the exact subset of direct tool names in row order.
|
|
110
|
+
* An empty tool list counts as "all direct" → `true`.
|
|
111
|
+
*/
|
|
112
|
+
export function computeSelection(toolRows: ToolRow[]): true | false | string[] {
|
|
113
|
+
if (toolRows.every((t) => t.isDirect)) return true;
|
|
114
|
+
if (toolRows.every((t) => !t.isDirect)) return false;
|
|
115
|
+
return toolRows.filter((t) => t.isDirect).map((t) => t.name);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Display helpers ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export function firstLine(text: string | null | undefined): string | undefined {
|
|
121
|
+
const line = text?.split("\n")[0]?.trim();
|
|
122
|
+
return line === undefined || line.length === 0 ? undefined : line;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Staleness formatting for persisted outcomes (ADR 0004). Deliberately
|
|
127
|
+
* duplicated from commands.ts instead of imported: commands.ts lazily loads
|
|
128
|
+
* panel.ts for /mcp panel, so a static import back would create a cycle.
|
|
129
|
+
*/
|
|
130
|
+
export function formatAge(ms: number): string {
|
|
131
|
+
const s = Math.floor(ms / 1000);
|
|
132
|
+
if (s < 60) return `${Math.max(1, s)}s ago`;
|
|
133
|
+
const m = Math.floor(s / 60);
|
|
134
|
+
if (m < 60) return `${m}m ago`;
|
|
135
|
+
const h = Math.floor(m / 60);
|
|
136
|
+
if (h < 24) return `${h}h ago`;
|
|
137
|
+
const d = Math.floor(h / 24);
|
|
138
|
+
if (d < 7) return `${d}d ago`;
|
|
139
|
+
if (d < 45) return `${Math.max(1, Math.round(d / 7))}w ago`;
|
|
140
|
+
return `${Math.max(1, Math.round(d / 30))}mo ago`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** "(X ago)" — omitted for fresh (<1m) entries or unknown timestamps. */
|
|
144
|
+
export function ageSuffix(at: number | undefined): string {
|
|
145
|
+
if (at === undefined) return "";
|
|
146
|
+
const age = Date.now() - at;
|
|
147
|
+
if (age < 60_000) return "";
|
|
148
|
+
return ` (${formatAge(age)})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Row seeding ───────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** Verified connection states — the only ones a row may claim live. */
|
|
154
|
+
export function isVerifiedStatus(s: ServerStatus): s is "connected" | "needs-auth" | "error" {
|
|
155
|
+
return s === "connected" || s === "needs-auth" || s === "error";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build/refresh one ServerRow from (defs, live manager, persisted outcomes,
|
|
160
|
+
* cache). Status: `disabled` wins; a live client whose captured status is
|
|
161
|
+
* VERIFIED (connected/needs-auth/error) wins next — "disconnected"/
|
|
162
|
+
* "connecting" are not verified, so ADR 0004 falls back to the persisted
|
|
163
|
+
* outcome; then the outcome (a persisted `connected` reads as "cached", i.e.
|
|
164
|
+
* was connected across sessions); else "cached". Tools: live (only while
|
|
165
|
+
* connected) wins over valid cache; wasDirect/isDirect seed from the
|
|
166
|
+
* resolved directTools setting.
|
|
167
|
+
*/
|
|
168
|
+
export function buildRow(name: string, def: ServerDef, sources: RowSources): ServerRow {
|
|
169
|
+
const client = sources.manager.getClient(name);
|
|
170
|
+
const clientStatus = client ? client.status : undefined;
|
|
171
|
+
const live = client && clientStatus !== undefined && isVerifiedStatus(clientStatus);
|
|
172
|
+
const outcome = live ? undefined : sources.outcomes[name];
|
|
173
|
+
|
|
174
|
+
let status: ServerRowStatus;
|
|
175
|
+
let statusAt: number | undefined;
|
|
176
|
+
let failureMessage: string | undefined;
|
|
177
|
+
if (def.disabled === true) {
|
|
178
|
+
status = "disabled";
|
|
179
|
+
} else if (live && clientStatus) {
|
|
180
|
+
status = clientStatus;
|
|
181
|
+
if (clientStatus !== "connected" && client) failureMessage = firstLine(client.error);
|
|
182
|
+
} else if (outcome) {
|
|
183
|
+
status = outcome.status === "connected" ? "cached" : outcome.status;
|
|
184
|
+
statusAt = outcome.at;
|
|
185
|
+
if (outcome.status !== "connected") failureMessage = firstLine(outcome.error);
|
|
186
|
+
} else {
|
|
187
|
+
status = "cached";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cached = sources.getCachedTools(name, def);
|
|
191
|
+
const liveTools = live && clientStatus === "connected" && client ? client.tools : undefined;
|
|
192
|
+
const direct = resolveServerSettings(def, sources.globalConfig).directTools;
|
|
193
|
+
// Config arrives from JSON without runtime validation — a non-boolean,
|
|
194
|
+
// non-array directTools must not throw (mirror of filterDirectTools).
|
|
195
|
+
const isDirectFor = (toolName: string): boolean =>
|
|
196
|
+
Array.isArray(direct) ? direct.includes(toolName) : direct !== false;
|
|
197
|
+
const tools: ToolRow[] = (liveTools ?? cached ?? []).map((t) => ({
|
|
198
|
+
name: t.name,
|
|
199
|
+
description: t.description ?? "",
|
|
200
|
+
isDirect: isDirectFor(t.name),
|
|
201
|
+
wasDirect: isDirectFor(t.name),
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const row: ServerRow = { name, expanded: false, status, tools, hasCachedData: cached !== undefined };
|
|
205
|
+
if (statusAt !== undefined) row.statusAt = statusAt;
|
|
206
|
+
if (failureMessage !== undefined) row.failureMessage = failureMessage;
|
|
207
|
+
return row;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|