@openephemeris/mcp-server 3.1.0 → 3.2.2

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.
Files changed (58) hide show
  1. package/README.md +51 -22
  2. package/config/dev-allowlist.json +1262 -1318
  3. package/dist/index.js +0 -0
  4. package/dist/scripts/dev-allowlist.d.ts +1 -0
  5. package/dist/scripts/dev-allowlist.js +287 -0
  6. package/dist/scripts/pack-audit.d.ts +1 -0
  7. package/dist/scripts/pack-audit.js +45 -0
  8. package/dist/scripts/schema-packs.d.ts +1 -0
  9. package/dist/scripts/schema-packs.js +150 -0
  10. package/dist/scripts/smoke-dev-profile.d.ts +1 -0
  11. package/dist/scripts/smoke-dev-profile.js +25 -0
  12. package/dist/scripts/sync-readme.d.ts +1 -0
  13. package/dist/scripts/sync-readme.js +141 -0
  14. package/dist/scripts/test-client.d.ts +1 -0
  15. package/dist/scripts/test-client.js +69 -0
  16. package/dist/scripts/test-sse-client.d.ts +1 -0
  17. package/dist/scripts/test-sse-client.js +221 -0
  18. package/dist/src/auth/credentials.d.ts +65 -0
  19. package/dist/src/auth/credentials.js +200 -0
  20. package/dist/src/auth/device-auth.d.ts +56 -0
  21. package/dist/src/auth/device-auth.js +147 -0
  22. package/dist/src/backend/client.d.ts +61 -0
  23. package/dist/src/backend/client.js +335 -0
  24. package/dist/src/index.d.ts +2 -0
  25. package/dist/src/index.js +98 -0
  26. package/dist/src/schema-packs/llm.d.ts +105 -0
  27. package/dist/src/schema-packs/llm.js +429 -0
  28. package/dist/src/server-sse.d.ts +1 -0
  29. package/dist/src/server-sse.js +264 -0
  30. package/dist/src/tools/auth.d.ts +1 -0
  31. package/dist/src/tools/auth.js +202 -0
  32. package/dist/src/tools/dev.d.ts +1 -0
  33. package/dist/src/tools/dev.js +195 -0
  34. package/dist/src/tools/index.d.ts +33 -0
  35. package/dist/src/tools/index.js +56 -0
  36. package/dist/src/tools/specialized/eclipse.d.ts +1 -0
  37. package/dist/src/tools/specialized/eclipse.js +53 -0
  38. package/dist/src/tools/specialized/electional.d.ts +1 -0
  39. package/dist/src/tools/specialized/electional.js +80 -0
  40. package/dist/src/tools/specialized/human_design.d.ts +1 -0
  41. package/dist/src/tools/specialized/human_design.js +54 -0
  42. package/dist/src/tools/specialized/moon.d.ts +1 -0
  43. package/dist/src/tools/specialized/moon.js +51 -0
  44. package/dist/src/tools/specialized/natal.d.ts +1 -0
  45. package/dist/src/tools/specialized/natal.js +80 -0
  46. package/dist/src/tools/specialized/relocation.d.ts +1 -0
  47. package/dist/src/tools/specialized/relocation.js +76 -0
  48. package/dist/src/tools/specialized/synastry.d.ts +1 -0
  49. package/dist/src/tools/specialized/synastry.js +73 -0
  50. package/dist/src/tools/specialized/transits.d.ts +1 -0
  51. package/dist/src/tools/specialized/transits.js +87 -0
  52. package/dist/test/allowlist-and-tools.test.d.ts +1 -0
  53. package/dist/test/allowlist-and-tools.test.js +96 -0
  54. package/dist/test/backend-client.test.d.ts +1 -0
  55. package/dist/test/backend-client.test.js +284 -0
  56. package/dist/test/credentials.test.d.ts +1 -0
  57. package/dist/test/credentials.test.js +143 -0
  58. package/package.json +27 -18
@@ -0,0 +1,69 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
6
+ async function main() {
7
+ console.log("šŸš€ Starting MCP Integration Test...");
8
+ // Path to the primary entrypoint
9
+ const serverPath = path.resolve(__dirname, "../src/index.ts");
10
+ // Spin up the stdio transport simulating a client like Claude/Cursor
11
+ const transport = new StdioClientTransport({
12
+ command: "npx",
13
+ args: ["tsx", serverPath],
14
+ env: {
15
+ ...process.env,
16
+ // Pass the profile explicitly for predictable responses
17
+ OPENEPHEMERIS_PROFILE: "dev",
18
+ },
19
+ });
20
+ const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
21
+ console.log("šŸ”Œ Connecting to local MCP server via stdio...");
22
+ await client.connect(transport);
23
+ console.log("āœ… Handshake complete. Protocol negotiated successfully.");
24
+ // Test 1: List Tools
25
+ console.log("\nšŸ“” Polling server tools...");
26
+ const toolsResponse = await client.listTools();
27
+ console.log(`āœ… Discovered ${toolsResponse.tools.length} tools.`);
28
+ // Verify core tools exist
29
+ const toolNames = toolsResponse.tools.map((t) => t.name);
30
+ const coreTools = ["auth.login", "dev.call", "ephemeris.moon_phase", "ephemeris.natal_chart"];
31
+ for (const name of coreTools) {
32
+ if (!toolNames.includes(name)) {
33
+ throw new Error(`āŒ Missing expected tool: ${name}`);
34
+ }
35
+ }
36
+ // Test 2: Call an open endpoint that doesn't strictly require API Key for simple responses,
37
+ // or handle the graceful 401 prompt gracefully if it does.
38
+ // Tools returning standard 401s with markdown prompts are still valid successful MCP JSON-RPC calls.
39
+ console.log("\nāš™ļø Testing 'ephemeris.moon_phase' tool call...");
40
+ try {
41
+ const result = await client.callTool({
42
+ name: "ephemeris.moon_phase",
43
+ arguments: {}, // Defaults to current time
44
+ });
45
+ // An MCP tool response is usually { content: [ { type: 'text', text: '...' } ], isError: boolean }
46
+ const res = result;
47
+ if (res.isError) {
48
+ console.log("āš ļø Tool execution returned an expected application error (e.g. 401 Unauthorized expected without valid credentials):\n");
49
+ console.log(res.content);
50
+ }
51
+ else {
52
+ console.log("āœ… Tool executed successfully! Response preview:");
53
+ const content = res.content[0];
54
+ console.log(content.text ? content.text.slice(0, 300) + "..." : res.content);
55
+ }
56
+ }
57
+ catch (err) {
58
+ console.error("āŒ Exception during tool execution:", err);
59
+ process.exit(1);
60
+ }
61
+ console.log("\nšŸŽ‰ Integration Test Passed. Client and Server are communicating perfectly.");
62
+ // Cleanup gracefully
63
+ await transport.close();
64
+ process.exit(0);
65
+ }
66
+ main().catch((err) => {
67
+ console.error("Fatal Error:", err);
68
+ process.exit(1);
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,221 @@
1
+ /**
2
+ * test-sse-client.ts — Integration tests for the Remote SSE MCP server.
3
+ *
4
+ * Usage:
5
+ * Internal (local):
6
+ * Start the SSE server first: npm run dev:sse
7
+ * Then run: npx tsx scripts/test-sse-client.ts
8
+ *
9
+ * External (production):
10
+ * OPENEPHEMERIS_SSE_URL=https://mcp.openephemeris.com \
11
+ * OPENEPHEMERIS_API_KEY=opene-xxx \
12
+ * npx tsx scripts/test-sse-client.ts
13
+ *
14
+ * Tests:
15
+ * 1. Health endpoint responds correctly
16
+ * 2. SSE connection without API key is rejected (401)
17
+ * 3. SSE connection with invalid API key is rejected (403)
18
+ * 4. SSE connection + MCP handshake succeeds with valid key
19
+ * 5. Tool discovery returns expected core tools
20
+ * 6. Tool call (moon phase) executes end-to-end
21
+ */
22
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
24
+ import axios from "axios";
25
+ const SSE_BASE = process.env.OPENEPHEMERIS_SSE_URL?.replace(/\/+$/, "") ||
26
+ "http://localhost:3001";
27
+ const API_KEY = process.env.OPENEPHEMERIS_API_KEY || "";
28
+ const passed = [];
29
+ const failed = [];
30
+ function ok(name) {
31
+ passed.push(name);
32
+ console.log(` āœ… ${name}`);
33
+ }
34
+ function fail(name, err) {
35
+ failed.push(name);
36
+ console.error(` āŒ ${name}: ${err instanceof Error ? err.message : String(err)}`);
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Test 1: Health endpoint
40
+ // ---------------------------------------------------------------------------
41
+ async function testHealth() {
42
+ const name = "Health endpoint responds";
43
+ try {
44
+ const res = await axios.get(`${SSE_BASE}/health`, { timeout: 5_000 });
45
+ if (res.data.ok !== true)
46
+ throw new Error(`Expected ok:true, got ${JSON.stringify(res.data)}`);
47
+ if (!res.data.version)
48
+ throw new Error("Missing version in health response");
49
+ if (res.data.transport !== "sse")
50
+ throw new Error(`Expected transport:sse, got ${res.data.transport}`);
51
+ ok(name);
52
+ }
53
+ catch (err) {
54
+ fail(name, err);
55
+ }
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // Test 2: No API key → 401
59
+ // ---------------------------------------------------------------------------
60
+ async function testNoApiKey() {
61
+ const name = "SSE without API key returns 401";
62
+ try {
63
+ const res = await axios.get(`${SSE_BASE}/sse`, {
64
+ timeout: 5_000,
65
+ validateStatus: () => true,
66
+ });
67
+ if (res.status !== 401)
68
+ throw new Error(`Expected 401, got ${res.status}`);
69
+ if (res.data.error !== "api_key_required")
70
+ throw new Error(`Unexpected error code: ${res.data.error}`);
71
+ ok(name);
72
+ }
73
+ catch (err) {
74
+ fail(name, err);
75
+ }
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Test 3: Invalid API key → 403 (or accepted if backend can't validate)
79
+ // ---------------------------------------------------------------------------
80
+ async function testInvalidApiKey() {
81
+ const name = "SSE with invalid API key is handled";
82
+ try {
83
+ const controller = new AbortController();
84
+ // Give the server 3s to respond with 403 or start SSE
85
+ const timeout = setTimeout(() => controller.abort(), 3_000);
86
+ const res = await axios.get(`${SSE_BASE}/sse?apiKey=opene-clearly-invalid-key-000`, {
87
+ validateStatus: () => true,
88
+ signal: controller.signal,
89
+ // Disable response buffering so the SSE stream doesn't hang
90
+ responseType: "stream",
91
+ });
92
+ clearTimeout(timeout);
93
+ if (res.status === 403) {
94
+ // Backend validated and rejected the key — ideal
95
+ ok(name + " (403 rejected)");
96
+ }
97
+ else if (res.status === 200) {
98
+ // Backend couldn't distinguish keys or endpoint doesn't require auth —
99
+ // SSE stream started. This is acceptable, the tool calls will fail later.
100
+ console.log(` āš ļø ${name}: Key accepted (backend may not validate at this layer)`);
101
+ passed.push(name);
102
+ }
103
+ else {
104
+ ok(name + ` (${res.status})`);
105
+ }
106
+ // Clean up the stream
107
+ try {
108
+ res.data?.destroy?.();
109
+ }
110
+ catch { /* ignore */ }
111
+ }
112
+ catch (err) {
113
+ if (err.code === "ERR_CANCELED" || err.name === "CanceledError") {
114
+ // The SSE stream started (200) and we aborted — that's valid
115
+ console.log(` āš ļø ${name}: SSE stream started (key was accepted), aborted cleanly`);
116
+ passed.push(name);
117
+ }
118
+ else {
119
+ fail(name, err);
120
+ }
121
+ }
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Test 4-6: Full SSE client connection + tool discovery + tool call
125
+ // ---------------------------------------------------------------------------
126
+ async function testFullSseFlow() {
127
+ if (!API_KEY) {
128
+ const skip = "Full SSE flow (skipped — no OPENEPHEMERIS_API_KEY set)";
129
+ console.log(` ā­ļø ${skip}`);
130
+ return;
131
+ }
132
+ // Test 4: Connect
133
+ const connectName = "SSE connection + MCP handshake";
134
+ let client = null;
135
+ let transport = null;
136
+ try {
137
+ const sseUrl = new URL(`${SSE_BASE}/sse?apiKey=${API_KEY}`);
138
+ transport = new SSEClientTransport(sseUrl);
139
+ client = new Client({ name: "sse-test-client", version: "1.0.0" }, { capabilities: {} });
140
+ await client.connect(transport);
141
+ ok(connectName);
142
+ }
143
+ catch (err) {
144
+ fail(connectName, err);
145
+ return; // Can't continue without a connection
146
+ }
147
+ // Test 5: Tool discovery
148
+ const discoveryName = "Tool discovery returns core tools";
149
+ try {
150
+ const tools = await client.listTools();
151
+ const names = tools.tools.map((t) => t.name);
152
+ const required = ["auth_login", "dev_call", "ephemeris_moon_phase", "ephemeris_natal_chart"];
153
+ const missing = required.filter((r) => !names.includes(r));
154
+ if (missing.length > 0)
155
+ throw new Error(`Missing tools: ${missing.join(", ")}`);
156
+ console.log(` Found ${tools.tools.length} tools`);
157
+ ok(discoveryName);
158
+ }
159
+ catch (err) {
160
+ fail(discoveryName, err);
161
+ }
162
+ // Test 6: Tool call
163
+ const callName = "Tool call (moon_phase) returns data";
164
+ try {
165
+ const result = await client.callTool({
166
+ name: "ephemeris_moon_phase",
167
+ arguments: {},
168
+ });
169
+ const res = result;
170
+ if (res.isError) {
171
+ // A 401 error is acceptable if the server's own key isn't set
172
+ const text = res.content?.[0]?.text || "";
173
+ if (text.includes("401") || text.includes("auth")) {
174
+ console.log(` āš ļø Tool returned auth error (server service key may not be configured)`);
175
+ ok(callName + " (auth expected)");
176
+ }
177
+ else {
178
+ throw new Error(`Tool returned error: ${text.slice(0, 200)}`);
179
+ }
180
+ }
181
+ else {
182
+ const text = res.content?.[0]?.text || "";
183
+ console.log(` Response preview: ${text.slice(0, 150)}...`);
184
+ ok(callName);
185
+ }
186
+ }
187
+ catch (err) {
188
+ fail(callName, err);
189
+ }
190
+ // Cleanup
191
+ try {
192
+ await transport?.close();
193
+ }
194
+ catch {
195
+ // ignore
196
+ }
197
+ }
198
+ // ---------------------------------------------------------------------------
199
+ // Runner
200
+ // ---------------------------------------------------------------------------
201
+ async function main() {
202
+ console.log(`\n🧪 SSE MCP Server Integration Tests`);
203
+ console.log(` Target: ${SSE_BASE}`);
204
+ console.log(` API Key: ${API_KEY ? API_KEY.slice(0, 10) + "..." : "(none — full flow skipped)"}\n`);
205
+ await testHealth();
206
+ await testNoApiKey();
207
+ await testInvalidApiKey();
208
+ await testFullSseFlow();
209
+ console.log(`\n${"─".repeat(50)}`);
210
+ console.log(` Passed: ${passed.length} | Failed: ${failed.length}`);
211
+ if (failed.length > 0) {
212
+ console.error(`\nāŒ ${failed.length} test(s) failed.`);
213
+ process.exit(1);
214
+ }
215
+ console.log(`\nšŸŽ‰ All tests passed!`);
216
+ process.exit(0);
217
+ }
218
+ main().catch((err) => {
219
+ console.error("Fatal:", err);
220
+ process.exit(1);
221
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Stored credential shape persisted to ~/.openephemeris/credentials.json.
3
+ */
4
+ export interface StoredCredentials {
5
+ access_token: string;
6
+ refresh_token: string;
7
+ /** ISO 8601 timestamp when the access_token expires. */
8
+ expires_at: string;
9
+ /** Supabase user ID. */
10
+ user_id?: string;
11
+ /** User email for display purposes. */
12
+ user_email?: string;
13
+ /** Timestamp when these credentials were created/refreshed. */
14
+ updated_at: string;
15
+ }
16
+ /**
17
+ * CredentialManager handles reading, writing, refreshing, and clearing
18
+ * persisted OAuth credentials for the MCP server.
19
+ */
20
+ export declare class CredentialManager {
21
+ private cachedCredentials;
22
+ private backendBaseURL;
23
+ constructor(backendBaseURL?: string);
24
+ /**
25
+ * Load credentials from disk. Returns null if file doesn't exist or is invalid.
26
+ */
27
+ load(): StoredCredentials | null;
28
+ /**
29
+ * Save credentials to disk.
30
+ */
31
+ save(credentials: StoredCredentials): void;
32
+ /**
33
+ * Check whether the current access_token is expired (or nearly expired).
34
+ */
35
+ isExpired(credentials?: StoredCredentials | null): boolean;
36
+ /**
37
+ * Attempt to refresh the access token using the stored refresh_token.
38
+ * Uses the Supabase GoTrue refresh endpoint directly.
39
+ */
40
+ refresh(): Promise<StoredCredentials | null>;
41
+ /**
42
+ * Fallback refresh via the web app's token refresh endpoint.
43
+ */
44
+ private refreshViaWebApp;
45
+ /**
46
+ * Get a valid access token, refreshing if necessary.
47
+ * Returns null if no credentials exist or refresh fails.
48
+ */
49
+ getValidToken(): Promise<string | null>;
50
+ /**
51
+ * Delete stored credentials.
52
+ */
53
+ clear(): void;
54
+ /**
55
+ * Get display info about the current auth state.
56
+ */
57
+ getStatus(): {
58
+ authenticated: boolean;
59
+ email?: string;
60
+ userId?: string;
61
+ expiresAt?: string;
62
+ };
63
+ /** Path to the credentials file (for diagnostics). */
64
+ static get credentialsPath(): string;
65
+ }
@@ -0,0 +1,200 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const CREDENTIALS_DIR = path.join(os.homedir(), ".openephemeris");
5
+ const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
6
+ /** How many seconds before actual expiry to consider the token "expired". */
7
+ const EXPIRY_BUFFER_SECONDS = 120;
8
+ /**
9
+ * CredentialManager handles reading, writing, refreshing, and clearing
10
+ * persisted OAuth credentials for the MCP server.
11
+ */
12
+ export class CredentialManager {
13
+ cachedCredentials = null;
14
+ backendBaseURL;
15
+ constructor(backendBaseURL) {
16
+ this.backendBaseURL =
17
+ backendBaseURL ||
18
+ process.env.NEXT_PUBLIC_APP_URL ||
19
+ process.env.OPENEPHEMERIS_FRONTEND_URL ||
20
+ "https://openephemeris.com";
21
+ }
22
+ /**
23
+ * Load credentials from disk. Returns null if file doesn't exist or is invalid.
24
+ */
25
+ load() {
26
+ if (this.cachedCredentials)
27
+ return this.cachedCredentials;
28
+ try {
29
+ if (!fs.existsSync(CREDENTIALS_FILE))
30
+ return null;
31
+ const raw = fs.readFileSync(CREDENTIALS_FILE, "utf-8");
32
+ const parsed = JSON.parse(raw);
33
+ if (!parsed.access_token || !parsed.refresh_token || !parsed.expires_at) {
34
+ return null;
35
+ }
36
+ this.cachedCredentials = parsed;
37
+ return parsed;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ /**
44
+ * Save credentials to disk.
45
+ */
46
+ save(credentials) {
47
+ try {
48
+ if (!fs.existsSync(CREDENTIALS_DIR)) {
49
+ fs.mkdirSync(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
50
+ }
51
+ const data = { ...credentials, updated_at: new Date().toISOString() };
52
+ fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(data, null, 2), {
53
+ encoding: "utf-8",
54
+ mode: 0o600, // Owner read/write only
55
+ });
56
+ this.cachedCredentials = data;
57
+ }
58
+ catch (err) {
59
+ console.error("Failed to save credentials:", err);
60
+ }
61
+ }
62
+ /**
63
+ * Check whether the current access_token is expired (or nearly expired).
64
+ */
65
+ isExpired(credentials) {
66
+ const creds = credentials || this.load();
67
+ if (!creds?.expires_at)
68
+ return true;
69
+ const expiresAt = new Date(creds.expires_at).getTime();
70
+ const now = Date.now();
71
+ return now >= expiresAt - EXPIRY_BUFFER_SECONDS * 1000;
72
+ }
73
+ /**
74
+ * Attempt to refresh the access token using the stored refresh_token.
75
+ * Uses the Supabase GoTrue refresh endpoint directly.
76
+ */
77
+ async refresh() {
78
+ const creds = this.load();
79
+ if (!creds?.refresh_token)
80
+ return null;
81
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ||
82
+ process.env.SUPABASE_URL;
83
+ const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
84
+ process.env.SUPABASE_ANON_KEY;
85
+ if (!supabaseUrl || !supabaseAnonKey) {
86
+ // Can't refresh without Supabase config — fall back to the web app endpoint
87
+ return this.refreshViaWebApp(creds.refresh_token);
88
+ }
89
+ try {
90
+ const response = await fetch(`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`, {
91
+ method: "POST",
92
+ headers: {
93
+ "Content-Type": "application/json",
94
+ "apikey": supabaseAnonKey,
95
+ },
96
+ body: JSON.stringify({ refresh_token: creds.refresh_token }),
97
+ });
98
+ if (!response.ok)
99
+ return null;
100
+ const data = await response.json();
101
+ if (!data.access_token || !data.refresh_token)
102
+ return null;
103
+ const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
104
+ const updated = {
105
+ access_token: data.access_token,
106
+ refresh_token: data.refresh_token,
107
+ expires_at: expiresAt,
108
+ user_id: data.user?.id || creds.user_id,
109
+ user_email: data.user?.email || creds.user_email,
110
+ updated_at: new Date().toISOString(),
111
+ };
112
+ this.save(updated);
113
+ return updated;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * Fallback refresh via the web app's token refresh endpoint.
121
+ */
122
+ async refreshViaWebApp(refreshToken) {
123
+ try {
124
+ const response = await fetch(`${this.backendBaseURL}/api/device/token`, {
125
+ method: "POST",
126
+ headers: { "Content-Type": "application/json" },
127
+ body: JSON.stringify({
128
+ grant_type: "refresh_token",
129
+ refresh_token: refreshToken,
130
+ }),
131
+ });
132
+ if (!response.ok)
133
+ return null;
134
+ const data = await response.json();
135
+ if (!data.access_token)
136
+ return null;
137
+ const expiresAt = new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString();
138
+ const updated = {
139
+ access_token: data.access_token,
140
+ refresh_token: data.refresh_token || refreshToken,
141
+ expires_at: expiresAt,
142
+ user_id: data.user?.id,
143
+ user_email: data.user?.email,
144
+ updated_at: new Date().toISOString(),
145
+ };
146
+ this.save(updated);
147
+ return updated;
148
+ }
149
+ catch {
150
+ return null;
151
+ }
152
+ }
153
+ /**
154
+ * Get a valid access token, refreshing if necessary.
155
+ * Returns null if no credentials exist or refresh fails.
156
+ */
157
+ async getValidToken() {
158
+ const creds = this.load();
159
+ if (!creds)
160
+ return null;
161
+ if (!this.isExpired(creds)) {
162
+ return creds.access_token;
163
+ }
164
+ // Try refresh
165
+ const refreshed = await this.refresh();
166
+ return refreshed?.access_token || null;
167
+ }
168
+ /**
169
+ * Delete stored credentials.
170
+ */
171
+ clear() {
172
+ this.cachedCredentials = null;
173
+ try {
174
+ if (fs.existsSync(CREDENTIALS_FILE)) {
175
+ fs.unlinkSync(CREDENTIALS_FILE);
176
+ }
177
+ }
178
+ catch {
179
+ // Ignore
180
+ }
181
+ }
182
+ /**
183
+ * Get display info about the current auth state.
184
+ */
185
+ getStatus() {
186
+ const creds = this.load();
187
+ if (!creds)
188
+ return { authenticated: false };
189
+ return {
190
+ authenticated: !this.isExpired(creds),
191
+ email: creds.user_email,
192
+ userId: creds.user_id,
193
+ expiresAt: creds.expires_at,
194
+ };
195
+ }
196
+ /** Path to the credentials file (for diagnostics). */
197
+ static get credentialsPath() {
198
+ return CREDENTIALS_FILE;
199
+ }
200
+ }
@@ -0,0 +1,56 @@
1
+ import { CredentialManager } from "./credentials.js";
2
+ export interface DeviceAuthStartResult {
3
+ device_code: string;
4
+ user_code: string;
5
+ verification_uri: string;
6
+ verification_uri_complete: string;
7
+ expires_in: number;
8
+ interval: number;
9
+ }
10
+ export interface DeviceAuthTokenResult {
11
+ access_token: string;
12
+ refresh_token: string;
13
+ token_type: string;
14
+ expires_in: number;
15
+ user?: {
16
+ id: string;
17
+ email: string;
18
+ };
19
+ }
20
+ /**
21
+ * Custom error thrown when the MCP server needs authentication
22
+ * but no credentials are available and device auth hasn't been started.
23
+ */
24
+ export declare class NeedsAuthError extends Error {
25
+ readonly verificationUri: string;
26
+ readonly userCode: string;
27
+ constructor(verificationUri: string, userCode: string);
28
+ }
29
+ /**
30
+ * DeviceAuthFlow implements RFC 8628 Device Authorization Grant
31
+ * for connecting the MCP server to a user's OpenEphemeris account.
32
+ */
33
+ export declare class DeviceAuthFlow {
34
+ private baseURL;
35
+ private credentialManager;
36
+ constructor(baseURL?: string, credentialManager?: CredentialManager);
37
+ /**
38
+ * Start the device authorization flow.
39
+ * Calls POST /api/device/authorize to get a device code + user code.
40
+ */
41
+ start(clientName?: string): Promise<DeviceAuthStartResult>;
42
+ /**
43
+ * Poll the token endpoint until the user authorizes the device code
44
+ * or the code expires.
45
+ *
46
+ * @param deviceCode - The device_code from start()
47
+ * @param onPending - Optional callback fired on each "authorization_pending" poll
48
+ * @returns The token result with access_token and refresh_token
49
+ */
50
+ poll(deviceCode: string, onPending?: (attempt: number) => void): Promise<DeviceAuthTokenResult>;
51
+ /**
52
+ * Start the full device auth flow: request code, print instructions, poll for token.
53
+ * This is the main entry point for interactive use.
54
+ */
55
+ authenticate(): Promise<DeviceAuthTokenResult>;
56
+ }