@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,147 @@
|
|
|
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("By using the OpenEphemeris API, you agree to the Terms of Service");
|
|
133
|
+
console.error("at https://openephemeris.com/terms");
|
|
134
|
+
console.error("");
|
|
135
|
+
console.error("Waiting for authorization...");
|
|
136
|
+
const result = await this.poll(startResult.device_code, (attempt) => {
|
|
137
|
+
if (attempt % 6 === 0) {
|
|
138
|
+
// Remind every 30 seconds
|
|
139
|
+
console.error(`Still waiting... Visit ${startResult.verification_uri} and enter code: ${startResult.user_code}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
console.error(`✅ Authenticated as ${result.user?.email || "user"}`);
|
|
143
|
+
console.error(` Credentials saved to ${CredentialManager.credentialsPath}`);
|
|
144
|
+
console.error("");
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import axios, { AxiosError } from "axios";
|
|
2
|
+
import { CredentialManager } from "../auth/credentials.js";
|
|
3
|
+
import { DeviceAuthFlow } from "../auth/device-auth.js";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 45_000;
|
|
5
|
+
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; // Three retries with backoff
|
|
6
|
+
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
7
|
+
const RETRYABLE_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "ENOTFOUND", "EAI_AGAIN"]);
|
|
8
|
+
const BINARY_ENDPOINT_PREFIXES = [
|
|
9
|
+
"/visualization/bi-wheel",
|
|
10
|
+
"/visualization/chart-wheel",
|
|
11
|
+
"/comparative/visualization/bi-wheel",
|
|
12
|
+
"/comparative/visualization/chart-wheel",
|
|
13
|
+
];
|
|
14
|
+
const DASHBOARD_ACCOUNT_URL = "https://openephemeris.com/dashboard?tab=account";
|
|
15
|
+
const LOGIN_SIGNUP_URL = "https://openephemeris.com/login?signup=true&redirect=%2Fdashboard%3Ftab%3Daccount";
|
|
16
|
+
const UPGRADE_URL = "https://openephemeris.com/pay";
|
|
17
|
+
function sleep(ms) {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
export class BackendError extends Error {
|
|
21
|
+
status;
|
|
22
|
+
code;
|
|
23
|
+
retryable;
|
|
24
|
+
upgradeUrl;
|
|
25
|
+
constructor(message, status, code, retryable, upgradeUrl) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.retryable = retryable;
|
|
30
|
+
this.upgradeUrl = upgradeUrl;
|
|
31
|
+
this.name = "BackendError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class BackendClient {
|
|
35
|
+
client;
|
|
36
|
+
userId;
|
|
37
|
+
jwt;
|
|
38
|
+
serviceKey;
|
|
39
|
+
apiKey;
|
|
40
|
+
credentialManager;
|
|
41
|
+
_pendingAuthFlow = null;
|
|
42
|
+
_authFlowResult = null;
|
|
43
|
+
constructor(config) {
|
|
44
|
+
this.userId = config.userId || "anonymous";
|
|
45
|
+
this.jwt =
|
|
46
|
+
config.jwt ||
|
|
47
|
+
config.authToken ||
|
|
48
|
+
process.env.OPENEPHEMERIS_JWT ||
|
|
49
|
+
process.env.ASTROMCP_JWT ||
|
|
50
|
+
process.env.MERIDIAN_AUTH_TOKEN;
|
|
51
|
+
this.serviceKey =
|
|
52
|
+
config.serviceKey ||
|
|
53
|
+
process.env.OPENEPHEMERIS_SERVICE_KEY ||
|
|
54
|
+
process.env.ASTROMCP_SERVICE_KEY ||
|
|
55
|
+
process.env.MERIDIAN_SERVICE_KEY;
|
|
56
|
+
this.apiKey =
|
|
57
|
+
config.apiKey ||
|
|
58
|
+
process.env.OPENEPHEMERIS_API_KEY ||
|
|
59
|
+
process.env.ASTROMCP_API_KEY ||
|
|
60
|
+
process.env.MERIDIAN_API_KEY;
|
|
61
|
+
this.credentialManager = new CredentialManager();
|
|
62
|
+
this.client = axios.create({
|
|
63
|
+
baseURL: config.baseURL ||
|
|
64
|
+
process.env.OPENEPHEMERIS_BACKEND_URL ||
|
|
65
|
+
process.env.ASTROMCP_BACKEND_URL ||
|
|
66
|
+
process.env.MERIDIAN_BACKEND_URL ||
|
|
67
|
+
"https://api.openephemeris.com",
|
|
68
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
// Add auth interceptor (priority: service key > API key > JWT env > cached JWT)
|
|
74
|
+
this.client.interceptors.request.use(async (req) => {
|
|
75
|
+
req.headers = req.headers ?? {};
|
|
76
|
+
// Explicit per-request headers win.
|
|
77
|
+
const hasAuthHeader = typeof req.headers.Authorization === "string" &&
|
|
78
|
+
req.headers.Authorization.length > 0;
|
|
79
|
+
const hasServiceKeyHeader = typeof req.headers["X-Service-Key"] === "string" &&
|
|
80
|
+
req.headers["X-Service-Key"].length > 0;
|
|
81
|
+
const hasApiKeyHeader = typeof req.headers["X-API-Key"] === "string" &&
|
|
82
|
+
req.headers["X-API-Key"].length > 0;
|
|
83
|
+
if (!hasAuthHeader && !hasServiceKeyHeader && !hasApiKeyHeader) {
|
|
84
|
+
if (this.serviceKey) {
|
|
85
|
+
req.headers["X-Service-Key"] = this.serviceKey;
|
|
86
|
+
}
|
|
87
|
+
else if (this.apiKey) {
|
|
88
|
+
req.headers["X-API-Key"] = this.apiKey;
|
|
89
|
+
}
|
|
90
|
+
else if (this.jwt) {
|
|
91
|
+
req.headers.Authorization = `Bearer ${this.jwt}`;
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
// Fall through to cached credentials from device auth flow
|
|
95
|
+
const cachedToken = await this.credentialManager.getValidToken();
|
|
96
|
+
if (cachedToken) {
|
|
97
|
+
req.headers.Authorization = `Bearer ${cachedToken}`;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// No credentials anywhere — auto-start device auth
|
|
101
|
+
await this.autoStartDeviceAuth();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return req;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Start the device auth flow in the background if not already running.
|
|
110
|
+
* Called automatically when no credentials are found in the interceptor.
|
|
111
|
+
* Idempotent — only starts once per client lifetime.
|
|
112
|
+
*/
|
|
113
|
+
async autoStartDeviceAuth() {
|
|
114
|
+
if (this._pendingAuthFlow)
|
|
115
|
+
return; // Already started
|
|
116
|
+
try {
|
|
117
|
+
const flow = new DeviceAuthFlow();
|
|
118
|
+
const startResult = await flow.start();
|
|
119
|
+
this._authFlowResult = {
|
|
120
|
+
verification_uri: startResult.verification_uri,
|
|
121
|
+
user_code: startResult.user_code,
|
|
122
|
+
verification_uri_complete: startResult.verification_uri_complete,
|
|
123
|
+
};
|
|
124
|
+
// Print to stderr so it's visible in MCP host logs
|
|
125
|
+
console.error(`\n🔗 OpenEphemeris: Account connection required.\n` +
|
|
126
|
+
` Visit: ${startResult.verification_uri}\n` +
|
|
127
|
+
` Code: ${startResult.user_code}\n` +
|
|
128
|
+
` Or: ${startResult.verification_uri_complete}\n`);
|
|
129
|
+
// Poll in background (don't await)
|
|
130
|
+
this._pendingAuthFlow = flow
|
|
131
|
+
.poll(startResult.device_code, (attempt) => {
|
|
132
|
+
if (attempt % 12 === 0) {
|
|
133
|
+
console.error(` Still waiting... Enter code ${startResult.user_code} at ${startResult.verification_uri}`);
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.then(() => {
|
|
137
|
+
console.error(`✅ Authenticated! Subsequent requests will use your account.`);
|
|
138
|
+
this._authFlowResult = null;
|
|
139
|
+
})
|
|
140
|
+
.catch((err) => {
|
|
141
|
+
console.error(`❌ Device auth failed: ${err.message}`);
|
|
142
|
+
this._pendingAuthFlow = null; // Allow retry
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
console.error(`Could not start device auth: ${err.message}`);
|
|
147
|
+
// Don't block — the request will go out unauthenticated and
|
|
148
|
+
// the 401 response will surface the error message to the user.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
expectsBinaryResponse(path, options) {
|
|
152
|
+
const normalizedPath = path.split("?")[0].trim().toLowerCase();
|
|
153
|
+
if (BINARY_ENDPOINT_PREFIXES.some((prefix) => normalizedPath.startsWith(prefix))) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
const format = options?.params?.format;
|
|
157
|
+
if (typeof format === "string") {
|
|
158
|
+
const normalizedFormat = format.trim().toLowerCase();
|
|
159
|
+
if (normalizedFormat === "png" || normalizedFormat === "svg") {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const acceptHeader = Object.entries(options?.headers ?? {}).find(([key]) => key.toLowerCase() === "accept")?.[1];
|
|
164
|
+
if (typeof acceptHeader === "string" && acceptHeader.toLowerCase().includes("image/")) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
toBuffer(data) {
|
|
170
|
+
if (data == null)
|
|
171
|
+
return null;
|
|
172
|
+
if (Buffer.isBuffer(data))
|
|
173
|
+
return data;
|
|
174
|
+
if (data instanceof ArrayBuffer)
|
|
175
|
+
return Buffer.from(data);
|
|
176
|
+
if (ArrayBuffer.isView(data)) {
|
|
177
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
178
|
+
}
|
|
179
|
+
if (typeof data === "string")
|
|
180
|
+
return Buffer.from(data);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
decodePayload(data) {
|
|
184
|
+
const asBuffer = this.toBuffer(data);
|
|
185
|
+
if (!asBuffer) {
|
|
186
|
+
return data;
|
|
187
|
+
}
|
|
188
|
+
const text = asBuffer.toString("utf8").trim();
|
|
189
|
+
if (!text)
|
|
190
|
+
return "";
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(text);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return text;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
extractMessage(data, fallback) {
|
|
199
|
+
if (typeof data === "string") {
|
|
200
|
+
const trimmed = data.trim();
|
|
201
|
+
return trimmed || fallback;
|
|
202
|
+
}
|
|
203
|
+
if (data && typeof data === "object") {
|
|
204
|
+
const obj = data;
|
|
205
|
+
for (const key of ["message", "detail", "title", "error"]) {
|
|
206
|
+
const value = obj[key];
|
|
207
|
+
if (typeof value === "string" && value.trim()) {
|
|
208
|
+
return value.trim();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return fallback;
|
|
213
|
+
}
|
|
214
|
+
normalizeContentType(value) {
|
|
215
|
+
if (typeof value !== "string")
|
|
216
|
+
return "";
|
|
217
|
+
return value.split(";")[0]?.trim().toLowerCase() || "";
|
|
218
|
+
}
|
|
219
|
+
isBinaryContentType(contentType) {
|
|
220
|
+
return contentType.startsWith("image/") || contentType === "application/octet-stream";
|
|
221
|
+
}
|
|
222
|
+
/** Maps an AxiosError to a structured BackendError. */
|
|
223
|
+
mapError(error) {
|
|
224
|
+
if (error.response) {
|
|
225
|
+
const status = error.response.status;
|
|
226
|
+
const data = this.decodePayload(error.response.data);
|
|
227
|
+
const msg = this.extractMessage(data, error.message);
|
|
228
|
+
if (status === 401) {
|
|
229
|
+
const authInfo = this._authFlowResult;
|
|
230
|
+
if (authInfo) {
|
|
231
|
+
return new BackendError(`🔗 Account connection required. Please visit ${authInfo.verification_uri} ` +
|
|
232
|
+
`and enter code: ${authInfo.user_code}\n\n` +
|
|
233
|
+
`Or open this direct link: ${authInfo.verification_uri_complete}\n\n` +
|
|
234
|
+
`Once you authorize, retry your request — the server is polling in the background. ` +
|
|
235
|
+
`If you don't have an account, you can create one at the link above (free tier, no credit card needed).`, 401, "auth_required", true, authInfo.verification_uri_complete);
|
|
236
|
+
}
|
|
237
|
+
return new BackendError(`Authentication required: ${msg}. Run auth.login to connect your account, ` +
|
|
238
|
+
`or set OPENEPHEMERIS_API_KEY in your MCP server config.`, 401, "auth_required", false, LOGIN_SIGNUP_URL);
|
|
239
|
+
}
|
|
240
|
+
if (status === 402) {
|
|
241
|
+
return new BackendError(`Usage quota exceeded: ${msg}. Review usage and overage settings in ${DASHBOARD_ACCOUNT_URL}, ` +
|
|
242
|
+
`or upgrade at ${UPGRADE_URL}.`, 402, "quota_exceeded", false, UPGRADE_URL);
|
|
243
|
+
}
|
|
244
|
+
if (status === 403) {
|
|
245
|
+
return new BackendError(`Access blocked by plan or policy: ${msg}. If this endpoint is tier-gated, upgrade at ${UPGRADE_URL}. ` +
|
|
246
|
+
`Manage keys and billing at ${DASHBOARD_ACCOUNT_URL}.`, 403, "tier_required", false, UPGRADE_URL);
|
|
247
|
+
}
|
|
248
|
+
if (status === 429) {
|
|
249
|
+
return new BackendError(`Rate limit exceeded. Your plan credits may be exhausted or requests are too frequent. ` +
|
|
250
|
+
`Check usage at ${DASHBOARD_ACCOUNT_URL}, then retry in a moment. (${msg})`, 429, "rate_limited", true);
|
|
251
|
+
}
|
|
252
|
+
if (status === 404)
|
|
253
|
+
return new BackendError(`Resource not found: ${msg}`, 404, "not_found", false);
|
|
254
|
+
if (status === 400)
|
|
255
|
+
return new BackendError(`Invalid request: ${msg}`, 400, "bad_request", false);
|
|
256
|
+
if (status >= 500)
|
|
257
|
+
return new BackendError(`Backend error (${status}): ${msg}`, status, "server_error", true);
|
|
258
|
+
}
|
|
259
|
+
return new BackendError(`Network error: ${error.message}`, 0, "network_error", true);
|
|
260
|
+
}
|
|
261
|
+
async get(path, params, timeoutMs) {
|
|
262
|
+
return this.request("GET", path, { params, timeoutMs });
|
|
263
|
+
}
|
|
264
|
+
async post(path, data, timeoutMs) {
|
|
265
|
+
return this.request("POST", path, { data, timeoutMs });
|
|
266
|
+
}
|
|
267
|
+
async delete(path) {
|
|
268
|
+
return this.request("DELETE", path);
|
|
269
|
+
}
|
|
270
|
+
async request(method, path, options) {
|
|
271
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
272
|
+
const expectsBinary = this.expectsBinaryResponse(path, options);
|
|
273
|
+
let lastError = new Error("Unknown error");
|
|
274
|
+
// Attempt + retries on transient failures (429, 502, 503, 504, network errors)
|
|
275
|
+
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
|
276
|
+
try {
|
|
277
|
+
const response = await this.client.request({
|
|
278
|
+
method,
|
|
279
|
+
url: path,
|
|
280
|
+
params: options?.params,
|
|
281
|
+
data: options?.data,
|
|
282
|
+
headers: options?.headers,
|
|
283
|
+
timeout: timeoutMs,
|
|
284
|
+
responseType: expectsBinary ? "arraybuffer" : "json",
|
|
285
|
+
});
|
|
286
|
+
if (!expectsBinary) {
|
|
287
|
+
return response.data;
|
|
288
|
+
}
|
|
289
|
+
const contentType = this.normalizeContentType(response.headers?.["content-type"]);
|
|
290
|
+
if (!this.isBinaryContentType(contentType)) {
|
|
291
|
+
return this.decodePayload(response.data);
|
|
292
|
+
}
|
|
293
|
+
const bytes = this.toBuffer(response.data) ?? Buffer.alloc(0);
|
|
294
|
+
const payload = {
|
|
295
|
+
content_type: contentType || "application/octet-stream",
|
|
296
|
+
content_length: bytes.length,
|
|
297
|
+
encoding: "base64",
|
|
298
|
+
data_base64: bytes.toString("base64"),
|
|
299
|
+
};
|
|
300
|
+
return payload;
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
if (err instanceof AxiosError) {
|
|
304
|
+
const isRetryable = (err.response && RETRYABLE_STATUSES.has(err.response.status)) ||
|
|
305
|
+
(!err.response && err.code && RETRYABLE_CODES.has(err.code));
|
|
306
|
+
if (isRetryable && attempt < RETRY_DELAYS_MS.length) {
|
|
307
|
+
const jitter = Math.random() * 500;
|
|
308
|
+
await sleep(RETRY_DELAYS_MS[attempt] + jitter);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
lastError = this.mapError(err);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
315
|
+
}
|
|
316
|
+
throw lastError;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
throw lastError;
|
|
320
|
+
}
|
|
321
|
+
getUserId() {
|
|
322
|
+
return this.userId;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Singleton instance
|
|
326
|
+
export const backendClient = new BackendClient({
|
|
327
|
+
baseURL: process.env.OPENEPHEMERIS_BACKEND_URL ||
|
|
328
|
+
process.env.ASTROMCP_BACKEND_URL ||
|
|
329
|
+
process.env.MERIDIAN_BACKEND_URL ||
|
|
330
|
+
"https://api.openephemeris.com",
|
|
331
|
+
userId: process.env.MCP_USER_ID || "anonymous",
|
|
332
|
+
jwt: process.env.OPENEPHEMERIS_JWT || process.env.ASTROMCP_JWT || process.env.MERIDIAN_AUTH_TOKEN,
|
|
333
|
+
serviceKey: process.env.OPENEPHEMERIS_SERVICE_KEY || process.env.ASTROMCP_SERVICE_KEY || process.env.MERIDIAN_SERVICE_KEY,
|
|
334
|
+
apiKey: process.env.OPENEPHEMERIS_API_KEY || process.env.ASTROMCP_API_KEY || process.env.MERIDIAN_API_KEY,
|
|
335
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
|
+
import { initTools, toolRegistry } from "./tools/index.js";
|
|
9
|
+
function resolveServerVersion() {
|
|
10
|
+
try {
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const pkgPath = path.resolve(here, "..", "package.json");
|
|
13
|
+
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
|
16
|
+
return parsed.version.trim();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Ignore and fall back to an explicit unknown version.
|
|
21
|
+
}
|
|
22
|
+
return "0.0.0-unknown";
|
|
23
|
+
}
|
|
24
|
+
const server = new Server({
|
|
25
|
+
name: "openephemeris-mcp",
|
|
26
|
+
version: resolveServerVersion(),
|
|
27
|
+
icons: [
|
|
28
|
+
{
|
|
29
|
+
src: "https://mcp.openephemeris.com/icon.png",
|
|
30
|
+
mimeType: "image/png",
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}, {
|
|
34
|
+
capabilities: {
|
|
35
|
+
tools: {},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
// List available tools
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
40
|
+
return {
|
|
41
|
+
tools: Object.values(toolRegistry).map((tool) => ({
|
|
42
|
+
name: tool.name,
|
|
43
|
+
description: tool.description,
|
|
44
|
+
inputSchema: tool.inputSchema,
|
|
45
|
+
annotations: tool.annotations ?? {
|
|
46
|
+
title: tool.name,
|
|
47
|
+
readOnlyHint: true,
|
|
48
|
+
destructiveHint: false,
|
|
49
|
+
},
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
// Handle tool calls
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
55
|
+
const toolName = request.params.name;
|
|
56
|
+
const tool = toolRegistry[toolName];
|
|
57
|
+
if (!tool) {
|
|
58
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const result = await tool.handler(request.params.arguments ?? {});
|
|
62
|
+
return {
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: JSON.stringify(result, null, 2),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
73
|
+
return {
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "text",
|
|
77
|
+
text: JSON.stringify({
|
|
78
|
+
success: false,
|
|
79
|
+
error: "TOOL_EXECUTION_ERROR",
|
|
80
|
+
message: errorMessage,
|
|
81
|
+
}, null, 2),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
isError: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
// Start server
|
|
89
|
+
async function main() {
|
|
90
|
+
await initTools();
|
|
91
|
+
const transport = new StdioServerTransport();
|
|
92
|
+
await server.connect(transport);
|
|
93
|
+
console.error("Open Ephemeris MCP server running on stdio");
|
|
94
|
+
}
|
|
95
|
+
main().catch((error) => {
|
|
96
|
+
console.error("Fatal error:", error);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|