@openephemeris/mcp-server 4.11.2 → 4.12.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 +28 -0
- package/dist/analytics.d.ts +11 -1
- package/dist/analytics.js +58 -5
- package/dist/index.js +46 -37
- package/dist/instructions.d.ts +16 -0
- package/dist/instructions.js +51 -0
- package/dist/server-sse.js +57 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ Version numbering follows [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [4.12.0] — 2026-08-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Server `instructions` told the model a tool needed no arguments when it
|
|
14
|
+
did.** The zero-data-entry-point line named `ephemeris_retrograde_status` as
|
|
15
|
+
needing no arguments; its schema has `required: ["datetime"]`. An agent
|
|
16
|
+
that took the line literally made a zero-argument call and got a validation
|
|
17
|
+
error as its first experience of the server. Now names
|
|
18
|
+
`explore_moon_phase` and `electional_moment_analysis` — both genuinely
|
|
19
|
+
zero-argument — and both are asserted against the real tool schemas in CI
|
|
20
|
+
so this cannot silently drift again.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Connector first-run activation telemetry.** `mcp_tools_listed` (both
|
|
24
|
+
transports, once per session) and `mcp_session_silent` (HTTP, on teardown
|
|
25
|
+
when tools were listed but never called) close the gap where "connected but
|
|
26
|
+
never used it" was only an absence of events — indistinguishable from a
|
|
27
|
+
session that died mid-handshake. Funnel: `mcp_session_init` →
|
|
28
|
+
`mcp_tools_listed` → `first_tool_call`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- **Server `instructions` deduplicated** into `src/instructions.ts`, shared by
|
|
32
|
+
both transports. They were previously copy-pasted verbatim into each
|
|
33
|
+
transport file, which is how the argument-count claim above drifted from
|
|
34
|
+
the schemas undetected.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
10
38
|
## [4.11.2] — 2026-08-13
|
|
11
39
|
|
|
12
40
|
### Fixed
|
package/dist/analytics.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
/** Stable
|
|
1
|
+
/** Stable identity from a credential — the Supabase user id for OAuth, else a hash of the API key. */
|
|
2
2
|
export declare function distinctIdFor(credential: string | undefined): string;
|
|
3
|
+
/**
|
|
4
|
+
* Fire `first_tool_call` the first time a given identity completes a tool
|
|
5
|
+
* call, so activation (site visit → connector install → first real use) is a
|
|
6
|
+
* queryable event instead of "earliest mcp_tool_call per person" math. Never
|
|
7
|
+
* fires for "anonymous" (no credential at all — nothing to attribute
|
|
8
|
+
* activation to). Best-effort dedup only: the set is per-process, so a
|
|
9
|
+
* session that lands on a different Fly machine can refire once — acceptable
|
|
10
|
+
* for an activation metric read in aggregate.
|
|
11
|
+
*/
|
|
12
|
+
export declare function captureFirstToolCallOnce(distinctId: string, properties?: Record<string, unknown>): void;
|
|
3
13
|
export declare function captureEvent(event: string, distinctId: string, properties?: Record<string, unknown>): void;
|
package/dist/analytics.js
CHANGED
|
@@ -6,6 +6,21 @@
|
|
|
6
6
|
* the 402 paywall. These events close that gap. Everything is best-effort:
|
|
7
7
|
* analytics must never slow down or fail a tool call.
|
|
8
8
|
*
|
|
9
|
+
* Events, in funnel order:
|
|
10
|
+
* mcp_session_init — handshake completed
|
|
11
|
+
* mcp_tools_listed — host fetched tools/list (once per session)
|
|
12
|
+
* first_tool_call — this identity's first-ever completed tool call
|
|
13
|
+
* mcp_tool_call — every completed call
|
|
14
|
+
* mcp_tool_error — every failed call
|
|
15
|
+
* mcp_session_silent — HTTP only: listed tools, never attempted one. See
|
|
16
|
+
* reportSilentSession() in server-sse.ts for its two
|
|
17
|
+
* reading caveats (late arrival, undercount).
|
|
18
|
+
*
|
|
19
|
+
* mcp_session_init → mcp_tools_listed → first_tool_call is the activation
|
|
20
|
+
* funnel. Before mcp_tools_listed existed, "connected but never used it" was
|
|
21
|
+
* only an absence of events and could not be told apart from a host that never
|
|
22
|
+
* listed, or a session that died mid-handshake.
|
|
23
|
+
*
|
|
9
24
|
* Configuration:
|
|
10
25
|
* POSTHOG_API_KEY — project API key (phc_...). On the Fly-hosted remote
|
|
11
26
|
* server this comes from a secret. On stdio (the npm
|
|
@@ -18,12 +33,18 @@
|
|
|
18
33
|
* DO_NOT_TRACK=1 (de-facto cross-tool standard)
|
|
19
34
|
* Disclosed in README.md under "Telemetry".
|
|
20
35
|
*
|
|
21
|
-
* Identity: we never send raw API keys
|
|
22
|
-
* prefix of the
|
|
23
|
-
* authentication.
|
|
24
|
-
*
|
|
36
|
+
* Identity: we never send raw API keys. API-key sessions get a SHA-256
|
|
37
|
+
* prefix of the key, stable per user across sessions but useless for
|
|
38
|
+
* authentication. OAuth (Supabase JWT) sessions instead use the JWT's `sub`
|
|
39
|
+
* claim directly as the distinct_id — that's the same Supabase user id the
|
|
40
|
+
* web app passes to posthog.identify() at apps/web/contexts/AuthContext.tsx,
|
|
41
|
+
* so an MCP session and a browser session for the same account land on one
|
|
42
|
+
* PostHog person instead of two unmergeable ones. No birth data, coordinates,
|
|
43
|
+
* names, or tool arguments are ever sent — only the tool NAME, duration,
|
|
44
|
+
* error status, and client name.
|
|
25
45
|
*/
|
|
26
46
|
import { createHash } from "node:crypto";
|
|
47
|
+
import { decodeJwtSub } from "./oauth/session-utils.js";
|
|
27
48
|
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
|
28
49
|
/**
|
|
29
50
|
* Public, write-only PostHog project ingestion key.
|
|
@@ -51,13 +72,45 @@ function apiKey() {
|
|
|
51
72
|
return configured;
|
|
52
73
|
return DEFAULT_INGEST_KEY.startsWith("phc_") ? DEFAULT_INGEST_KEY : undefined;
|
|
53
74
|
}
|
|
54
|
-
/** Stable
|
|
75
|
+
/** Stable identity from a credential — the Supabase user id for OAuth, else a hash of the API key. */
|
|
55
76
|
export function distinctIdFor(credential) {
|
|
56
77
|
if (!credential)
|
|
57
78
|
return "anonymous";
|
|
79
|
+
if (credential.startsWith("eyJ")) {
|
|
80
|
+
const sub = decodeJwtSub(credential);
|
|
81
|
+
if (sub)
|
|
82
|
+
return sub;
|
|
83
|
+
}
|
|
58
84
|
return "mcp_" + createHash("sha256").update(credential).digest("hex").slice(0, 24);
|
|
59
85
|
}
|
|
86
|
+
const identitiesWithFirstToolCall = new Set();
|
|
87
|
+
/**
|
|
88
|
+
* Fire `first_tool_call` the first time a given identity completes a tool
|
|
89
|
+
* call, so activation (site visit → connector install → first real use) is a
|
|
90
|
+
* queryable event instead of "earliest mcp_tool_call per person" math. Never
|
|
91
|
+
* fires for "anonymous" (no credential at all — nothing to attribute
|
|
92
|
+
* activation to). Best-effort dedup only: the set is per-process, so a
|
|
93
|
+
* session that lands on a different Fly machine can refire once — acceptable
|
|
94
|
+
* for an activation metric read in aggregate.
|
|
95
|
+
*/
|
|
96
|
+
export function captureFirstToolCallOnce(distinctId, properties = {}) {
|
|
97
|
+
if (distinctId === "anonymous" || identitiesWithFirstToolCall.has(distinctId))
|
|
98
|
+
return;
|
|
99
|
+
identitiesWithFirstToolCall.add(distinctId);
|
|
100
|
+
captureEvent("first_tool_call", distinctId, properties);
|
|
101
|
+
}
|
|
102
|
+
// Our own e2e/harness/canary scripts declare an MCP clientInfo.name that ends
|
|
103
|
+
// up in every event's `client_name` property (see transportProps() in
|
|
104
|
+
// server-sse.ts / index.ts) — e.g. "e2e-http-client", "harness-fixture-capture".
|
|
105
|
+
// Their runs were previously indistinguishable from real users in PostHog.
|
|
106
|
+
const INTERNAL_CLIENT_PREFIXES = ["e2e-", "harness-", "test-", "canary-", "deploy-state-check", "local-agent-mode", "verify"];
|
|
107
|
+
/** True if a captured `client_name` identifies one of our own test/harness/canary scripts. */
|
|
108
|
+
function isInternalClientName(name) {
|
|
109
|
+
return typeof name === "string" && INTERNAL_CLIENT_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
110
|
+
}
|
|
60
111
|
export function captureEvent(event, distinctId, properties = {}) {
|
|
112
|
+
if (isInternalClientName(properties.client_name))
|
|
113
|
+
return;
|
|
61
114
|
const key = apiKey();
|
|
62
115
|
if (!key)
|
|
63
116
|
return;
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
6
6
|
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, formatToolError, modelVisibleTools, parseToolSurface, SERVER_VERSION } from "./tools/index.js";
|
|
9
|
-
import {
|
|
10
|
-
import { captureEvent, distinctIdFor } from "./analytics.js";
|
|
9
|
+
import { SERVER_INSTRUCTIONS } from "./instructions.js";
|
|
10
|
+
import { captureEvent, captureFirstToolCallOnce, distinctIdFor } from "./analytics.js";
|
|
11
11
|
import { listPrompts, getPromptContent } from "./prompts.js";
|
|
12
12
|
// ── MCP App resource imports ────────────────────────────────────────────────
|
|
13
13
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
@@ -63,15 +63,7 @@ const server = new Server({
|
|
|
63
63
|
},
|
|
64
64
|
},
|
|
65
65
|
},
|
|
66
|
-
instructions:
|
|
67
|
-
"or approximate positions yourself; always call a tool. " +
|
|
68
|
-
"For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
|
|
69
|
-
"prefer the explore_* tools — they render interactive visuals inline. " +
|
|
70
|
-
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
71
|
-
"If the user has no birth data handy, start with the sky right now — " +
|
|
72
|
-
"explore_moon_phase and ephemeris_retrograde_status need none. " +
|
|
73
|
-
"See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
|
|
74
|
-
DATETIME_CONTRACT_INSTRUCTIONS,
|
|
66
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
75
67
|
});
|
|
76
68
|
// Which slice of the registry this process advertises. Tools outside the
|
|
77
69
|
// surface stay callable by name — this only controls what tools/list returns.
|
|
@@ -110,34 +102,49 @@ function transportProps() {
|
|
|
110
102
|
server_version: SERVER_VERSION,
|
|
111
103
|
};
|
|
112
104
|
}
|
|
105
|
+
// One-shot latch for mcp_tools_listed. A stdio process serves exactly one
|
|
106
|
+
// session for its whole lifetime, so process scope IS session scope here.
|
|
107
|
+
//
|
|
108
|
+
// There is deliberately no stdio equivalent of the HTTP transport's
|
|
109
|
+
// `mcp_session_silent`: the only teardown signal is process exit, and
|
|
110
|
+
// captureEvent is a fire-and-forget fetch that would not flush before the
|
|
111
|
+
// process is gone. On stdio, "listed but never called" is a PostHog query
|
|
112
|
+
// (mcp_tools_listed with no following first_tool_call), not an event.
|
|
113
|
+
let toolsListedReported = false;
|
|
113
114
|
// List available tools
|
|
114
115
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
visibility: tool._meta.ui.visibility ?? ["model", "app"],
|
|
135
|
-
},
|
|
116
|
+
const tools = modelVisibleTools("stdio", STDIO_SURFACE).map((tool) => ({
|
|
117
|
+
name: tool.name,
|
|
118
|
+
description: tool.description,
|
|
119
|
+
inputSchema: tool.inputSchema,
|
|
120
|
+
annotations: {
|
|
121
|
+
title: tool.annotations?.title ?? tool.name,
|
|
122
|
+
readOnlyHint: tool.annotations?.readOnlyHint ?? true,
|
|
123
|
+
destructiveHint: tool.annotations?.destructiveHint ?? false,
|
|
124
|
+
idempotentHint: tool.annotations?.idempotentHint ?? true,
|
|
125
|
+
openWorldHint: tool.annotations?.openWorldHint ?? false,
|
|
126
|
+
},
|
|
127
|
+
// Expose MCP Apps UI linkage so Claude Desktop can prefetch the resource
|
|
128
|
+
...(tool._meta?.ui?.resourceUri
|
|
129
|
+
? {
|
|
130
|
+
_meta: {
|
|
131
|
+
"ui/resourceUri": tool._meta.ui.resourceUri,
|
|
132
|
+
ui: {
|
|
133
|
+
resourceUri: tool._meta.ui.resourceUri,
|
|
134
|
+
visibility: tool._meta.ui.visibility ?? ["model", "app"],
|
|
136
135
|
},
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
};
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
: {}),
|
|
139
|
+
}));
|
|
140
|
+
if (!toolsListedReported) {
|
|
141
|
+
toolsListedReported = true;
|
|
142
|
+
captureEvent("mcp_tools_listed", analyticsId(), {
|
|
143
|
+
tool_count: tools.length,
|
|
144
|
+
...transportProps(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return { tools };
|
|
141
148
|
});
|
|
142
149
|
// List available prompts
|
|
143
150
|
server.setRequestHandler(ListPromptsRequestSchema, async () => {
|
|
@@ -279,11 +286,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
279
286
|
try {
|
|
280
287
|
const result = await tool.handler(request.params.arguments ?? {});
|
|
281
288
|
const durationMs = Date.now() - startTime;
|
|
282
|
-
|
|
289
|
+
const id = analyticsId();
|
|
290
|
+
captureEvent("mcp_tool_call", id, {
|
|
283
291
|
tool: toolName,
|
|
284
292
|
duration_ms: durationMs,
|
|
285
293
|
...transportProps(),
|
|
286
294
|
});
|
|
295
|
+
captureFirstToolCallOnce(id, { tool: toolName, ...transportProps() });
|
|
287
296
|
return formatToolResponse(toolName, result, durationMs);
|
|
288
297
|
}
|
|
289
298
|
catch (error) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools an agent can call with literally no arguments — the entry points for a
|
|
3
|
+
* user who has not offered birth data.
|
|
4
|
+
*
|
|
5
|
+
* Asserted against the real schemas in test/description-cross-references.test.ts:
|
|
6
|
+
* each name here must be registered, must be on the CORE surface (most hosts
|
|
7
|
+
* refuse to call a tool absent from tools/list), and must genuinely have no
|
|
8
|
+
* required fields. Naming a tool here that needs an argument puts the model's
|
|
9
|
+
* very first call on a path to a validation error, which is the exact failure
|
|
10
|
+
* this list exists to prevent.
|
|
11
|
+
*
|
|
12
|
+
* Order is deliberate: the visual one leads, because a rendered dial is a
|
|
13
|
+
* better first impression than a score.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ZERO_ARG_ENTRY_TOOLS: readonly ["explore_moon_phase", "electional_moment_analysis"];
|
|
16
|
+
export declare const SERVER_INSTRUCTIONS: string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instructions.ts — the server `instructions` string, shared by both transports.
|
|
3
|
+
*
|
|
4
|
+
* This is the highest-leverage text in the product. It is the only guidance a
|
|
5
|
+
* host puts in front of the model before the user's first message, and the
|
|
6
|
+
* connector funnel is where we lose people: of the MCP connector identities
|
|
7
|
+
* seen in a 60-day window, ~1 in 8 ever invoked a tool at all.
|
|
8
|
+
*
|
|
9
|
+
* It previously lived inline and duplicated verbatim in src/index.ts and
|
|
10
|
+
* src/server-sse.ts. That duplication is how it came to advertise
|
|
11
|
+
* `ephemeris_retrograde_status` as needing no arguments when its schema has
|
|
12
|
+
* `required: ["datetime"]` — an agent that took the line literally made a
|
|
13
|
+
* zero-argument call and got a validation error as its first experience of the
|
|
14
|
+
* server. Defining the text once, next to the list it interpolates, lets a test
|
|
15
|
+
* check the claim against the real schemas (see description-cross-references).
|
|
16
|
+
*
|
|
17
|
+
* Kept OUT of the tool surface on purpose: `instructions` is sent once at
|
|
18
|
+
* initialize and is not counted by the tool-surface token budget (GATE 4, see
|
|
19
|
+
* test/tool-surface-budget.test.ts), which is at ~19.6k of its 20k ceiling.
|
|
20
|
+
* First-run routing guidance therefore belongs here rather than in a tool
|
|
21
|
+
* description that every model pass re-pays for.
|
|
22
|
+
*/
|
|
23
|
+
import { DATETIME_CONTRACT_INSTRUCTIONS } from "./tools/datetime.js";
|
|
24
|
+
/**
|
|
25
|
+
* Tools an agent can call with literally no arguments — the entry points for a
|
|
26
|
+
* user who has not offered birth data.
|
|
27
|
+
*
|
|
28
|
+
* Asserted against the real schemas in test/description-cross-references.test.ts:
|
|
29
|
+
* each name here must be registered, must be on the CORE surface (most hosts
|
|
30
|
+
* refuse to call a tool absent from tools/list), and must genuinely have no
|
|
31
|
+
* required fields. Naming a tool here that needs an argument puts the model's
|
|
32
|
+
* very first call on a path to a validation error, which is the exact failure
|
|
33
|
+
* this list exists to prevent.
|
|
34
|
+
*
|
|
35
|
+
* Order is deliberate: the visual one leads, because a rendered dial is a
|
|
36
|
+
* better first impression than a score.
|
|
37
|
+
*/
|
|
38
|
+
export const ZERO_ARG_ENTRY_TOOLS = [
|
|
39
|
+
"explore_moon_phase",
|
|
40
|
+
"electional_moment_analysis",
|
|
41
|
+
];
|
|
42
|
+
export const SERVER_INSTRUCTIONS = "Open Ephemeris computes real astronomy (JPL DE440, sub-arcsecond) — never guess " +
|
|
43
|
+
"or approximate positions yourself; always call a tool. " +
|
|
44
|
+
"For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
|
|
45
|
+
"prefer the explore_* tools — they render interactive visuals inline. " +
|
|
46
|
+
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
47
|
+
"If the user has no birth data handy, open with the sky right now: " +
|
|
48
|
+
`${ZERO_ARG_ENTRY_TOOLS.join(" and ")} take no arguments at all — ` +
|
|
49
|
+
"call one rather than asking for a birth date first. " +
|
|
50
|
+
"See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
|
|
51
|
+
DATETIME_CONTRACT_INSTRUCTIONS;
|
package/dist/server-sse.js
CHANGED
|
@@ -21,10 +21,10 @@ import axios from "axios";
|
|
|
21
21
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
22
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
23
|
import { InMemoryEventStore } from "./event-store.js";
|
|
24
|
-
import { captureEvent, distinctIdFor } from "./analytics.js";
|
|
24
|
+
import { captureEvent, captureFirstToolCallOnce, distinctIdFor } from "./analytics.js";
|
|
25
25
|
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
|
|
26
26
|
import { initTools, toolRegistry, formatToolResponse, formatToolError, modelVisibleTools, parseToolSurface, describeSurface } from "./tools/index.js";
|
|
27
|
-
import {
|
|
27
|
+
import { SERVER_INSTRUCTIONS } from "./instructions.js";
|
|
28
28
|
import { BackendClient, runWithClient } from "./backend/client.js";
|
|
29
29
|
import { CHART_WHEEL_RESOURCE_URI, CHART_WHEEL_MIME_TYPE, getChartWheelBundle, } from "./tools/apps/chart-wheel-app.js";
|
|
30
30
|
import { BODYGRAPH_RESOURCE_URI, BODYGRAPH_MIME_TYPE, getBodygraphBundle, } from "./tools/apps/bodygraph-app.js";
|
|
@@ -224,7 +224,36 @@ function transportProps(server, surface) {
|
|
|
224
224
|
server_version: version,
|
|
225
225
|
};
|
|
226
226
|
}
|
|
227
|
-
function
|
|
227
|
+
function newSessionActivity() {
|
|
228
|
+
return { startedAt: Date.now(), listedTools: false, attemptedTool: false, silenceReported: false };
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Emit `mcp_session_silent` for a session that saw the tool list and never tried
|
|
232
|
+
* to use it — the measurable form of the connector drop-off.
|
|
233
|
+
*
|
|
234
|
+
* Two caveats for whoever reads this in PostHog, because they change the query:
|
|
235
|
+
*
|
|
236
|
+
* 1. The event lands when the session TEARS DOWN, not when it went quiet. A
|
|
237
|
+
* host that vanishes without a DELETE is only cleaned up by the idle reaper,
|
|
238
|
+
* so its event can arrive SESSION_IDLE_TTL_MS + up to one 10-minute sweep
|
|
239
|
+
* after the fact. Read it in daily aggregate, not as a live signal, and use
|
|
240
|
+
* `session_duration_ms` to separate "closed straight away" from "sat open
|
|
241
|
+
* and unused".
|
|
242
|
+
* 2. It UNDERCOUNTS. A process restart (i.e. every deploy) drops the sessions
|
|
243
|
+
* still pending teardown. `mcp_tools_listed` with no following
|
|
244
|
+
* `first_tool_call` is the reliable denominator; this event is the richer
|
|
245
|
+
* but lossier view.
|
|
246
|
+
*/
|
|
247
|
+
function reportSilentSession(server, analyticsId, surface, activity) {
|
|
248
|
+
if (!activity.listedTools || activity.attemptedTool || activity.silenceReported)
|
|
249
|
+
return;
|
|
250
|
+
activity.silenceReported = true;
|
|
251
|
+
captureEvent("mcp_session_silent", analyticsId, {
|
|
252
|
+
session_duration_ms: Date.now() - activity.startedAt,
|
|
253
|
+
...transportProps(server, surface),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
function createMcpServer(analyticsId = "anonymous", surface = "core", activity = newSessionActivity()) {
|
|
228
257
|
const server = new Server({
|
|
229
258
|
name: "openephemeris-mcp",
|
|
230
259
|
title: "Open Ephemeris",
|
|
@@ -266,19 +295,11 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
|
266
295
|
},
|
|
267
296
|
},
|
|
268
297
|
},
|
|
269
|
-
instructions:
|
|
270
|
-
"or approximate positions yourself; always call a tool. " +
|
|
271
|
-
"For anything the user should SEE (natal chart, bi-wheel, bodygraph, moon phase), " +
|
|
272
|
-
"prefer the explore_* tools — they render interactive visuals inline. " +
|
|
273
|
-
"ephemeris_* tools return data; use format='llm' on them for compact output. " +
|
|
274
|
-
"If the user has no birth data handy, start with the sky right now — " +
|
|
275
|
-
"explore_moon_phase and ephemeris_retrograde_status need none. " +
|
|
276
|
-
"See the 'welcome_to_open_ephemeris' prompt for orientation.\n\n" +
|
|
277
|
-
DATETIME_CONTRACT_INSTRUCTIONS,
|
|
298
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
278
299
|
});
|
|
279
300
|
// --- Tool handlers ---
|
|
280
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
281
|
-
tools
|
|
301
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
302
|
+
const tools = modelVisibleTools("http", surface).map((tool) => ({
|
|
282
303
|
name: tool.name,
|
|
283
304
|
description: tool.description,
|
|
284
305
|
inputSchema: tool.inputSchema,
|
|
@@ -295,8 +316,18 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
|
295
316
|
},
|
|
296
317
|
}
|
|
297
318
|
: {}),
|
|
298
|
-
}))
|
|
299
|
-
|
|
319
|
+
}));
|
|
320
|
+
// Once per session: the host has fetched the surface. Everything after this
|
|
321
|
+
// point is the model's choice, so this is the denominator for activation.
|
|
322
|
+
if (!activity.listedTools) {
|
|
323
|
+
activity.listedTools = true;
|
|
324
|
+
captureEvent("mcp_tools_listed", analyticsId, {
|
|
325
|
+
tool_count: tools.length,
|
|
326
|
+
...transportProps(server, surface),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return { tools };
|
|
330
|
+
});
|
|
300
331
|
// --- Prompt handlers ---
|
|
301
332
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
302
333
|
prompts: [WELCOME_PROMPT],
|
|
@@ -313,6 +344,9 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
|
313
344
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
314
345
|
const toolName = request.params.name;
|
|
315
346
|
const tool = toolRegistry[toolName];
|
|
347
|
+
// Marked before the registry check: reaching for a tool that does not exist
|
|
348
|
+
// is still a session that tried, and must not be counted as silence.
|
|
349
|
+
activity.attemptedTool = true;
|
|
316
350
|
if (!tool || tool.stdioOnly) {
|
|
317
351
|
// stdioOnly tools (device auth) mutate process-global state and must not
|
|
318
352
|
// be callable on the multi-tenant HTTP transport, even by name.
|
|
@@ -331,6 +365,7 @@ function createMcpServer(analyticsId = "anonymous", surface = "core") {
|
|
|
331
365
|
duration_ms: durationMs,
|
|
332
366
|
...transportProps(server, surface),
|
|
333
367
|
});
|
|
368
|
+
captureFirstToolCallOnce(analyticsId, { tool: toolName, ...transportProps(server, surface) });
|
|
334
369
|
return formatToolResponse(toolName, result, durationMs);
|
|
335
370
|
}
|
|
336
371
|
catch (error) {
|
|
@@ -854,7 +889,8 @@ export async function createSseApp() {
|
|
|
854
889
|
// A comma list of traditions works the same way — `?profile=hd,bazi` —
|
|
855
890
|
// and advertises just those plus geocoding, account and the escape hatch.
|
|
856
891
|
const surface = parseToolSurface(req.query.profile ?? req.headers["x-oe-tool-surface"]);
|
|
857
|
-
const
|
|
892
|
+
const activity = newSessionActivity();
|
|
893
|
+
const server = createMcpServer(analyticsId, surface, activity);
|
|
858
894
|
// Fire session_init after the handshake so getClientVersion() is populated
|
|
859
895
|
// — without this the connecting host is unknown and we cannot tell which
|
|
860
896
|
// clients the remote server is actually serving.
|
|
@@ -869,6 +905,10 @@ export async function createSseApp() {
|
|
|
869
905
|
httpSessions.delete(transport.sessionId);
|
|
870
906
|
console.error(`[HTTP] Session closed: ${transport.sessionId}`);
|
|
871
907
|
}
|
|
908
|
+
// All three teardown paths (transport close, DELETE /mcp, idle reaper)
|
|
909
|
+
// converge here — server.close() closes the transport — so this is the one
|
|
910
|
+
// place the silence verdict has to be made.
|
|
911
|
+
reportSilentSession(server, analyticsId, surface, activity);
|
|
872
912
|
};
|
|
873
913
|
await server.connect(transport);
|
|
874
914
|
await runWithClient(client, () => transport.handleRequest(req, res, req.body));
|