@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.
- package/README.md +51 -22
- package/config/dev-allowlist.json +1262 -1318
- package/dist/index.js +0 -0
- package/dist/scripts/dev-allowlist.d.ts +1 -0
- package/dist/scripts/dev-allowlist.js +287 -0
- package/dist/scripts/pack-audit.d.ts +1 -0
- package/dist/scripts/pack-audit.js +45 -0
- package/dist/scripts/schema-packs.d.ts +1 -0
- package/dist/scripts/schema-packs.js +150 -0
- package/dist/scripts/smoke-dev-profile.d.ts +1 -0
- package/dist/scripts/smoke-dev-profile.js +25 -0
- package/dist/scripts/sync-readme.d.ts +1 -0
- package/dist/scripts/sync-readme.js +141 -0
- package/dist/scripts/test-client.d.ts +1 -0
- package/dist/scripts/test-client.js +69 -0
- package/dist/scripts/test-sse-client.d.ts +1 -0
- package/dist/scripts/test-sse-client.js +221 -0
- package/dist/src/auth/credentials.d.ts +65 -0
- package/dist/src/auth/credentials.js +200 -0
- package/dist/src/auth/device-auth.d.ts +56 -0
- package/dist/src/auth/device-auth.js +147 -0
- package/dist/src/backend/client.d.ts +61 -0
- package/dist/src/backend/client.js +335 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +98 -0
- package/dist/src/schema-packs/llm.d.ts +105 -0
- package/dist/src/schema-packs/llm.js +429 -0
- package/dist/src/server-sse.d.ts +1 -0
- package/dist/src/server-sse.js +264 -0
- package/dist/src/tools/auth.d.ts +1 -0
- package/dist/src/tools/auth.js +202 -0
- package/dist/src/tools/dev.d.ts +1 -0
- package/dist/src/tools/dev.js +195 -0
- package/dist/src/tools/index.d.ts +33 -0
- package/dist/src/tools/index.js +56 -0
- package/dist/src/tools/specialized/eclipse.d.ts +1 -0
- package/dist/src/tools/specialized/eclipse.js +53 -0
- package/dist/src/tools/specialized/electional.d.ts +1 -0
- package/dist/src/tools/specialized/electional.js +80 -0
- package/dist/src/tools/specialized/human_design.d.ts +1 -0
- package/dist/src/tools/specialized/human_design.js +54 -0
- package/dist/src/tools/specialized/moon.d.ts +1 -0
- package/dist/src/tools/specialized/moon.js +51 -0
- package/dist/src/tools/specialized/natal.d.ts +1 -0
- package/dist/src/tools/specialized/natal.js +80 -0
- package/dist/src/tools/specialized/relocation.d.ts +1 -0
- package/dist/src/tools/specialized/relocation.js +76 -0
- package/dist/src/tools/specialized/synastry.d.ts +1 -0
- package/dist/src/tools/specialized/synastry.js +73 -0
- package/dist/src/tools/specialized/transits.d.ts +1 -0
- package/dist/src/tools/specialized/transits.js +87 -0
- package/dist/test/allowlist-and-tools.test.d.ts +1 -0
- package/dist/test/allowlist-and-tools.test.js +96 -0
- package/dist/test/backend-client.test.d.ts +1 -0
- package/dist/test/backend-client.test.js +284 -0
- package/dist/test/credentials.test.d.ts +1 -0
- package/dist/test/credentials.test.js +143 -0
- 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
|
+
}
|