@openephemeris/mcp-server 3.13.9 → 3.14.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/CHANGELOG.md +39 -0
- package/README.md +3 -3
- package/config/dev-allowlist.json +28 -4
- package/dist/index.js +20 -13
- package/dist/oauth/dcr.d.ts +11 -0
- package/dist/oauth/dcr.js +49 -0
- package/dist/oauth/discovery.d.ts +12 -0
- package/dist/oauth/discovery.js +45 -0
- package/dist/oauth/pkce.d.ts +18 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/rate-limit.d.ts +20 -0
- package/dist/oauth/rate-limit.js +65 -0
- package/dist/oauth/store.d.ts +88 -0
- package/dist/oauth/store.js +361 -0
- package/dist/oauth/supabase-jwt.d.ts +39 -0
- package/dist/oauth/supabase-jwt.js +194 -0
- package/dist/oauth/token.d.ts +21 -0
- package/dist/oauth/token.js +215 -0
- package/dist/prompts.js +180 -159
- package/dist/server-sse.js +154 -49
- package/dist/tools/apps/bazi-app.d.ts +23 -0
- package/dist/tools/apps/bazi-app.js +226 -0
- package/dist/tools/apps/bi-wheel-app.d.ts +14 -7
- package/dist/tools/apps/bi-wheel-app.js +347 -113
- package/dist/tools/apps/bodygraph-app.js +106 -13
- package/dist/tools/apps/chart-wheel-app.js +53 -4
- package/dist/tools/apps/location-tools.js +3 -0
- package/dist/tools/apps/moon-phase-app.d.ts +19 -0
- package/dist/tools/apps/moon-phase-app.js +244 -0
- package/dist/tools/apps/transit-timeline-app.d.ts +20 -0
- package/dist/tools/apps/transit-timeline-app.js +295 -0
- package/dist/tools/apps/vedic-chart-app.d.ts +17 -0
- package/dist/tools/apps/vedic-chart-app.js +228 -0
- package/dist/tools/auth.js +4 -0
- package/dist/tools/dev.js +3 -0
- package/dist/tools/index.d.ts +6 -0
- package/dist/tools/index.js +9 -4
- package/dist/tools/output-schemas.d.ts +37 -0
- package/dist/tools/output-schemas.js +130 -0
- package/dist/tools/specialized/acg.js +5 -2
- package/dist/tools/specialized/bazi.js +375 -44
- package/dist/tools/specialized/bi_wheel.js +3 -1
- package/dist/tools/specialized/chart_wheel.js +3 -1
- package/dist/tools/specialized/comparative.js +9 -4
- package/dist/tools/specialized/eclipse.js +3 -1
- package/dist/tools/specialized/electional.js +9 -4
- package/dist/tools/specialized/ephemeris_core.js +5 -2
- package/dist/tools/specialized/ephemeris_extended.js +17 -8
- package/dist/tools/specialized/hd_bodygraph.js +3 -1
- package/dist/tools/specialized/hd_cycles.js +5 -2
- package/dist/tools/specialized/hd_group.js +5 -2
- package/dist/tools/specialized/human_design.js +3 -1
- package/dist/tools/specialized/moon.js +5 -2
- package/dist/tools/specialized/natal.js +3 -1
- package/dist/tools/specialized/progressed.js +3 -1
- package/dist/tools/specialized/relocation.js +3 -1
- package/dist/tools/specialized/returns.js +7 -3
- package/dist/tools/specialized/synastry.js +3 -1
- package/dist/tools/specialized/transits.js +53 -44
- package/dist/tools/specialized/vedic.js +3 -1
- package/dist/tools/specialized/venus_star_points.js +13 -6
- package/dist/ui/bazi.html +213 -0
- package/dist/ui/bi-wheel.html +3714 -3048
- package/dist/ui/bodygraph.html +1952 -1766
- package/dist/ui/chart-wheel.html +3431 -2964
- package/dist/ui/moon-phase.html +6764 -0
- package/dist/ui/transit-timeline.html +6874 -0
- package/dist/ui/vedic-chart.html +210 -0
- package/package.json +15 -12
- package/smithery.yaml +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [3.14.0] — 2026-05-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Fullscreen bottom-bar layout** for bodygraph, bi-wheel, and chart-wheel iframes — chart fills the viewport, controls dock to the bottom for a cleaner read on small windows.
|
|
14
|
+
- **Debug highlight click handler** in the bodygraph (`?debugHighlight`) — click any gate to surface its center/channel data via iframe postMessage.
|
|
15
|
+
- **Highlight visualization** — highlighted gates render with CSS glow while non-highlighted gates dim, making activation patterns visually obvious.
|
|
16
|
+
- **Click payload metadata** — bodygraph gate groups now emit `data-center` for richer iframe click events.
|
|
17
|
+
- **OAuth refresh-token persistence** to Supabase, with in-memory expiry enforcement and a 90-day TTL (up from 30 days).
|
|
18
|
+
- **OAuth discovery test suite** with 401-compliance coverage.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Auth check now runs before `isInitializeRequest` — resolves the Claude Web `ofid_` initialization error.
|
|
22
|
+
- Light-mode ring tokens wired correctly in bi-wheel (were missing token references).
|
|
23
|
+
- Fullscreen bottom-bar layout polish — edge cases and styling fixes after initial rollout.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **Server-side SVG is now the only bodygraph render path** — the legacy client-side renderer has been retired. Reduces bundle size and removes a source of cross-environment rendering drift.
|
|
27
|
+
- Claude Web connector docs updated to reflect OAuth (not API key) as the connection method.
|
|
28
|
+
|
|
29
|
+
### Underlying API improvements (delivered transparently)
|
|
30
|
+
- **LLM-format projection now covers all 7 benchmarked endpoints** (was 4): `/ephemeris/natal-chart`, `/comparative/synastry`, `/comparative/natal-transits`, `/human-design/chart`, `/vedic/chart`, `/chinese/bazi`, `/acg/hits`. Weighted average reduction: 61% (GPT-4o), 60% (Claude). Range: 13%–98% per endpoint.
|
|
31
|
+
- `/acg/hits` newly supports `format=llm` — proximity hits as compact rows. Other `/acg/*` endpoints continue to return precision GeoJSON for map rendering.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [3.13.11] — 2026-05-15
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
- **Bi-Wheel UI Parity** — Unified the Bi-Wheel and Natal Chart Wheel architectures with matching feature sets.
|
|
39
|
+
- **Tally & Dignity Panels** — Integrated element/modality tallies and planetary dignity badges into the Bi-Wheel UI.
|
|
40
|
+
- **House System Controls** — Added dynamic house system selection to the Bi-Wheel aspect panel, triggering live recalculations.
|
|
41
|
+
- **Enhanced Rendering** — Exported dignity/tally logic from core renderer for consistent client-side UI generation.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
- Widened `BiWheelMode` type definitions to prevent TypeScript compilation errors during mode-specific panel rendering.
|
|
45
|
+
- Standardized CSS design tokens and layout containers between natal and comparative chart wheels.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
10
49
|
## [3.10.4] — 2026-04-24
|
|
11
50
|
|
|
12
51
|
### Changed
|
package/README.md
CHANGED
|
@@ -183,7 +183,7 @@ Cursor deeplink payload:
|
|
|
183
183
|
|
|
184
184
|
The server is hosted at `https://mcp.openephemeris.com/mcp` with full Streamable HTTP support (MCP 2025-11-25 spec). Remote-only clients can connect directly — no bridge/proxy required:
|
|
185
185
|
|
|
186
|
-
- **Claude Web**: Add `https://mcp.openephemeris.com/mcp` as a
|
|
186
|
+
- **Claude Web**: Add `https://mcp.openephemeris.com/mcp` as a custom connector URL — leave OAuth Client ID and Secret **blank**. The server uses OAuth 2.1 + PKCE (Dynamic Client Registration), so Claude handles authentication via a browser popup automatically.
|
|
187
187
|
- **Via Smithery**: Use the [Smithery listing](https://smithery.ai/servers/open-ephemeris/openephemeris) for managed connections with any client
|
|
188
188
|
- **Legacy SSE**: `https://mcp.openephemeris.com/sse` remains available for SSE-only clients
|
|
189
189
|
|
|
@@ -355,8 +355,8 @@ Generated by `npm run sync:readme` from `config/dev-allowlist.json` and the live
|
|
|
355
355
|
|
|
356
356
|
- Allowlisted operations: **28**
|
|
357
357
|
- Methods: `GET=4`, `POST=24`, `PUT=0`, `PATCH=0`, `DELETE=0`
|
|
358
|
-
- Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **
|
|
359
|
-
- Typed tools: `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_call`, `dev_list_allowed`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bi_wheel`, `explore_human_design`, `explore_natal_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `timezone_resolve`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
|
|
358
|
+
- Registered tools (`OPENEPHEMERIS_PROFILE=dev`): **79**
|
|
359
|
+
- Typed tools: `acg_hits`, `acg_power_lines`, `auth_login`, `auth_logout`, `auth_status`, `bazi_annual_pillar`, `bazi_chart`, `bazi_compatibility`, `bazi_element_balance`, `bazi_luck_pillars`, `bazi_ten_gods`, `bi_wheel_on_cross_aspect_click`, `bi_wheel_on_house_click`, `bi_wheel_on_planet_click`, `bi_wheel_recalculate`, `bi_wheel_synopsis`, `bodygraph_recalculate`, `chart_wheel_on_aspect_click`, `chart_wheel_on_house_click`, `chart_wheel_on_planet_click`, `chart_wheel_recalculate`, `chinese_bazi`, `dev_call`, `dev_list_allowed`, `electional_aspect_search`, `electional_moment_analysis`, `electional_station_tracker`, `ephemeris_angles_points`, `ephemeris_aspect_check`, `ephemeris_bi_wheel`, `ephemeris_chart_wheel`, `ephemeris_composite`, `ephemeris_composite_midpoint`, `ephemeris_dignities`, `ephemeris_electional`, `ephemeris_fixed_stars`, `ephemeris_hermetic_lots`, `ephemeris_house_cusps`, `ephemeris_lunar_return`, `ephemeris_midpoints`, `ephemeris_moon_phase`, `ephemeris_natal_batch`, `ephemeris_natal_chart`, `ephemeris_natal_transits`, `ephemeris_next_eclipse`, `ephemeris_next_lunar_phase`, `ephemeris_overlay`, `ephemeris_planet_position`, `ephemeris_planetary_return`, `ephemeris_progressed_chart`, `ephemeris_relocation`, `ephemeris_retrograde_status`, `ephemeris_solar_return`, `ephemeris_synastry`, `ephemeris_transits`, `explore_bi_wheel`, `explore_human_design`, `explore_moon_phase`, `explore_natal_chart`, `hd_on_center_click`, `hd_on_channel_click`, `hd_on_gate_click`, `hd_on_planet_click`, `hd_on_variable_click`, `hd_opposition`, `hd_planetary_return`, `human_design_bodygraph`, `human_design_chart`, `human_design_composite`, `human_design_penta`, `location_search`, `timezone_resolve`, `vedic_chart`, `venus_eight_year_star`, `venus_elongations`, `venus_phase`, `venus_star_points`, `venus_star_points_conjunctions`, `venus_stations`
|
|
360
360
|
- Generic tools:
|
|
361
361
|
|
|
362
362
|
### Allowlist Families
|
|
@@ -128,8 +128,8 @@
|
|
|
128
128
|
"path": "/visualization/chart-wheel"
|
|
129
129
|
}
|
|
130
130
|
],
|
|
131
|
-
"last_generated_at": "2026-
|
|
132
|
-
"openapi_sha256": "
|
|
131
|
+
"last_generated_at": "2026-05-14T01:35:40.409Z",
|
|
132
|
+
"openapi_sha256": "50112cd7017466815265d92e7d73cbc29be6bdc729a87db50989f189d562d201",
|
|
133
133
|
"candidates_get": [
|
|
134
134
|
{
|
|
135
135
|
"method": "GET",
|
|
@@ -469,6 +469,14 @@
|
|
|
469
469
|
],
|
|
470
470
|
"operationId": "aspects_acg_aspects_post"
|
|
471
471
|
},
|
|
472
|
+
{
|
|
473
|
+
"method": "POST",
|
|
474
|
+
"path": "/acg/ccg/parans",
|
|
475
|
+
"tags": [
|
|
476
|
+
"ACG"
|
|
477
|
+
],
|
|
478
|
+
"operationId": "acg_ccg_parans_post"
|
|
479
|
+
},
|
|
472
480
|
{
|
|
473
481
|
"method": "POST",
|
|
474
482
|
"path": "/acg/ccg",
|
|
@@ -541,6 +549,14 @@
|
|
|
541
549
|
],
|
|
542
550
|
"operationId": "midpoints_acg_midpoints_post"
|
|
543
551
|
},
|
|
552
|
+
{
|
|
553
|
+
"method": "POST",
|
|
554
|
+
"path": "/acg/parans",
|
|
555
|
+
"tags": [
|
|
556
|
+
"ACG"
|
|
557
|
+
],
|
|
558
|
+
"operationId": "acg_parans_post"
|
|
559
|
+
},
|
|
544
560
|
{
|
|
545
561
|
"method": "POST",
|
|
546
562
|
"path": "/acg/power-lines",
|
|
@@ -549,6 +565,14 @@
|
|
|
549
565
|
],
|
|
550
566
|
"operationId": "power_lines_acg_power_lines_post"
|
|
551
567
|
},
|
|
568
|
+
{
|
|
569
|
+
"method": "POST",
|
|
570
|
+
"path": "/acg/relational-parans",
|
|
571
|
+
"tags": [
|
|
572
|
+
"ACG"
|
|
573
|
+
],
|
|
574
|
+
"operationId": "acg_relational_parans_post"
|
|
575
|
+
},
|
|
552
576
|
{
|
|
553
577
|
"method": "POST",
|
|
554
578
|
"path": "/chinese/bazi/annual-pillar",
|
|
@@ -699,7 +723,7 @@
|
|
|
699
723
|
"tags": [
|
|
700
724
|
"ephemeris"
|
|
701
725
|
],
|
|
702
|
-
"operationId": "
|
|
726
|
+
"operationId": "CalculateDraconicChart"
|
|
703
727
|
},
|
|
704
728
|
{
|
|
705
729
|
"method": "POST",
|
|
@@ -779,7 +803,7 @@
|
|
|
779
803
|
"tags": [
|
|
780
804
|
"ephemeris"
|
|
781
805
|
],
|
|
782
|
-
"operationId": "
|
|
806
|
+
"operationId": "CalculatePrenatalLunation"
|
|
783
807
|
},
|
|
784
808
|
{
|
|
785
809
|
"method": "POST",
|
package/dist/index.js
CHANGED
|
@@ -7,9 +7,12 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
7
7
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
8
8
|
import { initTools, toolRegistry, formatToolResponse, modelVisibleTools } from "./tools/index.js";
|
|
9
9
|
import { listPrompts, getPromptContent } from "./prompts.js";
|
|
10
|
+
// ── MCP App resource imports ────────────────────────────────────────────────
|
|
10
11
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
11
12
|
import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
|
|
12
13
|
import { BI_WHEEL_RESOURCE_URI, BI_WHEEL_MIME_TYPE, getBiWheelBundle, } from "./tools/apps/bi-wheel-app.js";
|
|
14
|
+
import { MOON_PHASE_RESOURCE_URI, MOON_PHASE_MIME_TYPE, getMoonPhaseBundle, } from "./tools/apps/moon-phase-app.js";
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
16
|
function resolveServerVersion() {
|
|
14
17
|
try {
|
|
15
18
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -39,9 +42,6 @@ const server = new Server({
|
|
|
39
42
|
tools: {},
|
|
40
43
|
prompts: {},
|
|
41
44
|
resources: {},
|
|
42
|
-
experimental: {
|
|
43
|
-
"io.modelcontextprotocol/ui": {},
|
|
44
|
-
},
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
47
|
// List available tools
|
|
@@ -59,10 +59,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
59
59
|
openWorldHint: tool.annotations?.openWorldHint ?? false,
|
|
60
60
|
},
|
|
61
61
|
// Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
|
|
62
|
-
// and render the iframe when a tool result arrives.
|
|
63
|
-
// Include BOTH the modern nested key (_meta.ui.resourceUri) and the
|
|
64
|
-
// legacy flat key (_meta["ui/resourceUri"]) — the official ext-apps SDK
|
|
65
|
-
// normalizes both via registerAppTool() for host compatibility.
|
|
66
62
|
...(tool._meta?.ui?.resourceUri
|
|
67
63
|
? {
|
|
68
64
|
_meta: {
|
|
@@ -124,6 +120,14 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
|
124
120
|
mimeType: BI_WHEEL_MIME_TYPE,
|
|
125
121
|
});
|
|
126
122
|
}
|
|
123
|
+
if (getMoonPhaseBundle()) {
|
|
124
|
+
resources.push({
|
|
125
|
+
uri: MOON_PHASE_RESOURCE_URI,
|
|
126
|
+
name: "Moon Phase Explorer",
|
|
127
|
+
description: "Interactive lunar phase dial showing illumination, sign, and void-of-course status.",
|
|
128
|
+
mimeType: MOON_PHASE_MIME_TYPE,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
127
131
|
return { resources };
|
|
128
132
|
});
|
|
129
133
|
// Read resource
|
|
@@ -147,6 +151,12 @@ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
|
147
151
|
throw new Error("Bi-Wheel UI bundle not found. Run `npm run build:ui` to build it.");
|
|
148
152
|
return { contents: [{ uri: BI_WHEEL_RESOURCE_URI, mimeType: BI_WHEEL_MIME_TYPE, text: bundle }] };
|
|
149
153
|
}
|
|
154
|
+
if (uri === MOON_PHASE_RESOURCE_URI) {
|
|
155
|
+
const bundle = getMoonPhaseBundle();
|
|
156
|
+
if (!bundle)
|
|
157
|
+
throw new Error("Moon Phase UI bundle not found. Run `npm run build:ui` to build it.");
|
|
158
|
+
return { contents: [{ uri: MOON_PHASE_RESOURCE_URI, mimeType: MOON_PHASE_MIME_TYPE, text: bundle }] };
|
|
159
|
+
}
|
|
150
160
|
throw new Error(`Unknown resource: ${uri}`);
|
|
151
161
|
});
|
|
152
162
|
// Handle tool calls
|
|
@@ -166,19 +176,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
166
176
|
catch (error) {
|
|
167
177
|
const durationMs = Date.now() - startTime;
|
|
168
178
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
179
|
+
const isRetryable = error.retryable === true || error.status === 401 || error.code === "auth_required";
|
|
169
180
|
console.error(`[MCP] ❌ Failed: ${toolName} (${durationMs}ms) - ${errorMessage}`);
|
|
170
181
|
return {
|
|
171
182
|
content: [
|
|
172
183
|
{
|
|
173
184
|
type: "text",
|
|
174
|
-
text:
|
|
175
|
-
success: false,
|
|
176
|
-
error: "TOOL_EXECUTION_ERROR",
|
|
177
|
-
message: errorMessage,
|
|
178
|
-
}, null, 2),
|
|
185
|
+
text: `Tool execution failed:\n\n${errorMessage}\n\nPlease read the error above carefully and assist the user (e.g. by providing them the auth link or explaining the issue).`,
|
|
179
186
|
},
|
|
180
187
|
],
|
|
181
|
-
isError:
|
|
188
|
+
isError: !isRetryable,
|
|
182
189
|
};
|
|
183
190
|
}
|
|
184
191
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/dcr.ts — Dynamic Client Registration (RFC 7591).
|
|
3
|
+
*
|
|
4
|
+
* POST /oauth/register
|
|
5
|
+
*
|
|
6
|
+
* Deterministic client_id: SHA-256 of the sorted, joined redirect_uris.
|
|
7
|
+
* This means the same set of redirect URIs always produces the same client_id,
|
|
8
|
+
* making registration idempotent.
|
|
9
|
+
*/
|
|
10
|
+
import { type Router } from "express";
|
|
11
|
+
export declare const oauthDcrRouter: Router;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/dcr.ts — Dynamic Client Registration (RFC 7591).
|
|
3
|
+
*
|
|
4
|
+
* POST /oauth/register
|
|
5
|
+
*
|
|
6
|
+
* Deterministic client_id: SHA-256 of the sorted, joined redirect_uris.
|
|
7
|
+
* This means the same set of redirect URIs always produces the same client_id,
|
|
8
|
+
* making registration idempotent.
|
|
9
|
+
*/
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
export const oauthDcrRouter = express.Router();
|
|
13
|
+
oauthDcrRouter.post("/oauth/register", (req, res) => {
|
|
14
|
+
// Validate Content-Type
|
|
15
|
+
const contentType = req.headers["content-type"];
|
|
16
|
+
if (!contentType || !contentType.includes("application/json")) {
|
|
17
|
+
res.status(400).json({
|
|
18
|
+
error: "invalid_request",
|
|
19
|
+
error_description: "Content-Type must be application/json.",
|
|
20
|
+
});
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const body = req.body;
|
|
24
|
+
// Validate redirect_uris
|
|
25
|
+
if (!body ||
|
|
26
|
+
!Array.isArray(body.redirect_uris) ||
|
|
27
|
+
body.redirect_uris.length === 0) {
|
|
28
|
+
res.status(400).json({
|
|
29
|
+
error: "invalid_client_metadata",
|
|
30
|
+
error_description: "redirect_uris must be a non-empty array of strings.",
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const redirect_uris = body.redirect_uris;
|
|
35
|
+
const client_name = body.client_name || "Unknown Client";
|
|
36
|
+
// Deterministic client_id: SHA-256 of sorted redirect URIs
|
|
37
|
+
const uriFingerprint = [...redirect_uris].sort().join("\n");
|
|
38
|
+
const client_id = createHash("sha256")
|
|
39
|
+
.update(uriFingerprint, "utf8")
|
|
40
|
+
.digest("hex");
|
|
41
|
+
res.status(201).json({
|
|
42
|
+
client_id,
|
|
43
|
+
client_name,
|
|
44
|
+
redirect_uris,
|
|
45
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
46
|
+
token_endpoint_auth_method: "none",
|
|
47
|
+
created_at: new Date().toISOString(),
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/discovery.ts — OAuth 2.1 Discovery endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* GET /.well-known/oauth-protected-resource (RFC 8707)
|
|
6
|
+
* GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
7
|
+
*
|
|
8
|
+
* All URLs use https:// per the OAuth 2.1 specification.
|
|
9
|
+
*/
|
|
10
|
+
import { type Router } from "express";
|
|
11
|
+
export declare const PROTECTED_RESOURCE_METADATA_URL = "https://mcp.openephemeris.com/.well-known/oauth-protected-resource";
|
|
12
|
+
export declare const oauthDiscoveryRouter: Router;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/discovery.ts — OAuth 2.1 Discovery endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* GET /.well-known/oauth-protected-resource (RFC 8707)
|
|
6
|
+
* GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
7
|
+
*
|
|
8
|
+
* All URLs use https:// per the OAuth 2.1 specification.
|
|
9
|
+
*/
|
|
10
|
+
import express from "express";
|
|
11
|
+
const MCP_SERVER_ORIGIN = "https://mcp.openephemeris.com";
|
|
12
|
+
const WEB_APP_ORIGIN = "https://openephemeris.com";
|
|
13
|
+
export const PROTECTED_RESOURCE_METADATA_URL = `${MCP_SERVER_ORIGIN}/.well-known/oauth-protected-resource`;
|
|
14
|
+
export const oauthDiscoveryRouter = express.Router();
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// GET /.well-known/oauth-protected-resource
|
|
17
|
+
// Also served at the path-scoped /.well-known/oauth-protected-resource/mcp
|
|
18
|
+
// per the latest MCP Authorization spec draft (some clients probe per-resource).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const protectedResourceHandler = (_req, res) => {
|
|
21
|
+
res.json({
|
|
22
|
+
resource: `${MCP_SERVER_ORIGIN}/mcp`,
|
|
23
|
+
authorization_servers: [MCP_SERVER_ORIGIN],
|
|
24
|
+
bearer_methods_supported: ["header"],
|
|
25
|
+
scopes_supported: ["openephemeris:read"],
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
oauthDiscoveryRouter.get("/.well-known/oauth-protected-resource", protectedResourceHandler);
|
|
29
|
+
oauthDiscoveryRouter.get("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler);
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// GET /.well-known/oauth-authorization-server
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
oauthDiscoveryRouter.get("/.well-known/oauth-authorization-server", (_req, res) => {
|
|
34
|
+
res.json({
|
|
35
|
+
issuer: MCP_SERVER_ORIGIN,
|
|
36
|
+
authorization_endpoint: `${WEB_APP_ORIGIN}/oauth/authorize`,
|
|
37
|
+
token_endpoint: `${MCP_SERVER_ORIGIN}/oauth/token`,
|
|
38
|
+
registration_endpoint: `${MCP_SERVER_ORIGIN}/oauth/register`,
|
|
39
|
+
response_types_supported: ["code"],
|
|
40
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
41
|
+
code_challenge_methods_supported: ["S256"],
|
|
42
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
43
|
+
scopes_supported: ["openephemeris:read"],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/pkce.ts — PKCE (Proof Key for Code Exchange) utilities.
|
|
3
|
+
*
|
|
4
|
+
* Implements RFC 7636 S256 challenge verification and cryptographically
|
|
5
|
+
* random authorization code generation.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Verify that `code_challenge === base64url(SHA-256(code_verifier))`.
|
|
9
|
+
*
|
|
10
|
+
* Uses crypto.timingSafeEqual for the final comparison to prevent
|
|
11
|
+
* timing side-channel attacks.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verifyCodeChallenge(code_verifier: string, code_challenge: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Generate a cryptographically random authorization code.
|
|
16
|
+
* Returns 64 hex characters (32 random bytes).
|
|
17
|
+
*/
|
|
18
|
+
export declare function generateAuthorizationCode(): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/pkce.ts — PKCE (Proof Key for Code Exchange) utilities.
|
|
3
|
+
*
|
|
4
|
+
* Implements RFC 7636 S256 challenge verification and cryptographically
|
|
5
|
+
* random authorization code generation.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
8
|
+
/**
|
|
9
|
+
* Verify that `code_challenge === base64url(SHA-256(code_verifier))`.
|
|
10
|
+
*
|
|
11
|
+
* Uses crypto.timingSafeEqual for the final comparison to prevent
|
|
12
|
+
* timing side-channel attacks.
|
|
13
|
+
*/
|
|
14
|
+
export function verifyCodeChallenge(code_verifier, code_challenge) {
|
|
15
|
+
if (!code_verifier || !code_challenge)
|
|
16
|
+
return false;
|
|
17
|
+
// SHA-256 hash the verifier and base64url encode it
|
|
18
|
+
const computed = createHash("sha256")
|
|
19
|
+
.update(code_verifier, "ascii")
|
|
20
|
+
.digest("base64url");
|
|
21
|
+
// Buffers must be equal length for timingSafeEqual
|
|
22
|
+
const computedBuf = Buffer.from(computed, "utf8");
|
|
23
|
+
const challengeBuf = Buffer.from(code_challenge, "utf8");
|
|
24
|
+
if (computedBuf.length !== challengeBuf.length)
|
|
25
|
+
return false;
|
|
26
|
+
return timingSafeEqual(computedBuf, challengeBuf);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Generate a cryptographically random authorization code.
|
|
30
|
+
* Returns 64 hex characters (32 random bytes).
|
|
31
|
+
*/
|
|
32
|
+
export function generateAuthorizationCode() {
|
|
33
|
+
return randomBytes(32).toString("hex");
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/rate-limit.ts — Simple per-IP rate limiter for the token endpoint.
|
|
3
|
+
*
|
|
4
|
+
* No external dependencies — uses a plain Map + setInterval for cleanup.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* app.use("/oauth/token", createRateLimiter({ windowMs: 60_000, maxRequests: 10 }));
|
|
8
|
+
*/
|
|
9
|
+
import type { Request, Response, NextFunction } from "express";
|
|
10
|
+
export interface RateLimiterOptions {
|
|
11
|
+
windowMs: number;
|
|
12
|
+
maxRequests: number;
|
|
13
|
+
trustProxy?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Creates a rate-limiting Express middleware.
|
|
17
|
+
*
|
|
18
|
+
* Returns 429 with Retry-After header when the per-IP limit is exceeded.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createRateLimiter(options: RateLimiterOptions): (req: Request, res: Response, next: NextFunction) => void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/rate-limit.ts — Simple per-IP rate limiter for the token endpoint.
|
|
3
|
+
*
|
|
4
|
+
* No external dependencies — uses a plain Map + setInterval for cleanup.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* app.use("/oauth/token", createRateLimiter({ windowMs: 60_000, maxRequests: 10 }));
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Creates a rate-limiting Express middleware.
|
|
11
|
+
*
|
|
12
|
+
* Returns 429 with Retry-After header when the per-IP limit is exceeded.
|
|
13
|
+
*/
|
|
14
|
+
export function createRateLimiter(options) {
|
|
15
|
+
const { windowMs, maxRequests, trustProxy = false } = options;
|
|
16
|
+
const store = new Map();
|
|
17
|
+
// Cleanup expired entries every windowMs
|
|
18
|
+
const cleanupTimer = setInterval(() => {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
for (const [ip, entry] of store) {
|
|
21
|
+
if (entry.resetAt <= now) {
|
|
22
|
+
store.delete(ip);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}, windowMs);
|
|
26
|
+
if (cleanupTimer.unref)
|
|
27
|
+
cleanupTimer.unref();
|
|
28
|
+
return (req, res, next) => {
|
|
29
|
+
// Determine client IP
|
|
30
|
+
let ip;
|
|
31
|
+
if (trustProxy) {
|
|
32
|
+
const forwarded = req.headers["x-forwarded-for"];
|
|
33
|
+
if (typeof forwarded === "string") {
|
|
34
|
+
ip = forwarded.split(",")[0].trim();
|
|
35
|
+
}
|
|
36
|
+
else if (Array.isArray(forwarded) && forwarded.length > 0) {
|
|
37
|
+
ip = forwarded[0].split(",")[0].trim();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
45
|
+
}
|
|
46
|
+
const now = Date.now();
|
|
47
|
+
let entry = store.get(ip);
|
|
48
|
+
// Reset window if expired
|
|
49
|
+
if (!entry || entry.resetAt <= now) {
|
|
50
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
51
|
+
store.set(ip, entry);
|
|
52
|
+
}
|
|
53
|
+
entry.count++;
|
|
54
|
+
if (entry.count > maxRequests) {
|
|
55
|
+
const retryAfterSec = Math.ceil((entry.resetAt - now) / 1_000);
|
|
56
|
+
res.setHeader("Retry-After", String(retryAfterSec));
|
|
57
|
+
res.status(429).json({
|
|
58
|
+
error: "rate_limit_exceeded",
|
|
59
|
+
error_description: "Too many requests. Please try again later.",
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/oauth/store.ts — OAuth authorization code store.
|
|
3
|
+
*
|
|
4
|
+
* Architecture: in-memory is always the source of truth for the MCP server
|
|
5
|
+
* process. When Supabase env vars are present AND valid, codes are ALSO
|
|
6
|
+
* persisted async to Supabase for durability and multi-instance lookup.
|
|
7
|
+
*
|
|
8
|
+
* This means:
|
|
9
|
+
* - Unit tests (no env vars) → pure in-memory, zero network calls ✓
|
|
10
|
+
* - Production (Supabase set) → in-memory for hot-path, Supabase for
|
|
11
|
+
* persistence/audit trail ✓
|
|
12
|
+
*
|
|
13
|
+
* NOTE: The consent page (/api/oauth/approve) writes directly to Supabase
|
|
14
|
+
* using the service role key without going through this store. The store
|
|
15
|
+
* is used by the MCP server's own /oauth/* endpoints.
|
|
16
|
+
*
|
|
17
|
+
* Security:
|
|
18
|
+
* - NEVER log the `code` value. Log only the row `id` (uuid).
|
|
19
|
+
* - consumeCodeAsync marks codes consumed atomically in Supabase when
|
|
20
|
+
* Supabase is reachable; falls through to in-memory otherwise.
|
|
21
|
+
*/
|
|
22
|
+
export interface IssuedCodeOptions {
|
|
23
|
+
client_id: string;
|
|
24
|
+
redirect_uri: string;
|
|
25
|
+
code_challenge: string;
|
|
26
|
+
code_challenge_method: "S256";
|
|
27
|
+
/** Optional user_id — set by consent page; undefined in unit tests */
|
|
28
|
+
user_id?: string;
|
|
29
|
+
expiresInMs?: number;
|
|
30
|
+
}
|
|
31
|
+
export declare class OAuthStore {
|
|
32
|
+
/** In-memory is ALWAYS the hot-path source of truth */
|
|
33
|
+
private codes;
|
|
34
|
+
private refreshTokens;
|
|
35
|
+
private cleanupTimer;
|
|
36
|
+
constructor();
|
|
37
|
+
/**
|
|
38
|
+
* Issue a new authorization code.
|
|
39
|
+
*
|
|
40
|
+
* Always writes to in-memory (fast, test-safe).
|
|
41
|
+
* Also fire-and-forgets a Supabase persist when env vars are set.
|
|
42
|
+
*/
|
|
43
|
+
issueCode(options: IssuedCodeOptions): string;
|
|
44
|
+
/**
|
|
45
|
+
* Consume a code synchronously from in-memory (single-use).
|
|
46
|
+
* This is the fast-path used by the token endpoint.
|
|
47
|
+
*/
|
|
48
|
+
consumeCode(code: string): (IssuedCodeOptions & {
|
|
49
|
+
used: boolean;
|
|
50
|
+
expires_at: Date;
|
|
51
|
+
}) | null;
|
|
52
|
+
/**
|
|
53
|
+
* Async consume — checks in-memory first, then Supabase.
|
|
54
|
+
*
|
|
55
|
+
* Use this from token.ts so production deployments with Supabase
|
|
56
|
+
* can survive process restarts (code issued before restart lives in DB).
|
|
57
|
+
*/
|
|
58
|
+
consumeCodeAsync(code: string): Promise<(IssuedCodeOptions & {
|
|
59
|
+
used: boolean;
|
|
60
|
+
expires_at: Date;
|
|
61
|
+
}) | null>;
|
|
62
|
+
/**
|
|
63
|
+
* Refresh-token TTL — 90 days, sliding (reset on each rotation).
|
|
64
|
+
* Matches GitHub's 6-month convention more closely than Supabase's 30d
|
|
65
|
+
* default. Tradeoff: longer-lived refresh tokens = bigger blast radius
|
|
66
|
+
* on leak; rotation + Supabase RLS mitigates this. An active user who
|
|
67
|
+
* touches OE at least once per 90d gets an effectively permanent
|
|
68
|
+
* connection.
|
|
69
|
+
*/
|
|
70
|
+
private static REFRESH_TTL_MS;
|
|
71
|
+
storeRefreshToken(token: string, meta: object): void;
|
|
72
|
+
/**
|
|
73
|
+
* Synchronous in-memory consume. Kept for backward compat with any callers
|
|
74
|
+
* that don't need cross-instance resilience. Token endpoint must use
|
|
75
|
+
* consumeRefreshTokenAsync.
|
|
76
|
+
*/
|
|
77
|
+
consumeRefreshToken(token: string): object | null;
|
|
78
|
+
/**
|
|
79
|
+
* Async consume — checks in-memory first, then Supabase. This is what the
|
|
80
|
+
* token endpoint uses so refresh requests survive machine restarts and
|
|
81
|
+
* load-balancing across multiple Fly instances.
|
|
82
|
+
*/
|
|
83
|
+
consumeRefreshTokenAsync(token: string): Promise<object | null>;
|
|
84
|
+
generateRefreshToken(): string;
|
|
85
|
+
reset(): void;
|
|
86
|
+
private purgeExpired;
|
|
87
|
+
destroy(): void;
|
|
88
|
+
}
|