@openephemeris/mcp-server 3.2.0 ā 3.2.3
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 +22 -2
- package/config/dev-allowlist.json +1262 -1318
- 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/device-auth.js +3 -0
- package/dist/src/index.js +6 -0
- package/dist/src/server-sse.d.ts +1 -0
- package/dist/src/server-sse.js +264 -0
- package/dist/src/tools/auth.js +6 -6
- package/dist/src/tools/dev.js +26 -18
- package/dist/src/tools/index.d.ts +8 -0
- package/dist/src/tools/index.js +23 -0
- package/dist/src/tools/specialized/eclipse.js +11 -14
- package/dist/src/tools/specialized/electional.js +3 -2
- package/dist/src/tools/specialized/human_design.js +4 -3
- package/dist/src/tools/specialized/moon.js +3 -2
- package/dist/src/tools/specialized/natal.js +22 -13
- package/dist/src/tools/specialized/relocation.js +14 -9
- package/dist/src/tools/specialized/synastry.js +23 -11
- package/dist/src/tools/specialized/transits.js +20 -13
- package/dist/test/allowlist-and-tools.test.js +13 -13
- package/dist/test/backend-client.test.js +1 -3
- package/package.json +7 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|
|
@@ -129,6 +129,9 @@ export class DeviceAuthFlow {
|
|
|
129
129
|
console.error(`ā ${startResult.verification_uri_complete.slice(0, 48).padEnd(48)} ā`);
|
|
130
130
|
console.error("āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
|
|
131
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("");
|
|
132
135
|
console.error("Waiting for authorization...");
|
|
133
136
|
const result = await this.poll(startResult.device_code, (attempt) => {
|
|
134
137
|
if (attempt % 6 === 0) {
|
package/dist/src/index.js
CHANGED
|
@@ -24,6 +24,12 @@ function resolveServerVersion() {
|
|
|
24
24
|
const server = new Server({
|
|
25
25
|
name: "openephemeris-mcp",
|
|
26
26
|
version: resolveServerVersion(),
|
|
27
|
+
icons: [
|
|
28
|
+
{
|
|
29
|
+
src: "https://mcp.openephemeris.com/icon.png",
|
|
30
|
+
mimeType: "image/png",
|
|
31
|
+
}
|
|
32
|
+
]
|
|
27
33
|
}, {
|
|
28
34
|
capabilities: {
|
|
29
35
|
tools: {},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server-sse.ts ā Remote SSE transport for the OpenEphemeris MCP server.
|
|
3
|
+
*
|
|
4
|
+
* This wraps the exact same tool registry used by the local stdio server
|
|
5
|
+
* but exposes it over HTTP + Server-Sent Events so that Claude Web,
|
|
6
|
+
* Claude Mobile (future), and any remote MCP client can connect via URL.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - The SSE server validates the user's API key at connection time by
|
|
10
|
+
* making a lightweight call to the Go backend.
|
|
11
|
+
* - All subsequent tool calls use the server's OPENEPHEMERIS_SERVICE_KEY
|
|
12
|
+
* (set as a Fly secret) via the singleton BackendClient's X-Service-Key header.
|
|
13
|
+
* - This avoids the need to refactor all tool handlers for per-session auth.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import express from "express";
|
|
19
|
+
import axios from "axios";
|
|
20
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
21
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
22
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
23
|
+
import { initTools, toolRegistry } from "./tools/index.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function resolveServerVersion() {
|
|
28
|
+
try {
|
|
29
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
// Try multiple levels up ā compiled JS is at dist/src/, source is at src/
|
|
31
|
+
for (const rel of ["..", "../.."]) {
|
|
32
|
+
const pkgPath = path.resolve(here, rel, "package.json");
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (typeof parsed.version === "string" && parsed.version.trim()) {
|
|
37
|
+
return parsed.version.trim();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// fall through
|
|
47
|
+
}
|
|
48
|
+
return "0.0.0-unknown";
|
|
49
|
+
}
|
|
50
|
+
const PORT = parseInt(process.env.PORT || "3001", 10);
|
|
51
|
+
const BACKEND_URL = process.env.OPENEPHEMERIS_BACKEND_URL ||
|
|
52
|
+
process.env.ASTROMCP_BACKEND_URL ||
|
|
53
|
+
"https://api.openephemeris.com";
|
|
54
|
+
const version = resolveServerVersion();
|
|
55
|
+
/**
|
|
56
|
+
* Validate a user-supplied API key by hitting an auth-gated endpoint on
|
|
57
|
+
* the Go sidecar. Returns true if the key yields a 2xx, false on 401/403.
|
|
58
|
+
*/
|
|
59
|
+
async function validateApiKey(apiKey) {
|
|
60
|
+
try {
|
|
61
|
+
const resp = await axios.get(`${BACKEND_URL}/ephemeris/moon/phase`, {
|
|
62
|
+
headers: { "X-API-Key": apiKey },
|
|
63
|
+
timeout: 5_000,
|
|
64
|
+
validateStatus: () => true, // don't throw on any status
|
|
65
|
+
});
|
|
66
|
+
// 2xx = valid key, 401/403 = invalid key, anything else = let through
|
|
67
|
+
if (resp.status === 401 || resp.status === 403)
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Network error ā let them through; tool calls will fail if key is bad.
|
|
73
|
+
console.warn("Could not validate API key against backend, allowing connection.");
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// MCP Server factory ā one per SSE session
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
function createMcpServer() {
|
|
81
|
+
const server = new Server({
|
|
82
|
+
name: "openephemeris-mcp",
|
|
83
|
+
version,
|
|
84
|
+
icons: [
|
|
85
|
+
{
|
|
86
|
+
src: "https://mcp.openephemeris.com/icon.png",
|
|
87
|
+
mimeType: "image/png",
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}, { capabilities: { tools: {} } });
|
|
91
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
92
|
+
tools: Object.values(toolRegistry).map((tool) => ({
|
|
93
|
+
name: tool.name,
|
|
94
|
+
description: tool.description,
|
|
95
|
+
inputSchema: tool.inputSchema,
|
|
96
|
+
annotations: tool.annotations ?? {
|
|
97
|
+
title: tool.name,
|
|
98
|
+
readOnlyHint: true,
|
|
99
|
+
destructiveHint: false,
|
|
100
|
+
},
|
|
101
|
+
})),
|
|
102
|
+
}));
|
|
103
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
104
|
+
const toolName = request.params.name;
|
|
105
|
+
const tool = toolRegistry[toolName];
|
|
106
|
+
if (!tool) {
|
|
107
|
+
throw new Error(`Unknown tool: ${toolName}`);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const result = await tool.handler(request.params.arguments ?? {});
|
|
111
|
+
return {
|
|
112
|
+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
117
|
+
return {
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "text",
|
|
121
|
+
text: JSON.stringify({ success: false, error: "TOOL_EXECUTION_ERROR", message: errorMessage }, null, 2),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
isError: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
return server;
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Express + SSE wiring
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
async function main() {
|
|
134
|
+
await initTools();
|
|
135
|
+
const app = express();
|
|
136
|
+
// NOTE: Do NOT use express.json() globally ā SSEServerTransport.handlePostMessage
|
|
137
|
+
// reads the raw request stream itself. Pre-parsing with express.json() consumes it,
|
|
138
|
+
// causing "stream is not readable" errors.
|
|
139
|
+
// CORS ā required for Claude Web cross-origin SSE connections
|
|
140
|
+
app.use((_req, res, next) => {
|
|
141
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
142
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
143
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Meridian-API-Key");
|
|
144
|
+
if (_req.method === "OPTIONS") {
|
|
145
|
+
res.status(204).end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
next();
|
|
149
|
+
});
|
|
150
|
+
// Track active transports by session ID
|
|
151
|
+
const transports = new Map();
|
|
152
|
+
// Health check
|
|
153
|
+
app.get("/health", (_req, res) => {
|
|
154
|
+
res.json({ ok: true, version, transport: "sse", sessions: transports.size });
|
|
155
|
+
});
|
|
156
|
+
// Serve the MCP icon (earth + star)
|
|
157
|
+
// Compiled JS at dist/src/server-sse.js: ../../assets
|
|
158
|
+
// Source TS at src/server-sse.ts: ../assets
|
|
159
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
160
|
+
const assetsDir = fs.existsSync(path.resolve(here, "..", "..", "assets"))
|
|
161
|
+
? path.resolve(here, "..", "..", "assets")
|
|
162
|
+
: path.resolve(here, "..", "assets");
|
|
163
|
+
app.get("/icon.png", (_req, res) => {
|
|
164
|
+
const iconPath = path.join(assetsDir, "icon.png");
|
|
165
|
+
if (fs.existsSync(iconPath)) {
|
|
166
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
167
|
+
res.sendFile(iconPath);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
res.status(404).end();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
app.get("/favicon.ico", (_req, res) => {
|
|
174
|
+
const iconPath = path.join(assetsDir, "icon.png");
|
|
175
|
+
if (fs.existsSync(iconPath)) {
|
|
176
|
+
res.setHeader("Content-Type", "image/png");
|
|
177
|
+
res.setHeader("Cache-Control", "public, max-age=86400");
|
|
178
|
+
res.sendFile(iconPath);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
res.status(404).end();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
// SSE endpoint ā clients GET /sse to establish a stream
|
|
185
|
+
app.get("/sse", async (req, res) => {
|
|
186
|
+
// Extract API key from query param or header
|
|
187
|
+
const apiKey = req.query.apiKey ||
|
|
188
|
+
req.headers["x-api-key"] ||
|
|
189
|
+
req.headers["x-meridian-api-key"] ||
|
|
190
|
+
req.headers["authorization"]?.replace(/^Bearer\s+/i, "");
|
|
191
|
+
if (!apiKey) {
|
|
192
|
+
res.status(401).json({
|
|
193
|
+
error: "api_key_required",
|
|
194
|
+
message: "An OpenEphemeris API key is required. Generate one at https://openephemeris.com/dashboard?tab=apikeys",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// Validate the user's key against the Go backend
|
|
199
|
+
const valid = await validateApiKey(apiKey);
|
|
200
|
+
if (!valid) {
|
|
201
|
+
res.status(403).json({
|
|
202
|
+
error: "invalid_api_key",
|
|
203
|
+
message: "The provided API key is invalid or inactive. Check your key at https://openephemeris.com/dashboard?tab=apikeys",
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const transport = new SSEServerTransport("/message", res);
|
|
208
|
+
const sessionId = transport.sessionId;
|
|
209
|
+
transports.set(sessionId, transport);
|
|
210
|
+
const server = createMcpServer();
|
|
211
|
+
// Guard: prevent double-close from crashing the process
|
|
212
|
+
let closed = false;
|
|
213
|
+
const cleanup = () => {
|
|
214
|
+
if (closed)
|
|
215
|
+
return;
|
|
216
|
+
closed = true;
|
|
217
|
+
transports.delete(sessionId);
|
|
218
|
+
try {
|
|
219
|
+
server.close().catch(() => { });
|
|
220
|
+
}
|
|
221
|
+
catch { /* ignore */ }
|
|
222
|
+
};
|
|
223
|
+
transport.onclose = cleanup;
|
|
224
|
+
// Handle abrupt client disconnects (browser tab closed, etc.)
|
|
225
|
+
res.on("close", cleanup);
|
|
226
|
+
try {
|
|
227
|
+
// server.connect() calls transport.start() internally in SDK v1.27+
|
|
228
|
+
await server.connect(transport);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
console.error(`SSE session ${sessionId} failed to start:`, err);
|
|
232
|
+
cleanup();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
// Message endpoint ā clients POST /message?sessionId=xxx
|
|
236
|
+
app.post("/message", async (req, res) => {
|
|
237
|
+
const sessionId = req.query.sessionId;
|
|
238
|
+
const transport = transports.get(sessionId);
|
|
239
|
+
if (!transport) {
|
|
240
|
+
res.status(400).json({
|
|
241
|
+
error: "invalid_session",
|
|
242
|
+
message: "Unknown or expired session. Re-connect to /sse first.",
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await transport.handlePostMessage(req, res);
|
|
247
|
+
});
|
|
248
|
+
app.listen(PORT, "0.0.0.0", () => {
|
|
249
|
+
console.log(`OpenEphemeris MCP SSE server v${version} listening on port ${PORT}`);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// Prevent SDK recursive close bugs from crashing the process
|
|
253
|
+
process.on("uncaughtException", (err) => {
|
|
254
|
+
if (err instanceof RangeError && err.message.includes("call stack")) {
|
|
255
|
+
console.error("Caught stack overflow (likely SDK close recursion), ignoring.");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
console.error("Uncaught exception:", err);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
});
|
|
261
|
+
main().catch((error) => {
|
|
262
|
+
console.error("Fatal error:", error);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
package/dist/src/tools/auth.js
CHANGED
|
@@ -6,7 +6,7 @@ const credentialManager = new CredentialManager();
|
|
|
6
6
|
// auth.login ā Start the device authorization flow
|
|
7
7
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
8
8
|
registerTool({
|
|
9
|
-
name: "
|
|
9
|
+
name: "auth_login",
|
|
10
10
|
description: "Start the device authorization flow to connect this MCP server to your OpenEphemeris account. " +
|
|
11
11
|
"Returns a verification URL and code for the user to enter in their browser. " +
|
|
12
12
|
"The MCP server will then automatically receive credentials and all API calls will be " +
|
|
@@ -31,7 +31,7 @@ registerTool({
|
|
|
31
31
|
email: status.email,
|
|
32
32
|
user_id: status.userId,
|
|
33
33
|
expires_at: status.expiresAt,
|
|
34
|
-
message: `Already authenticated as ${status.email}. Use
|
|
34
|
+
message: `Already authenticated as ${status.email}. Use auth_logout to disconnect.`,
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
// Check if env var credentials are set
|
|
@@ -86,7 +86,7 @@ registerTool({
|
|
|
86
86
|
// auth.status ā Check current authentication state
|
|
87
87
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
88
88
|
registerTool({
|
|
89
|
-
name: "
|
|
89
|
+
name: "auth_status",
|
|
90
90
|
description: "Check the current authentication status of this MCP server. " +
|
|
91
91
|
"Shows whether the server is authenticated, which account it's linked to, " +
|
|
92
92
|
"the authentication method (API key, JWT, device auth), and token expiry.",
|
|
@@ -155,14 +155,14 @@ registerTool({
|
|
|
155
155
|
expired_at: creds.expires_at,
|
|
156
156
|
message: "Device auth credentials exist but are expired. " +
|
|
157
157
|
"The server will attempt to refresh automatically on the next API call, " +
|
|
158
|
-
"or you can run
|
|
158
|
+
"or you can run auth_login to re-authenticate.",
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
return {
|
|
162
162
|
authenticated: false,
|
|
163
163
|
method: "none",
|
|
164
164
|
message: "Not authenticated. Set OPENEPHEMERIS_API_KEY in your MCP server config, " +
|
|
165
|
-
"or run
|
|
165
|
+
"or run auth_login to connect your account interactively.",
|
|
166
166
|
};
|
|
167
167
|
},
|
|
168
168
|
});
|
|
@@ -170,7 +170,7 @@ registerTool({
|
|
|
170
170
|
// auth.logout ā Clear cached credentials
|
|
171
171
|
// āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
172
172
|
registerTool({
|
|
173
|
-
name: "
|
|
173
|
+
name: "auth_logout",
|
|
174
174
|
description: "Disconnect this MCP server from your OpenEphemeris account by clearing " +
|
|
175
175
|
"cached credentials. Does NOT revoke the API key if one is set via environment " +
|
|
176
176
|
"variable ā only clears device-auth cached credentials.",
|