mcpico 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/README.md +123 -2
- 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 +16 -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.d.ts +31 -0
- package/dist/server.js +132 -57
- 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 +2 -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 +14 -0
- package/src/discoverer.ts +25 -5
- package/src/help.test.ts +40 -0
- package/src/index.ts +1 -1
- package/src/oauth-provider.ts +110 -0
- package/src/server.test.ts +324 -0
- package/src/server.ts +165 -75
- package/src/token-store.test.ts +154 -0
- package/src/token-store.ts +109 -0
- package/vitest.config.ts +7 -4
|
@@ -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
|
+
}
|
package/vitest.config.ts
CHANGED
|
@@ -7,10 +7,13 @@ export default defineConfig({
|
|
|
7
7
|
include: ["src/**/*.ts"],
|
|
8
8
|
exclude: ["src/**/*.test.ts", "src/index.ts"],
|
|
9
9
|
thresholds: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
// startServer() is I/O orchestration (connect/discover/register/transport)
|
|
11
|
+
// — requires integration tests, not unit tests. Pure logic functions are
|
|
12
|
+
// extracted and fully tested. SSE/HTTP transport code is also I/O.
|
|
13
|
+
statements: 67,
|
|
14
|
+
branches: 68,
|
|
15
|
+
functions: 80,
|
|
16
|
+
lines: 66,
|
|
14
17
|
},
|
|
15
18
|
},
|
|
16
19
|
},
|