@openephemeris/mcp-server 3.1.0 → 3.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 +30 -21
- 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/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 +144 -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 +92 -0
- package/dist/src/schema-packs/llm.d.ts +105 -0
- package/dist/src/schema-packs/llm.js +429 -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 +187 -0
- package/dist/src/tools/index.d.ts +25 -0
- package/dist/src/tools/index.js +33 -0
- package/dist/src/tools/specialized/eclipse.d.ts +1 -0
- package/dist/src/tools/specialized/eclipse.js +56 -0
- package/dist/src/tools/specialized/electional.d.ts +1 -0
- package/dist/src/tools/specialized/electional.js +79 -0
- package/dist/src/tools/specialized/human_design.d.ts +1 -0
- package/dist/src/tools/specialized/human_design.js +53 -0
- package/dist/src/tools/specialized/moon.d.ts +1 -0
- package/dist/src/tools/specialized/moon.js +50 -0
- package/dist/src/tools/specialized/natal.d.ts +1 -0
- package/dist/src/tools/specialized/natal.js +71 -0
- package/dist/src/tools/specialized/relocation.d.ts +1 -0
- package/dist/src/tools/specialized/relocation.js +71 -0
- package/dist/src/tools/specialized/synastry.d.ts +1 -0
- package/dist/src/tools/specialized/synastry.js +61 -0
- package/dist/src/tools/specialized/transits.d.ts +1 -0
- package/dist/src/tools/specialized/transits.js +80 -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 +286 -0
- package/dist/test/credentials.test.d.ts +1 -0
- package/dist/test/credentials.test.js +143 -0
- package/package.json +21 -18
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { initTools, toolRegistry } from "../src/tools/index.js";
|
|
5
|
+
const CURSOR_BEGIN = "<!-- GENERATED:CURSOR_INSTALL:BEGIN -->";
|
|
6
|
+
const CURSOR_END = "<!-- GENERATED:CURSOR_INSTALL:END -->";
|
|
7
|
+
const SNAPSHOT_BEGIN = "<!-- GENERATED:RUNTIME_SNAPSHOT:BEGIN -->";
|
|
8
|
+
const SNAPSHOT_END = "<!-- GENERATED:RUNTIME_SNAPSHOT:END -->";
|
|
9
|
+
function parseMode(argv) {
|
|
10
|
+
if (argv.includes("--write"))
|
|
11
|
+
return "write";
|
|
12
|
+
if (argv.includes("--check"))
|
|
13
|
+
return "check";
|
|
14
|
+
throw new Error("Usage: tsx scripts/sync-readme.ts --write|--check");
|
|
15
|
+
}
|
|
16
|
+
function replaceSection(content, beginMarker, endMarker, body) {
|
|
17
|
+
const start = content.indexOf(beginMarker);
|
|
18
|
+
const end = content.indexOf(endMarker);
|
|
19
|
+
if (start === -1 || end === -1 || end < start) {
|
|
20
|
+
throw new Error(`Missing marker block: ${beginMarker} ... ${endMarker}`);
|
|
21
|
+
}
|
|
22
|
+
const before = content.slice(0, start + beginMarker.length);
|
|
23
|
+
const after = content.slice(end);
|
|
24
|
+
return `${before}\n${body.trim()}\n${after}`;
|
|
25
|
+
}
|
|
26
|
+
function getRepoPaths() {
|
|
27
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const mcpRoot = path.resolve(scriptDir, "..");
|
|
29
|
+
return {
|
|
30
|
+
readmePath: path.join(mcpRoot, "README.md"),
|
|
31
|
+
allowlistPath: path.join(mcpRoot, "config", "dev-allowlist.json"),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function loadAllowlist(allowlistPath) {
|
|
35
|
+
const raw = fs.readFileSync(allowlistPath, "utf-8");
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
if (parsed.schema !== "astromcp-dev-allowlist-v1" || !Array.isArray(parsed.allow)) {
|
|
38
|
+
throw new Error(`Invalid allowlist at ${allowlistPath}`);
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
function toCursorDeepLink() {
|
|
43
|
+
const config = {
|
|
44
|
+
command: "npx",
|
|
45
|
+
args: ["-y", "@openephemeris/mcp-server"],
|
|
46
|
+
env: {
|
|
47
|
+
OPENEPHEMERIS_PROFILE: "dev",
|
|
48
|
+
OPENEPHEMERIS_BACKEND_URL: "https://api.openephemeris.com",
|
|
49
|
+
OPENEPHEMERIS_API_KEY: "YOUR_API_KEY_HERE",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const configJson = JSON.stringify(config, null, 2);
|
|
53
|
+
const encoded = Buffer.from(JSON.stringify(config), "utf-8").toString("base64");
|
|
54
|
+
const url = `cursor://anysphere.cursor-deeplink/mcp/install?name=openephemeris&config=${encoded}`;
|
|
55
|
+
return { url, configJson };
|
|
56
|
+
}
|
|
57
|
+
function buildMethodCounts(entries) {
|
|
58
|
+
const counts = { GET: 0, POST: 0, PUT: 0, PATCH: 0, DELETE: 0 };
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
counts[e.method] += 1;
|
|
61
|
+
}
|
|
62
|
+
return counts;
|
|
63
|
+
}
|
|
64
|
+
function buildFamilyRows(entries) {
|
|
65
|
+
const families = new Map();
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
const family = e.path.split("/").filter(Boolean)[0] || "root";
|
|
68
|
+
const existing = families.get(family) || [];
|
|
69
|
+
existing.push(e);
|
|
70
|
+
families.set(family, existing);
|
|
71
|
+
}
|
|
72
|
+
const rows = [...families.entries()]
|
|
73
|
+
.map(([family, items]) => {
|
|
74
|
+
const sorted = [...items].sort((a, b) => `${a.path}:${a.method}`.localeCompare(`${b.path}:${b.method}`));
|
|
75
|
+
const sample = sorted.slice(0, 2).map((e) => `\`${e.method} ${e.path}\``).join(", ");
|
|
76
|
+
return { family, count: items.length, sample };
|
|
77
|
+
})
|
|
78
|
+
.sort((a, b) => a.family.localeCompare(b.family));
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
81
|
+
async function buildRuntimeSnapshot(allow) {
|
|
82
|
+
await initTools("dev");
|
|
83
|
+
const toolNames = Object.keys(toolRegistry).sort((a, b) => a.localeCompare(b));
|
|
84
|
+
const typedTools = toolNames.filter((t) => !t.startsWith("dev."));
|
|
85
|
+
const genericTools = toolNames.filter((t) => t.startsWith("dev."));
|
|
86
|
+
const methodCounts = buildMethodCounts(allow);
|
|
87
|
+
const familyRows = buildFamilyRows(allow);
|
|
88
|
+
const familiesTable = [
|
|
89
|
+
"| Family | Operations | Example |",
|
|
90
|
+
"|---|---:|---|",
|
|
91
|
+
...familyRows.map((row) => `| \`${row.family}\` | ${row.count} | ${row.sample} |`),
|
|
92
|
+
].join("\n");
|
|
93
|
+
return [
|
|
94
|
+
"## Runtime Snapshot (Generated)",
|
|
95
|
+
"",
|
|
96
|
+
"Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live tool registry.",
|
|
97
|
+
"",
|
|
98
|
+
`- Allowlisted operations: **${allow.length}**`,
|
|
99
|
+
`- Methods: \`GET=${methodCounts.GET}\`, \`POST=${methodCounts.POST}\`, \`PUT=${methodCounts.PUT}\`, \`PATCH=${methodCounts.PATCH}\`, \`DELETE=${methodCounts.DELETE}\``,
|
|
100
|
+
`- Registered tools (\`OPENEPHEMERIS_PROFILE=dev\`): **${toolNames.length}**`,
|
|
101
|
+
`- Typed tools: ${typedTools.map((t) => `\`${t}\``).join(", ")}`,
|
|
102
|
+
`- Generic tools: ${genericTools.map((t) => `\`${t}\``).join(", ")}`,
|
|
103
|
+
"",
|
|
104
|
+
"### Allowlist Families",
|
|
105
|
+
"",
|
|
106
|
+
familiesTable,
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
async function main() {
|
|
110
|
+
const mode = parseMode(process.argv.slice(2));
|
|
111
|
+
const { readmePath, allowlistPath } = getRepoPaths();
|
|
112
|
+
const allowlist = loadAllowlist(allowlistPath);
|
|
113
|
+
const { url, configJson } = toCursorDeepLink();
|
|
114
|
+
const runtimeSnapshot = await buildRuntimeSnapshot(allowlist.allow);
|
|
115
|
+
const cursorSection = [
|
|
116
|
+
`[](${url})`,
|
|
117
|
+
"",
|
|
118
|
+
"> Replace `YOUR_API_KEY_HERE` in Cursor MCP settings with your API key from https://openephemeris.com/dashboard.",
|
|
119
|
+
"",
|
|
120
|
+
"Cursor deeplink payload:",
|
|
121
|
+
"```json",
|
|
122
|
+
configJson,
|
|
123
|
+
"```",
|
|
124
|
+
].join("\n");
|
|
125
|
+
const original = fs.readFileSync(readmePath, "utf-8");
|
|
126
|
+
let next = replaceSection(original, CURSOR_BEGIN, CURSOR_END, cursorSection);
|
|
127
|
+
next = replaceSection(next, SNAPSHOT_BEGIN, SNAPSHOT_END, runtimeSnapshot);
|
|
128
|
+
if (mode === "check") {
|
|
129
|
+
if (next !== original) {
|
|
130
|
+
throw new Error("README drift detected. Run `npm run sync:readme`.");
|
|
131
|
+
}
|
|
132
|
+
console.log("README sync OK");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
fs.writeFileSync(readmePath, next, "utf-8");
|
|
136
|
+
console.log("README updated");
|
|
137
|
+
}
|
|
138
|
+
main().catch((err) => {
|
|
139
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
140
|
+
process.exit(1);
|
|
141
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { CredentialManager } from "./credentials.js";
|
|
2
|
+
/** Polling interval in milliseconds. */
|
|
3
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
4
|
+
/** Maximum time to poll before giving up (10 minutes = device code TTL). */
|
|
5
|
+
const MAX_POLL_DURATION_MS = 10 * 60 * 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Custom error thrown when the MCP server needs authentication
|
|
8
|
+
* but no credentials are available and device auth hasn't been started.
|
|
9
|
+
*/
|
|
10
|
+
export class NeedsAuthError extends Error {
|
|
11
|
+
verificationUri;
|
|
12
|
+
userCode;
|
|
13
|
+
constructor(verificationUri, userCode) {
|
|
14
|
+
super(`Authentication required. Visit ${verificationUri} and enter code: ${userCode}`);
|
|
15
|
+
this.name = "NeedsAuthError";
|
|
16
|
+
this.verificationUri = verificationUri;
|
|
17
|
+
this.userCode = userCode;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* DeviceAuthFlow implements RFC 8628 Device Authorization Grant
|
|
25
|
+
* for connecting the MCP server to a user's OpenEphemeris account.
|
|
26
|
+
*/
|
|
27
|
+
export class DeviceAuthFlow {
|
|
28
|
+
baseURL;
|
|
29
|
+
credentialManager;
|
|
30
|
+
constructor(baseURL, credentialManager) {
|
|
31
|
+
this.baseURL =
|
|
32
|
+
baseURL ||
|
|
33
|
+
process.env.NEXT_PUBLIC_APP_URL ||
|
|
34
|
+
process.env.OPENEPHEMERIS_FRONTEND_URL ||
|
|
35
|
+
"https://openephemeris.com";
|
|
36
|
+
this.credentialManager = credentialManager || new CredentialManager(this.baseURL);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start the device authorization flow.
|
|
40
|
+
* Calls POST /api/device/authorize to get a device code + user code.
|
|
41
|
+
*/
|
|
42
|
+
async start(clientName) {
|
|
43
|
+
const response = await fetch(`${this.baseURL}/api/device/authorize`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/json" },
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
client_name: clientName || "openephemeris-mcp",
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const errorData = await response.json().catch(() => ({}));
|
|
52
|
+
throw new Error(`Failed to start device authorization: ${errorData.error_description || response.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
return (await response.json());
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Poll the token endpoint until the user authorizes the device code
|
|
58
|
+
* or the code expires.
|
|
59
|
+
*
|
|
60
|
+
* @param deviceCode - The device_code from start()
|
|
61
|
+
* @param onPending - Optional callback fired on each "authorization_pending" poll
|
|
62
|
+
* @returns The token result with access_token and refresh_token
|
|
63
|
+
*/
|
|
64
|
+
async poll(deviceCode, onPending) {
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
let attempt = 0;
|
|
67
|
+
while (Date.now() - startedAt < MAX_POLL_DURATION_MS) {
|
|
68
|
+
attempt++;
|
|
69
|
+
const response = await fetch(`${this.baseURL}/api/device/token`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
device_code: deviceCode,
|
|
74
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
if (response.ok) {
|
|
78
|
+
// Success! User authorized the device.
|
|
79
|
+
const result = (await response.json());
|
|
80
|
+
// Persist credentials
|
|
81
|
+
const expiresAt = new Date(Date.now() + (result.expires_in || 3600) * 1000).toISOString();
|
|
82
|
+
this.credentialManager.save({
|
|
83
|
+
access_token: result.access_token,
|
|
84
|
+
refresh_token: result.refresh_token,
|
|
85
|
+
expires_at: expiresAt,
|
|
86
|
+
user_id: result.user?.id,
|
|
87
|
+
user_email: result.user?.email,
|
|
88
|
+
updated_at: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
// Parse error response
|
|
93
|
+
const errorData = await response.json().catch(() => ({ error: "unknown" }));
|
|
94
|
+
if (errorData.error === "authorization_pending") {
|
|
95
|
+
// Still waiting — keep polling
|
|
96
|
+
onPending?.(attempt);
|
|
97
|
+
await sleep(POLL_INTERVAL_MS);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (errorData.error === "slow_down") {
|
|
101
|
+
// Server wants us to slow down
|
|
102
|
+
await sleep(POLL_INTERVAL_MS * 2);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (errorData.error === "expired_token") {
|
|
106
|
+
throw new Error("Device code expired. Please run auth.login again to get a new code.");
|
|
107
|
+
}
|
|
108
|
+
// Any other error is fatal
|
|
109
|
+
throw new Error(`Device authorization failed: ${errorData.error_description || errorData.error}`);
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Device authorization timed out. Please try again.");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Start the full device auth flow: request code, print instructions, poll for token.
|
|
115
|
+
* This is the main entry point for interactive use.
|
|
116
|
+
*/
|
|
117
|
+
async authenticate() {
|
|
118
|
+
const startResult = await this.start();
|
|
119
|
+
// Print instructions to stderr (visible to the user in terminal,
|
|
120
|
+
// but not mixed into the MCP stdio JSON protocol)
|
|
121
|
+
console.error("");
|
|
122
|
+
console.error("╔══════════════════════════════════════════════════╗");
|
|
123
|
+
console.error("║ 🔗 OpenEphemeris - Connect Account ║");
|
|
124
|
+
console.error("╠══════════════════════════════════════════════════╣");
|
|
125
|
+
console.error(`║ Visit: ${startResult.verification_uri.padEnd(39)} ║`);
|
|
126
|
+
console.error(`║ Code: ${startResult.user_code.padEnd(39)} ║`);
|
|
127
|
+
console.error("╠══════════════════════════════════════════════════╣");
|
|
128
|
+
console.error("║ Or open this direct link: ║");
|
|
129
|
+
console.error(`║ ${startResult.verification_uri_complete.slice(0, 48).padEnd(48)} ║`);
|
|
130
|
+
console.error("╚══════════════════════════════════════════════════╝");
|
|
131
|
+
console.error("");
|
|
132
|
+
console.error("Waiting for authorization...");
|
|
133
|
+
const result = await this.poll(startResult.device_code, (attempt) => {
|
|
134
|
+
if (attempt % 6 === 0) {
|
|
135
|
+
// Remind every 30 seconds
|
|
136
|
+
console.error(`Still waiting... Visit ${startResult.verification_uri} and enter code: ${startResult.user_code}`);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
console.error(`✅ Authenticated as ${result.user?.email || "user"}`);
|
|
140
|
+
console.error(` Credentials saved to ${CredentialManager.credentialsPath}`);
|
|
141
|
+
console.error("");
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface BackendConfig {
|
|
2
|
+
baseURL: string;
|
|
3
|
+
userId?: string;
|
|
4
|
+
jwt?: string;
|
|
5
|
+
serviceKey?: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
/** Back-compat: previous name for JWT token (Bearer). */
|
|
8
|
+
authToken?: string;
|
|
9
|
+
}
|
|
10
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
11
|
+
export interface BackendRequestOptions {
|
|
12
|
+
params?: Record<string, unknown>;
|
|
13
|
+
data?: unknown;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
/** Override per-request timeout in milliseconds. */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface BinaryBackendResponse {
|
|
19
|
+
content_type: string;
|
|
20
|
+
content_length: number;
|
|
21
|
+
encoding: "base64";
|
|
22
|
+
data_base64: string;
|
|
23
|
+
}
|
|
24
|
+
export declare class BackendError extends Error {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly code: string;
|
|
27
|
+
readonly retryable: boolean;
|
|
28
|
+
readonly upgradeUrl?: string | undefined;
|
|
29
|
+
constructor(message: string, status: number, code: string, retryable: boolean, upgradeUrl?: string | undefined);
|
|
30
|
+
}
|
|
31
|
+
export declare class BackendClient {
|
|
32
|
+
private client;
|
|
33
|
+
private userId;
|
|
34
|
+
private jwt?;
|
|
35
|
+
private serviceKey?;
|
|
36
|
+
private apiKey?;
|
|
37
|
+
private credentialManager;
|
|
38
|
+
private _pendingAuthFlow;
|
|
39
|
+
private _authFlowResult;
|
|
40
|
+
constructor(config: BackendConfig);
|
|
41
|
+
/**
|
|
42
|
+
* Start the device auth flow in the background if not already running.
|
|
43
|
+
* Called automatically when no credentials are found in the interceptor.
|
|
44
|
+
* Idempotent — only starts once per client lifetime.
|
|
45
|
+
*/
|
|
46
|
+
private autoStartDeviceAuth;
|
|
47
|
+
private expectsBinaryResponse;
|
|
48
|
+
private toBuffer;
|
|
49
|
+
private decodePayload;
|
|
50
|
+
private extractMessage;
|
|
51
|
+
private normalizeContentType;
|
|
52
|
+
private isBinaryContentType;
|
|
53
|
+
/** Maps an AxiosError to a structured BackendError. */
|
|
54
|
+
private mapError;
|
|
55
|
+
get<T>(path: string, params?: Record<string, any>, timeoutMs?: number): Promise<T>;
|
|
56
|
+
post<T>(path: string, data?: any, timeoutMs?: number): Promise<T>;
|
|
57
|
+
delete<T>(path: string): Promise<T>;
|
|
58
|
+
request<T>(method: HttpMethod, path: string, options?: BackendRequestOptions): Promise<T>;
|
|
59
|
+
getUserId(): string;
|
|
60
|
+
}
|
|
61
|
+
export declare const backendClient: BackendClient;
|