@openephemeris/mcp-server 3.0.1 → 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.
Files changed (58) hide show
  1. package/README.md +32 -22
  2. package/config/dev-allowlist.json +1319 -1165
  3. package/dist/backend/client.d.ts +12 -0
  4. package/dist/backend/client.js +99 -35
  5. package/dist/index.js +5 -0
  6. package/dist/schema-packs/llm.d.ts +1 -1
  7. package/dist/schema-packs/llm.js +1 -1
  8. package/dist/scripts/dev-allowlist.d.ts +1 -0
  9. package/dist/scripts/dev-allowlist.js +287 -0
  10. package/dist/scripts/pack-audit.d.ts +1 -0
  11. package/dist/scripts/pack-audit.js +45 -0
  12. package/dist/scripts/schema-packs.d.ts +1 -0
  13. package/dist/scripts/schema-packs.js +150 -0
  14. package/dist/scripts/smoke-dev-profile.d.ts +1 -0
  15. package/dist/scripts/smoke-dev-profile.js +25 -0
  16. package/dist/scripts/sync-readme.d.ts +1 -0
  17. package/dist/scripts/sync-readme.js +141 -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 +144 -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 +92 -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/tools/auth.d.ts +1 -0
  29. package/dist/src/tools/auth.js +202 -0
  30. package/dist/src/tools/dev.d.ts +1 -0
  31. package/dist/src/tools/dev.js +187 -0
  32. package/dist/src/tools/index.d.ts +25 -0
  33. package/dist/src/tools/index.js +33 -0
  34. package/dist/src/tools/specialized/eclipse.d.ts +1 -0
  35. package/dist/src/tools/specialized/eclipse.js +56 -0
  36. package/dist/src/tools/specialized/electional.d.ts +1 -0
  37. package/dist/src/tools/specialized/electional.js +79 -0
  38. package/dist/src/tools/specialized/human_design.d.ts +1 -0
  39. package/dist/src/tools/specialized/human_design.js +53 -0
  40. package/dist/src/tools/specialized/moon.d.ts +1 -0
  41. package/dist/src/tools/specialized/moon.js +50 -0
  42. package/dist/src/tools/specialized/natal.d.ts +1 -0
  43. package/dist/src/tools/specialized/natal.js +71 -0
  44. package/dist/src/tools/specialized/relocation.d.ts +1 -0
  45. package/dist/src/tools/specialized/relocation.js +71 -0
  46. package/dist/src/tools/specialized/synastry.d.ts +1 -0
  47. package/dist/src/tools/specialized/synastry.js +61 -0
  48. package/dist/src/tools/specialized/transits.d.ts +1 -0
  49. package/dist/src/tools/specialized/transits.js +80 -0
  50. package/dist/test/allowlist-and-tools.test.d.ts +1 -0
  51. package/dist/test/allowlist-and-tools.test.js +96 -0
  52. package/dist/test/backend-client.test.d.ts +1 -0
  53. package/dist/test/backend-client.test.js +286 -0
  54. package/dist/test/credentials.test.d.ts +1 -0
  55. package/dist/test/credentials.test.js +143 -0
  56. package/dist/tools/dev.js +7 -3
  57. package/dist/tools/index.d.ts +7 -0
  58. package/package.json +3 -3
@@ -0,0 +1,150 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { LLM_V2_ASPECTS_SCHEMA, LLM_V2_DICT, LLM_V2_POINTS_SCHEMA, LlmV2PayloadSchema, llmV2JsonSchema, } from "../src/schema-packs/llm.js";
6
+ function parseMode(argv) {
7
+ if (argv.includes("--write"))
8
+ return "write";
9
+ if (argv.includes("--check"))
10
+ return "check";
11
+ throw new Error("Usage: tsx scripts/schema-packs.ts --write|--check");
12
+ }
13
+ function ensureDir(dirPath) {
14
+ fs.mkdirSync(dirPath, { recursive: true });
15
+ }
16
+ function readJsonFile(filePath) {
17
+ const raw = fs.readFileSync(filePath, "utf-8");
18
+ return JSON.parse(raw);
19
+ }
20
+ function writeJsonFile(filePath, obj) {
21
+ const raw = JSON.stringify(obj, null, 2) + "\n";
22
+ fs.writeFileSync(filePath, raw, "utf-8");
23
+ }
24
+ function stableStringify(obj) {
25
+ // Deterministic enough for our use: we always write with JSON.stringify(..., null, 2)
26
+ // and compare against the same formatting.
27
+ return JSON.stringify(obj, null, 2) + "\n";
28
+ }
29
+ function validateFixture(filePath) {
30
+ const payload = readJsonFile(filePath);
31
+ const parsed = LlmV2PayloadSchema.safeParse(payload);
32
+ if (!parsed.success) {
33
+ const msg = parsed.error.issues
34
+ .slice(0, 25)
35
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
36
+ .join("\n");
37
+ throw new Error(`Invalid llm fixture: ${filePath}\n${msg}`);
38
+ }
39
+ }
40
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
41
+ const MCP_ROOT = path.resolve(SCRIPT_DIR, "..");
42
+ const PACK_DIR = path.join(MCP_ROOT, "schema-packs", "llm");
43
+ const FIXTURES_DIR = path.join(PACK_DIR, "fixtures");
44
+ const SCHEMA_PATH = path.join(PACK_DIR, "schema.json");
45
+ const REPO_ROOT = path.resolve(MCP_ROOT, "..");
46
+ const CANDIDATE_FIXTURES = [
47
+ path.join(REPO_ROOT, "tmp", "llm_fe_sample.json"),
48
+ path.join(REPO_ROOT, "tmp", "llm_payload_user_2025-12-21.json"),
49
+ ];
50
+ function copyFirstExistingFixture() {
51
+ for (const candidate of CANDIDATE_FIXTURES) {
52
+ if (!fs.existsSync(candidate))
53
+ continue;
54
+ const dest = path.join(FIXTURES_DIR, path.basename(candidate));
55
+ fs.copyFileSync(candidate, dest);
56
+ return dest;
57
+ }
58
+ return null;
59
+ }
60
+ function writeFallbackFixture() {
61
+ const fallbackPath = path.join(FIXTURES_DIR, "minimal-natal.json");
62
+ const minimalPayload = {
63
+ output_mode: "llm",
64
+ chart: {
65
+ chart_type: "natal",
66
+ subject: {
67
+ datetime: "2026-01-01T00:00:00Z",
68
+ jd: null,
69
+ lat: 0,
70
+ lon: 0,
71
+ },
72
+ },
73
+ dict: LLM_V2_DICT,
74
+ present: {
75
+ point_count: 1,
76
+ kinds: {
77
+ planet: 1,
78
+ },
79
+ },
80
+ points_schema: LLM_V2_POINTS_SCHEMA,
81
+ points: [["sun", "planet", 0, 280, 0, 1, 0, 9, 10, null, 23, 0, "none", null, null, 0]],
82
+ aspects_schema: LLM_V2_ASPECTS_SCHEMA,
83
+ aspects: [],
84
+ counts: {
85
+ point_count: 1,
86
+ aspect_count: 0,
87
+ },
88
+ };
89
+ writeJsonFile(fallbackPath, minimalPayload);
90
+ return fallbackPath;
91
+ }
92
+ function listFixtures() {
93
+ if (!fs.existsSync(FIXTURES_DIR))
94
+ return [];
95
+ return fs
96
+ .readdirSync(FIXTURES_DIR)
97
+ .filter((f) => f.toLowerCase().endsWith(".json"))
98
+ .map((f) => path.join(FIXTURES_DIR, f));
99
+ }
100
+ function checkSchemaFile() {
101
+ if (!fs.existsSync(SCHEMA_PATH)) {
102
+ throw new Error(`Missing schema file: ${SCHEMA_PATH} (run --write)`);
103
+ }
104
+ const disk = readJsonFile(SCHEMA_PATH);
105
+ const expected = llmV2JsonSchema;
106
+ const diskRaw = fs.readFileSync(SCHEMA_PATH, "utf-8");
107
+ const expectedRaw = stableStringify(expected);
108
+ if (diskRaw !== expectedRaw) {
109
+ throw new Error(`schema.json drift detected (run --write): ${SCHEMA_PATH}`);
110
+ }
111
+ }
112
+ function main() {
113
+ const mode = parseMode(process.argv.slice(2));
114
+ if (mode === "write") {
115
+ ensureDir(FIXTURES_DIR);
116
+ ensureDir(PACK_DIR);
117
+ writeJsonFile(SCHEMA_PATH, llmV2JsonSchema);
118
+ if (listFixtures().length === 0) {
119
+ const copied = copyFirstExistingFixture();
120
+ if (copied) {
121
+ validateFixture(copied);
122
+ // eslint-disable-next-line no-console
123
+ console.log(`Copied fixture: ${copied}`);
124
+ }
125
+ else {
126
+ const fallback = writeFallbackFixture();
127
+ validateFixture(fallback);
128
+ // eslint-disable-next-line no-console
129
+ console.log(`Wrote fallback fixture: ${fallback}`);
130
+ }
131
+ }
132
+ for (const fixture of listFixtures()) {
133
+ validateFixture(fixture);
134
+ }
135
+ // eslint-disable-next-line no-console
136
+ console.log(`Wrote schema pack: ${PACK_DIR}`);
137
+ return;
138
+ }
139
+ // check
140
+ checkSchemaFile();
141
+ const fixtures = listFixtures();
142
+ if (fixtures.length === 0) {
143
+ throw new Error(`No fixtures found in: ${FIXTURES_DIR}`);
144
+ }
145
+ for (const fixture of fixtures)
146
+ validateFixture(fixture);
147
+ // eslint-disable-next-line no-console
148
+ console.log(`Schema pack OK (fixtures=${fixtures.length})`);
149
+ }
150
+ main();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { initTools, toolRegistry } from "../src/tools/index.js";
2
+ async function main() {
3
+ await initTools("dev");
4
+ const list = toolRegistry["dev.list_allowed"];
5
+ const call = toolRegistry["dev.call"];
6
+ if (!list || !call) {
7
+ throw new Error("Dev tools not registered (expected dev.list_allowed and dev.call)");
8
+ }
9
+ const allowed = await list.handler({});
10
+ console.log("ALLOWED_TOOLS:");
11
+ console.log(JSON.stringify(Object.keys(toolRegistry), null, 2));
12
+ console.log("\nALLOWLIST:");
13
+ console.log(JSON.stringify(allowed, null, 2));
14
+ // Smoke call a simple endpoint that should exist without extra payload.
15
+ const result = await call.handler({
16
+ method: "GET",
17
+ path: "/health",
18
+ });
19
+ console.log("\nDEV_CALL_RESULT:");
20
+ console.log(JSON.stringify(result, null, 2));
21
+ }
22
+ main().catch((err) => {
23
+ console.error(err instanceof Error ? err.stack || err.message : String(err));
24
+ process.exit(1);
25
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ `[![Install in Cursor](https://img.shields.io/badge/Install%20in-Cursor-1f6feb)](${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
+ }