mcpico 0.1.2 → 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/README.md +102 -1
- package/dist/auth-types.d.ts +54 -0
- package/dist/auth-types.js +9 -0
- package/dist/auth-types.js.map +1 -0
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +122 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js.map +1 -1
- package/dist/discoverer.d.ts +2 -1
- package/dist/discoverer.js +15 -3
- package/dist/discoverer.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/oauth-provider.d.ts +19 -0
- package/dist/oauth-provider.js +79 -0
- package/dist/oauth-provider.js.map +1 -0
- package/dist/server.js +19 -2
- package/dist/server.js.map +1 -1
- package/dist/token-store.d.ts +11 -0
- package/dist/token-store.js +94 -0
- package/dist/token-store.js.map +1 -0
- package/package.json +1 -1
- package/src/auth-types.ts +71 -0
- package/src/auth.test.ts +218 -0
- package/src/auth.ts +159 -0
- package/src/config.ts +6 -1
- package/src/discoverer.ts +25 -5
- package/src/index.ts +1 -1
- package/src/oauth-provider.ts +110 -0
- package/src/server.ts +23 -2
- package/src/token-store.test.ts +154 -0
- package/src/token-store.ts +109 -0
package/src/discoverer.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
2
2
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
4
|
import type { TransportConfig } from "./config.js";
|
|
5
|
+
import type { ResolvedUpstreamAuth } from "./auth-types.js";
|
|
6
|
+
import { upstreamAuthHeaders } from "./auth.js";
|
|
7
|
+
import { createClientCredentialsProvider } from "./oauth-provider.js";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Tool metadata from an upstream MCP server.
|
|
@@ -51,7 +54,8 @@ export interface DiscoveredServer {
|
|
|
51
54
|
export async function discoverServer(
|
|
52
55
|
name: string,
|
|
53
56
|
transportConfig: TransportConfig,
|
|
54
|
-
connectTimeoutMs: number = 30_000
|
|
57
|
+
connectTimeoutMs: number = 30_000,
|
|
58
|
+
auth?: ResolvedUpstreamAuth
|
|
55
59
|
): Promise<DiscoveredServer> {
|
|
56
60
|
let transport;
|
|
57
61
|
|
|
@@ -63,15 +67,31 @@ export async function discoverServer(
|
|
|
63
67
|
cwd: transportConfig.cwd,
|
|
64
68
|
});
|
|
65
69
|
} else if (transportConfig.type === "sse") {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
const url = new URL(transportConfig.url);
|
|
71
|
+
const opts: {
|
|
72
|
+
requestInit?: { headers: Record<string, string> };
|
|
73
|
+
authProvider?: ReturnType<typeof createClientCredentialsProvider>;
|
|
74
|
+
} = {};
|
|
75
|
+
|
|
76
|
+
if (auth) {
|
|
77
|
+
if (auth.type === "oauth") {
|
|
78
|
+
opts.authProvider = createClientCredentialsProvider(
|
|
79
|
+
auth.client_id,
|
|
80
|
+
auth.client_secret,
|
|
81
|
+
transportConfig.url
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
opts.requestInit = { headers: upstreamAuthHeaders(auth) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
transport = new StreamableHTTPClientTransport(url, opts);
|
|
69
89
|
} else {
|
|
70
90
|
throw new Error(`Unsupported transport type: ${(transportConfig as TransportConfig).type}`);
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
const client = new Client(
|
|
74
|
-
{ name: `MCPico-${name}`, version: "0.
|
|
94
|
+
{ name: `MCPico-${name}`, version: "0.2.0" },
|
|
75
95
|
{ capabilities: {} }
|
|
76
96
|
);
|
|
77
97
|
|
package/src/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ async function main(): Promise<void> {
|
|
|
24
24
|
configPath = args[i + 1] || configPath;
|
|
25
25
|
i++;
|
|
26
26
|
} else if (args[i] === "--version" || args[i] === "-v") {
|
|
27
|
-
console.log("MCPico v0.
|
|
27
|
+
console.log("MCPico v0.2.0");
|
|
28
28
|
process.exit(0);
|
|
29
29
|
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
30
30
|
console.log(`
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client_credentials provider for MCPico.
|
|
3
|
+
*
|
|
4
|
+
* Implements the MCP SDK's OAuthClientProvider interface so that
|
|
5
|
+
* StreamableHTTPClientTransport handles auth discovery, token exchange,
|
|
6
|
+
* and refresh automatically.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
OAuthClientProvider,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/client/auth.js";
|
|
11
|
+
import type {
|
|
12
|
+
OAuthClientMetadata,
|
|
13
|
+
OAuthClientInformationMixed,
|
|
14
|
+
OAuthTokens,
|
|
15
|
+
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
16
|
+
import { loadTokens, saveTokens, clearTokens } from "./token-store.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an OAuthClientProvider for client_credentials grant.
|
|
20
|
+
*
|
|
21
|
+
* Uses a fixed server URL for token storage key and provides
|
|
22
|
+
* client metadata from config. The MCP SDK handles:
|
|
23
|
+
* - RFC 9728 resource metadata discovery
|
|
24
|
+
* - RFC 8414 authorization server metadata discovery
|
|
25
|
+
* - Token exchange
|
|
26
|
+
* - Token refresh via refreshAuthorization()
|
|
27
|
+
*/
|
|
28
|
+
export function createClientCredentialsProvider(
|
|
29
|
+
clientId: string,
|
|
30
|
+
clientSecret: string,
|
|
31
|
+
serverUrl: string,
|
|
32
|
+
): OAuthClientProvider {
|
|
33
|
+
return new ClientCredentialsProvider(clientId, clientSecret, serverUrl);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class ClientCredentialsProvider implements OAuthClientProvider {
|
|
37
|
+
private _tokens: OAuthTokens | undefined;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private clientId: string,
|
|
41
|
+
private clientSecret: string,
|
|
42
|
+
private serverUrl: string,
|
|
43
|
+
) {
|
|
44
|
+
// Load any cached tokens on creation
|
|
45
|
+
this._tokens = loadTokens(serverUrl);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get redirectUrl(): string | URL | undefined {
|
|
49
|
+
// client_credentials is non-interactive — no redirect URL
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
54
|
+
return {
|
|
55
|
+
redirect_uris: ["http://localhost:0/mcplico-callback"],
|
|
56
|
+
grant_types: ["client_credentials"],
|
|
57
|
+
client_name: "MCPico",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Client information for token exchange
|
|
62
|
+
clientInformation(): OAuthClientInformationMixed | undefined {
|
|
63
|
+
return {
|
|
64
|
+
client_id: this.clientId,
|
|
65
|
+
client_secret: this.clientSecret,
|
|
66
|
+
client_secret_expires_at: 0, // Never expires
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Return cached tokens (or undefined for initial auth)
|
|
71
|
+
tokens(): OAuthTokens | undefined {
|
|
72
|
+
return this._tokens;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Persist tokens after successful auth/refresh
|
|
76
|
+
saveTokens(tokens: OAuthTokens): void {
|
|
77
|
+
this._tokens = tokens;
|
|
78
|
+
saveTokens(this.serverUrl, tokens);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// No redirect needed for client_credentials
|
|
82
|
+
redirectToAuthorization(_authorizationUrl: URL): void {
|
|
83
|
+
// Non-interactive — no redirect
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// PKCE not needed for client_credentials
|
|
87
|
+
saveCodeVerifier(_codeVerifier: string): void {
|
|
88
|
+
// No-op for client_credentials
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
codeVerifier(): string {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Use client_credentials grant
|
|
96
|
+
prepareTokenRequest(
|
|
97
|
+
_scope?: string,
|
|
98
|
+
): URLSearchParams | undefined {
|
|
99
|
+
const params = new URLSearchParams({
|
|
100
|
+
grant_type: "client_credentials",
|
|
101
|
+
});
|
|
102
|
+
return params;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clear tokens on invalidation
|
|
106
|
+
invalidateCredentials(_scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void {
|
|
107
|
+
this._tokens = undefined;
|
|
108
|
+
clearTokens(this.serverUrl);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { groupTools, type ToolGroup } from "./grouper.js";
|
|
|
15
15
|
import { parseCommand } from "./parser.js";
|
|
16
16
|
import { generateHelpText } from "./help.js";
|
|
17
17
|
import { forwardToolCall } from "./proxy.js";
|
|
18
|
+
import type { ResolvedListenAuth } from "./auth-types.js";
|
|
19
|
+
import { resolveUpstreamAuth, resolveListenAuth, extractBearerToken, validateBearerToken, sendUnauthorized } from "./auth.js";
|
|
18
20
|
|
|
19
21
|
/** Helper: create a simple text content result */
|
|
20
22
|
export function textResult(text: string): CallToolResult {
|
|
@@ -168,10 +170,14 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
168
170
|
? serverConfig.transport.url
|
|
169
171
|
: serverConfig.transport.command;
|
|
170
172
|
console.error(` Connecting to "${serverConfig.name}" (${serverConfig.transport.type}: ${transportLabel})...`);
|
|
173
|
+
const resolvedAuth = serverConfig.auth
|
|
174
|
+
? resolveUpstreamAuth(serverConfig.auth)
|
|
175
|
+
: undefined;
|
|
171
176
|
const discovered = await discoverServer(
|
|
172
177
|
serverConfig.name,
|
|
173
178
|
serverConfig.transport,
|
|
174
|
-
serverConfig.connectTimeoutMs
|
|
179
|
+
serverConfig.connectTimeoutMs,
|
|
180
|
+
resolvedAuth
|
|
175
181
|
);
|
|
176
182
|
servers.push(discovered);
|
|
177
183
|
|
|
@@ -205,7 +211,7 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
205
211
|
|
|
206
212
|
// Create the MCPico server
|
|
207
213
|
const server = new McpServer(
|
|
208
|
-
{ name: "MCPico", version: "0.
|
|
214
|
+
{ name: "MCPico", version: "0.2.0" },
|
|
209
215
|
{
|
|
210
216
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
211
217
|
instructions:
|
|
@@ -326,8 +332,23 @@ export async function startServer(config: MCPicoConfig): Promise<void> {
|
|
|
326
332
|
const listenConfig = config.listen || { type: "stdio" };
|
|
327
333
|
|
|
328
334
|
if (listenConfig.type === "sse") {
|
|
335
|
+
// Resolve listen auth
|
|
336
|
+
let listenAuth: ResolvedListenAuth | undefined;
|
|
337
|
+
if (listenConfig.auth) {
|
|
338
|
+
listenAuth = resolveListenAuth(listenConfig.auth);
|
|
339
|
+
}
|
|
340
|
+
|
|
329
341
|
const transport = new StreamableHTTPServerTransport();
|
|
330
342
|
const httpServer = createServer(async (req, res) => {
|
|
343
|
+
// Auth check
|
|
344
|
+
if (listenAuth) {
|
|
345
|
+
const providedToken = extractBearerToken(req);
|
|
346
|
+
if (!validateBearerToken(providedToken, listenAuth.token)) {
|
|
347
|
+
sendUnauthorized(res);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
331
352
|
// Collect body for handleRequest
|
|
332
353
|
const chunks: Buffer[] = [];
|
|
333
354
|
for await (const chunk of req) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for token store.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import {
|
|
9
|
+
storageKey,
|
|
10
|
+
saveTokens,
|
|
11
|
+
loadTokens,
|
|
12
|
+
clearTokens,
|
|
13
|
+
isTokenValid,
|
|
14
|
+
} from "./token-store.js";
|
|
15
|
+
|
|
16
|
+
const TEST_CREDENTIALS_DIR = join(homedir(), ".mcplico");
|
|
17
|
+
const TEST_CREDENTIALS_FILE = join(TEST_CREDENTIALS_DIR, "credentials.json");
|
|
18
|
+
|
|
19
|
+
// Mock homedir to a temp location for test isolation
|
|
20
|
+
const realHomedir = homedir;
|
|
21
|
+
|
|
22
|
+
describe("token-store", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
// Clean up between tests
|
|
25
|
+
try {
|
|
26
|
+
rmSync(TEST_CREDENTIALS_FILE, { force: true });
|
|
27
|
+
} catch {}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
try {
|
|
32
|
+
rmSync(TEST_CREDENTIALS_FILE, { force: true });
|
|
33
|
+
} catch {}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("storageKey", () => {
|
|
37
|
+
it("derives key from URL hostname and path", () => {
|
|
38
|
+
expect(storageKey("https://auth.example.com/token")).toBe(
|
|
39
|
+
"auth.example.com/token"
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("uses hostname only for root path", () => {
|
|
44
|
+
expect(storageKey("https://auth.example.com")).toBe("auth.example.com");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("handles non-URL strings gracefully", () => {
|
|
48
|
+
const key = storageKey("not-a-url");
|
|
49
|
+
expect(key).toBe("not_a_url");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("saveTokens and loadTokens", () => {
|
|
54
|
+
it("saves and loads tokens", () => {
|
|
55
|
+
const serverUrl = "https://api.example.com/mcp";
|
|
56
|
+
saveTokens(serverUrl, {
|
|
57
|
+
access_token: "access-123",
|
|
58
|
+
token_type: "Bearer",
|
|
59
|
+
expires_in: 3600,
|
|
60
|
+
refresh_token: "refresh-456",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const tokens = loadTokens(serverUrl);
|
|
64
|
+
expect(tokens).toBeDefined();
|
|
65
|
+
expect(tokens!.access_token).toBe("access-123");
|
|
66
|
+
expect(tokens!.token_type).toBe("Bearer");
|
|
67
|
+
expect(tokens!.refresh_token).toBe("refresh-456");
|
|
68
|
+
expect(tokens!.expires_in).toBeGreaterThan(0);
|
|
69
|
+
expect(tokens!.expires_in).toBeLessThanOrEqual(3600);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns undefined for unknown server", () => {
|
|
73
|
+
expect(loadTokens("https://unknown.example.com")).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("defaults token_type to Bearer if not stored", () => {
|
|
77
|
+
const serverUrl = "https://api.example.com";
|
|
78
|
+
saveTokens(serverUrl, {
|
|
79
|
+
access_token: "at",
|
|
80
|
+
token_type: "Bearer",
|
|
81
|
+
} as any);
|
|
82
|
+
|
|
83
|
+
// Manually strip token_type from the store
|
|
84
|
+
const fs = require("node:fs");
|
|
85
|
+
const raw = fs.readFileSync(TEST_CREDENTIALS_FILE, "utf-8");
|
|
86
|
+
const store = JSON.parse(raw);
|
|
87
|
+
const key = storageKey(serverUrl);
|
|
88
|
+
delete store[key].token_type;
|
|
89
|
+
fs.writeFileSync(TEST_CREDENTIALS_FILE, JSON.stringify(store));
|
|
90
|
+
|
|
91
|
+
const tokens = loadTokens(serverUrl);
|
|
92
|
+
expect(tokens!.token_type).toBe("Bearer");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("clearTokens", () => {
|
|
97
|
+
it("removes tokens for a server", () => {
|
|
98
|
+
const serverUrl = "https://api.example.com";
|
|
99
|
+
saveTokens(serverUrl, {
|
|
100
|
+
access_token: "at",
|
|
101
|
+
token_type: "Bearer",
|
|
102
|
+
});
|
|
103
|
+
expect(loadTokens(serverUrl)).toBeDefined();
|
|
104
|
+
|
|
105
|
+
clearTokens(serverUrl);
|
|
106
|
+
expect(loadTokens(serverUrl)).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("isTokenValid", () => {
|
|
111
|
+
it("returns false when no tokens stored", () => {
|
|
112
|
+
expect(isTokenValid("https://nonexistent.example.com")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns true for a freshly saved token", () => {
|
|
116
|
+
const serverUrl = "https://api.example.com";
|
|
117
|
+
saveTokens(serverUrl, {
|
|
118
|
+
access_token: "at",
|
|
119
|
+
token_type: "Bearer",
|
|
120
|
+
expires_in: 3600,
|
|
121
|
+
});
|
|
122
|
+
expect(isTokenValid(serverUrl)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns true for token without expiry info", () => {
|
|
126
|
+
const serverUrl = "https://api.example.com";
|
|
127
|
+
saveTokens(serverUrl, {
|
|
128
|
+
access_token: "at",
|
|
129
|
+
token_type: "Bearer",
|
|
130
|
+
});
|
|
131
|
+
expect(isTokenValid(serverUrl)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns false for expired token", () => {
|
|
135
|
+
const serverUrl = "https://api.example.com";
|
|
136
|
+
// Save token with expires_at in the past
|
|
137
|
+
const fs = require("node:fs");
|
|
138
|
+
saveTokens(serverUrl, {
|
|
139
|
+
access_token: "at",
|
|
140
|
+
token_type: "Bearer",
|
|
141
|
+
expires_in: 3600,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Manually set expires_at to 1 hour ago
|
|
145
|
+
const raw = fs.readFileSync(TEST_CREDENTIALS_FILE, "utf-8");
|
|
146
|
+
const store = JSON.parse(raw);
|
|
147
|
+
const key = storageKey(serverUrl);
|
|
148
|
+
store[key].expires_at = Date.now() - 3600_000;
|
|
149
|
+
fs.writeFileSync(TEST_CREDENTIALS_FILE, JSON.stringify(store));
|
|
150
|
+
|
|
151
|
+
expect(isTokenValid(serverUrl)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token store: persist OAuth tokens to ~/.mcplico/credentials.json
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
8
|
+
|
|
9
|
+
const CREDENTIALS_DIR = join(homedir(), ".mcplico");
|
|
10
|
+
const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json");
|
|
11
|
+
|
|
12
|
+
interface StoredToken {
|
|
13
|
+
access_token: string;
|
|
14
|
+
refresh_token?: string;
|
|
15
|
+
token_type?: string;
|
|
16
|
+
expires_at?: number; // Unix timestamp (ms)
|
|
17
|
+
scope?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CredentialsStore {
|
|
21
|
+
[key: string]: StoredToken;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ensureDir(): void {
|
|
25
|
+
if (!existsSync(CREDENTIALS_DIR)) {
|
|
26
|
+
mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readStore(): CredentialsStore {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(CREDENTIALS_FILE)) return {};
|
|
33
|
+
const raw = readFileSync(CREDENTIALS_FILE, "utf-8");
|
|
34
|
+
return JSON.parse(raw) as CredentialsStore;
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeStore(store: CredentialsStore): void {
|
|
41
|
+
ensureDir();
|
|
42
|
+
writeFileSync(CREDENTIALS_FILE, JSON.stringify(store, null, 2), {
|
|
43
|
+
mode: 0o600,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Derive a storage key from server URL */
|
|
48
|
+
export function storageKey(serverUrl: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(serverUrl);
|
|
51
|
+
// Use hostname + pathname to distinguish different servers
|
|
52
|
+
return `${url.hostname}${url.pathname === "/" ? "" : url.pathname}`;
|
|
53
|
+
} catch {
|
|
54
|
+
// Fallback for non-URL values
|
|
55
|
+
return serverUrl.replace(/[^a-zA-Z0-9]/g, "_");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Persist OAuth tokens for a server */
|
|
60
|
+
export function saveTokens(serverUrl: string, tokens: OAuthTokens): void {
|
|
61
|
+
const store = readStore();
|
|
62
|
+
const key = storageKey(serverUrl);
|
|
63
|
+
store[key] = {
|
|
64
|
+
access_token: tokens.access_token,
|
|
65
|
+
refresh_token: tokens.refresh_token,
|
|
66
|
+
token_type: tokens.token_type,
|
|
67
|
+
expires_at: tokens.expires_in
|
|
68
|
+
? Date.now() + tokens.expires_in * 1000
|
|
69
|
+
: undefined,
|
|
70
|
+
scope: tokens.scope,
|
|
71
|
+
};
|
|
72
|
+
writeStore(store);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Load persisted OAuth tokens for a server */
|
|
76
|
+
export function loadTokens(serverUrl: string): OAuthTokens | undefined {
|
|
77
|
+
const store = readStore();
|
|
78
|
+
const key = storageKey(serverUrl);
|
|
79
|
+
const stored = store[key];
|
|
80
|
+
if (!stored) return undefined;
|
|
81
|
+
return {
|
|
82
|
+
access_token: stored.access_token,
|
|
83
|
+
refresh_token: stored.refresh_token,
|
|
84
|
+
token_type: stored.token_type || "Bearer",
|
|
85
|
+
expires_in: stored.expires_at
|
|
86
|
+
? Math.max(0, Math.floor((stored.expires_at - Date.now()) / 1000)) || undefined
|
|
87
|
+
: undefined,
|
|
88
|
+
scope: stored.scope,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clear stored tokens (e.g., when invalidated) */
|
|
93
|
+
export function clearTokens(serverUrl: string): void {
|
|
94
|
+
const store = readStore();
|
|
95
|
+
const key = storageKey(serverUrl);
|
|
96
|
+
delete store[key];
|
|
97
|
+
writeStore(store);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Check if a stored token is still valid (not expired, with 60s buffer) */
|
|
101
|
+
export function isTokenValid(serverUrl: string): boolean {
|
|
102
|
+
const store = readStore();
|
|
103
|
+
const key = storageKey(serverUrl);
|
|
104
|
+
const stored = store[key];
|
|
105
|
+
if (!stored || !stored.access_token) return false;
|
|
106
|
+
if (!stored.expires_at) return true; // No expiry info — assume valid
|
|
107
|
+
// Refresh with 60 second buffer
|
|
108
|
+
return Date.now() < stored.expires_at - 60_000;
|
|
109
|
+
}
|